refactor: Vue 3 state, TypeScript, and lifecycle improvements
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m49s

- connectionsByGroup memoized as computed map — eliminates redundant filter on every render
- DockerPanel: replace any[] with typed DockerContainer/Image/Volume interfaces
- useRdp: replace ReturnType<typeof ref<boolean>> with Ref<boolean>
- SettingsModal: debounce sidebarWidth slider watch (300ms) to prevent rapid IPC calls
- useSftp: export cleanupSession() to prevent sessionPaths memory leak
- StatusBar, CommandPalette: migrate defineEmits to Vue 3.3+ tuple syntax
- SidebarToggle: replace manual v-model (defineProps + defineEmits) with defineModel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-29 16:53:17 -04:00
parent ebd3cee49e
commit 28619bba3f
8 changed files with 53 additions and 30 deletions

View File

@ -116,9 +116,9 @@ const connectionStore = useConnectionStore();
const sessionStore = useSessionStore(); const sessionStore = useSessionStore();
const emit = defineEmits<{ const emit = defineEmits<{
(e: "open-import"): void; "open-import": [];
(e: "open-settings"): void; "open-settings": [];
(e: "open-new-connection", protocol?: "ssh" | "rdp"): void; "open-new-connection": [protocol?: "ssh" | "rdp"];
}>(); }>();
const actions: PaletteAction[] = [ const actions: PaletteAction[] = [

View File

@ -422,9 +422,16 @@ watch(
() => settings.value.defaultProtocol, () => settings.value.defaultProtocol,
(val) => invoke("set_setting", { key: "default_protocol", value: val }).catch(console.error), (val) => invoke("set_setting", { key: "default_protocol", value: val }).catch(console.error),
); );
let sidebarWidthDebounce: ReturnType<typeof setTimeout>;
watch( watch(
() => settings.value.sidebarWidth, () => settings.value.sidebarWidth,
(val) => invoke("set_setting", { key: "sidebar_width", value: String(val) }).catch(console.error), (val) => {
clearTimeout(sidebarWidthDebounce);
sidebarWidthDebounce = setTimeout(
() => invoke("set_setting", { key: "sidebar_width", value: String(val) }).catch(console.error),
300,
);
},
); );
watch( watch(
() => settings.value.terminalTheme, () => settings.value.terminalTheme,

View File

@ -47,7 +47,7 @@ const connectionStore = useConnectionStore();
const activeThemeName = ref("Default"); const activeThemeName = ref("Default");
const emit = defineEmits<{ const emit = defineEmits<{
(e: "open-theme-picker"): void; "open-theme-picker": [];
}>(); }>();
const connectionInfo = computed(() => { const connectionInfo = computed(() => {

View File

@ -5,11 +5,11 @@
:key="tab.id" :key="tab.id"
class="flex-1 py-2 text-xs font-medium text-center transition-colors cursor-pointer" class="flex-1 py-2 text-xs font-medium text-center transition-colors cursor-pointer"
:class=" :class="
modelValue === tab.id model === tab.id
? 'text-[var(--wraith-accent-blue)] border-b-2 border-[var(--wraith-accent-blue)]' ? 'text-[var(--wraith-accent-blue)] border-b-2 border-[var(--wraith-accent-blue)]'
: 'text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-secondary)]' : 'text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-secondary)]'
" "
@click="emit('update:modelValue', tab.id)" @click="model = tab.id"
> >
{{ tab.label }} {{ tab.label }}
</button> </button>
@ -24,11 +24,5 @@ const tabs = [
{ id: "sftp" as const, label: "SFTP" }, { id: "sftp" as const, label: "SFTP" },
]; ];
defineProps<{ const model = defineModel<SidebarTab>();
modelValue: SidebarTab;
}>();
const emit = defineEmits<{
"update:modelValue": [tab: SidebarTab];
}>();
</script> </script>

View File

@ -81,12 +81,16 @@
import { ref, onMounted } from "vue"; import { ref, onMounted } from "vue";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
interface DockerContainer { id: string; name: string; image: string; status: string; ports: string; }
interface DockerImage { repository: string; tag: string; id: string; size: string; }
interface DockerVolume { name: string; driver: string; mountpoint: string; }
const props = defineProps<{ sessionId: string }>(); const props = defineProps<{ sessionId: string }>();
const tab = ref("containers"); const tab = ref("containers");
const containers = ref<any[]>([]); const containers = ref<DockerContainer[]>([]);
const images = ref<any[]>([]); const images = ref<DockerImage[]>([]);
const volumes = ref<any[]>([]); const volumes = ref<DockerVolume[]>([]);
const output = ref(""); const output = ref("");
async function refresh(): Promise<void> { async function refresh(): Promise<void> {

View File

@ -1,4 +1,5 @@
import { ref, onBeforeUnmount } from "vue"; import { ref, onBeforeUnmount } from "vue";
import type { Ref } from "vue";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
/** /**
@ -152,11 +153,11 @@ export function jsKeyToScancode(code: string): number | null {
export interface UseRdpReturn { export interface UseRdpReturn {
/** Whether the RDP session is connected (first frame received) */ /** Whether the RDP session is connected (first frame received) */
connected: ReturnType<typeof ref<boolean>>; connected: Ref<boolean>;
/** Whether keyboard capture is enabled */ /** Whether keyboard capture is enabled */
keyboardGrabbed: ReturnType<typeof ref<boolean>>; keyboardGrabbed: Ref<boolean>;
/** Whether clipboard sync is enabled */ /** Whether clipboard sync is enabled */
clipboardSync: ReturnType<typeof ref<boolean>>; clipboardSync: Ref<boolean>;
/** Fetch the current frame as RGBA ImageData */ /** Fetch the current frame as RGBA ImageData */
fetchFrame: (sessionId: string, width: number, height: number) => Promise<ImageData | null>; fetchFrame: (sessionId: string, width: number, height: number) => Promise<ImageData | null>;
/** Send a mouse event to the backend */ /** Send a mouse event to the backend */

View File

@ -24,6 +24,11 @@ export interface UseSftpReturn {
// Persist the last browsed path per session so switching tabs restores position // Persist the last browsed path per session so switching tabs restores position
const sessionPaths: Record<string, string> = {}; const sessionPaths: Record<string, string> = {};
/** Remove a session's saved path from the module-level cache. Call on session close. */
export function cleanupSession(sessionId: string): void {
delete sessionPaths[sessionId];
}
/** /**
* Composable that manages SFTP file browsing state. * Composable that manages SFTP file browsing state.
* Accepts a reactive session ID ref so it reinitializes on tab switch * Accepts a reactive session ID ref so it reinitializes on tab switch

View File

@ -51,22 +51,33 @@ export const useConnectionStore = defineStore("connection", () => {
); );
}); });
/** Memoized map of groupId → filtered connections. Recomputes only when connections or searchQuery change. */
const connectionsByGroupMap = computed<Record<number, Connection[]>>(() => {
const q = searchQuery.value.toLowerCase().trim();
const map: Record<number, Connection[]> = {};
for (const c of connections.value) {
if (c.groupId === null) continue;
if (q) {
const match =
c.name.toLowerCase().includes(q) ||
c.hostname.toLowerCase().includes(q) ||
c.tags?.some((t) => t.toLowerCase().includes(q));
if (!match) continue;
}
if (!map[c.groupId]) map[c.groupId] = [];
map[c.groupId].push(c);
}
return map;
});
/** Get connections belonging to a specific group. */ /** Get connections belonging to a specific group. */
function connectionsByGroup(groupId: number): Connection[] { function connectionsByGroup(groupId: number): Connection[] {
const q = searchQuery.value.toLowerCase().trim(); return connectionsByGroupMap.value[groupId] ?? [];
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). */ /** Check if a group has any matching connections (for search filtering). */
function groupHasResults(groupId: number): boolean { function groupHasResults(groupId: number): boolean {
return connectionsByGroup(groupId).length > 0; return (connectionsByGroupMap.value[groupId]?.length ?? 0) > 0;
} }
/** Load connections from the Rust backend. */ /** Load connections from the Rust backend. */
@ -101,6 +112,7 @@ export const useConnectionStore = defineStore("connection", () => {
groups, groups,
searchQuery, searchQuery,
filteredConnections, filteredConnections,
connectionsByGroupMap,
connectionsByGroup, connectionsByGroup,
groupHasResults, groupHasResults,
loadConnections, loadConnections,