feat(files): add CodeMirror 6 editor with save, dirty tracking, and 15 language modes
This commit is contained in:
parent
0ffbd93b8b
commit
3bb972fc01
6 changed files with 941 additions and 75 deletions
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
330
v2/src/lib/components/Workspace/CodeEditor.svelte
Normal file
330
v2/src/lib/components/Workspace/CodeEditor.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue