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.
This commit is contained in:
parent
f349f3bb14
commit
035d4186fa
5 changed files with 144 additions and 7 deletions
|
|
@ -125,6 +125,11 @@ fn session_touch(state: State<'_, AppState>, id: String) -> Result<(), String> {
|
||||||
state.session_db.touch_session(&id)
|
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]
|
#[tauri::command]
|
||||||
fn layout_save(state: State<'_, AppState>, layout: LayoutState) -> Result<(), String> {
|
fn layout_save(state: State<'_, AppState>, layout: LayoutState) -> Result<(), String> {
|
||||||
state.session_db.save_layout(&layout)
|
state.session_db.save_layout(&layout)
|
||||||
|
|
@ -239,6 +244,7 @@ pub fn run() {
|
||||||
session_delete,
|
session_delete,
|
||||||
session_update_title,
|
session_update_title,
|
||||||
session_touch,
|
session_touch,
|
||||||
|
session_update_group,
|
||||||
layout_save,
|
layout_save,
|
||||||
layout_load,
|
layout_load,
|
||||||
settings_get,
|
settings_get,
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ pub struct Session {
|
||||||
pub shell: Option<String>,
|
pub shell: Option<String>,
|
||||||
pub cwd: Option<String>,
|
pub cwd: Option<String>,
|
||||||
pub args: Option<Vec<String>>,
|
pub args: Option<Vec<String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub group_name: String,
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
pub last_used_at: i64,
|
pub last_used_at: i64,
|
||||||
}
|
}
|
||||||
|
|
@ -102,6 +104,18 @@ impl SessionDb {
|
||||||
);
|
);
|
||||||
"
|
"
|
||||||
).map_err(|e| format!("Migration failed: {e}"))?;
|
).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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,7 +157,7 @@ impl SessionDb {
|
||||||
pub fn list_sessions(&self) -> Result<Vec<Session>, String> {
|
pub fn list_sessions(&self) -> Result<Vec<Session>, String> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let mut stmt = conn
|
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}"))?;
|
.map_err(|e| format!("Query prepare failed: {e}"))?;
|
||||||
|
|
||||||
let sessions = stmt
|
let sessions = stmt
|
||||||
|
|
@ -157,8 +171,9 @@ impl SessionDb {
|
||||||
shell: row.get(3)?,
|
shell: row.get(3)?,
|
||||||
cwd: row.get(4)?,
|
cwd: row.get(4)?,
|
||||||
args,
|
args,
|
||||||
created_at: row.get(6)?,
|
group_name: row.get::<_, Option<String>>(6)?.unwrap_or_default(),
|
||||||
last_used_at: row.get(7)?,
|
created_at: row.get(7)?,
|
||||||
|
last_used_at: row.get(8)?,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.map_err(|e| format!("Query failed: {e}"))?
|
.map_err(|e| format!("Query failed: {e}"))?
|
||||||
|
|
@ -172,7 +187,7 @@ impl SessionDb {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let args_json = session.args.as_ref().map(|a| serde_json::to_string(a).unwrap_or_default());
|
let args_json = session.args.as_ref().map(|a| serde_json::to_string(a).unwrap_or_default());
|
||||||
conn.execute(
|
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![
|
params![
|
||||||
session.id,
|
session.id,
|
||||||
session.session_type,
|
session.session_type,
|
||||||
|
|
@ -180,6 +195,7 @@ impl SessionDb {
|
||||||
session.shell,
|
session.shell,
|
||||||
session.cwd,
|
session.cwd,
|
||||||
args_json,
|
args_json,
|
||||||
|
session.group_name,
|
||||||
session.created_at,
|
session.created_at,
|
||||||
session.last_used_at,
|
session.last_used_at,
|
||||||
],
|
],
|
||||||
|
|
@ -203,6 +219,15 @@ impl SessionDb {
|
||||||
Ok(())
|
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> {
|
pub fn touch_session(&self, id: &str) -> Result<(), String> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let now = std::time::SystemTime::now()
|
let now = std::time::SystemTime::now()
|
||||||
|
|
@ -334,6 +359,7 @@ mod tests {
|
||||||
shell: Some("/bin/bash".to_string()),
|
shell: Some("/bin/bash".to_string()),
|
||||||
cwd: Some("/home/user".to_string()),
|
cwd: Some("/home/user".to_string()),
|
||||||
args: Some(vec!["--login".to_string()]),
|
args: Some(vec!["--login".to_string()]),
|
||||||
|
group_name: String::new(),
|
||||||
created_at: 1000,
|
created_at: 1000,
|
||||||
last_used_at: 2000,
|
last_used_at: 2000,
|
||||||
}
|
}
|
||||||
|
|
@ -449,6 +475,7 @@ mod tests {
|
||||||
shell: None,
|
shell: None,
|
||||||
cwd: None,
|
cwd: None,
|
||||||
args: None,
|
args: None,
|
||||||
|
group_name: String::new(),
|
||||||
created_at: 1000,
|
created_at: 1000,
|
||||||
last_used_at: 2000,
|
last_used_at: 2000,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ export interface PersistedSession {
|
||||||
shell?: string;
|
shell?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
args?: string[];
|
args?: string[];
|
||||||
|
group_name?: string;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
last_used_at: number;
|
last_used_at: number;
|
||||||
}
|
}
|
||||||
|
|
@ -36,6 +37,10 @@ export async function touchSession(id: string): Promise<void> {
|
||||||
return invoke('session_touch', { id });
|
return invoke('session_touch', { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateSessionGroup(id: string, groupName: string): Promise<void> {
|
||||||
|
return invoke('session_update_group', { id, group_name: groupName });
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveLayout(layout: PersistedLayout): Promise<void> {
|
export async function saveLayout(layout: PersistedLayout): Promise<void> {
|
||||||
return invoke('layout_save', { layout });
|
return invoke('layout_save', { layout });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,54 @@
|
||||||
removePane,
|
removePane,
|
||||||
getActivePreset,
|
getActivePreset,
|
||||||
setPreset,
|
setPreset,
|
||||||
|
setPaneGroup,
|
||||||
type LayoutPreset,
|
type LayoutPreset,
|
||||||
|
type Pane,
|
||||||
} from '../../stores/layout.svelte';
|
} from '../../stores/layout.svelte';
|
||||||
import SshSessionList from '../SSH/SshSessionList.svelte';
|
import SshSessionList from '../SSH/SshSessionList.svelte';
|
||||||
|
|
||||||
let panes = $derived(getPanes());
|
let panes = $derived(getPanes());
|
||||||
let preset = $derived(getActivePreset());
|
let preset = $derived(getActivePreset());
|
||||||
|
|
||||||
|
let grouped = $derived.by(() => {
|
||||||
|
const groups = new Map<string, Pane[]>();
|
||||||
|
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<Set<string>>(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'];
|
const presets: LayoutPreset[] = ['1-col', '2-col', '3-col', '2x2', 'master-stack'];
|
||||||
|
|
||||||
function newTerminal() {
|
function newTerminal() {
|
||||||
|
|
@ -109,14 +150,33 @@
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="pane-list">
|
<ul class="pane-list">
|
||||||
{#each panes as pane (pane.id)}
|
{#snippet paneItem(pane: Pane)}
|
||||||
<li class="pane-item" class:focused={pane.focused}>
|
<li class="pane-item" class:focused={pane.focused}>
|
||||||
<button class="pane-btn" onclick={() => focusPane(pane.id)}>
|
<button class="pane-btn" onclick={() => focusPane(pane.id)} oncontextmenu={(e) => { e.preventDefault(); setGroup(pane.id); }}>
|
||||||
<span class="pane-icon">{pane.type === 'terminal' ? '>' : pane.type === 'agent' ? '*' : pane.type === 'markdown' ? 'M' : pane.type === 'ssh' ? '@' : pane.type === 'context' ? 'C' : '#'}</span>
|
<span class="pane-icon">{paneIcon(pane.type)}</span>
|
||||||
<span class="pane-name">{pane.title}</span>
|
<span class="pane-name">{pane.title}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="remove-btn" onclick={() => removePane(pane.id)}>×</button>
|
<button class="remove-btn" onclick={() => removePane(pane.id)}>×</button>
|
||||||
</li>
|
</li>
|
||||||
|
{/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)}
|
||||||
|
<li class="group-header" onclick={() => toggleGroup(groupName)}>
|
||||||
|
<span class="group-arrow">{collapsed.has(groupName) ? '\u25B6' : '\u25BC'}</span>
|
||||||
|
<span>{groupName}</span>
|
||||||
|
<span class="group-count">{groupPanes.length}</span>
|
||||||
|
</li>
|
||||||
|
{#if !collapsed.has(groupName)}
|
||||||
|
{#each groupPanes as pane (pane.id)}
|
||||||
|
{@render paneItem(pane)}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -212,6 +272,33 @@
|
||||||
color: var(--ctp-overlay0);
|
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 {
|
.pane-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
touchSession,
|
touchSession,
|
||||||
saveLayout,
|
saveLayout,
|
||||||
loadLayout,
|
loadLayout,
|
||||||
|
updateSessionGroup,
|
||||||
type PersistedSession,
|
type PersistedSession,
|
||||||
} from '../adapters/session-bridge';
|
} from '../adapters/session-bridge';
|
||||||
|
|
||||||
|
|
@ -20,6 +21,7 @@ export interface Pane {
|
||||||
shell?: string;
|
shell?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
args?: string[];
|
args?: string[];
|
||||||
|
group?: string;
|
||||||
focused: boolean;
|
focused: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,6 +41,7 @@ function persistSession(pane: Pane): void {
|
||||||
shell: pane.shell,
|
shell: pane.shell,
|
||||||
cwd: pane.cwd,
|
cwd: pane.cwd,
|
||||||
args: pane.args,
|
args: pane.args,
|
||||||
|
group_name: pane.group ?? '',
|
||||||
created_at: now,
|
created_at: now,
|
||||||
last_used_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 */
|
/** Restore panes and layout from SQLite on app startup */
|
||||||
export async function restoreFromDb(): Promise<void> {
|
export async function restoreFromDb(): Promise<void> {
|
||||||
if (initialized) return;
|
if (initialized) return;
|
||||||
|
|
@ -135,6 +146,7 @@ export async function restoreFromDb(): Promise<void> {
|
||||||
shell: s.shell ?? undefined,
|
shell: s.shell ?? undefined,
|
||||||
cwd: s.cwd ?? undefined,
|
cwd: s.cwd ?? undefined,
|
||||||
args: s.args ?? undefined,
|
args: s.args ?? undefined,
|
||||||
|
group: s.group_name || undefined,
|
||||||
focused: false,
|
focused: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue