feat(files): add CodeMirror 6 editor with save, dirty tracking, and 15 language modes

This commit is contained in:
Hibryda 2026-03-10 03:11:32 +01:00
parent 0ffbd93b8b
commit 3bb972fc01
6 changed files with 941 additions and 75 deletions

View file

@ -20,3 +20,7 @@ export function listDirectoryChildren(path: string): Promise<DirEntry[]> {
export function readFileContent(path: string): Promise<FileContent> {
return invoke<FileContent>('read_file_content', { path });
}
export function writeFileContent(path: string, content: string): Promise<void> {
return invoke<void>('write_file_content', { path, content });
}

View file

@ -0,0 +1,330 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, rectangularSelection, crosshairCursor, dropCursor } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching, foldGutter, foldKeymap } from '@codemirror/language';
import { closeBrackets, closeBracketsKeymap, autocompletion, completionKeymap } from '@codemirror/autocomplete';
import { highlightSelectionMatches, searchKeymap } from '@codemirror/search';
interface Props {
content: string;
lang: string;
onchange?: (content: string) => void;
onsave?: () => void;
onblur?: () => void;
}
let { content, lang, onchange, onsave, onblur }: Props = $props();
let container: HTMLDivElement | undefined = $state();
let view: EditorView | undefined = $state();
// Map lang hint to CodeMirror language extension
async function getLangExtension(lang: string) {
switch (lang) {
case 'javascript':
case 'jsx': {
const { javascript } = await import('@codemirror/lang-javascript');
return javascript({ jsx: true });
}
case 'typescript':
case 'tsx': {
const { javascript } = await import('@codemirror/lang-javascript');
return javascript({ jsx: true, typescript: true });
}
case 'html':
case 'svelte': {
const { html } = await import('@codemirror/lang-html');
return html();
}
case 'css':
case 'scss':
case 'less': {
const { css } = await import('@codemirror/lang-css');
return css();
}
case 'json': {
const { json } = await import('@codemirror/lang-json');
return json();
}
case 'markdown': {
const { markdown } = await import('@codemirror/lang-markdown');
return markdown();
}
case 'python': {
const { python } = await import('@codemirror/lang-python');
return python();
}
case 'rust': {
const { rust } = await import('@codemirror/lang-rust');
return rust();
}
case 'xml': {
const { xml } = await import('@codemirror/lang-xml');
return xml();
}
case 'sql': {
const { sql } = await import('@codemirror/lang-sql');
return sql();
}
case 'yaml': {
const { yaml } = await import('@codemirror/lang-yaml');
return yaml();
}
case 'cpp':
case 'c':
case 'h': {
const { cpp } = await import('@codemirror/lang-cpp');
return cpp();
}
case 'java': {
const { java } = await import('@codemirror/lang-java');
return java();
}
case 'php': {
const { php } = await import('@codemirror/lang-php');
return php();
}
case 'go': {
const { go } = await import('@codemirror/lang-go');
return go();
}
default:
return null;
}
}
// Catppuccin Mocha-inspired theme that reads CSS custom properties
const catppuccinTheme = EditorView.theme({
'&': {
backgroundColor: 'var(--ctp-base)',
color: 'var(--ctp-text)',
fontFamily: 'var(--term-font-family, "JetBrains Mono", monospace)',
fontSize: '0.775rem',
},
'.cm-content': {
caretColor: 'var(--ctp-rosewater)',
lineHeight: '1.55',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--ctp-rosewater)',
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: 'color-mix(in srgb, var(--ctp-blue) 25%, transparent)',
},
'.cm-panels': {
backgroundColor: 'var(--ctp-mantle)',
color: 'var(--ctp-text)',
},
'.cm-panels.cm-panels-top': {
borderBottom: '1px solid var(--ctp-surface0)',
},
'.cm-searchMatch': {
backgroundColor: 'color-mix(in srgb, var(--ctp-yellow) 25%, transparent)',
},
'.cm-searchMatch.cm-searchMatch-selected': {
backgroundColor: 'color-mix(in srgb, var(--ctp-peach) 30%, transparent)',
},
'.cm-activeLine': {
backgroundColor: 'color-mix(in srgb, var(--ctp-surface0) 40%, transparent)',
},
'.cm-selectionMatch': {
backgroundColor: 'color-mix(in srgb, var(--ctp-teal) 15%, transparent)',
},
'.cm-matchingBracket, .cm-nonmatchingBracket': {
backgroundColor: 'color-mix(in srgb, var(--ctp-blue) 20%, transparent)',
outline: '1px solid color-mix(in srgb, var(--ctp-blue) 40%, transparent)',
},
'.cm-gutters': {
backgroundColor: 'var(--ctp-mantle)',
color: 'var(--ctp-overlay0)',
border: 'none',
borderRight: '1px solid var(--ctp-surface0)',
},
'.cm-activeLineGutter': {
backgroundColor: 'color-mix(in srgb, var(--ctp-surface0) 40%, transparent)',
color: 'var(--ctp-text)',
},
'.cm-foldPlaceholder': {
backgroundColor: 'var(--ctp-surface0)',
border: 'none',
color: 'var(--ctp-overlay1)',
},
'.cm-tooltip': {
backgroundColor: 'var(--ctp-surface0)',
color: 'var(--ctp-text)',
border: '1px solid var(--ctp-surface1)',
borderRadius: '0.25rem',
},
'.cm-tooltip .cm-tooltip-arrow:before': {
borderTopColor: 'var(--ctp-surface1)',
borderBottomColor: 'var(--ctp-surface1)',
},
'.cm-tooltip .cm-tooltip-arrow:after': {
borderTopColor: 'var(--ctp-surface0)',
borderBottomColor: 'var(--ctp-surface0)',
},
'.cm-tooltip-autocomplete': {
'& > ul > li[aria-selected]': {
backgroundColor: 'color-mix(in srgb, var(--ctp-blue) 20%, transparent)',
color: 'var(--ctp-text)',
},
},
}, { dark: true });
async function createEditor() {
if (!container) return;
const langExt = await getLangExtension(lang);
const extensions = [
lineNumbers(),
highlightActiveLineGutter(),
history(),
foldGutter(),
drawSelection(),
dropCursor(),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
bracketMatching(),
closeBrackets(),
autocompletion(),
rectangularSelection(),
crosshairCursor(),
highlightActiveLine(),
highlightSelectionMatches(),
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...foldKeymap,
...completionKeymap,
indentWithTab,
{ key: 'Mod-s', run: () => { onsave?.(); return true; } },
]),
catppuccinTheme,
EditorView.updateListener.of(update => {
if (update.docChanged) {
onchange?.(update.state.doc.toString());
}
}),
EditorView.domEventHandlers({
blur: () => { onblur?.(); },
}),
EditorView.lineWrapping,
];
if (langExt) extensions.push(langExt);
view = new EditorView({
state: EditorState.create({ doc: content, extensions }),
parent: container,
});
}
onMount(() => {
createEditor();
});
onDestroy(() => {
view?.destroy();
});
// When content prop changes externally (different file loaded), replace editor content
let lastContent = content;
$effect(() => {
if (view && content !== lastContent) {
const currentDoc = view.state.doc.toString();
if (content !== currentDoc) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: content },
});
}
lastContent = content;
}
});
// When lang changes, recreate editor
let lastLang = lang;
$effect(() => {
if (lang !== lastLang && view) {
lastLang = lang;
const currentContent = view.state.doc.toString();
view.destroy();
// Small delay to let DOM settle
queueMicrotask(async () => {
const langExt = await getLangExtension(lang);
const extensions = [
lineNumbers(),
highlightActiveLineGutter(),
history(),
foldGutter(),
drawSelection(),
dropCursor(),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
bracketMatching(),
closeBrackets(),
autocompletion(),
rectangularSelection(),
crosshairCursor(),
highlightActiveLine(),
highlightSelectionMatches(),
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...foldKeymap,
...completionKeymap,
indentWithTab,
{ key: 'Mod-s', run: () => { onsave?.(); return true; } },
]),
catppuccinTheme,
EditorView.updateListener.of(update => {
if (update.docChanged) {
onchange?.(update.state.doc.toString());
}
}),
EditorView.domEventHandlers({
blur: () => { onblur?.(); },
}),
EditorView.lineWrapping,
];
if (langExt) extensions.push(langExt);
view = new EditorView({
state: EditorState.create({ doc: currentContent, extensions }),
parent: container!,
});
});
}
});
export function getContent(): string {
return view?.state.doc.toString() ?? content;
}
</script>
<div class="code-editor" bind:this={container}></div>
<style>
.code-editor {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.code-editor :global(.cm-editor) {
flex: 1;
overflow: hidden;
}
.code-editor :global(.cm-scroller) {
overflow: auto;
}
</style>

View file

@ -1,7 +1,8 @@
<script lang="ts">
import { listDirectoryChildren, readFileContent, type DirEntry, type FileContent } from '../../adapters/files-bridge';
import { getHighlighter, highlightCode, escapeHtml } from '../../utils/highlight';
import { listDirectoryChildren, readFileContent, writeFileContent, type DirEntry, type FileContent } from '../../adapters/files-bridge';
import { getSetting } from '../../adapters/settings-bridge';
import { convertFileSrc } from '@tauri-apps/api/core';
import CodeEditor from './CodeEditor.svelte';
interface Props {
cwd: string;
@ -22,11 +23,12 @@
name: string;
pinned: boolean;
content: FileContent | null;
dirty: boolean;
editContent: string; // current editor content (may differ from saved)
}
let roots = $state<TreeNode[]>([]);
let expandedPaths = $state<Set<string>>(new Set());
let highlighterReady = $state(false);
// Tab state: open file tabs + active tab
let fileTabs = $state<FileTab[]>([]);
@ -38,16 +40,21 @@
let sidebarWidth = $state(14); // rem
let resizing = $state(false);
// Settings
let saveOnBlur = $state(false);
// Derived: active tab's content
let activeTab = $derived(fileTabs.find(t => t.path === activeTabPath) ?? null);
// Load root directory
// Load root directory + settings
$effect(() => {
const dir = cwd;
loadDirectory(dir).then(entries => {
roots = entries.map(e => ({ ...e, depth: 0 }));
});
getHighlighter().then(() => { highlighterReady = true; });
getSetting('files_save_on_blur').then(v => {
saveOnBlur = v === 'true';
});
});
async function loadDirectory(path: string): Promise<DirEntry[]> {
@ -96,6 +103,8 @@
name: node.name,
pinned: false,
content: null,
dirty: false,
editContent: '',
};
if (existing) {
@ -116,7 +125,10 @@
try {
const content = await readFileContent(node.path);
const target = fileTabs.find(t => t.path === node.path);
if (target) target.content = content;
if (target) {
target.content = content;
target.editContent = content.type === 'Text' ? content.content : '';
}
} catch (e) {
const target = fileTabs.find(t => t.path === node.path);
if (target) target.content = { type: 'Binary', message: `Error: ${e}` };
@ -142,6 +154,11 @@
}
function closeTab(path: string) {
const tab = fileTabs.find(t => t.path === path);
if (tab?.dirty) {
// Save before closing if dirty
saveTab(tab);
}
fileTabs = fileTabs.filter(t => t.path !== path);
if (activeTabPath === path) {
activeTabPath = fileTabs[fileTabs.length - 1]?.path ?? null;
@ -184,20 +201,44 @@
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function renderHighlighted(content: string, lang: string): string {
if (!highlighterReady || lang === 'text' || lang === 'csv') {
return `<pre><code>${escapeHtml(content)}</code></pre>`;
}
const highlighted = highlightCode(content, lang);
if (highlighted !== escapeHtml(content)) return highlighted;
return `<pre><code>${escapeHtml(content)}</code></pre>`;
}
function isImageExt(path: string): boolean {
const ext = path.split('.').pop()?.toLowerCase() ?? '';
return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico', 'bmp'].includes(ext);
}
// Editor change handler
function handleEditorChange(tabPath: string, newContent: string) {
const tab = fileTabs.find(t => t.path === tabPath);
if (!tab || tab.content?.type !== 'Text') return;
tab.editContent = newContent;
tab.dirty = newContent !== tab.content.content;
}
// Save a tab to disk
async function saveTab(tab: FileTab) {
if (!tab.dirty || tab.content?.type !== 'Text') return;
try {
await writeFileContent(tab.path, tab.editContent);
// Update the saved content reference
tab.content = { type: 'Text', content: tab.editContent, lang: tab.content.lang };
tab.dirty = false;
} catch (e) {
console.warn('Failed to save file:', e);
}
}
// Save active tab
function saveActiveTab() {
if (activeTab?.dirty) saveTab(activeTab);
}
// Blur handler: save if setting enabled
function handleEditorBlur(tabPath: string) {
if (!saveOnBlur) return;
const tab = fileTabs.find(t => t.path === tabPath);
if (tab?.dirty) saveTab(tab);
}
// Drag-resize sidebar
function startResize(e: MouseEvent) {
e.preventDefault();
@ -279,10 +320,13 @@
class:preview={!tab.pinned}
onclick={() => activeTabPath = tab.path}
ondblclick={() => { tab.pinned = true; }}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activeTabPath = tab.path; } }}
role="tab"
tabindex="0"
>
<span class="file-tab-name" class:italic={!tab.pinned}>{tab.name}</span>
<span class="file-tab-name" class:italic={!tab.pinned}>
{tab.name}{#if tab.dirty}<span class="dirty-dot"></span>{/if}
</span>
<button class="file-tab-close" onclick={(e) => { e.stopPropagation(); closeTab(tab.path); }}>×</button>
</div>
{/each}
@ -308,17 +352,24 @@
<div class="viewer-state">{activeTab.content.message}</div>
{/if}
{:else if activeTab.content?.type === 'Text'}
<div class="viewer-code">
{#if activeTab.content.lang === 'csv'}
<pre class="csv-content"><code>{activeTab.content.content}</code></pre>
{:else}
{@html renderHighlighted(activeTab.content.content, activeTab.content.lang)}
{/if}
</div>
{#key activeTabPath}
<CodeEditor
content={activeTab.editContent}
lang={activeTab.content.lang}
onchange={(c) => handleEditorChange(activeTab!.path, c)}
onsave={saveActiveTab}
onblur={() => handleEditorBlur(activeTab!.path)}
/>
{/key}
{/if}
{#if activeTab}
<div class="viewer-path">{activeTab.path}</div>
<div class="viewer-path">
{activeTab.path}
{#if activeTab.dirty}
<span class="path-dirty">(unsaved)</span>
{/if}
</div>
{/if}
</main>
</div>
@ -517,12 +568,24 @@
.file-tab-name {
overflow: hidden;
text-overflow: ellipsis;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.file-tab-name.italic {
font-style: italic;
}
.dirty-dot {
display: inline-block;
width: 0.375rem;
height: 0.375rem;
border-radius: 50%;
background: var(--ctp-peach);
flex-shrink: 0;
}
.file-tab-close {
display: flex;
align-items: center;
@ -581,57 +644,6 @@
font-size: 0.7rem;
}
.viewer-code {
flex: 1;
overflow: auto;
padding: 0.75rem 1rem;
}
.viewer-code :global(pre) {
margin: 0;
font-family: var(--term-font-family, 'JetBrains Mono', monospace);
font-size: 0.775rem;
line-height: 1.55;
color: var(--ctp-text);
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
}
.viewer-code :global(code) {
font-family: inherit;
background: none;
padding: 0;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
}
.viewer-code :global(.shiki) {
background: transparent !important;
padding: 0;
margin: 0;
border: none;
box-shadow: none;
white-space: pre-wrap !important;
word-wrap: break-word;
overflow-wrap: break-word;
}
.viewer-code :global(.shiki code) {
white-space: pre-wrap !important;
word-wrap: break-word;
overflow-wrap: break-word;
}
.csv-content {
font-family: var(--term-font-family, monospace);
font-size: 0.75rem;
white-space: pre-wrap;
word-wrap: break-word;
tab-size: 4;
}
.viewer-image {
flex: 1;
display: flex;
@ -655,5 +667,13 @@
font-family: var(--term-font-family, monospace);
color: var(--ctp-overlay0);
flex-shrink: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.path-dirty {
color: var(--ctp-peach);
font-size: 0.6rem;
}
</style>