feat: implement file editing capabilities with CodeMirror integration

- Added CodeMirror editor for file content editing in FilesView.
- Introduced draft saving functionality with unsaved changes confirmation dialog.
- Implemented file writing API to persist changes to the filesystem.
- Enhanced language support for syntax highlighting based on file extensions.
- Updated UI components to support new editing features, including save and discard options.
- Refactored line selection logic for improved user experience on both desktop and mobile.
- Added new utility functions for language detection by file extension.
- Introduced a new theme for CodeMirror to align with the application's design.
This commit is contained in:
Bohdan Triapitsyn
2026-01-18 17:29:31 +02:00
parent 934df2b7e6
commit 24969ba856
11 changed files with 1151 additions and 189 deletions
@@ -0,0 +1,193 @@
import type { Extension } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { tags as t } from '@lezer/highlight';
import type { Theme } from '@/types/theme';
export function createFlexokiCodeMirrorTheme(theme: Theme): Extension {
const isDark = theme.metadata.variant === 'dark';
const monoFont = theme.config?.fonts?.mono || 'monospace';
const highlights = theme.colors.syntax.highlights || {};
const tokens = theme.colors.syntax.tokens || {};
const ui = EditorView.theme({
'&': {
backgroundColor: 'var(--background)',
color: theme.colors.syntax.base.foreground,
fontSize: 'var(--text-code)',
lineHeight: '1.5rem',
},
'.cm-scroller': {
fontFamily: monoFont,
backgroundColor: 'var(--background)',
},
/* StreamLanguage/legacy-modes tokens (class-based) */
'.cm-comment': {
color: theme.colors.syntax.base.comment,
},
'.cm-keyword': {
color: theme.colors.syntax.base.keyword,
},
'.cm-string': {
color: theme.colors.syntax.base.string,
},
'.cm-string-2': {
color: tokens.stringEscape || theme.colors.syntax.base.string,
},
'.cm-number': {
color: theme.colors.syntax.base.number,
},
'.cm-atom': {
color: tokens.boolean || theme.colors.syntax.base.number,
},
'.cm-builtin': {
color: tokens.functionCall || theme.colors.syntax.base.function,
},
'.cm-def': {
color: tokens.variableGlobal || theme.colors.syntax.base.variable,
},
// Legacy shell flags (--foo, -bar)
'.cm-attribute': {
color: tokens.variableOther || tokens.variableProperty || theme.colors.syntax.base.operator,
},
'.cm-meta': {
color: theme.colors.syntax.base.comment,
},
'.cm-property': {
color: tokens.variableProperty || theme.colors.syntax.base.keyword,
},
'.cm-variable': {
color: theme.colors.syntax.base.variable,
},
'.cm-variable-2': {
color: tokens.variableOther || theme.colors.syntax.base.function,
},
'.cm-variable-3': {
color: tokens.variableGlobal || theme.colors.syntax.base.type,
},
'.cm-tag': {
color: tokens.tag || theme.colors.syntax.base.keyword,
},
'.cm-link': {
color: tokens.url || theme.colors.syntax.base.keyword,
textDecoration: 'underline',
},
'.cm-content': {
caretColor: theme.colors.interactive.cursor,
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: theme.colors.interactive.cursor,
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection': {
backgroundColor: theme.colors.interactive.selection,
},
'.cm-gutters': {
backgroundColor: 'var(--background)',
color: highlights.lineNumber || theme.colors.syntax.base.comment,
borderRight: `1px solid ${theme.colors.interactive.border}`,
position: 'sticky',
paddingRight: '8px',
left: 0,
zIndex: 2,
boxShadow: `0 0 0 var(--background)`,
},
'.cm-gutter': {
backgroundColor: 'var(--background)',
},
'.cm-gutterElement': {
backgroundColor: 'var(--background)',
},
'.cm-lineNumbers': {
backgroundColor: 'var(--background)',
},
'.cm-lineNumbers .cm-gutterElement': {
paddingLeft: '8px',
paddingRight: '8px',
minWidth: '42px',
},
'.cm-activeLineGutter': {
color: highlights.lineNumberActive || theme.colors.syntax.base.foreground,
},
'.cm-activeLine': {
backgroundColor: theme.colors.surface.overlay,
},
'&.cm-focused': {
outline: 'none',
},
}, { dark: isDark });
const syntax = HighlightStyle.define([
{ tag: t.comment, color: theme.colors.syntax.base.comment },
{ tag: t.docComment, color: tokens.commentDoc || theme.colors.syntax.base.comment },
{ tag: t.keyword, color: theme.colors.syntax.base.keyword },
{ tag: t.controlKeyword, color: theme.colors.syntax.base.keyword },
{ tag: t.operatorKeyword, color: theme.colors.syntax.base.operator },
{ tag: t.moduleKeyword, color: tokens.keywordImport || theme.colors.syntax.base.keyword },
{ tag: [t.definitionKeyword, t.modifier], color: tokens.storageModifier || theme.colors.syntax.base.keyword },
{ tag: t.atom, color: tokens.boolean || theme.colors.syntax.base.number },
{ tag: [t.null, t.self], color: theme.colors.syntax.base.number },
{ tag: [t.meta, t.documentMeta], color: theme.colors.syntax.base.comment },
{ tag: t.string, color: theme.colors.syntax.base.string },
{ tag: t.escape, color: tokens.stringEscape || theme.colors.syntax.base.foreground },
{ tag: t.regexp, color: tokens.regex || theme.colors.syntax.base.string },
{ tag: t.number, color: theme.colors.syntax.base.number },
{ tag: t.bool, color: tokens.boolean || theme.colors.syntax.base.number },
// Operators + punctuation
{ tag: t.operator, color: theme.colors.syntax.base.operator },
{ tag: [t.derefOperator, t.updateOperator, t.definitionOperator, t.typeOperator, t.controlOperator], color: theme.colors.syntax.base.operator },
{ tag: [t.logicOperator, t.bitwiseOperator, t.arithmeticOperator], color: theme.colors.syntax.base.operator },
{ tag: [t.compareOperator], color: tokens.diffModified || theme.colors.syntax.base.operator },
{ tag: [t.punctuation, t.separator, t.bracket, t.paren, t.brace, t.squareBracket, t.angleBracket], color: tokens.punctuation || theme.colors.syntax.base.comment },
// Calls vs definitions
{ tag: t.function(t.variableName), color: tokens.functionCall || theme.colors.syntax.base.function },
{ tag: t.function(t.definition(t.variableName)), color: theme.colors.syntax.base.function },
{ tag: t.function(t.propertyName), color: tokens.method || tokens.functionCall || theme.colors.syntax.base.function },
// Names
{ tag: t.namespace, color: tokens.namespace || theme.colors.syntax.base.type },
{ tag: t.moduleKeyword, color: tokens.module || theme.colors.syntax.base.keyword },
{ tag: t.macroName, color: tokens.macro || theme.colors.syntax.base.keyword },
{ tag: t.labelName, color: tokens.label || theme.colors.syntax.base.keyword },
{ tag: t.annotation, color: tokens.decorator || theme.colors.syntax.base.keyword },
// Variables/properties
{ tag: t.propertyName, color: tokens.variableProperty || theme.colors.syntax.base.keyword },
{ tag: t.attributeName, color: tokens.tagAttribute || theme.colors.syntax.base.keyword },
// StreamLanguage/legacy token tags resolve to these
{ tag: t.standard(t.variableName), color: tokens.method || theme.colors.syntax.base.function },
{ tag: t.definition(t.variableName), color: theme.colors.syntax.base.variable },
{ tag: t.local(t.variableName), color: theme.colors.syntax.base.variable },
{ tag: t.special(t.variableName), color: tokens.variableOther || theme.colors.syntax.base.function },
{ tag: t.variableName, color: theme.colors.syntax.base.variable },
{ tag: t.special(t.string), color: theme.colors.syntax.base.string },
// Types/constants
{ tag: t.className, color: tokens.className || theme.colors.syntax.base.type },
{ tag: t.typeName, color: theme.colors.syntax.base.type },
{ tag: t.constant(t.variableName), color: tokens.constant || theme.colors.syntax.base.variable },
{ tag: t.literal, color: tokens.constant || theme.colors.syntax.base.variable },
// Markup
{ tag: t.tagName, color: tokens.tag || theme.colors.syntax.base.keyword },
{ tag: t.attributeValue, color: tokens.tagAttributeValue || theme.colors.syntax.base.string },
// Markdown-ish
{ tag: [t.heading, t.heading1, t.heading2, t.heading3, t.heading4, t.heading5, t.heading6], color: theme.colors.syntax.base.keyword, fontWeight: '600' },
{ tag: t.monospace, color: theme.colors.syntax.base.string },
{ tag: t.link, color: tokens.url || theme.colors.syntax.base.keyword, textDecoration: 'underline' },
]);
return [ui, syntaxHighlighting(syntax)];
}
@@ -0,0 +1,191 @@
import type { Extension } from '@codemirror/state';
import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { css } from '@codemirror/lang-css';
import { html } from '@codemirror/lang-html';
import { markdown } from '@codemirror/lang-markdown';
import { languages } from '@codemirror/language-data';
import { python } from '@codemirror/lang-python';
import { sql } from '@codemirror/lang-sql';
import { xml } from '@codemirror/lang-xml';
import { yaml as yamlLanguage } from '@codemirror/lang-yaml';
import { rust } from '@codemirror/lang-rust';
import { Language, LanguageDescription, StreamLanguage } from '@codemirror/language';
import { shell } from '@codemirror/legacy-modes/mode/shell';
import { toml } from '@codemirror/legacy-modes/mode/toml';
import { diff } from '@codemirror/legacy-modes/mode/diff';
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
import { ruby } from '@codemirror/legacy-modes/mode/ruby';
import { properties } from '@codemirror/legacy-modes/mode/properties';
const shellLanguage = StreamLanguage.define(shell);
const tomlLanguage = StreamLanguage.define(toml);
const diffLanguage = StreamLanguage.define(diff);
const dockerfileLanguage = StreamLanguage.define(dockerFile);
const rubyLanguage = StreamLanguage.define(ruby);
const propertiesLanguage = StreamLanguage.define(properties);
function codeBlockLanguageResolver(info: string): Language | LanguageDescription | null {
const normalized = info.trim().toLowerCase();
switch (normalized) {
case 'bash':
case 'sh':
case 'zsh':
case 'shell':
case 'shellsession':
case 'console':
return shellLanguage;
case 'toml':
return tomlLanguage;
case 'diff':
case 'patch':
return diffLanguage;
case 'json':
case 'jsonc':
case 'json5':
return json().language;
case 'js':
case 'javascript':
return javascript().language;
case 'jsx':
return javascript({ jsx: true }).language;
case 'ts':
case 'typescript':
return javascript({ typescript: true }).language;
case 'tsx':
return javascript({ typescript: true, jsx: true }).language;
case 'yaml':
case 'yml':
return yamlLanguage().language;
case 'html':
return html().language;
case 'css':
return css().language;
case 'xml':
case 'svg':
return xml().language;
case 'py':
case 'python':
return python().language;
case 'sql':
return sql().language;
case 'rs':
case 'rust':
return rust().language;
default:
return LanguageDescription.matchLanguageName(languages, normalized, true);
}
}
const normalizeFileName = (filePath: string) => filePath.split('/').pop()?.toLowerCase() ?? '';
export function languageByExtension(filePath: string): Extension | null {
const normalized = filePath.toLowerCase();
const filename = normalizeFileName(normalized);
// Special filenames
switch (filename) {
case 'dockerfile':
return dockerfileLanguage;
case 'makefile':
case 'gnumakefile':
// No dedicated mode; shell is a decent fallback for Make-ish files.
return shellLanguage;
}
const idx = normalized.lastIndexOf('.');
const ext = idx >= 0 ? normalized.slice(idx + 1) : '';
switch (ext) {
// JavaScript/TypeScript
case 'ts':
case 'tsx':
case 'mts':
case 'cts':
return javascript({ typescript: true, jsx: ext === 'tsx' });
case 'js':
case 'jsx':
case 'mjs':
case 'cjs':
return javascript({ typescript: false, jsx: ext === 'jsx' });
// Web
case 'json':
case 'jsonc':
case 'json5':
case 'jsonl':
case 'ndjson':
case 'geojson':
return json();
case 'css':
case 'scss':
case 'sass':
case 'less':
return css();
case 'html':
case 'htm':
return html();
case 'md':
case 'mdx':
case 'markdown':
case 'mdown':
case 'mkd':
return markdown({
codeLanguages: codeBlockLanguageResolver,
});
// Data/config
case 'yml':
case 'yaml':
return yamlLanguage();
case 'toml':
return tomlLanguage;
case 'ini':
case 'cfg':
case 'conf':
case 'config':
case 'properties':
return propertiesLanguage;
// Shell
case 'sh':
case 'bash':
case 'zsh':
case 'fish':
case 'env':
return shellLanguage;
// Languages we already ship
case 'py':
case 'pyw':
case 'pyi':
return python();
case 'sql':
case 'psql':
case 'plsql':
return sql();
case 'xml':
case 'xsl':
case 'xslt':
case 'xsd':
case 'dtd':
case 'plist':
case 'svg':
return xml();
case 'rs':
return rust();
// Legacy modes
case 'rb':
case 'erb':
case 'rake':
case 'gemspec':
return rubyLanguage;
default:
return null;
}
}