wraith/frontend/src/components/editor/EditorWindow.vue
Vantz Stockwell b46c20b0d0
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 1m4s
feat: wire all remaining stubs — settings, SFTP, RDP, credentials, FreeRDP callbacks
Four-agent parallel deployment:

1. Settings persistence — all 5 settings wired to SettingsService.Set/Get,
   theme picker persists, update check calls real UpdateService, external
   links use Browser.OpenURL, SFTP file open/save calls real service,
   Quick Connect creates real connection + session, exit uses Wails quit

2. SSH key management — credential dropdown in ConnectionEditDialog,
   collapsible "Add New Credential" panel with password/SSH key modes,
   CredentialService proxied through WraithApp (vault-locked guard),
   new CreateSSHKeyCredential method for atomic key+credential creation

3. RDP frontend wiring — useRdp.ts calls real RDPGetFrame/SendMouse/
   SendKey/SendClipboard via Wails bindings, ConnectRDP on WraithApp
   resolves credentials and builds RDPConfig, session store handles
   RDP protocol, frame pipeline uses polling at 30fps

4. FreeRDP3 callback registration — PostConnect and BitmapUpdate callbacks
   via syscall.NewCallback, GDI mode for automatic frame decoding,
   freerdp_context_new() call added, settings/input/context pointers
   extracted from struct offsets, BGRA→RGBA channel swap in frame copy,
   event loop fixed to pass context not instance

11 files changed. Zero build errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 11:25:03 -04:00

235 lines
6.9 KiB
Vue

<template>
<div class="flex flex-col border-b border-[var(--wraith-border)] bg-[var(--wraith-bg-secondary)]" :style="{ height: editorHeight + 'px' }">
<!-- Editor toolbar -->
<div class="flex items-center justify-between px-3 py-1.5 border-b border-[var(--wraith-border)] shrink-0">
<div class="flex items-center gap-2 text-xs min-w-0">
<!-- File icon -->
<svg class="w-3.5 h-3.5 text-[var(--wraith-text-muted)] shrink-0" viewBox="0 0 16 16" fill="currentColor">
<path d="M3.75 1.5a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25V6H9.75A1.75 1.75 0 0 1 8 4.25V1.5H3.75zm5.75.56v2.19c0 .138.112.25.25.25h2.19L9.5 2.06zM2 1.75C2 .784 2.784 0 3.75 0h5.086c.464 0 .909.184 1.237.513l3.414 3.414c.329.328.513.773.513 1.237v8.086A1.75 1.75 0 0 1 12.25 15h-8.5A1.75 1.75 0 0 1 2 13.25V1.75z" />
</svg>
<span class="text-[var(--wraith-text-primary)] truncate font-mono text-[11px]">
{{ filePath }}
</span>
<!-- Unsaved indicator -->
<span v-if="hasUnsavedChanges" class="w-2 h-2 rounded-full bg-[var(--wraith-accent-yellow)] shrink-0" title="Unsaved changes" />
</div>
<div class="flex items-center gap-2 shrink-0">
<!-- Save button -->
<button
class="px-3 py-1 text-xs rounded transition-colors cursor-pointer"
:class="
hasUnsavedChanges
? 'bg-[var(--wraith-accent-blue)] text-white hover:bg-blue-600'
: 'bg-[var(--wraith-bg-tertiary)] text-[var(--wraith-text-muted)] cursor-not-allowed'
"
:disabled="!hasUnsavedChanges"
@click="handleSave"
>
Save
</button>
<!-- Close button -->
<button
class="text-[var(--wraith-text-muted)] hover:text-[var(--wraith-accent-red)] transition-colors cursor-pointer"
@click="emit('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 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06z" />
</svg>
</button>
</div>
</div>
<!-- CodeMirror container -->
<div ref="editorRef" class="flex-1 min-h-0 overflow-hidden" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from "vue";
import { Call } from "@wailsio/runtime";
const SFTP = "github.com/vstockwell/wraith/internal/sftp.SFTPService";
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightSpecialChars } from "@codemirror/view";
import { EditorState } from "@codemirror/state";
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import { syntaxHighlighting, defaultHighlightStyle, bracketMatching } from "@codemirror/language";
import { oneDark } from "@codemirror/theme-one-dark";
import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete";
const props = defineProps<{
content: string;
filePath: string;
sessionId: string;
}>();
const emit = defineEmits<{
close: [];
}>();
const editorRef = ref<HTMLElement | null>(null);
const hasUnsavedChanges = ref(false);
const editorHeight = ref(300);
let view: EditorView | null = null;
/** Detect language extension from file path. */
async function getLanguageExtension(path: string) {
const ext = path.split(".").pop()?.toLowerCase() ?? "";
switch (ext) {
case "js":
case "jsx":
case "ts":
case "tsx": {
const { javascript } = await import("@codemirror/lang-javascript");
return javascript({ jsx: ext.includes("x"), typescript: ext.startsWith("t") });
}
case "json": {
const { json } = await import("@codemirror/lang-json");
return json();
}
case "py": {
const { python } = await import("@codemirror/lang-python");
return python();
}
case "md":
case "markdown": {
const { markdown } = await import("@codemirror/lang-markdown");
return markdown();
}
default:
return null;
}
}
onMounted(async () => {
if (!editorRef.value) return;
const langExt = await getLanguageExtension(props.filePath);
const extensions = [
lineNumbers(),
highlightActiveLine(),
highlightSpecialChars(),
history(),
bracketMatching(),
closeBrackets(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
oneDark,
keymap.of([
...defaultKeymap,
...historyKeymap,
...closeBracketsKeymap,
]),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
hasUnsavedChanges.value = true;
}
}),
EditorView.theme({
"&": {
height: "100%",
fontSize: "13px",
},
".cm-scroller": {
overflow: "auto",
fontFamily: "'JetBrains Mono', 'Cascadia Code', 'Fira Code', Menlo, Monaco, monospace",
},
}),
];
if (langExt) {
extensions.push(langExt);
}
const state = EditorState.create({
doc: props.content,
extensions,
});
view = new EditorView({
state,
parent: editorRef.value,
});
});
onBeforeUnmount(() => {
if (view) {
view.destroy();
view = null;
}
});
// Re-create editor when content/filePath changes
watch(
() => props.filePath,
async () => {
if (!view || !editorRef.value) return;
hasUnsavedChanges.value = false;
const langExt = await getLanguageExtension(props.filePath);
const extensions = [
lineNumbers(),
highlightActiveLine(),
highlightSpecialChars(),
history(),
bracketMatching(),
closeBrackets(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
oneDark,
keymap.of([
...defaultKeymap,
...historyKeymap,
...closeBracketsKeymap,
]),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
hasUnsavedChanges.value = true;
}
}),
EditorView.theme({
"&": {
height: "100%",
fontSize: "13px",
},
".cm-scroller": {
overflow: "auto",
fontFamily: "'JetBrains Mono', 'Cascadia Code', 'Fira Code', Menlo, Monaco, monospace",
},
}),
];
if (langExt) {
extensions.push(langExt);
}
view.destroy();
const state = EditorState.create({
doc: props.content,
extensions,
});
view = new EditorView({
state,
parent: editorRef.value,
});
},
);
async function handleSave(): Promise<void> {
if (!view || !hasUnsavedChanges.value) return;
const currentContent = view.state.doc.toString();
try {
await Call.ByName(`${SFTP}.WriteFile`, props.sessionId, props.filePath, currentContent);
hasUnsavedChanges.value = false;
} catch (err) {
console.error("Failed to save file:", err);
}
}
</script>