- Wire up lists and todos to C# backend with full CRUD persistence - Add models, DTOs, repositories, and services for lists and todo lists/items - Preserve SQLite DB across vault rebuild/load cycles - Add session store for vault password persistence across navigation - Add inline name input for creating lists and todo lists in SidePanel - Clear editor panel on section change with empty state placeholder - Default markdown editor to preview mode on item selection - Decompose EditorPanel into sub-components: - editor/FragmentEditor, editor/TodoEditor, editor/MarkdownEditor - Shared markdown utilities in utils/markdown.ts - Strip verbose console/eprintln logging from frontend and Tauri backend - Add graceful shutdown with vault persistence on window close Co-Authored-By: Oz <oz-agent@warp.dev>
323 lines
9.3 KiB
Rust
323 lines
9.3 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use std::env;
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Stdio;
|
|
use tauri::Manager;
|
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
|
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
|
|
use tokio::sync::Mutex;
|
|
|
|
#[derive(Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct CommandEnvelope {
|
|
action: String,
|
|
#[serde(default)]
|
|
correlation_id: Option<String>,
|
|
#[serde(default)]
|
|
id: Option<String>,
|
|
#[serde(default)]
|
|
r#type: Option<String>,
|
|
#[serde(default)]
|
|
tag: Option<String>,
|
|
#[serde(default)]
|
|
payload: Option<Value>,
|
|
}
|
|
|
|
#[derive(Deserialize, Serialize, Default)]
|
|
struct AppSettings {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
sidecar_root: Option<String>,
|
|
}
|
|
|
|
struct ManagedSidecar {
|
|
child: Child,
|
|
stdin: ChildStdin,
|
|
stdout: BufReader<ChildStdout>,
|
|
}
|
|
|
|
impl ManagedSidecar {
|
|
fn start(root: &Path) -> Result<Self, String> {
|
|
let sidecar_path = resolve_sidecar_path(root)?;
|
|
let mut cmd = Command::new(sidecar_path);
|
|
cmd.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::inherit())
|
|
.current_dir(root)
|
|
.env("JOURNAL_PROJECT_ROOT", root)
|
|
.kill_on_drop(true);
|
|
#[cfg(windows)]
|
|
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
|
|
let mut child = cmd
|
|
.spawn()
|
|
.map_err(|err| format!("Failed to start sidecar process: {err}"))?;
|
|
|
|
let stdin = child
|
|
.stdin
|
|
.take()
|
|
.ok_or_else(|| "Unable to open sidecar stdin.".to_string())?;
|
|
let stdout = child
|
|
.stdout
|
|
.take()
|
|
.ok_or_else(|| "Unable to open sidecar stdout.".to_string())?;
|
|
|
|
Ok(Self {
|
|
child,
|
|
stdin,
|
|
stdout: BufReader::new(stdout),
|
|
})
|
|
}
|
|
|
|
fn is_running(&mut self) -> bool {
|
|
match self.child.try_wait() {
|
|
Ok(None) => true,
|
|
Ok(Some(_)) => false,
|
|
Err(_) => false,
|
|
}
|
|
}
|
|
|
|
async fn send_command_line(&mut self, input_line: &str) -> Result<String, String> {
|
|
self.stdin
|
|
.write_all(format!("{input_line}\n").as_bytes())
|
|
.await
|
|
.map_err(|err| format!("Failed writing to sidecar stdin: {err}"))?;
|
|
self.stdin
|
|
.flush()
|
|
.await
|
|
.map_err(|err| format!("Failed flushing sidecar stdin: {err}"))?;
|
|
|
|
let mut response_line = String::new();
|
|
let read = self
|
|
.stdout
|
|
.read_line(&mut response_line)
|
|
.await
|
|
.map_err(|err| format!("Failed reading sidecar stdout: {err}"))?;
|
|
if read == 0 {
|
|
return Err("Sidecar stdout closed unexpectedly.".to_string());
|
|
}
|
|
|
|
let trimmed = response_line.trim().to_string();
|
|
if trimmed.is_empty() {
|
|
return Err("Sidecar returned an empty response line.".to_string());
|
|
}
|
|
|
|
Ok(trimmed)
|
|
}
|
|
}
|
|
|
|
impl Drop for ManagedSidecar {
|
|
fn drop(&mut self) {}
|
|
}
|
|
|
|
struct SidecarState {
|
|
process: Mutex<Option<ManagedSidecar>>,
|
|
root_override: Mutex<Option<PathBuf>>,
|
|
config_path: PathBuf,
|
|
}
|
|
|
|
fn load_settings(path: &Path) -> AppSettings {
|
|
fs::read_to_string(path)
|
|
.ok()
|
|
.and_then(|s| serde_json::from_str(&s).ok())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn save_settings(path: &Path, settings: &AppSettings) -> Result<(), String> {
|
|
let json = serde_json::to_string_pretty(settings)
|
|
.map_err(|e| format!("Failed to serialize settings: {e}"))?;
|
|
fs::write(path, json).map_err(|e| format!("Failed to save settings: {e}"))
|
|
}
|
|
|
|
fn auto_detect_root() -> Result<PathBuf, String> {
|
|
let mut current =
|
|
env::current_dir().map_err(|err| format!("Unable to read current dir: {err}"))?;
|
|
loop {
|
|
if current.join("Journal.Sidecar").exists() {
|
|
return Ok(current);
|
|
}
|
|
if !current.pop() {
|
|
return Err("Unable to locate repository root containing Journal.Sidecar.".to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn effective_root(root_override: &Option<PathBuf>) -> Result<PathBuf, String> {
|
|
if let Some(root) = root_override {
|
|
return Ok(root.clone());
|
|
}
|
|
auto_detect_root()
|
|
}
|
|
|
|
fn resolve_sidecar_path(root: &Path) -> Result<PathBuf, String> {
|
|
let debug_path = root.join("Journal.Sidecar/bin/Debug/net10.0/Journal.Sidecar.exe");
|
|
if debug_path.exists() {
|
|
return Ok(debug_path);
|
|
}
|
|
|
|
let release_path =
|
|
root.join("Journal.Sidecar/bin/Release/net10.0/win-x64/publish/Journal.Sidecar.exe");
|
|
if release_path.exists() {
|
|
return Ok(release_path);
|
|
}
|
|
|
|
Err("Journal.Sidecar.exe not found. Build Journal.Sidecar first.".to_string())
|
|
}
|
|
|
|
async fn send_with_managed_sidecar(
|
|
state: &SidecarState,
|
|
input_line: &str,
|
|
) -> Result<String, String> {
|
|
let root = {
|
|
let root_override = state.root_override.lock().await;
|
|
effective_root(&root_override)?
|
|
};
|
|
let mut guard = state.process.lock().await;
|
|
|
|
for attempt in 1..=2 {
|
|
let should_start = match guard.as_mut() {
|
|
Some(existing) => !existing.is_running(),
|
|
None => true,
|
|
};
|
|
if should_start {
|
|
*guard = Some(ManagedSidecar::start(&root)?);
|
|
}
|
|
|
|
let Some(process) = guard.as_mut() else {
|
|
return Err("Sidecar process unavailable.".to_string());
|
|
};
|
|
|
|
match process.send_command_line(input_line).await {
|
|
Ok(line) => return Ok(line),
|
|
Err(err) => {
|
|
*guard = None;
|
|
if attempt == 2 {
|
|
return Err(err);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Err("Failed to send command to sidecar.".to_string())
|
|
}
|
|
|
|
async fn stop_managed_sidecar(state: &SidecarState) {
|
|
let mut guard = state.process.lock().await;
|
|
guard.take();
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn get_sidecar_root(state: tauri::State<'_, SidecarState>) -> Result<Value, String> {
|
|
let root_override = state.root_override.lock().await.clone();
|
|
let root = effective_root(&root_override)?;
|
|
Ok(serde_json::json!({
|
|
"root": root.to_string_lossy(),
|
|
"isCustom": root_override.is_some()
|
|
}))
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn set_sidecar_root(
|
|
state: tauri::State<'_, SidecarState>,
|
|
path: String,
|
|
) -> Result<Value, String> {
|
|
let (new_override, root) = if path.trim().is_empty() {
|
|
let detected = auto_detect_root()?;
|
|
(None, detected)
|
|
} else {
|
|
let new_root = PathBuf::from(&path);
|
|
if !new_root.exists() {
|
|
return Err(format!(
|
|
"Directory '{}' does not exist.",
|
|
new_root.display()
|
|
));
|
|
}
|
|
resolve_sidecar_path(&new_root)?;
|
|
(Some(new_root.clone()), new_root)
|
|
};
|
|
|
|
// Stop the current sidecar so it restarts with new root
|
|
{
|
|
let mut guard = state.process.lock().await;
|
|
guard.take();
|
|
}
|
|
|
|
let is_custom = new_override.is_some();
|
|
*state.root_override.lock().await = new_override.clone();
|
|
|
|
save_settings(
|
|
&state.config_path,
|
|
&AppSettings {
|
|
sidecar_root: new_override.map(|p| p.to_string_lossy().into_owned()),
|
|
},
|
|
)?;
|
|
|
|
Ok(serde_json::json!({
|
|
"root": root.to_string_lossy(),
|
|
"isCustom": is_custom
|
|
}))
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn shutdown(
|
|
state: tauri::State<'_, SidecarState>,
|
|
app_handle: tauri::AppHandle,
|
|
) -> Result<(), String> {
|
|
stop_managed_sidecar(state.inner()).await;
|
|
app_handle.exit(0);
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn sidecar_command(
|
|
state: tauri::State<'_, SidecarState>,
|
|
command: CommandEnvelope,
|
|
) -> Result<Value, String> {
|
|
if command.action.trim().is_empty() {
|
|
return Err("Missing action".to_string());
|
|
}
|
|
|
|
let input_line = serde_json::to_string(&command)
|
|
.map_err(|err| format!("Serialize command failed: {err}"))?;
|
|
let response_line = send_with_managed_sidecar(state.inner(), &input_line).await?;
|
|
serde_json::from_str::<Value>(&response_line)
|
|
.map_err(|err| format!("Invalid sidecar JSON response: {err}"))
|
|
}
|
|
|
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
|
pub fn run() {
|
|
let app = tauri::Builder::default()
|
|
.plugin(tauri_plugin_opener::init())
|
|
.invoke_handler(tauri::generate_handler![
|
|
sidecar_command,
|
|
shutdown,
|
|
get_sidecar_root,
|
|
set_sidecar_root,
|
|
])
|
|
.setup(|app| {
|
|
let config_dir = app.path().app_config_dir()?;
|
|
fs::create_dir_all(&config_dir).ok();
|
|
let config_path = config_dir.join("settings.json");
|
|
let settings = load_settings(&config_path);
|
|
let root_override = settings.sidecar_root.map(PathBuf::from);
|
|
|
|
app.manage(SidecarState {
|
|
process: Mutex::new(None),
|
|
root_override: Mutex::new(root_override),
|
|
config_path,
|
|
});
|
|
Ok(())
|
|
})
|
|
.build(tauri::generate_context!())
|
|
.expect("error while building tauri application");
|
|
|
|
app.run(|app_handle, event| {
|
|
if let tauri::RunEvent::ExitRequested { .. } = event {
|
|
let state = app_handle.state::<SidecarState>();
|
|
if let Ok(mut guard) = state.process.try_lock() {
|
|
guard.take();
|
|
};
|
|
}
|
|
});
|
|
}
|