feat(ui): add connection context menu with edit/delete/duplicate

Right-click connections for Connect, Edit, Duplicate, Delete.
Right-click groups for New Connection, Rename, Delete.
ConnectionEditDialog provides a full form for creating/editing
connections with protocol toggle, group selector, color labels,
and tags. Generic ContextMenu component positions at cursor with
viewport edge detection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-17 07:27:45 -04:00
parent ae8127f33d
commit 95c2368ab5
3 changed files with 495 additions and 1 deletions

View File

@ -0,0 +1,97 @@
<template>
<Teleport to="body">
<div
v-if="visible"
class="fixed inset-0 z-50"
@click="close"
@contextmenu.prevent="close"
>
<div
ref="menuRef"
class="fixed bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl py-1 min-w-[160px] overflow-hidden"
:style="{ left: position.x + 'px', top: position.y + 'px' }"
@click.stop
>
<template v-for="(item, idx) in items" :key="idx">
<!-- Separator -->
<div v-if="item.separator" class="my-1 border-t border-[#30363d]" />
<!-- Menu item -->
<button
v-else
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-xs text-left transition-colors cursor-pointer"
:class="item.danger
? 'text-[var(--wraith-accent-red)] hover:bg-[var(--wraith-accent-red)]/10'
: 'text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)]'
"
@click="handleClick(item)"
>
<span v-if="item.icon" class="w-4 h-4 flex items-center justify-center shrink-0" v-html="item.icon" />
<span class="flex-1">{{ item.label }}</span>
<span v-if="item.shortcut" class="text-[10px] text-[var(--wraith-text-muted)]">{{ item.shortcut }}</span>
</button>
</template>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, nextTick } from "vue";
export interface ContextMenuItem {
label?: string;
icon?: string;
shortcut?: string;
danger?: boolean;
separator?: boolean;
action?: () => void;
}
const visible = ref(false);
const position = ref({ x: 0, y: 0 });
const items = ref<ContextMenuItem[]>([]);
const menuRef = ref<HTMLDivElement | null>(null);
function open(event: MouseEvent, menuItems: ContextMenuItem[]): void {
items.value = menuItems;
visible.value = true;
// Position at cursor, adjusting if near viewport edges
nextTick(() => {
const menu = menuRef.value;
if (!menu) {
position.value = { x: event.clientX, y: event.clientY };
return;
}
let x = event.clientX;
let y = event.clientY;
const menuWidth = menu.offsetWidth;
const menuHeight = menu.offsetHeight;
if (x + menuWidth > window.innerWidth) {
x = window.innerWidth - menuWidth - 4;
}
if (y + menuHeight > window.innerHeight) {
y = window.innerHeight - menuHeight - 4;
}
position.value = { x, y };
});
}
function close(): void {
visible.value = false;
}
function handleClick(item: ContextMenuItem): void {
if (item.action) {
item.action();
}
close();
}
defineExpose({ open, close, visible });
</script>

View File

