feat: tab notifications, session persistence, Docker panel, drag reorder sidebar
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 2m53s

Tab activity notifications:
- Background tabs pulse blue when new output arrives
- Clears when you switch to the tab
- useTerminal marks activity on every data event

Session persistence:
- Workspace saved to DB on window close (connection IDs + positions)
- Restored on launch — auto-reconnects saved sessions in order
- workspace_commands: save_workspace, load_workspace

Docker Manager (Tools → Docker Manager):
- Containers tab: list all, start/stop/restart/remove/logs
- Images tab: list all, remove
- Volumes tab: list all, remove
- One-click Builder Prune and System Prune buttons
- All operations via SSH exec channels — no Docker socket exposure

Sidebar drag-and-drop:
- Drag groups to reorder
- Drag connections between groups
- Drag connections within a group to reorder
- Blue border indicator on drop targets

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-25 11:50:49 -04:00
parent e6766062b1
commit 1b74527a62
11 changed files with 414 additions and 6 deletions

View File

@ -0,0 +1,115 @@
//! Tauri commands for Docker management via SSH exec channels.
use tauri::State;
use serde::Serialize;
use crate::AppState;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DockerContainer {
pub id: String,
pub name: String,
pub image: String,
pub status: String,
pub ports: String,
pub created: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DockerImage {
pub id: String,
pub repository: String,
pub tag: String,
pub size: String,
pub created: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DockerVolume {
pub name: String,
pub driver: String,
pub mountpoint: String,
}
#[tauri::command]
pub async fn docker_list_containers(session_id: String, all: Option<bool>, state: State<'_, AppState>) -> Result<Vec<DockerContainer>, String> {
let session = state.ssh.get_session(&session_id).ok_or("Session not found")?;
let flag = if all.unwrap_or(true) { "-a" } else { "" };
let output = exec(&session.handle, &format!("docker ps {} --format '{{{{.ID}}}}|{{{{.Names}}}}|{{{{.Image}}}}|{{{{.Status}}}}|{{{{.Ports}}}}|{{{{.CreatedAt}}}}' 2>&1", flag)).await?;
Ok(output.lines().filter(|l| !l.is_empty() && !l.starts_with("CONTAINER")).map(|line| {
let p: Vec<&str> = line.splitn(6, '|').collect();
DockerContainer {
id: p.first().unwrap_or(&"").to_string(),
name: p.get(1).unwrap_or(&"").to_string(),
image: p.get(2).unwrap_or(&"").to_string(),
status: p.get(3).unwrap_or(&"").to_string(),
ports: p.get(4).unwrap_or(&"").to_string(),
created: p.get(5).unwrap_or(&"").to_string(),
}
}).collect())
}
#[tauri::command]
pub async fn docker_list_images(session_id: String, state: State<'_, AppState>) -> Result<Vec<DockerImage>, String> {
let session = state.ssh.get_session(&session_id).ok_or("Session not found")?;
let output = exec(&session.handle, "docker images --format '{{.ID}}|{{.Repository}}|{{.Tag}}|{{.Size}}|{{.CreatedAt}}' 2>&1").await?;
Ok(output.lines().filter(|l| !l.is_empty()).map(|line| {
let p: Vec<&str> = line.splitn(5, '|').collect();
DockerImage {
id: p.first().unwrap_or(&"").to_string(),
repository: p.get(1).unwrap_or(&"").to_string(),
tag: p.get(2).unwrap_or(&"").to_string(),
size: p.get(3).unwrap_or(&"").to_string(),
created: p.get(4).unwrap_or(&"").to_string(),
}
}).collect())
}
#[tauri::command]
pub async fn docker_list_volumes(session_id: String, state: State<'_, AppState>) -> Result<Vec<DockerVolume>, String> {
let session = state.ssh.get_session(&session_id).ok_or("Session not found")?;
let output = exec(&session.handle, "docker volume ls --format '{{.Name}}|{{.Driver}}|{{.Mountpoint}}' 2>&1").await?;
Ok(output.lines().filter(|l| !l.is_empty()).map(|line| {
let p: Vec<&str> = line.splitn(3, '|').collect();
DockerVolume {
name: p.first().unwrap_or(&"").to_string(),
driver: p.get(1).unwrap_or(&"").to_string(),
mountpoint: p.get(2).unwrap_or(&"").to_string(),
}
}).collect())
}
#[tauri::command]
pub async fn docker_action(session_id: String, action: String, target: String, state: State<'_, AppState>) -> Result<String, String> {
let session = state.ssh.get_session(&session_id).ok_or("Session not found")?;
let cmd = match action.as_str() {
"start" => format!("docker start {} 2>&1", target),
"stop" => format!("docker stop {} 2>&1", target),
"restart" => format!("docker restart {} 2>&1", target),
"remove" => format!("docker rm -f {} 2>&1", target),
"logs" => format!("docker logs --tail 100 {} 2>&1", target),
"remove-image" => format!("docker rmi {} 2>&1", target),
"remove-volume" => format!("docker volume rm {} 2>&1", target),
"builder-prune" => "docker builder prune -f 2>&1".to_string(),
"system-prune" => "docker system prune -f 2>&1".to_string(),
"system-prune-all" => "docker system prune -a -f 2>&1".to_string(),
_ => return Err(format!("Unknown docker action: {}", action)),
};
exec(&session.handle, &cmd).await
}
async fn exec(handle: &std::sync::Arc<tokio::sync::Mutex<russh::client::Handle<crate::ssh::session::SshClient>>>, cmd: &str) -> Result<String, String> {
let mut channel = { let h = handle.lock().await; h.channel_open_session().await.map_err(|e| format!("Exec failed: {}", e))? };
channel.exec(true, cmd).await.map_err(|e| format!("Exec failed: {}", e))?;
let mut output = String::new();
loop {
match channel.wait().await {
Some(russh::ChannelMsg::Data { ref data }) => { if let Ok(t) = std::str::from_utf8(data.as_ref()) { output.push_str(t); } }
Some(russh::ChannelMsg::Eof) | Some(russh::ChannelMsg::Close) | None => break,
_ => {}
}
}
Ok(output)
}

View File

@ -12,3 +12,5 @@ pub mod scanner_commands;
pub mod tools_commands;
pub mod updater;
pub mod tools_commands_r2;
pub mod workspace_commands;
pub mod docker_commands;

View File

@ -0,0 +1,16 @@
//! Tauri commands for workspace persistence.
use tauri::State;
use crate::AppState;
use crate::workspace::{WorkspaceSnapshot, WorkspaceTab};
#[tauri::command]
pub fn save_workspace(tabs: Vec<WorkspaceTab>, state: State<'_, AppState>) -> Result<(), String> {
let snapshot = WorkspaceSnapshot { tabs };
state.workspace.save(&snapshot)
}
#[tauri::command]
pub fn load_workspace(state: State<'_, AppState>) -> Result<Option<WorkspaceSnapshot>, String> {
Ok(state.workspace.load())
}

View File

@ -196,6 +196,8 @@ pub fn run() {
commands::tools_commands::tool_ping, commands::tools_commands::tool_traceroute, commands::tools_commands::tool_wake_on_lan, commands::tools_commands::tool_generate_ssh_key, commands::tools_commands::tool_generate_password,
commands::tools_commands_r2::tool_dns_lookup, commands::tools_commands_r2::tool_whois, commands::tools_commands_r2::tool_bandwidth_iperf, commands::tools_commands_r2::tool_bandwidth_speedtest, commands::tools_commands_r2::tool_subnet_calc,
commands::updater::check_for_updates,
commands::workspace_commands::save_workspace, commands::workspace_commands::load_workspace,
commands::docker_commands::docker_list_containers, commands::docker_commands::docker_list_images, commands::docker_commands::docker_list_volumes, commands::docker_commands::docker_action,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@ -14,6 +14,7 @@
: 'text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-secondary)] hover:bg-[var(--wraith-bg-tertiary)]',
isRootUser(session) ? 'border-t-2 border-t-[#f8514966]' : '',
dragOverIndex === index ? 'border-l-2 border-l-[var(--wraith-accent-blue)]' : '',
session.hasActivity && session.id !== sessionStore.activeSessionId ? 'animate-pulse text-[var(--wraith-accent-blue)]' : '',
]"
@click="sessionStore.activateSession(session.id)"
@dragstart="onDragStart(index, $event)"

View File

@ -28,10 +28,17 @@
<!-- Only show groups that have matching connections during search -->
<div v-if="!connectionStore.searchQuery || connectionStore.groupHasResults(group.id)">
<!-- Group header -->
<button
class="w-full flex items-center gap-1.5 px-3 py-1.5 text-xs hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer"
<div
class="w-full flex items-center gap-1.5 px-3 py-1.5 text-xs hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer select-none"
:class="{ 'border-t-2 border-t-[var(--wraith-accent-blue)]': dragOverGroupId === group.id }"
draggable="true"
@click="toggleGroup(group.id)"
@contextmenu.prevent="showGroupMenu($event, group)"
@dragstart="onGroupDragStart(group, $event)"
@dragover.prevent="onGroupDragOver(group)"
@dragleave="dragOverGroupId = null"
@drop.prevent="onGroupDrop(group)"
@dragend="resetDragState"
>
<!-- Chevron -->
<svg
@ -58,16 +65,23 @@
<span class="ml-auto text-[var(--wraith-text-muted)] text-[10px]">
{{ connectionStore.connectionsByGroup(group.id).length }}
</span>
</button>
</div>
<!-- Connections in group -->
<div v-if="expandedGroups.has(group.id)">
<button
<div
v-for="conn in connectionStore.connectionsByGroup(group.id)"
:key="conn.id"
class="w-full flex items-center gap-2 pl-8 pr-3 py-1.5 text-xs hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer"
draggable="true"
class="w-full flex items-center gap-2 pl-8 pr-3 py-1.5 text-xs hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer select-none"
:class="{ 'border-t-2 border-t-[var(--wraith-accent-blue)]': dragOverConnId === conn.id }"
@dblclick="handleConnect(conn)"
@contextmenu.prevent="showConnectionMenu($event, conn)"
@dragstart="onConnDragStart(conn, group.id, $event)"
@dragover.prevent="onConnDragOver(conn)"
@dragleave="dragOverConnId = null"
@drop.prevent="onConnDrop(conn, group.id)"
@dragend="resetDragState"
>
<!-- Protocol dot -->
<span
@ -82,7 +96,7 @@
>
{{ tag }}
</span>
</button>
</div>
</div>
</div>
</template>
@ -118,6 +132,102 @@ const sessionStore = useSessionStore();
const contextMenu = ref<InstanceType<typeof ContextMenu> | null>(null);
const editDialog = ref<InstanceType<typeof ConnectionEditDialog> | null>(null);
// Drag and drop reordering
const dragOverGroupId = ref<number | null>(null);
const dragOverConnId = ref<number | null>(null);
let draggedGroup: Group | null = null;
let draggedConn: { conn: Connection; fromGroupId: number } | null = null;
function onGroupDragStart(group: Group, event: DragEvent): void {
draggedGroup = group;
draggedConn = null;
event.dataTransfer?.setData("text/plain", `group:${group.id}`);
}
function onGroupDragOver(target: Group): void {
if (draggedGroup && draggedGroup.id !== target.id) {
dragOverGroupId.value = target.id;
}
// Allow dropping connections onto groups to move them
if (draggedConn) {
dragOverGroupId.value = target.id;
}
}
async function onGroupDrop(target: Group): Promise<void> {
if (draggedGroup && draggedGroup.id !== target.id) {
// Reorder groups swap sort_order
const groups = connectionStore.groups;
const fromIdx = groups.findIndex(g => g.id === draggedGroup!.id);
const toIdx = groups.findIndex(g => g.id === target.id);
if (fromIdx !== -1 && toIdx !== -1) {
const [moved] = groups.splice(fromIdx, 1);
groups.splice(toIdx, 0, moved);
}
}
if (draggedConn && draggedConn.fromGroupId !== target.id) {
// Move connection to different group
try {
await invoke("update_connection", {
id: draggedConn.conn.id,
input: { groupId: target.id },
});
await connectionStore.loadAll();
} catch (err) {
console.error("Failed to move connection:", err);
}
}
resetDragState();
}
function onConnDragStart(conn: Connection, groupId: number, event: DragEvent): void {
draggedConn = { conn, fromGroupId: groupId };
draggedGroup = null;
event.dataTransfer?.setData("text/plain", `conn:${conn.id}`);
}
function onConnDragOver(target: Connection): void {
if (draggedConn && draggedConn.conn.id !== target.id) {
dragOverConnId.value = target.id;
}
}
async function onConnDrop(target: Connection, targetGroupId: number): Promise<void> {
if (draggedConn && draggedConn.conn.id !== target.id) {
// Reorder within group or move between groups
if (draggedConn.fromGroupId !== targetGroupId) {
// Move to different group
try {
await invoke("update_connection", {
id: draggedConn.conn.id,
input: { groupId: targetGroupId },
});
await connectionStore.loadAll();
} catch (err) {
console.error("Failed to move connection:", err);
}
} else {
// Reorder within same group
const conns = connectionStore.connectionsByGroup(targetGroupId);
const fromIdx = conns.findIndex(c => c.id === draggedConn!.conn.id);
const toIdx = conns.findIndex(c => c.id === target.id);
if (fromIdx !== -1 && toIdx !== -1) {
const [moved] = conns.splice(fromIdx, 1);
conns.splice(toIdx, 0, moved);
}
}
}
resetDragState();
}
function resetDragState(): void {
draggedGroup = null;
draggedConn = null;
dragOverGroupId.value = null;
dragOverConnId.value = null;
}
// All groups expanded by default
const expandedGroups = ref<Set<number>>(
new Set(connectionStore.groups.map((g) => g.id)),

View File

@ -0,0 +1,113 @@
<template>
<div class="flex flex-col h-full p-4 gap-3">
<!-- Tabs -->
<div class="flex items-center gap-2">
<button v-for="t in ['containers','images','volumes']" :key="t"
class="px-3 py-1 text-xs rounded cursor-pointer transition-colors"
:class="tab === t ? 'bg-[#58a6ff] text-black font-bold' : 'bg-[#21262d] text-[#8b949e] hover:text-white'"
@click="tab = t; refresh()"
>{{ t.charAt(0).toUpperCase() + t.slice(1) }}</button>
<div class="ml-auto flex gap-1">
<button class="px-2 py-1 text-[10px] rounded bg-[#21262d] text-[#8b949e] hover:text-white cursor-pointer" @click="refresh">Refresh</button>
<button class="px-2 py-1 text-[10px] rounded bg-[#da3633] text-white cursor-pointer" @click="action('builder-prune', '')">Builder Prune</button>
<button class="px-2 py-1 text-[10px] rounded bg-[#da3633] text-white cursor-pointer" @click="action('system-prune', '')">System Prune</button>
</div>
</div>
<!-- Containers -->
<div v-if="tab === 'containers'" class="flex-1 overflow-auto border border-[#30363d] rounded">
<table class="w-full text-xs"><thead class="bg-[#161b22] sticky top-0"><tr>
<th class="text-left px-3 py-2 text-[#8b949e]">Name</th>
<th class="text-left px-3 py-2 text-[#8b949e]">Image</th>
<th class="text-left px-3 py-2 text-[#8b949e]">Status</th>
<th class="text-left px-3 py-2 text-[#8b949e]">Actions</th>
</tr></thead><tbody>
<tr v-for="c in containers" :key="c.id" class="border-t border-[#21262d] hover:bg-[#161b22]">
<td class="px-3 py-1.5 font-mono">{{ c.name }}</td>
<td class="px-3 py-1.5 text-[#8b949e]">{{ c.image }}</td>
<td class="px-3 py-1.5" :class="c.status.startsWith('Up') ? 'text-[#3fb950]' : 'text-[#8b949e]'">{{ c.status }}</td>
<td class="px-3 py-1.5 flex gap-1">
<button v-if="!c.status.startsWith('Up')" class="px-1.5 py-0.5 text-[10px] rounded bg-[#238636] text-white cursor-pointer" @click="action('start', c.name)">Start</button>
<button v-if="c.status.startsWith('Up')" class="px-1.5 py-0.5 text-[10px] rounded bg-[#e3b341] text-black cursor-pointer" @click="action('stop', c.name)">Stop</button>
<button class="px-1.5 py-0.5 text-[10px] rounded bg-[#1f6feb] text-white cursor-pointer" @click="action('restart', c.name)">Restart</button>
<button class="px-1.5 py-0.5 text-[10px] rounded bg-[#21262d] text-[#8b949e] cursor-pointer" @click="viewLogs(c.name)">Logs</button>
<button class="px-1.5 py-0.5 text-[10px] rounded bg-[#da3633] text-white cursor-pointer" @click="action('remove', c.name)">Remove</button>
</td>
</tr>
</tbody></table>
</div>
<!-- Images -->
<div v-if="tab === 'images'" class="flex-1 overflow-auto border border-[#30363d] rounded">
<table class="w-full text-xs"><thead class="bg-[#161b22] sticky top-0"><tr>
<th class="text-left px-3 py-2 text-[#8b949e]">Repository</th>
<th class="text-left px-3 py-2 text-[#8b949e]">Tag</th>
<th class="text-left px-3 py-2 text-[#8b949e]">Size</th>
<th class="text-left px-3 py-2 text-[#8b949e]">Actions</th>
</tr></thead><tbody>
<tr v-for="img in images" :key="img.id" class="border-t border-[#21262d] hover:bg-[#161b22]">
<td class="px-3 py-1.5 font-mono">{{ img.repository }}</td>
<td class="px-3 py-1.5">{{ img.tag }}</td>
<td class="px-3 py-1.5 text-[#8b949e]">{{ img.size }}</td>
<td class="px-3 py-1.5"><button class="px-1.5 py-0.5 text-[10px] rounded bg-[#da3633] text-white cursor-pointer" @click="action('remove-image', img.id)">Remove</button></td>
</tr>
</tbody></table>
</div>
<!-- Volumes -->
<div v-if="tab === 'volumes'" class="flex-1 overflow-auto border border-[#30363d] rounded">
<table class="w-full text-xs"><thead class="bg-[#161b22] sticky top-0"><tr>
<th class="text-left px-3 py-2 text-[#8b949e]">Name</th>
<th class="text-left px-3 py-2 text-[#8b949e]">Driver</th>
<th class="text-left px-3 py-2 text-[#8b949e]">Actions</th>
</tr></thead><tbody>
<tr v-for="v in volumes" :key="v.name" class="border-t border-[#21262d] hover:bg-[#161b22]">
<td class="px-3 py-1.5 font-mono">{{ v.name }}</td>
<td class="px-3 py-1.5 text-[#8b949e]">{{ v.driver }}</td>
<td class="px-3 py-1.5"><button class="px-1.5 py-0.5 text-[10px] rounded bg-[#da3633] text-white cursor-pointer" @click="action('remove-volume', v.name)">Remove</button></td>
</tr>
</tbody></table>
</div>
<!-- Output -->
<pre v-if="output" class="max-h-32 overflow-auto bg-[#161b22] border border-[#30363d] rounded p-2 text-[10px] font-mono text-[#e0e0e0]">{{ output }}</pre>
<div class="text-[10px] text-[#484f58]">{{ containers.length }} containers · {{ images.length }} images · {{ volumes.length }} volumes</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { invoke } from "@tauri-apps/api/core";
const props = defineProps<{ sessionId: string }>();
const tab = ref("containers");
const containers = ref<any[]>([]);
const images = ref<any[]>([]);
const volumes = ref<any[]>([]);
const output = ref("");
async function refresh(): Promise<void> {
try {
if (tab.value === "containers") containers.value = await invoke("docker_list_containers", { sessionId: props.sessionId, all: true });
if (tab.value === "images") images.value = await invoke("docker_list_images", { sessionId: props.sessionId });
if (tab.value === "volumes") volumes.value = await invoke("docker_list_volumes", { sessionId: props.sessionId });
} catch (err) { output.value = String(err); }
}
async function action(act: string, target: string): Promise<void> {
try {
output.value = await invoke<string>("docker_action", { sessionId: props.sessionId, action: act, target });
await refresh();
} catch (err) { output.value = String(err); }
}
async function viewLogs(name: string): Promise<void> {
try { output.value = await invoke<string>("docker_action", { sessionId: props.sessionId, action: "logs", target: name }); }
catch (err) { output.value = String(err); }
}
onMounted(refresh);
</script>

View File

@ -9,6 +9,7 @@
<WhoisTool v-else-if="tool === 'whois'" :session-id="sessionId" />
<BandwidthTest v-else-if="tool === 'bandwidth'" :session-id="sessionId" />
<SubnetCalc v-else-if="tool === 'subnet-calc'" />
<DockerPanel v-else-if="tool === 'docker'" :session-id="sessionId" />
<FileEditor v-else-if="tool === 'editor'" :session-id="sessionId" />
<SshKeyGen v-else-if="tool === 'ssh-keygen'" />
<PasswordGen v-else-if="tool === 'password-gen'" />
@ -28,6 +29,7 @@ import DnsLookup from "./DnsLookup.vue";
import WhoisTool from "./WhoisTool.vue";
import BandwidthTest from "./BandwidthTest.vue";
import SubnetCalc from "./SubnetCalc.vue";
import DockerPanel from "./DockerPanel.vue";
import FileEditor from "./FileEditor.vue";
import SshKeyGen from "./SshKeyGen.vue";
import PasswordGen from "./PasswordGen.vue";

View File

@ -5,6 +5,7 @@ import { SearchAddon } from "@xterm/addon-search";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { useSessionStore } from "@/stores/session.store";
import "@xterm/xterm/css/xterm.css";
/** MobaXTerm Classicinspired terminal theme colors. */
@ -161,6 +162,9 @@ export function useTerminal(sessionId: string, backend: 'ssh' | 'pty' = 'ssh'):
// Tauri v2 listen() callback receives { payload: T } — the base64 string
// is in event.payload (not event.data as in Wails).
unlistenPromise = listen<string>(dataEvent, (event) => {
// Mark tab activity for background sessions
try { useSessionStore().markActivity(sessionId); } catch {}
const b64data = event.payload;
try {

View File

@ -113,6 +113,13 @@
<span class="flex-1">Subnet Calculator</span>
</button>
<div class="border-t border-[#30363d] my-1" />
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleToolAction('docker')"
>
<span class="flex-1">Docker Manager</span>
</button>
<div class="border-t border-[#30363d] my-1" />
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleToolAction('wake-on-lan')"
@ -333,6 +340,7 @@ async function handleToolAction(tool: string): Promise<void> {
"whois": { title: "Whois", width: 700, height: 500 },
"bandwidth": { title: "Bandwidth Test", width: 700, height: 450 },
"subnet-calc": { title: "Subnet Calculator", width: 650, height: 350 },
"docker": { title: "Docker Manager", width: 900, height: 600 },
"wake-on-lan": { title: "Wake on LAN", width: 500, height: 300 },
"ssh-keygen": { title: "SSH Key Generator", width: 700, height: 500 },
"password-gen": { title: "Password Generator", width: 500, height: 400 },
@ -430,6 +438,25 @@ onMounted(async () => {
document.addEventListener("keydown", handleKeydown);
await connectionStore.loadAll();
// Restore workspace reconnect saved tabs
try {
const workspace = await invoke<{ tabs: { connectionId: number; protocol: string; position: number }[] } | null>("load_workspace");
if (workspace?.tabs?.length) {
for (const tab of workspace.tabs.sort((a, b) => a.position - b.position)) {
sessionStore.connect(tab.connectionId).catch(() => {});
}
}
} catch {}
// Save workspace on window close
const appWindow = await import("@tauri-apps/api/window").then(m => m.getCurrentWindow());
appWindow.onCloseRequested(async () => {
const tabs = sessionStore.sessions
.filter(s => s.protocol === "ssh" || s.protocol === "rdp")
.map((s, i) => ({ connectionId: s.connectionId, protocol: s.protocol, position: i }));
await invoke("save_workspace", { tabs }).catch(() => {});
});
// Check for updates on startup (non-blocking)
invoke<{ currentVersion: string; latestVersion: string; updateAvailable: boolean; downloadUrl: string }>("check_for_updates")
.then((info) => {

View File

@ -13,6 +13,7 @@ export interface Session {
active: boolean;
username?: string;
status: "connected" | "disconnected";
hasActivity: boolean;
}
export interface TerminalDimensions {
@ -51,6 +52,16 @@ export const useSessionStore = defineStore("session", () => {
function activateSession(id: string): void {
activeSessionId.value = id;
// Clear activity indicator when switching to tab
const session = sessions.value.find(s => s.id === id);
if (session) session.hasActivity = false;
}
/** Mark a background tab as having new activity. */
function markActivity(sessionId: string): void {
if (sessionId === activeSessionId.value) return; // don't flash the active tab
const session = sessions.value.find(s => s.id === sessionId);
if (session) session.hasActivity = true;
}
/** Reorder sessions by moving a tab from one index to another. */
@ -155,6 +166,7 @@ export const useSessionStore = defineStore("session", () => {
active: true,
username: resolvedUsername,
status: "connected",
hasActivity: false,
});
setupStatusListeners(sessionId);
activeSessionId.value = sessionId;
@ -219,6 +231,7 @@ export const useSessionStore = defineStore("session", () => {
active: true,
username: resolvedUsername,
status: "connected",
hasActivity: false,
});
setupStatusListeners(sessionId);
activeSessionId.value = sessionId;
@ -303,6 +316,7 @@ export const useSessionStore = defineStore("session", () => {
active: true,
username,
status: "connected",
hasActivity: false,
});
activeSessionId.value = sessionId;
}
@ -333,6 +347,7 @@ export const useSessionStore = defineStore("session", () => {
protocol: "local",
active: true,
status: "connected",
hasActivity: false,
});
// Listen for PTY close
@ -376,6 +391,7 @@ export const useSessionStore = defineStore("session", () => {
connect,
spawnLocalTab,
moveSession,
markActivity,
setTheme,
setTerminalDimensions,
};