diff --git a/v2/package-lock.json b/v2/package-lock.json index 67511e8..48666cf 100644 --- a/v2/package-lock.json +++ b/v2/package-lock.json @@ -11,7 +11,8 @@ "@tauri-apps/api": "^2.10.1", "@xterm/addon-canvas": "^0.7.0", "@xterm/addon-fit": "^0.11.0", - "@xterm/xterm": "^6.0.0" + "@xterm/xterm": "^6.0.0", + "marked": "^17.0.4" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^6.2.1", @@ -1174,6 +1175,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "17.0.4", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.4.tgz", + "integrity": "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", diff --git a/v2/package.json b/v2/package.json index c08bc5e..3e459fa 100644 --- a/v2/package.json +++ b/v2/package.json @@ -25,6 +25,7 @@ "@tauri-apps/api": "^2.10.1", "@xterm/addon-canvas": "^0.7.0", "@xterm/addon-fit": "^0.11.0", - "@xterm/xterm": "^6.0.0" + "@xterm/xterm": "^6.0.0", + "marked": "^17.0.4" } } diff --git a/v2/src-tauri/Cargo.lock b/v2/src-tauri/Cargo.lock index 2cd8736..5bb3c98 100644 --- a/v2/src-tauri/Cargo.lock +++ b/v2/src-tauri/Cargo.lock @@ -19,6 +19,18 @@ dependencies = [ "version_check", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -221,8 +233,11 @@ dependencies = [ name = "bterminal" version = "0.1.0" dependencies = [ + "dirs 5.0.1", "log", + "notify", "portable-pty", + "rusqlite", "serde", "serde_json", "tauri", @@ -634,13 +649,34 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -651,7 +687,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -790,6 +826,18 @@ dependencies = [ "typeid", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -829,6 +877,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -893,6 +952,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "funty" version = "2.0.0" @@ -1299,7 +1367,16 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" dependencies = [ - "ahash", + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", ] [[package]] @@ -1317,6 +1394,15 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -1610,6 +1696,26 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "ioctl-rs" version = "0.1.6" @@ -1729,6 +1835,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -1799,7 +1925,21 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ + "bitflags 2.11.0", "libc", + "plain", + "redox_syscall 0.7.3", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", ] [[package]] @@ -1903,6 +2043,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.1.1" @@ -1991,6 +2143,25 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.11.0", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -2215,7 +2386,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] @@ -2378,6 +2549,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plist" version = "1.8.0" @@ -2685,6 +2862,26 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -2817,6 +3014,20 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags 2.11.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rust_decimal" version = "1.40.0" @@ -3242,7 +3453,7 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "redox_syscall", + "redox_syscall 0.5.18", "tracing", "wasm-bindgen", "web-sys", @@ -3448,7 +3659,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -3498,7 +3709,7 @@ checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -3818,7 +4029,7 @@ checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ "bytes", "libc", - "mio", + "mio 1.1.1", "pin-project-lite", "socket2", "windows-sys 0.61.2", @@ -4013,7 +4224,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2", @@ -4166,6 +4377,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" @@ -4637,6 +4854,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -4679,6 +4905,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -4736,6 +4977,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4754,6 +5001,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -4772,6 +5025,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4802,6 +5061,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -4820,6 +5085,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -4838,6 +5109,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -4856,6 +5133,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -5009,7 +5292,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dpi", "dunce", "gdkx11", diff --git a/v2/src-tauri/Cargo.toml b/v2/src-tauri/Cargo.toml index bf7ea50..f532c85 100644 --- a/v2/src-tauri/Cargo.toml +++ b/v2/src-tauri/Cargo.toml @@ -24,3 +24,6 @@ tauri = { version = "2.10.3", features = [] } tauri-plugin-log = "2" portable-pty = "0.8" uuid = { version = "1", features = ["v4"] } +rusqlite = { version = "0.31", features = ["bundled"] } +dirs = "5" +notify = { version = "6", features = ["macos_fsevent"] } diff --git a/v2/src-tauri/src/lib.rs b/v2/src-tauri/src/lib.rs index fbd8fef..8504d36 100644 --- a/v2/src-tauri/src/lib.rs +++ b/v2/src-tauri/src/lib.rs @@ -4,13 +4,17 @@ mod watcher; mod session; use pty::{PtyManager, PtyOptions}; +use session::{Session, SessionDb, LayoutState}; use sidecar::{AgentQueryOptions, SidecarManager}; +use watcher::FileWatcherManager; use std::sync::Arc; use tauri::State; struct AppState { pty_manager: Arc, sidecar_manager: Arc, + session_db: Arc, + file_watcher: Arc, } // --- PTY commands --- @@ -64,14 +68,90 @@ fn agent_ready(state: State<'_, AppState>) -> bool { state.sidecar_manager.is_ready() } +#[tauri::command] +fn agent_restart(app: tauri::AppHandle, state: State<'_, AppState>) -> Result<(), String> { + state.sidecar_manager.restart(&app) +} + +// --- File watcher commands --- + +#[tauri::command] +fn file_watch( + app: tauri::AppHandle, + state: State<'_, AppState>, + pane_id: String, + path: String, +) -> Result { + state.file_watcher.watch(&app, &pane_id, &path) +} + +#[tauri::command] +fn file_unwatch(state: State<'_, AppState>, pane_id: String) { + state.file_watcher.unwatch(&pane_id); +} + +#[tauri::command] +fn file_read(state: State<'_, AppState>, path: String) -> Result { + state.file_watcher.read_file(&path) +} + +// --- Session persistence commands --- + +#[tauri::command] +fn session_list(state: State<'_, AppState>) -> Result, String> { + state.session_db.list_sessions() +} + +#[tauri::command] +fn session_save(state: State<'_, AppState>, session: Session) -> Result<(), String> { + state.session_db.save_session(&session) +} + +#[tauri::command] +fn session_delete(state: State<'_, AppState>, id: String) -> Result<(), String> { + state.session_db.delete_session(&id) +} + +#[tauri::command] +fn session_update_title(state: State<'_, AppState>, id: String, title: String) -> Result<(), String> { + state.session_db.update_title(&id, &title) +} + +#[tauri::command] +fn session_touch(state: State<'_, AppState>, id: String) -> Result<(), String> { + state.session_db.touch_session(&id) +} + +#[tauri::command] +fn layout_save(state: State<'_, AppState>, layout: LayoutState) -> Result<(), String> { + state.session_db.save_layout(&layout) +} + +#[tauri::command] +fn layout_load(state: State<'_, AppState>) -> Result { + state.session_db.load_layout() +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let pty_manager = Arc::new(PtyManager::new()); let sidecar_manager = Arc::new(SidecarManager::new()); + // Initialize session database in app data directory + let data_dir = dirs::data_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("bterminal"); + let session_db = Arc::new( + SessionDb::open(&data_dir).expect("Failed to open session database") + ); + + let file_watcher = Arc::new(FileWatcherManager::new()); + let app_state = AppState { pty_manager, sidecar_manager: sidecar_manager.clone(), + session_db, + file_watcher, }; tauri::Builder::default() @@ -84,6 +164,17 @@ pub fn run() { agent_query, agent_stop, agent_ready, + agent_restart, + file_watch, + file_unwatch, + file_read, + session_list, + session_save, + session_delete, + session_update_title, + session_touch, + layout_save, + layout_load, ]) .setup(move |app| { if cfg!(debug_assertions) { diff --git a/v2/src-tauri/src/session.rs b/v2/src-tauri/src/session.rs index 55c8628..aeb0e65 100644 --- a/v2/src-tauri/src/session.rs +++ b/v2/src-tauri/src/session.rs @@ -1,2 +1,175 @@ // Session persistence via rusqlite -// Phase 4: CRUD, layout save/restore +// Stores sessions, layout preferences, and last-used state + +use rusqlite::{Connection, params}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::Mutex; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Session { + pub id: String, + #[serde(rename = "type")] + pub session_type: String, + pub title: String, + pub shell: Option, + pub cwd: Option, + pub args: Option>, + pub created_at: i64, + pub last_used_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LayoutState { + pub preset: String, + pub pane_ids: Vec, +} + +pub struct SessionDb { + conn: Mutex, +} + +impl SessionDb { + pub fn open(data_dir: &PathBuf) -> Result { + std::fs::create_dir_all(data_dir) + .map_err(|e| format!("Failed to create data dir: {e}"))?; + + let db_path = data_dir.join("sessions.db"); + let conn = Connection::open(&db_path) + .map_err(|e| format!("Failed to open database: {e}"))?; + + // Enable WAL mode for better concurrent read performance + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;") + .map_err(|e| format!("Failed to set pragmas: {e}"))?; + + let db = Self { conn: Mutex::new(conn) }; + db.migrate()?; + Ok(db) + } + + fn migrate(&self) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + title TEXT NOT NULL, + shell TEXT, + cwd TEXT, + args TEXT, + created_at INTEGER NOT NULL, + last_used_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS layout_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + preset TEXT NOT NULL DEFAULT '1-col', + pane_ids TEXT NOT NULL DEFAULT '[]' + ); + + INSERT OR IGNORE INTO layout_state (id, preset, pane_ids) VALUES (1, '1-col', '[]'); + " + ).map_err(|e| format!("Migration failed: {e}"))?; + Ok(()) + } + + pub fn list_sessions(&self) -> Result, String> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn + .prepare("SELECT id, type, title, shell, cwd, args, created_at, last_used_at FROM sessions ORDER BY last_used_at DESC") + .map_err(|e| format!("Query prepare failed: {e}"))?; + + let sessions = stmt + .query_map([], |row| { + let args_json: Option = row.get(5)?; + let args: Option> = args_json.and_then(|j| serde_json::from_str(&j).ok()); + Ok(Session { + id: row.get(0)?, + session_type: row.get(1)?, + title: row.get(2)?, + shell: row.get(3)?, + cwd: row.get(4)?, + args, + created_at: row.get(6)?, + last_used_at: row.get(7)?, + }) + }) + .map_err(|e| format!("Query failed: {e}"))? + .collect::, _>>() + .map_err(|e| format!("Row read failed: {e}"))?; + + Ok(sessions) + } + + pub fn save_session(&self, session: &Session) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + let args_json = session.args.as_ref().map(|a| serde_json::to_string(a).unwrap_or_default()); + conn.execute( + "INSERT OR REPLACE INTO sessions (id, type, title, shell, cwd, args, created_at, last_used_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + session.id, + session.session_type, + session.title, + session.shell, + session.cwd, + args_json, + session.created_at, + session.last_used_at, + ], + ).map_err(|e| format!("Insert failed: {e}"))?; + Ok(()) + } + + pub fn delete_session(&self, id: &str) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM sessions WHERE id = ?1", params![id]) + .map_err(|e| format!("Delete failed: {e}"))?; + Ok(()) + } + + pub fn update_title(&self, id: &str, title: &str) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE sessions SET title = ?1 WHERE id = ?2", + params![title, id], + ).map_err(|e| format!("Update failed: {e}"))?; + Ok(()) + } + + pub fn touch_session(&self, id: &str) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + conn.execute( + "UPDATE sessions SET last_used_at = ?1 WHERE id = ?2", + params![now, id], + ).map_err(|e| format!("Touch failed: {e}"))?; + Ok(()) + } + + pub fn save_layout(&self, layout: &LayoutState) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + let pane_ids_json = serde_json::to_string(&layout.pane_ids).unwrap_or_default(); + conn.execute( + "UPDATE layout_state SET preset = ?1, pane_ids = ?2 WHERE id = 1", + params![layout.preset, pane_ids_json], + ).map_err(|e| format!("Layout save failed: {e}"))?; + Ok(()) + } + + pub fn load_layout(&self) -> Result { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn + .prepare("SELECT preset, pane_ids FROM layout_state WHERE id = 1") + .map_err(|e| format!("Layout query failed: {e}"))?; + + stmt.query_row([], |row| { + let preset: String = row.get(0)?; + let pane_ids_json: String = row.get(1)?; + let pane_ids: Vec = serde_json::from_str(&pane_ids_json).unwrap_or_default(); + Ok(LayoutState { preset, pane_ids }) + }).map_err(|e| format!("Layout read failed: {e}")) + } +} diff --git a/v2/src-tauri/src/watcher.rs b/v2/src-tauri/src/watcher.rs index 9266cdd..24b044b 100644 --- a/v2/src-tauri/src/watcher.rs +++ b/v2/src-tauri/src/watcher.rs @@ -1,2 +1,97 @@ // File watcher for markdown viewer -// Phase 4: notify crate, debounce, Tauri events +// Uses notify crate to watch files and emit Tauri events on change + +use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher}; +use serde::Serialize; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Mutex; +use tauri::Emitter; + +#[derive(Clone, Serialize)] +struct FileChangedPayload { + pane_id: String, + path: String, + content: String, +} + +struct WatchEntry { + _watcher: RecommendedWatcher, + _path: PathBuf, +} + +pub struct FileWatcherManager { + watchers: Mutex>, +} + +impl FileWatcherManager { + pub fn new() -> Self { + Self { + watchers: Mutex::new(HashMap::new()), + } + } + + pub fn watch( + &self, + app: &tauri::AppHandle, + pane_id: &str, + path: &str, + ) -> Result { + let file_path = PathBuf::from(path); + if !file_path.exists() { + return Err(format!("File not found: {path}")); + } + + // Read initial content + let content = std::fs::read_to_string(&file_path) + .map_err(|e| format!("Failed to read file: {e}"))?; + + // Set up watcher + let app_handle = app.clone(); + let pane_id_owned = pane_id.to_string(); + let watch_path = file_path.clone(); + + let mut watcher = RecommendedWatcher::new( + move |res: Result| { + if let Ok(event) = res { + if event.kind.is_modify() { + if let Ok(new_content) = std::fs::read_to_string(&watch_path) { + let _ = app_handle.emit( + "file-changed", + FileChangedPayload { + pane_id: pane_id_owned.clone(), + path: watch_path.to_string_lossy().to_string(), + content: new_content, + }, + ); + } + } + } + }, + Config::default(), + ) + .map_err(|e| format!("Failed to create watcher: {e}"))?; + + watcher + .watch(file_path.parent().unwrap_or(&file_path), RecursiveMode::NonRecursive) + .map_err(|e| format!("Failed to watch path: {e}"))?; + + let mut watchers = self.watchers.lock().unwrap(); + watchers.insert(pane_id.to_string(), WatchEntry { + _watcher: watcher, + _path: file_path, + }); + + Ok(content) + } + + pub fn unwatch(&self, pane_id: &str) { + let mut watchers = self.watchers.lock().unwrap(); + watchers.remove(pane_id); + } + + pub fn read_file(&self, path: &str) -> Result { + std::fs::read_to_string(path) + .map_err(|e| format!("Failed to read file: {e}")) + } +} diff --git a/v2/src/App.svelte b/v2/src/App.svelte index f79b9b1..b8086f0 100644 --- a/v2/src/App.svelte +++ b/v2/src/App.svelte @@ -2,7 +2,7 @@ import { onMount, onDestroy } from 'svelte'; import SessionList from './lib/components/Sidebar/SessionList.svelte'; import TilingGrid from './lib/components/Layout/TilingGrid.svelte'; - import { addPane, focusPaneByIndex, getPanes } from './lib/stores/layout.svelte'; + import { addPane, focusPaneByIndex, getPanes, restoreFromDb } from './lib/stores/layout.svelte'; import { startAgentDispatcher, stopAgentDispatcher } from './lib/agent-dispatcher'; function newTerminal() { @@ -27,6 +27,7 @@ onMount(() => { startAgentDispatcher(); + restoreFromDb(); function handleKeydown(e: KeyboardEvent) { // Ctrl+N — new terminal diff --git a/v2/src/lib/adapters/file-bridge.ts b/v2/src/lib/adapters/file-bridge.ts new file mode 100644 index 0000000..7937488 --- /dev/null +++ b/v2/src/lib/adapters/file-bridge.ts @@ -0,0 +1,29 @@ +import { invoke } from '@tauri-apps/api/core'; +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; + +export interface FileChangedPayload { + pane_id: string; + path: string; + content: string; +} + +/** Start watching a file; returns initial content */ +export async function watchFile(paneId: string, path: string): Promise { + return invoke('file_watch', { paneId, path }); +} + +export async function unwatchFile(paneId: string): Promise { + return invoke('file_unwatch', { paneId }); +} + +export async function readFile(path: string): Promise { + return invoke('file_read', { path }); +} + +export async function onFileChanged( + callback: (payload: FileChangedPayload) => void +): Promise { + return listen('file-changed', (event) => { + callback(event.payload); + }); +} diff --git a/v2/src/lib/adapters/session-bridge.ts b/v2/src/lib/adapters/session-bridge.ts new file mode 100644 index 0000000..45deabf --- /dev/null +++ b/v2/src/lib/adapters/session-bridge.ts @@ -0,0 +1,45 @@ +import { invoke } from '@tauri-apps/api/core'; + +export interface PersistedSession { + id: string; + type: string; + title: string; + shell?: string; + cwd?: string; + args?: string[]; + created_at: number; + last_used_at: number; +} + +export interface PersistedLayout { + preset: string; + pane_ids: string[]; +} + +export async function listSessions(): Promise { + return invoke('session_list'); +} + +export async function saveSession(session: PersistedSession): Promise { + return invoke('session_save', { session }); +} + +export async function deleteSession(id: string): Promise { + return invoke('session_delete', { id }); +} + +export async function updateSessionTitle(id: string, title: string): Promise { + return invoke('session_update_title', { id, title }); +} + +export async function touchSession(id: string): Promise { + return invoke('session_touch', { id }); +} + +export async function saveLayout(layout: PersistedLayout): Promise { + return invoke('layout_save', { layout }); +} + +export async function loadLayout(): Promise { + return invoke('layout_load'); +} diff --git a/v2/src/lib/components/Layout/TilingGrid.svelte b/v2/src/lib/components/Layout/TilingGrid.svelte index 4b787df..b89bae4 100644 --- a/v2/src/lib/components/Layout/TilingGrid.svelte +++ b/v2/src/lib/components/Layout/TilingGrid.svelte @@ -2,6 +2,7 @@ import PaneContainer from './PaneContainer.svelte'; import TerminalPane from '../Terminal/TerminalPane.svelte'; import AgentPane from '../Agent/AgentPane.svelte'; + import MarkdownPane from '../Markdown/MarkdownPane.svelte'; import { getPanes, getGridTemplate, @@ -54,9 +55,15 @@ cwd={pane.cwd} onExit={() => removePane(pane.id)} /> + {:else if pane.type === 'markdown'} + removePane(pane.id)} + /> {:else}
-

{pane.type} pane — coming in Phase 4

+

{pane.type} pane — coming soon

{/if} diff --git a/v2/src/lib/components/Markdown/MarkdownPane.svelte b/v2/src/lib/components/Markdown/MarkdownPane.svelte new file mode 100644 index 0000000..4ae5312 --- /dev/null +++ b/v2/src/lib/components/Markdown/MarkdownPane.svelte @@ -0,0 +1,196 @@ + + +
+ {#if error} +
{error}
+ {:else} +
+ {@html renderedHtml} +
+ {/if} +
{filePath}
+
+ + diff --git a/v2/src/lib/components/Sidebar/SessionList.svelte b/v2/src/lib/components/Sidebar/SessionList.svelte index 7245e72..9c98cd1 100644 --- a/v2/src/lib/components/Sidebar/SessionList.svelte +++ b/v2/src/lib/components/Sidebar/SessionList.svelte @@ -33,15 +33,46 @@ title: `Agent ${num}`, }); } + + let fileInputEl: HTMLInputElement | undefined = $state(); + + function openMarkdown() { + fileInputEl?.click(); + } + + function handleFileSelect(e: Event) { + const input = e.target as HTMLInputElement; + const file = input.files?.[0]; + if (!file) return; + + // Tauri file paths from input elements include the full path + const path = (file as any).path ?? file.name; + const id = crypto.randomUUID(); + addPane({ + id, + type: 'markdown', + title: file.name, + cwd: path, + }); + input.value = ''; + }

Sessions

+
+
@@ -65,7 +96,7 @@ {#each panes as pane (pane.id)}
  • diff --git a/v2/src/lib/stores/layout.svelte.ts b/v2/src/lib/stores/layout.svelte.ts index c4083c0..79598eb 100644 --- a/v2/src/lib/stores/layout.svelte.ts +++ b/v2/src/lib/stores/layout.svelte.ts @@ -1,3 +1,14 @@ +import { + listSessions, + saveSession, + deleteSession, + updateSessionTitle, + touchSession, + saveLayout, + loadLayout, + type PersistedSession, +} from '../adapters/session-bridge'; + export type LayoutPreset = '1-col' | '2-col' | '3-col' | '2x2' | 'master-stack'; export type PaneType = 'terminal' | 'agent' | 'markdown' | 'empty'; @@ -15,6 +26,33 @@ export interface Pane { let panes = $state([]); let activePreset = $state('1-col'); let focusedPaneId = $state(null); +let initialized = false; + +// --- Persistence helpers (fire-and-forget with error logging) --- + +function persistSession(pane: Pane): void { + const now = Math.floor(Date.now() / 1000); + const session: PersistedSession = { + id: pane.id, + type: pane.type, + title: pane.title, + shell: pane.shell, + cwd: pane.cwd, + args: pane.args, + created_at: now, + last_used_at: now, + }; + saveSession(session).catch(e => console.warn('Failed to persist session:', e)); +} + +function persistLayout(): void { + saveLayout({ + preset: activePreset, + pane_ids: panes.map(p => p.id), + }).catch(e => console.warn('Failed to persist layout:', e)); +} + +// --- Public API --- export function getPanes(): Pane[] { return panes; @@ -32,6 +70,8 @@ export function addPane(pane: Omit): void { panes.push({ ...pane, focused: false }); focusPane(pane.id); autoPreset(); + persistSession({ ...pane, focused: false }); + persistLayout(); } export function removePane(id: string): void { @@ -40,11 +80,14 @@ export function removePane(id: string): void { focusedPaneId = panes.length > 0 ? panes[0].id : null; } autoPreset(); + deleteSession(id).catch(e => console.warn('Failed to delete session:', e)); + persistLayout(); } export function focusPane(id: string): void { focusedPaneId = id; panes = panes.map(p => ({ ...p, focused: p.id === id })); + touchSession(id).catch(e => console.warn('Failed to touch session:', e)); } export function focusPaneByIndex(index: number): void { @@ -55,6 +98,53 @@ export function focusPaneByIndex(index: number): void { export function setPreset(preset: LayoutPreset): void { activePreset = preset; + persistLayout(); +} + +export function renamePaneTitle(id: string, title: string): void { + const pane = panes.find(p => p.id === id); + if (pane) { + pane.title = title; + updateSessionTitle(id, title).catch(e => console.warn('Failed to update title:', e)); + } +} + +/** Restore panes and layout from SQLite on app startup */ +export async function restoreFromDb(): Promise { + if (initialized) return; + initialized = true; + + try { + const [sessions, layout] = await Promise.all([listSessions(), loadLayout()]); + + if (layout.preset) { + activePreset = layout.preset as LayoutPreset; + } + + // Restore panes in layout order, falling back to DB order + const sessionMap = new Map(sessions.map(s => [s.id, s])); + const orderedIds = layout.pane_ids.length > 0 ? layout.pane_ids : sessions.map(s => s.id); + + for (const id of orderedIds) { + const s = sessionMap.get(id); + if (!s) continue; + panes.push({ + id: s.id, + type: s.type as PaneType, + title: s.title, + shell: s.shell ?? undefined, + cwd: s.cwd ?? undefined, + args: s.args ?? undefined, + focused: false, + }); + } + + if (panes.length > 0) { + focusPane(panes[0].id); + } + } catch (e) { + console.warn('Failed to restore sessions from DB:', e); + } } function autoPreset(): void {