Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions plugins/diff-history/collect-side-lines.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, test } from 'vitest';
import { collectSideLines, type SideRow } from './collect-side-lines';

const rows: SideRow[] = [
{ kind: 'header', text: '@@ -1,3 +1,3 @@' },
{ kind: 'context', baseLn: 1, targetLn: 1, text: 'const a = 1;' },
{ kind: 'delete', baseLn: 2, text: 'const old = 2;' },
{ kind: 'add', targetLn: 2, text: 'const fresh = 2;' },
{ kind: 'change', baseLn: 3, baseText: 'return old;', targetLn: 3, targetText: 'return fresh;' },
];

describe('collectSideLines', () => {
test('base side takes context, delete, and change-base text in row order', () => {
expect(collectSideLines(rows, 'base')).toEqual([
{ index: 1, text: 'const a = 1;' },
{ index: 2, text: 'const old = 2;' },
{ index: 4, text: 'return old;' },
]);
});

test('target side takes context, add, and change-target text in row order', () => {
expect(collectSideLines(rows, 'target')).toEqual([
{ index: 1, text: 'const a = 1;' },
{ index: 3, text: 'const fresh = 2;' },
{ index: 4, text: 'return fresh;' },
]);
});

test('headers and the opposite side’s rows are excluded', () => {
expect(collectSideLines(rows, 'base').some((l) => l.index === 0)).toBe(false);
expect(collectSideLines(rows, 'base').some((l) => l.index === 3)).toBe(false);
expect(collectSideLines(rows, 'target').some((l) => l.index === 2)).toBe(false);
});
});
30 changes: 30 additions & 0 deletions plugins/diff-history/collect-side-lines.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
export type SideRow =
| { kind: 'header'; text: string }
| { kind: 'context'; baseLn: number; targetLn: number; text: string }
| { kind: 'delete'; baseLn: number; text: string }
| { kind: 'add'; targetLn: number; text: string }
| { kind: 'change'; baseLn: number; baseText: string; targetLn: number; targetText: string };

export type DiffSide = 'base' | 'target';

export type SideLine = { index: number; text: string };

/* pull out the code-bearing lines for one column, paired with their index in
the original row list. The index is what lets a colorized line be threaded
back to its row during render. Headers carry no code; each diff side only
shows its own half of a change. */
export function collectSideLines(rows: SideRow[], side: DiffSide): SideLine[] {
const lines: SideLine[] = [];
rows.forEach((row, index) => {
if (row.kind === 'context') {
lines.push({ index, text: row.text });
} else if (row.kind === 'delete' && side === 'base') {
lines.push({ index, text: row.text });
} else if (row.kind === 'add' && side === 'target') {
lines.push({ index, text: row.text });
} else if (row.kind === 'change') {
lines.push({ index, text: side === 'base' ? row.baseText : row.targetText });
}
});
return lines;
}
77 changes: 64 additions & 13 deletions plugins/diff-history/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import React, { useEffect, useRef, useState } from 'react';
import type { GitDiffResult } from '../../packages/sdk/src/host';
import type { BuiltinPluginProps } from '../shared';
import { PanelHeader, ResizeHandle, scheduleAfterPaint, useResizableSplit } from '../shared';
import { applyGlassTheme, colorizeLines, loadMonaco, monacoLanguageForPath } from '../shared/monaco-highlight';
import { collectSideLines, type SideRow } from './collect-side-lines';

type CompareMode = 'working' | 'branch';

Expand All @@ -14,13 +16,6 @@ const COMPARE_OPTIONS: Array<{
{ mode: 'branch', label: 'upstream vs current branch', detail: 'commits on this branch' },
];

type SideRow =
| { kind: 'header'; text: string }
| { kind: 'context'; baseLn: number; targetLn: number; text: string }
| { kind: 'delete'; baseLn: number; text: string }
| { kind: 'add'; targetLn: number; text: string }
| { kind: 'change'; baseLn: number; baseText: string; targetLn: number; targetText: string };