@ -0,0 +1,292 @@
<template>
<Teleport to="body">
<div
v-if="visible"
class="fixed inset-0 z-50 flex items-center justify-center"
@click.self="close"
@keydown.esc="close"
>
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/50" @click="close" />
<!-- Dialog -->
<div class="relative w-full max-w-md bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between px-4 py-3 border-b border-[#30363d]">
<h3 class="text-sm font-semibold text-[var(--wraith-text-primary)]">
{{ isEditing ? "Edit Connection" : "New Connection" }}
</h3>
<button
class="text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@click="close"
>
<svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
<path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.749.749 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.749.749 0 1 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z" />
</svg>
</button>
</div>
<!-- Body -->
<div class="px-4 py-4 space-y-3 max-h-[60vh] overflow-y-auto">
<!-- Name -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Name</label>
<input
v-model="form.name"
type="text"
placeholder="My Server"
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
<!-- Hostname & Port -->
<div class="flex gap-3">
<div class="flex-1">
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Hostname</label>
<input
v-model="form.hostname"
type="text"
placeholder="192.168.1.1"
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
<div class="w-24">
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Port</label>
<input
v-model.number="form.port"
type="number"
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
</div>
<!-- Protocol -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Protocol</label>
<div class="flex gap-2">
<button
class="flex-1 py-2 text-sm rounded border transition-colors cursor-pointer"
:class="form.protocol === 'ssh'
? 'bg-[#3fb950]/10 border-[#3fb950] text-[#3fb950]'
: 'bg-[#0d1117] border-[#30363d] text-[var(--wraith-text-muted)] hover:border-[var(--wraith-text-secondary)]'
"
@click="setProtocol('ssh')"
>
SSH
</button>
<button
class="flex-1 py-2 text-sm rounded border transition-colors cursor-pointer"
:class="form.protocol === 'rdp'
? 'bg-[#1f6feb]/10 border-[#1f6feb] text-[#1f6feb]'
: 'bg-[#0d1117] border-[#30363d] text-[var(--wraith-text-muted)] hover:border-[var(--wraith-text-secondary)]'
"
@click="setProtocol('rdp')"
>
RDP
</button>
</div>
</div>
<!-- Group -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Group</label>
<select
v-model="form.groupId"
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors cursor-pointer"
>
<option :value="null">No Group</option>
<option v-for="group in connectionStore.groups" :key="group.id" :value="group.id">
{{ group.name }}
</option>
</select>
</div>
<!-- Tags -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Tags (comma-separated)</label>
<input
v-model="tagsInput"
type="text"
placeholder="Prod, Linux, Web"
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
<!-- Color -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Color Label</label>
<div class="flex gap-2">
<button
v-for="color in colorOptions"
:key="color.value"
class="w-6 h-6 rounded-full border-2 transition-transform cursor-pointer hover:scale-110"
:class="form.color === color.value ? 'border-white scale-110' : 'border-transparent'"
:style="{ backgroundColor: color.hex }"
:title="color.label"
@click="form.color = color.value"
/>
</div>
</div>
<!-- Notes -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Notes</label>
<textarea
v-model="form.notes"
rows="3"
placeholder="Optional notes about this connection..."
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors resize-none"
/>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-2 px-4 py-3 border-t border-[#30363d]">
<button
class="px-3 py-1.5 text-xs text-[var(--wraith-text-secondary)] hover:text-[var(--wraith-text-primary)] rounded border border-[#30363d] hover:bg-[#30363d] transition-colors cursor-pointer"
@click="close"
>
Cancel
</button>
<button
class="px-3 py-1.5 text-xs text-white bg-[#238636] hover:bg-[#2ea043] rounded transition-colors cursor-pointer"
:class="{ 'opacity-50 cursor-not-allowed': !isValid }"
:disabled="!isValid"
@click="save"
>
{{ isEditing ? "Save Changes" : "Create" }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import { useConnectionStore, type Connection } from "@/stores/connection.store";
interface ConnectionForm {
name: string;
hostname: string;
port: number;
protocol: "ssh" | "rdp";
groupId: number | null;
color: string;
notes: string;
}
const connectionStore = useConnectionStore();
const visible = ref(false);
const isEditing = ref(false);
const editingId = ref<number | null>(null);
const tagsInput = ref("");
const form = ref<ConnectionForm>({
name: "",
hostname: "",
port: 22,
protocol: "ssh",
groupId: null,
color: "",
notes: "",
});
const colorOptions = [
{ value: "", label: "None", hex: "#30363d" },
{ value: "red", label: "Red", hex: "#f85149" },
{ value: "orange", label: "Orange", hex: "#d29922" },
{ value: "green", label: "Green", hex: "#3fb950" },
{ value: "blue", label: "Blue", hex: "#58a6ff" },
{ value: "purple", label: "Purple", hex: "#bc8cff" },
{ value: "pink", label: "Pink", hex: "#f778ba" },
];
const isValid = computed(() => {
return form.value.name.trim() !== "" && form.value.hostname.trim() !== "" && form.value.port > 0;
});
function setProtocol(protocol: "ssh" | "rdp"): void {
form.value.protocol = protocol;
if (protocol === "ssh" && form.value.port === 3389) {
form.value.port = 22;
} else if (protocol === "rdp" && form.value.port === 22) {
form.value.port = 3389;
}
}
function openNew(groupId?: number): void {
isEditing.value = false;
editingId.value = null;
form.value = {
name: "",
hostname: "",
port: 22,
protocol: "ssh",
groupId: groupId ?? null,
color: "",
notes: "",
};
tagsInput.value = "";
visible.value = true;
}
function openEdit(conn: Connection): void {
isEditing.value = true;
editingId.value = conn.id;
form.value = {
name: conn.name,
hostname: conn.hostname,
port: conn.port,
protocol: conn.protocol,
groupId: conn.groupId,
color: "",
notes: "",
};
tagsInput.value = conn.tags?.join(", ") ?? "";
visible.value = true;
}
function close(): void {
visible.value = false;
}
function save(): void {
if (!isValid.value) return;
const tags = tagsInput.value
.split(",")
.map((t) => t.trim())
.filter((t) => t.length > 0);
if (isEditing.value && editingId.value !== null) {
// TODO: Replace with Wails binding ConnectionService.UpdateConnection(id, input)
const conn = connectionStore.connections.find((c) => c.id === editingId.value);
if (conn) {
conn.name = form.value.name;
conn.hostname = form.value.hostname;
conn.port = form.value.port;
conn.protocol = form.value.protocol;
conn.groupId = form.value.groupId ?? conn.groupId;
conn.tags = tags;
}
} else {
// TODO: Replace with Wails binding ConnectionService.CreateConnection(input)
const newId = Math.max(...connectionStore.connections.map((c) => c.id), 0) + 1;
connectionStore.connections.push({
id: newId,
name: form.value.name,
hostname: form.value.hostname,
port: form.value.port,
protocol: form.value.protocol,
groupId: form.value.groupId ?? 1,
tags,
});
}
close();
}
defineExpose({ openNew, openEdit, close, visible });
</script>

View File

@ -7,6 +7,7 @@
<button <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" 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)" @click="toggleGroup(group.id)"
@contextmenu.prevent="showGroupMenu($event, group)"
> >
<!-- Chevron --> <!-- Chevron -->
<svg <svg
@ -42,6 +43,7 @@
:key="conn.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" 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)" @dblclick="handleConnect(conn)"
@contextmenu.prevent="showConnectionMenu($event, conn)"
> >
<!-- Protocol dot --> <!-- Protocol dot -->
<span <span
@ -60,17 +62,29 @@
</div> </div>
</div> </div>
</template> </template>
<!-- Context menu -->
<ContextMenu ref="contextMenu" />
<!-- Edit dialog -->
<ConnectionEditDialog ref="editDialog" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { ref } from "vue";
import { useConnectionStore, type Connection } from "@/stores/connection.store"; import { useConnectionStore, type Connection, type Group } from "@/stores/connection.store";
import { useSessionStore } from "@/stores/session.store"; import { useSessionStore } from "@/stores/session.store";
import ContextMenu from "@/components/common/ContextMenu.vue";
import type { ContextMenuItem } from "@/components/common/ContextMenu.vue";
import ConnectionEditDialog from "@/components/connections/ConnectionEditDialog.vue";
const connectionStore = useConnectionStore(); const connectionStore = useConnectionStore();
const sessionStore = useSessionStore(); const sessionStore = useSessionStore();
const contextMenu = ref<InstanceType<typeof ContextMenu> | null>(null);
const editDialog = ref<InstanceType<typeof ConnectionEditDialog> | null>(null);
// All groups expanded by default // All groups expanded by default
const expandedGroups = ref<Set<number>>( const expandedGroups = ref<Set<number>>(
new Set(connectionStore.groups.map((g) => g.id)), new Set(connectionStore.groups.map((g) => g.id)),
@ -88,4 +102,95 @@ function toggleGroup(groupId: number): void {
function handleConnect(conn: Connection): void { function handleConnect(conn: Connection): void {
sessionStore.connect(conn.id); 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: () => {
// TODO: Replace with Wails binding ConnectionService.RenameGroup(id, name)
const newName = prompt("Rename group:", group.name);
if (newName && newName !== group.name) {
group.name = newName;
}
},
},
{
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 with a new name. */
function duplicateConnection(conn: Connection): void {
// TODO: Replace with Wails binding ConnectionService.CreateConnection(input)
const newId = Math.max(...connectionStore.connections.map((c) => c.id), 0) + 1;
connectionStore.connections.push({
...conn,
id: newId,
name: `${conn.name} (copy)`,
});
}
/** Delete a connection after confirmation. */
function deleteConnection(conn: Connection): void {
// TODO: Replace with Wails binding ConnectionService.DeleteConnection(id)
const idx = connectionStore.connections.findIndex((c) => c.id === conn.id);
if (idx !== -1) {
connectionStore.connections.splice(idx, 1);
}
}
/** Delete a group after confirmation. */
function deleteGroup(group: Group): void {
// TODO: Replace with Wails binding ConnectionService.DeleteGroup(id)
const idx = connectionStore.groups.findIndex((g) => g.id === group.id);
if (idx !== -1) {
connectionStore.groups.splice(idx, 1);
}
}
</script> </script>