From 2cfe4c512f4e82ca9dcd2fa408649ce0bbff5960 Mon Sep 17 00:00:00 2001 From: noyobo Date: Tue, 22 Sep 2026 08:43:18 +0800 Subject: [PATCH 1/4] feat: expand diff syntax highlighting for styles and config files Register less/scss and common config grammars, map basenames like Dockerfile, and color CSS selector tokens so those diffs stop rendering as plain text. --- apps/desktop/src/app/globals.css | 5 ++- apps/desktop/src/shared/highlight.ts | 58 ++++++++++++++++++++++++++-- tests/unit/highlight.test.ts | 41 +++++++++++++++++++- 3 files changed, 98 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/app/globals.css b/apps/desktop/src/app/globals.css index 4df2e026..def71c7c 100644 --- a/apps/desktop/src/app/globals.css +++ b/apps/desktop/src/app/globals.css @@ -63,8 +63,9 @@ body { .hljs-number { color: hsl(var(--syntax-number)); } .hljs-comment { color: hsl(var(--faint-foreground)); font-style: italic; } .hljs-title, .hljs-function { color: hsl(var(--syntax-function)); } -.hljs-attr, .hljs-property, .hljs-variable { color: hsl(var(--syntax-variable)); } -.hljs-type, .hljs-class { color: hsl(var(--syntax-type)); } +.hljs-attr, .hljs-attribute, .hljs-property, .hljs-variable, .hljs-selector-attr { color: hsl(var(--syntax-variable)); } +.hljs-type, .hljs-class, .hljs-selector-tag, .hljs-selector-class, .hljs-selector-id { color: hsl(var(--syntax-type)); } +.hljs-selector-pseudo, .hljs-meta { color: hsl(var(--syntax-keyword)); } /* xterm fills its panel */ .terminal-host .xterm { diff --git a/apps/desktop/src/shared/highlight.ts b/apps/desktop/src/shared/highlight.ts index 4c1d0015..21f71a8f 100644 --- a/apps/desktop/src/shared/highlight.ts +++ b/apps/desktop/src/shared/highlight.ts @@ -8,6 +8,8 @@ import java from 'highlight.js/lib/languages/java'; import csharp from 'highlight.js/lib/languages/csharp'; import cpp from 'highlight.js/lib/languages/cpp'; import css from 'highlight.js/lib/languages/css'; +import less from 'highlight.js/lib/languages/less'; +import scss from 'highlight.js/lib/languages/scss'; import xml from 'highlight.js/lib/languages/xml'; import json from 'highlight.js/lib/languages/json'; import yaml from 'highlight.js/lib/languages/yaml'; @@ -18,6 +20,11 @@ import ruby from 'highlight.js/lib/languages/ruby'; import php from 'highlight.js/lib/languages/php'; import kotlin from 'highlight.js/lib/languages/kotlin'; import swift from 'highlight.js/lib/languages/swift'; +import ini from 'highlight.js/lib/languages/ini'; +import properties from 'highlight.js/lib/languages/properties'; +import dockerfile from 'highlight.js/lib/languages/dockerfile'; +import makefile from 'highlight.js/lib/languages/makefile'; +import cmake from 'highlight.js/lib/languages/cmake'; hljs.registerLanguage('typescript', typescript); hljs.registerLanguage('javascript', javascript); @@ -28,6 +35,8 @@ hljs.registerLanguage('java', java); hljs.registerLanguage('csharp', csharp); hljs.registerLanguage('cpp', cpp); hljs.registerLanguage('css', css); +hljs.registerLanguage('less', less); +hljs.registerLanguage('scss', scss); hljs.registerLanguage('xml', xml); hljs.registerLanguage('json', json); hljs.registerLanguage('yaml', yaml); @@ -38,17 +47,35 @@ hljs.registerLanguage('ruby', ruby); hljs.registerLanguage('php', php); hljs.registerLanguage('kotlin', kotlin); hljs.registerLanguage('swift', swift); +hljs.registerLanguage('ini', ini); +hljs.registerLanguage('properties', properties); +hljs.registerLanguage('dockerfile', dockerfile); +hljs.registerLanguage('makefile', makefile); +hljs.registerLanguage('cmake', cmake); + +/** Exact file basenames (lowercased), including leading-dot names. */ +const BASENAME_TO_LANG: Record = { + dockerfile: 'dockerfile', + makefile: 'makefile', + gnumakefile: 'makefile', + 'cmakelists.txt': 'cmake', + '.gitignore': 'properties', + '.gitattributes': 'properties', + '.editorconfig': 'properties', +}; const EXT_TO_LANG: Record = { ts: 'typescript', tsx: 'typescript', mts: 'typescript', + cts: 'typescript', js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript', rs: 'rust', py: 'python', + pyi: 'python', go: 'go', java: 'java', cs: 'csharp', @@ -57,9 +84,15 @@ const EXT_TO_LANG: Record = { cc: 'cpp', cpp: 'cpp', hpp: 'cpp', + hh: 'cpp', + hxx: 'cpp', + cxx: 'cpp', + ino: 'cpp', css: 'css', - scss: 'css', + less: 'less', + scss: 'scss', html: 'xml', + htm: 'xml', svg: 'xml', xml: 'xml', vue: 'xml', @@ -70,16 +103,33 @@ const EXT_TO_LANG: Record = { zsh: 'bash', bash: 'bash', md: 'markdown', + markdown: 'markdown', + mdx: 'markdown', sql: 'sql', rb: 'ruby', php: 'php', kt: 'kotlin', + kts: 'kotlin', swift: 'swift', + ini: 'ini', + cfg: 'ini', + conf: 'ini', + properties: 'properties', + cmake: 'cmake', }; +function fileName(path: string): string { + const slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); + return (slash >= 0 ? path.slice(slash + 1) : path).toLowerCase(); +} + export function languageOf(path: string): string | null { - const ext = path.split('.').pop()?.toLowerCase() ?? ''; - return EXT_TO_LANG[ext] ?? null; + const name = fileName(path); + const byName = BASENAME_TO_LANG[name]; + if (byName) return byName; + const dot = name.lastIndexOf('.'); + if (dot <= 0) return null; + return EXT_TO_LANG[name.slice(dot + 1)] ?? null; } const MAX_HIGHLIGHT_LENGTH = 5000; @@ -94,6 +144,8 @@ const BLOCK_COMMENT_OPENERS: Record = { csharp: '/*', cpp: '/*', css: '/*', + less: '/*', + scss: '/*', kotlin: '/*', swift: '/*', php: '/*', diff --git a/tests/unit/highlight.test.ts b/tests/unit/highlight.test.ts index c1f981d2..636c9545 100644 --- a/tests/unit/highlight.test.ts +++ b/tests/unit/highlight.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { highlightLineState, supportsBlockComments } from '@/shared/highlight'; +import { highlightLineState, languageOf, supportsBlockComments } from '@/shared/highlight'; const text = (html: string) => html.replace(/<[^>]+>/g, ''); const wholeLineIsComment = (html: string) => @@ -63,6 +63,45 @@ describe('highlightLineState', () => { expect(highlightLineState('done -->', 'xml', true).endsInComment).toBe(false); }); + it('highlights a less variable and selector', () => { + expect(languageOf('styles/theme.less')).toBe('less'); + expect(supportsBlockComments('less')).toBe(true); + const variable = highlightLineState('@color: #fff;', 'less'); + expect(variable.html).toContain('hljs-variable'); + expect(variable.html).toContain('hljs-number'); + const rule = highlightLineState('.btn { color: red; }', 'less'); + expect(rule.html).toContain('hljs-selector-class'); + expect(rule.html).toContain('hljs-attribute'); + }); + + it('maps aliases and basenames for common config and style files', () => { + expect(languageOf('src/util.cts')).toBe('typescript'); + expect(languageOf('readme.markdown')).toBe('markdown'); + expect(languageOf('types/foo.pyi')).toBe('python'); + expect(languageOf('App.kts')).toBe('kotlin'); + expect(languageOf('board.ino')).toBe('cpp'); + expect(languageOf('index.htm')).toBe('xml'); + expect(languageOf('styles/app.scss')).toBe('scss'); + expect(languageOf('config/app.ini')).toBe('ini'); + expect(languageOf('messages.properties')).toBe('properties'); + expect(languageOf('repo/.gitignore')).toBe('properties'); + expect(languageOf('.editorconfig')).toBe('properties'); + expect(languageOf('Dockerfile')).toBe('dockerfile'); + expect(languageOf('path/Makefile')).toBe('makefile'); + expect(languageOf('CMakeLists.txt')).toBe('cmake'); + expect(languageOf('tool.cmake')).toBe('cmake'); + }); + + it('highlights scss variables and dockerfile keywords', () => { + expect(supportsBlockComments('scss')).toBe(true); + const scssLine = highlightLineState('$color: #fff;', 'scss'); + expect(scssLine.html).toContain('hljs-variable'); + const docker = highlightLineState('FROM node:20-alpine', 'dockerfile'); + expect(docker.html).toContain('hljs-keyword'); + const ignore = highlightLineState('node_modules/', 'properties'); + expect(text(ignore.html)).toBe('node_modules/'); + }); + it('ignores the continuation flag for languages without block comments', () => { expect(supportsBlockComments('python')).toBe(false); const line = highlightLineState('x = 1', 'python', true); From 066c7c159ed016b7fcd7ce13474d7fa4769e6a87 Mon Sep 17 00:00:00 2001 From: noyobo Date: Tue, 22 Sep 2026 09:09:41 +0800 Subject: [PATCH 2/4] feat: add syntax-lab demo repo for highlight samples Keep suffix fixtures off the main angkorgit working copy so less, scss, Dockerfile, and config diffs can be judged on their own. --- apps/desktop/src/core/demo.ts | 185 +++++++++++++++++++++++++++++++ apps/desktop/src/core/ipc.ts | 6 +- tests/unit/demoSyntaxLab.test.ts | 43 +++++++ 3 files changed, 231 insertions(+), 3 deletions(-) create mode 100644 tests/unit/demoSyntaxLab.test.ts diff --git a/apps/desktop/src/core/demo.ts b/apps/desktop/src/core/demo.ts index dd2b6785..d62c323d 100644 --- a/apps/desktop/src/core/demo.ts +++ b/apps/desktop/src/core/demo.ts @@ -7,6 +7,7 @@ import type { CommitFileInfo, CommitInfo, FileDiff, + FileStatus, HistoryPage, HistoryQuery, HttpRequest, @@ -103,12 +104,39 @@ export const demoRepo: RepositoryInfo = { mainPath: null, }; +/** Suffix-only Working Copy โ€” open this to judge highlight, not angkorgit WIP. */ +export const DEMO_SYNTAX_REPO_PATH = '/Users/demo/projects/syntax-lab'; + +export const DEMO_LESS_PATH = 'styles/theme.less'; +export const DEMO_SCSS_PATH = 'styles/button.scss'; +export const DEMO_DOCKERFILE_PATH = 'Dockerfile'; +export const DEMO_MAKEFILE_PATH = 'Makefile'; +export const DEMO_CMAKE_LISTS_PATH = 'CMakeLists.txt'; +export const DEMO_CMAKE_PATH = 'build/tool.cmake'; +export const DEMO_GITIGNORE_PATH = '.gitignore'; +export const DEMO_EDITORCONFIG_PATH = '.editorconfig'; +export const DEMO_INI_PATH = 'config/app.ini'; +export const DEMO_PROPERTIES_PATH = 'config/messages.properties'; + export const demoRecents: RecentRepository[] = [ { path: '/Users/demo/projects/angkorgit', name: 'angkorgit', lastOpenedAt: 1754200000 }, { path: '/Users/demo/projects/temple-ui', name: 'temple-ui', lastOpenedAt: 1754100000 }, + { path: DEMO_SYNTAX_REPO_PATH, name: 'syntax-lab', lastOpenedAt: 1754000000 }, { path: '/Users/demo/work/api-gateway', name: 'api-gateway', lastOpenedAt: 1753900000 }, ]; +export function demoRepoAt(path: string): RepositoryInfo { + if (path === DEMO_SYNTAX_REPO_PATH) { + return { + ...demoRepo, + path, + name: 'syntax-lab', + state: 'clean', + }; + } + return demoRepo; +} + export const demoFonts = [ { family: 'Fira Code', monospaced: true }, { family: 'Helvetica Neue', monospaced: false }, @@ -188,6 +216,31 @@ export const demoStatus: StatusSummary = { behind: 0, }; +const DEMO_SYNTAX_STATUS_SEED: FileStatus[] = [ + { path: DEMO_LESS_PATH, origPath: null, staged: null, unstaged: 'modified' }, + { path: DEMO_SCSS_PATH, origPath: null, staged: null, unstaged: 'modified' }, + { path: DEMO_DOCKERFILE_PATH, origPath: null, staged: null, unstaged: 'untracked' }, + { path: DEMO_MAKEFILE_PATH, origPath: null, staged: null, unstaged: 'untracked' }, + { path: DEMO_CMAKE_LISTS_PATH, origPath: null, staged: null, unstaged: 'untracked' }, + { path: DEMO_CMAKE_PATH, origPath: null, staged: null, unstaged: 'modified' }, + { path: DEMO_GITIGNORE_PATH, origPath: null, staged: null, unstaged: 'modified' }, + { path: DEMO_EDITORCONFIG_PATH, origPath: null, staged: null, unstaged: 'untracked' }, + { path: DEMO_INI_PATH, origPath: null, staged: null, unstaged: 'modified' }, + { path: DEMO_PROPERTIES_PATH, origPath: null, staged: null, unstaged: 'modified' }, +]; + +export function demoStatusAt(path: string): StatusSummary { + if (path === DEMO_SYNTAX_REPO_PATH) { + return { + files: DEMO_SYNTAX_STATUS_SEED.map((f) => ({ ...f })), + branch: 'main', + ahead: 0, + behind: 0, + }; + } + return demoStatus; +} + export const demoBranches: BranchInfo[] = [ { name: 'main', isHead: true, isRemote: false, upstream: 'origin/main', ahead: 2, behind: 0, targetOid: ALL_COMMITS[0].oid }, { name: 'develop', isHead: false, isRemote: false, upstream: null, ahead: 0, behind: 0, targetOid: ALL_COMMITS[20].oid }, @@ -402,8 +455,140 @@ export const demoLargeFileDiff: FileDiff = { ], }; +function demoStatusDiff( + path: string, + status: FileDiff['status'], + rows: Array, +): FileDiff { + let oldNo = status === 'new' ? 0 : 1; + let newNo = status === 'deleted' ? 0 : 1; + let additions = 0; + let deletions = 0; + const lines = rows.map(([mark, content]) => { + if (mark === '-') { + deletions += 1; + return { kind: 'deletion' as const, oldLineNo: oldNo++, newLineNo: null, content }; + } + if (mark === '+') { + additions += 1; + return { kind: 'addition' as const, oldLineNo: null, newLineNo: newNo++, content }; + } + return { kind: 'context' as const, oldLineNo: oldNo++, newLineNo: newNo++, content }; + }); + return { + path, + oldPath: null, + status, + isBinary: false, + isImage: false, + oldImage: null, + newImage: null, + additions, + deletions, + hunks: [ + { + header: `@@ -${status === 'new' ? 0 : 1},${deletions} +${status === 'deleted' ? 0 : 1},${additions} @@`, + oldStart: status === 'new' ? 0 : 1, + oldLines: deletions, + newStart: status === 'deleted' ? 0 : 1, + newLines: additions, + lines, + }, + ], + }; +} + export function demoFileDiffFor(path: string): FileDiff { if (path === demoLargeFileDiff.path) return demoLargeFileDiff; + if (path === DEMO_LESS_PATH) { + return demoStatusDiff(path, 'modified', [ + [' ', '@accent: #2563eb;'], + ['-', '.btn { color: #111; }'], + ['+', '.btn {'], + ['+', ' color: @accent;'], + ['+', ' &:hover { color: darken(@accent, 8%); }'], + ['+', '}'], + ]); + } + if (path === DEMO_SCSS_PATH) { + return demoStatusDiff(path, 'modified', [ + [' ', '$radius: 8px;'], + ['-', '.chip { border-radius: 4px; }'], + ['+', '.chip {'], + ['+', ' border-radius: $radius;'], + ['+', ' &--active { font-weight: 600; }'], + ['+', '}'], + ]); + } + if (path === DEMO_DOCKERFILE_PATH) { + return demoStatusDiff(path, 'new', [ + ['+', 'FROM node:20-alpine'], + ['+', 'WORKDIR /app'], + ['+', 'COPY package.json bun.lock ./'], + ['+', 'RUN bun install --frozen-lockfile'], + ['+', 'COPY . .'], + ['+', 'CMD ["bun", "run", "dev"]'], + ]); + } + if (path === DEMO_MAKEFILE_PATH) { + return demoStatusDiff(path, 'new', [ + ['+', '.PHONY: build test'], + ['+', ''], + ['+', 'build:'], + ['+', '\tbun run build'], + ['+', ''], + ['+', 'test:'], + ['+', '\tbun test'], + ]); + } + if (path === DEMO_CMAKE_LISTS_PATH) { + return demoStatusDiff(path, 'new', [ + ['+', 'cmake_minimum_required(VERSION 3.20)'], + ['+', 'project(syntax_lab LANGUAGES CXX)'], + ['+', 'add_executable(app main.cpp)'], + ]); + } + if (path === DEMO_CMAKE_PATH) { + return demoStatusDiff(path, 'modified', [ + [' ', 'option(SYNTAX_LAB_TESTS "Build tests" ON)'], + ['-', 'set(SYNTAX_LAB_WARNINGS OFF)'], + ['+', 'set(SYNTAX_LAB_WARNINGS ON)'], + ]); + } + if (path === DEMO_GITIGNORE_PATH) { + return demoStatusDiff(path, 'modified', [ + [' ', 'node_modules/'], + [' ', 'dist/'], + ['-', '.env'], + ['+', '.env'], + ['+', '.env.local'], + ['+', '*.log'], + ]); + } + if (path === DEMO_EDITORCONFIG_PATH) { + return demoStatusDiff(path, 'new', [ + ['+', 'root = true'], + ['+', ''], + ['+', '[*]'], + ['+', 'indent_style = space'], + ['+', 'indent_size = 2'], + ]); + } + if (path === DEMO_INI_PATH) { + return demoStatusDiff(path, 'modified', [ + [' ', '[app]'], + ['-', 'theme = dark'], + ['+', 'theme = system'], + ['+', 'locale = en'], + ]); + } + if (path === DEMO_PROPERTIES_PATH) { + return demoStatusDiff(path, 'modified', [ + [' ', 'app.name=AngKorGit'], + ['-', 'app.tagline=Git client'], + ['+', 'app.tagline=Fast Git client'], + ]); + } return { ...demoFileDiff, path }; } diff --git a/apps/desktop/src/core/ipc.ts b/apps/desktop/src/core/ipc.ts index fbf92292..ae23a6da 100644 --- a/apps/desktop/src/core/ipc.ts +++ b/apps/desktop/src/core/ipc.ts @@ -123,12 +123,12 @@ export const ipc = { async openRepository(path: string): Promise { if (!isTauri()) { await delay(); - return demo.demoRepo; + return demo.demoRepoAt(path); } return invoke('repo_open', { path }); }, async repoInfo(path: string): Promise { - if (!isTauri()) return demo.demoRepo; + if (!isTauri()) return demo.demoRepoAt(path); return invoke('repo_info', { path }); }, async refFingerprint(path: string): Promise { @@ -147,7 +147,7 @@ export const ipc = { return invoke('repo_clone', { url, into, branch: branch ?? null }); }, async status(path: string): Promise { - if (!isTauri()) return demo.demoStatus; + if (!isTauri()) return demo.demoStatusAt(path); return invoke('repo_status', { path }); }, async stateCleanup(path: string): Promise { diff --git a/tests/unit/demoSyntaxLab.test.ts b/tests/unit/demoSyntaxLab.test.ts new file mode 100644 index 00000000..a856b854 --- /dev/null +++ b/tests/unit/demoSyntaxLab.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { + DEMO_DOCKERFILE_PATH, + DEMO_GITIGNORE_PATH, + DEMO_LESS_PATH, + DEMO_SCSS_PATH, + DEMO_SYNTAX_REPO_PATH, + demoFileDiffFor, + demoRecents, + demoRepoAt, + demoStatus, + demoStatusAt, +} from '../../apps/desktop/src/core/demo'; + +describe('syntax-lab demo fixture', () => { + it('is a recent with only suffix samples', () => { + expect(demoRecents.some((r) => r.path === DEMO_SYNTAX_REPO_PATH && r.name === 'syntax-lab')).toBe( + true, + ); + expect(demoRepoAt(DEMO_SYNTAX_REPO_PATH)).toMatchObject({ + name: 'syntax-lab', + path: DEMO_SYNTAX_REPO_PATH, + }); + expect(demoStatus.files.some((f) => f.path === DEMO_LESS_PATH)).toBe(false); + const files = demoStatusAt(DEMO_SYNTAX_REPO_PATH).files; + expect(files.map((f) => f.path)).toContain(DEMO_LESS_PATH); + expect(files.map((f) => f.path)).toContain(DEMO_DOCKERFILE_PATH); + expect(demoStatusAt(DEMO_SYNTAX_REPO_PATH)).toMatchObject({ ahead: 0, behind: 0 }); + }); + + it('serves highlightable diffs for less, scss, dockerfile, and gitignore', () => { + expect(demoFileDiffFor(DEMO_LESS_PATH).hunks[0]?.lines.some((l) => l.content.includes('@accent'))).toBe( + true, + ); + expect(demoFileDiffFor(DEMO_SCSS_PATH).hunks[0]?.lines.some((l) => l.content.includes('$radius'))).toBe( + true, + ); + expect(demoFileDiffFor(DEMO_DOCKERFILE_PATH).status).toBe('new'); + expect( + demoFileDiffFor(DEMO_GITIGNORE_PATH).hunks[0]?.lines.some((l) => l.content === '.env.local'), + ).toBe(true); + }); +}); From cfa708101bd024463cda09a33818a8a1b2a94acf Mon Sep 17 00:00:00 2001 From: noyobo Date: Wed, 23 Sep 2026 06:35:30 +0800 Subject: [PATCH 3/4] fix review --- CHANGELOG.md | 7 ++ CLAUDE.md | 2 +- apps/desktop/src/app/globals.css | 2 +- apps/desktop/src/core/demo.ts | 121 +---------------------- apps/desktop/src/core/ipc.ts | 6 +- apps/desktop/src/shared/highlight.ts | 7 +- tests/unit/demoHighlightFixtures.test.ts | 27 +++++ tests/unit/demoSyntaxLab.test.ts | 43 -------- tests/unit/highlight.test.ts | 11 ++- 9 files changed, 49 insertions(+), 177 deletions(-) create mode 100644 tests/unit/demoHighlightFixtures.test.ts delete mode 100644 tests/unit/demoSyntaxLab.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 76a3003c..5901ffa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ All notable changes to AngKorGit are documented here. The format follows ## [Unreleased] +### Added +- **More languages in the diff highlighter.** Diffs for Less, SCSS, Dockerfiles, + Makefiles, CMake, `.ini` / `.properties`, and `.editorconfig` pick up highlight.js + grammars instead of rendering as plain text. Common aliases (`cts`, `pyi`, `mdx`, + `kts`, โ€ฆ) and basenames (`Dockerfile`, `Makefile`, `CMakeLists.txt`) map to the + right grammar. CSS selector token classes are coloured so style diffs are readable. + ## [0.18.0] โ€” 2026-09-24 The review release. AI can now review one file from its diff or a whole commit from the diff --git a/CLAUDE.md b/CLAUDE.md index 1340e540..0275c278 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ craftsmanship from Cambodia ๐Ÿ‡ฐ๐Ÿ‡ญ. | Animation | **Framer Motion 11** (subtle; splash logo draw) | | | Router | react-router-dom 6 (MemoryRouter: `/welcome`, `/repo`) | | | Icons / fonts | lucide-react ยท Inter Variable + JetBrains Mono (self-hosted @fontsource) | | -| Key libs | @tanstack/react-virtual, react-resizable-panels, cmdk, sonner, @xterm/xterm, highlight.js (lib/core + 20 langs) | | +| Key libs | @tanstack/react-virtual, react-resizable-panels, cmdk, sonner, @xterm/xterm, highlight.js (lib/core + 26 langs) | | | Rust deps | tauri-plugin-dialog/opener, portable-pty, keyring 3, notify-debouncer-mini, reqwest (rustls), which 7, base64, thiserror | | ## 3. Monorepo layout (pnpm workspaces) diff --git a/apps/desktop/src/app/globals.css b/apps/desktop/src/app/globals.css index def71c7c..5707c878 100644 --- a/apps/desktop/src/app/globals.css +++ b/apps/desktop/src/app/globals.css @@ -65,7 +65,7 @@ body { .hljs-title, .hljs-function { color: hsl(var(--syntax-function)); } .hljs-attr, .hljs-attribute, .hljs-property, .hljs-variable, .hljs-selector-attr { color: hsl(var(--syntax-variable)); } .hljs-type, .hljs-class, .hljs-selector-tag, .hljs-selector-class, .hljs-selector-id { color: hsl(var(--syntax-type)); } -.hljs-selector-pseudo, .hljs-meta { color: hsl(var(--syntax-keyword)); } +.hljs-selector-pseudo { color: hsl(var(--syntax-keyword)); } /* xterm fills its panel */ .terminal-host .xterm { diff --git a/apps/desktop/src/core/demo.ts b/apps/desktop/src/core/demo.ts index d62c323d..9823c02e 100644 --- a/apps/desktop/src/core/demo.ts +++ b/apps/desktop/src/core/demo.ts @@ -7,7 +7,6 @@ import type { CommitFileInfo, CommitInfo, FileDiff, - FileStatus, HistoryPage, HistoryQuery, HttpRequest, @@ -104,39 +103,15 @@ export const demoRepo: RepositoryInfo = { mainPath: null, }; -/** Suffix-only Working Copy โ€” open this to judge highlight, not angkorgit WIP. */ -export const DEMO_SYNTAX_REPO_PATH = '/Users/demo/projects/syntax-lab'; - export const DEMO_LESS_PATH = 'styles/theme.less'; -export const DEMO_SCSS_PATH = 'styles/button.scss'; export const DEMO_DOCKERFILE_PATH = 'Dockerfile'; -export const DEMO_MAKEFILE_PATH = 'Makefile'; -export const DEMO_CMAKE_LISTS_PATH = 'CMakeLists.txt'; -export const DEMO_CMAKE_PATH = 'build/tool.cmake'; -export const DEMO_GITIGNORE_PATH = '.gitignore'; -export const DEMO_EDITORCONFIG_PATH = '.editorconfig'; -export const DEMO_INI_PATH = 'config/app.ini'; -export const DEMO_PROPERTIES_PATH = 'config/messages.properties'; export const demoRecents: RecentRepository[] = [ { path: '/Users/demo/projects/angkorgit', name: 'angkorgit', lastOpenedAt: 1754200000 }, { path: '/Users/demo/projects/temple-ui', name: 'temple-ui', lastOpenedAt: 1754100000 }, - { path: DEMO_SYNTAX_REPO_PATH, name: 'syntax-lab', lastOpenedAt: 1754000000 }, { path: '/Users/demo/work/api-gateway', name: 'api-gateway', lastOpenedAt: 1753900000 }, ]; -export function demoRepoAt(path: string): RepositoryInfo { - if (path === DEMO_SYNTAX_REPO_PATH) { - return { - ...demoRepo, - path, - name: 'syntax-lab', - state: 'clean', - }; - } - return demoRepo; -} - export const demoFonts = [ { family: 'Fira Code', monospaced: true }, { family: 'Helvetica Neue', monospaced: false }, @@ -210,37 +185,14 @@ export const demoStatus: StatusSummary = { unstaged: 'untracked', }, { path: 'src/old-layout.tsx', origPath: null, staged: 'deleted', unstaged: null }, + { path: DEMO_LESS_PATH, origPath: null, staged: null, unstaged: 'modified' }, + { path: DEMO_DOCKERFILE_PATH, origPath: null, staged: null, unstaged: 'untracked' }, ], branch: 'main', ahead: 2, behind: 0, }; -const DEMO_SYNTAX_STATUS_SEED: FileStatus[] = [ - { path: DEMO_LESS_PATH, origPath: null, staged: null, unstaged: 'modified' }, - { path: DEMO_SCSS_PATH, origPath: null, staged: null, unstaged: 'modified' }, - { path: DEMO_DOCKERFILE_PATH, origPath: null, staged: null, unstaged: 'untracked' }, - { path: DEMO_MAKEFILE_PATH, origPath: null, staged: null, unstaged: 'untracked' }, - { path: DEMO_CMAKE_LISTS_PATH, origPath: null, staged: null, unstaged: 'untracked' }, - { path: DEMO_CMAKE_PATH, origPath: null, staged: null, unstaged: 'modified' }, - { path: DEMO_GITIGNORE_PATH, origPath: null, staged: null, unstaged: 'modified' }, - { path: DEMO_EDITORCONFIG_PATH, origPath: null, staged: null, unstaged: 'untracked' }, - { path: DEMO_INI_PATH, origPath: null, staged: null, unstaged: 'modified' }, - { path: DEMO_PROPERTIES_PATH, origPath: null, staged: null, unstaged: 'modified' }, -]; - -export function demoStatusAt(path: string): StatusSummary { - if (path === DEMO_SYNTAX_REPO_PATH) { - return { - files: DEMO_SYNTAX_STATUS_SEED.map((f) => ({ ...f })), - branch: 'main', - ahead: 0, - behind: 0, - }; - } - return demoStatus; -} - export const demoBranches: BranchInfo[] = [ { name: 'main', isHead: true, isRemote: false, upstream: 'origin/main', ahead: 2, behind: 0, targetOid: ALL_COMMITS[0].oid }, { name: 'develop', isHead: false, isRemote: false, upstream: null, ahead: 0, behind: 0, targetOid: ALL_COMMITS[20].oid }, @@ -510,16 +462,6 @@ export function demoFileDiffFor(path: string): FileDiff { ['+', '}'], ]); } - if (path === DEMO_SCSS_PATH) { - return demoStatusDiff(path, 'modified', [ - [' ', '$radius: 8px;'], - ['-', '.chip { border-radius: 4px; }'], - ['+', '.chip {'], - ['+', ' border-radius: $radius;'], - ['+', ' &--active { font-weight: 600; }'], - ['+', '}'], - ]); - } if (path === DEMO_DOCKERFILE_PATH) { return demoStatusDiff(path, 'new', [ ['+', 'FROM node:20-alpine'], @@ -530,65 +472,6 @@ export function demoFileDiffFor(path: string): FileDiff { ['+', 'CMD ["bun", "run", "dev"]'], ]); } - if (path === DEMO_MAKEFILE_PATH) { - return demoStatusDiff(path, 'new', [ - ['+', '.PHONY: build test'], - ['+', ''], - ['+', 'build:'], - ['+', '\tbun run build'], - ['+', ''], - ['+', 'test:'], - ['+', '\tbun test'], - ]); - } - if (path === DEMO_CMAKE_LISTS_PATH) { - return demoStatusDiff(path, 'new', [ - ['+', 'cmake_minimum_required(VERSION 3.20)'], - ['+', 'project(syntax_lab LANGUAGES CXX)'], - ['+', 'add_executable(app main.cpp)'], - ]); - } - if (path === DEMO_CMAKE_PATH) { - return demoStatusDiff(path, 'modified', [ - [' ', 'option(SYNTAX_LAB_TESTS "Build tests" ON)'], - ['-', 'set(SYNTAX_LAB_WARNINGS OFF)'], - ['+', 'set(SYNTAX_LAB_WARNINGS ON)'], - ]); - } - if (path === DEMO_GITIGNORE_PATH) { - return demoStatusDiff(path, 'modified', [ - [' ', 'node_modules/'], - [' ', 'dist/'], - ['-', '.env'], - ['+', '.env'], - ['+', '.env.local'], - ['+', '*.log'], - ]); - } - if (path === DEMO_EDITORCONFIG_PATH) { - return demoStatusDiff(path, 'new', [ - ['+', 'root = true'], - ['+', ''], - ['+', '[*]'], - ['+', 'indent_style = space'], - ['+', 'indent_size = 2'], - ]); - } - if (path === DEMO_INI_PATH) { - return demoStatusDiff(path, 'modified', [ - [' ', '[app]'], - ['-', 'theme = dark'], - ['+', 'theme = system'], - ['+', 'locale = en'], - ]); - } - if (path === DEMO_PROPERTIES_PATH) { - return demoStatusDiff(path, 'modified', [ - [' ', 'app.name=AngKorGit'], - ['-', 'app.tagline=Git client'], - ['+', 'app.tagline=Fast Git client'], - ]); - } return { ...demoFileDiff, path }; } diff --git a/apps/desktop/src/core/ipc.ts b/apps/desktop/src/core/ipc.ts index ae23a6da..fbf92292 100644 --- a/apps/desktop/src/core/ipc.ts +++ b/apps/desktop/src/core/ipc.ts @@ -123,12 +123,12 @@ export const ipc = { async openRepository(path: string): Promise { if (!isTauri()) { await delay(); - return demo.demoRepoAt(path); + return demo.demoRepo; } return invoke('repo_open', { path }); }, async repoInfo(path: string): Promise { - if (!isTauri()) return demo.demoRepoAt(path); + if (!isTauri()) return demo.demoRepo; return invoke('repo_info', { path }); }, async refFingerprint(path: string): Promise { @@ -147,7 +147,7 @@ export const ipc = { return invoke('repo_clone', { url, into, branch: branch ?? null }); }, async status(path: string): Promise { - if (!isTauri()) return demo.demoStatusAt(path); + if (!isTauri()) return demo.demoStatus; return invoke('repo_status', { path }); }, async stateCleanup(path: string): Promise { diff --git a/apps/desktop/src/shared/highlight.ts b/apps/desktop/src/shared/highlight.ts index 21f71a8f..47a0ca65 100644 --- a/apps/desktop/src/shared/highlight.ts +++ b/apps/desktop/src/shared/highlight.ts @@ -53,15 +53,12 @@ hljs.registerLanguage('dockerfile', dockerfile); hljs.registerLanguage('makefile', makefile); hljs.registerLanguage('cmake', cmake); -/** Exact file basenames (lowercased), including leading-dot names. */ const BASENAME_TO_LANG: Record = { dockerfile: 'dockerfile', makefile: 'makefile', gnumakefile: 'makefile', 'cmakelists.txt': 'cmake', - '.gitignore': 'properties', - '.gitattributes': 'properties', - '.editorconfig': 'properties', + '.editorconfig': 'ini', }; const EXT_TO_LANG: Record = { @@ -112,8 +109,6 @@ const EXT_TO_LANG: Record = { kts: 'kotlin', swift: 'swift', ini: 'ini', - cfg: 'ini', - conf: 'ini', properties: 'properties', cmake: 'cmake', }; diff --git a/tests/unit/demoHighlightFixtures.test.ts b/tests/unit/demoHighlightFixtures.test.ts new file mode 100644 index 00000000..ee7edca6 --- /dev/null +++ b/tests/unit/demoHighlightFixtures.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { + DEMO_DOCKERFILE_PATH, + DEMO_LESS_PATH, + demoFileDiffFor, + demoRecents, + demoStatus, +} from '../../apps/desktop/src/core/demo'; + +describe('demo highlight fixtures', () => { + it('keeps less and Dockerfile samples on the main demo working copy', () => { + expect(demoRecents.some((r) => r.name === 'syntax-lab')).toBe(false); + const paths = demoStatus.files.map((f) => f.path); + expect(paths).toContain(DEMO_LESS_PATH); + expect(paths).toContain(DEMO_DOCKERFILE_PATH); + }); + + it('serves highlightable diffs for less and dockerfile', () => { + expect(demoFileDiffFor(DEMO_LESS_PATH).hunks[0]?.lines.some((l) => l.content.includes('@accent'))).toBe( + true, + ); + expect(demoFileDiffFor(DEMO_DOCKERFILE_PATH).status).toBe('new'); + expect( + demoFileDiffFor(DEMO_DOCKERFILE_PATH).hunks[0]?.lines.some((l) => l.content.startsWith('FROM ')), + ).toBe(true); + }); +}); diff --git a/tests/unit/demoSyntaxLab.test.ts b/tests/unit/demoSyntaxLab.test.ts deleted file mode 100644 index a856b854..00000000 --- a/tests/unit/demoSyntaxLab.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - DEMO_DOCKERFILE_PATH, - DEMO_GITIGNORE_PATH, - DEMO_LESS_PATH, - DEMO_SCSS_PATH, - DEMO_SYNTAX_REPO_PATH, - demoFileDiffFor, - demoRecents, - demoRepoAt, - demoStatus, - demoStatusAt, -} from '../../apps/desktop/src/core/demo'; - -describe('syntax-lab demo fixture', () => { - it('is a recent with only suffix samples', () => { - expect(demoRecents.some((r) => r.path === DEMO_SYNTAX_REPO_PATH && r.name === 'syntax-lab')).toBe( - true, - ); - expect(demoRepoAt(DEMO_SYNTAX_REPO_PATH)).toMatchObject({ - name: 'syntax-lab', - path: DEMO_SYNTAX_REPO_PATH, - }); - expect(demoStatus.files.some((f) => f.path === DEMO_LESS_PATH)).toBe(false); - const files = demoStatusAt(DEMO_SYNTAX_REPO_PATH).files; - expect(files.map((f) => f.path)).toContain(DEMO_LESS_PATH); - expect(files.map((f) => f.path)).toContain(DEMO_DOCKERFILE_PATH); - expect(demoStatusAt(DEMO_SYNTAX_REPO_PATH)).toMatchObject({ ahead: 0, behind: 0 }); - }); - - it('serves highlightable diffs for less, scss, dockerfile, and gitignore', () => { - expect(demoFileDiffFor(DEMO_LESS_PATH).hunks[0]?.lines.some((l) => l.content.includes('@accent'))).toBe( - true, - ); - expect(demoFileDiffFor(DEMO_SCSS_PATH).hunks[0]?.lines.some((l) => l.content.includes('$radius'))).toBe( - true, - ); - expect(demoFileDiffFor(DEMO_DOCKERFILE_PATH).status).toBe('new'); - expect( - demoFileDiffFor(DEMO_GITIGNORE_PATH).hunks[0]?.lines.some((l) => l.content === '.env.local'), - ).toBe(true); - }); -}); diff --git a/tests/unit/highlight.test.ts b/tests/unit/highlight.test.ts index 636c9545..d857fc30 100644 --- a/tests/unit/highlight.test.ts +++ b/tests/unit/highlight.test.ts @@ -84,8 +84,11 @@ describe('highlightLineState', () => { expect(languageOf('styles/app.scss')).toBe('scss'); expect(languageOf('config/app.ini')).toBe('ini'); expect(languageOf('messages.properties')).toBe('properties'); - expect(languageOf('repo/.gitignore')).toBe('properties'); - expect(languageOf('.editorconfig')).toBe('properties'); + expect(languageOf('repo/.gitignore')).toBeNull(); + expect(languageOf('.gitattributes')).toBeNull(); + expect(languageOf('.editorconfig')).toBe('ini'); + expect(languageOf('nginx.conf')).toBeNull(); + expect(languageOf('app.cfg')).toBeNull(); expect(languageOf('Dockerfile')).toBe('dockerfile'); expect(languageOf('path/Makefile')).toBe('makefile'); expect(languageOf('CMakeLists.txt')).toBe('cmake'); @@ -98,8 +101,8 @@ describe('highlightLineState', () => { expect(scssLine.html).toContain('hljs-variable'); const docker = highlightLineState('FROM node:20-alpine', 'dockerfile'); expect(docker.html).toContain('hljs-keyword'); - const ignore = highlightLineState('node_modules/', 'properties'); - expect(text(ignore.html)).toBe('node_modules/'); + const editorconfig = highlightLineState('[*]', 'ini'); + expect(editorconfig.html).toContain('hljs-section'); }); it('ignores the continuation flag for languages without block comments', () => { From 0ba3b7a31661d2a0776781f59d23f1949c681b7b Mon Sep 17 00:00:00 2001 From: noyobo Date: Thu, 24 Sep 2026 16:00:35 +0800 Subject: [PATCH 4/4] fix: count context lines in demo diff hunk headers The working-copy All files check still expected 6 files after the highlight fixtures were added. --- apps/desktop/src/core/demo.ts | 16 +++++++++++----- tests/e2e/smoke.spec.ts | 2 +- tests/unit/demoHighlightFixtures.test.ts | 8 +++----- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/core/demo.ts b/apps/desktop/src/core/demo.ts index 9823c02e..4f9d2bbb 100644 --- a/apps/desktop/src/core/demo.ts +++ b/apps/desktop/src/core/demo.ts @@ -416,6 +416,7 @@ function demoStatusDiff( let newNo = status === 'deleted' ? 0 : 1; let additions = 0; let deletions = 0; + let context = 0; const lines = rows.map(([mark, content]) => { if (mark === '-') { deletions += 1; @@ -425,8 +426,13 @@ function demoStatusDiff( additions += 1; return { kind: 'addition' as const, oldLineNo: null, newLineNo: newNo++, content }; } + context += 1; return { kind: 'context' as const, oldLineNo: oldNo++, newLineNo: newNo++, content }; }); + const oldLines = deletions + context; + const newLines = additions + context; + const oldStart = oldLines === 0 ? 0 : 1; + const newStart = newLines === 0 ? 0 : 1; return { path, oldPath: null, @@ -439,11 +445,11 @@ function demoStatusDiff( deletions, hunks: [ { - header: `@@ -${status === 'new' ? 0 : 1},${deletions} +${status === 'deleted' ? 0 : 1},${additions} @@`, - oldStart: status === 'new' ? 0 : 1, - oldLines: deletions, - newStart: status === 'deleted' ? 0 : 1, - newLines: additions, + header: `@@ -${oldStart},${oldLines} +${newStart},${newLines} @@`, + oldStart, + oldLines, + newStart, + newLines, lines, }, ], diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts index 59e4bceb..3b912eb9 100644 --- a/tests/e2e/smoke.spec.ts +++ b/tests/e2e/smoke.spec.ts @@ -1890,7 +1890,7 @@ test('the All files view shows the whole working tree with changed files still a await expect(inspector.getByText('README.md')).toHaveCount(0); await page.getByRole('button', { name: 'All files' }).click(); - await expect(inspector.getByText('6 changed')).toBeVisible(); + await expect(inspector.getByText('8 changed')).toBeVisible(); await expect(inspector.getByText('README.md')).toBeVisible(); await expect(inspector.getByLabel('Stage src/core/ipc.ts')).toBeVisible(); await expect(inspector.getByLabel('Unstage src/features/graph/CommitGraph.tsx')).toBeVisible(); diff --git a/tests/unit/demoHighlightFixtures.test.ts b/tests/unit/demoHighlightFixtures.test.ts index ee7edca6..78fa65e2 100644 --- a/tests/unit/demoHighlightFixtures.test.ts +++ b/tests/unit/demoHighlightFixtures.test.ts @@ -3,22 +3,20 @@ import { DEMO_DOCKERFILE_PATH, DEMO_LESS_PATH, demoFileDiffFor, - demoRecents, demoStatus, } from '../../apps/desktop/src/core/demo'; describe('demo highlight fixtures', () => { it('keeps less and Dockerfile samples on the main demo working copy', () => { - expect(demoRecents.some((r) => r.name === 'syntax-lab')).toBe(false); const paths = demoStatus.files.map((f) => f.path); expect(paths).toContain(DEMO_LESS_PATH); expect(paths).toContain(DEMO_DOCKERFILE_PATH); }); it('serves highlightable diffs for less and dockerfile', () => { - expect(demoFileDiffFor(DEMO_LESS_PATH).hunks[0]?.lines.some((l) => l.content.includes('@accent'))).toBe( - true, - ); + const less = demoFileDiffFor(DEMO_LESS_PATH); + expect(less.hunks[0]?.header).toBe('@@ -1,2 +1,5 @@'); + expect(less.hunks[0]?.lines.some((l) => l.content.includes('@accent'))).toBe(true); expect(demoFileDiffFor(DEMO_DOCKERFILE_PATH).status).toBe('new'); expect( demoFileDiffFor(DEMO_DOCKERFILE_PATH).hunks[0]?.lines.some((l) => l.content.startsWith('FROM ')),