Edit workspace files directly on web (#2270)
* feat(files): edit workspace files on web Keep source buffers synchronized with host file changes and require an explicit overwrite or reload when revisions diverge. * feat(panels): surface and protect modified tabs Expose tooltip and modification state through the generic panel boundary so tabs can show stable metadata and guard every close route consistently. * fix(tests): use portable fake timeout handle * fix(files): harden editor conflict handling Preserve modified panel state across tab eviction, use precise revisions for optimistic writes, coalesce concurrent file watchers, and localize the editor interface. * fix(files): close editor concurrency gaps Coalesce clean reloads, preserve subscriber identities and file permissions, suspend pending saves during close confirmation, and carry precise revisions through file reads. * test(files): expect read revision metadata
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isLanguageSupported, getSupportedExtensions, getParserForFile } from "../parsers.js";
|
||||
import {
|
||||
isLanguageSupported,
|
||||
getSupportedExtensions,
|
||||
getParserForFile,
|
||||
getLanguageForFile,
|
||||
} from "../parsers.js";
|
||||
|
||||
describe("isLanguageSupported", () => {
|
||||
it("returns true for supported file extensions", () => {
|
||||
@@ -60,6 +65,13 @@ describe("getSupportedExtensions", () => {
|
||||
});
|
||||
|
||||
describe("getParserForFile", () => {
|
||||
it("projects the parser retained by the editor language registry", () => {
|
||||
for (const extension of getSupportedExtensions()) {
|
||||
const filename = `source.${extension}`;
|
||||
expect(getParserForFile(filename)).toBe(getLanguageForFile(filename)?.parser);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a parser for supported files", () => {
|
||||
expect(getParserForFile("test.js")).not.toBeNull();
|
||||
expect(getParserForFile("test.py")).not.toBeNull();
|
||||
|
||||
@@ -1,50 +1,8 @@
|
||||
import { highlightTree, tagHighlighter, tags } from "@lezer/highlight";
|
||||
import { highlightTree } from "@lezer/highlight";
|
||||
import type { HighlightStyle, HighlightToken } from "./types.js";
|
||||
import { getParserForFile } from "./parsers.js";
|
||||
|
||||
const highlighter = tagHighlighter([
|
||||
{ tag: tags.keyword, class: "keyword" },
|
||||
{ tag: tags.controlKeyword, class: "keyword" },
|
||||
{ tag: tags.operatorKeyword, class: "keyword" },
|
||||
{ tag: tags.definitionKeyword, class: "keyword" },
|
||||
{ tag: tags.moduleKeyword, class: "keyword" },
|
||||
{ tag: tags.comment, class: "comment" },
|
||||
{ tag: tags.lineComment, class: "comment" },
|
||||
{ tag: tags.blockComment, class: "comment" },
|
||||
{ tag: tags.docComment, class: "comment" },
|
||||
{ tag: tags.string, class: "string" },
|
||||
{ tag: tags.special(tags.string), class: "string" },
|
||||
{ tag: tags.number, class: "number" },
|
||||
{ tag: tags.integer, class: "number" },
|
||||
{ tag: tags.float, class: "number" },
|
||||
{ tag: tags.bool, class: "literal" },
|
||||
{ tag: tags.null, class: "literal" },
|
||||
{ tag: tags.function(tags.variableName), class: "function" },
|
||||
{ tag: tags.function(tags.propertyName), class: "function" },
|
||||
{ tag: tags.definition(tags.variableName), class: "definition" },
|
||||
{ tag: tags.definition(tags.propertyName), class: "definition" },
|
||||
{ tag: tags.definition(tags.function(tags.variableName)), class: "definition" },
|
||||
{ tag: tags.className, class: "class" },
|
||||
{ tag: tags.definition(tags.className), class: "class" },
|
||||
{ tag: tags.typeName, class: "type" },
|
||||
{ tag: tags.tagName, class: "tag" },
|
||||
{ tag: tags.attributeName, class: "attribute" },
|
||||
{ tag: tags.attributeValue, class: "string" },
|
||||
{ tag: tags.propertyName, class: "property" },
|
||||
{ tag: tags.variableName, class: "variable" },
|
||||
{ tag: tags.local(tags.variableName), class: "variable" },
|
||||
{ tag: tags.special(tags.variableName), class: "variable" },
|
||||
{ tag: tags.operator, class: "operator" },
|
||||
{ tag: tags.punctuation, class: "punctuation" },
|
||||
{ tag: tags.bracket, class: "punctuation" },
|
||||
{ tag: tags.separator, class: "punctuation" },
|
||||
{ tag: tags.regexp, class: "regexp" },
|
||||
{ tag: tags.escape, class: "escape" },
|
||||
{ tag: tags.meta, class: "meta" },
|
||||
{ tag: tags.heading, class: "heading" },
|
||||
{ tag: tags.link, class: "link" },
|
||||
{ tag: tags.url, class: "link" },
|
||||
]);
|
||||
import { staticSyntaxHighlighter } from "./syntax-roles.js";
|
||||
|
||||
export function highlightCode(code: string, filename: string): HighlightToken[][] {
|
||||
const parser = getParserForFile(filename);
|
||||
@@ -64,7 +22,7 @@ export function highlightCode(code: string, filename: string): HighlightToken[][
|
||||
// Build a map of character positions to styles
|
||||
const styleMap: Array<HighlightStyle | null> = Array.from({ length: code.length }, () => null);
|
||||
|
||||
highlightTree(tree, highlighter, (from, to, classes) => {
|
||||
highlightTree(tree, staticSyntaxHighlighter, (from, to, classes) => {
|
||||
for (let i = from; i < to && i < styleMap.length; i++) {
|
||||
styleMap[i] = classes as HighlightStyle;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
export type { HighlightStyle, HighlightToken } from "./types.js";
|
||||
export { getParserForFile, isLanguageSupported, getSupportedExtensions } from "./parsers.js";
|
||||
export {
|
||||
getLanguageForFile,
|
||||
getParserForFile,
|
||||
isLanguageSupported,
|
||||
getSupportedExtensions,
|
||||
} from "./parsers.js";
|
||||
export { createCodeMirrorHighlightStyle } from "./syntax-roles.js";
|
||||
export { highlightCode, highlightLine } from "./highlighter.js";
|
||||
export { darkHighlightColors, lightHighlightColors } from "./colors.js";
|
||||
export type { SyntaxThemeId, SyntaxThemeOption, SyntaxColors } from "./themes.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { StreamLanguage } from "@codemirror/language";
|
||||
import { defineLanguageFacet, Language, StreamLanguage } from "@codemirror/language";
|
||||
import { dart } from "@codemirror/legacy-modes/mode/clike";
|
||||
import { swift } from "@codemirror/legacy-modes/mode/swift";
|
||||
import { parser as jsParser } from "@lezer/javascript";
|
||||
@@ -18,65 +18,73 @@ import { csharpLanguage } from "@replit/codemirror-lang-csharp";
|
||||
import { parser as elixirParser } from "lezer-elixir";
|
||||
import type { Parser } from "@lezer/common";
|
||||
|
||||
const parsersByExtension: Record<string, Parser> = {
|
||||
function language(parser: Parser): Language {
|
||||
return new Language(defineLanguageFacet(), parser);
|
||||
}
|
||||
|
||||
const languagesByExtension: Record<string, Language> = {
|
||||
// JavaScript/TypeScript
|
||||
js: jsParser,
|
||||
jsx: jsParser.configure({ dialect: "jsx" }),
|
||||
ts: jsParser.configure({ dialect: "ts" }),
|
||||
tsx: jsParser.configure({ dialect: "ts jsx" }),
|
||||
mjs: jsParser,
|
||||
cjs: jsParser,
|
||||
js: language(jsParser),
|
||||
jsx: language(jsParser.configure({ dialect: "jsx" })),
|
||||
ts: language(jsParser.configure({ dialect: "ts" })),
|
||||
tsx: language(jsParser.configure({ dialect: "ts jsx" })),
|
||||
mjs: language(jsParser),
|
||||
cjs: language(jsParser),
|
||||
// C / C++ / Objective-C
|
||||
c: cppParser,
|
||||
h: cppParser,
|
||||
cc: cppParser,
|
||||
cpp: cppParser,
|
||||
cxx: cppParser,
|
||||
hpp: cppParser,
|
||||
hxx: cppParser,
|
||||
m: cppParser,
|
||||
mm: cppParser,
|
||||
c: language(cppParser),
|
||||
h: language(cppParser),
|
||||
cc: language(cppParser),
|
||||
cpp: language(cppParser),
|
||||
cxx: language(cppParser),
|
||||
hpp: language(cppParser),
|
||||
hxx: language(cppParser),
|
||||
m: language(cppParser),
|
||||
mm: language(cppParser),
|
||||
// JSON
|
||||
json: jsonParser,
|
||||
json: language(jsonParser),
|
||||
// CSS
|
||||
css: cssParser,
|
||||
scss: cssParser,
|
||||
css: language(cssParser),
|
||||
scss: language(cssParser),
|
||||
// HTML
|
||||
html: htmlParser,
|
||||
htm: htmlParser,
|
||||
html: language(htmlParser),
|
||||
htm: language(htmlParser),
|
||||
// XML
|
||||
xml: xmlParser,
|
||||
xml: language(xmlParser),
|
||||
// Java
|
||||
java: javaParser,
|
||||
java: language(javaParser),
|
||||
// Python
|
||||
py: pythonParser,
|
||||
py: language(pythonParser),
|
||||
// Go
|
||||
go: goParser,
|
||||
go: language(goParser),
|
||||
// PHP
|
||||
php: phpParser,
|
||||
php: language(phpParser),
|
||||
// YAML
|
||||
yaml: yamlParser,
|
||||
yml: yamlParser,
|
||||
yaml: language(yamlParser),
|
||||
yml: language(yamlParser),
|
||||
// Rust
|
||||
rs: rustParser,
|
||||
rs: language(rustParser),
|
||||
// Swift
|
||||
swift: StreamLanguage.define(swift).parser,
|
||||
swift: StreamLanguage.define(swift),
|
||||
// Dart
|
||||
dart: StreamLanguage.define(dart).parser,
|
||||
dart: StreamLanguage.define(dart),
|
||||
// C#
|
||||
cs: csharpLanguage.parser,
|
||||
cs: csharpLanguage,
|
||||
// Elixir
|
||||
ex: elixirParser,
|
||||
exs: elixirParser,
|
||||
ex: language(elixirParser),
|
||||
exs: language(elixirParser),
|
||||
// Markdown
|
||||
md: markdownParser,
|
||||
mdx: markdownParser,
|
||||
md: language(markdownParser),
|
||||
mdx: language(markdownParser),
|
||||
};
|
||||
|
||||
export function getParserForFile(filename: string): Parser | null {
|
||||
export function getLanguageForFile(filename: string): Language | null {
|
||||
const ext = filename.split(".").pop()?.toLowerCase();
|
||||
if (!ext) return null;
|
||||
return parsersByExtension[ext] ?? null;
|
||||
return languagesByExtension[ext] ?? null;
|
||||
}
|
||||
|
||||
export function getParserForFile(filename: string): Parser | null {
|
||||
return getLanguageForFile(filename)?.parser ?? null;
|
||||
}
|
||||
|
||||
export function isLanguageSupported(filename: string): boolean {
|
||||
@@ -84,5 +92,5 @@ export function isLanguageSupported(filename: string): boolean {
|
||||
}
|
||||
|
||||
export function getSupportedExtensions(): string[] {
|
||||
return Object.keys(parsersByExtension);
|
||||
return Object.keys(languagesByExtension);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { HighlightStyle as CodeMirrorHighlightStyle } from "@codemirror/language";
|
||||
import { tagHighlighter, tags, type Tag } from "@lezer/highlight";
|
||||
import type { HighlightStyle } from "./types.js";
|
||||
|
||||
export const syntaxRoleTags: ReadonlyArray<{ tag: Tag; role: HighlightStyle }> = [
|
||||
{ tag: tags.keyword, role: "keyword" },
|
||||
{ tag: tags.controlKeyword, role: "keyword" },
|
||||
{ tag: tags.operatorKeyword, role: "keyword" },
|
||||
{ tag: tags.definitionKeyword, role: "keyword" },
|
||||
{ tag: tags.moduleKeyword, role: "keyword" },
|
||||
{ tag: tags.comment, role: "comment" },
|
||||
{ tag: tags.lineComment, role: "comment" },
|
||||
{ tag: tags.blockComment, role: "comment" },
|
||||
{ tag: tags.docComment, role: "comment" },
|
||||
{ tag: tags.string, role: "string" },
|
||||
{ tag: tags.special(tags.string), role: "string" },
|
||||
{ tag: tags.number, role: "number" },
|
||||
{ tag: tags.integer, role: "number" },
|
||||
{ tag: tags.float, role: "number" },
|
||||
{ tag: tags.bool, role: "literal" },
|
||||
{ tag: tags.null, role: "literal" },
|
||||
{ tag: tags.function(tags.variableName), role: "function" },
|
||||
{ tag: tags.function(tags.propertyName), role: "function" },
|
||||
{ tag: tags.definition(tags.variableName), role: "definition" },
|
||||
{ tag: tags.definition(tags.propertyName), role: "definition" },
|
||||
{ tag: tags.definition(tags.function(tags.variableName)), role: "definition" },
|
||||
{ tag: tags.className, role: "class" },
|
||||
{ tag: tags.definition(tags.className), role: "class" },
|
||||
{ tag: tags.typeName, role: "type" },
|
||||
{ tag: tags.tagName, role: "tag" },
|
||||
{ tag: tags.attributeName, role: "attribute" },
|
||||
{ tag: tags.attributeValue, role: "string" },
|
||||
{ tag: tags.propertyName, role: "property" },
|
||||
{ tag: tags.variableName, role: "variable" },
|
||||
{ tag: tags.local(tags.variableName), role: "variable" },
|
||||
{ tag: tags.special(tags.variableName), role: "variable" },
|
||||
{ tag: tags.operator, role: "operator" },
|
||||
{ tag: tags.punctuation, role: "punctuation" },
|
||||
{ tag: tags.bracket, role: "punctuation" },
|
||||
{ tag: tags.separator, role: "punctuation" },
|
||||
{ tag: tags.regexp, role: "regexp" },
|
||||
{ tag: tags.escape, role: "escape" },
|
||||
{ tag: tags.meta, role: "meta" },
|
||||
{ tag: tags.heading, role: "heading" },
|
||||
{ tag: tags.link, role: "link" },
|
||||
{ tag: tags.url, role: "link" },
|
||||
];
|
||||
|
||||
export const staticSyntaxHighlighter = tagHighlighter(
|
||||
syntaxRoleTags.map(({ tag, role }) => ({ tag, class: role })),
|
||||
);
|
||||
|
||||
export function createCodeMirrorHighlightStyle(colors: Record<HighlightStyle, string>) {
|
||||
return CodeMirrorHighlightStyle.define(
|
||||
syntaxRoleTags.map(({ tag, role }) => ({ tag, color: colors[role] })),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user