cc-haha/desktop/scripts/check-bundle-budget.ts
小橙子 3fb246ca6a
feat: CodeMirror editor + atomic save + conflict banner foundation (Phase 2) (#24)
* feat(desktop): add CodeMirror deps + bundle budget + encoding utils

Phase 2 foundation pieces (tasks 6-7 of editor-lsp-foundation):

- Add 9 @codemirror/* dependencies (state, view, commands, search, language,
  autocomplete, lang-{javascript,json,markdown}). Tree-shaking keeps the
  current bundle delta at +0.00 KB until WorkspaceEditor.tsx imports them
  in task 8.
- Add scripts/check-bundle-budget.ts + bun script "check:bundle-budget".
  Asserts dist/assets/*.js gzipped total <= baseline + 100 KB. Baseline
  captured at origin/main @ 95931d49 (post-R5, pre-CodeMirror) = 3299.76 KB.
- Add encodingDetect.ts: detectEncoding(bytes) -> 'utf-8' | 'utf-8-bom' |
  'unsupported' (uses TextDecoder fatal:true to map invalid UTF-8 to
  unsupported); detectLineEnding(text) -> 'LF' | 'CRLF' | 'CR' (dominant
  style with LF as fallback for empty/single-line buffers).

Tested:
- bun run test src/components/workspace/encodingDetect.test.ts -- --run (18/18 pass)
- bun run scripts/check-bundle-budget.ts (OK, +0.00 KB delta)

_Requirements: 1.3, 1.6, 1.7, 1.8_

* feat(server,desktop): workspace file save endpoint + buffer/conflict store

Phase 2 backend pieces (tasks 9-12 of editor-lsp-foundation):

Server:
- src/server/events/workspaceFileSaved.ts: shared emitter
  emitWorkspaceFileSaved() so both write paths (user now, agent in PR-4)
  funnel through one well-typed WS message shape.
- src/server/services/workspaceFileService.ts: WriteWorkspaceFileSchema
  (Zod), atomic write (temp file + fsync + rename, Windows EBUSY/EPERM
  retry 3x50ms), realpath-based symlink containment, BOM/CRLF/CR
  round-trip, sha256 stale-base check (409 on hash mismatch), structured
  400/404/409/500 error bodies, post-write workspace.file.saved emit.
- src/server/api/sessions.ts: POST /api/sessions/:id/workspace/file
  routes through new handleSessionWorkspacePost; GET path unchanged.
- src/server/services/workspaceFileService.test.ts: 17 tests covering
  200 / 400 (bad request, path-escape, parent-missing, absolute-path) /
  404 session-missing / 409 stale-base / BOM round-trip / CRLF round-trip /
  CR round-trip / temp-file-not-leaked / 10 MiB content cap. Includes
  symlink-escape coverage on POSIX (skipped on Windows where symlink
  creation needs elevation).

Desktop store:
- desktop/src/stores/workspacePanelStore.ts: bufferStateByTabId record,
  WorkspaceBufferState/Conflict types, initBuffer / setBufferState /
  applyExternalSave / acknowledgeConflict / clearBuffer actions.
  applyExternalSave is the WS-driven entry point — clean buffers rebase
  silently when content is supplied; dirty buffers raise a conflict
  banner. acknowledgeConflict('reload') resets to base, 'keepMine' /
  'openConflict' just dismiss the banner. closePreviewTabs and
  clearSession now drop matching buffer state to prevent leaks.

Tested:
- bun test src/server/services/workspaceFileService.test.ts (17/17 pass)
- bun run lint (server tsc clean)
- bun run test src/stores/workspacePanelStore.test.ts (24/24 pass — store
  extension is purely additive, R5 tests still green)

Note: pre-existing e2e/business-flow failures in src/server/__tests__/e2e
remain untouched by this change (44 fails on baseline, no new fails
introduced).

_Requirements: 1.5, 2.1-2.9, 3.1-3.3_

* feat(desktop): WorkspaceEditor + ConflictBanner + UnsavedChangesModal

Phase 2 UI pieces (tasks 8, 13, 14, 15 of editor-lsp-foundation):

- desktop/src/components/workspace/WorkspaceEditor.tsx: CodeMirror 6
  editor wrapping the workspace panel buffer state. Hooks
  EditorView.updateListener -> setBufferState for live dirty tracking,
  picks language by extension (ts/tsx/js/jsx/json/md), wires Save to
  sessionsApi.saveWorkspaceFile (R2 atomic write endpoint), refreshes
  base hash on success, falls back to a read-only message when
  detectEncoding returns 'unsupported'. External rebases (e.g. clean
  buffer applyExternalSave) are pushed back into the EditorView via
  dispatch.
- desktop/src/components/workspace/ConflictBanner.tsx: clean buffer ->
  single Reload button; dirty buffer -> three buttons (Reload (discard),
  Keep mine, Open conflict view). Renders workspace-relative path,
  hash first 8 hex, relative timestamp refreshing every 60 s.
- desktop/src/components/workspace/UnsavedChangesModal.tsx: Discard /
  Save / Cancel modal. Cancel is the default focus, Esc triggers it.
  While saving, all three buttons disable and the host calls onSave
  which only resolves close on success. 30 s in-prompt timeout fires
  onTimeout (host owns the toast).
- desktop/src/components/workspace/WorkspaceEditor.test.tsx: 8 RTL
  tests covering buffer init / encoding detection / unsupported
  fallback / dirty marker / unsaved-changes modal flows / conflict
  banner clean-vs-dirty layouts.
- desktop/src/api/sessions.ts: SaveWorkspaceFileInput +
  SaveWorkspaceFileResult types and sessionsApi.saveWorkspaceFile
  POST helper.

Bundle delta: +0.28 KB gz (CodeMirror 6 + 3 lang packs imported but
WorkspaceEditor is not yet wired into WorkspacePanel — that integration
ships in a follow-up PR alongside Phase 3 LSP work, keeping this PR
focused on the foundation).

Tested:
- bun run lint (tsc --noEmit clean)
- bun run test (workspacePanelStore 24/24, encodingDetect 18/18,
  WorkspaceEditor 8/8, WorkspacePanel 27/27 — 77/77 across 4 files)
- bun run check:bundle-budget (delta +0.28 KB gz, 99.72 KB headroom)
- bun run build (clean, 1.20s)

_Requirements: 1.1-1.8, 3.2-3.5, 4.1-4.6_

---------

Co-authored-by: 你的姓名 <you@example.com>
2026-06-12 07:36:52 +08:00

93 lines
3.2 KiB
TypeScript

#!/usr/bin/env bun
/**
* Bundle budget gate for the editor-lsp-foundation feature.
*
* Asserts that the total gzipped JS bundle size in `dist/assets/` does not
* exceed `BASELINE_GZIP_BYTES + BUDGET_GZIP_BYTES`.
*
* The baseline was captured on `origin/main @ 95931d49` (post-R5 default-view
* change, pre-CodeMirror) by summing gzip-compressed output of every
* `dist/assets/*.js` file produced by `bun run build`.
*
* Run before/after dependency or feature changes that affect the desktop
* client bundle. Fails non-zero when the budget is exceeded so CI can gate it.
*
* _Requirements: 1.3, 1.8 (Phase 2 task 6)_
*/
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { gzipSync } from 'node:zlib'
// Captured 2026-06-12 on origin/main @ 95931d49 (post-R5, pre-CodeMirror).
const BASELINE_GZIP_BYTES = 3_378_955
// Budget: ~100 KB (gzipped) of headroom for CodeMirror 6 core + 3 lang packs.
const BUDGET_GZIP_BYTES = 100 * 1024
const ROOT_DIR = new URL('..', import.meta.url).pathname
// Strip leading slash on Windows paths returned from URL.pathname (e.g. "/C:/...").
const DESKTOP_DIR = process.platform === 'win32' && ROOT_DIR.startsWith('/')
? ROOT_DIR.slice(1)
: ROOT_DIR
const DIST_DIR = join(DESKTOP_DIR, 'dist', 'assets')
function fail(message: string): never {
console.error(`bundle-budget: ${message}`)
process.exit(1)
}
function formatBytes(bytes: number): string {
return `${(bytes / 1024).toFixed(2)} KB`
}
let entries: string[]
try {
entries = readdirSync(DIST_DIR)
} catch (err) {
fail(
`cannot read ${DIST_DIR}: ${err instanceof Error ? err.message : String(err)}\n` +
`Run "bun run build" first.`,
)
}
let totalGzip = 0
let totalRaw = 0
let jsFileCount = 0
for (const name of entries) {
if (!name.endsWith('.js')) continue
const fullPath = join(DIST_DIR, name)
const stats = statSync(fullPath)
if (!stats.isFile()) continue
const buf = readFileSync(fullPath)
totalRaw += buf.length
totalGzip += gzipSync(buf).length
jsFileCount += 1
}
if (jsFileCount === 0) {
fail(`no .js files found in ${DIST_DIR}. Did the build emit assets?`)
}
const ceiling = BASELINE_GZIP_BYTES + BUDGET_GZIP_BYTES
const delta = totalGzip - BASELINE_GZIP_BYTES
console.log(`bundle-budget: scanned ${jsFileCount} JS files in dist/assets`)
console.log(`bundle-budget: total raw = ${formatBytes(totalRaw)}`)
console.log(`bundle-budget: total gzip = ${formatBytes(totalGzip)}`)
console.log(`bundle-budget: baseline = ${formatBytes(BASELINE_GZIP_BYTES)} (origin/main @ 95931d49, pre-CodeMirror)`)
console.log(`bundle-budget: budget = +${formatBytes(BUDGET_GZIP_BYTES)} gz`)
console.log(`bundle-budget: ceiling = ${formatBytes(ceiling)}`)
console.log(`bundle-budget: delta = ${delta >= 0 ? '+' : ''}${formatBytes(delta)} (vs baseline)`)
if (totalGzip > ceiling) {
fail(
`total gzip ${formatBytes(totalGzip)} exceeds ceiling ${formatBytes(ceiling)} ` +
`(baseline ${formatBytes(BASELINE_GZIP_BYTES)} + budget ${formatBytes(BUDGET_GZIP_BYTES)}). ` +
`Either reduce the change or update BASELINE_GZIP_BYTES with justification.`,
)
}
console.log(`bundle-budget: OK (${formatBytes(ceiling - totalGzip)} headroom remaining)`)