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 4df2e026..5707c878 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 { 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 dd2b6785..4f9d2bbb 100644 --- a/apps/desktop/src/core/demo.ts +++ b/apps/desktop/src/core/demo.ts @@ -103,6 +103,9 @@ export const demoRepo: RepositoryInfo = { mainPath: null, }; +export const DEMO_LESS_PATH = 'styles/theme.less'; +export const DEMO_DOCKERFILE_PATH = 'Dockerfile'; + export const demoRecents: RecentRepository[] = [ { path: '/Users/demo/projects/angkorgit', name: 'angkorgit', lastOpenedAt: 1754200000 }, { path: '/Users/demo/projects/temple-ui', name: 'temple-ui', lastOpenedAt: 1754100000 }, @@ -182,6 +185,8 @@ 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, @@ -402,8 +407,77 @@ 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; + let context = 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 }; + } + 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, + status, + isBinary: false, + isImage: false, + oldImage: null, + newImage: null, + additions, + deletions, + hunks: [ + { + header: `@@ -${oldStart},${oldLines} +${newStart},${newLines} @@`, + oldStart, + oldLines, + newStart, + newLines, + 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_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"]'], + ]); + } return { ...demoFileDiff, path }; } diff --git a/apps/desktop/src/shared/highlight.ts b/apps/desktop/src/shared/highlight.ts index 4c1d0015..47a0ca65 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,32 @@ 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); + +const BASENAME_TO_LANG: Record = { + dockerfile: 'dockerfile', + makefile: 'makefile', + gnumakefile: 'makefile', + 'cmakelists.txt': 'cmake', + '.editorconfig': 'ini', +}; 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 +81,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 +100,31 @@ 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', + 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 +139,8 @@ const BLOCK_COMMENT_OPENERS: Record = { csharp: '/*', cpp: '/*', css: '/*', + less: '/*', + scss: '/*', kotlin: '/*', swift: '/*', php: '/*', 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 new file mode 100644 index 00000000..78fa65e2 --- /dev/null +++ b/tests/unit/demoHighlightFixtures.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { + DEMO_DOCKERFILE_PATH, + DEMO_LESS_PATH, + demoFileDiffFor, + demoStatus, +} from '../../apps/desktop/src/core/demo'; + +describe('demo highlight fixtures', () => { + it('keeps less and Dockerfile samples on the main demo working copy', () => { + 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', () => { + 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 ')), + ).toBe(true); + }); +}); diff --git a/tests/unit/highlight.test.ts b/tests/unit/highlight.test.ts index c1f981d2..d857fc30 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,48 @@ 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')).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'); + 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 editorconfig = highlightLineState('[*]', 'ini'); + expect(editorconfig.html).toContain('hljs-section'); + }); + it('ignores the continuation flag for languages without block comments', () => { expect(supportsBlockComments('python')).toBe(false); const line = highlightLineState('x = 1', 'python', true);