generated from nhcarrigan/template
e45a1a1c98
## Summary - Add CodeMirror 6 editor with syntax highlighting for 40+ languages - Add file browser sidebar with collapsible directory tree navigation - Add multi-tab support with dirty state indicators and close buttons - Add keyboard shortcuts (Ctrl+E toggle, Ctrl+B file browser, Ctrl+S save, Ctrl+W close tab) - Add editor toggle button to status bar (disabled when not connected) - Editor automatically uses current session's working directory - Add Tauri backend commands for file operations (list_directory, read_file_content, write_file_content) ## Test Plan - [ ] Connect to a session and verify the editor toggle button becomes enabled - [ ] Press Ctrl+E to open the editor and verify file tree shows the session's CWD - [ ] Navigate directories and open files to verify syntax highlighting works - [ ] Edit a file and verify the dirty indicator (*) appears - [ ] Save with Ctrl+S and verify the dirty indicator disappears - [ ] Open multiple files and verify tab switching works - [ ] Close tabs with Ctrl+W or the X button - [ ] Disconnect and verify the editor automatically closes - [ ] Verify keyboard shortcuts are documented in the shortcuts modal Closes #72 ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #79 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
483 lines
14 KiB
Svelte
483 lines
14 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from "svelte";
|
|
import { EditorView, basicSetup } from "codemirror";
|
|
import { EditorState, Compartment } from "@codemirror/state";
|
|
import { keymap } from "@codemirror/view";
|
|
import { oneDark } from "@codemirror/theme-one-dark";
|
|
import { javascript } from "@codemirror/lang-javascript";
|
|
import { python } from "@codemirror/lang-python";
|
|
import { rust } from "@codemirror/lang-rust";
|
|
import { html } from "@codemirror/lang-html";
|
|
import { css } from "@codemirror/lang-css";
|
|
import { json } from "@codemirror/lang-json";
|
|
import { markdown } from "@codemirror/lang-markdown";
|
|
import { xml } from "@codemirror/lang-xml";
|
|
import { sql } from "@codemirror/lang-sql";
|
|
import { java } from "@codemirror/lang-java";
|
|
import { cpp } from "@codemirror/lang-cpp";
|
|
import { php } from "@codemirror/lang-php";
|
|
import { go } from "@codemirror/lang-go";
|
|
import { yaml } from "@codemirror/lang-yaml";
|
|
import { sass } from "@codemirror/lang-sass";
|
|
import { less } from "@codemirror/lang-less";
|
|
import { vue } from "@codemirror/lang-vue";
|
|
import { angular } from "@codemirror/lang-angular";
|
|
import { wast } from "@codemirror/lang-wast";
|
|
import { StreamLanguage } from "@codemirror/language";
|
|
import { shell } from "@codemirror/legacy-modes/mode/shell";
|
|
import { ruby } from "@codemirror/legacy-modes/mode/ruby";
|
|
import { swift } from "@codemirror/legacy-modes/mode/swift";
|
|
import { lua } from "@codemirror/legacy-modes/mode/lua";
|
|
import { r } from "@codemirror/legacy-modes/mode/r";
|
|
import { toml } from "@codemirror/legacy-modes/mode/toml";
|
|
import { dockerFile } from "@codemirror/legacy-modes/mode/dockerfile";
|
|
import { powerShell } from "@codemirror/legacy-modes/mode/powershell";
|
|
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
|
import { tags } from "@lezer/highlight";
|
|
import { editorStore } from "$lib/stores/editor";
|
|
import { configStore } from "$lib/stores/config";
|
|
import EditorContextMenu from "./EditorContextMenu.svelte";
|
|
import type { EditorTab } from "$lib/types/editor";
|
|
import type { Extension } from "@codemirror/state";
|
|
|
|
export let tab: EditorTab;
|
|
|
|
let editorContainer: HTMLDivElement;
|
|
let view: EditorView | null = null;
|
|
let themeCompartment = new Compartment();
|
|
|
|
// Context menu state
|
|
let contextMenuShow = false;
|
|
let contextMenuX = 0;
|
|
let contextMenuY = 0;
|
|
|
|
function handleContextMenu(event: MouseEvent) {
|
|
event.preventDefault();
|
|
contextMenuShow = true;
|
|
contextMenuX = event.clientX;
|
|
contextMenuY = event.clientY;
|
|
}
|
|
|
|
function closeContextMenu() {
|
|
contextMenuShow = false;
|
|
}
|
|
|
|
// Subscribe to theme changes
|
|
const config = configStore.config;
|
|
|
|
// Light theme
|
|
const lightTheme = EditorView.theme(
|
|
{
|
|
"&": {
|
|
backgroundColor: "#ffffff",
|
|
color: "#24292e",
|
|
},
|
|
".cm-content": {
|
|
caretColor: "#24292e",
|
|
},
|
|
".cm-cursor, .cm-dropCursor": {
|
|
borderLeftColor: "#24292e",
|
|
},
|
|
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
|
|
backgroundColor: "#c8d3f5",
|
|
},
|
|
".cm-panels": {
|
|
backgroundColor: "#f6f8fa",
|
|
color: "#24292e",
|
|
},
|
|
".cm-panels.cm-panels-top": {
|
|
borderBottom: "1px solid #e1e4e8",
|
|
},
|
|
".cm-panels.cm-panels-bottom": {
|
|
borderTop: "1px solid #e1e4e8",
|
|
},
|
|
".cm-searchMatch": {
|
|
backgroundColor: "#ffdf5d",
|
|
outline: "1px solid #c4a000",
|
|
},
|
|
".cm-searchMatch.cm-searchMatch-selected": {
|
|
backgroundColor: "#c4a000",
|
|
},
|
|
".cm-activeLine": {
|
|
backgroundColor: "#f6f8fa",
|
|
},
|
|
".cm-selectionMatch": {
|
|
backgroundColor: "#c8d3f5",
|
|
},
|
|
".cm-matchingBracket, .cm-nonmatchingBracket": {
|
|
backgroundColor: "#c8d3f5",
|
|
outline: "1px solid #888",
|
|
},
|
|
".cm-gutters": {
|
|
backgroundColor: "#f6f8fa",
|
|
color: "#6a737d",
|
|
border: "none",
|
|
borderRight: "1px solid #e1e4e8",
|
|
},
|
|
".cm-activeLineGutter": {
|
|
backgroundColor: "#e1e4e8",
|
|
},
|
|
".cm-foldPlaceholder": {
|
|
backgroundColor: "transparent",
|
|
border: "none",
|
|
color: "#6a737d",
|
|
},
|
|
".cm-tooltip": {
|
|
border: "1px solid #e1e4e8",
|
|
backgroundColor: "#ffffff",
|
|
},
|
|
".cm-tooltip .cm-tooltip-arrow:before": {
|
|
borderTopColor: "transparent",
|
|
borderBottomColor: "transparent",
|
|
},
|
|
".cm-tooltip .cm-tooltip-arrow:after": {
|
|
borderTopColor: "#ffffff",
|
|
borderBottomColor: "#ffffff",
|
|
},
|
|
".cm-tooltip-autocomplete": {
|
|
"& > ul > li[aria-selected]": {
|
|
backgroundColor: "#e1e4e8",
|
|
color: "#24292e",
|
|
},
|
|
},
|
|
},
|
|
{ dark: false }
|
|
);
|
|
|
|
const lightHighlightStyle = HighlightStyle.define([
|
|
{ tag: tags.keyword, color: "#d73a49" },
|
|
{
|
|
tag: [tags.name, tags.deleted, tags.character, tags.propertyName, tags.macroName],
|
|
color: "#6f42c1",
|
|
},
|
|
{ tag: [tags.function(tags.variableName), tags.labelName], color: "#6f42c1" },
|
|
{ tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)], color: "#005cc5" },
|
|
{ tag: [tags.definition(tags.name), tags.separator], color: "#24292e" },
|
|
{
|
|
tag: [
|
|
tags.typeName,
|
|
tags.className,
|
|
tags.number,
|
|
tags.changed,
|
|
tags.annotation,
|
|
tags.modifier,
|
|
tags.self,
|
|
tags.namespace,
|
|
],
|
|
color: "#e36209",
|
|
},
|
|
{
|
|
tag: [
|
|
tags.operator,
|
|
tags.operatorKeyword,
|
|
tags.url,
|
|
tags.escape,
|
|
tags.regexp,
|
|
tags.link,
|
|
tags.special(tags.string),
|
|
],
|
|
color: "#032f62",
|
|
},
|
|
{ tag: [tags.meta, tags.comment], color: "#6a737d" },
|
|
{ tag: tags.strong, fontWeight: "bold" },
|
|
{ tag: tags.emphasis, fontStyle: "italic" },
|
|
{ tag: tags.strikethrough, textDecoration: "line-through" },
|
|
{ tag: tags.link, color: "#032f62", textDecoration: "underline" },
|
|
{ tag: tags.heading, fontWeight: "bold", color: "#005cc5" },
|
|
{ tag: [tags.atom, tags.bool, tags.special(tags.variableName)], color: "#005cc5" },
|
|
{ tag: [tags.processingInstruction, tags.string, tags.inserted], color: "#22863a" },
|
|
{ tag: tags.invalid, color: "#cb2431" },
|
|
]);
|
|
|
|
// High contrast theme
|
|
const highContrastTheme = EditorView.theme(
|
|
{
|
|
"&": {
|
|
backgroundColor: "#000000",
|
|
color: "#ffffff",
|
|
},
|
|
".cm-content": {
|
|
caretColor: "#ffffff",
|
|
},
|
|
".cm-cursor, .cm-dropCursor": {
|
|
borderLeftColor: "#ffffff",
|
|
borderLeftWidth: "2px",
|
|
},
|
|
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
|
|
backgroundColor: "#264f78",
|
|
},
|
|
".cm-panels": {
|
|
backgroundColor: "#000000",
|
|
color: "#ffffff",
|
|
},
|
|
".cm-panels.cm-panels-top": {
|
|
borderBottom: "2px solid #ffffff",
|
|
},
|
|
".cm-panels.cm-panels-bottom": {
|
|
borderTop: "2px solid #ffffff",
|
|
},
|
|
".cm-searchMatch": {
|
|
backgroundColor: "#515c6a",
|
|
outline: "2px solid #ffff00",
|
|
},
|
|
".cm-searchMatch.cm-searchMatch-selected": {
|
|
backgroundColor: "#ffff00",
|
|
color: "#000000",
|
|
},
|
|
".cm-activeLine": {
|
|
backgroundColor: "#1a1a1a",
|
|
},
|
|
".cm-selectionMatch": {
|
|
backgroundColor: "#264f78",
|
|
},
|
|
".cm-matchingBracket, .cm-nonmatchingBracket": {
|
|
backgroundColor: "#515c6a",
|
|
outline: "2px solid #ffff00",
|
|
},
|
|
".cm-gutters": {
|
|
backgroundColor: "#000000",
|
|
color: "#858585",
|
|
border: "none",
|
|
borderRight: "2px solid #ffffff",
|
|
},
|
|
".cm-activeLineGutter": {
|
|
backgroundColor: "#1a1a1a",
|
|
color: "#ffffff",
|
|
},
|
|
".cm-foldPlaceholder": {
|
|
backgroundColor: "transparent",
|
|
border: "none",
|
|
color: "#ffff00",
|
|
},
|
|
".cm-tooltip": {
|
|
border: "2px solid #ffffff",
|
|
backgroundColor: "#000000",
|
|
},
|
|
".cm-tooltip .cm-tooltip-arrow:before": {
|
|
borderTopColor: "transparent",
|
|
borderBottomColor: "transparent",
|
|
},
|
|
".cm-tooltip .cm-tooltip-arrow:after": {
|
|
borderTopColor: "#000000",
|
|
borderBottomColor: "#000000",
|
|
},
|
|
".cm-tooltip-autocomplete": {
|
|
"& > ul > li[aria-selected]": {
|
|
backgroundColor: "#264f78",
|
|
color: "#ffffff",
|
|
},
|
|
},
|
|
},
|
|
{ dark: true }
|
|
);
|
|
|
|
const highContrastHighlightStyle = HighlightStyle.define([
|
|
{ tag: tags.keyword, color: "#569cd6", fontWeight: "bold" },
|
|
{
|
|
tag: [tags.name, tags.deleted, tags.character, tags.propertyName, tags.macroName],
|
|
color: "#9cdcfe",
|
|
},
|
|
{ tag: [tags.function(tags.variableName), tags.labelName], color: "#dcdcaa" },
|
|
{ tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)], color: "#4fc1ff" },
|
|
{ tag: [tags.definition(tags.name), tags.separator], color: "#ffffff" },
|
|
{
|
|
tag: [
|
|
tags.typeName,
|
|
tags.className,
|
|
tags.number,
|
|
tags.changed,
|
|
tags.annotation,
|
|
tags.modifier,
|
|
tags.self,
|
|
tags.namespace,
|
|
],
|
|
color: "#4ec9b0",
|
|
},
|
|
{
|
|
tag: [
|
|
tags.operator,
|
|
tags.operatorKeyword,
|
|
tags.url,
|
|
tags.escape,
|
|
tags.regexp,
|
|
tags.link,
|
|
tags.special(tags.string),
|
|
],
|
|
color: "#d4d4d4",
|
|
},
|
|
{ tag: [tags.meta, tags.comment], color: "#6a9955" },
|
|
{ tag: tags.strong, fontWeight: "bold" },
|
|
{ tag: tags.emphasis, fontStyle: "italic" },
|
|
{ tag: tags.strikethrough, textDecoration: "line-through" },
|
|
{ tag: tags.link, color: "#3794ff", textDecoration: "underline" },
|
|
{ tag: tags.heading, fontWeight: "bold", color: "#569cd6" },
|
|
{ tag: [tags.atom, tags.bool, tags.special(tags.variableName)], color: "#569cd6" },
|
|
{ tag: [tags.processingInstruction, tags.string, tags.inserted], color: "#ce9178" },
|
|
{ tag: tags.invalid, color: "#f44747" },
|
|
]);
|
|
|
|
function getThemeExtension(theme: string): Extension {
|
|
switch (theme) {
|
|
case "light":
|
|
return [lightTheme, syntaxHighlighting(lightHighlightStyle)];
|
|
case "high-contrast":
|
|
return [highContrastTheme, syntaxHighlighting(highContrastHighlightStyle)];
|
|
case "dark":
|
|
case "custom":
|
|
default:
|
|
return oneDark;
|
|
}
|
|
}
|
|
|
|
function getLanguageExtension(language: string): Extension {
|
|
const languageMap: Record<string, () => Extension> = {
|
|
javascript: () => javascript({ jsx: true, typescript: false }),
|
|
typescript: () => javascript({ jsx: true, typescript: true }),
|
|
python: () => python(),
|
|
rust: () => rust(),
|
|
html: () => html(),
|
|
css: () => css(),
|
|
json: () => json(),
|
|
markdown: () => markdown(),
|
|
xml: () => xml(),
|
|
sql: () => sql(),
|
|
java: () => java(),
|
|
c: () => cpp(),
|
|
cpp: () => cpp(),
|
|
csharp: () => cpp(),
|
|
php: () => php(),
|
|
go: () => go(),
|
|
yaml: () => yaml(),
|
|
scss: () => sass(),
|
|
sass: () => sass(),
|
|
less: () => less(),
|
|
vue: () => vue(),
|
|
angular: () => angular(),
|
|
wasm: () => wast(),
|
|
shell: () => StreamLanguage.define(shell),
|
|
ruby: () => StreamLanguage.define(ruby),
|
|
swift: () => StreamLanguage.define(swift),
|
|
lua: () => StreamLanguage.define(lua),
|
|
r: () => StreamLanguage.define(r),
|
|
toml: () => StreamLanguage.define(toml),
|
|
dockerfile: () => StreamLanguage.define(dockerFile),
|
|
powershell: () => StreamLanguage.define(powerShell),
|
|
svelte: () => html(),
|
|
};
|
|
|
|
const getExtension = languageMap[language];
|
|
return getExtension ? getExtension() : [];
|
|
}
|
|
|
|
function createEditor() {
|
|
if (!editorContainer) return;
|
|
|
|
const currentTheme = $config.theme;
|
|
|
|
const saveKeymap = keymap.of([
|
|
{
|
|
key: "Mod-s",
|
|
run: () => {
|
|
editorStore.saveFile(tab.id);
|
|
return true;
|
|
},
|
|
},
|
|
]);
|
|
|
|
const updateListener = EditorView.updateListener.of((update) => {
|
|
if (update.docChanged) {
|
|
const content = update.state.doc.toString();
|
|
editorStore.updateTabContent(tab.id, content);
|
|
}
|
|
});
|
|
|
|
const state = EditorState.create({
|
|
doc: tab.content,
|
|
extensions: [
|
|
basicSetup,
|
|
themeCompartment.of(getThemeExtension(currentTheme)),
|
|
getLanguageExtension(tab.language),
|
|
saveKeymap,
|
|
updateListener,
|
|
EditorView.theme({
|
|
"&": {
|
|
height: "100%",
|
|
fontSize: "14px",
|
|
},
|
|
".cm-scroller": {
|
|
overflow: "auto",
|
|
},
|
|
".cm-content": {
|
|
fontFamily: "'JetBrains Mono', 'Fira Code', 'Consolas', monospace",
|
|
},
|
|
}),
|
|
],
|
|
});
|
|
|
|
view = new EditorView({
|
|
state,
|
|
parent: editorContainer,
|
|
});
|
|
}
|
|
|
|
function destroyEditor() {
|
|
if (view) {
|
|
view.destroy();
|
|
view = null;
|
|
}
|
|
}
|
|
|
|
// Watch for theme changes and update the editor
|
|
$: if (view && $config.theme) {
|
|
view.dispatch({
|
|
effects: themeCompartment.reconfigure(getThemeExtension($config.theme)),
|
|
});
|
|
}
|
|
|
|
onMount(() => {
|
|
createEditor();
|
|
});
|
|
|
|
onDestroy(() => {
|
|
destroyEditor();
|
|
});
|
|
|
|
$: if (view && tab.content !== view.state.doc.toString()) {
|
|
view.dispatch({
|
|
changes: {
|
|
from: 0,
|
|
to: view.state.doc.length,
|
|
insert: tab.content,
|
|
},
|
|
});
|
|
}
|
|
</script>
|
|
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div class="code-editor" bind:this={editorContainer} oncontextmenu={handleContextMenu}></div>
|
|
|
|
{#if contextMenuShow && view}
|
|
<EditorContextMenu
|
|
x={contextMenuX}
|
|
y={contextMenuY}
|
|
editorView={view}
|
|
onClose={closeContextMenu}
|
|
/>
|
|
{/if}
|
|
|
|
<style>
|
|
.code-editor {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
background-color: var(--bg-terminal);
|
|
}
|
|
|
|
.code-editor :global(.cm-editor) {
|
|
height: 100%;
|
|
}
|
|
|
|
.code-editor :global(.cm-focused) {
|
|
outline: none;
|
|
}
|
|
</style>
|