All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 2m47s
Tauri v2 matches JS keys to Rust parameter names. The frontend was sending connectionId/groupId but Rust expects id. Fixed: - delete_connection: connectionId → id - delete_group: groupId → id - rename_group: groupId → id - create_group: parentId → parent_id Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
260 lines
11 KiB
Vue
260 lines
11 KiB
Vue
<template>
|
|
<div class="py-1">
|
|
<!-- Add Group / Add Host buttons -->
|
|
<div class="flex gap-1 px-3 py-1.5 border-b border-[var(--wraith-border)]">
|
|
<button
|
|
class="flex-1 flex items-center justify-center gap-1 px-2 py-1 text-[10px] font-medium rounded
|
|
bg-[var(--wraith-bg-tertiary)] text-[var(--wraith-text-muted)]
|
|
hover:text-[var(--wraith-text-primary)] hover:bg-[var(--wraith-bg-secondary)] transition-colors"
|
|
title="New Group"
|
|
@click="addGroup"
|
|
>
|
|
<svg viewBox="0 0 16 16" fill="currentColor" class="w-3 h-3"><path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"/></svg>
|
|
Group
|
|
</button>
|
|
<button
|
|
class="flex-1 flex items-center justify-center gap-1 px-2 py-1 text-[10px] font-medium rounded
|
|
bg-[var(--wraith-bg-tertiary)] text-[var(--wraith-text-muted)]
|
|
hover:text-[var(--wraith-text-primary)] hover:bg-[var(--wraith-bg-secondary)] transition-colors"
|
|
title="New Connection"
|
|
@click="editDialog?.openNew()"
|
|
>
|
|
<svg viewBox="0 0 16 16" fill="currentColor" class="w-3 h-3"><path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"/></svg>
|
|
Host
|
|
</button>
|
|
</div>
|
|
|
|
<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)"
|
|
@contextmenu.prevent="showGroupMenu($event, group)"
|
|
>
|
|
<!-- 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"
|
|
@dblclick="handleConnect(conn)"
|
|
@contextmenu.prevent="showConnectionMenu($event, conn)"
|
|
>
|
|
<!-- 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>
|
|
|
|
<!-- Context menu -->
|
|
<ContextMenu ref="contextMenu" />
|
|
|
|
<!-- Edit dialog -->
|
|
<ConnectionEditDialog ref="editDialog" />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref } from "vue";
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import { useConnectionStore, type Connection, type Group } from "@/stores/connection.store";
|
|
import { useSessionStore } from "@/stores/session.store";
|
|
import ContextMenu from "@/components/common/ContextMenu.vue";
|
|
|
|
interface ContextMenuItem {
|
|
label?: string;
|
|
icon?: string;
|
|
action?: () => void;
|
|
separator?: boolean;
|
|
disabled?: boolean;
|
|
danger?: boolean;
|
|
}
|
|
import ConnectionEditDialog from "@/components/connections/ConnectionEditDialog.vue";
|
|
|
|
const connectionStore = useConnectionStore();
|
|
const sessionStore = useSessionStore();
|
|
|
|
const contextMenu = ref<InstanceType<typeof ContextMenu> | null>(null);
|
|
const editDialog = ref<InstanceType<typeof ConnectionEditDialog> | null>(null);
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
/** Add a new group. */
|
|
async function addGroup(): Promise<void> {
|
|
const name = prompt("New group name:");
|
|
if (!name) return;
|
|
try {
|
|
await invoke("create_group", { name, parent_id: null });
|
|
await connectionStore.loadGroups();
|
|
} catch (err) {
|
|
console.error("Failed to create group:", err);
|
|
}
|
|
}
|
|
|
|
/** Double-click a connection to open a new session. */
|
|
function handleConnect(conn: Connection): void {
|
|
sessionStore.connect(conn.id);
|
|
}
|
|
|
|
/** Show context menu for a connection. */
|
|
function showConnectionMenu(event: MouseEvent, conn: Connection): void {
|
|
const items: ContextMenuItem[] = [
|
|
{
|
|
label: "Connect",
|
|
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-3.5 h-3.5"><path d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 13.25 15H2.75A1.75 1.75 0 0 1 1 13.25Zm5 1.22v8.06l4.97-4.03L6 3.97Z"/></svg>`,
|
|
action: () => handleConnect(conn),
|
|
},
|
|
{ separator: true },
|
|
{
|
|
label: "Edit",
|
|
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-3.5 h-3.5"><path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.25.25 0 0 0-.064.108l-.558 1.953 1.953-.558a.249.249 0 0 0 .108-.064Zm1.238-3.763a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354Z"/></svg>`,
|
|
action: () => editDialog.value?.openEdit(conn),
|
|
},
|
|
{
|
|
label: "Duplicate",
|
|
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-3.5 h-3.5"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25ZM5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/></svg>`,
|
|
action: () => duplicateConnection(conn),
|
|
},
|
|
{ separator: true },
|
|
{
|
|
label: "Delete",
|
|
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-3.5 h-3.5"><path d="M11 1.75V3h2.25a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75ZM4.496 6.675l.66 6.6a.25.25 0 0 0 .249.225h5.19a.25.25 0 0 0 .249-.225l.66-6.6a.75.75 0 0 1 1.492.149l-.66 6.6A1.748 1.748 0 0 1 10.595 15h-5.19a1.75 1.75 0 0 1-1.741-1.575l-.66-6.6a.75.75 0 1 1 1.492-.15ZM6.5 1.75V3h3V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25Z"/></svg>`,
|
|
danger: true,
|
|
action: () => deleteConnection(conn),
|
|
},
|
|
];
|
|
|
|
contextMenu.value?.open(event, items);
|
|
}
|
|
|
|
/** Show context menu for a group. */
|
|
function showGroupMenu(event: MouseEvent, group: Group): void {
|
|
const items: ContextMenuItem[] = [
|
|
{
|
|
label: "New Connection",
|
|
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-3.5 h-3.5"><path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"/></svg>`,
|
|
action: () => editDialog.value?.openNew(group.id),
|
|
},
|
|
{ separator: true },
|
|
{
|
|
label: "Rename",
|
|
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-3.5 h-3.5"><path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.25.25 0 0 0-.064.108l-.558 1.953 1.953-.558a.249.249 0 0 0 .108-.064Zm1.238-3.763a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354Z"/></svg>`,
|
|
action: async () => {
|
|
const newName = prompt("Rename group:", group.name);
|
|
if (newName && newName !== group.name) {
|
|
try {
|
|
await invoke("rename_group", { id: group.id, name: newName });
|
|
await connectionStore.loadGroups();
|
|
} catch (err) {
|
|
console.error("Failed to rename group:", err);
|
|
}
|
|
}
|
|
},
|
|
},
|
|
{
|
|
label: "Delete",
|
|
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-3.5 h-3.5"><path d="M11 1.75V3h2.25a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75ZM4.496 6.675l.66 6.6a.25.25 0 0 0 .249.225h5.19a.25.25 0 0 0 .249-.225l.66-6.6a.75.75 0 0 1 1.492.149l-.66 6.6A1.748 1.748 0 0 1 10.595 15h-5.19a1.75 1.75 0 0 1-1.741-1.575l-.66-6.6a.75.75 0 1 1 1.492-.15ZM6.5 1.75V3h3V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25Z"/></svg>`,
|
|
danger: true,
|
|
action: () => deleteGroup(group),
|
|
},
|
|
];
|
|
|
|
contextMenu.value?.open(event, items);
|
|
}
|
|
|
|
/** Duplicate a connection via the Rust backend. */
|
|
async function duplicateConnection(conn: Connection): Promise<void> {
|
|
try {
|
|
await invoke("create_connection", {
|
|
name: `${conn.name} (copy)`,
|
|
hostname: conn.hostname,
|
|
port: conn.port,
|
|
protocol: conn.protocol,
|
|
groupId: conn.groupId,
|
|
credentialId: conn.credentialId ?? null,
|
|
color: conn.color ?? "",
|
|
tags: conn.tags ?? [],
|
|
notes: conn.notes ?? "",
|
|
options: conn.options ?? "{}",
|
|
});
|
|
await connectionStore.loadConnections();
|
|
} catch (err) {
|
|
console.error("Failed to duplicate connection:", err);
|
|
}
|
|
}
|
|
|
|
/** Delete a connection via the Rust backend after confirmation. */
|
|
async function deleteConnection(conn: Connection): Promise<void> {
|
|
if (!confirm(`Delete "${conn.name}"?`)) return;
|
|
try {
|
|
await invoke("delete_connection", { id: conn.id });
|
|
await connectionStore.loadConnections();
|
|
} catch (err) {
|
|
console.error("Failed to delete connection:", err);
|
|
}
|
|
}
|
|
|
|
/** Delete a group via the Rust backend after confirmation. */
|
|
async function deleteGroup(group: Group): Promise<void> {
|
|
if (!confirm(`Delete group "${group.name}" and all its connections?`)) return;
|
|
try {
|
|
await invoke("delete_group", { id: group.id });
|
|
await connectionStore.loadAll();
|
|
} catch (err) {
|
|
console.error("Failed to delete group:", err);
|
|
}
|
|
}
|
|
</script>
|