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:
parent
ae8127f33d
commit
95c2368ab5
97
frontend/src/components/common/ContextMenu.vue
Normal file
97
frontend/src/components/common/ContextMenu.vue
Normal 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>
|
||||||
292
frontend/src/components/connections/ConnectionEditDialog.vue
Normal file
292
frontend/src/components/connections/ConnectionEditDialog.vue
Normal 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>
|
||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user