From 035d4186faf6876a90aaa8f25a22311ab0014345 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Fri, 6 Mar 2026 15:42:16 +0100 Subject: [PATCH] feat(v2): add session groups with collapsible sidebar headers Add group_name column to sessions table with ALTER TABLE migration, setPaneGroup in layout store, grouped sidebar rendering with Svelte 5 snippets, and right-click to assign group via prompt dialog. --- v2/src-tauri/src/lib.rs | 6 ++ v2/src-tauri/src/session.rs | 35 ++++++- v2/src/lib/adapters/session-bridge.ts | 5 + .../lib/components/Sidebar/SessionList.svelte | 93 ++++++++++++++++++- v2/src/lib/stores/layout.svelte.ts | 12 +++ 5 files changed, 144 insertions(+), 7 deletions(-) diff --git a/v2/src-tauri/src/lib.rs b/v2/src-tauri/src/lib.rs index c88666b..4603006 100644 --- a/v2/src-tauri/src/lib.rs +++ b/v2/src-tauri/src/lib.rs @@ -125,6 +125,11 @@ fn session_touch(state: State<'_, AppState>, id: String) -> Result<(), String> { state.session_db.touch_session(&id) } +#[tauri::command] +fn session_update_group(state: State<'_, AppState>, id: String, group_name: String) -> Result<(), String> { + state.session_db.update_group(&id, &group_name) +} + #[tauri::command] fn layout_save(state: State<'_, AppState>, layout: LayoutState) -> Result<(), String> { state.session_db.save_layout(&layout) @@ -239,6 +244,7 @@ pub fn run() { session_delete, session_update_title, session_touch, + session_update_group, layout_save, layout_load, settings_get, diff --git a/v2/src-tauri/src/session.rs b/v2/src-tauri/src/session.rs index 712ee4e..d031360 100644 --- a/v2/src-tauri/src/session.rs +++ b/v2/src-tauri/src/session.rs @@ -29,6 +29,8 @@ pub struct Session { pub shell: Option, pub cwd: Option, pub args: Option>, + #[serde(default)] + pub group_name: String, pub created_at: i64, pub last_used_at: i64, } @@ -102,6 +104,18 @@ impl SessionDb { ); " ).map_err(|e| format!("Migration failed: {e}"))?; + + // Add group_name column if missing (v2 migration) + let has_group: i64 = conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('sessions') WHERE name='group_name'", + [], + |row| row.get(0), + ).unwrap_or(0); + if has_group == 0 { + conn.execute("ALTER TABLE sessions ADD COLUMN group_name TEXT DEFAULT ''", []) + .map_err(|e| format!("Migration (group_name) failed: {e}"))?; + } + Ok(()) } @@ -143,7 +157,7 @@ impl SessionDb { 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") + .prepare("SELECT id, type, title, shell, cwd, args, group_name, created_at, last_used_at FROM sessions ORDER BY last_used_at DESC") .map_err(|e| format!("Query prepare failed: {e}"))?; let sessions = stmt @@ -157,8 +171,9 @@ impl SessionDb { shell: row.get(3)?, cwd: row.get(4)?, args, - created_at: row.get(6)?, - last_used_at: row.get(7)?, + group_name: row.get::<_, Option>(6)?.unwrap_or_default(), + created_at: row.get(7)?, + last_used_at: row.get(8)?, }) }) .map_err(|e| format!("Query failed: {e}"))? @@ -172,7 +187,7 @@ impl SessionDb { 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)", + "INSERT OR REPLACE INTO sessions (id, type, title, shell, cwd, args, group_name, created_at, last_used_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", params![ session.id, session.session_type, @@ -180,6 +195,7 @@ impl SessionDb { session.shell, session.cwd, args_json, + session.group_name, session.created_at, session.last_used_at, ], @@ -203,6 +219,15 @@ impl SessionDb { Ok(()) } + pub fn update_group(&self, id: &str, group_name: &str) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE sessions SET group_name = ?1 WHERE id = ?2", + params![group_name, id], + ).map_err(|e| format!("Update group failed: {e}"))?; + Ok(()) + } + pub fn touch_session(&self, id: &str) -> Result<(), String> { let conn = self.conn.lock().unwrap(); let now = std::time::SystemTime::now() @@ -334,6 +359,7 @@ mod tests { shell: Some("/bin/bash".to_string()), cwd: Some("/home/user".to_string()), args: Some(vec!["--login".to_string()]), + group_name: String::new(), created_at: 1000, last_used_at: 2000, } @@ -449,6 +475,7 @@ mod tests { shell: None, cwd: None, args: None, + group_name: String::new(), created_at: 1000, last_used_at: 2000, }; diff --git a/v2/src/lib/adapters/session-bridge.ts b/v2/src/lib/adapters/session-bridge.ts index 45deabf..4815ca7 100644 --- a/v2/src/lib/adapters/session-bridge.ts +++ b/v2/src/lib/adapters/session-bridge.ts @@ -7,6 +7,7 @@ export interface PersistedSession { shell?: string; cwd?: string; args?: string[]; + group_name?: string; created_at: number; last_used_at: number; } @@ -36,6 +37,10 @@ export async function touchSession(id: string): Promise { return invoke('session_touch', { id }); } +export async function updateSessionGroup(id: string, groupName: string): Promise { + return invoke('session_update_group', { id, group_name: groupName }); +} + export async function saveLayout(layout: PersistedLayout): Promise { return invoke('layout_save', { layout }); } diff --git a/v2/src/lib/components/Sidebar/SessionList.svelte b/v2/src/lib/components/Sidebar/SessionList.svelte index 379ab21..e69d7f2 100644 --- a/v2/src/lib/components/Sidebar/SessionList.svelte +++ b/v2/src/lib/components/Sidebar/SessionList.svelte @@ -6,13 +6,54 @@ removePane, getActivePreset, setPreset, + setPaneGroup, type LayoutPreset, + type Pane, } from '../../stores/layout.svelte'; import SshSessionList from '../SSH/SshSessionList.svelte'; let panes = $derived(getPanes()); let preset = $derived(getActivePreset()); + let grouped = $derived.by(() => { + const groups = new Map(); + for (const pane of panes) { + const g = pane.group || ''; + if (!groups.has(g)) groups.set(g, []); + groups.get(g)!.push(pane); + } + return groups; + }); + + let collapsed = $state>(new Set()); + + function toggleGroup(name: string) { + if (collapsed.has(name)) { + collapsed = new Set([...collapsed].filter(g => g !== name)); + } else { + collapsed = new Set([...collapsed, name]); + } + } + + function setGroup(paneId: string) { + const current = panes.find(p => p.id === paneId)?.group || ''; + const name = prompt('Group name (empty to ungroup):', current); + if (name !== null) { + setPaneGroup(paneId, name); + } + } + + function paneIcon(type: string): string { + switch (type) { + case 'terminal': return '>'; + case 'agent': return '*'; + case 'markdown': return 'M'; + case 'ssh': return '@'; + case 'context': return 'C'; + default: return '#'; + } + } + const presets: LayoutPreset[] = ['1-col', '2-col', '3-col', '2x2', 'master-stack']; function newTerminal() { @@ -109,14 +150,33 @@ {:else}
    - {#each panes as pane (pane.id)} + {#snippet paneItem(pane: Pane)}
  • -
  • + {/snippet} + + {#if grouped.has('')} + {#each grouped.get('')! as pane (pane.id)} + {@render paneItem(pane)} + {/each} + {/if} + + {#each [...grouped.entries()].filter(([k]) => k !== '') as [groupName, groupPanes] (groupName)} +
  • toggleGroup(groupName)}> + {collapsed.has(groupName) ? '\u25B6' : '\u25BC'} + {groupName} + {groupPanes.length} +
  • + {#if !collapsed.has(groupName)} + {#each groupPanes as pane (pane.id)} + {@render paneItem(pane)} + {/each} + {/if} {/each}
{/if} @@ -212,6 +272,33 @@ color: var(--ctp-overlay0); } + .group-header { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 6px; + font-size: 11px; + color: var(--ctp-overlay1); + cursor: pointer; + user-select: none; + list-style: none; + } + + .group-header:hover { + color: var(--text-primary); + } + + .group-arrow { + font-size: 8px; + width: 12px; + } + + .group-count { + margin-left: auto; + font-size: 9px; + color: var(--ctp-overlay0); + } + .pane-list { list-style: none; display: flex; diff --git a/v2/src/lib/stores/layout.svelte.ts b/v2/src/lib/stores/layout.svelte.ts index 1be81eb..6c9e82e 100644 --- a/v2/src/lib/stores/layout.svelte.ts +++ b/v2/src/lib/stores/layout.svelte.ts @@ -6,6 +6,7 @@ import { touchSession, saveLayout, loadLayout, + updateSessionGroup, type PersistedSession, } from '../adapters/session-bridge'; @@ -20,6 +21,7 @@ export interface Pane { shell?: string; cwd?: string; args?: string[]; + group?: string; focused: boolean; } @@ -39,6 +41,7 @@ function persistSession(pane: Pane): void { shell: pane.shell, cwd: pane.cwd, args: pane.args, + group_name: pane.group ?? '', created_at: now, last_used_at: now, }; @@ -109,6 +112,14 @@ export function renamePaneTitle(id: string, title: string): void { } } +export function setPaneGroup(id: string, group: string): void { + const pane = panes.find(p => p.id === id); + if (pane) { + pane.group = group || undefined; + updateSessionGroup(id, group).catch(e => console.warn('Failed to update group:', e)); + } +} + /** Restore panes and layout from SQLite on app startup */ export async function restoreFromDb(): Promise { if (initialized) return; @@ -135,6 +146,7 @@ export async function restoreFromDb(): Promise { shell: s.shell ?? undefined, cwd: s.cwd ?? undefined, args: s.args ?? undefined, + group: s.group_name || undefined, focused: false, }); }