From a95dcb02003c7b5c30e1eaadee9bdeeda2f4ac83 Mon Sep 17 00:00:00 2001 From: ehud-am Date: Wed, 8 Apr 2026 12:01:41 -0400 Subject: [PATCH] feat: surface ignored files as local-only --- .npmignore | 12 + AGENTS.md | 6 +- CHANGELOG.md | 7 + package-lock.json | 489 +++++++++--------- package.json | 10 +- .../checklists/requirements.md | 35 ++ .../contracts/local-only-visibility.md | 57 ++ .../data-model.md | 70 +++ specs/008-ignored-files-visibility/plan.md | 111 ++++ .../quickstart.md | 37 ++ .../008-ignored-files-visibility/research.md | 42 ++ specs/008-ignored-files-visibility/spec.md | 104 ++++ specs/008-ignored-files-visibility/tasks.md | 218 ++++++++ src/git/repo.ts | 6 +- src/git/tree.ts | 75 ++- src/handlers/search.ts | 8 +- src/types.ts | 2 + tests/integration/server.test.ts | 24 +- tests/unit/git/repo.test.ts | 17 +- tests/unit/git/tree.test.ts | 21 + tests/unit/handlers/git.test.ts | 37 +- tests/unit/handlers/search.test.ts | 22 + ui/package-lock.json | 123 +++-- ui/package.json | 8 +- ui/src/App.css | 60 ++- ui/src/App.test.tsx | 182 ++++++- ui/src/App.tsx | 49 +- .../ContentPanel/ContentPanel.test.tsx | 132 ++++- .../components/ContentPanel/ContentPanel.tsx | 37 +- ui/src/components/FileTree/FileTree.test.tsx | 85 +-- ui/src/components/FileTree/FileTree.tsx | 6 +- ui/src/components/FileTree/FileTreeNode.tsx | 5 +- ui/src/components/Search/SearchPanel.test.tsx | 59 ++- ui/src/components/Search/SearchPanel.tsx | 14 +- ui/src/components/Search/SearchResults.tsx | 7 +- ui/src/types/index.ts | 2 + 36 files changed, 1745 insertions(+), 434 deletions(-) create mode 100644 .npmignore create mode 100644 specs/008-ignored-files-visibility/checklists/requirements.md create mode 100644 specs/008-ignored-files-visibility/contracts/local-only-visibility.md create mode 100644 specs/008-ignored-files-visibility/data-model.md create mode 100644 specs/008-ignored-files-visibility/plan.md create mode 100644 specs/008-ignored-files-visibility/quickstart.md create mode 100644 specs/008-ignored-files-visibility/research.md create mode 100644 specs/008-ignored-files-visibility/spec.md create mode 100644 specs/008-ignored-files-visibility/tasks.md diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..f158825 --- /dev/null +++ b/.npmignore @@ -0,0 +1,12 @@ +node_modules/ +ui/node_modules/ +coverage/ +ui/coverage/ +src/ +tests/ +specs/ +.specify/ +.agents/ +.claude/ +*.log +.env* diff --git a/AGENTS.md b/AGENTS.md index 34b5c79..e21de48 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # gitlocal Development Guidelines -Auto-generated from all feature plans. Last updated: 2026-04-04 +Auto-generated from all feature plans. Last updated: 2026-04-08 ## Active Technologies - **Runtime**: Node.js 22+ (active LTS), TypeScript 5.x @@ -19,6 +19,8 @@ Auto-generated from all feature plans. Last updated: 2026-04-04 - TypeScript 5.x on Node.js 22+ for server and CLI, TypeScript + React 18 for the UI + Hono, @hono/node-server, React 18, @tanstack/react-query, react-markdown, remark-gfm, rehype-highlight, Vite 7, Vitest, React Testing Library, esbuild (007-editor-empty-repo) - TypeScript 5.x on Node.js 22+ + Hono, React 18, Vite 7, @tanstack/react-query, react-markdown, remark-gfm, rehype-highlight, Vitest, React Testing Library (007-editor-empty-repo) - None; runtime state is derived from local filesystem metadata, git metadata, browser URL state, and in-memory server/UI state (007-editor-empty-repo) +- TypeScript 5.x on Node.js 22+ + Hono, React 18, Vite 7, @tanstack/react-query, Vitest, React Testing Library (008-ignored-files-visibility) +- None; runtime state is derived from local filesystem contents, git metadata, browser URL state, and in-memory server/UI state (008-ignored-files-visibility) ## Project Structure @@ -44,9 +46,9 @@ npm run verify # Run tests, builds, and dependency audits TypeScript 5.x + Node.js 22+: follow standard conventions. Use `.js` extensions on all imports (NodeNext module resolution). No Go, no Makefile, no shell scripts. ## Recent Changes +- 008-ignored-files-visibility: Added TypeScript 5.x on Node.js 22+ + Hono, React 18, Vite 7, @tanstack/react-query, Vitest, React Testing Library - 007-editor-empty-repo: Added TypeScript 5.x on Node.js 22+ + Hono, React 18, Vite 7, @tanstack/react-query, react-markdown, remark-gfm, rehype-highlight, Vitest, React Testing Library - 007-editor-empty-repo: Added TypeScript 5.x on Node.js 22+ for server and CLI, TypeScript + React 18 for the UI + Hono, @hono/node-server, React 18, @tanstack/react-query, react-markdown, remark-gfm, rehype-highlight, Vite 7, Vitest, React Testing Library, esbuild -- 007-editor-empty-repo: Added TypeScript 5.x on Node.js 22+ for server and CLI, TypeScript + React 18 for the UI + Hono, @hono/node-server, React 18, @tanstack/react-query, Vite 7, Vitest, React Testing Library, esbuild diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c78d4b..f261a4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.4.7 + +- Added ignored file and folder visibility across the repository tree, folder listings, and search so local-only content remains discoverable in the UI. +- Marked ignored content consistently as local-only in navigation and active file context to clarify that it exists only on the local machine and will not be pushed to a remote. +- Fixed ignored-only directories and roots so they no longer fall into misleading empty states when ignored content is the only visible content. +- Updated Hono, Vitest, and Vite dependencies to publish-safe versions so the release verification audit passes cleanly. + ## 0.4.6 - Reserved the next release version for the upcoming editor workspace and empty-repository UX improvements captured in `007-editor-empty-repo`. diff --git a/package-lock.json b/package-lock.json index b6061cd..b6ae611 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "gitlocal", - "version": "0.4.6", + "version": "0.4.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitlocal", - "version": "0.4.6", + "version": "0.4.7", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.13.7", - "hono": "^4.7.5", + "@hono/node-server": "^1.19.13", + "hono": "^4.12.12", "open": "^10.1.0" }, "bin": { @@ -20,10 +20,10 @@ "@emnapi/core": "^1.9.1", "@emnapi/runtime": "^1.9.1", "@types/node": "^22.0.0", - "@vitest/coverage-v8": "^4.1.2", + "@vitest/coverage-v8": "^4.1.3", "esbuild": "^0.27.4", "typescript": "^5.8.3", - "vitest": "^4.1.2" + "vitest": "^4.1.3" }, "engines": { "node": ">=22.0.0" @@ -565,9 +565,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", - "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "version": "1.19.13", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", + "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -605,9 +605,9 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", - "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", "dev": true, "license": "MIT", "optional": true, @@ -624,9 +624,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.122.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", - "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "version": "0.123.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.123.0.tgz", + "integrity": "sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew==", "dev": true, "license": "MIT", "funding": { @@ -634,9 +634,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-5ZiiecKH2DXAVJTNN13gNMUcCDg4Jy8ZjbXEsPnqa248wgOVeYRX0iqXXD5Jz4bI9BFHgKsI2qmyJynstbmr+g==", "cpu": [ "arm64" ], @@ -651,9 +651,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-tz/v/8G77seu8zAB3A5sK3UFoOl06zcshEzhUO62sAEtrEuW/H1CcyoupOrD+NbQJytYgA4CppXPzlrmp4JZKA==", "cpu": [ "arm64" ], @@ -668,9 +668,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.13.tgz", + "integrity": "sha512-8DakphqOz8JrMYWTJmWA+vDJxut6LijZ8Xcdc4flOlAhU7PNVwo2MaWBF9iXjJAPo5rC/IxEFZDhJ3GC7NHvug==", "cpu": [ "x64" ], @@ -685,9 +685,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.13.tgz", + "integrity": "sha512-4wBQFfjDuXYN/SVI8inBF3Aa+isq40rc6VMFbk5jcpolUBTe5cYnMsHZ51nFWsx3PVyyNN3vgoESki0Hmr/4BA==", "cpu": [ "x64" ], @@ -702,9 +702,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", - "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.13.tgz", + "integrity": "sha512-JW/e4yPIXLms+jmnbwwy5LA/LxVwZUWLN8xug+V200wzaVi5TEGIWQlh8o91gWYFxW609euI98OCCemmWGuPrw==", "cpu": [ "arm" ], @@ -719,9 +719,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-ZfKWpXiUymDnavepCaM6KG/uGydJ4l2nBmMxg60Ci4CbeefpqjPWpfaZM7PThOhk2dssqBAcwLc6rAyr0uTdXg==", "cpu": [ "arm64" ], @@ -736,9 +736,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.13.tgz", + "integrity": "sha512-bmRg3O6Z0gq9yodKKWCIpnlH051sEfdVwt+6m5UDffAQMUUqU0xjnQqqAUm+Gu7ofAAly9DqiQDtKu2nPDEABA==", "cpu": [ "arm64" ], @@ -753,9 +753,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-8Wtnbw4k7pMYN9B/mOEAsQ8HOiq7AZ31Ig4M9BKn2So4xRaFEhtCSa4ZJaOutOWq50zpgR4N5+L/opnlaCx8wQ==", "cpu": [ "ppc64" ], @@ -770,9 +770,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-D/0Nlo8mQuxSMohNJUF2lDXWRsFDsHldfRRgD9bRgktj+EndGPj4DOV37LqDKPYS+osdyhZEH7fTakTAEcW7qg==", "cpu": [ "s390x" ], @@ -787,9 +787,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-eRrPvat2YaVQcwwKi/JzOP6MKf1WRnOCr+VaI3cTWz3ZoLcP/654z90lVCJ4dAuMEpPdke0n+qyAqXDZdIC4rA==", "cpu": [ "x64" ], @@ -804,9 +804,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.13.tgz", + "integrity": "sha512-PsdONiFRp8hR8KgVjTWjZ9s7uA3uueWL0t74/cKHfM4dR5zXYv4AjB8BvA+QDToqxAFg4ZkcVEqeu5F7inoz5w==", "cpu": [ "x64" ], @@ -821,9 +821,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-hCNXgC5dI3TVOLrPT++PKFNZ+1EtS0mLQwfXXXSUD/+rGlB65gZDwN/IDuxLpQP4x8RYYHqGomlUXzpO8aVI2w==", "cpu": [ "arm64" ], @@ -838,9 +838,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", - "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.13.tgz", + "integrity": "sha512-viLS5C5et8NFtLWw9Sw3M/w4vvnVkbWkO7wSNh3C+7G1+uCkGpr6PcjNDSFcNtmXY/4trjPBqUfcOL+P3sWy/g==", "cpu": [ "wasm32" ], @@ -848,16 +848,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@emnapi/core": "1.9.1", + "@emnapi/runtime": "1.9.1", + "@napi-rs/wasm-runtime": "^1.1.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.13.tgz", + "integrity": "sha512-Fqa3Tlt1xL4wzmAYxGNFV36Hb+VfPc9PYU+E25DAnswXv3ODDu/yyWjQDbXMo5AGWkQVjLgQExuVu8I/UaZhPQ==", "cpu": [ "arm64" ], @@ -872,9 +874,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.13.tgz", + "integrity": "sha512-/pLI5kPkGEi44TDlnbio3St/5gUFeN51YWNAk/Gnv6mEQBOahRBh52qVFVBpmrnU01n2yysvBML9Ynu7K4kGAQ==", "cpu": [ "x64" ], @@ -889,9 +891,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", - "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", + "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", "dev": true, "license": "MIT" }, @@ -950,14 +952,15 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.2.tgz", - "integrity": "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.3.tgz", + "integrity": "sha512-/MBdrkA8t6hbdCWFKs09dPik774xvs4Z6L4bycdCxYNLHM8oZuRyosumQMG19LUlBsB6GeVpL1q4kFFazvyKGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.2", + "@vitest/utils": "4.1.3", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -971,8 +974,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.2", - "vitest": "4.1.2" + "@vitest/browser": "4.1.3", + "vitest": "4.1.3" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -981,16 +984,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", - "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.3.tgz", + "integrity": "sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.2", - "@vitest/utils": "4.1.2", + "@vitest/spy": "4.1.3", + "@vitest/utils": "4.1.3", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -998,10 +1001,37 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/mocker": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.3.tgz", + "integrity": "sha512-XN3TrycitDQSzGRnec/YWgoofkYRhouyVQj4YNsJ5r/STCUFqMrP4+oxEv3e7ZbLi4og5kIHrZwekDJgw6hcjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.3", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "node_modules/@vitest/pretty-format": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", - "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.3.tgz", + "integrity": "sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg==", "dev": true, "license": "MIT", "dependencies": { @@ -1012,13 +1042,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", - "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.3.tgz", + "integrity": "sha512-VwgOz5MmT0KhlUj40h02LWDpUBVpflZ/b7xZFA25F29AJzIrE+SMuwzFf0b7t4EXdwRNX61C3B6auIXQTR3ttA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.2", + "@vitest/utils": "4.1.3", "pathe": "^2.0.3" }, "funding": { @@ -1026,14 +1056,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", - "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.3.tgz", + "integrity": "sha512-9l+k/J9KG5wPJDX9BcFFzhhwNjwkRb8RsnYhaT1vPY7OufxmQFc9sZzScRCPTiETzl37mrIWVY9zxzmdVeJwDQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.2", - "@vitest/utils": "4.1.2", + "@vitest/pretty-format": "4.1.3", + "@vitest/utils": "4.1.3", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1042,9 +1072,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", - "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.3.tgz", + "integrity": "sha512-ujj5Uwxagg4XUIfAUyRQxAg631BP6e9joRiN99mr48Bg9fRs+5mdUElhOoZ6rP5mBr8Bs3lmrREnkrQWkrsTCw==", "dev": true, "license": "MIT", "funding": { @@ -1052,13 +1082,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", - "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.3.tgz", + "integrity": "sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.2", + "@vitest/pretty-format": "4.1.3", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -1284,9 +1314,9 @@ } }, "node_modules/hono": { - "version": "4.12.9", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", - "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", + "version": "4.12.12", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", + "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", "license": "MIT", "peer": true, "engines": { @@ -1770,9 +1800,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", "dev": true, "funding": [ { @@ -1799,14 +1829,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", - "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.13.tgz", + "integrity": "sha512-bvVj8YJmf0rq4pSFmH7laLa6pYrhghv3PRzrCdRAr23g66zOKVJ4wkvFtgohtPLWmthgg8/rkaqRHrpUEh0Zbw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.122.0", - "@rolldown/pluginutils": "1.0.0-rc.12" + "@oxc-project/types": "=0.123.0", + "@rolldown/pluginutils": "1.0.0-rc.13" }, "bin": { "rolldown": "bin/cli.mjs" @@ -1815,21 +1845,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-x64": "1.0.0-rc.12", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + "@rolldown/binding-android-arm64": "1.0.0-rc.13", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.13", + "@rolldown/binding-darwin-x64": "1.0.0-rc.13", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.13", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.13", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.13", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.13", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.13", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.13", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.13" } }, "node_modules/run-applescript": { @@ -1909,9 +1939,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", - "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", "dev": true, "license": "MIT", "engines": { @@ -1919,14 +1949,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -1973,192 +2003,173 @@ "dev": true, "license": "MIT" }, - "node_modules/vitest": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", - "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "node_modules/vite": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.7.tgz", + "integrity": "sha512-P1PbweD+2/udplnThz3btF4cf6AgPky7kk23RtHUkJIU5BIxwPprhRGmOAHs6FTI7UiGbTNrgNP6jSYD6JaRnw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@vitest/expect": "4.1.2", - "@vitest/mocker": "4.1.2", - "@vitest/pretty-format": "4.1.2", - "@vitest/runner": "4.1.2", - "@vitest/snapshot": "4.1.2", - "@vitest/spy": "4.1.2", - "@vitest/utils": "4.1.2", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.13", + "tinyglobby": "^0.2.15" }, "bin": { - "vitest": "vitest.mjs" + "vite": "bin/vite.js" }, "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" }, "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.2", - "@vitest/browser-preview": "4.1.2", - "@vitest/browser-webdriverio": "4.1.2", - "@vitest/ui": "4.1.2", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { - "@edge-runtime/vm": { + "@types/node": { "optional": true }, - "@opentelemetry/api": { + "@vitejs/devtools": { "optional": true }, - "@types/node": { + "esbuild": { "optional": true }, - "@vitest/browser-playwright": { + "jiti": { "optional": true }, - "@vitest/browser-preview": { + "less": { "optional": true }, - "@vitest/browser-webdriverio": { + "sass": { "optional": true }, - "@vitest/ui": { + "sass-embedded": { "optional": true }, - "happy-dom": { + "stylus": { "optional": true }, - "jsdom": { + "sugarss": { "optional": true }, - "vite": { - "optional": false - } - } - }, - "node_modules/vitest/node_modules/@vitest/mocker": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", - "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.2", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { + "terser": { "optional": true }, - "vite": { + "tsx": { + "optional": true + }, + "yaml": { "optional": true } } }, - "node_modules/vitest/node_modules/vite": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", - "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "node_modules/vitest": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.3.tgz", + "integrity": "sha512-DBc4Tx0MPNsqb9isoyOq00lHftVx/KIU44QOm2q59npZyLUkENn8TMFsuzuO+4U2FUa9rgbbPt3udrP25GcjXw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.12", - "tinyglobby": "^0.2.15" + "@vitest/expect": "4.1.3", + "@vitest/mocker": "4.1.3", + "@vitest/pretty-format": "4.1.3", + "@vitest/runner": "4.1.3", + "@vitest/snapshot": "4.1.3", + "@vitest/spy": "4.1.3", + "@vitest/utils": "4.1.3", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" }, "bin": { - "vite": "bin/vite.js" + "vitest": "vitest.mjs" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" + "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.3", + "@vitest/browser-preview": "4.1.3", + "@vitest/browser-webdriverio": "4.1.3", + "@vitest/coverage-istanbul": "4.1.3", + "@vitest/coverage-v8": "4.1.3", + "@vitest/ui": "4.1.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { - "@types/node": { + "@edge-runtime/vm": { "optional": true }, - "@vitejs/devtools": { + "@opentelemetry/api": { "optional": true }, - "esbuild": { + "@types/node": { "optional": true }, - "jiti": { + "@vitest/browser-playwright": { "optional": true }, - "less": { + "@vitest/browser-preview": { "optional": true }, - "sass": { + "@vitest/browser-webdriverio": { "optional": true }, - "sass-embedded": { + "@vitest/coverage-istanbul": { "optional": true }, - "stylus": { + "@vitest/coverage-v8": { "optional": true }, - "sugarss": { + "@vitest/ui": { "optional": true }, - "terser": { + "happy-dom": { "optional": true }, - "tsx": { + "jsdom": { "optional": true }, - "yaml": { - "optional": true + "vite": { + "optional": false } } }, diff --git a/package.json b/package.json index 9c2f5d4..e9d5e49 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gitlocal", - "version": "0.4.6", + "version": "0.4.7", "description": "Browse any local git repository in your browser", "keywords": [ "git", @@ -43,18 +43,18 @@ "prepublishOnly": "npm run verify" }, "dependencies": { - "@hono/node-server": "^1.13.7", - "hono": "^4.7.5", + "@hono/node-server": "^1.19.13", + "hono": "^4.12.12", "open": "^10.1.0" }, "devDependencies": { "@emnapi/core": "^1.9.1", "@emnapi/runtime": "^1.9.1", "@types/node": "^22.0.0", - "@vitest/coverage-v8": "^4.1.2", + "@vitest/coverage-v8": "^4.1.3", "esbuild": "^0.27.4", "typescript": "^5.8.3", - "vitest": "^4.1.2" + "vitest": "^4.1.3" }, "license": "MIT", "repository": { diff --git a/specs/008-ignored-files-visibility/checklists/requirements.md b/specs/008-ignored-files-visibility/checklists/requirements.md new file mode 100644 index 0000000..27e14c8 --- /dev/null +++ b/specs/008-ignored-files-visibility/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Ignored Local File Visibility + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-08 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Validated on 2026-04-08 after drafting. No open clarification markers remain. +- Scope intentionally includes current working-tree search results so ignored-item visibility stays consistent across the main browsing surfaces. diff --git a/specs/008-ignored-files-visibility/contracts/local-only-visibility.md b/specs/008-ignored-files-visibility/contracts/local-only-visibility.md new file mode 100644 index 0000000..9beeac0 --- /dev/null +++ b/specs/008-ignored-files-visibility/contracts/local-only-visibility.md @@ -0,0 +1,57 @@ +# Local-Only Visibility Contract + +## Repository Tree Contract + +- `GET /api/tree` remains the source for repository tree and folder-list entries. +- The response entry shape adds: + - `localOnly: boolean` +- For the current working tree: + - Entries include tracked items, untracked local items already visible today, and ignored local items that match the feature rules. + - Ignored files and ignored folders are returned as normal browse entries with `localOnly: true`. + - Repository internals such as `.git` remain excluded. +- For non-current branches or historical views: + - Responses continue to come from git tree data. + - Returned entries use the same shape but `localOnly` is always `false`. + +## Repository Info Contract + +- `GET /api/info` continues to return repository summary metadata used by the app shell. +- `rootEntryCount` now counts visible ignored local root items in the current working tree. +- `rootEntryCount` still excludes repository internals and hidden dotfile entries that the landing-state logic intentionally ignores. + +## Search Contract + +- `GET /api/search` keeps its existing query parameters and response structure. +- Each search result adds: + - `localOnly: boolean` +- Current working-tree search behavior: + - Name searches can return ignored local files and folders with `localOnly: true`. + - Content searches, when used, can return ignored local files with `localOnly: true`. +- Non-current branch and historical search behavior remains unchanged apart from the added `localOnly: false` field. + +## Active View Contract + +- If a user opens an ignored local file or folder from the tree, folder list, or search results, the resulting active view continues to expose that item through the normal browsing flow. +- The active context must preserve a visible local-only cue near the selected item identity so users do not lose that explanation after opening the item. +- This feature does not add new ignore-management or tracking actions. + +## Presentation Contract + +- The file tree, content-panel directory list, and search results all use the same local-only wording pattern. +- The cue must: + - Be readable in mixed lists that include normal items. + - Avoid warning or error styling that implies failure. + - Distinguish local-only items without hiding open actions or making the item feel disabled. + +## Test Coverage Contract + +- Backend tests must cover: + - Current working-tree tree listings that include ignored files and ignored folders. + - Root-entry counting when visible content is ignored-only. + - Search responses that include local-only matches for current working-tree searches. + - Historical branch responses that continue excluding current working-tree ignored items. +- UI tests must cover: + - Local-only cue rendering in the file tree. + - Local-only cue rendering in the content-panel folder list. + - Local-only cue rendering in search results and preserved context after opening an ignored item. + - Ignored-only roots or folders avoiding misleading empty states. diff --git a/specs/008-ignored-files-visibility/data-model.md b/specs/008-ignored-files-visibility/data-model.md new file mode 100644 index 0000000..55c16ed --- /dev/null +++ b/specs/008-ignored-files-visibility/data-model.md @@ -0,0 +1,70 @@ +# Data Model: Ignored Local File Visibility + +## Browse Entry + +- **Description**: A single repository item returned for current working-tree browsing and rendered in the left tree or content-panel directory list. +- **Fields**: + - `path`: Repository-relative path for the item. + - `name`: Display name derived from the final path segment. + - `type`: `file` or `dir`. + - `localOnly`: Whether the item is visible only in the local working tree and is not currently part of the tracked remote-facing repository state. +- **Validation rules**: + - `path` must remain inside the opened repository boundary. + - `localOnly` is `true` only for current working-tree items that match ignore rules. + - Repository internals such as `.git` must never be returned as browse entries. + - Ignored directories may appear as a single `dir` entry even when their descendants are also ignored. +- **Relationships**: + - Returned by the tree browsing contract. + - Consumed by the file tree and the content-panel directory list. + +## Search Match + +- **Description**: A repository item returned from working-tree or historical search. +- **Fields**: + - `path`: Repository-relative match path. + - `type`: `file` or `dir`. + - `matchType`: `name` or `content`. + - `line`: Matching line number for content matches when available. + - `snippet`: Short matching text for content matches when available. + - `localOnly`: Whether the matched item is a visible ignored local item. +- **Validation rules**: + - Current working-tree searches may return `localOnly: true`. + - Non-current branch or historical searches always return `localOnly: false`. + - `snippet` and `line` remain optional and are present only for content matches. +- **Relationships**: + - Produced by the search handler. + - Consumed by the quick-search UI and any future content-search UI using the same response shape. + +## Repository Summary + +- **Description**: Lightweight repository metadata used to decide initial landing behavior and other top-level UI state. +- **Fields**: + - `name`: Repository display name. + - `path`: Opened repository path. + - `currentBranch`: Current working-tree branch name. + - `isGitRepo`: Whether the opened folder is a valid git repository. + - `pickerMode`: Whether the app is in repository-picker mode. + - `version`: Running application version. + - `hasCommits`: Whether the repository has at least one reachable commit. + - `rootEntryCount`: Count of immediate visible root entries used for landing-state decisions. +- **Validation rules**: + - `rootEntryCount` includes visible ignored local items at the repository root. + - `rootEntryCount` excludes `.git` and hidden dotfiles that are still intentionally omitted from the landing-state count. +- **Relationships**: + - Returned by the repository info contract. + - Used by the app shell and content panel to avoid false empty states. + +## Local-Only Presentation State + +- **Description**: The presentation metadata that keeps the local-only explanation consistent wherever an ignored item is shown or selected. +- **Fields**: + - `label`: Short visible copy presented to users, expected to communicate "Local only". + - `supportingText`: Optional explanatory copy for surfaces with more room, clarifying that the item is not part of the remote-facing repository state. + - `emphasisLevel`: Lightweight visual treatment that distinguishes the item without presenting it as an error. +- **Validation rules**: + - The label must be understandable without assuming git expertise. + - The cue must remain readable beside normal browse metadata and actions. + - Selected ignored items continue showing the cue in their active context. +- **Relationships**: + - Derived from `BrowseEntry.localOnly` and `SearchMatch.localOnly`. + - Consumed by tree, folder-list, search-result, and active-item presentation components. diff --git a/specs/008-ignored-files-visibility/plan.md b/specs/008-ignored-files-visibility/plan.md new file mode 100644 index 0000000..e9ef7cd --- /dev/null +++ b/specs/008-ignored-files-visibility/plan.md @@ -0,0 +1,111 @@ +# Implementation Plan: Ignored Local File Visibility + +**Branch**: `008-ignored-files-visibility` | **Date**: 2026-04-08 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `specs/008-ignored-files-visibility/spec.md` + +## Summary + +Surface ignored local files and folders in GitLocal's current working-tree browsing flows without making them look tracked. The implementation will extend shared tree and search entry metadata with a local-only flag, keep historical branch views unchanged, count visible ignored items when deciding whether a repository or folder is empty, and render a consistent local-only cue across the file tree, folder list, search results, and active item context. + +## Technical Context + +**Language/Version**: TypeScript 5.x on Node.js 22+ +**Primary Dependencies**: Hono, React 18, Vite 7, @tanstack/react-query, Vitest, React Testing Library +**Storage**: None; runtime state is derived from local filesystem contents, git metadata, browser URL state, and in-memory server/UI state +**Testing**: Vitest, React Testing Library, existing unit and integration test suites +**Target Platform**: Local desktop browser served by the Node-based local server +**Project Type**: Full-stack web application +**Performance Goals**: Preserve current interactive browsing and search responsiveness for typical local repositories while adding local-only metadata +**Constraints**: Offline-capable, no database, no remote calls, GitHub-like browsing clarity, >=90% per-file branch coverage, repository-relative documentation only +**Scale/Scope**: Single local repository session with tree browsing, folder navigation, active file viewing, and quick file search + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- `TypeScript-first`: Pass. The feature extends existing TypeScript server and React client types instead of adding a parallel model layer. +- `Test coverage`: Pass. The change fits the current unit, handler, integration, and UI test suites and can preserve the required per-file coverage thresholds. +- `Fully local`: Pass. Ignored-item detection and presentation rely only on local filesystem and local git metadata. +- `Node.js-served React UI`: Pass. The work stays within the existing Hono server and Vite-served React SPA. +- `Clean & useful UI`: Pass. The plan adds a lightweight, consistent local-only cue rather than introducing a separate status workflow or visually noisy warning system. +- `Repository-relative paths`: Pass. This plan and the generated artifacts use repository-relative paths and links suitable for contributors on other machines. +- `Post-Phase-1 re-check`: Pass. The research, data model, quickstart, and contracts keep the feature local, typed, testable, and scoped to the existing browsing experience with no constitution violations introduced. + +## Project Structure + +### Documentation (this feature) + +```text +specs/008-ignored-files-visibility/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── local-only-visibility.md +└── tasks.md +``` + +### Source Code (repository root) + +```text +src/ +├── handlers/ +│ ├── files.ts +│ ├── git.ts +│ └── search.ts +├── git/ +│ ├── repo.ts +│ └── tree.ts +└── types.ts + +tests/ +├── integration/ +│ └── server.test.ts +└── unit/ + ├── git/ + │ ├── repo.test.ts + │ └── tree.test.ts + └── handlers/ + ├── git.test.ts + └── search.test.ts + +ui/ +└── src/ + ├── App.tsx + ├── App.css + ├── App.test.tsx + ├── services/ + │ └── api.ts + ├── types/ + │ └── index.ts + └── components/ + ├── ContentPanel/ + │ ├── ContentPanel.tsx + │ └── ContentPanel.test.tsx + ├── FileTree/ + │ ├── FileTree.tsx + │ ├── FileTree.test.tsx + │ └── FileTreeNode.tsx + └── Search/ + ├── SearchPanel.test.tsx + └── SearchResults.tsx +``` + +**Structure Decision**: Keep the existing single server + single UI structure. Server work will enrich current working-tree entry/search metadata and root-entry counting, while UI work will apply a shared local-only presentation across the tree, folder list, search results, and active item context. + +## Phase 0: Research Focus + +- Choose the smallest shared server/client metadata shape that can mark ignored local items across tree entries, folder rows, and search results. +- Confirm how to enumerate current working-tree items so ignored files and ignored folders become visible while repository internals remain hidden. +- Confirm how repository-empty logic should treat ignored visible items so ignored-only roots and folders do not render misleading empty states. + +## Phase 1: Design Focus + +- Define the normalized browse-entry and search-result shapes, including the local-only flag used by both server and UI. +- Define cue placement and wording for the file tree, content-panel directory list, search results, and active item header so users understand the item remains local. +- Define the verification approach for ignored files, ignored directories, ignored-only roots, and transitions where an item's local-only status changes or the item disappears. + +## Complexity Tracking + +No constitution violations are expected for this feature. diff --git a/specs/008-ignored-files-visibility/quickstart.md b/specs/008-ignored-files-visibility/quickstart.md new file mode 100644 index 0000000..966113b --- /dev/null +++ b/specs/008-ignored-files-visibility/quickstart.md @@ -0,0 +1,37 @@ +# Quickstart: Ignored Local File Visibility + +## Prerequisites + +- Install dependencies with `npm ci` and `npm --prefix ui ci`. +- Start the UI and server locally with `npm run dev:ui` and `npm run dev:server`. +- Prepare three local repositories for manual validation: + - A mixed repository with tracked files plus a `.gitignore` that matches at least one file and one folder. + - A repository whose visible root content is ignored-only, such as a local notes file and generated folder covered by `.gitignore`. + - A repository with at least one additional branch or historical state so current working-tree behavior can be compared with non-current branch browsing. + +## Validation Flow + +1. Open GitLocal against the mixed repository. +2. Confirm the left file tree shows both normal repository items and ignored local items. +3. Verify each ignored item includes a visible local-only cue without losing its normal open or navigation affordances. +4. Expand an ignored folder from the tree and confirm its immediate children remain browsable. +5. Select an ignored folder and confirm the main content panel lists its children with the same local-only cue. +6. Open an ignored file and confirm the active view still communicates that the file is local-only. +7. Use repository search to look up the ignored file by name and confirm the result appears with the same local-only treatment. +8. Open the repository whose visible root content is ignored-only. +9. Confirm GitLocal shows the ignored local items instead of an empty or broken repository state. +10. Open a folder whose visible contents are ignored-only and confirm it renders as a normal folder view rather than an empty-folder message. +11. Switch to a non-current branch or historical view and confirm ignored local items from the current working tree no longer appear there. +12. Change an ignore rule or begin tracking a previously ignored item, refresh the affected view, and confirm the local-only cue updates to match the item's new state. +13. Delete or move an ignored item outside GitLocal, refresh, and confirm the interface handles the missing item gracefully instead of leaving stale broken UI. + +## Automated Checks + +- Run `npm test`. +- Run `npm run lint`. +- Run `npm run build`. + +## Validation Notes + +- Automated implementation verification on 2026-04-08 completed successfully with `npm test`, `npm run lint`, and `npm run build`. +- Manual UI validation from the flow above was not executed in this terminal-only session and remains pending. diff --git a/specs/008-ignored-files-visibility/research.md b/specs/008-ignored-files-visibility/research.md new file mode 100644 index 0000000..d55d91e --- /dev/null +++ b/specs/008-ignored-files-visibility/research.md @@ -0,0 +1,42 @@ +# Research: Ignored Local File Visibility + +## Decision 1: Add a shared `localOnly` flag to browse and search records + +- **Decision**: Extend the server/client tree and search models with a single `localOnly: boolean` field instead of creating ignored-only endpoints or separate view-specific state. +- **Rationale**: The feature needs one consistent signal that the UI can reuse everywhere it lists repository items. A boolean keeps the shared types compact, avoids duplicating view logic, and lets the UI express the user-facing language as "Local only" without leaking git jargon into every component. +- **Alternatives considered**: + - Separate ignored-item lists per surface: rejected because it would fragment the browsing model and create inconsistent filtering rules. + - `ignored: boolean`: rejected because it pushes git terminology into the presentation model when the product goal is a clearer local-only explanation. + - Multi-value status enum: rejected because the feature currently needs only two display states. + +## Decision 2: Use filesystem-backed working-tree enumeration for current-branch visibility + +- **Decision**: Keep historical and non-current branch browsing on git tree data, but enumerate current working-tree entries from the filesystem so tracked, untracked, and ignored local items can all be surfaced when appropriate. +- **Rationale**: Ignored items do not exist in git tree listings for the working tree, so current-branch visibility must come from the local filesystem. The existing working-tree directory listing already follows that model and can be extended without changing historical branch behavior. +- **Alternatives considered**: + - Merge `git ls-files` output with filesystem scans at render time: rejected because it complicates deduplication and still requires a filesystem walk for ignored folders. + - Continue using tracked-only search helpers while changing tree browsing only: rejected because the feature would feel inconsistent and incomplete. + +## Decision 3: Keep `.git` and other intentionally hidden internals excluded + +- **Decision**: Expand working-tree visibility to ignored local content while continuing to hide repository internals such as `.git`, and continue treating hidden dotfiles separately in root-entry empty-state logic. +- **Rationale**: The feature is about showing meaningful local project content, not exposing repository internals or every hidden filesystem artifact. Preserving those exclusions keeps the UI understandable and aligned with current product behavior. +- **Alternatives considered**: + - Show every ignored filesystem path including `.git`: rejected because it would surface internal metadata that users should not browse as project content. + - Reuse the previous tracked-only root-count logic unchanged: rejected because ignored-only repositories or folders would still look empty. + +## Decision 4: Treat ignored visible items as real content for empty-state decisions + +- **Decision**: Count visible ignored local items when determining whether the current repository root or folder should display content instead of an empty-state message. +- **Rationale**: Once ignored items are intentionally shown in the browser, empty-state logic must treat them as legitimate visible content. Otherwise the UI would contradict itself by showing "empty" while also displaying local-only items elsewhere. +- **Alternatives considered**: + - Reserve empty-state suppression for tracked items only: rejected because it would leave ignored-only repositories and folders feeling broken. + - Add a separate ignored-only empty-state mode: rejected because it adds state complexity without improving user comprehension. + +## Decision 5: Reuse a compact "Local only" cue across all visible surfaces + +- **Decision**: Apply the same local-only wording and styling pattern anywhere a user can discover or act on an ignored item, including tree nodes, directory rows, search results, and the active content context. +- **Rationale**: A single cue pattern makes the feature easier to learn and reduces the chance that users misread ignored items as tracked repository content in one surface but not another. +- **Alternatives considered**: + - Use different indicators in each surface: rejected because it would create unnecessary cognitive load. + - Use warning-like colors or heavy alerts: rejected because the feature is informational, not an error state. diff --git a/specs/008-ignored-files-visibility/spec.md b/specs/008-ignored-files-visibility/spec.md new file mode 100644 index 0000000..974a13d --- /dev/null +++ b/specs/008-ignored-files-visibility/spec.md @@ -0,0 +1,104 @@ +# Feature Specification: Ignored Local File Visibility + +**Feature Branch**: `008-ignored-files-visibility` +**Created**: 2026-04-08 +**Status**: Draft +**Input**: User description: "our next feature is about showing files that are in the gitignore list. The idea is that we still want to show these in the gitlocal ui, but we also want to have a visual queue suggesting this is a local file only that will not be sent to remote" + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Browse ignored local items in GitLocal (Priority: P1) + +When a user opens a repository in GitLocal's current local working-tree view, they can see ignored local files and folders in the same browsing experience as other repository items so they do not have to leave the app to understand what is present on disk. + +**Why this priority**: The core need is visibility. If ignored local items remain hidden, users cannot rely on GitLocal as an accurate picture of the files they are actively working with. + +**Independent Test**: Open a repository whose working tree contains a mix of tracked items and ignored local items, then verify that ignored items appear in the repository tree, current-folder listing, and working-tree search results. + +**Acceptance Scenarios**: + +1. **Given** the current working tree contains an ignored local file or folder, **When** the repository browser loads that location, **Then** the ignored item appears alongside other visible repository items instead of being omitted. +2. **Given** a user expands folders in the current working-tree tree view, **When** an ignored child item exists in that folder, **Then** the ignored child can be discovered through normal browsing. +3. **Given** a user searches the current working tree for a name that matches an ignored local item, **When** search results are shown, **Then** that ignored item appears in the results. + +--- + +### User Story 2 - Understand that ignored items stay local (Priority: P1) + +When a user sees an ignored item in GitLocal, they can immediately tell that it is local-only content and is not currently part of what reaches the remote repository. + +**Why this priority**: Visibility without context would be misleading. Users need a fast, trustworthy signal that these items are intentionally different from normal tracked repository content. + +**Independent Test**: View ignored items in a mixed repository and confirm that users can identify them as local-only from the visible UI treatment without opening extra help or documentation. + +**Acceptance Scenarios**: + +1. **Given** an ignored item is shown in a repository listing, **When** a user scans the row or tree entry, **Then** a visible cue distinguishes it from normal tracked content. +2. **Given** a repository view contains both tracked and ignored items, **When** a user compares them, **Then** the ignored items are still readable and usable while remaining clearly marked as local-only. +3. **Given** a user opens an ignored file or folder from a repository listing, **When** the selected item becomes the active context, **Then** the local-only state remains understandable in that active view. + +--- + +### User Story 3 - Avoid false empty states for ignored-only content (Priority: P2) + +When a repository or folder contains only ignored local items, GitLocal shows that content instead of presenting the location as empty or broken. + +**Why this priority**: Repositories often keep generated, personal, or machine-local files under ignore rules. Hiding those items can make a live local workspace look empty even when useful content is present. + +**Independent Test**: Open a repository root or folder that contains only ignored local items and verify that GitLocal presents those items as visible local content rather than showing an empty-state message. + +**Acceptance Scenarios**: + +1. **Given** a repository root contains ignored local items but no other browseable items, **When** the default working-tree view loads, **Then** GitLocal shows those ignored items instead of treating the repository as empty. +2. **Given** a folder contains only ignored local items, **When** that folder view opens, **Then** GitLocal shows the ignored contents instead of an empty-folder message. +3. **Given** a user switches from the current working tree to a non-current branch or historical view, **When** ignored local items do not exist in that repository state, **Then** those ignored local items no longer appear in the browser. + +### Edge Cases + +- How does GitLocal handle ignore rules that match entire folders rather than a single file? +- What happens when an ignored item's status changes during the session because the ignore rule changes or the user starts tracking that item? +- How does the repository root behave when the only visible local content is ignored and all other root items are hidden dotfiles? +- What happens when a user searches for a term that matches both tracked items and ignored local items in the same result set? +- How does the active view behave if an ignored item is deleted or moved on disk after GitLocal has already listed it? + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The system MUST include ignored local files and folders in the current working-tree repository browsing experience instead of filtering them out. +- **FR-002**: The system MUST apply a clear visual cue to ignored local items indicating that they are local-only content and are not currently part of what reaches the remote repository. +- **FR-003**: The system MUST present that local-only cue in user-facing language that is understandable without requiring prior knowledge of git ignore rules. +- **FR-004**: The system MUST apply ignored-item visibility and the local-only cue consistently across all current working-tree UI surfaces that list repository items, including the repository tree, folder listings, and working-tree search results. +- **FR-005**: Users MUST be able to open a visible ignored file or folder through the same basic browsing actions available for other visible local items. +- **FR-006**: The system MUST keep ignored local items scoped to the current working-tree view and MUST NOT show them in non-current branch or historical repository views unless those views independently contain matching content. +- **FR-007**: The system MUST treat ignored local items as visible repository content when determining whether the current repository root or folder should appear empty. +- **FR-008**: The system MUST support ignored directories and nested ignored items, not only single ignored files at the repository root. +- **FR-009**: The system MUST refresh ignored-item visibility and local-only labeling when the repository view refreshes after an item's ignore status changes. +- **FR-010**: The system MUST avoid wording or presentation that suggests an ignored local item is already tracked, committed, or available on the remote repository. +- **FR-011**: The system MUST continue to hide repository internals that are intentionally excluded from browsing, such as the repository metadata directory, even while other ignored local items become visible. +- **FR-012**: If an ignored item is shown in a listing but becomes unavailable before the user opens it, the system MUST provide a clear unavailable outcome rather than leaving the interface in a broken or misleading state. + +### Key Entities *(include if feature involves data)* + +- **Ignored Local Item**: A file or folder present in the local working tree that matches the repository's ignore rules and is therefore local-only unless its status changes. +- **Local-Only Cue**: The visible label, iconography, or text treatment that tells users an ignored item stays local and is not currently part of the remote-facing repository state. +- **Working-Tree Browse Surface**: Any GitLocal screen element that lists current local repository items for browsing, such as the repository tree, folder content lists, and search results. +- **Repository Content State**: GitLocal's user-facing understanding of whether a repository root or folder contains visible local content worth showing instead of an empty-state message. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In validation testing, 100% of ignored local items in the current working tree appear in the designated browsing surfaces for their location. +- **SC-002**: In usability review, at least 90% of participants can correctly identify an ignored item as local-only within 5 seconds of seeing it. +- **SC-003**: In validation testing, 100% of repositories or folders that contain only ignored visible items are presented as containing browseable content rather than as empty or broken states. +- **SC-004**: In validation testing, 100% of ignored local items disappear from the browser when the user switches from the current working-tree view to a non-current branch or historical view where those items are not present. +- **SC-005**: In task-based validation, users can open an ignored visible file or folder in the same number of interaction steps required for a comparable non-ignored item in the same view. + +## Assumptions + +- The initial feature scope covers user-facing repository browsing surfaces in the current working-tree view, including search results, but does not add new git status management workflows. +- "Will not be sent to remote" is communicated to users as a local-only state, while the actual act of changing ignore rules or tracking an item remains outside this feature. +- Existing file-viewing and lightweight local-file actions that already apply to local working-tree files may continue to apply to ignored items when those actions are otherwise valid. +- Ignored directories should be treated consistently with ignored files so that users can understand complete local-only areas of a repository. +- Repository internals that are intentionally excluded from browsing, such as the repository metadata directory, remain hidden even though other ignored local items become visible. diff --git a/specs/008-ignored-files-visibility/tasks.md b/specs/008-ignored-files-visibility/tasks.md new file mode 100644 index 0000000..fd80eae --- /dev/null +++ b/specs/008-ignored-files-visibility/tasks.md @@ -0,0 +1,218 @@ +# Tasks: Ignored Local File Visibility + +**Input**: Design documents from `specs/008-ignored-files-visibility/` +**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/` + +**Tests**: Include targeted backend and frontend tests because the constitution, contracts, and quickstart require coverage-preserving validation for local-only metadata, ignored-item discoverability, and ignored-only empty-state behavior. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this belongs to (e.g. `US1`, `US2`) +- Include exact file paths in descriptions + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Prepare shared client/server types and API contracts for ignored local item metadata. + +- [X] T001 Add `localOnly` support to shared browse-entry and search-result models in `src/types.ts` +- [X] T002 [P] Mirror `localOnly` browse-entry and search-result models in `ui/src/types/index.ts` +- [X] T003 [P] Update tree and search API typings for `localOnly` metadata in `ui/src/services/api.ts` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Build the shared ignored-item detection and server-contract plumbing that every user story depends on. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [X] T004 Extend ignored-path classification and browseable root-entry counting for visible local-only items in `src/git/repo.ts` +- [X] T005 Extend current-working-tree list and search helpers to emit ignored files, ignored folders, and `localOnly` metadata in `src/git/tree.ts` +- [X] T006 [P] Expose enriched tree-entry responses in `src/handlers/files.ts` +- [X] T007 [P] Expose ignored-aware repository metadata in `src/handlers/git.ts` +- [X] T008 [P] Expose `localOnly` search results for working-tree queries in `src/handlers/search.ts` +- [X] T009 [P] Add unit coverage for ignored directory entries and root-entry counting in `tests/unit/git/repo.test.ts` +- [X] T010 [P] Add unit coverage for ignored working-tree list and search helpers in `tests/unit/git/tree.test.ts` +- [X] T011 [P] Add handler coverage for ignored-aware repository info and tree responses in `tests/unit/handlers/git.test.ts` +- [X] T012 [P] Add handler coverage for local-only search responses in `tests/unit/handlers/search.test.ts` +- [X] T013 [P] Add integration coverage for ignored tree and search contracts in `tests/integration/server.test.ts` + +**Checkpoint**: Foundation ready - user story implementation can now begin in parallel + +--- + +## Phase 3: User Story 1 - Browse ignored local items in GitLocal (Priority: P1) 🎯 MVP + +**Goal**: Make ignored local files and folders discoverable through the current working-tree browser, folder view, and repository search. + +**Independent Test**: Open a mixed repository and verify ignored items appear in the repository tree, current-folder listing, and working-tree search results, and can be opened through the same basic browsing flows as other visible items. + +### Tests for User Story 1 + +- [X] T014 [P] [US1] Add frontend coverage for ignored entries in the repository tree in `ui/src/components/FileTree/FileTree.test.tsx` +- [X] T015 [P] [US1] Add frontend coverage for ignored entries in folder directory views in `ui/src/components/ContentPanel/ContentPanel.test.tsx` +- [X] T016 [P] [US1] Add frontend coverage for ignored search results and folder-result activation in `ui/src/components/Search/SearchPanel.test.tsx` +- [X] T017 [P] [US1] Add app-level coverage for browsing ignored items from tree, folder list, and search in `ui/src/App.test.tsx` + +### Implementation for User Story 1 + +- [X] T018 [US1] Wire ignored-aware selected item state and folder-capable search activation through the viewer flow in `ui/src/App.tsx` +- [X] T019 [US1] Update repository search result rendering and activation for ignored files and folders in `ui/src/components/Search/SearchResults.tsx` + +**Checkpoint**: User Story 1 should be fully functional and testable independently + +--- + +## Phase 4: User Story 2 - Understand that ignored items stay local (Priority: P1) + +**Goal**: Apply a consistent local-only cue so ignored items are clearly visible as local content rather than tracked remote-facing repository content. + +**Independent Test**: View ignored items in a mixed repository and confirm the tree, folder list, search results, and active item context all make the local-only status understandable without extra explanation. + +### Tests for User Story 2 + +- [X] T020 [P] [US2] Add frontend coverage for local-only tree cues in `ui/src/components/FileTree/FileTree.test.tsx` +- [X] T021 [P] [US2] Add frontend coverage for local-only directory and active-context cues in `ui/src/components/ContentPanel/ContentPanel.test.tsx` +- [X] T022 [P] [US2] Add frontend coverage for local-only search-result cues in `ui/src/components/Search/SearchPanel.test.tsx` +- [X] T023 [P] [US2] Add app-level coverage for preserving local-only context after opening ignored items in `ui/src/App.test.tsx` + +### Implementation for User Story 2 + +- [X] T024 [US2] Render a local-only cue for ignored tree entries in `ui/src/components/FileTree/FileTreeNode.tsx` +- [X] T025 [US2] Render local-only cues in directory rows and active file or folder context in `ui/src/components/ContentPanel/ContentPanel.tsx` +- [X] T026 [US2] Render local-only cues in repository search results in `ui/src/components/Search/SearchResults.tsx` +- [X] T027 [P] [US2] Add shared local-only presentation styles in `ui/src/App.css` + +**Checkpoint**: User Story 2 should be fully functional and testable independently + +--- + +## Phase 5: User Story 3 - Avoid false empty states for ignored-only content (Priority: P2) + +**Goal**: Treat ignored-only roots and folders as real visible content so GitLocal does not present them as empty or broken, and handle disappeared ignored items gracefully. + +**Independent Test**: Open a repository root or folder that contains only ignored local items and verify GitLocal shows those items instead of an empty state; then remove an ignored item and confirm the UI falls back to a clear unavailable outcome. + +### Tests for User Story 3 + +- [X] T028 [P] [US3] Add app-level coverage for ignored-only repository landing states and non-current-branch fallback in `ui/src/App.test.tsx` +- [X] T029 [P] [US3] Add content-panel coverage for ignored-only folders and unavailable ignored items in `ui/src/components/ContentPanel/ContentPanel.test.tsx` + +### Implementation for User Story 3 + +- [X] T030 [US3] Update repository landing-state classification to treat ignored-only root content as browseable in `ui/src/App.tsx` +- [X] T031 [US3] Adjust directory empty-state and unavailable-item handling for ignored-only content in `ui/src/components/ContentPanel/ContentPanel.tsx` + +**Checkpoint**: User Story 3 should be fully functional and testable independently + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Finish copy consistency, manual validation, and full verification across the feature. + +- [X] T032 Review local-only copy and result labels for consistency across `ui/src/App.tsx`, `ui/src/components/ContentPanel/ContentPanel.tsx`, and `ui/src/components/Search/SearchResults.tsx` +- [ ] T033 [P] Run the manual validation flow in `specs/008-ignored-files-visibility/quickstart.md` and capture any follow-up notes in `specs/008-ignored-files-visibility/quickstart.md` +- [X] T034 [P] Run full verification with `npm test`, `npm run lint`, and `npm run build` from `package.json` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies - can start immediately +- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories +- **User Story 1 (Phase 3)**: Depends on Foundational completion +- **User Story 2 (Phase 4)**: Depends on User Story 1 because the local-only cue builds on ignored-item visibility and selection flow +- **User Story 3 (Phase 5)**: Depends on User Story 1 and the ignored-aware repository metadata from Foundational +- **Polish (Phase 6)**: Depends on all desired user stories being complete + +### User Story Dependencies + +- **User Story 1 (P1)**: Can start after Foundational - no dependency on later stories +- **User Story 2 (P1)**: Depends on US1's visible ignored-item flows so the cue can be applied consistently +- **User Story 3 (P2)**: Depends on the shared ignored-item metadata and browse surfaces from Foundational + US1 + +### Within Each User Story + +- Coverage tasks should be written before or alongside implementation for the same story +- Viewer-state wiring before surface-specific cue rendering +- Core story behavior before polish or wording cleanup + +### Parallel Opportunities + +- `T002` and `T003` can run in parallel after `T001` +- `T006`, `T007`, and `T008` can run in parallel after `T004` and `T005` +- `T009` through `T013` can run in parallel after the foundational handlers and helpers are in place +- `T014` through `T017` can run in parallel inside US1 before the final implementation pass in `T018` and `T019` +- `T020` through `T023` and `T027` can run in parallel inside US2 after US1 is complete +- `T028` and `T029` can run in parallel inside US3 before `T030` and `T031` +- `T033` and `T034` can run in parallel during polish after implementation is complete + +--- + +## Parallel Example: User Story 1 + +```bash +Task: "Add frontend coverage for ignored entries in the repository tree in ui/src/components/FileTree/FileTree.test.tsx" +Task: "Add frontend coverage for ignored entries in folder directory views in ui/src/components/ContentPanel/ContentPanel.test.tsx" +Task: "Add frontend coverage for ignored search results and folder-result activation in ui/src/components/Search/SearchPanel.test.tsx" +Task: "Add app-level coverage for browsing ignored items from tree, folder list, and search in ui/src/App.test.tsx" +``` + +## Parallel Example: User Story 2 + +```bash +Task: "Add frontend coverage for local-only tree cues in ui/src/components/FileTree/FileTree.test.tsx" +Task: "Add frontend coverage for local-only directory and active-context cues in ui/src/components/ContentPanel/ContentPanel.test.tsx" +Task: "Add frontend coverage for local-only search-result cues in ui/src/components/Search/SearchPanel.test.tsx" +Task: "Add shared local-only presentation styles in ui/src/App.css" +``` + +## Parallel Example: User Story 3 + +```bash +Task: "Add app-level coverage for ignored-only repository landing states and non-current-branch fallback in ui/src/App.test.tsx" +Task: "Add content-panel coverage for ignored-only folders and unavailable ignored items in ui/src/components/ContentPanel/ContentPanel.test.tsx" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup +2. Complete Phase 2: Foundational +3. Complete Phase 3: User Story 1 +4. Stop and validate ignored-item discoverability independently before adding cue work + +### Incremental Delivery + +1. Complete Setup + Foundational to establish ignored-item metadata and contracts +2. Deliver User Story 1 so ignored files and folders become discoverable across browsing and search +3. Add User Story 2 so ignored items are clearly marked as local-only +4. Add User Story 3 so ignored-only roots and folders no longer feel empty or broken +5. Finish with copy cleanup, quickstart validation, and full verification + +### Parallel Team Strategy + +1. One developer handles server helpers and handlers in Phase 2 while another prepares shared UI type and API updates from Phase 1 +2. Once Foundational is complete: + - Developer A: User Story 1 visibility and search activation + - Developer B: User Story 2 local-only cue presentation + - Developer C: User Story 3 empty-state behavior +3. Rejoin for polish and full verification after story-specific work stabilizes + +--- + +## Notes + +- [P] tasks are safe parallel opportunities because they touch different files or depend only on completed shared work +- Each user story is scoped to remain independently testable +- The suggested MVP scope is User Story 1 only +- All tasks follow the required checklist format with task ID, optional parallel marker, story label where needed, and exact file paths diff --git a/src/git/repo.ts b/src/git/repo.ts index 47cded4..d381ef1 100644 --- a/src/git/repo.ts +++ b/src/git/repo.ts @@ -373,13 +373,13 @@ export function listWorkingTreeDirectoryEntries(repoPath: string, subpath: strin .filter((entry) => entry.name !== '.git') .map((entry) => { const path = normalized ? `${normalized}/${entry.name}` : entry.name - return { entry, path } + return { entry, path, localOnly: isIgnoredPath(repoPath, path) } }) - .filter(({ path }) => !isIgnoredPath(repoPath, path)) - .map(({ entry, path }) => ({ + .map(({ entry, path, localOnly }) => ({ name: entry.name, path, type: entry.isDirectory() ? 'dir' as const : 'file' as const, + localOnly, })) .sort((a, b) => { if (a.type !== b.type) return a.type === 'dir' ? -1 : 1 diff --git a/src/git/tree.ts b/src/git/tree.ts index 5d8c7c3..0e3dced 100644 --- a/src/git/tree.ts +++ b/src/git/tree.ts @@ -2,7 +2,12 @@ import { statSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' import { spawnSync } from 'node:child_process' import type { TreeNode } from '../types.js' -import { getTrackedWorkingTreeFiles, listWorkingTreeDirectoryEntries } from './repo.js' +import { + getTrackedPathType, + listWorkingTreeDirectoryEntries, + normalizeRepoRelativePath, + resolveSafeRepoPath, +} from './repo.js' function runLsTree(repoPath: string, args: string[]): string { const result = spawnSync('git', ['ls-tree', ...args], { cwd: repoPath, encoding: 'utf-8' }) @@ -23,13 +28,13 @@ export function listDir(repoPath: string, branch: string, subpath: string = ''): for (const line of output.split('\n').filter(Boolean)) { const spaceIdx = line.indexOf(' ') const objType = line.slice(0, spaceIdx) - const name = line.slice(spaceIdx + 1) - /* v8 ignore next */ - if (!name) continue - const type: 'file' | 'dir' = objType === 'tree' ? 'dir' : 'file' - const fullPath = subpath ? `${subpath}/${name}` : name - nodes.push({ name, path: fullPath, type }) - } + const name = line.slice(spaceIdx + 1) + /* v8 ignore next */ + if (!name) continue + const type: 'file' | 'dir' = objType === 'tree' ? 'dir' : 'file' + const fullPath = subpath ? `${subpath}/${name}` : name + nodes.push({ name, path: fullPath, type, localOnly: false }) + } // Sort: dirs first (lexicographic), then files (lexicographic) return nodes.sort((a, b) => { @@ -39,38 +44,32 @@ export function listDir(repoPath: string, branch: string, subpath: string = ''): }) } -function getTrackedWorkingTreeEntries(repoPath: string): TreeNode[] { - const files = getTrackedWorkingTreeFiles(repoPath) - const dirs = new Set() +export function listWorkingTreeDir(repoPath: string, subpath: string = ''): TreeNode[] { + return listWorkingTreeDirectoryEntries(repoPath, subpath) +} - for (const filePath of files) { - const parts = filePath.split('/') - for (let index = 1; index < parts.length; index += 1) { - dirs.add(parts.slice(0, index).join('/')) - } - } +function getSearchableWorkingTreeEntries(repoPath: string, subpath: string = ''): TreeNode[] { + const normalized = normalizeRepoRelativePath(subpath) + const dirPath = normalized ? resolveSafeRepoPath(repoPath, normalized) : repoPath - const nodes: TreeNode[] = [ - ...Array.from(dirs).map((dirPath) => ({ - name: dirPath.split('/').pop() as string, - path: dirPath, - type: 'dir' as const, - })), - ...files.map((filePath) => ({ - name: filePath.split('/').pop() as string, - path: filePath, - type: 'file' as const, - })), - ] + if (!dirPath) return [] - return nodes.sort((a, b) => { - if (a.type !== b.type) return a.type === 'dir' ? -1 : 1 - return a.path.localeCompare(b.path) - }) -} + const entries = listWorkingTreeDirectoryEntries(repoPath, normalized) + const results: TreeNode[] = [] -export function listWorkingTreeDir(repoPath: string, subpath: string = ''): TreeNode[] { - return listWorkingTreeDirectoryEntries(repoPath, subpath) + for (const entry of entries) { + const trackedType = getTrackedPathType(repoPath, entry.path) + const included = entry.localOnly || trackedType === entry.type + if (!included) continue + + results.push(entry) + + if (entry.type === 'dir' && (entry.localOnly || trackedType === 'dir')) { + results.push(...getSearchableWorkingTreeEntries(repoPath, entry.path)) + } + } + + return results } function readSnippet(filePath: string, query: string, caseSensitive: boolean): { line: number; snippet: string } | null { @@ -92,7 +91,7 @@ function readSnippet(filePath: string, query: string, caseSensitive: boolean): { } export function searchWorkingTreeByName(repoPath: string, query: string, caseSensitive: boolean): TreeNode[] { - const entries = getTrackedWorkingTreeEntries(repoPath) + const entries = getSearchableWorkingTreeEntries(repoPath) const needle = caseSensitive ? query : query.toLowerCase() return entries.filter((entry) => { const hay = caseSensitive ? entry.path : entry.path.toLowerCase() @@ -101,7 +100,7 @@ export function searchWorkingTreeByName(repoPath: string, query: string, caseSen } export function searchWorkingTreeByContent(repoPath: string, query: string, caseSensitive: boolean): Array { - const entries = getTrackedWorkingTreeEntries(repoPath).filter((entry) => entry.type === 'file') + const entries = getSearchableWorkingTreeEntries(repoPath).filter((entry) => entry.type === 'file') const matches: Array = [] for (const entry of entries) { diff --git a/src/handlers/search.ts b/src/handlers/search.ts index 6111f98..c4bd293 100644 --- a/src/handlers/search.ts +++ b/src/handlers/search.ts @@ -29,13 +29,13 @@ function searchGitTreeByName(repoPath: string, branch: string, query: string, ca for (const dir of Array.from(dirs)) { const hay = caseSensitive ? dir : dir.toLowerCase() if (needle && hay.includes(needle)) { - results.push({ path: dir, type: 'dir', matchType: 'name' }) + results.push({ path: dir, type: 'dir', matchType: 'name', localOnly: false }) } } for (const name of names) { const hay = caseSensitive ? name : name.toLowerCase() if (needle && hay.includes(needle)) { - results.push({ path: name, type: 'file', matchType: 'name' }) + results.push({ path: name, type: 'file', matchType: 'name', localOnly: false }) } } return results @@ -46,6 +46,7 @@ function searchWorkingTreeByTrackedName(repoPath: string, query: string, caseSen path: entry.path, type: entry.type, matchType: 'name' as const, + localOnly: entry.localOnly, })) } @@ -56,6 +57,7 @@ function searchWorkingTreeByTrackedContent(repoPath: string, query: string, case matchType: 'content' as const, line: entry.line, snippet: entry.snippet, + localOnly: entry.localOnly, })) } @@ -78,7 +80,7 @@ function searchGitTreeByContent(repoPath: string, branch: string, query: string, const [, path, lineNoText, ...snippetParts] = line.split(':') const snippet = snippetParts.join(':').trim() const lineNo = Number(lineNoText) - return { path, type: 'file', matchType: 'content' as const, line: lineNo, snippet } + return { path, type: 'file', matchType: 'content' as const, line: lineNo, snippet, localOnly: false } }) } diff --git a/src/types.ts b/src/types.ts index eea8756..b1a40e4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -39,6 +39,7 @@ export interface TreeNode { name: string path: string type: 'file' | 'dir' + localOnly: boolean } export type FileEncoding = 'utf-8' | 'base64' | 'none' @@ -115,6 +116,7 @@ export interface SearchResult { matchType: SearchMode snippet?: string line?: number + localOnly: boolean } export interface SearchResponse { diff --git a/tests/integration/server.test.ts b/tests/integration/server.test.ts index dbabd30..29085aa 100644 --- a/tests/integration/server.test.ts +++ b/tests/integration/server.test.ts @@ -90,11 +90,31 @@ describe('Server integration', () => { expect(res.status).toBe(200) const body = await res.json() as Array<{ name: string; path: string; type: 'file' | 'dir' }> expect(body).toEqual([ - { name: 'guide.md', path: 'docs/guide.md', type: 'file' }, - { name: 'tree-view.md', path: 'docs/tree-view.md', type: 'file' }, + { name: 'guide.md', path: 'docs/guide.md', type: 'file', localOnly: false }, + { name: 'tree-view.md', path: 'docs/tree-view.md', type: 'file', localOnly: false }, ]) }) + it('GET /api/tree includes ignored local entries with localOnly metadata', async () => { + writeFileSync(join(dir, '.gitignore'), 'ignored.txt\n') + writeFileSync(join(dir, 'ignored.txt'), 'local only') + const app = createApp(dir) + const res = await app.fetch(new Request('http://localhost/api/tree')) + expect(res.status).toBe(200) + const body = await res.json() as Array<{ path: string; localOnly?: boolean }> + expect(body).toContainEqual(expect.objectContaining({ path: 'ignored.txt', localOnly: true })) + }) + + it('GET /api/search includes ignored local matches with localOnly metadata', async () => { + writeFileSync(join(dir, '.gitignore'), 'ignored.txt\n') + writeFileSync(join(dir, 'ignored.txt'), 'search me locally') + const app = createApp(dir) + const res = await app.fetch(new Request('http://localhost/api/search?query=ignored&mode=name')) + expect(res.status).toBe(200) + const body = await res.json() as { results: Array<{ path: string; localOnly?: boolean }> } + expect(body.results).toContainEqual(expect.objectContaining({ path: 'ignored.txt', localOnly: true })) + }) + it('GET /unknown-path returns index.html SPA fallback', async () => { const app = createApp(dir) const res = await app.fetch(new Request('http://localhost/some/spa/route')) diff --git a/tests/unit/git/repo.test.ts b/tests/unit/git/repo.test.ts index 6932e7d..f1b54c6 100644 --- a/tests/unit/git/repo.test.ts +++ b/tests/unit/git/repo.test.ts @@ -495,6 +495,7 @@ describe('working tree helpers', () => { expect(listWorkingTreeDirectoryEntries(dir, 'notes').some((node) => node.path === 'notes/new.md')).toBe(true) writeFileSync(join(dir, 'ignored.txt'), 'skip me') expect(isIgnoredPath(dir, 'ignored.txt')).toBe(true) + expect(listWorkingTreeDirectoryEntries(dir, '').some((node) => node.path === 'ignored.txt' && node.localOnly === true)).toBe(true) deleteWorkingTreeFile(dir, 'notes/new.md') expect(readWorkingTreeFile(dir, 'notes/new.md')).toBeNull() expect(() => writeWorkingTreeTextFile(dir, '../escape.txt', 'x')).toThrow(/inside the opened repository/i) @@ -504,12 +505,26 @@ describe('working tree helpers', () => { } }) + it('surfaces ignored directories and their children as local-only entries', () => { + const { dir, cleanup } = makeGitRepo() + try { + writeFileSync(join(dir, '.gitignore'), 'cache/\n') + mkdirSync(join(dir, 'cache')) + writeFileSync(join(dir, 'cache', 'draft.txt'), 'draft') + + expect(listWorkingTreeDirectoryEntries(dir, '').some((node) => node.path === 'cache' && node.type === 'dir' && node.localOnly === true)).toBe(true) + expect(listWorkingTreeDirectoryEntries(dir, 'cache').some((node) => node.path === 'cache/draft.txt' && node.localOnly === true)).toBe(true) + } finally { + cleanup() + } + }) + it('counts only browseable root entries for landing-state decisions', () => { const { dir, cleanup } = makeGitRepo() try { writeFileSync(join(dir, '.gitignore'), 'ignored.txt\n') writeFileSync(join(dir, 'ignored.txt'), 'skip me') - expect(getBrowseableRootEntryCount(dir)).toBe(4) + expect(getBrowseableRootEntryCount(dir)).toBe(5) } finally { cleanup() } diff --git a/tests/unit/git/tree.test.ts b/tests/unit/git/tree.test.ts index 2f22379..81c71cb 100644 --- a/tests/unit/git/tree.test.ts +++ b/tests/unit/git/tree.test.ts @@ -157,6 +157,27 @@ describe('working tree tree helpers', () => { expect(searchWorkingTreeByName(dir, 'scratch', false)).toEqual([]) }) + it('surfaces ignored working-tree entries and marks them local-only', () => { + writeFileSync(join(dir, '.gitignore'), 'ignored.txt\nignored-dir/\n') + writeFileSync(join(dir, 'ignored.txt'), 'hidden note') + mkdirSync(join(dir, 'ignored-dir')) + writeFileSync(join(dir, 'ignored-dir', 'nested.md'), 'nested local only') + + expect(listWorkingTreeDir(dir, '').some((node) => node.path === 'ignored.txt' && node.localOnly === true)).toBe(true) + expect(listWorkingTreeDir(dir, '').some((node) => node.path === 'ignored-dir' && node.localOnly === true)).toBe(true) + expect(searchWorkingTreeByName(dir, 'ignored', false)).toContainEqual(expect.objectContaining({ path: 'ignored.txt', localOnly: true })) + expect(searchWorkingTreeByName(dir, 'ignored', false)).toContainEqual(expect.objectContaining({ path: 'ignored-dir', localOnly: true })) + }) + + it('searches ignored file contents in the current working tree and marks them local-only', () => { + writeFileSync(join(dir, '.gitignore'), 'ignored.txt\n') + writeFileSync(join(dir, 'ignored.txt'), 'hidden search target') + + expect(searchWorkingTreeByContent(dir, 'search target', false)).toContainEqual( + expect.objectContaining({ path: 'ignored.txt', localOnly: true }), + ) + }) + it('returns no matches for an empty name query', () => { expect(searchWorkingTreeByName(dir, '', false)).toEqual([]) }) diff --git a/tests/unit/handlers/git.test.ts b/tests/unit/handlers/git.test.ts index 0f520f0..0d9d233 100644 --- a/tests/unit/handlers/git.test.ts +++ b/tests/unit/handlers/git.test.ts @@ -59,6 +59,18 @@ describe('infoHandler', () => { expect(body.rootEntryCount).toBeGreaterThan(0) }) + it('counts ignored local root entries in repo metadata', async () => { + writeFileSync(join(dir, '.gitignore'), 'ignored.txt\n') + writeFileSync(join(dir, 'ignored.txt'), 'local only') + + const app = createApp(dir) + const client = testClient(app) + const res = await client.api.info.$get() + const body = await res.json() + + expect(body.rootEntryCount).toBe(2) + }) + it('returns empty-repo metadata for a repo with no commits and no browseable entries', async () => { const emptyDir = mkdtempSync(join(tmpdir(), 'gitlocal-empty-info-')) spawnSync('git', ['init'], { cwd: emptyDir }) @@ -258,6 +270,25 @@ describe('readmeHandler', () => { }) }) +describe('tree responses', () => { + it('returns ignored entries with localOnly metadata for the working tree', async () => { + const { dir, cleanup } = makeGitRepo() + + try { + writeFileSync(join(dir, '.gitignore'), 'ignored.txt\n') + writeFileSync(join(dir, 'ignored.txt'), 'local only') + + const app = createApp(dir) + const res = await app.fetch(new Request('http://localhost/api/tree')) + const body = await res.json() as Array<{ path: string; localOnly?: boolean }> + + expect(body).toContainEqual(expect.objectContaining({ path: 'ignored.txt', localOnly: true })) + } finally { + cleanup() + } + }) +}) + describe('treeHandler', () => { it('returns immediate child files and folders for the requested directory', async () => { const repo = makeGitRepo() @@ -275,9 +306,9 @@ describe('treeHandler', () => { expect(res.status).toBe(200) const body = await res.json() expect(body).toEqual([ - { name: 'nested', path: 'docs/nested', type: 'dir' }, - { name: 'guide.md', path: 'docs/guide.md', type: 'file' }, - { name: 'notes.md', path: 'docs/notes.md', type: 'file' }, + { name: 'nested', path: 'docs/nested', type: 'dir', localOnly: false }, + { name: 'guide.md', path: 'docs/guide.md', type: 'file', localOnly: false }, + { name: 'notes.md', path: 'docs/notes.md', type: 'file', localOnly: false }, ]) } finally { repo.cleanup() diff --git a/tests/unit/handlers/search.test.ts b/tests/unit/handlers/search.test.ts index 891eedb..950599f 100644 --- a/tests/unit/handlers/search.test.ts +++ b/tests/unit/handlers/search.test.ts @@ -72,6 +72,28 @@ describe('searchHandler', () => { expect((await contentRes.json()).results).toEqual([]) }) + it('returns local-only matches for ignored files in current-branch searches', async () => { + writeFileSync(join(dir, '.gitignore'), 'ignored.txt\n') + writeFileSync(join(dir, 'ignored.txt'), 'ignored search body') + + const client = testClient(createApp(dir)) + const nameRes = await client.api.search.$get({ + query: { query: 'ignored', branch, mode: 'name' }, + }) + const contentRes = await client.api.search.$get({ + query: { query: 'search body', branch, mode: 'content' }, + }) + + expect((await nameRes.json()).results).toContainEqual(expect.objectContaining({ + path: 'ignored.txt', + localOnly: true, + })) + expect((await contentRes.json()).results).toContainEqual(expect.objectContaining({ + path: 'ignored.txt', + localOnly: true, + })) + }) + it('honors case-sensitive matching', async () => { const client = testClient(createApp(dir)) const sensitive = await client.api.search.$get({ diff --git a/ui/package-lock.json b/ui/package-lock.json index 3d762f6..b2ee3bd 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -24,12 +24,12 @@ "@types/react": "^18.3.1", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^5.1.0", - "@vitest/coverage-v8": "^4.1.2", + "@vitest/coverage-v8": "^4.1.3", "jest-axe": "^10.0.0", "jsdom": "^24.0.0", "typescript": "^5.4.5", - "vite": "^7.1.3", - "vitest": "^4.1.2" + "vite": "^7.3.2", + "vitest": "^4.1.3" } }, "node_modules/@adobe/css-tools": { @@ -1657,14 +1657,15 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.2.tgz", - "integrity": "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.3.tgz", + "integrity": "sha512-/MBdrkA8t6hbdCWFKs09dPik774xvs4Z6L4bycdCxYNLHM8oZuRyosumQMG19LUlBsB6GeVpL1q4kFFazvyKGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.2", + "@vitest/utils": "4.1.3", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -1678,8 +1679,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.2", - "vitest": "4.1.2" + "@vitest/browser": "4.1.3", + "vitest": "4.1.3" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1688,16 +1689,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", - "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.3.tgz", + "integrity": "sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.2", - "@vitest/utils": "4.1.2", + "@vitest/spy": "4.1.3", + "@vitest/utils": "4.1.3", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1706,13 +1707,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", - "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.3.tgz", + "integrity": "sha512-XN3TrycitDQSzGRnec/YWgoofkYRhouyVQj4YNsJ5r/STCUFqMrP4+oxEv3e7ZbLi4og5kIHrZwekDJgw6hcjw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.2", + "@vitest/spy": "4.1.3", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1733,9 +1734,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", - "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.3.tgz", + "integrity": "sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg==", "dev": true, "license": "MIT", "dependencies": { @@ -1746,13 +1747,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", - "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.3.tgz", + "integrity": "sha512-VwgOz5MmT0KhlUj40h02LWDpUBVpflZ/b7xZFA25F29AJzIrE+SMuwzFf0b7t4EXdwRNX61C3B6auIXQTR3ttA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.2", + "@vitest/utils": "4.1.3", "pathe": "^2.0.3" }, "funding": { @@ -1760,14 +1761,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", - "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.3.tgz", + "integrity": "sha512-9l+k/J9KG5wPJDX9BcFFzhhwNjwkRb8RsnYhaT1vPY7OufxmQFc9sZzScRCPTiETzl37mrIWVY9zxzmdVeJwDQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.2", - "@vitest/utils": "4.1.2", + "@vitest/pretty-format": "4.1.3", + "@vitest/utils": "4.1.3", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1776,9 +1777,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", - "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.3.tgz", + "integrity": "sha512-ujj5Uwxagg4XUIfAUyRQxAg631BP6e9joRiN99mr48Bg9fRs+5mdUElhOoZ6rP5mBr8Bs3lmrREnkrQWkrsTCw==", "dev": true, "license": "MIT", "funding": { @@ -1786,13 +1787,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", - "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.3.tgz", + "integrity": "sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.2", + "@vitest/pretty-format": "4.1.3", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -4680,9 +4681,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", - "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", "dev": true, "license": "MIT", "engines": { @@ -4961,9 +4962,9 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", "peer": true, @@ -5037,20 +5038,20 @@ } }, "node_modules/vitest": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", - "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.3.tgz", + "integrity": "sha512-DBc4Tx0MPNsqb9isoyOq00lHftVx/KIU44QOm2q59npZyLUkENn8TMFsuzuO+4U2FUa9rgbbPt3udrP25GcjXw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@vitest/expect": "4.1.2", - "@vitest/mocker": "4.1.2", - "@vitest/pretty-format": "4.1.2", - "@vitest/runner": "4.1.2", - "@vitest/snapshot": "4.1.2", - "@vitest/spy": "4.1.2", - "@vitest/utils": "4.1.2", + "@vitest/expect": "4.1.3", + "@vitest/mocker": "4.1.3", + "@vitest/pretty-format": "4.1.3", + "@vitest/runner": "4.1.3", + "@vitest/snapshot": "4.1.3", + "@vitest/spy": "4.1.3", + "@vitest/utils": "4.1.3", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -5078,10 +5079,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.2", - "@vitest/browser-preview": "4.1.2", - "@vitest/browser-webdriverio": "4.1.2", - "@vitest/ui": "4.1.2", + "@vitest/browser-playwright": "4.1.3", + "@vitest/browser-preview": "4.1.3", + "@vitest/browser-webdriverio": "4.1.3", + "@vitest/coverage-istanbul": "4.1.3", + "@vitest/coverage-v8": "4.1.3", + "@vitest/ui": "4.1.3", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -5105,6 +5108,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, diff --git a/ui/package.json b/ui/package.json index 366238e..fbc8d72 100644 --- a/ui/package.json +++ b/ui/package.json @@ -24,17 +24,17 @@ "remark-gfm": "^4.0.0" }, "devDependencies": { - "jest-axe": "^10.0.0", "@testing-library/jest-dom": "^6.4.2", "@testing-library/react": "^15.0.2", "@testing-library/user-event": "^14.5.2", "@types/react": "^18.3.1", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^5.1.0", - "@vitest/coverage-v8": "^4.1.2", + "@vitest/coverage-v8": "^4.1.3", + "jest-axe": "^10.0.0", "jsdom": "^24.0.0", "typescript": "^5.4.5", - "vite": "^7.1.3", - "vitest": "^4.1.2" + "vite": "^7.3.2", + "vitest": "^4.1.3" } } diff --git a/ui/src/App.css b/ui/src/App.css index b79ebbd..d60f192 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -242,12 +242,40 @@ body { flex-shrink: 0; } -.file-tree-node span { +.file-tree-node-label { + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + overflow: hidden; +} + +.file-tree-node-name { overflow: hidden; text-overflow: ellipsis; font-size: 13px; } +.local-only-badge { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + padding: 4px 9px; + border-radius: 999px; + border: 1px solid rgba(191, 135, 0, 0.28); + background: rgba(255, 228, 156, 0.28); + color: #9a6700; + font-size: 11px; + font-weight: 600; + white-space: nowrap; +} + +.local-only-badge-compact { + padding: 2px 8px; + font-size: 10px; +} + .file-tree-children { padding-left: 16px; } @@ -735,6 +763,22 @@ body { color: var(--color-text); } +.content-active-context { + display: flex; + flex-direction: column; + gap: 6px; + max-width: 1040px; + width: 100%; + margin: 0 auto 16px; +} + +.content-active-heading-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + .content-directory-list { display: flex; flex-direction: column; @@ -799,6 +843,13 @@ body { color: var(--color-text); } +.content-directory-name-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + .content-directory-path { font-size: 12px; color: var(--color-text-muted); @@ -1386,6 +1437,13 @@ body { font-size: 13px; } +.search-result-meta { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + .search-empty { padding: 16px 2px 4px; } diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx index ae4d51c..3a84de8 100644 --- a/ui/src/App.test.tsx +++ b/ui/src/App.test.tsx @@ -220,7 +220,7 @@ describe('App', () => { branch: 'main', mode: 'name', caseSensitive: false, - results: [{ path: 'README.md', type: 'file', matchType: 'name' }], + results: [{ path: 'README.md', type: 'file', matchType: 'name', localOnly: false }], }) renderWithClient() @@ -236,11 +236,125 @@ describe('App', () => { }) }) + it('opens folder quick-finder results as folders instead of loading them as files', async () => { + vi.mocked(api.getSearchResults).mockResolvedValue({ + query: 'doc', + branch: 'main', + mode: 'name', + caseSensitive: false, + results: [{ path: 'docs', type: 'dir', matchType: 'name', localOnly: true }], + }) + vi.mocked(api.getTree).mockImplementation(async (path?: string) => ( + path === 'docs' + ? [{ name: 'guide.md', path: 'docs/guide.md', type: 'file', localOnly: false }] + : [] + )) + + renderWithClient() + + const searchInput = await screen.findByRole('searchbox', { name: /search query/i }) + fireEvent.change(searchInput, { target: { value: 'doc' } }) + + fireEvent.click(await screen.findByRole('button', { name: /docs/i })) + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'docs' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /open file guide\.md/i })).toBeInTheDocument() + }) + + expect(api.getFile).not.toHaveBeenCalledWith('docs', 'main', false) + }) + + it('opens ignored folders from the repository tree', async () => { + window.history.replaceState(null, '', '/?branch=main') + vi.mocked(api.getReadme).mockResolvedValueOnce({ path: '' }) + vi.mocked(api.getInfo).mockResolvedValueOnce({ + name: 'repo', + path: '/tmp/repo', + currentBranch: 'main', + isGitRepo: true, + pickerMode: false, + version: APP_VERSION.version, + hasCommits: true, + rootEntryCount: 1, + }) + vi.mocked(api.getTree).mockImplementation(async (path?: string) => ( + path === '.cache' + ? [{ name: 'index.db', path: '.cache/index.db', type: 'file', localOnly: true }] + : [{ name: '.cache', path: '.cache', type: 'dir', localOnly: true }] + )) + + renderWithClient() + + fireEvent.click(await screen.findByRole('treeitem', { name: /\.cache/i })) + + await waitFor(() => { + expect(screen.getByRole('heading', { name: '.cache' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /open file index\.db/i })).toBeInTheDocument() + }) + }) + + it('opens ignored files from the folder list', async () => { + window.history.replaceState(null, '', '/?branch=main&path=.cache&pathType=dir') + vi.mocked(api.getTree).mockImplementation(async (path?: string) => ( + path === '.cache' + ? [{ name: 'index.db', path: '.cache/index.db', type: 'file', localOnly: true }] + : [] + )) + vi.mocked(api.getFile).mockResolvedValueOnce({ + path: '.cache/index.db', + type: 'text', + content: 'cache entry', + language: '', + encoding: 'utf-8', + editable: true, + revisionToken: 'rev-cache', + }) + + renderWithClient() + + fireEvent.click(await screen.findByRole('button', { name: /open file index\.db/i })) + + await waitFor(() => { + expect(api.getFile).toHaveBeenLastCalledWith('.cache/index.db', 'main', false) + }) + }) + + it('preserves the local-only cue after opening ignored items', async () => { + vi.mocked(api.getSearchResults).mockResolvedValue({ + query: 'env', + branch: 'main', + mode: 'name', + caseSensitive: false, + results: [{ path: '.env', type: 'file', matchType: 'name', localOnly: true }], + }) + vi.mocked(api.getFile).mockResolvedValueOnce({ + path: '.env', + type: 'text', + content: 'SECRET=value', + language: '', + encoding: 'utf-8', + editable: true, + revisionToken: 'rev-env', + }) + + renderWithClient() + + const searchInput = await screen.findByRole('searchbox', { name: /search query/i }) + fireEvent.change(searchInput, { target: { value: 'env' } }) + fireEvent.click(await screen.findByRole('button', { name: /\.env/i })) + + await waitFor(() => { + expect(screen.getByRole('heading', { name: '.env' })).toBeInTheDocument() + }) + expect(screen.getByText(/local only/i)).toBeInTheDocument() + }) + it('hydrates a saved folder selection without trying to load it as a file', async () => { window.history.replaceState(null, '', '/?branch=main&path=docs&pathType=dir') vi.mocked(api.getTree).mockImplementation(async (path?: string) => ( path === 'docs' - ? [{ name: 'guide.md', path: 'docs/guide.md', type: 'file' }] + ? [{ name: 'guide.md', path: 'docs/guide.md', type: 'file', localOnly: false }] : [] )) @@ -369,8 +483,8 @@ describe('App', () => { path ? [] : [ - { name: 'README.md', path: 'README.md', type: 'file' }, - { name: 'docs', path: 'docs', type: 'dir' }, + { name: 'README.md', path: 'README.md', type: 'file', localOnly: false }, + { name: 'docs', path: 'docs', type: 'dir', localOnly: false }, ] )) @@ -403,6 +517,37 @@ describe('App', () => { expect(screen.getByText(/newly initialized or empty/i)).toBeInTheDocument() }) + it('shows ignored-only root content instead of the empty-repository landing state', async () => { + window.history.replaceState(null, '', '/') + vi.mocked(api.getInfo).mockResolvedValue({ + name: 'repo', + path: '/tmp/repo', + currentBranch: 'main', + isGitRepo: true, + pickerMode: false, + version: APP_VERSION.version, + hasCommits: true, + rootEntryCount: 2, + }) + vi.mocked(api.getReadme).mockResolvedValue({ path: '' }) + vi.mocked(api.getTree).mockImplementation(async (path?: string) => ( + path + ? [] + : [ + { name: '.cache', path: '.cache', type: 'dir', localOnly: true }, + { name: '.env', path: '.env', type: 'file', localOnly: true }, + ] + )) + + renderWithClient() + + await waitFor(() => { + expect(screen.getByRole('button', { name: /open folder \.cache/i })).toBeInTheDocument() + }, { timeout: 3000 }) + expect(screen.getByRole('button', { name: /open file \.env/i })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /create first file/i })).not.toBeInTheDocument() + }) + it('opens a working-tree README by default before the first commit exists', async () => { window.history.replaceState(null, '', '/') vi.mocked(api.getInfo).mockResolvedValueOnce({ @@ -470,8 +615,8 @@ describe('App', () => { path ? [] : [ - { name: 'src', path: 'src', type: 'dir' }, - { name: 'main.ts', path: 'main.ts', type: 'file' }, + { name: 'src', path: 'src', type: 'dir', localOnly: false }, + { name: 'main.ts', path: 'main.ts', type: 'file', localOnly: false }, ] )) @@ -481,4 +626,29 @@ describe('App', () => { expect(screen.getByRole('button', { name: /open folder src/i })).toBeInTheDocument() expect(screen.getByRole('button', { name: /open file main\.ts/i })).toBeInTheDocument() }) + + it('falls back to a neutral empty state on a non-current branch when only ignored working-tree content exists', async () => { + window.history.replaceState(null, '', '/?branch=feature') + vi.mocked(api.getInfo).mockResolvedValue({ + name: 'repo', + path: '/tmp/repo', + currentBranch: 'main', + isGitRepo: true, + pickerMode: false, + version: APP_VERSION.version, + hasCommits: true, + rootEntryCount: 2, + }) + vi.mocked(api.getBranches).mockResolvedValue([ + { name: 'main', isCurrent: true }, + { name: 'feature', isCurrent: false }, + ]) + vi.mocked(api.getReadme).mockResolvedValue({ path: '' }) + vi.mocked(api.getTree).mockResolvedValue([]) + + renderWithClient() + + expect(await screen.findByText(/does not have any visible files or folders yet/i)).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: /no readme yet/i })).not.toBeInTheDocument() + }) }) diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 8e65230..a04f34f 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -31,6 +31,7 @@ export default function App() { const [viewerRepoPath, setViewerRepoPath] = useState(initialViewerState.repoPath) const [selectedPath, setSelectedPath] = useState(initialViewerState.path) const [selectedPathType, setSelectedPathType] = useState(initialViewerState.pathType) + const [selectedPathLocalOnly, setSelectedPathLocalOnly] = useState(false) const [currentBranch, setCurrentBranch] = useState(initialViewerState.branch) const [showRaw, setShowRaw] = useState(initialViewerState.raw) const [sidebarCollapsed, setSidebarCollapsed] = useState(initialViewerState.sidebarCollapsed) @@ -111,6 +112,7 @@ export default function App() { setViewerRepoPath(info.path) setSelectedPath('') setSelectedPathType('none') + setSelectedPathLocalOnly(false) setShowRaw(false) setSearchPresentation('collapsed') setSearchQuery('') @@ -158,6 +160,11 @@ export default function App() { } }, [searchPresentation, searchQuery]) + useEffect(() => { + if (!info?.currentBranch || currentBranch === info.currentBranch) return + setSelectedPathLocalOnly(false) + }, [currentBranch, info?.currentBranch]) + useEffect(() => { const handler = (event: KeyboardEvent) => { const key = event.key.toLowerCase() @@ -186,6 +193,7 @@ export default function App() { setStatusMessage(syncStatus.statusMessage) setSelectedPath(syncStatus.resolvedPath) setSelectedPathType(syncStatus.resolvedPathType === 'missing' ? 'none' : syncStatus.resolvedPathType) + setSelectedPathLocalOnly(false) setShowRaw(false) return } @@ -215,24 +223,30 @@ export default function App() { return window.confirm('Discard your unsaved file changes?') } - function handleSelectFile(path: string) { + function handleSelectFile(path: string, localOnly = false) { if (!confirmDiscardChanges()) return setSelectedPath(path) setSelectedPathType(path ? 'file' : 'none') + setSelectedPathLocalOnly(path ? localOnly : false) setStatusMessage('') setShowRaw(false) } - function handleSelectFolder(path: string) { + function handleSelectFolder(path: string, localOnly = false) { if (!confirmDiscardChanges()) return setSelectedPath(path) setSelectedPathType(path ? 'dir' : 'none') + setSelectedPathLocalOnly(path ? localOnly : false) setStatusMessage('') setShowRaw(false) } function handleSelectSearchResult(result: SearchResult) { - handleSelectFile(result.path) + if (result.type === 'dir') { + handleSelectFolder(result.path, result.localOnly) + } else { + handleSelectFile(result.path, result.localOnly) + } setSearchPresentation('collapsed') setSearchQuery('') } @@ -308,6 +322,7 @@ export default function App() { setHasUnsavedChanges(false) setSelectedPath(event.nextPath) setSelectedPathType(event.nextPathType) + setSelectedPathLocalOnly(false) setShowRaw(false) setStatusMessage(event.result.message) setTreeRefreshToken((value) => value + 1) @@ -317,13 +332,15 @@ export default function App() { const visibleSelectedPath = hasRepoMismatch ? '' : selectedPath const visibleSelectedPathType: ViewerPathType = hasRepoMismatch ? 'none' : selectedPathType + const visibleSelectedPathLocalOnly = hasRepoMismatch ? false : selectedPathLocalOnly const visibleShowRaw = hasRepoMismatch ? false : showRaw + const isWorkingTreeBranchSelected = !info?.currentBranch || currentBranch === info.currentBranch let emptyStateTitle: string | undefined let emptyStateDetail: string | undefined let emptyStateActions: LandingAction[] | undefined if (!visibleSelectedPath && !hasRepoMismatch) { - if (readmeMissing && info?.rootEntryCount === 0) { + if (readmeMissing && isWorkingTreeBranchSelected && info?.rootEntryCount === 0) { emptyStateTitle = 'This repository is ready for a first file' emptyStateDetail = 'This repository looks newly initialized or empty, so GitLocal is showing a guided landing state instead of an empty document view.' emptyStateActions = canMutateFiles @@ -332,7 +349,7 @@ export default function App() { { label: 'Browse parent folder', action: 'open-parent' }, ] : [{ label: 'Browse parent folder', action: 'open-parent' }] - } else if (readmeMissing) { + } else if (readmeMissing && isWorkingTreeBranchSelected) { emptyStateTitle = 'No README yet' emptyStateDetail = 'This repository has content, but there is no README to open by default. You can browse the repository tree, create a new file, or return to a parent folder.' emptyStateActions = canMutateFiles @@ -396,12 +413,13 @@ export default function App() { onSelect={( path, type, + localOnly, ) => { if (type === 'dir') { - handleSelectFolder(path) + handleSelectFolder(path, localOnly) return } - handleSelectFile(path) + handleSelectFile(path, localOnly) }} /> { + onOpenPath={(path, type, localOnly) => { if (type === 'dir') { - handleSelectFolder(path) + handleSelectFolder(path, localOnly) return } - handleSelectFile(path) + handleSelectFile(path, localOnly) }} + selectedPathLocalOnly={visibleSelectedPathLocalOnly} onDirtyChange={setHasUnsavedChanges} onMutationComplete={(event) => { void handleMutationComplete(event) }} - placeholder={readmeMissing && !visibleSelectedPath ? 'No README found in this repository.' : undefined} + placeholder={ + !visibleSelectedPath && readmeMissing + ? ( + isWorkingTreeBranchSelected + ? 'No README found in this repository.' + : 'This branch does not have any visible files or folders yet.' + ) + : undefined + } emptyStateTitle={emptyStateTitle} emptyStateDetail={emptyStateDetail} emptyStateActions={emptyStateActions} diff --git a/ui/src/components/ContentPanel/ContentPanel.test.tsx b/ui/src/components/ContentPanel/ContentPanel.test.tsx index e733b8c..bb135d8 100644 --- a/ui/src/components/ContentPanel/ContentPanel.test.tsx +++ b/ui/src/components/ContentPanel/ContentPanel.test.tsx @@ -148,9 +148,9 @@ describe('ContentPanel', () => { it('suggests myfile names when README.md already exists in the folder', async () => { vi.mocked(api.getTree).mockResolvedValue([ - { name: 'README.md', path: 'README.md', type: 'file' }, - { name: 'myfile.md', path: 'myfile.md', type: 'file' }, - { name: 'myfile 1.md', path: 'myfile 1.md', type: 'file' }, + { name: 'README.md', path: 'README.md', type: 'file', localOnly: false }, + { name: 'myfile.md', path: 'myfile.md', type: 'file', localOnly: false }, + { name: 'myfile 1.md', path: 'myfile 1.md', type: 'file', localOnly: false }, ]) renderWithClient( @@ -178,7 +178,7 @@ describe('ContentPanel', () => { message: 'File created successfully.', }) vi.mocked(api.getTree).mockResolvedValue([ - { name: 'guide.md', path: 'docs/guide.md', type: 'file' }, + { name: 'guide.md', path: 'docs/guide.md', type: 'file', localOnly: false }, ]) renderWithClient( @@ -198,9 +198,81 @@ describe('ContentPanel', () => { expect(screen.getByLabelText(/new file path/i)).toHaveValue('docs/README.md') }) + it('shows ignored files and folders in directory listings and opens them normally', async () => { + vi.mocked(api.getTree).mockResolvedValue([ + { name: '.cache', path: 'docs/.cache', type: 'dir', localOnly: true }, + { name: '.env', path: 'docs/.env', type: 'file', localOnly: true }, + ]) + + const onOpenPath = vi.fn() + + renderWithClient( + , + ) + + fireEvent.click(await screen.findByRole('button', { name: /open folder \.cache/i })) + fireEvent.click(screen.getByRole('button', { name: /open file \.env/i })) + + expect(onOpenPath).toHaveBeenNthCalledWith(1, 'docs/.cache', 'dir', true) + expect(onOpenPath).toHaveBeenNthCalledWith(2, 'docs/.env', 'file', true) + }) + + it('shows local-only cues in ignored directory rows and active folder context', async () => { + vi.mocked(api.getTree).mockResolvedValue([ + { name: '.cache', path: 'docs/.cache', type: 'dir', localOnly: true }, + { name: '.env', path: 'docs/.env', type: 'file', localOnly: true }, + ]) + + renderWithClient( + , + ) + + await waitFor(() => { + expect(screen.getAllByText(/local only/i)).toHaveLength(3) + }) + expect(screen.getByRole('heading', { name: 'docs' })).toBeInTheDocument() + }) + + it('shows a local-only cue in the active file context for ignored files', async () => { + vi.mocked(api.getFile).mockResolvedValue(makeTextFile({ path: '.env' })) + + renderWithClient( + , + ) + + expect(await screen.findByText(/local only/i)).toBeInTheDocument() + expect(screen.getByRole('heading', { name: '.env' })).toBeInTheDocument() + }) + it('cancels create mode from the draft form', async () => { vi.mocked(api.getTree).mockResolvedValue([ - { name: 'guide.md', path: 'docs/guide.md', type: 'file' }, + { name: 'guide.md', path: 'docs/guide.md', type: 'file', localOnly: false }, ]) renderWithClient( @@ -223,8 +295,8 @@ describe('ContentPanel', () => { it('opens directory entries with the row button and double click', async () => { vi.mocked(api.getTree).mockResolvedValue([ - { name: 'src', path: 'src', type: 'dir' }, - { name: 'main.ts', path: 'main.ts', type: 'file' }, + { name: 'src', path: 'src', type: 'dir', localOnly: false }, + { name: 'main.ts', path: 'main.ts', type: 'file', localOnly: false }, ]) const onOpenPath = vi.fn() @@ -244,8 +316,8 @@ describe('ContentPanel', () => { fireEvent.click(await screen.findByRole('button', { name: /open folder src/i })) fireEvent.doubleClick(screen.getByRole('button', { name: /open file main\.ts/i }).closest('.content-directory-row') as HTMLElement) - expect(onOpenPath).toHaveBeenNthCalledWith(1, 'src', 'dir') - expect(onOpenPath).toHaveBeenNthCalledWith(2, 'main.ts', 'file') + expect(onOpenPath).toHaveBeenNthCalledWith(1, 'src', 'dir', false) + expect(onOpenPath).toHaveBeenNthCalledWith(2, 'main.ts', 'file', false) }) it('shows an intentional empty-folder state for selected folders with no entries', async () => { @@ -266,6 +338,29 @@ describe('ContentPanel', () => { expect(await screen.findByText(/does not have any visible files or folders yet/i)).toBeInTheDocument() }) + it('shows ignored-only folder contents instead of the empty-folder state', async () => { + vi.mocked(api.getTree).mockResolvedValue([ + { name: '.cache', path: 'generated/.cache', type: 'dir', localOnly: true }, + { name: '.env', path: 'generated/.env', type: 'file', localOnly: true }, + ]) + + renderWithClient( + , + ) + + expect(await screen.findByRole('button', { name: /open folder \.cache/i })).toBeInTheDocument() + expect(screen.queryByText(/does not have any visible files or folders yet/i)).not.toBeInTheDocument() + }) + it('shows loading skeleton while fetching', async () => { vi.mocked(api.getFile).mockReturnValue(new Promise(() => {})) @@ -302,6 +397,25 @@ describe('ContentPanel', () => { expect(await screen.findByText(/failed to load file/i)).toBeInTheDocument() }) + it('shows an unavailable message when an ignored local file disappears', async () => { + vi.mocked(api.getFile).mockRejectedValue(new Error('boom')) + + renderWithClient( + , + ) + + expect(await screen.findByText(/local-only file is no longer available/i)).toBeInTheDocument() + }) + it('shows markdown renderer for markdown files', async () => { vi.mocked(api.getFile).mockResolvedValue( makeTextFile({ type: 'markdown', language: '', content: '# Hello' }), diff --git a/ui/src/components/ContentPanel/ContentPanel.tsx b/ui/src/components/ContentPanel/ContentPanel.tsx index a0847ba..914a568 100644 --- a/ui/src/components/ContentPanel/ContentPanel.tsx +++ b/ui/src/components/ContentPanel/ContentPanel.tsx @@ -23,9 +23,10 @@ interface Props { refreshToken: number selectedPath: string selectedPathType: ViewerPathType + selectedPathLocalOnly?: boolean branch: string onNavigate: (path: string) => void - onOpenPath: (path: string, type: 'file' | 'dir') => void + onOpenPath: (path: string, type: 'file' | 'dir', localOnly: boolean) => void onDirtyChange?: (value: boolean) => void onMutationComplete?: (event: FileMutationEvent) => void placeholder?: string @@ -80,6 +81,7 @@ export default function ContentPanel({ refreshToken, selectedPath, selectedPathType, + selectedPathLocalOnly = false, branch, onNavigate, onOpenPath, @@ -112,6 +114,7 @@ export default function ContentPanel({ const { data: directoryEntries, isLoading: isDirectoryLoading, + isError: isDirectoryError, } = useQuery({ queryKey: ['tree', directoryPath, branch, refreshToken, 'content-panel'], queryFn: () => api.getTree(directoryPath, branch), @@ -310,7 +313,10 @@ export default function ContentPanel({