function parseUnifiedDiff(unified: string): SideRow[] {
if (!unified.trim()) return [];
const rows: SideRow[] = [];
Expand Down Expand Up @@ -93,6 +88,14 @@ export function DiffHistoryPanel({ header, host }: BuiltinPluginProps) {
const [diffParsing, setDiffParsing] = useState(false);
const [sideRows, setSideRows] = useState<SideRow[]>([]);
const [visibleRowCount, setVisibleRowCount] = useState(0);
/* row index -> Monaco-colorized HTML for that line, one map per column.
missing entries (plaintext, unloaded monaco) fall back to plain text. */
const [baseHtml, setBaseHtml] = useState<Record<number, string>>({});
const [targetHtml, setTargetHtml] = useState<Record<number, string>>({});
/* apply the Monaco theme once — it's global + accent-independent for token
colors, so re-applying on every file switch would needlessly recolor any
live editor. */
const themeAppliedRef = useRef(false);

const [selectedFile, setSelectedFile] = useState<string>('');
const [compareMode, setCompareMode] = useState<CompareMode>('working');
Expand Down Expand Up @@ -230,6 +233,54 @@ export function DiffHistoryPanel({ header, host }: BuiltinPluginProps) {
return () => window.cancelAnimationFrame(frame);
}, [sideRows.length, visibleRowCount]);

/* syntax-highlight each column with the same Monaco tokenizer/theme the
editor uses. Colorize the contiguous per-side text once, then thread each
resulting line back to its row index. Diff-status tints stay dominant —
these are foreground token colors over the row background. Falls back to
plain text for plaintext/unknown languages or if Monaco fails to load. */
useEffect(() => {
let cancelled = false;
setBaseHtml({});
setTargetHtml({});
if (sideRows.length === 0 || !activeFile) return () => { cancelled = true; };
loadMonaco()
.then((monaco) => {
if (cancelled) return;
if (!themeAppliedRef.current) {
applyGlassTheme(monaco);
themeAppliedRef.current = true;
}
const language = monacoLanguageForPath(monaco, activeFile);
if (language === 'plaintext') return;
const baseLines = collectSideLines(sideRows, 'base');
const targetLines = collectSideLines(sideRows, 'target');
return Promise.all([
colorizeLines(monaco, baseLines.map((line) => line.text).join('\n'), language),
colorizeLines(monaco, targetLines.map((line) => line.text).join('\n'), language),
]).then(([baseColored, targetColored]) => {
if (cancelled) return;
const baseMap: Record<number, string> = {};
baseLines.forEach((line, i) => {
if (baseColored[i] != null) baseMap[line.index] = baseColored[i];
});
const targetMap: Record<number, string> = {};
targetLines.forEach((line, i) => {
if (targetColored[i] != null) targetMap[line.index] = targetColored[i];
});
setBaseHtml(baseMap);
setTargetHtml(targetMap);
});
})
.catch(() => { /* leave the plain-text fallback in place */ });
return () => { cancelled = true; };
}, [sideRows, activeFile]);

const renderCode = (html: Record<number, string>, index: number, fallback: string) => {
const colored = html[index];
if (colored != null) return <code dangerouslySetInnerHTML={{ __html: colored || ' ' }} />;
return <code>{fallback || ' '}</code>;
};

return (
<div className="diff-history-shell">
<PanelHeader {...header}>
Expand Down Expand Up @@ -371,23 +422,23 @@ export function DiffHistoryPanel({ header, host }: BuiltinPluginProps) {
return (
<div key={`L-${index}`} className="diff-line diff-line--same">
<span>{row.baseLn}</span>
<code>{row.text || ' '}</code>
{renderCode(baseHtml, index, row.text)}
</div>
);
}
if (row.kind === 'delete') {
return (
<div key={`L-${index}`} className="diff-line diff-line--remove">
<span>{row.baseLn}</span>
<code>{row.text || ' '}</code>
{renderCode(baseHtml, index, row.text)}
</div>
);
}
if (row.kind === 'change') {
return (
<div key={`L-${index}`} className="diff-line diff-line--remove">
<span>{row.baseLn}</span>
<code>{row.baseText || ' '}</code>
{renderCode(baseHtml, index, row.baseText)}
</div>
);
}
Expand Down Expand Up @@ -421,23 +472,23 @@ export function DiffHistoryPanel({ header, host }: BuiltinPluginProps) {
return (
<div key={`R-${index}`} className="diff-line diff-line--same">
<span>{row.targetLn}</span>
<code>{row.text || ' '}</code>
{renderCode(targetHtml, index, row.text)}
</div>
);
}
if (row.kind === 'add') {
return (
<div key={`R-${index}`} className="diff-line diff-line--add">
<span>{row.targetLn}</span>
<code>{row.text || ' '}</code>
{renderCode(targetHtml, index, row.text)}
</div>
);
}
if (row.kind === 'change') {
return (
<div key={`R-${index}`} className="diff-line diff-line--add">
<span>{row.targetLn}</span>
<code>{row.targetText || ' '}</code>
{renderCode(targetHtml, index, row.targetText)}
</div>
);
}
Expand Down
6 changes: 5 additions & 1 deletion plugins/diff-history/diff-history.css
Original file line number Diff line number Diff line change
Expand Up @@ -685,7 +685,11 @@
}


.diff-line span {
/* direct child only — this is the line-number gutter. Monaco's colorize()
output is also <span> (token spans) nested inside <code>; a bare
`.diff-line span` would out-specify the .mtkN token-color classes and flatten
syntax highlighting to the gutter color. */
.diff-line > span {
height: 100%;
display: grid;
place-items: center end;
Expand Down
102 changes: 7 additions & 95 deletions plugins/editor/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
normalizeTypeScriptProjectConfig,
} from './ambient-types';
import type { DebugState } from '../../packages/sdk/src/host';
import { loadInterfaceSettings } from '../../src/settings/settingsStorage';
import { applyGlassTheme, loadMonaco, monacoLanguageForPath } from '../shared/monaco-highlight';
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import CssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker';
import HtmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker';
Expand All @@ -33,49 +33,6 @@ type MonacoEditorModel = ReturnType<MonacoModule['editor']['createModel']>;
type MonacoEditorInstance = ReturnType<MonacoModule['editor']['create']>;
type ExtraLibDisposable = { dispose: () => void };

/* Build Monaco theme color map from the current accent hex. All accent-derived
slots use 8-digit hex (RRGGBBAA) so they update together when the accent
changes; fixed warm-dark surface colors stay hardcoded. */
function buildMonacoThemeColors(accentHex: string): Record<string, string> {
let r = 240, g = 179, b = 90; // honey fallback
const clean = accentHex.replace('#', '');
if (/^[0-9a-fA-F]{6}$/.test(clean)) {
r = parseInt(clean.slice(0, 2), 16);
g = parseInt(clean.slice(2, 4), 16);
b = parseInt(clean.slice(4, 6), 16);
}
const hex2 = (n: number) => Math.round(Math.max(0, Math.min(255, n))).toString(16).padStart(2, '0');
const a = (alpha: number) => `#${hex2(r)}${hex2(g)}${hex2(b)}${hex2(alpha * 255)}`;
const full = `#${hex2(r)}${hex2(g)}${hex2(b)}`;
return {
'editor.background': '#0d0a0700',
'editor.foreground': '#ffffff',
'editorLineNumber.foreground': '#5c4a32',
'editorLineNumber.activeForeground': full,
'editor.selectionBackground': a(0.40),
'editor.lineHighlightBackground': '#1a120c80',
'editorCursor.foreground': full,
'editorIndentGuide.background': '#2a1c1240',
'editorIndentGuide.activeBackground': a(0.19),
'editorBracketMatch.background': a(0.19),
'editorBracketMatch.border': full,
'editorStickyScroll.background': '#120c08f5',
'editorStickyScrollHover.background': '#1d1410f8',
'editor.findMatchBackground': a(0.31),
'editor.findMatchHighlightBackground': a(0.13),
'editor.findMatchBorder': a(0.60),
'editor.findRangeHighlightBackground': a(0.06),
'editorWidget.background': '#1a110a',
'editorWidget.border': '#3d2a1a',
'editorWidget.foreground': '#ffffff',
'input.background': '#2a1c10',
'input.border': '#3d2a1a',
'input.foreground': '#ffffff',
'inputOption.activeBorder': full,
'inputOption.activeBackground': a(0.13),
};
}

const TYPE_SCRIPT_FILE_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'];
const WORKSPACE_IMPORT_LIMIT = 80;
const PACKAGE_LIB_LIMIT = 180;
Expand Down Expand Up @@ -528,22 +485,12 @@ export function EditorPanel({ header, host }: BuiltinPluginProps) {
loads the editor with zero language contributions, which is why
opening a .tsx file rendered as flat plaintext. */
perfPoint('editor:monaco-import-start');
import('monaco-editor/esm/vs/editor/editor.main').then((monaco) => {
loadMonaco().then((monaco) => {
perfPoint('editor:monaco-import-resolved');
if (disposed || !monacoHostRef.current) return;
/* glass editor theme — warm-dark surface with transparent background so
the panel's frosted layer shows through. Accent colors are derived from
the user's current settings so they update when the theme changes. */
const applyTheme = () => {
const accent = loadInterfaceSettings().accent;
monaco.editor.defineTheme('polypore-warm', {
base: 'vs-dark',
inherit: true,
rules: [],
colors: buildMonacoThemeColors(accent),
});
monaco.editor.setTheme('polypore-warm');
};
/* glass editor theme — derived from the user's accent, shared with the
diff panel via applyGlassTheme so both stay in sync. */
const applyTheme = () => applyGlassTheme(monaco);
applyTheme();
/* watch :root inline style for changes — applyInterfaceSettings writes
CSS vars there, so this fires whenever the user changes their accent. */
Expand Down Expand Up @@ -1977,43 +1924,8 @@ function declarationReferenceTypes(text: string) {
.flatMap((match) => match[1] ? [match[1]] : []);
}

/* look up monaco's registered language id for a file path by querying the
editor's own language registry. `monaco.languages.getLanguages()` returns
every contribution registered by `editor.main` (the basic-languages
bundle), each with the file extensions, recognized filenames (Dockerfile,
Makefile, …), and aliases it claims. matching against that registry means
we automatically pick up any language monaco ships with — no whitelist to
keep in sync. falls back to plaintext if nothing claims the file. */
function monacoLanguageForPath(monaco: MonacoModule, path: string): string {
if (!path) return 'plaintext';
const filename = (path.split('/').pop() ?? '').toLowerCase();
const dot = filename.lastIndexOf('.');
const ext = dot >= 0 ? filename.slice(dot) : '';
const langs = (monaco as unknown as {
languages: {
getLanguages: () => Array<{
id: string;
extensions?: string[];
filenames?: string[];
aliases?: string[];
}>;
};
}).languages.getLanguages();
for (const lang of langs) {
if (ext && lang.extensions?.some((e) => e.toLowerCase() === ext)) return lang.id;
if (lang.filenames?.some((f) => f.toLowerCase() === filename)) return lang.id;
}
/* aliases/ids without dotted extensions (e.g. "Dockerfile.dev") — last
ditch: check if the bare filename or trailing token matches a language
id or alias. */
const tail = dot >= 0 ? filename.slice(dot + 1) : filename;
for (const lang of langs) {
if (lang.id.toLowerCase() === tail) return lang.id;
if (lang.aliases?.some((a) => a.toLowerCase() === tail)) return lang.id;
}
return 'plaintext';
}

/* project language-server config wins; otherwise fall back to monaco's own
language registry (monacoLanguageForPath, in shared/monaco-highlight). */
function editorLanguageForPath(monaco: MonacoModule, path: string, projectConfig: ProjectLanguageConfig): string {
return projectLanguageForPath(projectConfig, path) ?? monacoLanguageForPath(monaco, path);
}
Expand Down
25 changes: 25 additions & 0 deletions plugins/shared/monaco-highlight.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, test } from 'vitest';
import { splitColorizedLines } from './monaco-highlight';

describe('splitColorizedLines', () => {
test('splits monaco colorize output on <br/> and drops the trailing empty entry', () => {
const html = '<span class="mtk1">const</span><br/><span class="mtk1">x</span><br/>';
expect(splitColorizedLines(html)).toEqual([
'<span class="mtk1">const</span>',
'<span class="mtk1">x</span>',
]);
});

test('keeps interior blank lines so row alignment is preserved', () => {
const html = 'a<br/><br/>b<br/>';
expect(splitColorizedLines(html)).toEqual(['a', '', 'b']);
});

test('returns an empty array for empty input', () => {
expect(splitColorizedLines('')).toEqual([]);
});

test('a lone <br/> is a single blank line', () => {
expect(splitColorizedLines('<br/>')).toEqual(['']);
});
});
Loading
Loading