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:
Vantz Stockwell 2026-03-17 06:32:17 -04:00
parent d67e183d72
commit d57cd6cfbb
8 changed files with 485 additions and 0 deletions

View 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)]">&middot;</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&times;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>

View 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>

View 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)"
>
&times;
</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>

View 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>

View 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>

View 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()"
>
&#x1f512;
</button>
<button
class="hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
title="Settings"
>
&#x2699;
</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>

View 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,
};
});

View 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,
};
});