From 7195b4929c30ba001edac74f7ad1037b0a09ca6e Mon Sep 17 00:00:00 2001 From: KKranthi6881 Date: Tue, 21 Apr 2026 22:32:12 -0500 Subject: [PATCH] v1.0.4: v0.12 Git-native versioning & collaboration-lite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T1 — Canonical .dqlnb serialization New packages/dql-core/src/format/notebook.ts with canonicalizeNotebook, parseDqlNotebook version-aware loader, key-sorted JSON emitter, and `dql migrate format` that walks the project and upgrades .dql/.dqlnb in place. T2 — `dql diff` polish diffNotebook + cell/dashboard/workbook change variants; `dql diff ` now diffs against HEAD via the new git-service helper. T3 — In-app git service /api/git/{status,diff,log} endpoints. /api/git/diff returns raw unified diff plus a semantic DiffReport for .dql/.dqlnb, reading HEAD blob and working-copy concurrently. T4 — Git panel UI New GitDiffView renders semantic DiffReport with per-field pills and falls back to raw unified diff for non-DQL files. GitPanel polls status every 2s with order-independent change detection so terminal-side edits surface without clicking refresh. T5 — Run snapshots /api/run-snapshot persists last-run results to sibling .{dql,dqlnb}.run.json (gitignored). Notebooks hydrate on open and cells render a "cached" chip until re-run. Simplify pass: parallelized git shell-outs, O(1) cell-hydration lookup, always-clear pending-timer cleanup in the snapshot autosave, single try/catch in place of existsSync+readFileSync TOCTOU, stat-based .git root probe. Co-Authored-By: Claude Opus 4.7 --- .gitignore | 4 +- apps/cli/package.json | 11 +- apps/cli/src/commands/diff.ts | 51 ++++- apps/cli/src/commands/fmt.ts | 6 +- apps/cli/src/commands/migrate.ts | 90 ++++++++ apps/cli/src/git-service.ts | 60 ++++++ apps/cli/src/index.ts | 6 +- apps/cli/src/local-runtime.ts | 77 ++++++- apps/dql-notebook/package.json | 3 +- apps/dql-notebook/src/api/client.ts | 11 +- .../src/components/cells/Cell.tsx | 16 ++ .../src/components/layout/AppShell.tsx | 33 +-- .../src/components/sidebar/GitDiffView.tsx | 174 +++++++++++++++ .../src/components/sidebar/GitPanel.tsx | 108 ++++------ .../src/hooks/useQueryExecution.ts | 2 +- .../src/hooks/useRunSnapshotAutosave.ts | 25 ++- apps/dql-notebook/src/store/types.ts | 1 + apps/dql-notebook/src/utils/parse-workbook.ts | 6 +- .../gallery/jaffle-shop-lineage-demo.dqlnb | 25 +-- packages/create-dql-app/package.json | 2 +- .../templates/empty/notebooks/welcome.dqlnb | 3 +- .../jaffle-shop/notebooks/welcome.dqlnb | 7 +- packages/dql-charts/package.json | 2 +- packages/dql-compiler/package.json | 2 +- packages/dql-connectors/package.json | 2 +- packages/dql-core/package.json | 6 +- packages/dql-core/src/format/diff.test.ts | 94 ++++++++- packages/dql-core/src/format/diff.ts | 150 ++++++++++++- packages/dql-core/src/format/index.ts | 1 + packages/dql-core/src/format/notebook.test.ts | 88 ++++++++ packages/dql-core/src/format/notebook.ts | 104 +++++++++ packages/dql-governance/package.json | 2 +- packages/dql-lsp/package.json | 2 +- packages/dql-notebook/package.json | 2 +- packages/dql-project/package.json | 2 +- packages/dql-runtime/package.json | 2 +- pnpm-lock.yaml | 198 ++++++++++++++++++ 37 files changed, 1220 insertions(+), 158 deletions(-) create mode 100644 apps/cli/src/git-service.ts create mode 100644 apps/dql-notebook/src/components/sidebar/GitDiffView.tsx create mode 100644 packages/dql-core/src/format/notebook.test.ts create mode 100644 packages/dql-core/src/format/notebook.ts diff --git a/.gitignore b/.gitignore index 0ae8109b..52ab925d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,6 @@ npm-debug.log* pnpm-debug.log* jaffle-shop/ !packages/create-dql-app/templates/jaffle-shop/ -.claude/worktrees/ +.claude/ +*.dqlnb.run.json +*.dql.run.json diff --git a/apps/cli/package.json b/apps/cli/package.json index 3d60aebe..2f2dc4fc 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@duckcodeailabs/dql-cli", - "version": "1.0.3", + "version": "1.0.4", "description": "Public CLI for parsing, formatting, testing, and certifying DQL blocks", "license": "Apache-2.0", "type": "module", @@ -27,17 +27,18 @@ "directory": "apps/cli" }, "dependencies": { + "@duckcodeailabs/dql-compiler": "workspace:*", "@duckcodeailabs/dql-connectors": "workspace:*", "@duckcodeailabs/dql-core": "workspace:*", - "@duckcodeailabs/dql-compiler": "workspace:*", - "@duckcodeailabs/dql-project": "workspace:*", "@duckcodeailabs/dql-governance": "workspace:*", - "@duckcodeailabs/dql-notebook": "workspace:*" + "@duckcodeailabs/dql-notebook": "workspace:*", + "@duckcodeailabs/dql-project": "workspace:*", + "isomorphic-git": "^1.27.0" }, "devDependencies": { "@duckcodeailabs/dql-notebook-app": "workspace:*", - "typescript": "^5.7.0", "tsx": "^4.19.0", + "typescript": "^5.7.0", "vitest": "^3.0.0" }, "engines": { diff --git a/apps/cli/src/commands/diff.ts b/apps/cli/src/commands/diff.ts index d3d52fa9..9fa9d9f0 100644 --- a/apps/cli/src/commands/diff.ts +++ b/apps/cli/src/commands/diff.ts @@ -1,21 +1,24 @@ -import { readFileSync } from 'node:fs'; -import { diffDQL, renderDiffText } from '@duckcodeailabs/dql-core'; +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { diffDQL, diffNotebook, renderDiffText, type DiffReport } from '@duckcodeailabs/dql-core'; +import { findRepoContext, readHeadBlob } from '../git-service.js'; import type { CLIFlags } from '../args.js'; export async function runDiff( - beforePath: string | null, + firstArg: string | null, rest: string[], flags: CLIFlags, ): Promise { - const afterPath = rest[0]; - if (!beforePath || !afterPath) { - console.error('Usage: dql diff '); + const secondArg = rest[0]; + + if (!firstArg) { + console.error('Usage: dql diff | dql diff '); process.exit(1); } - const before = readFileSync(beforePath, 'utf-8'); - const after = readFileSync(afterPath, 'utf-8'); - const report = diffDQL(before, after); + const report = secondArg + ? await diffTwoFiles(firstArg, secondArg) + : await diffAgainstHead(firstArg); if (flags.format === 'json') { console.log(JSON.stringify(report, null, 2)); @@ -27,3 +30,33 @@ export async function runDiff( // (`dql diff a b && echo unchanged`), mirroring git-diff and fmt --check. if (!report.identical) process.exit(1); } + +async function diffTwoFiles(beforePath: string, afterPath: string): Promise { + const before = readFileSync(beforePath, 'utf-8'); + const after = readFileSync(afterPath, 'utf-8'); + return diffByExtension(afterPath, before, after); +} + +async function diffAgainstHead(path: string): Promise { + const absPath = resolve(path); + if (!existsSync(absPath)) { + console.error(`Error: file not found: ${path}`); + process.exit(1); + } + const ctx = findRepoContext(absPath); + if (!ctx) { + console.error(`Error: ${path} is not inside a git repository. Use: dql diff `); + process.exit(1); + } + const before = await readHeadBlob(ctx); + const after = readFileSync(absPath, 'utf-8'); + return diffByExtension(absPath, before, after); +} + +function diffByExtension(path: string, before: string | null, after: string): DiffReport { + if (path.endsWith('.dqlnb')) { + return diffNotebook(before, after); + } + // diffDQL doesn't accept null — newly added files diff against empty source. + return diffDQL(before ?? '', after); +} diff --git a/apps/cli/src/commands/fmt.ts b/apps/cli/src/commands/fmt.ts index 41cbab52..acdf9f16 100644 --- a/apps/cli/src/commands/fmt.ts +++ b/apps/cli/src/commands/fmt.ts @@ -1,10 +1,12 @@ import { readFileSync, writeFileSync } from 'node:fs'; -import { canonicalize } from '@duckcodeailabs/dql-core'; +import { canonicalize, canonicalizeNotebook } from '@duckcodeailabs/dql-core'; import type { CLIFlags } from '../args.js'; export async function runFmt(filePath: string, flags: CLIFlags): Promise { const source = readFileSync(filePath, 'utf-8'); - const formatted = canonicalize(source); + const formatted = filePath.endsWith('.dqlnb') + ? canonicalizeNotebook(source) + : canonicalize(source); const changed = source !== formatted; if (flags.check) { diff --git a/apps/cli/src/commands/migrate.ts b/apps/cli/src/commands/migrate.ts index 9e82604e..a66febd1 100644 --- a/apps/cli/src/commands/migrate.ts +++ b/apps/cli/src/commands/migrate.ts @@ -1,3 +1,6 @@ +import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { canonicalize, canonicalizeNotebook } from '@duckcodeailabs/dql-core'; import type { CLIFlags } from '../args.js'; export type MigrationSource = 'looker' | 'tableau' | 'dbt' | 'metabase' | 'raw-sql'; @@ -48,6 +51,10 @@ function generateBlockDQL(opts: { } export async function runMigrate(file: string, flags: CLIFlags): Promise { + if (file === 'format') { + await runFormatMigrate(flags.input || '.', flags); + return; + } // file is used as the source type for migration const source = file as MigrationSource; const validSources: MigrationSource[] = ['looker', 'tableau', 'dbt', 'metabase', 'raw-sql']; @@ -55,6 +62,7 @@ export async function runMigrate(file: string, flags: CLIFlags): Promise { if (!validSources.includes(source)) { console.error(`\n ✗ Unknown migration source: "${source}"`); console.error(` Valid sources: ${validSources.join(', ')}`); + console.error(` Or: "format" to upgrade .dql/.dqlnb files to the canonical on-disk format`); console.error(''); process.exit(1); } @@ -130,3 +138,85 @@ export async function runMigrate(file: string, flags: CLIFlags): Promise { console.log(' 4. Commit and push for certification'); console.log(''); } + +interface FormatMigrateReport { + scanned: number; + alreadyCanonical: number; + upgraded: number; + failed: Array<{ path: string; error: string }>; + dryRun: boolean; +} + +export async function runFormatMigrate(root: string, flags: CLIFlags): Promise { + const dryRun = flags.check === true; + const report: FormatMigrateReport = { + scanned: 0, + alreadyCanonical: 0, + upgraded: 0, + failed: [], + dryRun, + }; + + for (const absPath of walkDqlFiles(root)) { + report.scanned += 1; + const rel = relative(root, absPath) || absPath; + const source = readFileSync(absPath, 'utf-8'); + let canonical: string; + try { + canonical = absPath.endsWith('.dqlnb') ? canonicalizeNotebook(source) : canonicalize(source); + } catch (error) { + report.failed.push({ path: rel, error: error instanceof Error ? error.message : String(error) }); + continue; + } + if (canonical === source) { + report.alreadyCanonical += 1; + continue; + } + if (!dryRun) writeFileSync(absPath, canonical, 'utf-8'); + report.upgraded += 1; + } + + if (flags.format === 'json') { + console.log(JSON.stringify(report, null, 2)); + if (report.failed.length > 0) process.exit(1); + return; + } + + console.log(`\n DQL format migration${dryRun ? ' (dry run)' : ''}`); + console.log(' ─────────────────────────────'); + console.log(` Scanned: ${report.scanned}`); + console.log(` Already canonical: ${report.alreadyCanonical}`); + console.log(` ${dryRun ? 'Would upgrade' : 'Upgraded'}: ${report.upgraded}`); + if (report.failed.length > 0) { + console.log(` Failed: ${report.failed.length}`); + for (const f of report.failed) console.log(` ✗ ${f.path}: ${f.error}`); + process.exit(1); + } + console.log(''); +} + +function* walkDqlFiles(root: string): Generator { + const stack: string[] = [root]; + while (stack.length > 0) { + const dir = stack.pop()!; + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'target') continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) { + stack.push(full); + } else if (entry.isFile() && (entry.name.endsWith('.dql') || entry.name.endsWith('.dqlnb'))) { + try { + if (statSync(full).size > 0) yield full; + } catch { + // skip unreadable + } + } + } + } +} diff --git a/apps/cli/src/git-service.ts b/apps/cli/src/git-service.ts new file mode 100644 index 00000000..28027787 --- /dev/null +++ b/apps/cli/src/git-service.ts @@ -0,0 +1,60 @@ +// Thin isomorphic-git wrapper used by `dql diff ` to resolve the +// HEAD blob for a file inside a git repo. + +import { statSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import git from 'isomorphic-git'; +import * as fs from 'node:fs'; + +export interface RepoContext { + /** Absolute path to the repository root (where `.git` lives). */ + dir: string; + /** Relative path of the requested file from the repo root, POSIX-separated. */ + relpath: string; +} + +/** + * Locate the git repository containing `absPath`. Walks upward looking + * for a `.git` directory. Returns `null` when no repository is found. + */ +export function findRepoContext(absPath: string): RepoContext | null { + let dir = resolve(absPath); + if (!statSync(dir, { throwIfNoEntry: false })?.isDirectory()) { + dir = resolve(dir, '..'); + } + while (true) { + if (statSync(join(dir, '.git'), { throwIfNoEntry: false })) { + const rel = relative(dir, absPath).split(/[\\/]/).join('/'); + return { dir, relpath: rel }; + } + const parent = resolve(dir, '..'); + if (parent === dir) return null; + dir = parent; + } +} + +/** + * Read the HEAD-committed contents of `relpath` as a UTF-8 string. Returns + * `null` if HEAD has no such file (newly added in the working copy). + */ +export async function readHeadBlob(ctx: RepoContext): Promise { + try { + const commitSha = await git.resolveRef({ fs, dir: ctx.dir, ref: 'HEAD' }); + const { blob } = await git.readBlob({ + fs, + dir: ctx.dir, + oid: commitSha, + filepath: ctx.relpath, + }); + return new TextDecoder('utf-8').decode(blob); + } catch (error) { + if (isNotFoundError(error)) return null; + throw error; + } +} + +function isNotFoundError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const code = (error as { code?: string }).code; + return code === 'NotFoundError' || code === 'ENOENT'; +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index ec147048..42a2cc6b 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -40,8 +40,10 @@ const HELP = ` dql certify Evaluate certification rules dql info Show block metadata dql migrate Scaffold migration from looker/tableau/dbt/metabase/raw-sql - dql fmt Format DQL file in place - dql diff Semantic diff between two .dql files + dql migrate format [--check] Upgrade all .dql/.dqlnb files to canonical format + dql fmt Format DQL/notebook file in place + dql diff Diff a .dql/.dqlnb file vs HEAD + dql diff Semantic diff between two files dql notebook [path] Launch the browser-first notebook for a project dql semantic [path] Semantic layer: list, validate, query, pull dql compile [path] Generate project manifest (dql-manifest.json) diff --git a/apps/cli/src/local-runtime.ts b/apps/cli/src/local-runtime.ts index 05e1c699..fac4c925 100644 --- a/apps/cli/src/local-runtime.ts +++ b/apps/cli/src/local-runtime.ts @@ -34,6 +34,10 @@ import { type LineageMetricInput, type LineageDimensionInput, canonicalize, + canonicalizeNotebook, + diffDQL, + diffNotebook, + type DiffReport, } from '@duckcodeailabs/dql-core'; import { listBlockTemplates } from './block-templates.js'; import { @@ -272,7 +276,11 @@ export async function startLocalServer(opts: LocalServerOptions): Promise { +async function readGitDiff( + cwd: string, + filePath: string, +): Promise<{ + inRepo: boolean; + diff: string; + before: string | null; + after: string | null; + diffReport: DiffReport | null; +}> { const isRepo = await execGit(cwd, ['rev-parse', '--is-inside-work-tree']); - if (isRepo.code !== 0) return { inRepo: false, diff: '' }; + if (isRepo.code !== 0) { + return { inRepo: false, diff: '', before: null, after: null, diffReport: null }; + } if (!filePath) { const res = await execGit(cwd, ['diff', '--no-color']); - return { inRepo: true, diff: res.stdout }; + return { inRepo: true, diff: res.stdout, before: null, after: null, diffReport: null }; + } + const isSemantic = filePath.endsWith('.dql') || filePath.endsWith('.dqlnb'); + const [diffRes, before, after] = await Promise.all([ + execGit(cwd, ['diff', '--no-color', '--', filePath]), + isSemantic ? readHeadBlob(cwd, filePath) : Promise.resolve(null), + isSemantic ? readWorkingCopy(join(cwd, filePath)) : Promise.resolve(null), + ]); + const diffReport = isSemantic ? computeSemanticDiff(filePath, before, after) : null; + return { inRepo: true, diff: diffRes.stdout, before, after, diffReport }; +} + +async function readHeadBlob(cwd: string, filePath: string): Promise { + try { + const res = await execGit(cwd, ['show', `HEAD:${filePath}`]); + return res.code === 0 ? res.stdout : null; + } catch { + return null; + } +} + +async function readWorkingCopy(absPath: string): Promise { + try { + return readFileSync(absPath, 'utf-8'); + } catch { + return null; + } +} + +function computeSemanticDiff( + filePath: string, + before: string | null, + after: string | null, +): DiffReport | null { + if (before === after) return null; + try { + return filePath.endsWith('.dqlnb') + ? diffNotebook(before, after) + : diffDQL(before ?? '', after ?? ''); + } catch { + return null; } - const res = await execGit(cwd, ['diff', '--no-color', '--', filePath]); - return { inRepo: true, diff: res.stdout }; } diff --git a/apps/dql-notebook/package.json b/apps/dql-notebook/package.json index 7231541d..c6f5789e 100644 --- a/apps/dql-notebook/package.json +++ b/apps/dql-notebook/package.json @@ -1,6 +1,6 @@ { "name": "@duckcodeailabs/dql-notebook-app", - "version": "1.0.3", + "version": "1.0.4", "private": true, "type": "module", "scripts": { @@ -20,6 +20,7 @@ "@codemirror/theme-one-dark": "^6.1.2", "@codemirror/view": "^6.36.3", "@dagrejs/dagre": "^3.0.0", + "@duckcodeailabs/dql-core": "workspace:*", "@duckcodeailabs/dql-ui": "workspace:*", "zustand": "^5.0.0", "@xyflow/react": "^12.10.1", diff --git a/apps/dql-notebook/src/api/client.ts b/apps/dql-notebook/src/api/client.ts index 0d772235..7b9c2123 100644 --- a/apps/dql-notebook/src/api/client.ts +++ b/apps/dql-notebook/src/api/client.ts @@ -1,3 +1,4 @@ +import type { DiffReport } from '@duckcodeailabs/dql-core/format'; import type { NotebookFile, QueryResult, @@ -623,12 +624,18 @@ export const api = { }); }, - async fetchGitDiff(path?: string): Promise<{ inRepo: boolean; diff: string }> { + async fetchGitDiff(path?: string): Promise<{ + inRepo: boolean; + diff: string; + before: string | null; + after: string | null; + diffReport: DiffReport | null; + }> { try { const qs = path ? `?path=${encodeURIComponent(path)}` : ''; return await request(`/api/git/diff${qs}`); } catch { - return { inRepo: false, diff: '' }; + return { inRepo: false, diff: '', before: null, after: null, diffReport: null }; } }, }; diff --git a/apps/dql-notebook/src/components/cells/Cell.tsx b/apps/dql-notebook/src/components/cells/Cell.tsx index 5885ee38..7e3f3599 100644 --- a/apps/dql-notebook/src/components/cells/Cell.tsx +++ b/apps/dql-notebook/src/components/cells/Cell.tsx @@ -984,6 +984,22 @@ export function CellComponent({ cell, index }: CellProps) { )} )} + {cell.fromSnapshot && cell.result && ( + + cached + + )} {cell.error && ( Error diff --git a/apps/dql-notebook/src/components/layout/AppShell.tsx b/apps/dql-notebook/src/components/layout/AppShell.tsx index 4751d86f..c296c08e 100644 --- a/apps/dql-notebook/src/components/layout/AppShell.tsx +++ b/apps/dql-notebook/src/components/layout/AppShell.tsx @@ -59,22 +59,23 @@ export function AppShell() { const { content } = await api.readNotebook(file.path); const { title, cells, metadata } = parseNotebookFile(file.path, content); - // Hydrate last-run results from sibling .run.json so the notebook - // shows executed output without forcing a re-run on open. - const snap = await api.fetchRunSnapshot(file.path); - const hydrated = snap.found && snap.snapshot - ? cells.map((c) => { - const entry = snap.snapshot!.cells.find((e) => e.cellId === c.id); - if (!entry) return c; - return { - ...c, - status: entry.status ?? c.status, - result: entry.result ?? c.result, - error: entry.error ?? c.error, - executionCount: entry.executionCount ?? c.executionCount, - }; - }) - : cells; + const snap = file.path.endsWith('.dqlnb') ? await api.fetchRunSnapshot(file.path) : null; + let hydrated = cells; + if (snap?.found && snap.snapshot) { + const byId = new Map(snap.snapshot.cells.map((e) => [e.cellId, e])); + hydrated = cells.map((c) => { + const entry = byId.get(c.id); + if (!entry) return c; + return { + ...c, + status: entry.status ?? c.status, + result: entry.result ?? c.result, + error: entry.error ?? c.error, + executionCount: entry.executionCount ?? c.executionCount, + fromSnapshot: entry.result != null, + }; + }); + } dispatch({ type: 'OPEN_FILE', file, cells: hydrated, title, metadata }); // Ensure files panel is visible diff --git a/apps/dql-notebook/src/components/sidebar/GitDiffView.tsx b/apps/dql-notebook/src/components/sidebar/GitDiffView.tsx new file mode 100644 index 00000000..80330d40 --- /dev/null +++ b/apps/dql-notebook/src/components/sidebar/GitDiffView.tsx @@ -0,0 +1,174 @@ +import React from 'react'; +import type { DiffReport, DiffChange, FieldChange } from '@duckcodeailabs/dql-core/format'; + +// Renders either a semantic DiffReport (for .dql/.dqlnb) or a raw unified +// git diff (everything else). The two paths share one component so panels +// can pass whichever the server returned without branching per file type. + +interface Props { + diff: string; + diffReport: DiffReport | null; + activeFilePath: string | null; + diffPath: string | null; + onScopeToFile: () => void; + onClearScope: () => void; + t: any; +} + +export function GitDiffView({ + diff, diffReport, activeFilePath, diffPath, onScopeToFile, onClearScope, t, +}: Props) { + const hasSemantic = diffReport !== null && !diffReport.identical; + const hasRaw = diff.trim() !== ''; + + return ( +
+
+ + {activeFilePath && ( + + )} + {diffPath && ( + + {diffPath} + + )} +
+ + {hasSemantic && } + + {!hasSemantic && hasRaw && } + + {!hasSemantic && !hasRaw && ( +
+ {diffReport && diffReport.identical ? 'No semantic changes.' : 'No unstaged changes.'} +
+ )} +
+ ); +} + +function SemanticDiff({ report, t }: { report: DiffReport; t: any }) { + return ( +
+ {report.changes.map((change, i) => ( + + ))} +
+ ); +} + +function ChangeRow({ change, t }: { change: DiffChange; t: any }) { + const { marker, color, label } = formatChange(change); + const fields = 'fields' in change ? change.fields : null; + return ( +
+
+ {marker} + {label} +
+ {fields && fields.length > 0 && ( +
+ {fields.map((f, i) => ( + + ))} +
+ )} +
+ ); +} + +function FieldRow({ field, t }: { field: FieldChange; t: any }) { + return ( +
+ {field.path} + : + {truncate(field.before)} + + {truncate(field.after)} +
+ ); +} + +function RawDiff({ diff, t }: { diff: string; t: any }) { + return ( +
+      {diff.split('\n').map((line, i) => (
+        
{line || ' '}
+ ))} +
+ ); +} + +function formatChange(change: DiffChange): { marker: string; color: string; label: string } { + const added = { marker: '+', color: '#3cb371' }; + const removed = { marker: '-', color: '#e06060' }; + const changed = { marker: '~', color: '#d4a24c' }; + switch (change.kind) { + case 'block-added': return { ...added, label: `block "${change.name}"` }; + case 'block-removed': return { ...removed, label: `block "${change.name}"` }; + case 'block-changed': return { ...changed, label: `block "${change.name}"` }; + case 'dashboard-added': return { ...added, label: `dashboard "${change.title}"` }; + case 'dashboard-removed':return { ...removed, label: `dashboard "${change.title}"` }; + case 'dashboard-changed':return { ...changed, label: `dashboard "${change.title}"` }; + case 'workbook-added': return { ...added, label: `workbook "${change.title}"` }; + case 'workbook-removed': return { ...removed, label: `workbook "${change.title}"` }; + case 'workbook-changed': return { ...changed, label: `workbook "${change.title}"` }; + case 'cell-added': return { ...added, label: `cell ${cellRef(change.id, change.name)} [${change.cellType}]` }; + case 'cell-removed': return { ...removed, label: `cell ${cellRef(change.id, change.name)} [${change.cellType}]` }; + case 'cell-changed': return { ...changed, label: `cell ${cellRef(change.id, change.name)}` }; + case 'notebook-changed': return { ...changed, label: 'notebook' }; + } +} + +function cellRef(id: string, name?: string): string { + return name ? `"${name}" (${id.slice(0, 8)})` : id.slice(0, 8); +} + +function truncate(v: string | null, max = 60): string { + if (v == null) return '∅'; + const one = v.replace(/\s+/g, ' ').trim(); + return one.length > max ? `${one.slice(0, max)}…` : one; +} + +function scopeBtn(active: boolean, t: any): React.CSSProperties { + return { + background: active ? t.btnHover : 'transparent', + color: active ? t.textPrimary : t.textMuted, + border: `1px solid ${t.headerBorder}`, + padding: '2px 8px', + borderRadius: 4, + fontSize: 11, + cursor: 'pointer', + }; +} + +function diffLineColor(line: string, t: any): string { + if (line.startsWith('+++') || line.startsWith('---')) return t.textMuted; + if (line.startsWith('+')) return t.success; + if (line.startsWith('-')) return t.error; + if (line.startsWith('@@')) return t.accent; + return t.textPrimary; +} diff --git a/apps/dql-notebook/src/components/sidebar/GitPanel.tsx b/apps/dql-notebook/src/components/sidebar/GitPanel.tsx index dfff840f..dd864a29 100644 --- a/apps/dql-notebook/src/components/sidebar/GitPanel.tsx +++ b/apps/dql-notebook/src/components/sidebar/GitPanel.tsx @@ -2,12 +2,16 @@ import React, { useEffect, useState, useCallback } from 'react'; import { useNotebook } from '../../store/NotebookStore'; import { themes } from '../../themes/notebook-theme'; import { api } from '../../api/client'; +import type { DiffReport } from '@duckcodeailabs/dql-core/format'; +import { GitDiffView } from './GitDiffView'; type Status = Awaited>; type LogResult = Awaited>; type Tab = 'status' | 'log' | 'diff'; +const STATUS_POLL_MS = 2000; + export function GitPanel() { const { state } = useNotebook(); const t = themes[state.themeMode]; @@ -16,6 +20,7 @@ export function GitPanel() { const [status, setStatus] = useState(null); const [log, setLog] = useState(null); const [diff, setDiff] = useState(''); + const [diffReport, setDiffReport] = useState(null); const [diffPath, setDiffPath] = useState(null); const [loading, setLoading] = useState(false); @@ -29,6 +34,7 @@ export function GitPanel() { } else { const result = await api.fetchGitDiff(diffPath ?? undefined); setDiff(result.diff); + setDiffReport(result.diffReport); } } finally { setLoading(false); @@ -39,6 +45,18 @@ export function GitPanel() { void refresh(); }, [refresh]); + // Quiet poll on the status tab so the file list reflects terminal-side + // edits within 2s without the user clicking refresh. + useEffect(() => { + if (tab !== 'status') return; + const id = window.setInterval(() => { + void api.fetchGitStatus().then((next) => { + setStatus((prev) => (statusEqual(prev, next) ? prev : next)); + }); + }, STATUS_POLL_MS); + return () => window.clearInterval(id); + }, [tab]); + const activeFilePath = state.activeFile?.path ?? null; return ( @@ -61,19 +79,20 @@ export function GitPanel() {
- {loading &&
Loading…
} + {loading && !status && !log &&
Loading…
} - {!loading && tab === 'status' && status && ( + {tab === 'status' && status && ( )} - {!loading && tab === 'log' && log && ( + {tab === 'log' && log && ( )} - {!loading && tab === 'diff' && ( - setDiffPath(activeFilePath)} @@ -86,7 +105,17 @@ export function GitPanel() { ); } -function StatusView({ status, t }: { status: Status; t: ReturnType }) { +function statusEqual(a: Status | null, b: Status): boolean { + if (!a) return false; + if (a.inRepo !== b.inRepo || a.branch !== b.branch || a.ahead !== b.ahead || a.behind !== b.behind) return false; + if (a.changes.length !== b.changes.length) return false; + const ak = a.changes.map((c) => `${c.path}\0${c.status}`).sort(); + const bk = b.changes.map((c) => `${c.path}\0${c.status}`).sort(); + for (let i = 0; i < ak.length; i++) if (ak[i] !== bk[i]) return false; + return true; +} + +function StatusView({ status, t }: { status: Status; t: any }) { if (!status.inRepo) { return
Not a git repository.
; } @@ -137,53 +166,6 @@ function LogView({ log, t }: { log: LogResult; t: any }) { ); } -function DiffView({ - diff, activeFilePath, diffPath, onScopeToFile, onClearScope, t, -}: { - diff: string; activeFilePath: string | null; diffPath: string | null; - onScopeToFile: () => void; onClearScope: () => void; t: any; -}) { - return ( -
-
- - {activeFilePath && ( - - )} - {diffPath && ( - - {diffPath} - - )} -
- {diff.trim() === '' ? ( -
No unstaged changes.
- ) : ( -
-          {diff.split('\n').map((line, i) => (
-            
{line || ' '}
- ))} -
- )} -
- ); -} - function TabButton({ active, onClick, children, t }: { active: boolean; onClick: () => void; children: React.ReactNode; t: any }) { return (