From b91e817f215dfe45d96ed9efcb1bb6d878ff677b Mon Sep 17 00:00:00 2001 From: ChrisLi Date: Fri, 8 May 2026 08:01:28 +0800 Subject: [PATCH 1/2] feat: reorder connections via drag or arrow buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a config_reorder_connections Tauri command (mirroring the existing buttons_reorder pattern) and exposes ▲▼ buttons plus pointer-based drag-and-drop on each row in the Connections dialog. Reordering is optimistic in the UI and reloads from disk if the persist call fails. Co-Authored-By: Claude Opus 4.7 --- src-tauri/src/config.rs | 7 ++ src-tauri/src/lib.rs | 1 + src/components/ConnectionDialog.tsx | 123 +++++++++++++++++++++++++--- src/ipc/api.ts | 3 + src/stores/connections.ts | 30 +++++++ 5 files changed, 151 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 165426c..17ac64f 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -121,3 +121,10 @@ pub async fn config_delete_connection(id: String) -> Result<(), String> { all.retain(|c| c.id != id); save_connections(&all) } + +#[tauri::command] +pub async fn config_reorder_connections(ids: Vec) -> Result<(), String> { + let mut all = load_connections()?; + all.sort_by_key(|c| ids.iter().position(|id| id == &c.id).unwrap_or(usize::MAX)); + save_connections(&all) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index be1edd8..1c8e958 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -49,6 +49,7 @@ pub fn run() { config::config_list_connections, config::config_save_connection, config::config_delete_connection, + config::config_reorder_connections, logger::logger_open_dir, logger::logger_dir_path, logger::logs_list, diff --git a/src/components/ConnectionDialog.tsx b/src/components/ConnectionDialog.tsx index 2d9db4b..7564367 100644 --- a/src/components/ConnectionDialog.tsx +++ b/src/components/ConnectionDialog.tsx @@ -5,7 +5,9 @@ import { connections, defaultLocalShell, deleteConnection, + moveConnection, newConnectionId, + reorderConnections, upsertConnection, } from "../stores/connections"; import type { Connection } from "../ipc/api"; @@ -31,6 +33,42 @@ const empty = (): Connection => ({ export function ConnectionDialog(props: Props) { const [editing, setEditing] = createSignal(null); const [pwPrompt, setPwPrompt] = createSignal<{ conn: Connection; pw: string } | null>(null); + const [draggingId, setDraggingId] = createSignal(null); + /** Connection id under cursor (or "__end__"). Null when not over a slot. */ + const [dropTargetId, setDropTargetId] = createSignal(null); + + /** Pointer-event-based drag, mirroring TabBar.tsx. WebView2's HTML5 drag is + * unreliable, so we hand-roll it with mousedown/move/up. */ + function startDrag(ev: MouseEvent, id: string) { + if (ev.button !== 0) return; + const target = ev.target as HTMLElement; + if (target.closest("button") || target.tagName === "INPUT") return; + const startX = ev.clientX; + const startY = ev.clientY; + let active = false; + const onMove = (e: MouseEvent) => { + if (!active) { + if (Math.hypot(e.clientX - startX, e.clientY - startY) < 4) return; + active = true; + setDraggingId(id); + } + const el = document.elementFromPoint(e.clientX, e.clientY); + const slot = el?.closest("[data-conn-slot]"); + setDropTargetId(slot?.getAttribute("data-conn-slot") ?? null); + }; + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + const target = dropTargetId(); + if (active && target && target !== id) { + reorderConnections(id, target === "__end__" ? null : target); + } + setDraggingId(null); + setDropTargetId(null); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + } function startEdit(c: Connection) { setEditing({ ...c }); @@ -75,22 +113,62 @@ export function ConnectionDialog(props: Props) { fallback={
No saved connections. Click + New.
} > - {(c) => ( -
-
-
{c.name}
-
- {c.kind === "local" - ? `📟 local · ${c.shell ?? defaultLocalShell()}` - : `${c.user}@${c.host}:${c.port}`} + {(c, i) => { + const isDragging = () => draggingId() === c.id; + const isDropTarget = () => dropTargetId() === c.id && draggingId() && draggingId() !== c.id; + return ( +
startDrag(e, c.id)} + style={{ + ...rowStyle, + cursor: isDragging() ? "grabbing" : "grab", + opacity: isDragging() ? 0.4 : 1, + "border-top": isDropTarget() ? `2px solid ${C.accent}` : "2px solid transparent", + }} + > +
+ + +
+
+
{c.name}
+
+ {c.kind === "local" + ? `📟 local · ${c.shell ?? defaultLocalShell()}` + : `${c.user}@${c.host}:${c.port}`} +
+ + +
- - - -
- )} + ); + }} + +
+ @@ -208,6 +286,25 @@ const rowStyle = { "border-radius": "8px", background: C.bg3, "margin-bottom": "6px", + "user-select": "none", +} as const; + +const reorderColStyle = { + display: "flex", + "flex-direction": "column", + gap: "1px", + "margin-right": "4px", +} as const; + +const arrowBtn = { + background: "transparent", + color: C.text2, + border: `1px solid ${C.border}`, + "border-radius": "4px", + padding: "1px 6px", + "font-size": "9px", + "line-height": "1", + cursor: "pointer", } as const; const input = inputStyle; diff --git a/src/ipc/api.ts b/src/ipc/api.ts index 46f33cb..aa91fda 100644 --- a/src/ipc/api.ts +++ b/src/ipc/api.ts @@ -68,6 +68,9 @@ export const api = { deleteConnection: (id: string) => invoke("config_delete_connection", { id }), + reorderConnections: (ids: string[]) => + invoke("config_reorder_connections", { ids }), + // Logging loggerOpenDir: () => invoke("logger_open_dir"), loggerDirPath: () => invoke("logger_dir_path"), diff --git a/src/stores/connections.ts b/src/stores/connections.ts index 1d2d747..4e4d46b 100644 --- a/src/stores/connections.ts +++ b/src/stores/connections.ts @@ -24,6 +24,36 @@ export async function deleteConnection(id: string) { await loadConnections(); } +export async function reorderConnections(sourceId: string, targetId: string | null) { + if (sourceId === targetId) return; + const arr = [...connections()]; + const fromIdx = arr.findIndex((c) => c.id === sourceId); + if (fromIdx < 0) return; + const [moved] = arr.splice(fromIdx, 1); + if (targetId === null) { + arr.push(moved); + } else { + const toIdx = arr.findIndex((c) => c.id === targetId); + if (toIdx < 0) arr.push(moved); + else arr.splice(toIdx, 0, moved); + } + setConnections(arr); + await api.reorderConnections(arr.map((c) => c.id)).catch((e) => { + console.error("reorderConnections persist failed", e); + loadConnections(); + }); +} + +export async function moveConnection(id: string, delta: -1 | 1) { + const arr = connections(); + const idx = arr.findIndex((c) => c.id === id); + if (idx < 0) return; + const newIdx = idx + delta; + if (newIdx < 0 || newIdx >= arr.length) return; + const targetId = delta < 0 ? arr[newIdx].id : (newIdx + 1 < arr.length ? arr[newIdx + 1].id : null); + await reorderConnections(id, targetId); +} + export function newConnectionId(): string { return `conn-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } From 2e46c52adee7b730552a4c0edd43b6fd6eb2ccba Mon Sep 17 00:00:00 2001 From: ChrisLi Date: Fri, 8 May 2026 08:19:33 +0800 Subject: [PATCH 2/2] feat: arrow-button reorder + ESC-to-close on dialogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connections dialog: drop the drag-and-drop affordance and keep only the ▲▼ arrow buttons (simpler UX, no accidental drags). Command Buttons dialog: add the same ▲▼ reorder controls (using the existing buttons_reorder backend command) and ESC-to-close. Co-Authored-By: Claude Opus 4.7 --- src/components/ButtonEditor.tsx | 57 +++++++++++- src/components/ConnectionDialog.tsx | 137 ++++++++++------------------ src/stores/buttons.ts | 15 +++ 3 files changed, 119 insertions(+), 90 deletions(-) diff --git a/src/components/ButtonEditor.tsx b/src/components/ButtonEditor.tsx index b2a3e65..247a0c9 100644 --- a/src/components/ButtonEditor.tsx +++ b/src/components/ButtonEditor.tsx @@ -1,8 +1,9 @@ -import { createSignal, For, Show } from "solid-js"; +import { createSignal, For, onCleanup, onMount, Show } from "solid-js"; import { CloseX } from "./CloseX"; import { buttons, loadButtons, + moveButton, newButtonId, removeButton, saveButton, @@ -30,6 +31,22 @@ export function ButtonEditor(props: Props) { loadButtons(); + // ESC closes the dialog (or backs out of the edit form). + onMount(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + if (editing()) { + e.preventDefault(); + setEditing(null); + } else { + e.preventDefault(); + props.onClose(); + } + }; + document.addEventListener("keydown", onKey); + onCleanup(() => document.removeEventListener("keydown", onKey)); + }); + function startEdit(b: CommandButton) { setEditing({ ...b }); } @@ -57,8 +74,26 @@ export function ButtonEditor(props: Props) { 0} fallback={
No buttons. Click + New.
}> - {(b) => ( + {(b, i) => (
+
+ + +
{b.icon ? `${b.icon} ` : ""} @@ -173,3 +208,21 @@ const btn = { cursor: "pointer", "font-weight": 600, } as const; + +const reorderCol = { + display: "flex", + "flex-direction": "column", + gap: "1px", + "margin-right": "4px", +} as const; + +const arrowBtn = { + background: "transparent", + color: "#cdd6f4", + border: "1px solid #45475a", + "border-radius": "4px", + padding: "1px 6px", + "font-size": "9px", + "line-height": "1", + cursor: "pointer", +} as const; diff --git a/src/components/ConnectionDialog.tsx b/src/components/ConnectionDialog.tsx index 7564367..45f07a1 100644 --- a/src/components/ConnectionDialog.tsx +++ b/src/components/ConnectionDialog.tsx @@ -1,4 +1,4 @@ -import { createSignal, For, Show } from "solid-js"; +import { createSignal, For, onCleanup, onMount, Show } from "solid-js"; import { C, overlayStyle as baseOverlay, dialogStyle as baseDialog, inputStyle, btnPrimary, btnSecondary, btnDanger } from "../theme"; import { CloseX } from "./CloseX"; import { @@ -7,7 +7,6 @@ import { deleteConnection, moveConnection, newConnectionId, - reorderConnections, upsertConnection, } from "../stores/connections"; import type { Connection } from "../ipc/api"; @@ -33,42 +32,26 @@ const empty = (): Connection => ({ export function ConnectionDialog(props: Props) { const [editing, setEditing] = createSignal(null); const [pwPrompt, setPwPrompt] = createSignal<{ conn: Connection; pw: string } | null>(null); - const [draggingId, setDraggingId] = createSignal(null); - /** Connection id under cursor (or "__end__"). Null when not over a slot. */ - const [dropTargetId, setDropTargetId] = createSignal(null); - /** Pointer-event-based drag, mirroring TabBar.tsx. WebView2's HTML5 drag is - * unreliable, so we hand-roll it with mousedown/move/up. */ - function startDrag(ev: MouseEvent, id: string) { - if (ev.button !== 0) return; - const target = ev.target as HTMLElement; - if (target.closest("button") || target.tagName === "INPUT") return; - const startX = ev.clientX; - const startY = ev.clientY; - let active = false; - const onMove = (e: MouseEvent) => { - if (!active) { - if (Math.hypot(e.clientX - startX, e.clientY - startY) < 4) return; - active = true; - setDraggingId(id); + // ESC closes the dialog. When a sub-state (edit form / password prompt) + // is open, ESC backs out of that state first instead of closing outright. + onMount(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + if (editing()) { + e.preventDefault(); + setEditing(null); + } else if (pwPrompt()) { + e.preventDefault(); + setPwPrompt(null); + } else { + e.preventDefault(); + props.onClose(); } - const el = document.elementFromPoint(e.clientX, e.clientY); - const slot = el?.closest("[data-conn-slot]"); - setDropTargetId(slot?.getAttribute("data-conn-slot") ?? null); }; - const onUp = () => { - document.removeEventListener("mousemove", onMove); - document.removeEventListener("mouseup", onUp); - const target = dropTargetId(); - if (active && target && target !== id) { - reorderConnections(id, target === "__end__" ? null : target); - } - setDraggingId(null); - setDropTargetId(null); - }; - document.addEventListener("mousemove", onMove); - document.addEventListener("mouseup", onUp); - } + document.addEventListener("keydown", onKey); + onCleanup(() => document.removeEventListener("keydown", onKey)); + }); function startEdit(c: Connection) { setEditing({ ...c }); @@ -113,62 +96,40 @@ export function ConnectionDialog(props: Props) { fallback={
No saved connections. Click + New.
} > - {(c, i) => { - const isDragging = () => draggingId() === c.id; - const isDropTarget = () => dropTargetId() === c.id && draggingId() && draggingId() !== c.id; - return ( -
startDrag(e, c.id)} - style={{ - ...rowStyle, - cursor: isDragging() ? "grabbing" : "grab", - opacity: isDragging() ? 0.4 : 1, - "border-top": isDropTarget() ? `2px solid ${C.accent}` : "2px solid transparent", - }} - > -
- - -
-
-
{c.name}
-
- {c.kind === "local" - ? `📟 local · ${c.shell ?? defaultLocalShell()}` - : `${c.user}@${c.host}:${c.port}`} -
+ {(c, i) => ( +
+
+ + +
+
+
{c.name}
+
+ {c.kind === "local" + ? `📟 local · ${c.shell ?? defaultLocalShell()}` + : `${c.user}@${c.host}:${c.port}`}
- - -
- ); - }} + + + +
+ )} - -
- diff --git a/src/stores/buttons.ts b/src/stores/buttons.ts index b102ca5..88dbfb7 100644 --- a/src/stores/buttons.ts +++ b/src/stores/buttons.ts @@ -28,6 +28,21 @@ export async function reorderButtons(ids: string[]) { await loadButtons(); } +export async function moveButton(id: string, delta: -1 | 1) { + const arr = [...buttons()]; + const idx = arr.findIndex((b) => b.id === id); + if (idx < 0) return; + const newIdx = idx + delta; + if (newIdx < 0 || newIdx >= arr.length) return; + const [moved] = arr.splice(idx, 1); + arr.splice(newIdx, 0, moved); + setButtons(arr); + await api.buttonsReorder(arr.map((b) => b.id)).catch((e) => { + console.error("moveButton persist failed", e); + loadButtons(); + }); +} + export function newButtonId(): string { return `btn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; }