diff --git a/src-tauri/src/commands/docker_commands.rs b/src-tauri/src/commands/docker_commands.rs new file mode 100644 index 0000000..36c6ce9 --- /dev/null +++ b/src-tauri/src/commands/docker_commands.rs @@ -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, state: State<'_, AppState>) -> Result, 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, 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, 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 { + 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>>, cmd: &str) -> Result { + 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) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index a396abb..31f3cc7 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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; diff --git a/src-tauri/src/commands/workspace_commands.rs b/src-tauri/src/commands/workspace_commands.rs new file mode 100644 index 0000000..0934114 --- /dev/null +++ b/src-tauri/src/commands/workspace_commands.rs @@ -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, state: State<'_, AppState>) -> Result<(), String> { + let snapshot = WorkspaceSnapshot { tabs }; + state.workspace.save(&snapshot) +} + +#[tauri::command] +pub fn load_workspace(state: State<'_, AppState>) -> Result, String> { + Ok(state.workspace.load()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 15282ae..2f2835f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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"); diff --git a/src/components/session/TabBar.vue b/src/components/session/TabBar.vue index a6490e5..bd5af80 100644 --- a/src/components/session/TabBar.vue +++ b/src/components/session/TabBar.vue @@ -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)" diff --git a/src/components/sidebar/ConnectionTree.vue b/src/components/sidebar/ConnectionTree.vue index aadd952..4a38aeb 100644 --- a/src/components/sidebar/ConnectionTree.vue +++ b/src/components/sidebar/ConnectionTree.vue @@ -28,10 +28,17 @@
- +
- +
@@ -118,6 +132,102 @@ const sessionStore = useSessionStore(); const contextMenu = ref | null>(null); const editDialog = ref | null>(null); +// ── Drag and drop reordering ────────────────────────────────────────────────── + +const dragOverGroupId = ref(null); +const dragOverConnId = ref(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 { + 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 { + 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>( new Set(connectionStore.groups.map((g) => g.id)), diff --git a/src/components/tools/DockerPanel.vue b/src/components/tools/DockerPanel.vue new file mode 100644 index 0000000..6ee57b2 --- /dev/null +++ b/src/components/tools/DockerPanel.vue @@ -0,0 +1,113 @@ + + + diff --git a/src/components/tools/ToolWindow.vue b/src/components/tools/ToolWindow.vue index c2f50ce..64e2aca 100644 --- a/src/components/tools/ToolWindow.vue +++ b/src/components/tools/ToolWindow.vue @@ -9,6 +9,7 @@ + @@ -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"; diff --git a/src/composables/useTerminal.ts b/src/composables/useTerminal.ts index 2068c50..bbebdf6 100644 --- a/src/composables/useTerminal.ts +++ b/src/composables/useTerminal.ts @@ -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 Classic–inspired 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(dataEvent, (event) => { + // Mark tab activity for background sessions + try { useSessionStore().markActivity(sessionId); } catch {} + const b64data = event.payload; try { diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index 855e467..73e4759 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -113,6 +113,13 @@ Subnet Calculator
+ +