feat(v2): implement session persistence, file watcher, and markdown viewer

Phase 4 complete (MVP ship):
- SessionDb (rusqlite, WAL mode): sessions + layout_state tables, CRUD
- FileWatcherManager (notify v6): watch files, emit Tauri change events
- MarkdownPane: marked.js rendering with Catppuccin styles, live reload
- Layout store wired to persistence (addPane/removePane/setPreset persist)
- restoreFromDb() on startup restores panes in layout order
- Sidebar "M" button opens file picker for markdown files
- New adapters: session-bridge.ts, file-bridge.ts
- Deps: rusqlite (bundled), dirs 5, notify 6, marked
This commit is contained in:
Hibryda 2026-03-06 12:19:56 +01:00
parent 5ca035d438
commit bdb87978a9
14 changed files with 1075 additions and 17 deletions

View file

@ -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<PtyManager>,
sidecar_manager: Arc<SidecarManager>,
session_db: Arc<SessionDb>,
file_watcher: Arc<FileWatcherManager>,
}
// --- 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<String, String> {
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<String, String> {
state.file_watcher.read_file(&path)
}
// --- Session persistence commands ---
#[tauri::command]
fn session_list(state: State<'_, AppState>) -> Result<Vec<Session>, 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<LayoutState, String> {
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) {

View file

@ -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<String>,
pub cwd: Option<String>,
pub args: Option<Vec<String>>,
pub created_at: i64,
pub last_used_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LayoutState {
pub preset: String,
pub pane_ids: Vec<String>,
}
pub struct SessionDb {
conn: Mutex<Connection>,
}
impl SessionDb {
pub fn open(data_dir: &PathBuf) -> Result<Self, String> {
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<Vec<Session>, 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<String> = row.get(5)?;
let args: Option<Vec<String>> = 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::<Result<Vec<_>, _>>()
.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<LayoutState, String> {
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<String> = serde_json::from_str(&pane_ids_json).unwrap_or_default();
Ok(LayoutState { preset, pane_ids })
}).map_err(|e| format!("Layout read failed: {e}"))
}
}

View file

@ -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<HashMap<String, WatchEntry>>,
}
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<String, String> {
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<Event, notify::Error>| {
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<String, String> {
std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read file: {e}"))
}
}