feat: main layout — sidebar connection tree, tab bar, status bar
Three-panel layout with 240px sidebar, tabbed session area, and status bar. Sidebar has Connections/SFTP toggle, search input, and collapsible group tree with protocol-colored dots. Tab bar shows active sessions with color-coded indicators and close buttons. Status bar displays connection info, theme, encoding, and terminal size. All backed by connection and session Pinia stores with mock data until Wails bindings are connected. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d67e183d72
commit
d57cd6cfbb
47
frontend/src/components/common/StatusBar.vue
Normal file
47
frontend/src/components/common/StatusBar.vue
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<template>
|
||||||
|
<div class="h-6 flex items-center justify-between px-4 bg-[var(--wraith-bg-secondary)] border-t border-[var(--wraith-border)] text-[10px] text-[var(--wraith-text-muted)] shrink-0">
|
||||||
|
<!-- Left: connection info -->
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<template v-if="sessionStore.activeSession">
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
class="w-1.5 h-1.5 rounded-full"
|
||||||
|
:class="sessionStore.activeSession.protocol === 'ssh' ? 'bg-[#3fb950]' : 'bg-[#1f6feb]'"
|
||||||
|
/>
|
||||||
|
{{ sessionStore.activeSession.protocol.toUpperCase() }}
|
||||||
|
</span>
|
||||||
|
<span class="text-[var(--wraith-text-secondary)]">·</span>
|
||||||
|
<span>{{ connectionInfo }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span>Ready</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: terminal info -->
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span>Theme: Dracula</span>
|
||||||
|
<span>UTF-8</span>
|
||||||
|
<span>120×40</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useSessionStore } from "@/stores/session.store";
|
||||||
|
import { useConnectionStore } from "@/stores/connection.store";
|
||||||
|
|
||||||
|
const sessionStore = useSessionStore();
|
||||||
|
const connectionStore = useConnectionStore();
|
||||||
|
|
||||||
|
const connectionInfo = computed(() => {
|
||||||
|
const session = sessionStore.activeSession;
|
||||||
|
if (!session) return "";
|
||||||
|
|
||||||
|
const conn = connectionStore.connections.find((c) => c.id === session.connectionId);
|
||||||
|
if (!conn) return session.name;
|
||||||
|
|
||||||
|
return `root@${conn.hostname}:${conn.port}`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
26
frontend/src/components/session/SessionContainer.vue
Normal file
26
frontend/src/components/session/SessionContainer.vue
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex-1 flex items-center justify-center bg-[var(--wraith-bg-primary)]">
|
||||||
|
<div v-if="sessionStore.activeSession" class="text-center">
|
||||||
|
<p class="text-[var(--wraith-text-secondary)] text-sm">
|
||||||
|
Session: <span class="text-[var(--wraith-text-primary)]">{{ sessionStore.activeSession.name }}</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-[var(--wraith-text-muted)] text-xs mt-1">
|
||||||
|
{{ sessionStore.activeSession.protocol.toUpperCase() }} terminal will render here
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-center">
|
||||||
|
<p class="text-[var(--wraith-text-muted)] text-sm">
|
||||||
|
No active session
|
||||||
|
</p>
|
||||||
|
<p class="text-[var(--wraith-text-muted)] text-xs mt-1">
|
||||||
|
Double-click a connection to start a session
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useSessionStore } from "@/stores/session.store";
|
||||||
|
|
||||||
|
const sessionStore = useSessionStore();
|
||||||
|
</script>
|
||||||
48
frontend/src/components/session/TabBar.vue
Normal file
48
frontend/src/components/session/TabBar.vue
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex items-center bg-[var(--wraith-bg-secondary)] border-b border-[var(--wraith-border)] h-9 shrink-0">
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="flex items-center overflow-x-auto min-w-0">
|
||||||
|
<button
|
||||||
|
v-for="session in sessionStore.sessions"
|
||||||
|
:key="session.id"
|
||||||
|
class="group flex items-center gap-2 px-3 h-9 text-xs whitespace-nowrap border-r border-[var(--wraith-border)] transition-all duration-500 cursor-pointer shrink-0"
|
||||||
|
:class="
|
||||||
|
session.id === sessionStore.activeSessionId
|
||||||
|
? 'bg-[var(--wraith-bg-primary)] text-[var(--wraith-text-primary)] border-b-2 border-b-[var(--wraith-accent-blue)]'
|
||||||
|
: 'text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-secondary)] hover:bg-[var(--wraith-bg-tertiary)]'
|
||||||
|
"
|
||||||
|
@click="sessionStore.activateSession(session.id)"
|
||||||
|
>
|
||||||
|
<!-- Protocol dot -->
|
||||||
|
<span
|
||||||
|
class="w-2 h-2 rounded-full shrink-0"
|
||||||
|
:class="session.protocol === 'ssh' ? 'bg-[#3fb950]' : 'bg-[#1f6feb]'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span>{{ session.name }}</span>
|
||||||
|
|
||||||
|
<!-- Close button -->
|
||||||
|
<span
|
||||||
|
class="ml-1 opacity-0 group-hover:opacity-100 hover:text-[var(--wraith-accent-red)] transition-opacity"
|
||||||
|
@click.stop="sessionStore.closeSession(session.id)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New tab button -->
|
||||||
|
<button
|
||||||
|
class="flex items-center justify-center w-9 h-9 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer shrink-0"
|
||||||
|
title="New session"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useSessionStore } from "@/stores/session.store";
|
||||||
|
|
||||||
|
const sessionStore = useSessionStore();
|
||||||
|
</script>
|
||||||
83
frontend/src/components/sidebar/ConnectionTree.vue
Normal file
83
frontend/src/components/sidebar/ConnectionTree.vue
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
<template>
|
||||||
|
<div class="py-1">
|
||||||
|
<template v-for="group in connectionStore.groups" :key="group.id">
|
||||||
|
<!-- 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"
|
||||||
|
@click="toggleGroup(group.id)"
|
||||||
|
>
|
||||||
|
<!-- Chevron -->
|
||||||
|
<svg
|
||||||
|
class="w-3 h-3 text-[var(--wraith-text-muted)] transition-transform shrink-0"
|
||||||
|
:class="{ 'rotate-90': expandedGroups.has(group.id) }"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M6 4l4 4-4 4z" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- Folder icon -->
|
||||||
|
<svg
|
||||||
|
class="w-3.5 h-3.5 text-[var(--wraith-accent-yellow)] shrink-0"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75z" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<span class="text-[var(--wraith-text-primary)] truncate">{{ group.name }}</span>
|
||||||
|
|
||||||
|
<!-- Connection count -->
|
||||||
|
<span class="ml-auto text-[var(--wraith-text-muted)] text-[10px]">
|
||||||
|
{{ connectionStore.connectionsByGroup(group.id).length }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Connections in group -->
|
||||||
|
<div v-if="expandedGroups.has(group.id)">
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<!-- Protocol dot -->
|
||||||
|
<span
|
||||||
|
class="w-2 h-2 rounded-full shrink-0"
|
||||||
|
:class="conn.protocol === 'ssh' ? 'bg-[#3fb950]' : 'bg-[#1f6feb]'"
|
||||||
|
/>
|
||||||
|
<span class="text-[var(--wraith-text-primary)] truncate">{{ conn.name }}</span>
|
||||||
|
<span
|
||||||
|
v-for="tag in conn.tags"
|
||||||
|
:key="tag"
|
||||||
|
class="ml-auto text-[10px] px-1.5 py-0.5 rounded bg-[var(--wraith-bg-tertiary)] text-[var(--wraith-text-muted)]"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useConnectionStore } from "@/stores/connection.store";
|
||||||
|
|
||||||
|
const connectionStore = useConnectionStore();
|
||||||
|
|
||||||
|
// All groups expanded by default
|
||||||
|
const expandedGroups = ref<Set<number>>(
|
||||||
|
new Set(connectionStore.groups.map((g) => g.id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggleGroup(groupId: number): void {
|
||||||
|
if (expandedGroups.value.has(groupId)) {
|
||||||
|
expandedGroups.value.delete(groupId);
|
||||||
|
} else {
|
||||||
|
expandedGroups.value.add(groupId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
28
frontend/src/components/sidebar/SidebarToggle.vue
Normal file
28
frontend/src/components/sidebar/SidebarToggle.vue
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex border-b border-[var(--wraith-border)]">
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.id"
|
||||||
|
class="flex-1 py-2 text-xs font-medium text-center transition-colors cursor-pointer"
|
||||||
|
:class="
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'text-[var(--wraith-accent-blue)] border-b-2 border-[var(--wraith-accent-blue)]'
|
||||||
|
: 'text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-secondary)]'
|
||||||
|
"
|
||||||
|
@click="activeTab = tab.id"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: "connections", label: "Connections" },
|
||||||
|
{ id: "sftp", label: "SFTP" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const activeTab = ref<"connections" | "sftp">("connections");
|
||||||
|
</script>
|
||||||
85
frontend/src/layouts/MainLayout.vue
Normal file
85
frontend/src/layouts/MainLayout.vue
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
<template>
|
||||||
|
<div class="h-screen w-screen flex flex-col overflow-hidden">
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div
|
||||||
|
class="h-10 flex items-center justify-between px-4 bg-[var(--wraith-bg-secondary)] border-b border-[var(--wraith-border)] shrink-0"
|
||||||
|
style="--wails-draggable: drag"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-bold tracking-widest text-[var(--wraith-accent-blue)]">
|
||||||
|
WRAITH
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center gap-3 text-xs text-[var(--wraith-text-secondary)]">
|
||||||
|
<span>{{ sessionStore.sessionCount }} session{{ sessionStore.sessionCount !== 1 ? "s" : "" }}</span>
|
||||||
|
<button
|
||||||
|
class="hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
||||||
|
title="Lock vault"
|
||||||
|
@click="appStore.lock()"
|
||||||
|
>
|
||||||
|
🔒
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
⚙
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main content area -->
|
||||||
|
<div class="flex flex-1 min-h-0">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div
|
||||||
|
class="flex flex-col bg-[var(--wraith-bg-secondary)] border-r border-[var(--wraith-border)] shrink-0"
|
||||||
|
:style="{ width: sidebarWidth + 'px' }"
|
||||||
|
>
|
||||||
|
<SidebarToggle />
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="px-3 py-2">
|
||||||
|
<input
|
||||||
|
v-model="connectionStore.searchQuery"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search connections..."
|
||||||
|
class="w-full px-2.5 py-1.5 text-xs rounded bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Connection tree -->
|
||||||
|
<div class="flex-1 overflow-y-auto">
|
||||||
|
<ConnectionTree />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content area -->
|
||||||
|
<div class="flex-1 flex flex-col min-w-0">
|
||||||
|
<!-- Tab bar -->
|
||||||
|
<TabBar />
|
||||||
|
|
||||||
|
<!-- Session area -->
|
||||||
|
<SessionContainer />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status bar -->
|
||||||
|
<StatusBar />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useAppStore } from "@/stores/app.store";
|
||||||
|
import { useConnectionStore } from "@/stores/connection.store";
|
||||||
|
import { useSessionStore } from "@/stores/session.store";
|
||||||
|
import SidebarToggle from "@/components/sidebar/SidebarToggle.vue";
|
||||||
|
import ConnectionTree from "@/components/sidebar/ConnectionTree.vue";
|
||||||
|
import TabBar from "@/components/session/TabBar.vue";
|
||||||
|
import SessionContainer from "@/components/session/SessionContainer.vue";
|
||||||
|
import StatusBar from "@/components/common/StatusBar.vue";
|
||||||
|
|
||||||
|
const appStore = useAppStore();
|
||||||
|
const connectionStore = useConnectionStore();
|
||||||
|
const sessionStore = useSessionStore();
|
||||||
|
|
||||||
|
const sidebarWidth = ref(240);
|
||||||
|
</script>
|
||||||
95
frontend/src/stores/connection.store.ts
Normal file
95
frontend/src/stores/connection.store.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
|
||||||
|
export interface Connection {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
hostname: string;
|
||||||
|
port: number;
|
||||||
|
protocol: "ssh" | "rdp";
|
||||||
|
groupId: number;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Group {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
parentId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connection store.
|
||||||
|
* Manages connections, groups, and search state.
|
||||||
|
*
|
||||||
|
* Uses mock data until Wails bindings are connected.
|
||||||
|
*/
|
||||||
|
export const useConnectionStore = defineStore("connection", () => {
|
||||||
|
const connections = ref<Connection[]>([
|
||||||
|
{ id: 1, name: "Asgard", hostname: "192.168.1.4", port: 22, protocol: "ssh", groupId: 1, tags: ["Prod"] },
|
||||||
|
{ id: 2, name: "Docker", hostname: "155.254.29.221", port: 22, protocol: "ssh", groupId: 1, tags: ["Prod"] },
|
||||||
|
{ id: 3, name: "Predator Mac", hostname: "192.168.1.214", port: 22, protocol: "ssh", groupId: 1 },
|
||||||
|
{ id: 4, name: "CLT-VMHOST01", hostname: "100.64.1.204", port: 3389, protocol: "rdp", groupId: 1 },
|
||||||
|
{ id: 5, name: "ITFlow", hostname: "192.154.253.106", port: 22, protocol: "ssh", groupId: 2 },
|
||||||
|
{ id: 6, name: "Mautic", hostname: "192.154.253.112", port: 22, protocol: "ssh", groupId: 2 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const groups = ref<Group[]>([
|
||||||
|
{ id: 1, name: "Vantz's Stuff", parentId: null },
|
||||||
|
{ id: 2, name: "MSPNerd", parentId: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const searchQuery = ref("");
|
||||||
|
|
||||||
|
/** Filter connections by search query. */
|
||||||
|
const filteredConnections = computed(() => {
|
||||||
|
const q = searchQuery.value.toLowerCase().trim();
|
||||||
|
if (!q) return connections.value;
|
||||||
|
return connections.value.filter(
|
||||||
|
(c) =>
|
||||||
|
c.name.toLowerCase().includes(q) ||
|
||||||
|
c.hostname.toLowerCase().includes(q) ||
|
||||||
|
c.tags?.some((t) => t.toLowerCase().includes(q)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Get connections belonging to a specific group. */
|
||||||
|
function connectionsByGroup(groupId: number): Connection[] {
|
||||||
|
const q = searchQuery.value.toLowerCase().trim();
|
||||||
|
const groupConns = connections.value.filter((c) => c.groupId === groupId);
|
||||||
|
if (!q) return groupConns;
|
||||||
|
return groupConns.filter(
|
||||||
|
(c) =>
|
||||||
|
c.name.toLowerCase().includes(q) ||
|
||||||
|
c.hostname.toLowerCase().includes(q) ||
|
||||||
|
c.tags?.some((t) => t.toLowerCase().includes(q)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a group has any matching connections (for search filtering). */
|
||||||
|
function groupHasResults(groupId: number): boolean {
|
||||||
|
return connectionsByGroup(groupId).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load connections from backend (mock for now). */
|
||||||
|
async function loadConnections(): Promise<void> {
|
||||||
|
// TODO: replace with Wails binding — ConnectionService.ListConnections()
|
||||||
|
// connections.value = await ConnectionService.ListConnections();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load groups from backend (mock for now). */
|
||||||
|
async function loadGroups(): Promise<void> {
|
||||||
|
// TODO: replace with Wails binding — ConnectionService.ListGroups()
|
||||||
|
// groups.value = await ConnectionService.ListGroups();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
connections,
|
||||||
|
groups,
|
||||||
|
searchQuery,
|
||||||
|
filteredConnections,
|
||||||
|
connectionsByGroup,
|
||||||
|
groupHasResults,
|
||||||
|
loadConnections,
|
||||||
|
loadGroups,
|
||||||
|
};
|
||||||
|
});
|
||||||
73
frontend/src/stores/session.store.ts
Normal file
73
frontend/src/stores/session.store.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
id: string;
|
||||||
|
connectionId: number;
|
||||||
|
name: string;
|
||||||
|
protocol: "ssh" | "rdp";
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session store.
|
||||||
|
* Manages active sessions and tab order.
|
||||||
|
*
|
||||||
|
* Sessions are populated by the Go SessionManager once plugins are wired up.
|
||||||
|
* For now, mock sessions are used to render the tab bar.
|
||||||
|
*/
|
||||||
|
export const useSessionStore = defineStore("session", () => {
|
||||||
|
const sessions = ref<Session[]>([
|
||||||
|
{ id: "s1", connectionId: 1, name: "Asgard", protocol: "ssh", active: true },
|
||||||
|
{ id: "s2", connectionId: 2, name: "Docker", protocol: "ssh", active: false },
|
||||||
|
{ id: "s3", connectionId: 4, name: "CLT-VMHOST01", protocol: "rdp", active: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const activeSessionId = ref<string | null>("s1");
|
||||||
|
|
||||||
|
const activeSession = computed(() =>
|
||||||
|
sessions.value.find((s) => s.id === activeSessionId.value) ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sessionCount = computed(() => sessions.value.length);
|
||||||
|
|
||||||
|
/** Switch to a session tab. */
|
||||||
|
function activateSession(id: string): void {
|
||||||
|
activeSessionId.value = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close a session tab. */
|
||||||
|
function closeSession(id: string): void {
|
||||||
|
const idx = sessions.value.findIndex((s) => s.id === id);
|
||||||
|
if (idx === -1) return;
|
||||||
|
|
||||||
|
sessions.value.splice(idx, 1);
|
||||||
|
|
||||||
|
// If we closed the active session, activate an adjacent one
|
||||||
|
if (activeSessionId.value === id) {
|
||||||
|
if (sessions.value.length === 0) {
|
||||||
|
activeSessionId.value = null;
|
||||||
|
} else {
|
||||||
|
const nextIdx = Math.min(idx, sessions.value.length - 1);
|
||||||
|
activeSessionId.value = sessions.value[nextIdx].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a new session (placeholder — will be called from connection double-click). */
|
||||||
|
function addSession(connectionId: number, name: string, protocol: "ssh" | "rdp"): void {
|
||||||
|
const id = `s${Date.now()}`;
|
||||||
|
sessions.value.push({ id, connectionId, name, protocol, active: false });
|
||||||
|
activeSessionId.value = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessions,
|
||||||
|
activeSessionId,
|
||||||
|
activeSession,
|
||||||
|
sessionCount,
|
||||||
|
activateSession,
|
||||||
|
closeSession,
|
||||||
|
addSession,
|
||||||
|
};
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user