Jacob Schmidt c7933aeeec Lists & todos backend, editor refactor, inline create, UX improvements
- 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>
2026-02-26 17:33:27 -06:00

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();
};
}
});
}