{path ? 'Folder' : 'Current folder'}

-

{path || 'root'}

+
+

{path || 'root'}

+ {selectedPathLocalOnly && path ? Local only : null} +
{canMutateFiles ? (
- {readyForResults ? 'Matching file names' : 'Results appear after 3 characters'} + {readyForResults ? 'Matching files and folders' : 'Results appear after 3 characters'}
{!readyForResults ? ( -

Type 3 or more characters to see matching file names.

+

Type 3 or more characters to see matching files and folders.

) : isLoading ? (

Searching…

) : isError ? (

Search failed. Please try again.

) : ( - + )} ) diff --git a/ui/src/components/Search/SearchResults.tsx b/ui/src/components/Search/SearchResults.tsx index 563eb0d..e59f293 100644 --- a/ui/src/components/Search/SearchResults.tsx +++ b/ui/src/components/Search/SearchResults.tsx @@ -7,7 +7,7 @@ interface Props { export default function SearchResults({ results, onSelect }: Props) { if (results.length === 0) { - return

No file names matched the current search.

+ return

No files or folders matched the current search.

} return ( @@ -16,7 +16,10 @@ export default function SearchResults({ results, onSelect }: Props) {
  • ))} diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts index cfe8917..6baf72f 100644 --- a/ui/src/types/index.ts +++ b/ui/src/types/index.ts @@ -32,6 +32,7 @@ export interface TreeNode { name: string path: string type: 'file' | 'dir' + localOnly: boolean } export type FileEncoding = 'utf-8' | 'base64' | 'none' @@ -103,6 +104,7 @@ export interface SearchResult { matchType: 'name' | 'content' snippet?: string line?: number + localOnly: boolean } export interface SearchResponse {