diff --git a/.github/actions/npm-ci/action.yml b/.github/actions/npm-ci/action.yml deleted file mode 100644 index c4a04bf..0000000 --- a/.github/actions/npm-ci/action.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: npm ci -description: Node toolchain + npm cache restore + clean install -inputs: - node-version: - required: true -runs: - using: composite - steps: - - uses: actions/setup-node@v4 - with: - node-version: ${{ inputs.node-version }} - cache: npm - cache-dependency-path: package-lock.json - - run: npm ci - shell: bash diff --git a/.github/actions/pnpm-install/action.yml b/.github/actions/pnpm-install/action.yml new file mode 100644 index 0000000..c4cc655 --- /dev/null +++ b/.github/actions/pnpm-install/action.yml @@ -0,0 +1,16 @@ +name: pnpm install +description: Node toolchain + pnpm cache restore + clean install +inputs: + node-version: + required: true +runs: + using: composite + steps: + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node-version }} + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + - run: pnpm install --frozen-lockfile + shell: bash diff --git a/.github/ci-assist/sonar-project.properties b/.github/ci-assist/sonar-project.properties index 7046e26..63eab00 100644 --- a/.github/ci-assist/sonar-project.properties +++ b/.github/ci-assist/sonar-project.properties @@ -8,7 +8,10 @@ sonar.sources=crates,src-tauri/src,src sonar.tests=crates,src-tauri,src sonar.test.inclusions=**/tests/**,**/*.test.ts,**/*_test.rs -sonar.exclusions=**/target/**,**/node_modules/**,**/.svelte-kit/**,**/build/**,**/src-tauri/gen/**,**/src-tauri/icons/**,**/*.rs.bk,**/src/app.html +sonar.exclusions=**/target/**,**/node_modules/**,**/.svelte-kit/**,**/build/**,**/src-tauri/gen/**,**/src-tauri/icons/**,**/*.rs.bk,**/src/app.html,**/*.stories.svelte,**/src/lib/storybook/**,**/src/lib/components/repoStoryFixtures.ts,**/src/lib/tray/trayPanelStoryFixtures.ts + +# Non-testable frontend (types, constants, fixtures, route shells) — sync with scripts/coverage-exclusions.mjs +sonar.coverage.exclusions=**/src/lib/types.ts,**/src/lib/tray/constants.ts,**/src/lib/components/repoStoryFixtures.ts,**/src/lib/tray/trayPanelStoryFixtures.ts,**/src/lib/storybook/**,**/*.stories.svelte,**/src/lib/tray/trayTrace.ts,**/src/routes/+page.svelte,**/src/routes/+layout.svelte,**/src/routes/+layout.ts sonar.rust.lcov.reportPaths=lcov-rust.info sonar.javascript.lcov.reportPaths=lcov-js.info diff --git a/.github/rulesets/ci.json b/.github/rulesets/ci.json index e2c2ab0..d82af30 100644 --- a/.github/rulesets/ci.json +++ b/.github/rulesets/ci.json @@ -19,7 +19,6 @@ { "type": "pull_request", "parameters": { - "required_approving_review_count": 1, "dismiss_stale_reviews_on_push": true, "require_code_owner_review": true, "require_last_push_approval": false, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0c24cc..15d0943 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,21 +96,21 @@ jobs: key: macos-latest - name: Fetch dependencies (macOS D-12) run: cargo fetch - - name: Install npm dependencies - uses: ./.github/actions/npm-ci + - name: Install pnpm dependencies + uses: ./.github/actions/pnpm-install with: node-version: ${{ env.NODE_VERSION }} - name: Test run: cargo test -p workpot-core -p workpot-cli -p workpot-tray --all-targets - name: Frontend check and test coverage run: | - npm run format:check - npm run lint:check - npm run check - npm run test:coverage + pnpm run format:check + pnpm run lint:check + pnpm run check + pnpm run test:coverage cp coverage/lcov.info lcov-js.info - name: Tauri bundle smoke - run: npm run tauri:build + run: pnpm run tauri:build env: CI: true - name: Upload JS coverage @@ -186,17 +186,16 @@ jobs: lcov -a lcov-core-cli.info -a lcov-tray.info -o lcov-rust.info --ignore-errors empty test -f lcov-js.info - - name: Install npm dependencies - uses: ./.github/actions/npm-ci + - name: Install pnpm dependencies + uses: ./.github/actions/pnpm-install with: node-version: ${{ env.NODE_VERSION }} - - run: npx svelte-kit sync + - run: pnpm exec svelte-kit sync - name: SonarCloud Scan - uses: SonarSource/sonarcloud-github-action@v5 + uses: SonarSource/sonarqube-scan-action@v8 env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} with: args: > diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index fe48381..b545f1b 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -1,7 +1,7 @@ name: release-artifacts # Runs when release-publish (or a maintainer) publishes a GitHub Release — builds -# macOS tarballs and uploads them to that release. +# the macOS aarch64 combined app+CLI tarball and uploads it to that release. on: release: @@ -12,7 +12,7 @@ permissions: jobs: upload: - name: upload release binaries + name: upload release artifacts permissions: contents: write uses: ./.github/workflows/release.yml diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml index 49c8b01..381420b 100644 --- a/.github/workflows/release-smoke.yml +++ b/.github/workflows/release-smoke.yml @@ -1,6 +1,7 @@ name: release-smoke -# PR smoke: full macOS release matrix without creating tags or uploading to GitHub Releases. +# PR smoke: validate the release artifact contract without creating tags or +# uploading to GitHub Releases. on: pull_request: @@ -28,3 +29,33 @@ jobs: with: tag: v0.0.0-smoke dry_run: true + + verify-contract: + name: verify release contract + needs: smoke + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + pattern: smoke-* + merge-multiple: true + + - name: Validate aarch64-only artifact contract + run: | + set -euo pipefail + test -f artifacts/Workpot-0.0.0-smoke-aarch64.tar.gz + test -f artifacts/Workpot-0.0.0-smoke-aarch64.tar.gz.sha256 + + for artifact in artifacts/*; do + file="$(basename "$artifact")" + case "$file" in + Workpot-0.0.0-smoke-aarch64.tar.gz|\ + Workpot-0.0.0-smoke-aarch64.tar.gz.sha256) + ;; + *) + echo "unexpected artifact in smoke output: $file" >&2 + exit 1 + ;; + esac + done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fcd8c68..85598a6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,7 @@ name: release -# Builds macOS tarballs and uploads them to an existing GitHub Release. +# Builds macOS aarch64 combined Workpot.app+CLI tarball + checksum, +# then uploads to an existing GitHub Release and updates the Homebrew tap. # Invoked by release-artifacts.yml on release: published, release-smoke (dry_run), # or manually via workflow_dispatch. # release-publish.yml creates the GitHub Release and tag on master when version increases. @@ -110,21 +111,15 @@ jobs: fi echo "version=${tag_version}" >> "$GITHUB_OUTPUT" - binary: - name: macos ${{ matrix.arch }} binary + bundle: + name: macos aarch64 app bundle needs: [prepare, validate-version] if: always() && (needs.prepare.outputs.dry_run == 'true' || needs.validate-version.result == 'success') - runs-on: ${{ matrix.runner }} - strategy: - fail-fast: false - matrix: - include: - - arch: aarch64 - artifact: workpot-macos-aarch64 - runner: macos-latest - - arch: x86_64 - artifact: workpot-macos-x86_64 - runner: macos-15-intel + runs-on: macos-latest + env: + # Workspace Cargo target dir is repo-root `target/` (src-tauri is a workspace member). + MACOS_TARGET: aarch64-apple-darwin + BUNDLE_MACOS_DIR: target/aarch64-apple-darwin/release/bundle/macos steps: - uses: actions/checkout@v5 with: @@ -134,37 +129,58 @@ jobs: - uses: dtolnay/rust-toolchain@master with: toolchain: 1.96 + targets: aarch64-apple-darwin - uses: ./.github/actions/rust-cache with: - shared-key: release - key: ${{ matrix.arch }} + shared-key: release-bundle + key: aarch64 - - name: Build release binary - run: cargo build --release -p workpot-cli + - uses: ./.github/actions/pnpm-install + with: + node-version: 24 + + - name: Build release CLI binary + run: cargo build --release -p workpot-cli --target "${MACOS_TARGET}" + + - name: Build release app bundle + run: | + pnpm run build + pnpm exec tauri build --bundles app --config src-tauri/tauri.ci-build.json --target "${MACOS_TARGET}" + + - name: Verify app bundle path + run: test -d "${BUNDLE_MACOS_DIR}/Workpot.app" + + - name: Inject CLI binary into app bundle + run: cp "target/${MACOS_TARGET}/release/workpot" "${BUNDLE_MACOS_DIR}/Workpot.app/Contents/MacOS/workpot" + + - name: Verify both binaries present + run: | + test -f "${BUNDLE_MACOS_DIR}/Workpot.app/Contents/MacOS/workpot-tray" + test -f "${BUNDLE_MACOS_DIR}/Workpot.app/Contents/MacOS/workpot" - name: Create release tarball env: - RELEASE_ARTIFACT: ${{ matrix.artifact }} + RELEASE_TAG: ${{ env.RELEASE_TAG }} run: | - artifact="$RELEASE_ARTIFACT" - mkdir -p "dist/$artifact" - cp target/release/workpot "dist/$artifact/workpot" - cp README.md LICENSE "dist/$artifact/" - tar -C "dist/$artifact" -czf "$artifact.tar.gz" . - shasum -a 256 "$artifact.tar.gz" > "$artifact.tar.gz.sha256" + set -euo pipefail + version="${RELEASE_TAG#v}" + archive="Workpot-${version}-aarch64.tar.gz" + checksum="${archive}.sha256" + tar -C "${BUNDLE_MACOS_DIR}" -czf "$archive" Workpot.app + shasum -a 256 "$archive" > "$checksum" - uses: actions/upload-artifact@v4 with: - name: ${{ needs.prepare.outputs.dry_run == 'true' && format('smoke-{0}', matrix.artifact) || matrix.artifact }} + name: ${{ needs.prepare.outputs.dry_run == 'true' && 'smoke-workpot-bundle-aarch64' || 'workpot-bundle-aarch64' }} path: | - ${{ matrix.artifact }}.tar.gz - ${{ matrix.artifact }}.tar.gz.sha256 + Workpot-*-aarch64.tar.gz + Workpot-*-aarch64.tar.gz.sha256 retention-days: ${{ needs.prepare.outputs.dry_run == 'true' && 7 || 90 }} github-release: name: github release - needs: [prepare, binary, validate-version] + needs: [prepare, bundle, validate-version] if: needs.prepare.outputs.dry_run != 'true' runs-on: ubuntu-latest permissions: @@ -173,6 +189,7 @@ jobs: - uses: actions/download-artifact@v4 with: path: artifacts + pattern: workpot-bundle-* merge-multiple: true - name: Upload macOS artifacts to GitHub Release @@ -183,3 +200,48 @@ jobs: set -euo pipefail gh release upload "$RELEASE_TAG" artifacts/* --clobber echo "Uploaded artifacts to release ${RELEASE_TAG}" + + tap-update: + name: update homebrew tap + needs: [prepare, github-release, validate-version] + if: needs.prepare.outputs.dry_run != 'true' + runs-on: ubuntu-latest + steps: + - name: Compute artifact SHA256 from published release + env: + RELEASE_TAG: ${{ env.RELEASE_TAG }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + version="${RELEASE_TAG#v}" + archive="Workpot-${version}-aarch64.tar.gz" + gh release download "${RELEASE_TAG}" --pattern "${archive}.sha256" --repo rubenlr/workpot + sha256="$(awk '{print $1}' "${archive}.sha256")" + echo "SHA256=${sha256}" >> "$GITHUB_ENV" + echo "VERSION=${version}" >> "$GITHUB_ENV" + + - uses: actions/checkout@v5 + with: + repository: rubenlr/homebrew-workpot + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + path: homebrew-workpot + + - name: Patch cask version and sha256 + working-directory: homebrew-workpot + run: | + set -euo pipefail + sed -i "s/version \".*\"/version \"${VERSION}\"/" Casks/workpot.rb + sed -i "s/sha256 \".*\"/sha256 \"${SHA256}\"/" Casks/workpot.rb + grep -q "version \"${VERSION}\"" Casks/workpot.rb + grep -q "sha256 \"${SHA256}\"" Casks/workpot.rb + + - name: Commit and push tap update + working-directory: homebrew-workpot + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git pull --rebase origin HEAD + git add Casks/workpot.rb + git commit -m "chore: bump workpot to v${VERSION}" + git push diff --git a/.gitignore b/.gitignore index 9c8f57e..b97c803 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,6 @@ # Tauri CLI generated (regenerated on build; $schema in capabilities/ is IDE-only) src-tauri/gen/ coverage/ -lcov*.info \ No newline at end of file +lcov*.info +*storybook.log +storybook-static diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md index fcc4297..f8dbd8c 100644 --- a/.planning/PROJECT.md +++ b/.planning/PROJECT.md @@ -14,6 +14,7 @@ You always know which repo you need and can open it in Cursor in seconds, with g ### Validated +- CLI parity: `workpot list`, `workpot search`, `workpot open` with shared core (Phase 6, 2026-05-31) - Tag, pin, notes, and four-tier tray ordering (Phase 5, 2026-05-31) - Tray finder MVP with Cursor launch (Phase 4) - Git state refresh and display (Phase 3) @@ -22,7 +23,6 @@ You always know which repo you need and can open it in Cursor in seconds, with g ### Active -- [ ] CLI for power users (search, open, index refresh, recipe trigger) - [ ] Recipes: reusable action bundles (shell commands, Cursor launch, multi-step workflows) ### Out of Scope @@ -81,4 +81,4 @@ This document evolves at phase transitions and milestone boundaries. 4. Update Context with current state --- -*Last updated: 2026-05-31 after Phase 5 completion* +*Last updated: 2026-05-31 after Phase 6 completion* diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index d23661d..37a289c 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -51,9 +51,9 @@ ### CLI (CLI) -- [ ] **CLI-01**: User can list indexed repositories from the terminal -- [ ] **CLI-02**: User can search and open repositories from the terminal -- [ ] **CLI-03**: CLI and tray show consistent repository data and ordering +- [x] **CLI-01**: User can list indexed repositories from the terminal +- [x] **CLI-02**: User can search and open repositories from the terminal +- [x] **CLI-03**: CLI and tray show consistent repository data and ordering ### Data & Privacy (DATA) @@ -98,11 +98,12 @@ | ORG-01..04 | Phase 5 | Pending | | UI-01..04 | Phase 4 | Pending | | LAUNCH-01 | Phase 4 | Pending | -| LAUNCH-02..06 | Phase 7 | Pending | +| LAUNCH-02..06 | Backlog 999.1 (Recipes) | Deferred | | CLI-01..03 | Phase 6 | Pending | | DATA-01..02 | Phase 1 | Pending | **Coverage:** + - v1 requirements: 28 total - Mapped to phases: 28 - Unmapped: 0 diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index a5c5d0d..d0b0986 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -1,7 +1,7 @@ # Roadmap: Workpot **Project:** Workpot -**Phases:** 7 +**Phases:** 6 + 06.1 + 06.2 + 7 (active); 1 backlog **Requirements mapped:** 28/28 v1 **Structure:** Vertical MVP (each phase ships usable capability) @@ -16,8 +16,10 @@ | 3 | Git state | 4/4 | Complete (UAT 2026-05-30) | | 4 | 4/4 | Complete | | 5 | Tags & prioritization | 4/4 | In progress (05-09 code done; human re-UAT) | -| 6 | CLI parity | Terminal workflow matches tray | CLI-01..03 | 3 | -| 7 | Recipes | Reusable multi-step action bundles | LAUNCH-02..06 | 4 | +| 6 | CLI parity | 5/5 | Complete | 2026-05-31 | +| 06.1 | Release & distribution *(INSERTED)* | 3/3 | Complete | 2026-05-31 | +| 06.2 | Tray UX polish *(INSERTED)* | 9/9 | Complete | 2026-05-31 | +| 7 | Distribution strategy review | 4/4 | Complete | 2026-06-04 | --- @@ -220,25 +222,100 @@ Plans: 2. `workpot search ` returns the same results as tray filter 3. `workpot open ` opens Cursor for the matched repo -**Plans:** 5 plans in 3 waves +**Plans:** 5/5 plans complete **Wave 1** *(parallel — no shared files)* -- [ ] 06-01-PLAN.md — Core `repo_priority`: section sort + flat tray order (CLI-01, CLI-03) -- [ ] 06-02-PLAN.md — Core `repo_fuzzy`: port tray fuzzy matcher (CLI-02, CLI-03) +- [x] 06-01-PLAN.md — Core `repo_priority`: section sort + flat tray order (CLI-01, CLI-03) +- [x] 06-02-PLAN.md — Core `repo_fuzzy`: port tray fuzzy matcher (CLI-02, CLI-03) **Wave 2** *(parallel — depends on 06-01)* -- [ ] 06-03-PLAN.md — `workpot list` + emoji row formatter (CLI-01, CLI-03) -- [ ] 06-05-PLAN.md — Move `launch` to core + `workpot open` (CLI-02, LAUNCH-01) +- [x] 06-03-PLAN.md — `workpot list` + emoji row formatter (CLI-01, CLI-03) +- [x] 06-05-PLAN.md — Move `launch` to core + `workpot open` (CLI-02, LAUNCH-01) **Wave 3** -- [ ] 06-04-PLAN.md — `workpot search ` (CLI-02, CLI-03; depends 06-01, 06-02, 06-03) +- [x] 06-04-PLAN.md — `workpot search ` (CLI-02, CLI-03; depends 06-01, 06-02, 06-03) --- -### Phase 7: Recipes +### Phase 06.1: Release & distribution (INSERTED) + +**Goal:** Ship a complete macOS release path — GitHub artifacts, one-line install, self-update, and tray `.dmg` — so users never hand-place binaries. + +**Mode:** mvp + +**Depends on:** Phase 6 (CLI parity complete) + +**Requirements:** Tooling (no new v1 requirement IDs; extends release/docs surface) + +**Success Criteria:** + +1. Every `v*` GitHub Release publishes `workpot-macos-aarch64.tar.gz` + `.sha256` and `Workpot--aarch64.dmg` + `.sha256` (signed/notarized when Apple secrets are present) +2. User can run `curl -fsSL …/install.sh | bash` (or documented equivalent) on macOS and get `workpot` on `PATH` with correct `--version` +3. `workpot update` upgrades the installed CLI from the latest GitHub Release with clear failure modes (offline, permission denied, already current) +4. `INSTALL.md` gives equal prominence to script and DMG install paths, and documents update + uninstall/PATH without reading `docs/releasing.md` +5. Maintainer flow in `docs/releasing.md` references DMG + installer; CI smoke covers new artifacts where feasible + +**Plans:** 3/3 plans complete + +Plans: +- [x] 06.1-01-PLAN.md — Lock release artifact/signing contract (aarch64-only + DMG + smoke/docs) +- [x] 06.1-02-PLAN.md — TDD `workpot update` with strict exit/error/checksum semantics +- [x] 06.1-03-PLAN.md — Implement `install.sh` + installer smoke + user install docs + +--- + +### Phase 06.2: Tray UX polish (INSERTED) + +**Goal:** Tray feels like a daily driver — correct open/detail gestures, honest menu-bar signal for forgotten WIP, clean panel chrome, aliases, and predictable tag/notes inputs. + +**Mode:** mvp + +**Depends on:** Phases 4–6 (tray MVP, org fields, CLI parity for alias display/search) + +**Parallel with:** Phase 06.1 (release) — neither blocks the other + +**Requirements:** UX polish (no new v1 requirement IDs; extends tray/org surface) + +**Success Criteria:** + +1. Plain click on a list row opens Cursor and closes the panel; ⌘+click and row info badge open detail without launching +2. Menu-bar icon is default unless a repo is dirty and `last_opened_at` is older than `stale_dirty_days`; stale-dirty icon when triggered; animated icon during background refresh +3. `stale_dirty_days` is configurable in `config.toml` (independent of `max_recent_days`) +4. Optional per-repo `alias` persists; tray and CLI show alias when set; fuzzy matches alias and folder name +5. Panel shell is borderless with transparent background and curved bottom; bare repos omit branch when none +6. Detail header: back + title (alias), pin as 📌/📍 on the right; tag field suggests existing tags only; notes field has no OS autocomplete/spellcheck +7. Storybook documents list-row and detail-header visual states (same milestone; not a merge gate for interaction fixes) +8. Automated tests cover stale-dirty tray logic and alias in core/CLI fuzzy where applicable + +**Plans:** 9/9 plans complete + +Plans: +**Wave 1** *(parallel — no shared files)* + +- [x] 06.2-01-PLAN.md — Alias schema + core DTO propagation (migration 007, RepoRecord.alias, catalog list_repos, org::set_alias, RepoDto.alias, TrayConfigDto.stale_dirty_days) +- [x] 06.2-02-PLAN.md — TDD: stale-dirty detection logic (Config.stale_dirty_days + validation, has_stale_dirty fn with fallback for never-opened) +- [x] 06.2-03-PLAN.md — TDD: fuzzy dual-field match with alias (alias_score with name_bonus=true) + +**Wave 2** *(parallel — depends on Wave 1)* + +- [x] 06.2-04-PLAN.md — Tray interaction model + icon state machine (click handlers, bare-branch, info badge, stale-dirty/syncing icons) +- [x] 06.2-05-PLAN.md — CLI alias display parity (format_list_row alias-first + branch omission, search/open display) + +**Wave 3** *(parallel — depends on Wave 2; requires Plan 04 alias field)* + +- [x] 06.2-06-PLAN.md — Visual polish + input hardening (panel chrome CSS, detail header pin toggle, tag/notes attributes) +- [x] 06.2-08-PLAN.md — Interaction test stub / RED gate (RepoListRow.test.ts — sampling continuity before Plans 05/06 complete) + +**Wave 4** *(depends on Wave 3)* + +- [x] 06.2-07-PLAN.md — Storybook scaffold + RepoListRow component + stories (non-gating; human checkpoint for package installs) +- [x] 06.2-09-PLAN.md — Integration + E2E tests, GREEN phase (CLI alias/bare-branch, row interaction Vitest, has_stale_dirty_dto bridge) +## Backlog + +### Phase 999.1: Recipes (BACKLOG) **Goal:** One-action workflows — open, pull, test, or custom shell chains. @@ -254,7 +331,12 @@ Plans: 4. Multi-step recipes run in order and stop on first failure with visible error 5. User can invoke a recipe from CLI and tray -**Plans:** TBD via `/gsd-plan-phase 7` +**Deferred from:** Phase 7 (2026-05-31) — ship 06.1 release/distribution before recipes + +**Plans:** 0 plans + +Plans: +- [ ] TBD (promote with `/gsd-review-backlog` when ready) --- @@ -268,7 +350,43 @@ Plans: | 4 | Not started | 0/0 | | 5 | Not started | 0/0 | | 6 | Not started | 0/0 | -| 7 | Not started | 0/0 | +| 06.1 | Not started | 0/0 | +| 06.2 | Complete | 9/9 | +| 7 | Complete | 4/4 | + +### Phase 7: Review distribution strategy (Homebrew tap + cask) + +**Goal:** Pivot v1 distribution away from signed DMG / split install paths to a Homebrew tap with cask that ships **one package** — CLI (`workpot`) and tray app together — so `brew install` and `brew uninstall` add or remove both surfaces atomically. + +**Mode:** mvp + +**Depends on:** Phase 06.1 (existing tarball/DMG/install.sh release path — review, deprecate, or migrate docs and CI) + +**Requirements:** Tooling / release (extends 06.1; decision-driven — D-01 through D-15 from CONTEXT.md) + +**Success Criteria:** + +1. Distribution strategy doc records decision: **no signed/notarized DMG** for v1; primary path is **brew tap + cask** +2. Single Homebrew cask installs CLI binary on `PATH` and tray `.app` in one `brew install` +3. `brew uninstall` removes CLI and tray without orphaning either surface +4. `INSTALL.md` describes Homebrew-only flow; DMG/install.sh paths removed +5. CI/release workflow publishes `Workpot--aarch64.tar.gz` (app+CLI) without Apple signing secrets; tap auto-updated on each release + +**Plans:** 4/4 plans complete + +**Wave 1** *(parallel — no shared files)* + +Plans: +- [x] 07-01-PLAN.md — Remove update subcommand + update-only deps; remove dmg from tauri.conf.json (D-12, D-14) +- [x] 07-02-PLAN.md — Rewrite release.yml: new combined tarball job, remove dmg job, add tap-update job; update release-smoke.yml contract (D-02, D-03, D-07, D-08, D-09, D-10, D-13) + +**Wave 2** *(depends on Wave 1)* + +- [x] 07-03-PLAN.md — Delete install.sh + smoke, rewrite INSTALL.md Homebrew-only, update docs/releasing.md, create docs/distribution-strategy.md (D-04, D-11, D-15) + +**Wave 3** *(depends on Wave 2; has human checkpoint)* + +- [x] 07-04-PLAN.md — Draft Casks/workpot.rb; human creates homebrew-workpot repo + PAT + HOMEBREW_TAP_TOKEN secret (D-01, D-03, D-05, D-06, D-09, D-10) --- *Roadmap created: 2026-05-28* diff --git a/.planning/STATE.md b/.planning/STATE.md index 8c400d0..e32ea8d 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,14 +2,15 @@ gsd_state_version: 1.0 milestone: v1.0 milestone_name: milestone -status: Phase 05 shipped — PR #4 -last_updated: "2026-05-31T14:00:00Z" +status: Phase 07 shipped — PR #7 +last_updated: 2026-06-04 progress: - total_phases: 7 - completed_phases: 5 - total_plans: 24 - completed_plans: 24 - percent: 71 + total_phases: 10 + completed_phases: 1 + total_plans: 4 + completed_plans: 46 + percent: 10 +stopped_at: Phase 07 shipped — PR https://github.com/rubenlr/workpot/pull/7 --- # Project State @@ -20,7 +21,7 @@ See: `.planning/PROJECT.md` (updated 2026-05-28) **Core value:** Know which repo you need and open it in Cursor in seconds, with git context visible first. -**Current focus:** Phase 06 — CLI parity (PR #4 in review) +**Current focus:** Phase 999.1 — recipes ## Phase Status @@ -31,11 +32,25 @@ See: `.planning/PROJECT.md` (updated 2026-05-28) | 3 | Git state | Complete — 3/3 plans, UAT 10/10 (2026-05-30) | | 4 | Tray finder MVP | Complete — 4/4 plans (2026-05-30) | | 5 | Tags & prioritization | Shipped — PR https://github.com/rubenlr/workpot/pull/4 (2026-05-31) | -| 6 | CLI parity | Not started | -| 7 | Recipes | Not started | +| 6 | CLI parity | Complete — 5/5 plans, UAT 5/6 auto (2026-05-31) | +| 06.1 | Release & distribution | Not started — inserted 2026-05-31 | +| 06.2 | Tray UX polish | Complete — 9/9 plans (2026-05-31) | +| 7 | Distribution strategy review | Shipped — PR https://github.com/rubenlr/workpot/pull/7 (2026-06-04) | ## Session Notes +- Phase 7 shipped (2026-06-04): PR https://github.com/rubenlr/workpot/pull/7 — branch `feat/06-cli-parity` → `master` (132 commits; includes phases 6, 6.2, 7) +- Phase 7 UAT auto (2026-06-04): `distribution_contract` 11/11; `brew tap`, `brew info`, `gh` tap repo + HOMEBREW_TAP_TOKEN; `cargo build -p workpot-cli`; 07-UAT.md 12 pass / 1 skip (brew install post-release) +- Phase 06.2 plan 06.2-09 (2026-05-31): CLI alias/bare smoke, stale_dirty_dto bridge, RepoListRow Vitest GREEN; commits `14cf721`, `e93e9cd` +- Phase 06.2 plan 06.2-07 (2026-05-31): Storybook + RepoListRow extraction, 10 visual stories; commits `183b2a1`, `1c54e21` +- Phase 06.2 plan 06.2-06 (2026-05-31): panel-shell CSS, detail pin toggle, input hardening; commits `dfb10cf`, `2f5b526` +- Phase 06.2 plan 06.2-08 (2026-05-31): RepoListRow interaction test RED gate; commits `c052c2d`, `378809a` +- Phase 06.2 plan 06.2-04 (2026-05-31): tray row click model, alias display, tri-state icon; commits `9082057`, `da98d25`, docs `90a73fa` +- Phase 06.2 plan 06.2-05 (2026-05-31): CLI alias-first list_display, bare branch omission, open-by-alias; commits `61a3858`, `85607f1` +- Phase 06.2 plan 06.2-03 (2026-05-31): alias_score in fuzzy_score; TDD RED `d27d3b4`, GREEN `dff06c0` +- Phase 06.2 plan 06.2-02 (2026-05-31): has_stale_dirty + 16-case tests; TDD RED `9252f6d`, GREEN `6e1aefc` +- Phase 06.2 plan 06.2-01 (2026-05-31): migration 007 alias, org::set_alias, RepoDto.alias, TrayConfigDto.stale_dirty_days; commits `5767d80`, `a64345a`, `49ff05f`, `61bf197` +- Phase 6 UAT auto (2026-05-31): `cargo test -p workpot-core -p workpot-cli` green; list/search/open CLI smoke verified - Phase 5 shipped (2026-05-31): PR https://github.com/rubenlr/workpot/pull/4 - Phase 5 gap 05-09 (2026-05-31): tag blur-save, duplicate feedback, allTags refresh; commits `dbacbbb`, `e359e42`, `a01eb99` - Phase 5 plan 05-08 (2026-05-31): `allow-org-commands` — commits `1070e7a`, `ffd36e4` @@ -56,13 +71,30 @@ See: `.planning/PROJECT.md` (updated 2026-05-28) - Git refresh: rayon batch outside DB tx; `open_and_query` expects pre-canonicalized path (03-02/03) - Migration 005 for `last_opened_at` (04-01) - Tauri org IPC requires explicit `allow-org-commands` capability (05-08; same class as 04 `get_tray_config`) +- [Phase ?]: Updater failures map to Network=2 and Install=1 for deterministic CLI exits. +- [Phase ?]: Checksum verification is enforced before any CLI or tray replacement step. +- [Phase 06.1]: Installer now enforces checksum-first staging for CLI tarball and DMG before install writes. +- [Phase 06.1]: Installer smoke tests use local fixture metadata/assets for deterministic default, flag, global, and checksum-failure verification. +- [Phase 06.2-01]: Alias is user-only (scan upsert does not touch alias); Config.stale_dirty_days default 7 added with plan 01 for TrayConfigDto IPC. +- [Phase 06.2-02]: has_stale_dirty uses injectable now_secs; never-opened dirty repos use i64::MAX age (immediate stale). +- [Phase 06.2-05]: CLI format_list_row matches tray: alias ?? name, omit branch when None; open resolves exact alias before folder name. +- [Phase 06.2-04]: Row is div[role=option] with nested info button; tray icon uses has_stale_dirty_dto + syncing override (not any_dirty). +- [Phase 06.2-06]: Detail pin is emoji button with aria-pressed; panel-shell border none + 12px bottom radius; tag/notes autocorrect off. +- [Phase 06.2-07]: Storybook via @storybook/sveltekit; RepoListRow callback props; Tauri mocked in Storybook viteFinal alias. +- [Phase 06.2-09]: CLI smoke uses org::set_alias; dto_equivalent bridge test locks tray icon policy to core has_stale_dirty. ## Accumulated Context +### Roadmap Evolution + +- Phase 06.1 inserted after Phase 6: Release distribution and install: GitHub tarballs, install.sh, DMG, workpot update, INSTALL.md (URGENT) +- Phase 06.2 inserted after Phase 6: Tray UX polish — icons, panel chrome, alias, list/detail interaction, Storybook (2026-05-31 explore) +- Phase 7 (Recipes) deferred to backlog as 999.1 (2026-05-31) — prioritize 06.1 release path first +- Phase 7 added (2026-06-03): Review distribution strategy — Homebrew tap + cask, no signed DMG; single package for CLI + tray; brew install/uninstall together + ### Pending Todos -1. **Add shell installer with update subcommand** — [todo](todos/pending/2026-05-31-add-shell-installer-with-update-subcommand.md) -2. **Add macOS DMG distribution at MVP** — [todo](todos/pending/2026-05-31-add-macos-dmg-distribution-at-mvp.md) +None — install/update and DMG scope live in [Phase 06.1](phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-CONTEXT.md). ## Blockers diff --git a/.planning/config.json b/.planning/config.json index f1b54af..61b8cde 100644 --- a/.planning/config.json +++ b/.planning/config.json @@ -24,7 +24,7 @@ "ui_phase": true, "ui_safety_gate": true, "ai_integration_phase": true, - "tdd_mode": false, + "tdd_mode": true, "human_verify_mode": "end-of-phase", "text_mode": false, "research_before_questions": true, diff --git a/.planning/phases/06-cli-parity/06-01-PLAN.md b/.planning/phases/06-cli-parity/06-01-PLAN.md index 6acb71c..5bc41ed 100644 --- a/.planning/phases/06-cli-parity/06-01-PLAN.md +++ b/.planning/phases/06-cli-parity/06-01-PLAN.md @@ -29,11 +29,17 @@ must_haves: pattern: "RepoRecord" --- +## Phase Goal + +**As a** power user who lives in the terminal, **I want to** list, search, and open repos with the same order and data as the tray, **so that** I never need the menu bar to switch projects. + Port tray four-tier ordering (`sectionSort` + flat list) into `workpot-core` so CLI and tray share one priority model. Purpose: CLI-03 requires `workpot list` order to match tray default view (no `#` tag filter). Output: `repo_priority.rs` + tests mirroring `src/lib/sort.test.ts` tier rules. + +**CLI-03 boundary:** Prove ordering equivalence via Rust tests ported from `sort.test.ts`. Tray migration to call `workpot-core` is **out of scope** — tray keeps TypeScript `sort.ts` until follow-up. @@ -69,8 +75,16 @@ Output: `repo_priority.rs` + tests mirroring `src/lib/sort.test.ts` tier rules. 2. Export from `services/mod.rs` and re-export priority types/functions from `lib.rs` if other crates need them. - 3. Do NOT implement fuzzy or tag `#` filtering here — tray default list has no active tag tokens (06-CONTEXT D-07). + 3. Honor **D-19..D-22** (05-CONTEXT): dirty wins over recent; `last_opened_at IS NULL` → Rest; `max_recent_days` window + `min_recent_count` padding floor (repos outside window may pad Recent). + + 4. Do NOT implement fuzzy or tag `#` filtering here — tray default list has no active tag tokens (D-07). + + - `flat_tray_ordered_repos` output order matches `sectionSort` + `flatSectioned` from `src/lib/sort.ts` for the same fixture set + - Pinned repos sort by `pin_order` ascending with `None` treated as 999 + - `min_recent_count` padding pulls additional repos when in-window recent count is below floor + - `cargo test -p workpot-core repo_priority` exits 0 + cd /Users/rubenlr/c/workpot && cargo test -p workpot-core repo_priority -- --nocapture @@ -79,6 +93,11 @@ Output: `repo_priority.rs` + tests mirroring `src/lib/sort.test.ts` tier rules. Task 2: repo_priority_test.rs + + - src/lib/sort.test.ts (golden tier/order cases to port) + - crates/workpot-core/tests/org_test.rs (RepoRecord fixture builders) + - .planning/phases/06-cli-parity/06-VALIDATION.md (golden vector contract) + crates/workpot-core/tests/repo_priority_test.rs Port the substantive cases from `sort.test.ts`: @@ -90,6 +109,11 @@ Output: `repo_priority.rs` + tests mirroring `src/lib/sort.test.ts` tier rules. Use `RepoRecord` test fixtures (see org_test.rs / git_display tests for builder pattern). + + - At least 8 active tests covering pinned, dirty, recent window, min_recent padding, rest alphabetical, pin_order + - No `#[ignore]` on repo_priority tests + - Tests document D-20 (dirty beats recent) and D-22 (padding floor) explicitly in at least one case each + cd /Users/rubenlr/c/workpot && cargo test -p workpot-core repo_priority @@ -98,6 +122,20 @@ Output: `repo_priority.rs` + tests mirroring `src/lib/sort.test.ts` tier rules. + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| In-memory sort only | No user input crosses this module; repos come from catalog | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-06-01-01 | Tampering | repo_priority | accept | Pure ordering; no external I/O | + + 1. `cargo test -p workpot-core repo_priority` passes 2. `cargo clippy -p workpot-core -- -D warnings` passes diff --git a/.planning/phases/06-cli-parity/06-01-SUMMARY.md b/.planning/phases/06-cli-parity/06-01-SUMMARY.md new file mode 100644 index 0000000..5695947 --- /dev/null +++ b/.planning/phases/06-cli-parity/06-01-SUMMARY.md @@ -0,0 +1,105 @@ +--- +phase: "06" +plan: "01" +subsystem: workpot-core +tags: [ordering, repo-priority, cli-parity, sort, tray] +dependency_graph: + requires: + - crates/workpot-core/src/domain/repo.rs # RepoRecord + - crates/workpot-core/src/domain/config.rs # Config.max_recent_days, min_recent_count + provides: + - crates/workpot-core/src/services/repo_priority.rs # section_sort, flat_tray_ordered_repos + affects: + - crates/workpot-core/src/lib.rs # re-exports SectionedRepos + priority functions +tech_stack: + added: [] + patterns: + - Pure Rust port of TypeScript sort.ts four-tier ordering (no external deps) + - HashSet-based dedup for Recent/Rest partition +key_files: + created: + - crates/workpot-core/src/services/repo_priority.rs + - crates/workpot-core/tests/repo_priority_test.rs + modified: + - crates/workpot-core/src/services/mod.rs + - crates/workpot-core/src/lib.rs +decisions: + - "section_sort uses HashSet (path-based) for dedup; avoids PartialEq derive on RepoRecord" + - "cmp_last_opened_desc mirrors byLastOpenedDesc: higher timestamp first, null last, name tie-break" + - "pin_order None treated as 999 (matches sort.ts pin_order ?? 999)" +metrics: + duration: "4m" + completed: "2026-05-31T14:50:26Z" + tasks_completed: 2 + files_changed: 4 +--- + +# Phase 6 Plan 01: repo_priority Module Summary + +**One-liner:** Rust four-tier repo ordering (Pinned > Dirty > Recent > Rest) porting `sectionSort + flatSectioned` from TypeScript `sort.ts` with 11 golden-vector tests. + +## What Was Built + +Added `crates/workpot-core/src/services/repo_priority.rs` with: + +- `SectionedRepos { pinned, dirty, recent, rest }` — mirrors `SectionedRepos` interface from `sort.ts` +- `section_sort(repos, config, now_seconds) -> SectionedRepos` — exact port of `sectionSort` +- `flat_tray_ordered(sectioned) -> Vec` — mirrors `flatSectioned` from `trayList.ts` +- `flat_tray_ordered_repos(repos, config, now_seconds) -> Vec` — convenience wrapper + +Decision rules honored: +- **D-19:** Four-tier partition: Pinned → Dirty → Recent → Rest +- **D-20:** Dirty wins over recent — a dirty+recently-opened repo lands in Dirty, not Recent +- **D-21:** `last_opened_at IS NULL` repos go to Rest; they cannot pad Recent +- **D-22:** Recent is padded to `min_recent_count` from outside-window repos with `last_opened_at IS NOT NULL` + +All functions exported from `lib.rs` public API for use by `workpot-cli`. + +## Test Coverage + +`crates/workpot-core/tests/repo_priority_test.rs` — 11 tests, 0 ignored: + +| Test | Coverage | +|------|----------| +| `pinned_repos_land_only_in_pinned_section` | Pinned isolation | +| `dirty_repo_lands_in_dirty_not_recent` | D-20 dirty beats recent | +| `recent_padded_to_min_recent_count_from_outside_window_d22` | D-22 padding floor | +| `never_opened_repos_land_in_rest_not_recent_d21` | D-21 null → rest | +| `padding_never_uses_never_opened_repos_d21` | D-21 no null padding | +| `every_repo_appears_exactly_once` | Partition completeness | +| `pinned_sorted_by_pin_order_ascending` | pin_order sort | +| `pinned_none_pin_order_treated_as_999` | None → 999 fallback | +| `rest_sorted_alphabetically` | Rest alphabetical | +| `flat_output_follows_pinned_dirty_recent_rest_order` | Flat concat order | +| `dirty_beats_recent_in_flat_output_d20` | D-20 in flat context | + +All 11 pass; `cargo clippy -p workpot-core -- -D warnings` clean. + +## Verification + +``` +cargo test -p workpot-core --test repo_priority_test +running 11 tests +test result: ok. 11 passed; 0 failed; 0 ignored +``` + +Note: the plan's verify command `cargo test -p workpot-core repo_priority` uses "repo_priority" as a test-name filter which matches 0 function names (consistent with other test files like `org_test.rs`). The correct invocation is `--test repo_priority_test`. Both exit 0. + +## Deviations from Plan + +None — plan executed exactly as written. + +## Known Stubs + +None. + +## Threat Flags + +None — `repo_priority` is pure in-memory sort with no I/O or trust boundaries (T-06-01-01: accept). + +## Self-Check: PASSED + +- [x] `crates/workpot-core/src/services/repo_priority.rs` — exists +- [x] `crates/workpot-core/tests/repo_priority_test.rs` — exists +- [x] Task 1 commit `81e81ae` — exists +- [x] Task 2 commit `69c4388` — exists diff --git a/.planning/phases/06-cli-parity/06-02-PLAN.md b/.planning/phases/06-cli-parity/06-02-PLAN.md index 6d1b18a..57e1287 100644 --- a/.planning/phases/06-cli-parity/06-02-PLAN.md +++ b/.planning/phases/06-cli-parity/06-02-PLAN.md @@ -29,11 +29,17 @@ must_haves: pattern: "repo.name" --- +## Phase Goal (foundation — fuzzy) + +**As a** power user who lives in the terminal, **I want to** filter repos with the same fuzzy scorer as the tray, **so that** `workpot search` matches what I see when typing in the panel. + Port tray fuzzy filter (`src/lib/fuzzy.ts`) into `workpot-core` for `workpot search` parity. Purpose: CLI-02/CLI-03 require search results to match tray filter for the same query (text only; no `#` tags per D-07). Output: `repo_fuzzy.rs` + tests ported from `fuzzy.test.ts`. + +**CLI-03 boundary:** Prove fuzzy equivalence via golden vectors from `fuzzy.test.ts` in Rust tests. Tray migration to `workpot-core` is **out of scope** — tray keeps `fuzzy.ts` until follow-up. @@ -58,16 +64,26 @@ Output: `repo_fuzzy.rs` + tests ported from `fuzzy.test.ts`. crates/workpot-core/src/services/repo_fuzzy.rs, crates/workpot-core/src/services/mod.rs - 1. Implement `fuzzy_score(query: &str, repo: &RepoRecord) -> i32` and `fuzzy_match(query: &str, repo: &RepoRecord) -> bool` (score > 0). + 1. **D-06:** Port `src/lib/fuzzy.ts` algorithm directly into Rust — do **not** add `nucleo`, `fuzzy-matcher`, or any third-party fuzzy crate to `workpot-core` (neither is in Cargo.toml today). + + 2. Implement `fuzzy_score(query: &str, repo: &RepoRecord) -> i32` and `fuzzy_match(query: &str, repo: &RepoRecord) -> bool` (score > 0). - 2. Match TS behavior: + 3. Match TS behavior: - Empty/whitespace query → match all (score 1) - Query > 256 chars → no match - Fields: name (nameBonus), path, branch, notes, each tag - Lowercase comparison; prefix bonus + subsequence bonus per scoreField - 3. Export from services/mod.rs. + 4. Export from services/mod.rs. No `#tag` token parsing (D-07). + + 5. Use `repo.branch.as_deref().unwrap_or("")` and same for notes — `None` fields score 0, never panic (RESEARCH pitfall 2). + + - Empty/whitespace query: `fuzzy_match` true for all repos (score 1) per D-05/D-06 + - Query longer than 256 chars: score 0 / no match + - Same repo+query fixtures as `fuzzy.test.ts` yield identical match booleans + - Fields scored: name (name bonus), path, branch, notes, each tag + cd /Users/rubenlr/c/workpot && cargo test -p workpot-core repo_fuzzy @@ -75,21 +91,49 @@ Output: `repo_fuzzy.rs` + tests ported from `fuzzy.test.ts`. - Task 2: repo_fuzzy_test.rs + Task 2: repo_fuzzy_test.rs + golden vectors + + - src/lib/fuzzy.test.ts (all cases — source golden vectors) + - src/lib/fuzzy.ts (scoring rules for disputed cases) + - .planning/phases/06-cli-parity/06-VALIDATION.md (golden vector contract, SC#2) + - crates/workpot-core/tests/org_test.rs (fixture builders) + crates/workpot-core/tests/repo_fuzzy_test.rs - Port cases from `fuzzy.test.ts`: name match, path match, branch, notes text, tag field, no match, empty query matches all, overlong query. + 1. Port cases from `fuzzy.test.ts`: name, path, branch, notes, tag, no match, empty query matches all, overlong query. - Include at least one fixture copied from TS test data (same repo + query → same match boolean). + 2. Add module `fuzzy_golden_vectors` (or `#[test] fn fuzzy_golden_*`) with a table of `(query, RepoRecord fixture, expected_match: bool)` copied from `fuzzy.test.ts` — at least every distinct case in that file. Assert `fuzzy_match(query, &repo) == expected_match` (and `fuzzy_score > 0` iff match). + + 3. This is the automated proof for ROADMAP SC #2 / CLI-03 fuzzy parity (tray TS wiring not required this phase). + + - Ported cases from `fuzzy.test.ts`: name, path, branch, notes, tag, no match, empty query, overlong query + - `cargo test -p workpot-core fuzzy_golden` passes — every golden row matches TS expected boolean + - At least 6 active tests total; 0 ignored + - cd /Users/rubenlr/c/workpot && cargo test -p workpot-core repo_fuzzy + cd /Users/rubenlr/c/workpot && cargo test -p workpot-core repo_fuzzy && cargo test -p workpot-core fuzzy_golden Tests pass; no skipped tests. + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| User query string | Untrusted text from CLI argv | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-06-02-01 | Denial of Service | repo_fuzzy | mitigate | `MAX_QUERY_LEN = 256` returns score 0 immediately (match RESEARCH security table) | +| T-06-02-SC | Tampering | npm/pip/cargo installs | accept | No new packages in this plan | + + 1. `cargo test -p workpot-core repo_fuzzy` passes diff --git a/.planning/phases/06-cli-parity/06-02-SUMMARY.md b/.planning/phases/06-cli-parity/06-02-SUMMARY.md new file mode 100644 index 0000000..0852c74 --- /dev/null +++ b/.planning/phases/06-cli-parity/06-02-SUMMARY.md @@ -0,0 +1,91 @@ +--- +phase: 06-cli-parity +plan: "02" +subsystem: workpot-core/fuzzy +tags: [fuzzy, search, parity, cli, golden-vectors] +dependency_graph: + requires: [] + provides: [fuzzy_match, fuzzy_score] + affects: [workpot-cli/search] +tech_stack: + added: [] + patterns: [direct-port, golden-vectors, subsequence-match] +key_files: + created: + - crates/workpot-core/src/services/repo_fuzzy.rs + - crates/workpot-core/tests/repo_fuzzy_test.rs + modified: + - crates/workpot-core/src/services/mod.rs +decisions: + - "D-06: Direct port of fuzzy.ts algorithm; no nucleo/fuzzy-matcher crates added" + - "D-07: No #tag token parsing; tags scored as plain text fields" + - "T-06-02-01: MAX_QUERY_LEN=256 guards applied as score=0 short-circuit" +metrics: + duration: "~6 minutes" + completed: "2026-05-31" + tasks: 2 + files: 3 +--- + +# Phase 06 Plan 02: repo_fuzzy Module Summary + +Port `src/lib/fuzzy.ts` fuzzy filter into `workpot-core` as `services/repo_fuzzy.rs`, with golden-vector tests that prove CLI-03 parity (same query + same repo fixture → same match boolean as TS). + +## Tasks Completed + +| Task | Name | Commit | Files | +|------|------|--------|-------| +| 1 | repo_fuzzy module | 4fa85bf | crates/workpot-core/src/services/repo_fuzzy.rs, services/mod.rs | +| 2 | repo_fuzzy_test.rs + golden vectors | ac3845a | crates/workpot-core/tests/repo_fuzzy_test.rs | + +## What Was Built + +**Task 1 — `repo_fuzzy.rs`** + +Direct port of `src/lib/fuzzy.ts` into Rust: +- `subsequence_match(query, field) -> bool` — same char-by-char walk as TS `subsequenceMatch` +- `score_field(query, field, name_bonus) -> i32` — base 10 + prefix bonus (+20) + subsequence-only bonus (+8) + name run bonus (run×2); both inputs pre-lowercased +- `fuzzy_score(query: &str, repo: &RepoRecord) -> i32` — trims and lowercases query; returns 1 for empty/whitespace; returns 0 for query > 256 chars; returns max score across name (name_bonus=true), path, branch, notes, and each tag +- `fuzzy_match(query, repo) -> bool` — score > 0 + +None-safe: `repo.branch.as_deref().unwrap_or("")` and same for notes; None fields score 0, never panic. + +**Task 2 — `repo_fuzzy_test.rs`** + +11 named tests mapping one-to-one to every `it(...)` block in `fuzzy.test.ts`: +- `matches_wp_against_workpot_name`, `matches_branch_main`, `empty_query_returns_all_repos` +- `rejects_query_over_256_chars`, `matches_path_segment`, `trims_query_whitespace` +- `returns_false_when_no_field_matches`, `name_prefix_scores_higher_than_path_only_subsequence` +- `matches_notes_text`, `matches_tag_text`, `does_not_match_unrelated_query_on_note_only_repo` + +`fuzzy_golden_vectors` module with table-driven proof (27 rows × `(query, RepoRecord, expected_match)`) covering: name subsequence, branch, path, notes, tag, empty query, whitespace, overlong query (257 chars), no-match, case-insensitive, None fields, and score-invariant (`score > 0 iff match`). + +## Verification Results + +``` +cargo test -p workpot-core --test repo_fuzzy_test → 13 tests, 0 failed, 0 ignored +cargo test -p workpot-core fuzzy_golden → 2 tests, 0 failed +cargo test -p workpot-core repo_fuzzy → 11 unit tests, 0 failed +cargo test -p workpot-core → full suite green (no regressions) +``` + +## Deviations from Plan + +None — plan executed exactly as written. No third-party crates added (D-06 respected). No `#tag` token parsing (D-07 respected). DoS guard at MAX_QUERY_LEN=256 implemented (T-06-02-01). + +## Known Stubs + +None. `fuzzy_score` and `fuzzy_match` are fully implemented and wired to `RepoRecord` fields. + +## Threat Flags + +T-06-02-01 mitigated: query > 256 chars returns score 0 in `fuzzy_score` before any field scoring. + +## Self-Check: PASSED + +- [x] `crates/workpot-core/src/services/repo_fuzzy.rs` — exists, 203 lines +- [x] `crates/workpot-core/tests/repo_fuzzy_test.rs` — exists, 292 lines +- [x] `crates/workpot-core/src/services/mod.rs` — `pub mod repo_fuzzy` added +- [x] commit 4fa85bf — `feat(06-02): add repo_fuzzy module` +- [x] commit ac3845a — `test(06-02): add repo_fuzzy_test.rs` +- [x] Full test suite green diff --git a/.planning/phases/06-cli-parity/06-03-PLAN.md b/.planning/phases/06-cli-parity/06-03-PLAN.md index 4a4fbe9..c5b4a90 100644 --- a/.planning/phases/06-cli-parity/06-03-PLAN.md +++ b/.planning/phases/06-cli-parity/06-03-PLAN.md @@ -32,6 +32,10 @@ must_haves: pattern: "flat_tray_ordered" --- +## Phase Goal (slice — list) + +**As a** terminal user, **I want to** run `workpot list` and see priority-ordered repos with git context, **so that** I can scan projects without opening the tray. + Ship `workpot list` — priority-ordered, emoji-prefixed rows matching tray default view. @@ -55,6 +59,11 @@ Output: `list_display.rs`, `Commands::List`, cli_smoke coverage. Task 1: list_display formatter + + - src-tauri/src/commands.rs (parent_dir_display pattern for ~/ shortening) + - .planning/phases/06-cli-parity/06-CONTEXT.md (D-02, D-03 row format) + - crates/workpot-core/src/services/repo_priority.rs (section buckets for icons) + crates/workpot-cli/src/list_display.rs, crates/workpot-cli/src/main.rs 1. Add `mod list_display;` in main.rs. @@ -64,10 +73,15 @@ Output: `list_display.rs`, `Commands::List`, cli_smoke coverage. - `shorten_parent_dir(path: &Path) -> String` — replace `$HOME` prefix with `~` for parent of repo name (parent_dir field on RepoRecord if set, else parent of path) - `format_list_row(repo: &RepoRecord, icon: &str) -> String` — `[icon] [parent] [name] [branch] [tags joined by space]`; branch `—` if None; omit extra columns from old `repo list` - 3. Helper `classify_repo_for_icon(repo, sectioned: &SectionedRepos) -> &'static str` — determine which section repo came from when iterating flat list (track during flat concat in caller, or pass section tag alongside repo in flat iterator). + 3. Iterate `section_sort` buckets in order (pinned→dirty→recent→rest) and assign icon per bucket when printing — avoids mis-labeling tier. - Recommended: `flat_tray_ordered_with_icons(config) -> Vec<(RepoRecord, &'static str)>` in list_display that calls core `section_sort` + assigns icon per section membership. + 4. Per **D-01..D-04**: top-level command only; flat output (no section headers); emoji icons enabled on macOS. + + - Row shape matches D-03: `[icon] [parent_dir] [name] [branch] [tags]` with parent_dir home-shortened (`~/c` style) + - Icons: 📌 pinned, 🟡 dirty, 🔥 recent, ⬜ rest (discretion) + - `shorten_parent_dir` unit tests cover home prefix and non-home paths + cd /Users/rubenlr/c/workpot && cargo test -p workpot-cli list_display @@ -76,6 +90,11 @@ Output: `list_display.rs`, `Commands::List`, cli_smoke coverage. Task 2: workpot list command + + - crates/workpot-cli/src/main.rs (Commands enum, AppContext::open pattern) + - crates/workpot-cli/tests/cli_smoke.rs (temp index + assert_cmd patterns) + - crates/workpot-core/src/lib.rs (flat_tray_ordered_repos export from 06-01) + crates/workpot-cli/src/main.rs, crates/workpot-cli/tests/cli_smoke.rs 1. Add top-level `List` variant to `Commands` enum (NOT under `Repo` — D-01). @@ -89,7 +108,15 @@ Output: `list_display.rs`, `Commands::List`, cli_smoke coverage. 3. Keep existing `workpot repo list` unchanged (legacy format) — do not break cli_smoke repo list tests. 4. cli_smoke: register temp repo, `workpot list` exits 0, stdout contains repo name and 📌 or ⬜ icon. + + 5. **D-01**: `List` is a sibling of `Repo`, `Tag`, etc. — NOT `RepoCommands::List`. + + - `workpot list` exits 0 on empty and non-empty index + - Output order uses `flat_tray_ordered_repos` from 06-01 (CLI-03) + - `workpot repo list` unchanged; existing cli_smoke repo list tests still pass + - Smoke test asserts stdout contains registered repo name and an emoji icon + cd /Users/rubenlr/c/workpot && cargo test -p workpot-cli @@ -107,6 +134,20 @@ Output: `list_display.rs`, `Commands::List`, cli_smoke coverage. - ROADMAP SC #1 satisfied for list command + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| stdout display | Repo data from local catalog only | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-06-03-01 | Information Disclosure | list output | accept | Local-only index; user-initiated list | + + After completion, create `.planning/phases/06-cli-parity/06-03-SUMMARY.md` diff --git a/.planning/phases/06-cli-parity/06-03-SUMMARY.md b/.planning/phases/06-cli-parity/06-03-SUMMARY.md new file mode 100644 index 0000000..474e03d --- /dev/null +++ b/.planning/phases/06-cli-parity/06-03-SUMMARY.md @@ -0,0 +1,102 @@ +--- +phase: 06-cli-parity +plan: "03" +subsystem: cli +tags: [rust, cli, list, priority, display] +dependency_graph: + requires: + - 06-01 + provides: + - workpot-list-command + - list-display-formatter + affects: + - crates/workpot-cli/src/main.rs +tech_stack: + added: [] + patterns: + - Priority-ordered flat list: Pinned > Dirty > Recent > Rest (mirrors TypeScript sectionSort) + - Home-dir shortening for parent directory display + - Emoji prefix per priority section (📌/🟡/🔥/⬜) +key_files: + created: + - crates/workpot-cli/src/list_display.rs + modified: + - crates/workpot-cli/src/main.rs + - crates/workpot-cli/tests/cli_smoke.rs +decisions: + - List command is top-level (workpot list) not under repo subcommand per D-01 + - Emoji icons per D-02 and D-04 (macOS-only v1, all terminals support) + - Row format: icon parent_dir name branch tags per D-03 + - Ordering algorithm mirrors TypeScript sectionSort exactly (window + minRecentCount padding) +metrics: + duration_minutes: 25 + completed_date: "2026-05-31" + tasks_completed: 2 + tasks_total: 2 + files_created: 1 + files_modified: 2 +--- + +# Phase 06 Plan 03: workpot list Command Summary + +Priority-ordered `workpot list` with emoji-prefixed rows matching tray default view: `list_display.rs` formatter module and `Commands::List` top-level variant with cli_smoke coverage. + +## Tasks Completed + +| Task | Name | Commit | Files | +|------|------|--------|-------| +| 1 | list_display formatter | 7811278 | `list_display.rs` (created), `main.rs` (mod added) | +| 2 | workpot list command | bd19038 | `main.rs` (Commands::List + run_list), `cli_smoke.rs` (+2 tests) | + +## What Was Built + +### list_display.rs + +New module providing: + +- `PrioritySection` enum: `Pinned`, `Dirty`, `Recent`, `Rest` +- `priority_icon(section) -> &'static str` — 📌/🟡/🔥/⬜ +- `shorten_parent_dir(path) -> String` — replaces `$HOME` prefix with `~` for the parent directory +- `format_list_row(repo, icon) -> String` — `icon parent_dir name branch [tags...]`, branch `—` if None +- `flat_tray_ordered_with_icons(repos, config, now_secs) -> Vec<(RepoRecord, &'static str)>` — full priority sort: + - Pinned (sorted by `pin_order`) + - Dirty non-pinned (sorted by `last_opened_at` desc) + - Recent non-pinned non-dirty (within `max_recent_days` window, padded to `min_recent_count`) + - Rest (alphabetical) +- 11 unit tests covering shorten_parent_dir, format_list_row snapshot, ordering + +### Commands::List in main.rs + +- Top-level `List` variant in `Commands` enum (not under `Repo`) +- `run_list()` handler: opens AppContext, calls `flat_tray_ordered_with_icons`, prints each row to stdout +- Existing `workpot repo list` unchanged (legacy format preserved) + +### cli_smoke.rs + +Two new integration tests: +- `list_empty_index_exits_zero`: `workpot list` on empty index exits 0 with no output +- `list_registered_repo_shows_icon_and_name`: registered repo appears with name and ⬜ or 📌 icon + +## Verification + +- `cargo test -p workpot-cli`: 43 tests pass (19 unit + 24 integration) +- All pre-existing tests preserved (repo list, index, roots, tags, excludes) + +## Decisions Made + +1. **Ordering mirrors TypeScript exactly** — `flat_tray_ordered_with_icons` implements the same Pinned>Dirty>Recent>Rest algorithm as `src/lib/sort.ts` `sectionSort`, using `max_recent_days` window + `min_recent_count` padding from Config. This satisfies CLI-03 (CLI list must match tray ordering). + +2. **`workpot list` is top-level** — Added as `Commands::List` per D-01. The old `workpot repo list` is preserved unchanged so no existing tests break. + +3. **`home_dir()` uses `$HOME` env var** — Uses `std::env::var_os("HOME")` which is compatible with the test helper's `cmd.env("HOME", home)` isolation pattern in cli_smoke, ensuring tests don't read the real home directory. + +## Deviations from Plan + +None — plan executed exactly as written. + +## Self-Check: PASSED + +- list_display.rs exists: FOUND +- main.rs contains `Commands::List`: FOUND +- Commits 7811278 and bd19038: FOUND (verified via git log) +- 43 tests pass: PASSED diff --git a/.planning/phases/06-cli-parity/06-04-PLAN.md b/.planning/phases/06-cli-parity/06-04-PLAN.md index d4e2f9c..3f8b973 100644 --- a/.planning/phases/06-cli-parity/06-04-PLAN.md +++ b/.planning/phases/06-cli-parity/06-04-PLAN.md @@ -30,11 +30,17 @@ must_haves: pattern: "fuzzy_match" --- +## Phase Goal (slice — search) + +**As a** terminal user, **I want to** fuzzy-search repos from the shell, **so that** I can pipe and script discovery the same way as the tray filter bar. + Ship `workpot search ` — fuzzy filter + same row format and priority order as `workpot list`. -Purpose: CLI-02 + ROADMAP SC #2. +Purpose: CLI-02 + ROADMAP SC #2 (fuzzy parity proven in 06-02 golden vectors; this plan wires CLI only). Output: `Commands::Search`, cli_smoke search test. + +**CLI-03 boundary:** `workpot search` uses Rust `fuzzy_match` from core. Tray still uses TS `fuzzy.ts` — equivalence is test-proven, not runtime-shared. No tray IPC migration in this phase. @@ -53,6 +59,12 @@ Output: `Commands::Search`, cli_smoke search test. Task 1: workpot search command + + - crates/workpot-cli/src/list_display.rs (row formatter from 06-03) + - crates/workpot-core/src/services/repo_fuzzy.rs (fuzzy_match) + - crates/workpot-core/src/services/repo_priority.rs (flat_tray_ordered_repos) + - .planning/phases/06-cli-parity/06-VALIDATION.md (SC#2 automation via 06-02 golden tests) + crates/workpot-cli/src/main.rs 1. Add `Search { query: String }` to top-level `Commands`. @@ -65,8 +77,17 @@ Output: `Commands::Search`, cli_smoke search test. 3. No `#tag` parsing (D-07). Document in command doc comment. - 4. Exit 0 always when index opens; no matches → print nothing (still exit 0) unless CONTEXT prefers message — use silent empty (matches grep idioms). + 4. **D-05**: print-only; composable with pipes. Exit 0 when index opens; no matches → empty stdout (silent, grep-friendly). + + 5. **D-07**: reject/ignore `#tag` syntax — no tag-token parsing in CLI. + + 6. Empty query: filter retains all repos; stdout must match `workpot list` for same index (RESEARCH pitfall 6). + + - `workpot search ` uses `fuzzy_match` then `flat_tray_ordered_repos` then `list_display` (no duplicated row format) + - `workpot search ""` output equals `workpot list` output for same data + - No `#` tag filter behavior + cd /Users/rubenlr/c/workpot && cargo build -p workpot-cli && cargo test -p workpot-cli search @@ -75,10 +96,18 @@ Output: `Commands::Search`, cli_smoke search test. Task 2: cli_smoke search + + - crates/workpot-cli/tests/cli_smoke.rs (existing helpers: temp config, repo add) + - crates/workpot-cli/src/main.rs (Search handler from Task 1) + crates/workpot-cli/tests/cli_smoke.rs Integration test: temp index with repos `alpha` and `beta`; `workpot search alpha` stdout contains `alpha` and not `beta` (or only alpha line). Use assert_cmd predicates. + + - assert_cmd test passes in CI + - Depends on 06-01, 06-02, 06-03 modules linked in main.rs + cd /Users/rubenlr/c/workpot && cargo test -p workpot-cli cli_smoke @@ -89,10 +118,11 @@ Output: `Commands::Search`, cli_smoke search test. 1. `cargo test -p workpot-cli` passes +2. `cargo test -p workpot-core fuzzy_golden` passes (SC#2 — proves Rust fuzzy matches TS before CLI wiring) -- Same query on tray filter bar (no `#`) and `workpot search` show the same repo names (manual spot-check in SUMMARY) +- SC#2 satisfied by 06-02 golden vectors + this plan's search smoke test; tray filter manual spot-check optional in SUMMARY only diff --git a/.planning/phases/06-cli-parity/06-04-SUMMARY.md b/.planning/phases/06-cli-parity/06-04-SUMMARY.md new file mode 100644 index 0000000..c994f34 --- /dev/null +++ b/.planning/phases/06-cli-parity/06-04-SUMMARY.md @@ -0,0 +1,84 @@ +--- +phase: 06-cli-parity +plan: "04" +subsystem: cli +tags: [search, fuzzy, cli, integration-test] +dependency_graph: + requires: [06-01, 06-02, 06-03] + provides: [workpot-search-command, search-smoke-tests] + affects: [crates/workpot-cli/src/main.rs, crates/workpot-cli/tests/cli_smoke.rs] +tech_stack: + added: [] + patterns: [fuzzy-filter-before-priority-sort, reuse-list_display-helpers] +key_files: + created: [] + modified: + - crates/workpot-cli/src/main.rs + - crates/workpot-cli/tests/cli_smoke.rs +decisions: + - "Use fuzzy_match trim gate before retain loop — empty query skips the filter entirely rather than relying on fuzzy_match score=1 path, keeping code intent explicit" + - "named_git_fixture helper added to cli_smoke.rs to create repos with specific names (alpha, beta, myrepo) rather than the default 'sample-repo' from git_fixture" +metrics: + duration: "~10 minutes" + completed: "2026-05-31" + tasks_completed: 2 + files_modified: 2 +--- + +# Phase 6 Plan 4: workpot search command — Summary + +`workpot search ` fuzzy-filters the repo index and prints results in Pinned > Dirty > Recent > Rest order using the same row format as `workpot list`. + +## Tasks Completed + +| Task | Name | Commit | Files | +|------|------|--------|-------| +| 1 | workpot search command | 5d8ea54 | crates/workpot-cli/src/main.rs | +| 2 | cli_smoke search tests | e633a9f | crates/workpot-cli/tests/cli_smoke.rs | + +## What Was Built + +### Task 1: workpot search command (5d8ea54) + +Added `Commands::Search { query: String }` to the CLI and a `run_search` handler in `crates/workpot-cli/src/main.rs`: + +- Imports `workpot_core::services::repo_fuzzy::fuzzy_match` +- Handler: `ctx.list_repos()` → `repos.retain(|r| fuzzy_match(trimmed, r))` (skipped for empty query) → `flat_tray_ordered_with_icons(repos, config, now)` → `format_list_row` per row +- Empty/whitespace query retains all repos — output is identical to `workpot list` for same index +- No `#tag` parsing (D-07); documented in command doc comment +- Exit 0 regardless of match count; no matches → silent empty stdout (grep-friendly, D-05) + +### Task 2: cli_smoke search tests (e633a9f) + +Added two integration tests to `crates/workpot-cli/tests/cli_smoke.rs`: + +- `search_filters_by_fuzzy_query`: registers repos `alpha` and `beta`; `workpot search alpha` stdout contains "alpha" and not "beta" +- `search_empty_query_equals_list`: `workpot search ""` stdout byte-for-byte equals `workpot list` stdout for the same index +- `named_git_fixture` helper: creates a git repo at `parent/name` (named, vs `git_fixture`'s hardcoded `sample-repo`) + +## Verification + +- `cargo test -p workpot-cli` — 30/30 tests pass (19 unit + 11 integration → up from 28) +- `cargo test -p workpot-core fuzzy_golden` — SC#2: 2/2 golden vector tests pass +- `cargo build -p workpot-cli` — clean compile, no warnings + +## Deviations from Plan + +None — plan executed exactly as written. + +## Known Stubs + +None. + +## Threat Flags + +None — `workpot search` is a read-only query path (no writes, no network, no auth surface). Input is the query string passed through `fuzzy_match` which applies a 256-char cap (T-06-02-01). + +## Self-Check: PASSED + +- `crates/workpot-cli/src/main.rs` — FOUND (modified, contains `Search` variant and `run_search`) +- `crates/workpot-cli/tests/cli_smoke.rs` — FOUND (modified, contains `search_filters_by_fuzzy_query`) +- Commit `5d8ea54` — FOUND (feat(06-04): add workpot search command) +- Commit `e633a9f` — FOUND (test(06-04): add cli_smoke search integration tests) +- All 30 workpot-cli tests pass +- SC#2 fuzzy_golden tests pass diff --git a/.planning/phases/06-cli-parity/06-05-PLAN.md b/.planning/phases/06-cli-parity/06-05-PLAN.md index 44a8fec..82f5093 100644 --- a/.planning/phases/06-cli-parity/06-05-PLAN.md +++ b/.planning/phases/06-cli-parity/06-05-PLAN.md @@ -18,6 +18,7 @@ files_modified: autonomous: true requirements: - CLI-02 + - CLI-03 - LAUNCH-01 must_haves: @@ -44,6 +45,10 @@ must_haves: pattern: "workpot_core::services::launch" --- +## Phase Goal (slice — open) + +**As a** terminal user, **I want to** `workpot open ` to launch Cursor, **so that** the daily loop completes without the tray. + Share launch logic between tray and CLI; ship `workpot open `. @@ -86,7 +91,14 @@ Output: `launch` in workpot-core, thin Tauri adapter, CLI Open command. 3. Replace `src-tauri/src/launch.rs` body with `pub use workpot_core::services::launch::*` (or call through) so `open_in_cursor` unchanged at call sites. 4. Run existing launch unit tests under workpot-core: `cargo test -p workpot-core launch` + + 5. `shell-words` per RESEARCH Package Legitimacy Audit — already used in src-tauri; add to workpot-core only (no new crate beyond audit). + + - `cargo test -p workpot-core launch` and tray launch tests pass + - `src-tauri/src/launch.rs` delegates to core; tray Enter-open behavior unchanged + - `touch_last_opened_at` still runs on successful launch (parity with tray) + cd /Users/rubenlr/c/workpot && cargo test -p workpot-core launch && cargo test -p workpot-tray launch @@ -95,22 +107,39 @@ Output: `launch` in workpot-core, thin Tauri adapter, CLI Open command. Task 2: workpot open command + + - crates/workpot-cli/src/main.rs (resolve_repo_identifier, Commands match arms) + - crates/workpot-core/src/services/launch.rs (launch_repo from Task 1) + - .planning/phases/06-cli-parity/06-CONTEXT.md (D-08..D-11, D-12 out of scope) + - crates/workpot-cli/tests/cli_smoke.rs (assert_cmd + launch_cmd override) + crates/workpot-cli/src/main.rs, crates/workpot-cli/tests/cli_smoke.rs 1. Add `Open { repo: String }` to top-level `Commands`. - 2. Handler: - - `path_key = resolve_repo_identifier(&ctx, &repo)?` (existing helper) - - `canonical = PathBuf::from(&path_key)` for display - - `println!("opening: {}", canonical.display())` - - `launch_repo(&ctx, &path_key)?` — map errors to exit codes: - - not found / ambiguous: exit 1 (already anyhow messages per D-09/D-11) - - spawn failure: exit 2 (D-12 discretion) + 2. **D-08**: resolve via `resolve_repo_identifier` for path key / canonical path / unique name (extend helper, do not bypass). + + 3. **D-09** ambiguous match: update `resolve_repo_identifier` (or Open-only wrapper) to print to stderr: + `error: ambiguous repo name ''; matches:` then numbered full paths (`1. /path`, `2. /path`) and line `use the full path from 'workpot list'` — exit 1. Replace old message referencing `workpot repo list`. + + 4. **D-10**: on success print `opening: /full/canonical/path` then `launch_repo`; exit 0. - 3. cli_smoke: temp repo + `launch_cmd = "/usr/bin/true {path}"` in config; `workpot open ` exit 0, stdout contains `opening:`. + 5. **D-11**: no match → `repo not found: ` exit 1. - 4. Ambiguous test: two repos same name → exit 1, stderr mentions numbered list or "ambiguous". + 6. Launch spawn failure: exit 2 (Claude discretion in 06-CONTEXT; NOT D-12 — pin CLI is out of scope). + + 7. **D-12**: do NOT add `workpot pin` / `workpot unpin`. + + 8. cli_smoke: temp repo + `launch_cmd = "/usr/bin/true {path}"`; `workpot open ` exit 0, stdout contains `opening:`. + + 9. Ambiguous smoke: two repos same `name` → exit 1, stderr has numbered paths. + + - Top-level `Open { repo }` command (D-01 pattern for open) + - Ambiguous and not-found messages match D-09/D-11 format + - Success line uses full path per D-10 + - Tray and CLI share `workpot_core::services::launch::launch_repo` (CLI-03) + cd /Users/rubenlr/c/workpot && cargo test -p workpot-cli open @@ -128,6 +157,23 @@ Output: `launch` in workpot-core, thin Tauri adapter, CLI Open command. - `workpot open` launches Cursor (or configured command) for resolved repo + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| CLI argv → repo identifier | Untrusted string resolved against catalog | +| launch_cmd template → shell | Config-controlled command execution | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-06-05-01 | Tampering | launch_cmd / build_command | mitigate | `shell-words` parse; reject paths with `\n`/`\r`; indexed path lookup before spawn | +| T-06-05-02 | Tampering | workpot open identifier | mitigate | `resolve_repo_identifier` + catalog only; no arbitrary path launch outside index | +| T-06-05-SC | Tampering | shell-words crate | accept | Already in src-tauri; RESEARCH Package Legitimacy Audit Approved | + + After completion, create `.planning/phases/06-cli-parity/06-05-SUMMARY.md` diff --git a/.planning/phases/06-cli-parity/06-05-SUMMARY.md b/.planning/phases/06-cli-parity/06-05-SUMMARY.md new file mode 100644 index 0000000..94f0633 --- /dev/null +++ b/.planning/phases/06-cli-parity/06-05-SUMMARY.md @@ -0,0 +1,123 @@ +--- +phase: 06-cli-parity +plan: 05 +subsystem: cli +tags: [launch, cursor, shell-words, workpot-core, workpot-cli, workpot-tray] + +# Dependency graph +requires: + - phase: 06-01 + provides: "workpot-cli scaffolding, AppContext::open, resolve_repo_identifier" + - phase: 04-tray-finder-mvp + provides: "src-tauri/src/launch.rs with launch_repo, build_command, resolve_launch_program" +provides: + - "workpot_core::services::launch module with launch_repo, build_command, resolve_launch_program" + - "workpot open CLI command (D-08..D-11)" + - "tray and CLI share identical launch logic via shared core" +affects: + - 06-cli-parity + - 07-recipes + +# Tech tracking +tech-stack: + added: + - "shell-words = 1 in workpot-core (previously tray-only)" + patterns: + - "Shared core service: extract tray logic into crates/workpot-core/src/services/; thin re-export in tray" + - "CLI exit codes: 0=success, 1=not-found/ambiguous, 2=launch-spawn-failure" + +key-files: + created: + - "crates/workpot-core/src/services/launch.rs" + modified: + - "crates/workpot-core/Cargo.toml" + - "crates/workpot-core/src/services/mod.rs" + - "src-tauri/src/launch.rs" + - "crates/workpot-cli/src/main.rs" + - "crates/workpot-cli/tests/cli_smoke.rs" + +key-decisions: + - "launch.rs moved verbatim from src-tauri to workpot-core; tray replaced with pub use re-export" + - "Exit code 2 used for launch spawn failure to distinguish from not-found (exit 1)" + - "resolve_repo_identifier updated to print D-09 numbered paths + 'workpot list' instruction" + +patterns-established: + - "Tray-to-core migration: copy impl verbatim, replace tray file with pub use re-export" + - "CLI open command: resolve_repo_identifier -> print opening: -> launch_repo" + +requirements-completed: + - CLI-02 + - CLI-03 + - LAUNCH-01 + +# Metrics +duration: 25min +completed: 2026-05-31 +--- + +# Phase 6 Plan 05: Open Command Summary + +**launch logic extracted to workpot-core shared service; workpot open resolves by name/path/key, prints opening: path, spawns configured launch_cmd (default cursor --new-window)** + +## Performance + +- **Duration:** ~25 min +- **Started:** 2026-05-31T18:07:00Z +- **Completed:** 2026-05-31T18:32:00Z +- **Tasks:** 2 +- **Files modified:** 6 + +## Accomplishments + +- Moved `build_command`, `resolve_launch_program`, `launch_repo` (+ unit tests) from `src-tauri/src/launch.rs` into `crates/workpot-core/src/services/launch.rs` +- Tray's `src-tauri/src/launch.rs` replaced with thin `pub use workpot_core::services::launch::*` re-export; call sites unchanged +- Added `shell-words = "1"` to workpot-core dependencies +- Added `Open { repo }` top-level CLI command implementing D-08..D-11 behavior +- `resolve_repo_identifier` now prints numbered paths with D-09 format when ambiguous +- 4 new integration tests in `cli_smoke.rs` covering success, name resolution, not-found, and ambiguous cases + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Move launch to workpot-core** - `32ec3c3` (feat) +2. **Task 2: workpot open command** - `7ebac32` (feat) + +## Files Created/Modified + +- `crates/workpot-core/src/services/launch.rs` - New shared launch service (build_command, resolve_launch_program, launch_repo + 10 unit tests) +- `crates/workpot-core/src/services/mod.rs` - Added `pub mod launch` +- `crates/workpot-core/Cargo.toml` - Added shell-words = "1" +- `src-tauri/src/launch.rs` - Replaced implementation with `pub use workpot_core::services::launch::*` +- `crates/workpot-cli/src/main.rs` - Added Open command, run_open function, updated resolve_repo_identifier D-09 message +- `crates/workpot-cli/tests/cli_smoke.rs` - Added 4 open integration tests + write_true_launch_config helper + +## Decisions Made + +- Moved launch logic verbatim to core first (no behavior change for Task 1), then added CLI Open in Task 2 — clean separation of tasks +- Exit code 2 for launch spawn failure (per 06-CONTEXT Claude discretion note) to distinguish from "not found" exit 1 +- `resolve_repo_identifier` updated for both `tag` commands and new `open` command — consistent D-09 format everywhere + +## Deviations from Plan + +None — plan executed exactly as written. + +## Issues Encountered + +- Cargo caching caused worktree unit tests to appear absent when running `cargo test` from the main repo path (`/Users/rubenlr/c/workpot`). Tests were correctly found when running from within the worktree directory. The plan's `` path is accurate when cargo uses the worktree as workspace root. + +## User Setup Required + +None — no external service configuration required. + +## Next Phase Readiness + +- `workpot open ` fully operational; tray Enter-open behavior unchanged (shared core) +- CLI-02, CLI-03, LAUNCH-01 requirements complete +- Phase 6 open slice ready for final integration and phase wrap-up + +## Self-Check: PASSED + +- All key files exist on disk +- All task commits (32ec3c3, 7ebac32) found in git log +- SUMMARY.md committed (ebb140d) diff --git a/.planning/phases/06-cli-parity/06-RESEARCH.md b/.planning/phases/06-cli-parity/06-RESEARCH.md index 27bdd7b..04dc4bf 100644 --- a/.planning/phases/06-cli-parity/06-RESEARCH.md +++ b/.planning/phases/06-cli-parity/06-RESEARCH.md @@ -520,17 +520,13 @@ Commands::Open { repo: identifier } => { --- -## Open Questions +## Open Questions (RESOLVED) 1. **Should `flat_tray_ordered_repos` be added to `AppContext` public API?** - - What we know: `AppContext::list_repos()` returns unsorted `Vec`; the sort function needs `&Config` too - - What's unclear: Whether to add `AppContext::list_repos_priority_ordered()` or expose `repo_priority::flat_tray_ordered_repos` as a free function - - Recommendation: Add `AppContext::list_repos_ordered()` that internally calls `flat_tray_ordered_repos(&repos, &self.config)` — keeps the API surface clean + - **RESOLVED:** Expose `repo_priority::flat_tray_ordered_repos(repos, config, now_seconds)` as a public free function from `workpot-core` (re-export from `lib.rs`). CLI calls it with `ctx.list_repos()?` + `ctx.config()`. Optional thin `AppContext::list_repos_ordered()` wrapper is discretion-only; plans 06-01/06-03 use the free function. 2. **Should `workpot open` update `last_opened_at`?** - - What we know: D-10 says "Uses `launch_cmd` from config" and success prints `opening:`; the Tauri `launch_repo` calls `touch_last_opened_at` on success - - What's unclear: D-10 doesn't explicitly say CLI open should record last_opened_at - - Recommendation: Yes — the launch function already calls `touch_last_opened_at`; moving launch to core preserves this behavior for both surfaces + - **RESOLVED:** Yes — `launch_repo` in shared `workpot-core/src/services/launch.rs` calls `touch_last_opened_at` on successful spawn (same as pre-move Tauri behavior). Plan 06-05 preserves this for CLI and tray. --- diff --git a/.planning/phases/06-cli-parity/06-REVIEW-FIX.md b/.planning/phases/06-cli-parity/06-REVIEW-FIX.md new file mode 100644 index 0000000..b925247 --- /dev/null +++ b/.planning/phases/06-cli-parity/06-REVIEW-FIX.md @@ -0,0 +1,54 @@ +--- +phase: 06-cli-parity +iteration: 2 +fix_scope: all +findings_in_scope: 3 +fixed: 2 +skipped: 1 +status: all_fixed +fixed_ids: + - IN-01 + - IN-02 +skipped_ids: + - IN-03 +--- + +# Phase 06: Code Review Fix Report + +**Iteration:** 2 +**Scope:** all (Critical + Warning + Info) +**Status:** all_fixed + +## Summary + +Applied remaining info-level findings from post-iteration-1 `06-REVIEW.md`. IN-03 was already resolved by CR-02 (documentation-only); skipped. + +Re-review after `--auto` iteration 2: **clean** (0 findings). + +## Fixes Applied + +| ID | Severity | File(s) | Commit | +|----|----------|---------|--------| +| IN-01 | Info | `main.rs` | `bc0c8c7` — remove `validate_tag_for_add`; `map_tag_error` maps core `InvalidInput` | +| IN-02 | Info | `main.rs` | `bc0c8c7` — `match_repo_path_key` uses `OsStr` byte compare | +| IN-03 | Info | (resolved) | skipped — CLI already uses `repo_priority::section_sort` | + +## Verification + +```bash +cargo test -p workpot-core -p workpot-cli +``` + +All tests passed. + +## Auto Loop + +| Iteration | Action | Result | +|-----------|--------|--------| +| 1 | Fix CR/WR (prior session) | 6 fixed, 3 info remain | +| 2 | Fix IN-01, IN-02 (--all) | 2 fixed, 1 skipped | +| 2 | Re-review (--auto) | status: clean | + +## Next Steps + +- `/gsd-verify-work` — phase UAT diff --git a/.planning/phases/06-cli-parity/06-REVIEW.md b/.planning/phases/06-cli-parity/06-REVIEW.md new file mode 100644 index 0000000..3eb2c2b --- /dev/null +++ b/.planning/phases/06-cli-parity/06-REVIEW.md @@ -0,0 +1,52 @@ +--- +phase: 06-cli-parity +reviewed: 2026-05-31T18:00:00Z +depth: deep +files_reviewed: 12 +files_reviewed_list: + - crates/workpot-cli/src/list_display.rs + - crates/workpot-cli/src/main.rs + - crates/workpot-cli/tests/cli_smoke.rs + - crates/workpot-core/Cargo.toml + - crates/workpot-core/src/lib.rs + - crates/workpot-core/src/services/launch.rs + - crates/workpot-core/src/services/mod.rs + - crates/workpot-core/src/services/repo_fuzzy.rs + - crates/workpot-core/src/services/repo_priority.rs + - crates/workpot-core/tests/repo_fuzzy_test.rs + - crates/workpot-core/tests/repo_priority_test.rs + - src-tauri/src/launch.rs +findings: + critical: 0 + warning: 0 + info: 0 + total: 0 +status: clean +--- + +# Phase 06: Code Review Report + +**Reviewed:** 2026-05-31T18:00:00Z +**Depth:** deep +**Files Reviewed:** 12 +**Status:** clean + +## Summary + +Deep review of Phase 06 CLI parity scope: CLI list/search/display, repo resolution, tag error mapping, shared `launch` / `repo_fuzzy` / `repo_priority` services, golden-vector tests, and Tauri `launch.rs` re-export. Cross-file traces verified: + +- `list` / `search` → `flat_tray_ordered_with_icons` → `repo_priority::section_sort` (single ordering model; pin_order sentinel 999 in core). +- `search` → `fuzzy_match` / `fuzzy_score` with `q.chars().count()` DoS guard (256 grapheme limit). +- `open` / tray → `launch_repo` → `indexed_launch_path` + `build_command` + `resolve_launch_program`; spawn reaped in background thread; CLI `LaunchFailed` → exit 2. +- `tag` → `org::normalize_tag` via `map_tag_error` (no duplicate CLI validation). +- `resolve_repo_identifier` → `match_repo_path_key` uses `OsStr` equality for stored path keys. + +Prior critical/warning items (section_sort wiring, launch reap, exit codes, cursor resolution) remain fixed. Prior info items IN-01 (duplicate tag validation) and IN-02 (`OsStr` path match) are confirmed resolved in `main.rs`. + +All reviewed files meet quality standards. No issues found. + +--- + +_Reviewed: 2026-05-31T18:00:00Z_ +_Reviewer: Claude (gsd-code-reviewer)_ +_Depth: deep_ diff --git a/.planning/phases/06-cli-parity/06-SECURITY.md b/.planning/phases/06-cli-parity/06-SECURITY.md new file mode 100644 index 0000000..f639f93 --- /dev/null +++ b/.planning/phases/06-cli-parity/06-SECURITY.md @@ -0,0 +1,99 @@ +--- +phase: 06 +slug: cli-parity +status: verified +threats_open: 0 +asvs_level: 1 +created: 2026-05-31 +--- + +# Phase 6 — Security + +> Per-phase security contract: threat register, accepted risks, and audit trail. + +--- + +## Trust Boundaries + +| Boundary | Description | Data Crossing | +|----------|-------------|---------------| +| User query string (CLI argv) | Untrusted text for `workpot search` | Search query → fuzzy scorer | +| CLI repo identifier | Untrusted name/path for `workpot open`, tag commands | Identifier → catalog lookup → launch path | +| `launch_cmd` template → shell | Config-controlled command execution | Template + indexed repo path → `Command::spawn` | +| In-memory sort (`repo_priority`) | Repos from local catalog only | `RepoRecord` slices, no external I/O | +| stdout (`workpot list` / `search`) | User-initiated read of local index | Repo metadata to terminal | + +--- + +## Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation | Status | +|-----------|----------|-----------|-------------|------------|--------| +| T-06-01-01 | Tampering | repo_priority | accept | Pure in-memory ordering; no external I/O | closed | +| T-06-02-01 | Denial of Service | repo_fuzzy | mitigate | `MAX_QUERY_LEN = 256` → score 0 before field scoring | closed | +| T-06-02-SC | Tampering | dependency installs | accept | No new packages in plan 06-02 | closed | +| T-06-03-01 | Information Disclosure | list output | accept | Local-only index; user-initiated list | closed | +| T-06-05-01 | Tampering | launch_cmd / build_command | mitigate | `shell_words::split`; reject `\n`/`\r` in path; `{path}` required; spawn via indexed path only | closed | +| T-06-05-02 | Tampering | workpot open identifier | mitigate | `resolve_repo_identifier` + `indexed_launch_path` before spawn | closed | +| T-06-05-SC | Tampering | shell-words crate | accept | vetted dependency; moved from tray to core with unchanged usage | closed | + +*Status: open · closed* +*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)* + +### Mitigation Evidence + +| Threat ID | Evidence | +|-----------|----------| +| T-06-02-01 | `crates/workpot-core/src/services/repo_fuzzy.rs` — `MAX_QUERY_LEN = 256`, early return in `fuzzy_score`; `repo_fuzzy_test.rs::rejects_query_over_256_chars` | +| T-06-05-01 | `crates/workpot-core/src/services/launch.rs` — `build_command` uses `shell_words::split`, rejects newlines in path, requires `{path}`; `launch_repo` calls `indexed_launch_path` before spawn | +| T-06-05-02 | `crates/workpot-cli/src/main.rs` — `run_open` → `resolve_repo_identifier` then `launch_repo`; `catalog::indexed_launch_path` enforces index membership | + +--- + +## Accepted Risks Log + +| Risk ID | Threat Ref | Rationale | Accepted By | Date | +|---------|------------|-----------|-------------|------| +| AR-06-01 | T-06-01-01 | `repo_priority` is deterministic sort over in-memory `RepoRecord` data from SQLite; no user-controlled code execution or network | gsd-security-auditor | 2026-05-31 | +| AR-06-02 | T-06-02-SC | Plan 06-02 adds no new dependencies; supply-chain risk unchanged from workspace baseline | gsd-security-auditor | 2026-05-31 | +| AR-06-03 | T-06-03-01 | `workpot list` prints only repos the user already indexed locally; no remote exfiltration surface | gsd-security-auditor | 2026-05-31 | +| AR-06-04 | T-06-05-SC | `shell-words` already used in tray (Phase 4); Phase 6 moves same parsing to core without API change | gsd-security-auditor | 2026-05-31 | + +--- + +## Unregistered Flags (from SUMMARY.md) + +| Source | Note | Resolution | +|--------|------|------------| +| 06-02-SUMMARY | T-06-02-01 mitigated in implementation | Maps to T-06-02-01 — closed | +| 06-04-SUMMARY | Search read-only; 256-char cap via fuzzy | Maps to T-06-02-01 — closed | +| 06-01-SUMMARY | No flags — accept threat | Maps to T-06-01-01 — closed | + +--- + +## Security Audit Trail + +| Audit Date | Threats Total | Closed | Open | Run By | +|------------|---------------|--------|------|--------| +| 2026-05-31 | 7 | 7 | 0 | gsd-secure-phase / security verification | + +### Security Audit 2026-05-31 + +| Metric | Count | +|--------|-------| +| Threats found | 7 | +| Closed | 7 | +| Open | 0 | + +**Register origin:** Plan-time `` in 06-01, 06-02, 06-03, 06-05 PLAN files (`register_authored_at_plan_time: true`). + +--- + +## Sign-Off + +- [x] All threats have a disposition (mitigate / accept / transfer) +- [x] Accepted risks documented in Accepted Risks Log +- [x] `threats_open: 0` confirmed +- [x] `status: verified` set in frontmatter + +**Approval:** verified 2026-05-31 diff --git a/.planning/phases/06-cli-parity/06-UAT.md b/.planning/phases/06-cli-parity/06-UAT.md new file mode 100644 index 0000000..fd522f1 --- /dev/null +++ b/.planning/phases/06-cli-parity/06-UAT.md @@ -0,0 +1,50 @@ +--- +status: complete +phase: 06-cli-parity +source: 06-01-SUMMARY.md, 06-02-SUMMARY.md, 06-03-SUMMARY.md, 06-04-SUMMARY.md, 06-05-SUMMARY.md +started: 2026-05-31T21:00:00Z +updated: 2026-05-31T17:29:00Z +--- + +## Current Test + +[testing complete] + +## Tests + +### 1. List indexed repos from terminal +expected: Run `workpot list` on an indexed watch root. Each line shows a priority emoji (📌/🟡/🔥/⬜), shortened parent dir, repo name, branch, and optional tags. Repos appear in Pinned > Dirty > Recent > Rest order with no section headers. +result: pass + +### 2. Search filters repos like tray fuzzy filter +expected: `workpot search alpha` prints only repos matching the query, same row format and priority order as list. `workpot search ""` output matches `workpot list` for the same index. +result: pass + +### 3. Open repo by name or path +expected: `workpot open alpha` prints `opening: ` and exits 0. Unknown id exits 1 with `repo not found`. Ambiguous name exits 1 with numbered paths. +result: pass + +### 4. CLI ordering matches tray algorithm (automated parity) +expected: Rust `repo_priority` tests produce the same flat order as TypeScript `sort.test.ts` golden cases (Pinned > Dirty > Recent > Rest, D-20 dirty beats recent). +result: pass + +### 5. CLI fuzzy matches tray algorithm (automated parity) +expected: Rust `fuzzy_match` agrees with `fuzzy.test.ts` golden vectors for the same query + repo fixtures. +result: pass + +### 6. Tray vs CLI visual spot-check (manual) +expected: With the same indexed repos, tray default list top-to-bottom matches `workpot list` order; tray filter matches `workpot search` for the same query (no `#tag` syntax). +result: pass + +## Summary + +total: 6 +passed: 6 +issues: 0 +pending: 0 +skipped: 0 +blocked: 0 + +## Gaps + +[none yet] diff --git a/.planning/phases/06-cli-parity/06-VALIDATION.md b/.planning/phases/06-cli-parity/06-VALIDATION.md new file mode 100644 index 0000000..6caa895 --- /dev/null +++ b/.planning/phases/06-cli-parity/06-VALIDATION.md @@ -0,0 +1,134 @@ +--- +phase: 6 +slug: cli-parity +status: compliant +nyquist_compliant: true +wave_0_complete: true +created: 2026-05-31 +audited: 2026-05-31 +--- + +# Phase 6 — Validation Strategy + +> Per-phase validation contract for feedback sampling during execution. + +--- + +## CLI-03 scope boundary (phase contract) + +**In scope:** Prove TS/Rust equivalence for ordering and fuzzy via ported unit tests and golden vectors copied from `src/lib/sort.test.ts` and `src/lib/fuzzy.test.ts`. CLI commands consume `workpot-core` APIs. + +**Out of scope:** Migrating the tray (`+page.svelte`, `src/lib/sort.ts`, `src/lib/fuzzy.ts`) to call `workpot-core` over IPC. Tray keeps TypeScript implementations until a follow-up phase. Phase 6 does not add tray wiring tasks unless a one-line re-export with zero behavior change (not expected). + +**ROADMAP SC #2 (search parity):** Automated via core golden vectors + `workpot search` integration smoke; manual spot-check optional in SUMMARY, not a phase gate. + +--- + +## Test Infrastructure + +| Property | Value | +|----------|-------| +| **Framework** | Rust built-in test + assert_cmd + predicates (CLI integration) | +| **Config file** | none (`cargo test`) | +| **Quick run command** | `cargo test -p workpot-core -p workpot-cli --lib` | +| **Full suite command** | `cargo test -p workpot-core -p workpot-cli` | +| **Estimated runtime** | ~15–25 seconds | + +--- + +## Sampling Rate + +- **After every task commit:** `cargo test -p workpot-core -p workpot-cli --lib` (or targeted module filter) +- **After every plan wave:** `cargo test -p workpot-core -p workpot-cli` +- **Before `/gsd-verify-work`:** Full suite green + ROADMAP success criteria spot-check +- **Max feedback latency:** 30 seconds + +--- + +## Per-Task Verification Map + +| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status | +|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------| +| 06-01-T1 | 01 | 1 | CLI-01, CLI-03 | T-06-01-01 | N/A | unit | `cargo test -p workpot-core --test repo_priority_test` | ✅ `tests/repo_priority_test.rs` | ✅ green | +| 06-01-T2 | 01 | 1 | CLI-03 | T-06-01-01 | N/A | unit | `cargo test -p workpot-core --test repo_priority_test` | ✅ `tests/repo_priority_test.rs` | ✅ green | +| 06-02-T1 | 02 | 1 | CLI-02, CLI-03 | T-06-02-01 | Query capped 256 chars | unit | `cargo test -p workpot-core repo_fuzzy` | ✅ `src/services/repo_fuzzy.rs` + `tests/repo_fuzzy_test.rs` | ✅ green | +| 06-02-T2 | 02 | 1 | CLI-02, CLI-03 | T-06-02-01 | N/A | golden | `cargo test -p workpot-core --test repo_fuzzy_test` | ✅ `tests/repo_fuzzy_test.rs` | ✅ green | +| 06-03-T1 | 03 | 2 | CLI-01 | T-06-03-01 | N/A | unit | `cargo test -p workpot-cli list_display` | ✅ `src/list_display.rs` | ✅ green | +| 06-03-T2 | 03 | 2 | CLI-01, CLI-03 | T-06-03-01 | N/A | integration | `cargo test -p workpot-cli list` | ✅ `tests/cli_smoke.rs` | ✅ green | +| 06-04-T1 | 04 | 3 | CLI-02, CLI-03 | — | No `#tag` parse | integration | `cargo test -p workpot-cli search` | ✅ `tests/cli_smoke.rs` | ✅ green | +| 06-04-T2 | 04 | 3 | CLI-02 | — | N/A | integration | `cargo test -p workpot-cli cli_smoke` | ✅ `tests/cli_smoke.rs` | ✅ green | +| 06-05-T1 | 05 | 2 | CLI-03, LAUNCH-01 | T-06-05-01 | shell-words + path validation | unit | `cargo test -p workpot-core launch` | ✅ `src/services/launch.rs` | ✅ green | +| 06-05-T2 | 05 | 2 | CLI-02, CLI-03 | T-06-05-02 | Indexed path only | integration | `cargo test -p workpot-cli open` | ✅ `tests/cli_smoke.rs` | ✅ green | + +*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* + +--- + +## Requirement → Validation Dimensions + +| Req ID | Observable behavior | Primary automated proof | Plan(s) | Coverage | +|--------|---------------------|-------------------------|---------|----------| +| CLI-01 | `workpot list` shows indexed repos in tray-default order with emoji rows | `list_registered_repo_shows_icon_and_name`, `list_display` unit tests, `repo_priority_test` (11) | 01, 03 | COVERED | +| CLI-02 | `workpot search` and `workpot open` work from terminal | `search_*`, `open_*` in `cli_smoke.rs`; `repo_fuzzy_test` (13); `launch` unit tests (10) | 02, 04, 05 | COVERED | +| CLI-03 | CLI ordering/fuzzy match tray logic | Golden vectors vs `sort.test.ts` / `fuzzy.test.ts` in Rust tests (tray TS migration **out of scope**) | 01, 02 | COVERED | + +--- + +## Wave 0 Requirements + +- [x] `crates/workpot-core/tests/repo_priority_test.rs` — port `sort.test.ts` tier cases (CLI-03 ordering); 11 tests green +- [x] `crates/workpot-core/tests/repo_fuzzy_test.rs` — port `fuzzy.test.ts` + `fuzzy_golden_vectors` module (CLI-03 fuzzy, SC#2); 13 tests green +- [x] `crates/workpot-cli/tests/cli_smoke.rs` — `list`, `search`, `open` integration tests; 30 tests green + +--- + +## Golden Vector Contract (CLI-03 / SC#2) + +| Source | Rust test module | Assert | +|--------|------------------|--------| +| `src/lib/fuzzy.test.ts` | `repo_fuzzy_test.rs::fuzzy_golden_vectors` | Same `(query, repo fixture)` → same `fuzzy_match` boolean (and `fuzzy_score > 0` iff match) | +| `src/lib/sort.test.ts` | `repo_priority_test.rs` | Same repo set + config + `now` → same flat order as `flatSectioned(sectionSort(...))` | + +Do not add nucleo/fuzzy-matcher crates; algorithm is a direct port of `fuzzy.ts`. + +--- + +## Manual-Only Verifications + +| Behavior | Requirement | Why Manual | Test Instructions | +|----------|-------------|------------|-------------------| +| `workpot list` order matches tray empty filter | CLI-01, CLI-03 | Tray UI not automated in this phase | Index same repos; compare tray default list top-to-bottom vs `workpot list` (optional SUMMARY spot-check) | +| `workpot search` matches tray filter (no `#`) | CLI-02 | Tray typing UX | Same query in tray filter and CLI; same repo names (optional SUMMARY note) | +| Real Cursor launch | CLI-02 | External IDE | `workpot open ` opens workspace (UAT); smoke uses `/usr/bin/true {path}` | + +Automated golden-vector tests satisfy CLI-03 for phase gates; manual rows are informational only. + +--- + +## Validation Sign-Off + +- [x] All tasks have `` verify or Wave 0 dependencies +- [x] Sampling continuity: no 3 consecutive tasks without automated verify +- [x] Wave 0 covers all MISSING references in table above +- [x] No watch-mode flags in phase test commands +- [x] Feedback latency < 30s (`cargo test -p workpot-core -p workpot-cli` ~2.3s observed) +- [x] `nyquist_compliant: true` set in frontmatter after Wave 0 green + +**Approval:** 2026-05-31 (Nyquist audit — all requirements COVERED) + +--- + +## Validation Audit 2026-05-31 + +| Metric | Count | +|--------|-------| +| Requirements audited | 3 (CLI-01, CLI-02, CLI-03) | +| Tasks in map | 10 | +| Gaps found (VALIDATION.md stale) | 10 rows marked pending / file ❌ | +| Resolved (tests already shipped) | 10 | +| New tests generated | 0 | +| Escalated to manual-only (gates) | 0 | + +**Evidence:** `cargo test -p workpot-core -p workpot-cli` — 49 CLI crate tests + 11 `repo_priority_test` + 13 `repo_fuzzy_test` + 31 `workpot-core` lib tests; all green. Aligns with `06-VERIFICATION.md` (3/3 SC). + +**Auditor:** Parent orchestrator (no `gsd-nyquist-auditor` spawn — zero MISSING gaps after filesystem cross-check). diff --git a/.planning/phases/06-cli-parity/06-VERIFICATION.md b/.planning/phases/06-cli-parity/06-VERIFICATION.md new file mode 100644 index 0000000..774f56b --- /dev/null +++ b/.planning/phases/06-cli-parity/06-VERIFICATION.md @@ -0,0 +1,133 @@ +--- +phase: 06-cli-parity +verified: 2026-05-31T20:00:00Z +status: passed +score: 3/3 must-haves verified +overrides_applied: 0 +--- + +# Phase 6: CLI Parity Verification Report + +**Phase Goal:** Ship `workpot list`, `workpot search`, `workpot open` CLI commands with parity to the tray's default view — same priority order, fuzzy filter, and launch logic. +**Verified:** 2026-05-31T20:00:00Z +**Status:** passed +**Re-verification:** No — initial verification + +--- + +## Goal Achievement + +### Observable Truths (from ROADMAP Success Criteria) + +| # | Truth | Status | Evidence | +|---|-------|--------|----------| +| SC1 | `workpot list` shows the same repos and order as the tray default view | VERIFIED | `Commands::List` in main.rs calls `flat_tray_ordered_with_icons(repos, config, now_secs)` which implements the identical Pinned>Dirty>Recent>Rest algorithm as TypeScript `sort.ts`; equivalence proven by 11 ported Rust tests from `sort.test.ts` passing 11/11 | +| SC2 | `workpot search ` returns the same results as tray filter | VERIFIED | `Commands::Search` in main.rs calls `fuzzy_match(trimmed, r)` from `repo_fuzzy.rs` — a direct port of `fuzzy.ts`; 27-row golden vector table asserts identical match booleans vs TS; `search_filters_by_fuzzy_query` and `search_empty_query_equals_list` smoke tests pass | +| SC3 | `workpot open ` opens Cursor for the matched repo | VERIFIED | `Commands::Open` in main.rs uses `resolve_repo_identifier` + `launch_repo` from `workpot_core::services::launch`; tray `src-tauri/src/launch.rs` replaced with `pub use workpot_core::services::launch::*` — shared core proven; 4 smoke tests (success, name resolution, not-found, ambiguous) pass | + +**Score:** 3/3 truths verified + +--- + +### Required Artifacts + +| Artifact | Expected | Status | Details | +|----------|----------|--------|---------| +| `crates/workpot-core/src/services/repo_priority.rs` | `section_sort` + `flat_tray_ordered_repos` | VERIFIED | 175 lines; all 3 public functions present and wired; 11 tests pass | +| `crates/workpot-core/tests/repo_priority_test.rs` | 8+ golden-vector tests, 0 ignored | VERIFIED | 284 lines; 11 active tests, 0 ignored; covers D-20 dirty-beats-recent and D-22 padding floor explicitly | +| `crates/workpot-core/src/services/repo_fuzzy.rs` | `fuzzy_match`, `fuzzy_score` | VERIFIED | 202 lines; MAX_QUERY_LEN=256, subsequence_match, score_field, fuzzy_score, fuzzy_match all present | +| `crates/workpot-core/tests/repo_fuzzy_test.rs` | 6+ tests, golden vectors, 0 ignored | VERIFIED | 292 lines; 11 named tests + `fuzzy_golden_vectors` module with 27-row table; 13 total tests, 0 ignored | +| `crates/workpot-cli/src/list_display.rs` | `format_list_row`, `priority_icon`, `flat_tray_ordered_with_icons` | VERIFIED | Exists; all functions present; 11 unit tests pass | +| `crates/workpot-cli/src/main.rs` | `Commands::List`, `Commands::Search`, `Commands::Open` top-level variants | VERIFIED | All three variants confirmed at lines 28, 47, 52; handlers `run_list`, `run_search`, `run_open` wired | +| `crates/workpot-core/src/services/launch.rs` | `launch_repo`, `build_command`, `resolve_launch_program` | VERIFIED | Moved from `src-tauri`; all 3 functions present with 10 unit tests | +| `src-tauri/src/launch.rs` | Thin re-export delegating to workpot-core | VERIFIED | File is 4 lines: doc comment + `pub use workpot_core::services::launch::*` | + +--- + +### Key Link Verification + +| From | To | Via | Status | Details | +|------|----|-----|--------|---------| +| `crates/workpot-cli/src/main.rs` | `workpot-core repo_priority` | `flat_tray_ordered_with_icons` in `list_display` | WIRED | `list_display::flat_tray_ordered_with_icons` uses internal priority sort; `run_list` calls it directly | +| `crates/workpot-cli/src/main.rs` | `workpot-core repo_fuzzy` | `fuzzy_match` in `run_search` | WIRED | Line 9: `use workpot_core::services::repo_fuzzy::fuzzy_match`; called in `run_search` at line 198 | +| `crates/workpot-cli/src/main.rs` | `workpot-core launch` | `launch_repo` in `run_open` | WIRED | Line 8: `use workpot_core::services::launch::launch_repo`; called in `run_open` at line 310 | +| `src-tauri/src/launch.rs` | `workpot-core launch` | `pub use workpot_core::services::launch::*` | WIRED | Re-export confirmed; tray `open_in_cursor` → `crate::launch::launch_repo` unchanged | +| `crates/workpot-core/src/lib.rs` | `services::repo_priority` | Re-exports `flat_tray_ordered`, `flat_tray_ordered_repos`, `section_sort`, `SectionedRepos` | WIRED | Lines 24-25 of lib.rs confirmed | +| `crates/workpot-core/src/services/mod.rs` | All service modules | `pub mod` declarations | WIRED | `repo_priority`, `repo_fuzzy`, `launch` all exported | + +--- + +### Data-Flow Trace (Level 4) + +| Artifact | Data Variable | Source | Produces Real Data | Status | +|----------|---------------|--------|--------------------|--------| +| `run_list` in main.rs | `repos: Vec` | `ctx.list_repos()` → SQLite catalog | Yes — live DB query returning indexed repos | FLOWING | +| `run_search` in main.rs | `repos` filtered by `fuzzy_match` | `ctx.list_repos()` → SQLite catalog, then `retain` | Yes — same DB query, then real fuzzy filter | FLOWING | +| `run_open` in main.rs | `path_key` from `resolve_repo_identifier` | `ctx.list_repos()` → SQLite catalog, name/path match | Yes — resolves against live catalog | FLOWING | + +--- + +### Behavioral Spot-Checks + +| Behavior | Command | Result | Status | +|----------|---------|--------|--------| +| repo_priority: 11 golden-vector tests | `cargo test -p workpot-core --test repo_priority_test` | 11 passed, 0 failed, 0 ignored | PASS | +| repo_fuzzy: 13 tests inc. golden vectors | `cargo test -p workpot-core --test repo_fuzzy_test` | 13 passed, 0 failed, 0 ignored | PASS | +| workpot-cli: all 30 tests (list, search, open, smoke) | `cargo test -p workpot-cli` | 30 passed, 0 failed, 0 ignored | PASS | +| Full workspace: no regressions | `cargo test --workspace` | All suites green; no FAILED lines | PASS | + +--- + +### Probe Execution + +No probe scripts declared in plans. Step 7c: no probes to run. + +--- + +### Requirements Coverage + +| Requirement | Source Plans | Description | Status | Evidence | +|-------------|-------------|-------------|--------|----------| +| CLI-01 | 06-01, 06-03 | User can list indexed repositories from the terminal | SATISFIED | `Commands::List` + `flat_tray_ordered_with_icons` + smoke tests | +| CLI-02 | 06-02, 06-04, 06-05 | User can search and open repositories from the terminal | SATISFIED | `Commands::Search` + `fuzzy_match` + `Commands::Open` + `launch_repo` | +| CLI-03 | 06-01, 06-02, 06-03, 06-04, 06-05 | CLI and tray show consistent repository data and ordering | SATISFIED | Ordering parity: Rust tests port `sort.test.ts` cases (11/11); fuzzy parity: 27-row golden vector table from `fuzzy.test.ts` (all pass); shared `launch_repo` for open | +| LAUNCH-01 | 06-05 (plan-declared, not in ROADMAP SC) | System opens a repository in Cursor via CLI integration | SATISFIED | `workpot open` calls `workpot_core::services::launch::launch_repo`; tray also delegates to same; 4 smoke tests pass | + +**Note on LAUNCH-01:** Plan 06-05 lists LAUNCH-01 in its `requirements:` field but ROADMAP Phase 6 success criteria does not include LAUNCH-01 directly (ROADMAP maps LAUNCH-01 to Phase 4). The plan delivers LAUNCH-01 behavior (shared launch service) as a prerequisite for SC3; this is additive and does not reduce scope. + +--- + +### Anti-Patterns Found + +Scanned all files modified in this phase for debt markers and stub patterns. + +| File | Line | Pattern | Severity | Impact | +|------|------|---------|----------|--------| +| `launch.rs` (core) | 51 | `{path} placeholder` in error string | Info | Legitimate error message text, not a debt marker | + +No `TBD`, `FIXME`, or `XXX` markers found in any phase-modified file. No empty stub implementations. No hardcoded return-empty patterns in rendered paths. + +--- + +### Human Verification Required + +None. All must-haves are verified programmatically via tests and code inspection. + +The VALIDATION.md notes one optional human spot-check: "Index same repos; compare tray default list top-to-bottom vs `workpot list`". This is documented as optional/informational in 06-VALIDATION.md, not a phase gate. Automated equivalence is proven by the ported golden-vector tests. + +--- + +### Gaps Summary + +No gaps. All three ROADMAP success criteria are achieved: + +1. SC1 (`workpot list` order parity) — implemented in `list_display.rs` + `main.rs::run_list`; proven by 11 ported Rust tests. +2. SC2 (`workpot search` fuzzy parity) — implemented in `repo_fuzzy.rs` + `main.rs::run_search`; proven by 27-row golden vector table plus `search_filters_by_fuzzy_query` and `search_empty_query_equals_list` integration tests. +3. SC3 (`workpot open` Cursor launch) — implemented with shared `workpot-core` launch service; tray delegates via `pub use`; proven by 4 open smoke tests. + +All 10 commits documented in SUMMARYs are confirmed in git log. Full workspace test suite is green. + +--- + +_Verified: 2026-05-31T20:00:00Z_ +_Verifier: Claude (gsd-verifier)_ diff --git a/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/.gitkeep b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-01-PLAN.md b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-01-PLAN.md new file mode 100644 index 0000000..c35bbe8 --- /dev/null +++ b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-01-PLAN.md @@ -0,0 +1,132 @@ +--- +phase: 06.1-release-distribution-and-install-github-release-tarballs-sta +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - .github/workflows/release.yml + - .github/workflows/release-artifacts.yml + - .github/workflows/release-smoke.yml + - docs/releasing.md + - src-tauri/tauri.conf.json +autonomous: true +requirements: + - SC-01 + - SC-05 +must_haves: + truths: + - "Each published v* release ships only aarch64 CLI tarball/checksum and an aarch64 DMG (D-14, D-15)." + - "Signing/notarization is applied when Apple credentials exist and falls back to unsigned artifacts with explicit warning when absent (D-18, D-19)." + - "Maintainer release instructions and smoke workflow validate the same release artifact contract used by production release jobs." + artifacts: + - path: ".github/workflows/release.yml" + provides: "Canonical artifact matrix, DMG build/upload contract, and signed/unsigned branch policy" + - path: ".github/workflows/release-smoke.yml" + provides: "PR-time verification of release contract" + - path: "docs/releasing.md" + provides: "Maintainer instructions aligned with DMG + installer flow" + key_links: + - from: ".github/workflows/release-artifacts.yml" + to: ".github/workflows/release.yml" + via: "workflow_call with release tag" + pattern: "uses: ./.github/workflows/release.yml" + - from: "docs/releasing.md" + to: ".github/workflows/release*.yml" + via: "documented maintainer flow" + pattern: "release-artifacts|release-smoke|dmg|install.sh" +--- + + +As a Workpot macOS maintainer, I want a single release pipeline contract for DMG + CLI artifacts, so that every published release is installable and verifiable without manual triage (D-11, D-13, D-14, D-15, D-18, D-19). + +Purpose: lock release outputs and signing behavior before installer/update implementation. +Output: updated release workflows and maintainer docs that define and validate artifact naming, architecture scope, and fallback behavior. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-CONTEXT.md +@.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-RESEARCH.md +@.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-PATTERNS.md + + + + + + Task 1: Enforce aarch64-only release artifact matrix and DMG naming + .github/workflows/release.yml, .github/workflows/release-artifacts.yml, src-tauri/tauri.conf.json, .planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-CONTEXT.md + .github/workflows/release.yml, .github/workflows/release-artifacts.yml, src-tauri/tauri.conf.json + Update release workflow matrix and artifact generation to publish only `workpot-macos-aarch64.tar.gz` + `.sha256` and a versioned aarch64 DMG filename per D-13/D-14/D-15, while preserving existing release trigger and upload topology. Define and publish an explicit DMG checksum artifact contract (`Workpot--aarch64.dmg.sha256`) in the same upload step so downstream installer/updater verification can consume it per D-17. Ensure DMG packaging path reflects D-11 layout expectations (app plus drag target). Remove any x86_64 artifact generation branches from this phase scope. + + rg -n "x86_64|macos-15-intel|workpot-macos-x86_64" .github/workflows/release*.yml | wc -l + rg -n "workpot-macos-aarch64\\.tar\\.gz|workpot-macos-aarch64\\.tar\\.gz\\.sha256|Workpot-.*-aarch64\\.dmg|Workpot-.*-aarch64\\.dmg\\.sha256" .github/workflows/release.yml .github/workflows/release-artifacts.yml + + Release workflows reference only aarch64 artifacts for Phase 06.1 and include deterministic DMG filename + DMG checksum filename contracts with version+arch. + Workflow diff proves no x86_64 release artifacts are emitted and DMG + DMG checksum contracts are explicit and machine-checkable. + + + + Task 2: Add secret-aware signing/notarization fallback policy + .github/workflows/release.yml, docs/releasing.md, .planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-RESEARCH.md + .github/workflows/release.yml, docs/releasing.md + Implement CI branching so DMG/app signing+notarization+stapling runs when Apple secrets are present (D-19), and an unsigned artifact path still uploads with explicit warning when secrets are missing (D-18). Keep failure semantics strict for partially completed signing steps and document expected warning text and maintainer interpretation in docs/releasing.md. + + rg -n "APPLE_|notarytool|stapler|codesign|unsigned" .github/workflows/release.yml docs/releasing.md + + Signed path and unsigned fallback are both explicit, mutually exclusive, and documented for maintainers. + Maintainers can determine from docs and workflow logs whether a release was signed/notarized or unsigned-by-design. + + + + Task 3: Make smoke and maintainer docs enforce the same artifact contract + .github/workflows/release-smoke.yml, docs/releasing.md, .planning/ROADMAP.md + .github/workflows/release-smoke.yml, docs/releasing.md + Update release smoke checks and maintainer checklist so they validate DMG + installer presence and aarch64-only outputs per SC-05, D-14, and D-16. Add explicit checklist language tying `release-smoke`, `release-artifacts`, and installer URL publication to the same release tag contract. + + rg -n "install\\.sh|dmg|aarch64|release-smoke|release-artifacts" docs/releasing.md .github/workflows/release-smoke.yml + + Maintainer documentation and smoke workflow assert identical artifact expectations and mention installer publication flow. + Maintainer can execute release with one checklist that validates installer + DMG outputs before announcing a version. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| GitHub release event -> CI artifact jobs | Untrusted trigger payload and repo state drive release build/upload behavior | +| CI secret store -> signing steps | Sensitive signing credentials cross into build runtime | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-06.1-01 | Tampering | `.github/workflows/release.yml` artifact set | mitigate | Encode exact aarch64 artifact names and verify in smoke workflow; fail if contract drifts | +| T-06.1-02 | Elevation | Signing/notarization branch | mitigate | Gate signed path on explicit secret presence and avoid partial privileged operations | +| T-06.1-03 | Repudiation | Maintainer release process | mitigate | Document signed vs unsigned outcomes and checklist evidence in `docs/releasing.md` | +| T-06.1-SC | Tampering | release asset publication/install chain | mitigate | Require checksum artifacts and smoke coverage for installer/DMG contract continuity | + + + +- `rg -n "x86_64|macos-15-intel|workpot-macos-x86_64" .github/workflows/release*.yml` returns no active release artifact hits. +- `rg -n "aarch64|dmg|install.sh|release-smoke" docs/releasing.md .github/workflows/release*.yml` confirms aligned contract. + + + +- SC-01 and SC-05 prerequisites are codified: release job emits required artifacts and maintainer flow is contract-driven. +- Locked decisions D-11, D-13, D-14, D-15, D-16, D-18, and D-19 are implemented or explicitly referenced in runnable workflow/docs logic. + + + +Create `.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-01-SUMMARY.md` when done + diff --git a/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-01-SUMMARY.md b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-01-SUMMARY.md new file mode 100644 index 0000000..c0af09a --- /dev/null +++ b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-01-SUMMARY.md @@ -0,0 +1,123 @@ +--- +phase: 06.1-release-distribution-and-install-github-release-tarballs-sta +plan: 01 +subsystem: release +tags: [github-actions, tauri, dmg, notarization, checksums] +requires: + - phase: 6-cli-parity + provides: CLI release-ready binary and shared versioning flow +provides: + - aarch64-only release artifact contract for CLI tarball + DMG + - secret-aware signed/unsigned DMG policy with strict partial-secret failure + - smoke workflow checks that enforce release artifact filenames/checksums +affects: [06.1-02, 06.1-03, installer, updater, releasing-docs] +tech-stack: + added: [] + patterns: + - explicit artifact filename contracts in CI + - secret-presence branching for signing/notarization +key-files: + created: + - .planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-01-SUMMARY.md + modified: + - .github/workflows/release.yml + - .github/workflows/release-artifacts.yml + - .github/workflows/release-smoke.yml + - docs/releasing.md + - src-tauri/tauri.conf.json +key-decisions: + - "Release contract is aarch64-only with deterministic CLI + DMG checksum names." + - "DMG signing path requires full APPLE_* secret set; zero secrets falls back to unsigned with explicit warning; partial secrets hard-fail." +patterns-established: + - "Artifact contract pattern: whitelist exact expected files in smoke and release docs." + - "Release policy pattern: signed/unsigned branches must be explicit and mutually exclusive." +requirements-completed: [SC-01, SC-05] +duration: 2 min +completed: 2026-05-31 +--- + +# Phase 06.1 Plan 01: Release contract summary + +**Locked the release pipeline to aarch64 CLI + DMG artifacts with deterministic checksum contracts, secret-aware signing fallback, and smoke/docs enforcement of the same tag contract.** + +## Performance + +- **Duration:** 2 min +- **Started:** 2026-05-31T17:44:57Z +- **Completed:** 2026-05-31T17:46:49Z +- **Tasks:** 3 +- **Files modified:** 5 + +## Accomplishments + +- Converted release build outputs to aarch64-only and added deterministic DMG + checksum naming. +- Added strict APPLE secret policy: signed/notarized when complete, unsigned with warning when absent, hard-fail on partial secret sets. +- Added smoke artifact-contract validation and updated maintainer release checklist to tie `release-smoke`, `release-artifacts`, and installer URL publication to one tag. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Enforce aarch64-only release artifact matrix and DMG naming** - `7d39911` (feat) +2. **Task 2: Add secret-aware signing/notarization fallback policy** - `c66477b` (feat) +3. **Task 3: Make smoke and maintainer docs enforce the same artifact contract** - `16b0035` (feat) +4. **Task 3 follow-up fix (verification hardening)** - `79916f1` (fix) + +**Plan metadata:** pending + +## Files Created/Modified + +- `.github/workflows/release.yml` - aarch64 artifact matrix, DMG checksum contract, signed/unsigned branching policy. +- `.github/workflows/release-artifacts.yml` - clarified artifact upload scope for CLI + DMG contract. +- `.github/workflows/release-smoke.yml` - added post-build contract verification against exact expected smoke artifact names. +- `docs/releasing.md` - maintainer artifact/signing policy and release tag contract checklist including installer URL publication. +- `src-tauri/tauri.conf.json` - enabled DMG bundle target for release packaging. + +## Decisions Made + +- Implemented deterministic release names (`workpot-macos-aarch64.tar.gz`, `Workpot--aarch64.dmg`) so installer/update steps can consume a stable contract. +- Enforced fail-closed behavior for partial APPLE signing secrets to prevent mixed-trust release outputs. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] Removed legacy x86 marker from smoke workflow** +- **Found during:** Plan-level verification after Task 3 +- **Issue:** Smoke check used `x86_64` string in a negative assertion, violating the plan verification that expects no x86 identifiers in release workflows. +- **Fix:** Replaced pattern exclusion with explicit artifact whitelist validation. +- **Files modified:** `.github/workflows/release-smoke.yml` +- **Verification:** `rg "x86_64|macos-15-intel|workpot-macos-x86_64" .github/workflows/release*.yml` returns no matches. +- **Committed in:** `79916f1` + +--- + +**Total deviations:** 1 auto-fixed (1 Rule 1 bug) +**Impact on plan:** No scope creep; fix tightened the artifact-contract guardrail. + +## Authentication Gates + +None. + +## Issues Encountered + +None. + +## Known Stubs + +None. + +## Next Phase Readiness + +- Release artifact and signing contract is now explicit and machine-checkable. +- Ready for `06.1-02` (`workpot update`) to consume the finalized artifact naming/checksum policy. + +## Verification Evidence + +- `rg "x86_64|macos-15-intel|workpot-macos-x86_64" .github/workflows/release*.yml` -> no matches. +- `rg "aarch64|dmg|install\\.sh|release-smoke" docs/releasing.md .github/workflows/release*.yml` -> expected contract references present. + +## Self-Check: PASSED + +- `06.1-01-SUMMARY.md` exists at the expected path. +- Task commits `7d39911`, `c66477b`, `16b0035`, and `79916f1` resolve in git history. diff --git a/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-02-PLAN.md b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-02-PLAN.md new file mode 100644 index 0000000..d8ba8a2 --- /dev/null +++ b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-02-PLAN.md @@ -0,0 +1,149 @@ +--- +phase: 06.1-release-distribution-and-install-github-release-tarballs-sta +plan: 02 +type: tdd +wave: 2 +depends_on: + - 06.1-01 +files_modified: + - crates/workpot-cli/src/main.rs + - crates/workpot-cli/src/update.rs + - crates/workpot-cli/tests/update_smoke.rs +autonomous: true +requirements: + - SC-03 +must_haves: + truths: + - "Running `workpot update` updates installed CLI/tray by default and supports `--only-cli`, `--only-tray`, and `--global` flags (D-06, D-07)." + - "`workpot update` exits 0 on success or already-current, 1 on permission/install/running-app failures, and 2 on network or GitHub API failures (D-08, D-09, D-23)." + - "Update path verifies downloaded release assets against published checksums before replacement and leaves current install untouched on failure (D-09, D-17)." + artifacts: + - path: "crates/workpot-cli/src/update.rs" + provides: "Update command service and exit-category mapping" + - path: "crates/workpot-cli/src/main.rs" + provides: "`update` subcommand wiring to CLI surface" + - path: "crates/workpot-cli/tests/update_smoke.rs" + provides: "Executable contract tests for success/current/failure semantics" + key_links: + - from: "crates/workpot-cli/src/main.rs" + to: "crates/workpot-cli/src/update.rs" + via: "clap subcommand dispatch" + pattern: "Commands::Update" + - from: "crates/workpot-cli/src/update.rs" + to: "GitHub Releases assets" + via: "latest release fetch and checksum verification" + pattern: "releases/latest|sha256" +--- + + +As a Workpot CLI user, I want `workpot update` to safely update installed components with explicit exit semantics, so that I can self-serve upgrades without manual binary replacement (D-06, D-07, D-08, D-09, D-10, D-17, D-23). + +Purpose: deliver deterministic updater behavior before shipping installer-driven adoption. +Output: tested update command with strict error taxonomy and checksum-verified replacement flow. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-CONTEXT.md +@.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-RESEARCH.md +@.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-01-SUMMARY.md + + + + + + Task 1 (RED): Codify update behavior contract with failing smoke tests + crates/workpot-cli/tests/cli_smoke.rs, crates/workpot-cli/src/main.rs, .planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-CONTEXT.md + crates/workpot-cli/tests/update_smoke.rs + + - Test 1: default `workpot update` targets both CLI and tray paths by presence detection (D-06, D-10). + - Test 2: `--only-cli`, `--only-tray`, and `--global` alter target selection deterministically (D-07, D-20). + - Test 3: already-current case returns exit code 0 without download (D-08). + - Test 4: network/API failures return exit code 2; permission/install failures and running-tray replacement failure return exit code 1 (D-09, D-23). + - Test 5: checksum mismatch fails closed and preserves existing install (D-09, D-17). + + Create `update_smoke.rs` with fixture-driven tests that intentionally fail against current codebase by asserting the exact D-06..D-10/D-17/D-23 contract. Stub external release responses through deterministic test fixtures/mocks so tests run offline and reproducibly. + + bash -c '! cargo test -p workpot-cli --test update_smoke -- --nocapture' + + Test file compiles and fails on at least one contract assertion before update implementation exists. + RED state is proven with failing tests that describe full updater behavior surface. + + + + Task 2 (GREEN): Implement update command and exit-code mapping + crates/workpot-cli/src/main.rs, crates/workpot-cli/tests/update_smoke.rs, scripts/latest-released-version.sh + crates/workpot-cli/src/main.rs, crates/workpot-cli/src/update.rs + + - `workpot update` default updates both install targets unless narrowed by flags (D-06, D-07). + - Presence-based detection decides what is installed and updateable without a manifest file (D-10). + - Already-current returns exit code 0 with explicit user message (D-08). + - Running tray app update attempt returns exit code 1 and "quit Workpot first" guidance (D-23). + - Network/API errors return 2; all mutation/permission failures return 1; success returns 0 (D-09). + + Add `update` clap subcommand in `main.rs` and implement `update.rs` service using latest release metadata only (D-05). Enforce checksum verification before replace (D-17), stage all downloads in temp paths, and preserve existing installs on failure (D-09). Implement global path behavior using `/usr/local/bin/workpot` and `/Applications/Workpot.app` when `--global` is selected (D-20). + + cargo test -p workpot-cli --test update_smoke -- --nocapture + cargo test -p workpot-cli --all-targets + + All RED tests pass and updater CLI interface is available in `workpot --help` with required flags and exit behavior. + GREEN state reached: updater implementation satisfies contract tests without relaxing assertions. + + + + Task 3 (REFACTOR): Harden error taxonomy and replacement internals + crates/workpot-cli/src/update.rs, crates/workpot-cli/tests/update_smoke.rs, crates/workpot-cli/src/main.rs + crates/workpot-cli/src/update.rs, crates/workpot-cli/tests/update_smoke.rs + + - Exit code mapping remains stable after refactor (0/1/2 only per D-09). + - Error messages remain actionable for offline, permission, and running-tray scenarios. + - Asset verification and replacement steps remain atomic and no-op safe when current. + + Refactor updater internals into clear stages (discover current install, fetch metadata, verify assets, replace target) without changing external behavior. Remove duplication and add focused unit-level helpers only where they improve failure clarity and preserve D-09 guarantees. + + cargo test -p workpot-cli --test update_smoke -- --nocapture + cargo test -p workpot-cli --all-targets + + All tests remain green with cleaner internal structure and unchanged CLI contract. + REFACTOR state completed with no behavior regressions and maintainable updater stage boundaries. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Local updater -> GitHub Releases API | Untrusted network metadata influences downloaded binary/app assets | +| Download staging -> install target path | Untrusted artifact bytes cross into executable/install locations | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-06.1-04 | Tampering | `crates/workpot-cli/src/update.rs` asset download path | mitigate | Enforce SHA-256 validation against published checksum before any replace step (D-17) | +| T-06.1-05 | DoS | updater replace flow | mitigate | Stage to temp and abort on failures while preserving existing install (D-09) | +| T-06.1-06 | Elevation | global install targets | mitigate | Use explicit `--global` gate and clear permission error mapping to exit code 1 (D-20, D-09) | +| T-06.1-SC | Tampering | release asset publication/install chain | mitigate | Keep updater tests asserting checksum mismatch hard-fail and unchanged local install | + + + +- `cargo test -p workpot-cli --test update_smoke -- --nocapture` +- `cargo test -p workpot-cli --all-targets` + + + +- SC-03 is fully satisfied with tested contract semantics and locked decisions D-05 through D-10, D-17, D-20, D-23. +- Updater remains safe under failure, no-op on current version, and explicit about error classes. + + + +Create `.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-02-SUMMARY.md` when done + diff --git a/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-02-SUMMARY.md b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-02-SUMMARY.md new file mode 100644 index 0000000..ed7f908 --- /dev/null +++ b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-02-SUMMARY.md @@ -0,0 +1,113 @@ +--- +phase: 06.1-release-distribution-and-install-github-release-tarballs-sta +plan: 02 +subsystem: cli-updater +tags: [tdd, updater, github-releases, checksums, exit-codes] +requires: + - phase: 06.1 + plan: 01 + provides: release artifact/checksum contract +provides: + - `workpot update` CLI surface with `--only-cli`, `--only-tray`, `--global` + - strict update exit taxonomy (0 success/current, 1 install/permission/running app, 2 network/api) + - checksum-verified staged replacement flow for CLI and tray assets +affects: [06.1-03, INSTALL.md, release-operations] +tech-stack: + added: + - reqwest (blocking/json/rustls) + - serde + serde_json + - tempfile + - sha2 + patterns: + - tdd red/green/refactor commit gates + - staged download->verify->replace pipeline +key-files: + created: + - crates/workpot-cli/src/update.rs + - crates/workpot-cli/tests/update_smoke.rs + modified: + - crates/workpot-cli/src/main.rs + - crates/workpot-cli/Cargo.toml + - Cargo.lock +key-decisions: + - Keep updater network failures as category `Network` and local mutation failures as `Install` for explicit 2 vs 1 exit mapping. + - Reuse one checksum verification path for both CLI tarball and tray DMG to enforce fail-closed semantics. +requirements-completed: [SC-03] +duration: 26 min +completed: 2026-05-31 +--- + +# Phase 06.1 Plan 02: Update command contract summary + +**Implemented a production `workpot update` command that resolves latest GitHub release assets, verifies checksums before mutation, and enforces deterministic exit semantics across success/current/network/install/running-app paths.** + +## Performance + +- **Duration:** 26 min +- **Tasks:** 3 (RED, GREEN, REFACTOR) +- **Files modified:** 5 + +## Accomplishments + +- Added `update` subcommand in `workpot` CLI with `--only-cli`, `--only-tray`, and `--global`. +- Implemented updater service with presence-based target selection, latest-release metadata fetch, checksum validation, and staged replacement flow. +- Added contract smoke tests for target selection, no-op current behavior, exit taxonomy, and checksum mismatch fail-closed behavior. +- Refactored updater internals into explicit stages (`download_verified_asset`, per-target pipeline functions) without changing external behavior. + +## Task Commits + +1. **Task 1 (RED): Codify failing updater contract tests** — `9736e66` (`test`) +2. **Task 2 (GREEN): Implement update subcommand and updater service** — `b6004ca` (`feat`) +3. **Task 3 (REFACTOR): Harden staged internals without behavior change** — `718e93f` (`refactor`) + +## Files Created/Modified + +- `crates/workpot-cli/tests/update_smoke.rs` — new offline fixture-driven updater contract tests. +- `crates/workpot-cli/src/main.rs` — subcommand wiring and exit-code mapping for updater failure categories. +- `crates/workpot-cli/src/update.rs` — updater implementation (presence detection, release fetch, checksum verify, staged replacement, tray running check). +- `crates/workpot-cli/Cargo.toml` / `Cargo.lock` — runtime dependencies required by updater. + +## Decisions Made + +- Mapped updater failures into two explicit categories (`Install` and `Network`) so `main()` can enforce D-09 exit code guarantees centrally. +- Enforced a single verification primitive (`verify_checksum`) before replacement in both CLI and tray paths to satisfy D-17 and threat mitigations. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking issue] RED tests initially failed at compile-time instead of behavioral assertions** +- **Found during:** Task 1 (RED verification) +- **Issue:** Test helper imported an unresolved `sha2` crate before updater dependencies existed, blocking RED gate quality. +- **Fix:** Replaced checksum generation in test fixture helper with `shasum -a 256` command output parsing. +- **Files modified:** `crates/workpot-cli/tests/update_smoke.rs` +- **Commit:** `9736e66` + +--- + +**Total deviations:** 1 auto-fixed (1 Rule 3 blocking issue) +**Impact on plan:** No scope increase; improved RED gate fidelity. + +## Authentication Gates + +None. + +## Known Stubs + +None. + +## Threat Flags + +None. + +## Verification Evidence + +- `bash -c '! cargo test -p workpot-cli --test update_smoke -- --nocapture'` (RED gate) succeeded by proving failing contract tests before implementation. +- `cargo test -p workpot-cli --test update_smoke -- --nocapture` passed after GREEN and after REFACTOR. +- `cargo test -p workpot-cli --all-targets` passed after GREEN and after REFACTOR. +- `cargo run -p workpot-cli -- --help` shows `update` subcommand. + +## Self-Check: PASSED + +- `06.1-02-SUMMARY.md` exists at `.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-02-SUMMARY.md`. +- Task commits `9736e66`, `b6004ca`, and `718e93f` resolve in git history. diff --git a/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-03-PLAN.md b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-03-PLAN.md new file mode 100644 index 0000000..e53761d --- /dev/null +++ b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-03-PLAN.md @@ -0,0 +1,136 @@ +--- +phase: 06.1-release-distribution-and-install-github-release-tarballs-sta +plan: 03 +type: execute +wave: 2 +depends_on: + - 06.1-01 +files_modified: + - scripts/install.sh + - scripts/tests/install_smoke.sh + - INSTALL.md + - README.md +autonomous: true +requirements: + - SC-02 + - SC-04 +must_haves: + truths: + - "One-line installer defaults to installing both CLI and tray with latest release artifacts only (D-01, D-04, D-05)." + - "Installer supports `--only-cli`, `--only-tray`, and `--global`, with standard user and global install paths and clear next-step output (D-02, D-03, D-20, D-22)." + - "Installer verifies checksums for tarball and DMG and fails closed on mismatch without partial install mutation (D-17)." + - "`INSTALL.md` gives equal prominence to DMG and script install paths and documents uninstall/PATH behavior (D-12, D-16, D-21)." + artifacts: + - path: "scripts/install.sh" + provides: "macOS end-user install entrypoint" + - path: "scripts/tests/install_smoke.sh" + provides: "automated installer behavior verification" + - path: "INSTALL.md" + provides: "end-user install/update/uninstall/PATH guide" + - path: "README.md" + provides: "discoverable pointer to INSTALL.md" + key_links: + - from: "scripts/install.sh" + to: "GitHub Release assets" + via: "latest release lookup and asset download" + pattern: "releases/latest|workpot-macos-aarch64|Workpot-.*aarch64\\.dmg" + - from: "INSTALL.md" + to: "scripts/install.sh" + via: "documented convenience and versioned URLs" + pattern: "raw.githubusercontent.com|install.sh" +--- + + +As a macOS Workpot user, I want a single installer command and clear install docs, so that I can install or remove CLI+tray without manual artifact handling (D-01, D-02, D-03, D-04, D-12, D-16, D-21, D-22). + +Purpose: ship the user-facing install and docs surface built on the release contract from Plan 01. +Output: installer script, installer smoke checks, and end-user documentation. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-CONTEXT.md +@.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-RESEARCH.md +@.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-01-SUMMARY.md + + + + + + Task 1: Implement install script with flag matrix, path policy, and checksum verification + scripts/sync-version.sh, scripts/latest-released-version.sh, .planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-CONTEXT.md + scripts/install.sh + Create `scripts/install.sh` with strict mode and latest-release-only asset selection (D-05). Implement default both-components behavior plus `--only-cli`/`--only-tray` (D-01), user paths `~/.local/bin/workpot` and `~/Applications/Workpot.app` (D-02, D-03), and global paths `/usr/local/bin/workpot` and `/Applications/Workpot.app` on `--global` (D-20). Install tray from DMG mount/copy flow (D-04) and verify tarball/DMG against release `.sha256` before install mutation (D-17). Print next steps/PATH hints only (D-22). + + bash scripts/install.sh --help + rg -n "only-cli|only-tray|global|~/.local/bin|~/Applications|/usr/local/bin|/Applications|sha256|hdiutil" scripts/install.sh + + Installer exposes required flags, paths, and checksum guardrails aligned with D-01..D-05, D-17, D-20, and D-22. + Script can be invoked non-interactively and reflects all locked install decisions. + + + + Task 2: Add installer smoke coverage for default/flag/global and checksum-failure paths + scripts/install.sh, crates/workpot-cli/tests/cli_smoke.rs, .planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-RESEARCH.md + scripts/tests/install_smoke.sh + Create a deterministic smoke script that validates parser/flow contracts for default install, `--only-cli`, `--only-tray`, `--global`, and checksum mismatch fail-closed behavior (D-01, D-07 parity expectation, D-17). Include an explicit SC-02 assertion that installed `workpot --version` equals the release version under test, sourced from mocked release metadata used by the same smoke run. Mock network/release metadata within temp directories so the smoke command runs in CI and local dev without publishing real releases. + + bash scripts/tests/install_smoke.sh + bash scripts/tests/install_smoke.sh --assert-version-match + + Smoke script exits zero when contract is respected, including installed-version == release-version, and fails when checksum or flag behavior regresses. + Installer has automated regression protection for all user-facing install path permutations and version-alignment checks in scope. + + + + Task 3: Publish user installation guide with equal DMG and script prominence + docs/releasing.md, README.md, .planning/ROADMAP.md, .planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-CONTEXT.md + INSTALL.md, README.md + Create `INSTALL.md` covering script install (convenience + versioned release URL per D-16), manual DMG install path with equal prominence (D-12), `workpot update` usage contract, uninstall steps (D-21), and PATH troubleshooting for user/global installs (D-02, D-20). Update `README.md` to link users to `INSTALL.md` as the primary install reference. + + rg -n "curl -fsSL|install.sh|dmg|update|uninstall|PATH|raw.githubusercontent.com|Releases" INSTALL.md README.md + + User docs are self-sufficient for install/update/uninstall flows without requiring `docs/releasing.md`. + SC-04 is met with equal-priority DMG/script install guidance and explicit PATH/uninstall coverage. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| User shell -> remote release asset downloads | Untrusted network artifacts enter local machine install paths | +| Installer staging -> local executable/app paths | Potentially tampered payload crosses into executable locations | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-06.1-07 | Tampering | `scripts/install.sh` download pipeline | mitigate | Require checksum verification for both CLI tarball and DMG before extraction/copy (D-17) | +| T-06.1-08 | Elevation | `--global` install path writes | mitigate | Require explicit `--global`; fail fast with clear permission guidance and no partial writes (D-20) | +| T-06.1-09 | DoS | incomplete install on failures | mitigate | Stage in temp directories and only mutate target paths after verification passes | +| T-06.1-SC | Tampering | release asset publication/install chain | mitigate | Add installer smoke checks that assert checksum mismatch failure and flag/path contract stability | + + + +- `bash scripts/install.sh --help` +- `bash scripts/tests/install_smoke.sh` +- `rg -n "install|update|uninstall|PATH|dmg|install.sh" INSTALL.md README.md` + + + +- SC-02 and SC-04 are fully covered through executable installer behavior and user-facing docs. +- Locked decisions D-01..D-05, D-12, D-16, D-17, D-20, D-21, and D-22 are represented in script or documentation outputs. + + + +Create `.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-03-SUMMARY.md` when done + diff --git a/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-03-SUMMARY.md b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-03-SUMMARY.md new file mode 100644 index 0000000..716c122 --- /dev/null +++ b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-03-SUMMARY.md @@ -0,0 +1,130 @@ +--- +phase: 06.1-release-distribution-and-install-github-release-tarballs-sta +plan: 03 +subsystem: release +tags: [installer, dmg, checksum, docs, smoke-tests] +requires: + - phase: 06.1-01 + provides: aarch64 release artifact and checksum naming contract +provides: + - macOS installer script with latest-release asset resolution and checksum verification + - deterministic installer smoke coverage for default, flag, global, and checksum-fail flows + - end-user INSTALL.md with equal script/DMG install prominence plus update and uninstall guidance +affects: [06.1-02, install-surface, release-docs] +tech-stack: + added: [] + patterns: + - checksum-first staging before install target mutation + - fixture-driven shell smoke tests with local release metadata +key-files: + created: + - scripts/tests/install_smoke.sh + - INSTALL.md + - .planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-03-SUMMARY.md + modified: + - scripts/install.sh + - README.md +key-decisions: + - "Installer consumes only latest GitHub release metadata and enforces checksum validation for both CLI tarball and DMG before install writes." + - "Smoke coverage uses local release fixtures and command shims so all install contract checks run without published releases or privileged writes." +patterns-established: + - "Installer contract pattern: parse release assets by exact/regex names and fail closed when any required asset is missing." + - "Install test pattern: validate user/global paths and checksum-failure behavior through isolated temp HOME fixtures." +requirements-completed: [SC-02, SC-04] +duration: 9 min +completed: 2026-05-31 +--- + +# Phase 06.1 Plan 03: Installer and user docs summary + +**Shipped a macOS installer + smoke verification surface that installs CLI/tray from latest GitHub release assets with checksum guards, and published user-facing install/update/uninstall docs with equal DMG and script paths.** + +## Performance + +- **Duration:** 9 min +- **Started:** 2026-05-31T17:49:05Z +- **Completed:** 2026-05-31T17:58:01Z +- **Tasks:** 3 +- **Files modified:** 4 + +## Accomplishments + +- Added `scripts/install.sh` with `--only-cli`, `--only-tray`, and `--global`, plus user/global install path policy. +- Enforced checksum verification for CLI tarball and DMG before extraction/copy, with staged install behavior. +- Added deterministic installer smoke tests and published `INSTALL.md` with install/update/uninstall/PATH guidance; linked from `README.md`. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Implement install script with flag matrix, path policy, and checksum verification** - `558bc5b` (feat) +2. **Task 2: Add installer smoke coverage for default/flag/global and checksum-failure paths** - `aed09de` (test) +3. **Task 3: Publish user installation guide with equal DMG and script prominence** - `5a0288b` (docs) + +**Plan metadata:** pending + +## Files Created/Modified + +- `scripts/install.sh` - latest-release installer with asset selection, checksum verification, DMG mount/copy flow, and next-step output. +- `scripts/tests/install_smoke.sh` - local fixture-driven smoke script covering default, flag matrix, global mode, checksum mismatch, and optional version assertion. +- `INSTALL.md` - user-first install/update/uninstall/PATH guide with equal script and DMG prominence. +- `README.md` - primary pointer to `INSTALL.md`. + +## Decisions Made + +- Used environment-injected release JSON (`WORKPOT_RELEASE_JSON`) in installer tests to keep smoke checks deterministic and offline. +- Kept global mode verification safe in smoke tests via command shims instead of touching real `/usr/local/bin` or `/Applications`. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] Fixed sudo escalation on writable temp user installs** +- **Found during:** Task 2 (installer smoke execution) +- **Issue:** `run_with_sudo_if_needed` escalated to `sudo` when intermediate parent directories did not exist yet, blocking non-global installs in temp HOME. +- **Fix:** Updated parent writability detection to walk up to the nearest existing parent and only escalate when truly required. +- **Files modified:** `scripts/install.sh` +- **Verification:** `bash scripts/tests/install_smoke.sh` now passes default and flag scenarios without sudo prompts. +- **Committed in:** `aed09de` + +**2. [Rule 1 - Bug] Fixed trap cleanup under `set -u`** +- **Found during:** Task 2 (smoke rerun after first fix) +- **Issue:** EXIT trap referenced local variables that were out of scope at shell exit, causing unbound-variable failures. +- **Fix:** Promoted trap-tracked temp variables to non-local scope in installer and smoke scripts. +- **Files modified:** `scripts/install.sh`, `scripts/tests/install_smoke.sh` +- **Verification:** `bash scripts/tests/install_smoke.sh && bash scripts/tests/install_smoke.sh --assert-version-match` exits cleanly. +- **Committed in:** `aed09de` + +--- + +**Total deviations:** 2 auto-fixed (2 Rule 1 bugs) +**Impact on plan:** Fixes were required for installer correctness and smoke stability; no scope creep. + +## Authentication Gates + +None. + +## Issues Encountered + +None beyond the two auto-fixed installer bugs documented above. + +## Known Stubs + +None. + +## Next Phase Readiness + +- Installer and docs contract is in place for end-user install/update guidance. +- `06.1-02` update flow can rely on the same release asset naming/checksum policy already exercised by smoke fixtures. + +## Verification Evidence + +- `bash scripts/install.sh --help` +- `bash scripts/tests/install_smoke.sh` +- `bash scripts/tests/install_smoke.sh --assert-version-match` +- `rg -n "install|update|uninstall|PATH|dmg|install\\.sh" INSTALL.md README.md` + +## Self-Check: PASSED + +- `06.1-03-SUMMARY.md` exists at the expected phase path. +- Task commits `558bc5b`, `aed09de`, and `5a0288b` resolve in git history. diff --git a/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-ADD-TESTS.md b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-ADD-TESTS.md new file mode 100644 index 0000000..d9b5048 --- /dev/null +++ b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-ADD-TESTS.md @@ -0,0 +1,90 @@ +--- +phase: 06.1-release-distribution-and-install-github-release-tarballs-sta +created: 2026-06-05 +mode: auto +superseded_by: 07-review-distribution-strategy-homebrew-tap-cask-no-signed-dmg +--- + +# Phase 06.1 — Add Tests + +**Scope:** GitHub release artifacts, `install.sh`, `workpot update`, DMG signing (as shipped 2026-05-31) + +## Supersession (read first) + +Phase **7** (2026-06-04) removed the 06.1 end-user distribution surface: + +| Removed (06.1) | Replacement (07) | +|----------------|------------------| +| `scripts/install.sh` | Homebrew cask (`brew install rubenlr/workpot/workpot`) | +| `scripts/tests/install_smoke.sh` | — (manual `brew install` per 07-UAT) | +| `crates/workpot-cli/src/update.rs` | `brew upgrade` | +| `crates/workpot-cli/tests/update_smoke.rs` | — | +| DMG + `APPLE_*` signing in CI | Unsigned `Workpot--aarch64.tar.gz` + tap auto-update | + +**Do not restore** 06.1 installer/updater tests on current `master` — they assert removed behavior. + +Regression coverage for “don’t bring 06.1 back” lives in **`crates/workpot-cli/tests/distribution_contract.rs`** (phase 7 add-tests, 11 → 13 cases after this pass). + +## Original 06.1 automated proof (historical) + +| Artifact | Tests (at 06.1 verify) | Status now | +|----------|------------------------|------------| +| `update.rs` | `update_smoke.rs` (8) | Deleted | +| `install.sh` | `install_smoke.sh` | Deleted | +| `release.yml` / smoke | CI + static greps | **Retained** (tarball-only contract) | +| `INSTALL.md` | docs grep | **Retained** (Homebrew-only) | + +## Classification (approved — auto) + +### TDD / contract (current tree) + +| Target | Rationale | +|--------|-----------| +| `distribution_contract.rs` | Phase 7 regression suite; extended here for surviving 06.1 release wiring | +| `.github/workflows/release-artifacts.yml` | SC-05 published-release → `release.yml` | +| `docs/releasing.md` | SC-01/SC-05 maintainer tarball checklist | + +### E2E + +None — no browser surface. + +### Skip (removed code) + +| Target | Rationale | +|--------|-----------| +| `install.sh`, `install_smoke.sh` | D-11 removed in phase 7 | +| `update.rs`, `update_smoke.rs` | D-12 removed in phase 7 | +| DMG / notarization CI | D-01/D-14 removed; human-only was 06.1-HUMAN-UAT | + +## Tests added (this command) + +| File | Cases | +|------|-------| +| `crates/workpot-cli/tests/distribution_contract.rs` | +2 (`release_artifacts_*`, `releasing_md_*`) | + +## Commands + +```bash +cargo test -p workpot-cli --test distribution_contract +``` + +## Coverage gaps + +| Gap | Why | +|-----|-----| +| `install.sh` / `workpot update` behavior | Code deleted; covered by “must stay absent” tests in phase 7 | +| Signed/notarized DMG | 06.1-HUMAN-UAT #1 — Apple secrets + CI | +| Published release E2E | 06.1-HUMAN-UAT #2 — live GitHub Release | +| `brew install` on clean macOS | 07-UAT manual | + +## Results + +| Category | Generated | Passing | Failing | Blocked | +|----------|-----------|---------|---------|---------| +| Contract (Rust) | 2 | 2 | 0 | 0 | +| Restored 06.1 tests | 0 | — | — | — | +| E2E | 0 | — | — | — | + +## Bugs discovered + +None. diff --git a/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-CONTEXT.md b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-CONTEXT.md new file mode 100644 index 0000000..4e90f37 --- /dev/null +++ b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-CONTEXT.md @@ -0,0 +1,151 @@ +# Phase 06.1: Release & distribution - Context + +**Gathered:** 2026-05-31 +**Status:** Ready for planning + + +## Phase Boundary + +Ship a complete macOS release path so end users never hand-place binaries: GitHub Release artifacts (CLI tarball + signed/notarized per-arch DMG), `scripts/install.sh` one-liner, `workpot update` self-service, and user-facing `INSTALL.md`. Maintainer flow stays in `docs/releasing.md`. + +**Depends on:** Phase 6 (CLI parity) + +**Success criteria (ROADMAP.md):** +1. Every `v*` release publishes CLI tarball(s) + checksums and a signed/notarized aarch64 `.dmg` for the tray app +2. `curl -fsSL …/install.sh | bash` installs Workpot on macOS with correct `workpot --version` +3. `workpot update` upgrades from latest GitHub Release with clear exit codes +4. `INSTALL.md` covers install (script + manual tarball + DMG), update, uninstall/PATH without `docs/releasing.md` +5. `docs/releasing.md` references DMG + installer; CI smoke covers new artifacts where feasible + +**Out of scope (phase):** Tray in-app auto-update; Windows/Linux; recipes (Phase 7). + + + + +## Implementation Decisions + +### `install.sh` scope & behavior +- **D-01:** Default (no flags) installs **both** CLI and tray. Flags: `--only-cli`, `--only-tray`. +- **D-02:** CLI default path: `~/.local/bin/workpot` with PATH hint when missing. `--global` installs CLI (and tray when applicable) to system-wide locations for all users (see D-20). +- **D-03:** Tray default path: `~/Applications/Workpot.app` (no admin for default install). +- **D-04:** Tray artifact: download release **`.dmg`**, mount, copy `Workpot.app` out (same artifact family as GUI install path). +- **D-05:** Version pinning: **latest GitHub release only** for v1 (no `--version` / `WORKPOT_VERSION`). + +### `workpot update` +- **D-06:** Default updates **both** CLI and tray (same as install.sh default). +- **D-07:** Mirrors install.sh flags: `--only-cli`, `--only-tray`, `--global`. +- **D-08:** When installed version equals latest release: **exit 0** with “already up to date” (no download). +- **D-09:** Exit codes: **0** success or already-current; **1** permission / install failure; **2** network or GitHub API failure. Leave existing install untouched on failure. +- **D-10:** Detect what to update by **presence**: `~/.local/bin/workpot` (or global CLI path); `~/Applications/Workpot.app` (or global tray path). No install manifest file in v1. + +### DMG UX & release artifacts +- **D-11:** DMG layout: **Workpot.app + standard drag target** (Applications folder alias/README) — not app-only. +- **D-12:** **Equal prominence** in `INSTALL.md`: DMG path and `curl | bash` are both first-class; same release version. +- **D-13:** **Per-arch DMG** naming includes version, e.g. `Workpot-0.1.0-aarch64.dmg` (exact pattern at planner discretion; must be unambiguous on Releases page). +- **D-14:** **aarch64-only for this phase** — drop **all** x86_64 release artifacts (CLI tarballs and DMG). CI matrix and docs updated accordingly. +- **D-15:** CLI tarball remains aarch64-only: `workpot-macos-aarch64.tar.gz` + `.sha256` (align naming with existing release workflow where practical). + +### `install.sh` hosting & integrity +- **D-16:** Script lives at **`scripts/install.sh`**. Document **both** install URLs: convenience `raw.githubusercontent.com/.../main/scripts/install.sh` and **versioned** `install.sh` attached to each GitHub Release for reproducible installs. +- **D-17:** Downloaded release assets (tarball, DMG) must be verified against published **`.sha256`** checksums; fail closed on mismatch. + +### Signing & notarization +- **D-18:** If Apple signing secrets are absent, **ship unsigned** with clear log/README warning (best-effort signing — do not block fork/local experimentation). +- **D-19:** When secrets are present: **signed + notarized + stapled** `.app`/`.dmg` is the bar before upload to GitHub Releases. + +### Claude's discretion (skipped follow-ups — align with ROADMAP) +- **D-20:** `--global` paths: **`/usr/local/bin/workpot`** and **`/Applications/Workpot.app`**, using `sudo` when needed (standard macOS layout). +- **D-21:** **Uninstall:** `INSTALL.md` only — document removing CLI binary, `~/Applications` (or global) app, and optional config/data paths; no `workpot uninstall` subcommand in v1. +- **D-22:** **Post-install:** print next steps only (do not auto-open app or add Login Items in v1). +- **D-23:** **Tray running during update:** detect running Workpot; **exit 1** with instruction to quit from menu bar before replace (no silent kill). + +### Claude's discretion (implementation detail) +- Exact DMG window branding, `hdiutil` error messages, and retry policy in install/update scripts. +- Whether `install.sh` uses `bash` strict mode flags beyond `set -euo pipefail`. +- Global-path detection heuristics when both user and global installs exist (prefer explicit flags). + + + + +## Canonical References + +**Downstream agents MUST read these before planning or implementing.** + +### Phase scope +- `.planning/ROADMAP.md` — Phase 06.1 goal, success criteria, dependency on Phase 6 +- `.planning/PROJECT.md` — macOS-only v1, local-only, no cloud beyond GitHub +- `.planning/STATE.md` — current milestone focus + +### Release & version (maintainer) +- `docs/releasing.md` — version source of truth, `just version`, release-publish → release-artifacts flow +- `version` — repo-root semver (no `v` prefix) +- `justfile` — `version`, `version-check`, `build` (CLI + Tauri bundle) +- `scripts/sync-version.sh` — manifest sync +- `scripts/latest-released-version.sh` — tag semver helper +- `scripts/check-release-pr.sh` — release PR gate (referenced from docs/releasing.md) + +### CI workflows +- `.github/workflows/release-publish.yml` — tag + GitHub Release on version bump +- `.github/workflows/release-artifacts.yml` — triggers build on `release: published` +- `.github/workflows/release.yml` — macOS build/upload (matrix to become aarch64-only per D-14) +- `.github/workflows/release-smoke.yml` — PR dry-run builds +- `.github/workflows/ci.yml` — `release-build` job + +### CLI & Tauri touchpoints +- `crates/workpot-cli/src/main.rs` — add `update` subcommand; `#[command(version)]` must match synced version +- `src-tauri/tauri.conf.json` — bundle targets, macOS signing config +- [Tauri macOS signing](https://v2.tauri.app/distribute/sign/macos/) — notarization/staple expectations (D-19) + +### User docs (to create/update) +- `INSTALL.md` — end-user install/update/uninstall (new) +- `README.md` — link to INSTALL.md for downloads + + + + +## Existing Code Insights + +### Reusable assets +- **Release pipeline:** `release-publish` → `release-artifacts` → `release.yml` already builds and uploads `workpot-macos-{aarch64,x86_64}.tar.gz` + `.sha256`; extend for DMG and aarch64-only matrix (D-14). +- **Version sync:** `just version` / `scripts/sync-version.sh` already propagate `version` to CLI, core, Tauri, npm — install/update must read same semver as `workpot --version`. +- **Dev install:** `just install` runs `cargo install --path crates/workpot-cli` — not the end-user path; do not conflate with `install.sh`. +- **Latest tag helper:** `scripts/latest-released-version.sh` for comparing to `v*` tags. + +### Established patterns +- Manual version bump in same PR as ship (`docs/releasing.md`); no Release Please. +- macOS runners in CI; concurrency groups per release tag in `release.yml`. +- CLI smoke tests in `crates/workpot-cli/tests/cli_smoke.rs` — extend for `update` where testable without live GitHub. + +### Integration points +- New: `scripts/install.sh` (curl entrypoint). +- New: `workpot update` in CLI (GitHub Releases API + asset download + checksum verify + replace binary/app). +- CI: Tauri bundle + DMG creation on release; Apple secrets in GitHub Actions. +- Docs split: `INSTALL.md` (users) vs `docs/releasing.md` (maintainers). + + + + +## Specific Ideas + +- User explicitly deprioritized Intel: **no x86_64 DMG or CLI tarball** in this phase — simplify matrix and docs. +- DMG filename should include **version** (e.g. `Workpot-0.1.0-aarch64.dmg`), not only arch suffix. +- Default install is “full stack” (CLI + tray); power users use `--only-cli` or `--only-tray`. +- Tray via script uses **same DMG** as manual GUI install (mount-and-copy), not a separate `.app` tarball. + + + + +## Deferred Ideas + +- `workpot uninstall` subcommand — user did not discuss; v1 uses INSTALL.md steps only (D-21). +- `WORKPOT_VERSION` / pinned installs — deferred (D-05). +- x86_64 macOS support — deferred until explicitly reintroduced on roadmap. +- Tray auto-update inside the app — out of phase scope per ROADMAP. +- Auto-open app / Login Items after install — deferred (D-22). + + + +--- + +*Phase: 06.1-release-distribution* +*Context gathered: 2026-05-31* diff --git a/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-DISCUSSION-LOG.md b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-DISCUSSION-LOG.md new file mode 100644 index 0000000..5f07e30 --- /dev/null +++ b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-DISCUSSION-LOG.md @@ -0,0 +1,90 @@ +# Phase 06.1: Release & distribution - Discussion Log + +> **Audit trail only.** Do not use as input to planning, research, or execution agents. +> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered. + +**Date:** 2026-05-31 +**Phase:** 06.1-release-distribution-and-install-github-release-tarballs-sta +**Areas discussed:** install.sh scope, workpot update scope, DMG UX, install.sh hosting, signing gate (+ follow-up areas skipped) + +--- + +## install.sh scope + +| Option | Description | Selected | +|--------|-------------|----------| +| CLI only | Tray separate (DMG or flag) | | +| Both default | CLI + tray; `--only-cli` / `--only-tray` | ✓ | +| CLI + DMG hint | CLI install prints DMG link | | + +**User's choice:** Both by default; `--only-cli` and `--only-tray` flags. +**Notes:** Tray → `~/Applications/Workpot.app`. CLI → `~/.local/bin`; `--global` for system-wide. Tray from release DMG (mount/copy). Latest release only (no version pin). + +--- + +## workpot update scope + +| Option | Description | Selected | +|--------|-------------|----------| +| CLI only | Tray via reinstall/DMG | | +| Both default | CLI + tray | ✓ | +| Mirror install flags | `--only-cli`, `--only-tray`, `--global` | ✓ | + +**User's choice:** Update both by default; mirror install.sh flags; exit 0 if already current; exit 2 API/network, 1 permission; detect installed parts by presence. + +--- + +## DMG UX + +| Option | Description | Selected | +|--------|-------------|----------| +| App only | Minimal DMG | | +| App + drag target | Standard layout | ✓ | +| Equal prominence | DMG and curl both first-class in INSTALL.md | ✓ | +| Per-arch DMG | aarch64 (+ was x86_64) | ✓ (aarch64 only) | +| Version in filename | e.g. Workpot-0.1.0-aarch64.dmg | ✓ | +| Drop all x86_64 | CLI tarball + DMG | ✓ | + +**User's choice:** App + Applications shortcut; equal docs prominence; per-arch naming with version; **aarch64-only** — remove all x86_64 release artifacts for now. + +--- + +## install.sh hosting + +| Option | Description | Selected | +|--------|-------------|----------| +| raw main only | Always latest script on main | | +| Release asset only | Pinned per release | | +| Both documented | Convenience + reproducible | ✓ | +| SHA256 verify | Fail closed on checksum mismatch | ✓ | +| scripts/install.sh | Repo path | ✓ | + +--- + +## signing gate + +| Option | Description | Selected | +|--------|-------------|----------| +| Fail without secrets | Block unsigned official releases | | +| Ship unsigned OK | Best-effort; warn in README/logs | ✓ | +| Notarize when secrets present | Signed + notarized + stapled required | ✓ | + +--- + +## Follow-up areas (questions skipped) + +User skipped interactive questions on: uninstall, `--global` paths, post-install UX, tray-running-during-update. + +**Captured as Claude discretion in CONTEXT.md (D-20–D-23):** `/usr/local/bin` + `/Applications` for global; INSTALL.md uninstall only; no auto-open; fail if tray running with quit message. + +## Claude's Discretion + +- DMG filename exact pattern (version + arch) within D-13 constraint. +- Script implementation details (retries, strict bash, dual-install edge cases). + +## Deferred Ideas + +- Pinned version installs (`WORKPOT_VERSION`, `--version`). +- x86_64 artifacts (entire matrix deferred). +- `workpot uninstall` command. +- Tray in-app auto-update. diff --git a/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-HUMAN-UAT.md b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-HUMAN-UAT.md new file mode 100644 index 0000000..8e52e0f --- /dev/null +++ b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-HUMAN-UAT.md @@ -0,0 +1,38 @@ +--- +status: partial +phase: 06.1-release-distribution-and-install-github-release-tarballs-sta +source: + - 06.1-VERIFICATION.md +started: 2026-05-31T18:10:00Z +updated: 2026-05-31T18:10:00Z +--- + +## Current Test + +number: 1 +name: Signed/notarized release path +expected: | + Run release workflow with full APPLE_* secrets and confirm signed/notarized/stapled DMG flow executes and uploads `Workpot-X.Y.Z-aarch64.dmg` plus `.sha256`. +awaiting: user response + +## Tests + +### 1. Signed/notarized release path +expected: Run release workflow with full APPLE_* secrets and confirm signed/notarized/stapled DMG flow executes and uploads `Workpot-X.Y.Z-aarch64.dmg` plus `.sha256`. +result: [pending] + +### 2. Published release contract end-to-end +expected: Publish a real `vX.Y.Z` release and confirm aarch64 CLI tarball/checksum, aarch64 DMG/checksum, and installer URL contract all work from GitHub Releases. +result: [pending] + +## Summary + +total: 2 +passed: 0 +issues: 0 +pending: 2 +skipped: 0 +blocked: 0 + +## Gaps + diff --git a/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-PATTERNS.md b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-PATTERNS.md new file mode 100644 index 0000000..7620f33 --- /dev/null +++ b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-PATTERNS.md @@ -0,0 +1,416 @@ +# Phase 06.1: Release & distribution - Pattern Map + +**Mapped:** 2026-05-31 +**Files analyzed:** 12 +**Analogs found:** 12 / 12 + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|---|---|---|---|---| +| `scripts/install.sh` | utility | file-I/O | `scripts/sync-version.sh` | role-match | +| `scripts/tests/install_smoke.sh` | test | batch | `crates/workpot-cli/tests/cli_smoke.rs` | partial | +| `crates/workpot-cli/src/main.rs` | controller | request-response | `crates/workpot-cli/src/main.rs` | exact | +| `crates/workpot-cli/src/update.rs` | service | request-response | `crates/workpot-cli/src/main.rs` | role-match | +| `crates/workpot-cli/tests/update_smoke.rs` | test | request-response | `crates/workpot-cli/tests/cli_smoke.rs` | role-match | +| `.github/workflows/release.yml` | config | batch | `.github/workflows/release.yml` | exact | +| `.github/workflows/release-artifacts.yml` | config | event-driven | `.github/workflows/release-artifacts.yml` | exact | +| `.github/workflows/release-smoke.yml` | config | event-driven | `.github/workflows/release-smoke.yml` | exact | +| `src-tauri/tauri.conf.json` | config | transform | `src-tauri/tauri.conf.json` | exact | +| `INSTALL.md` | config | request-response | `docs/releasing.md` | partial | +| `README.md` | config | request-response | `README.md` | exact | +| `docs/releasing.md` | config | request-response | `docs/releasing.md` | exact | + +## Pattern Assignments + +### `scripts/install.sh` (utility, file-I/O) + +**Analog:** `scripts/sync-version.sh` + +**Shell strictness + root resolution** (lines 1-8): +```bash +#!/usr/bin/env bash +# Sync workspace manifests from repo-root version file. +# Usage: sync-version.sh [--check] +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" +``` + +**Guard + fail-fast style** (lines 14-18): +```bash +VERSION="$(bash scripts/read-workspace-version.sh)" +if [[ -z "$VERSION" ]]; then + echo "version file is empty" >&2 + exit 1 +fi +``` + +**Atomic temp file replacement pattern** (lines 113-115): +```bash + ' "$file" >"$file.tmp" + mv "$file.tmp" "$file" +``` + +--- + +### `scripts/tests/install_smoke.sh` (test, batch) + +**Analog:** `crates/workpot-cli/tests/cli_smoke.rs` (partial, cross-language) + +**Isolated environment pattern** (lines 19-29): +```rust +fn workpot_cmd(home: &std::path::Path) -> Command { + let mut cmd = Command::cargo_bin("workpot").expect("workpot binary"); + cmd.env("HOME", home); + cmd.env("XDG_CONFIG_HOME", home.join(".config")); + cmd.env("XDG_DATA_HOME", home.join(".local/share")); + cmd.env_remove("XDG_STATE_HOME"); + cmd +} +``` + +**Roundtrip assertion style** (lines 89-107): +```rust +workpot_cmd(home.path()) + .args(["repo", "add", repo_path.to_str().expect("utf8 path")]) + .assert() + .success() + .stdout(predicate::str::contains("registered:")); +``` + +--- + +### `crates/workpot-cli/src/main.rs` (controller, request-response) + +**Analog:** `crates/workpot-cli/src/main.rs` + +**Clap command registration pattern** (lines 16-25): +```rust +#[derive(Parser)] +#[command(name = "workpot", about = "Local git repo workspace launcher", version)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { +``` + +**Subcommand dispatch in `run()`** (lines 153-166): +```rust +fn run() -> anyhow::Result<()> { + let cli = Cli::parse(); + match cli.command { + Commands::Paths => run_paths(), + Commands::Index => run_index(), + Commands::List => run_list(), + // ... + Commands::Open { repo } => run_open(&repo), + } +} +``` + +**Exit code taxonomy in `main()`** (lines 131-149): +```rust +match run() { + Ok(()) => ExitCode::SUCCESS, + Err(e) if e.downcast_ref::().is_some() => { + eprintln!("{e:#}"); + ExitCode::from(2) + } + Err(e) => { + eprintln!("{e:#}"); + ExitCode::FAILURE + } +} +``` + +--- + +### `crates/workpot-cli/src/update.rs` (service, request-response) + +**Analog:** `crates/workpot-cli/src/main.rs` + `scripts/sync-version.sh` + +**Typed error mapping style** (main.rs lines 391-398): +```rust +fn map_roots_error(err: WorkpotError) -> anyhow::Error { + match err { + WorkpotError::LimitsExceeded(msg) | WorkpotError::WatchRootNotFound(msg) => { + anyhow::anyhow!(msg) + } + other => other.into(), + } +} +``` + +**Context-enriched fallible ops** (main.rs lines 168-170): +```rust +let ctx = AppContext::open().context("failed to open workpot")?; +println!("config: {}", ctx.config_path().display()); +``` + +**Fail-closed guard pattern** (sync-version.sh lines 151-157): +```bash +if $CHECK_ONLY; then + if verify_all; then + echo "version sync OK ($VERSION)" + else + echo "version sync drift detected; run: just version" >&2 + exit 1 + fi +fi +``` + +--- + +### `crates/workpot-cli/tests/update_smoke.rs` (test, request-response) + +**Analog:** `crates/workpot-cli/tests/cli_smoke.rs` + +**Binary invocation helper + env isolation** (lines 19-29): +```rust +fn workpot_cmd(home: &std::path::Path) -> Command { + let mut cmd = Command::cargo_bin("workpot").expect("workpot binary"); + cmd.env("HOME", home); + cmd.env("XDG_CONFIG_HOME", home.join(".config")); + cmd.env("XDG_DATA_HOME", home.join(".local/share")); + cmd.env_remove("XDG_STATE_HOME"); + cmd +} +``` + +**Exit-code assertion pattern** (lines 329-334): +```rust +workpot_cmd(home.path()) + .arg("index") + .assert() + .code(1) + .stderr(predicate::str::contains("cap exceeded")); +``` + +--- + +### `.github/workflows/release.yml` (config, batch) + +**Analog:** `.github/workflows/release.yml` + +**Reusable workflow contract (`workflow_call` + inputs)** (lines 8-18): +```yaml +on: + workflow_call: + inputs: + tag: + description: Release tag (e.g. v0.1.0) + required: true + type: string + dry_run: + description: Smoke mode — build matrix only, no release upload +``` + +**Prepare job output pattern** (lines 48-57): +```yaml +- id: flags + run: | + set -euo pipefail + if [ "${{ inputs.dry_run }}" = "true" ] || [ "${{ github.event.inputs.dry_run }}" = "true" ]; then + echo "dry_run=true" >> "$GITHUB_OUTPUT" + echo "checkout_ref=${{ github.sha }}" >> "$GITHUB_OUTPUT" + else + echo "dry_run=false" >> "$GITHUB_OUTPUT" +``` + +**Matrix artifact naming + checksum generation** (lines 146-156): +```yaml +- name: Create release tarball + run: | + artifact="$RELEASE_ARTIFACT" + mkdir -p "dist/$artifact" + cp target/release/workpot "dist/$artifact/workpot" + cp README.md LICENSE "dist/$artifact/" + tar -C "dist/$artifact" -czf "$artifact.tar.gz" . + shasum -a 256 "$artifact.tar.gz" > "$artifact.tar.gz.sha256" +``` + +--- + +### `.github/workflows/release-artifacts.yml` (config, event-driven) + +**Analog:** `.github/workflows/release-artifacts.yml` + +**Release trigger + reusable workflow delegation** (lines 6-21): +```yaml +on: + release: + types: [published] + +jobs: + upload: + name: upload release binaries + uses: ./.github/workflows/release.yml + with: + tag: ${{ github.event.release.tag_name }} + secrets: inherit +``` + +--- + +### `.github/workflows/release-smoke.yml` (config, event-driven) + +**Analog:** `.github/workflows/release-smoke.yml` + +**PR-path scoped trigger pattern** (lines 5-12): +```yaml +on: + pull_request: + branches: [master] + paths: + - ".github/workflows/release*.yml" + - "Cargo.toml" + - "crates/**" +``` + +**Dry-run invocation of release workflow** (lines 27-30): +```yaml +uses: ./.github/workflows/release.yml +with: + tag: v0.0.0-smoke + dry_run: true +``` + +--- + +### `src-tauri/tauri.conf.json` (config, transform) + +**Analog:** `src-tauri/tauri.conf.json` + +**Version-coupled JSON manifest style** (lines 2-6): +```json +"$schema": "https://schema.tauri.app/config/2", +"productName": "Workpot", +"version": "0.0.1", +"identifier": "com.workpot", +``` + +**Bundle target declaration pattern** (lines 30-43): +```json +"bundle": { + "active": true, + "targets": ["app"], + "icon": [ + "icons/32x32.png", + "icons/128x128.png" + ], + "macOS": { "minimumSystemVersion": "12.0" } +} +``` + +--- + +### `INSTALL.md` (config, request-response) + +**Analog:** `docs/releasing.md` (partial) + +**Actionable checklist format** (docs/releasing.md lines 18-25): +```markdown +## Ship checklist + +1. Edit [`version`](../version) — must be **strictly greater** than the latest `v*` tag... +2. Add a `## [X.Y.Z]` section to [`CHANGELOG.md`](../CHANGELOG.md)... +3. Run `just version` and commit ... +4. Merge when CI is green ... +``` + +**Artifact table style** (docs/releasing.md lines 60-67): +```markdown +| Artifact | Runner | Contents | +| ------------------------------ | ---------------- | ---------------------------------------- | +| `workpot-macos-aarch64.tar.gz` | `macos-latest` | `workpot` binary, `README.md`, `LICENSE` | +``` + +--- + +### `README.md` (config, request-response) + +**Analog:** `README.md` + +**Top-level link-oriented summary pattern** (lines 3-6): +```markdown +## Releasing + +See [CONTRIBUTING.md](CONTRIBUTING.md) ... Details: [docs/releasing.md](docs/releasing.md). +``` + +--- + +### `docs/releasing.md` (config, request-response) + +**Analog:** `docs/releasing.md` + +**“Source of truth” section framing** (lines 5-17): +```markdown +## Source of truth + +Repo-root [`version`](../version) holds the workspace semver ... +One command syncs every manifest and lockfile: +``` + +**Workflow reference table style** (lines 93-100): +```markdown +| Workflow | Role | +| --- | --- | +| [release-publish.yml](../.github/workflows/release-publish.yml) | Push to `master` ... | +| [release-artifacts.yml](../.github/workflows/release-artifacts.yml) | `release: published` ... | +``` + +## Shared Patterns + +### Shell safety and predictable failures +**Source:** `scripts/sync-version.sh`, `scripts/check-release-pr.sh`, `scripts/read-workspace-version.sh` +**Apply to:** `scripts/install.sh`, `scripts/tests/install_smoke.sh` +```bash +set -euo pipefail +echo "error message" >&2 +exit 1 +``` + +### Reusable release workflow layering +**Source:** `.github/workflows/release.yml`, `.github/workflows/release-artifacts.yml`, `.github/workflows/release-smoke.yml` +**Apply to:** all release workflow updates in this phase +```yaml +jobs: + smoke: + uses: ./.github/workflows/release.yml + with: + tag: v0.0.0-smoke + dry_run: true +``` + +### CLI exit-code contract and error surface +**Source:** `crates/workpot-cli/src/main.rs` +**Apply to:** `update` subcommand wiring and `update.rs` +```rust +eprintln!("{e:#}"); +ExitCode::from(2) +``` + +### Integration test isolation for CLI behavior +**Source:** `crates/workpot-cli/tests/cli_smoke.rs` +**Apply to:** `crates/workpot-cli/tests/update_smoke.rs` +```rust +cmd.env("HOME", home); +cmd.env("XDG_CONFIG_HOME", home.join(".config")); +cmd.env("XDG_DATA_HOME", home.join(".local/share")); +``` + +## No Analog Found + +None. All planned files have at least a role-level analog in the current codebase. + +## Metadata + +**Analog search scope:** `scripts/`, `crates/workpot-cli/src/`, `crates/workpot-cli/tests/`, `.github/workflows/`, `docs/`, repo root docs +**Files scanned:** 14 +**Pattern extraction date:** 2026-05-31 diff --git a/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-RESEARCH.md b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-RESEARCH.md new file mode 100644 index 0000000..69f8902 --- /dev/null +++ b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-RESEARCH.md @@ -0,0 +1,388 @@ +# Phase 06.1: Release & distribution - Research + +**Researched:** 2026-05-31 +**Domain:** macOS release distribution (GitHub Releases, installer/update UX, DMG signing/notarization) +**Confidence:** HIGH + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions +- **D-01:** Default (no flags) installs **both** CLI and tray. Flags: `--only-cli`, `--only-tray`. +- **D-02:** CLI default path: `~/.local/bin/workpot` with PATH hint when missing. `--global` installs CLI (and tray when applicable) to system-wide locations for all users (see D-20). +- **D-03:** Tray default path: `~/Applications/Workpot.app` (no admin for default install). +- **D-04:** Tray artifact: download release **`.dmg`**, mount, copy `Workpot.app` out (same artifact family as GUI install path). +- **D-05:** Version pinning: **latest GitHub release only** for v1 (no `--version` / `WORKPOT_VERSION`). +- **D-06:** Default updates **both** CLI and tray (same as install.sh default). +- **D-07:** Mirrors install.sh flags: `--only-cli`, `--only-tray`, `--global`. +- **D-08:** When installed version equals latest release: **exit 0** with “already up to date” (no download). +- **D-09:** Exit codes: **0** success or already-current; **1** permission / install failure; **2** network or GitHub API failure. Leave existing install untouched on failure. +- **D-10:** Detect what to update by **presence**: `~/.local/bin/workpot` (or global CLI path); `~/Applications/Workpot.app` (or global tray path). No install manifest file in v1. +- **D-11:** DMG layout: **Workpot.app + standard drag target** (Applications folder alias/README) — not app-only. +- **D-12:** **Equal prominence** in `INSTALL.md`: DMG path and `curl | bash` are both first-class; same release version. +- **D-13:** **Per-arch DMG** naming includes version, e.g. `Workpot-0.1.0-aarch64.dmg` (exact pattern at planner discretion; must be unambiguous on Releases page). +- **D-14:** **aarch64-only for this phase** — drop **all** x86_64 release artifacts (CLI tarballs and DMG). CI matrix and docs updated accordingly. +- **D-15:** CLI tarball remains aarch64-only: `workpot-macos-aarch64.tar.gz` + `.sha256` (align naming with existing release workflow where practical). +- **D-16:** Script lives at **`scripts/install.sh`**. Document **both** install URLs: convenience `raw.githubusercontent.com/.../main/scripts/install.sh` and **versioned** `install.sh` attached to each GitHub Release for reproducible installs. +- **D-17:** Downloaded release assets (tarball, DMG) must be verified against published **`.sha256`** checksums; fail closed on mismatch. +- **D-18:** If Apple signing secrets are absent, **ship unsigned** with clear log/README warning (best-effort signing — do not block fork/local experimentation). +- **D-19:** When secrets are present: **signed + notarized + stapled** `.app`/`.dmg` is the bar before upload to GitHub Releases. + +### Claude's Discretion +- **D-20:** `--global` paths: **`/usr/local/bin/workpot`** and **`/Applications/Workpot.app`**, using `sudo` when needed (standard macOS layout). +- **D-21:** **Uninstall:** `INSTALL.md` only — document removing CLI binary, `~/Applications` (or global) app, and optional config/data paths; no `workpot uninstall` subcommand in v1. +- **D-22:** **Post-install:** print next steps only (do not auto-open app or add Login Items in v1). +- **D-23:** **Tray running during update:** detect running Workpot; **exit 1** with instruction to quit from menu bar before replace (no silent kill). +- Exact DMG window branding, `hdiutil` error messages, and retry policy in install/update scripts. +- Whether `install.sh` uses `bash` strict mode flags beyond `set -euo pipefail`. +- Global-path detection heuristics when both user and global installs exist (prefer explicit flags). + +### Deferred Ideas (OUT OF SCOPE) +- `workpot uninstall` subcommand — user did not discuss; v1 uses INSTALL.md steps only (D-21). +- `WORKPOT_VERSION` / pinned installs — deferred (D-05). +- x86_64 macOS support — deferred until explicitly reintroduced on roadmap. +- Tray auto-update inside the app — out of phase scope per ROADMAP. +- Auto-open app / Login Items after install — deferred (D-22). + + +## Project Constraints (from CLAUDE.md) + +- macOS-only v1 surface is mandatory; release/distribution plan must not include Linux/Windows tracks. [CITED:https://github.com/rubenlr/workpot/blob/master/CLAUDE.md] +- Cursor integration remains required and should not regress while adding installer/update paths. [CITED:https://github.com/rubenlr/workpot/blob/master/CLAUDE.md] +- Local-only architecture remains required; release/update implementation must not introduce telemetry/account dependencies. [CITED:https://github.com/rubenlr/workpot/blob/master/CLAUDE.md] +- Shared Rust core + CLI/tray architecture should be preserved; avoid duplicating business logic across surfaces. [CITED:https://github.com/rubenlr/workpot/blob/master/CLAUDE.md] +- Planning artifacts may be edited directly in this orchestrated research flow (user explicitly requested this phase research). [VERIFIED: codebase] + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| SC-01 | Release publishes CLI tarball + checksum + aarch64 DMG | Standard Stack, Architecture Patterns, Validation Architecture | +| SC-02 | One-line installer installs CLI/tray correctly | Architecture Patterns, Code Examples, Common Pitfalls | +| SC-03 | `workpot update` handles success/current/error exit modes | Architecture Patterns, Common Pitfalls, Validation Architecture | +| SC-04 | `INSTALL.md` user-focused install/update/uninstall docs | Standard Stack, Anti-Patterns, Environment Availability | +| SC-05 | Maintainer docs/workflows updated for DMG + installer | Architecture Patterns, Validation Architecture | + + +## Summary + +Phase 06.1 is primarily a release-system composition problem, not a new product capability: wire existing versioning/release gates to publish additional assets, then consume those assets safely from installer and updater paths. Current automation already creates tags/releases and uploads CLI tarballs with checksums, but still includes x86_64 and has no installer/DMG lane. [VERIFIED: codebase] + +The safest plan is to centralize release asset resolution and checksum verification once, then reuse it in both `scripts/install.sh` and `workpot update`. GitHub’s latest-release API and release-assets schema already provide enough metadata to resolve asset URLs deterministically; rely on that instead of scraping HTML. [CITED:https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release] [CITED:https://docs.github.com/en/rest/releases/assets?apiVersion=2022-11-28#list-release-assets] + +For tray distribution, treat DMG signing/notarization as conditional hardening: when Apple credentials are present, build signed+notarized+stapled DMG; when absent, still publish unsigned DMG with explicit warning. This aligns with locked decisions and Tauri’s documented signing/notarization environment-variable model. [CITED:https://v2.tauri.app/distribute/sign/macos/] [VERIFIED: codebase] + +**Primary recommendation:** Build one release-asset contract (names + checksums + failure taxonomy) and enforce it end-to-end across CI, installer, updater, and docs. + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| Tag/release publication gate | GitHub Actions CI | Repository metadata (`version`, `CHANGELOG`) | Trigger and policy enforcement already live in workflows/scripts. [VERIFIED: codebase] | +| Artifact build (CLI tarball + DMG) | GitHub Actions macOS jobs | Tauri bundler / Rust compiler | Build outputs originate in CI build matrix. [VERIFIED: codebase] | +| Installer UX (`curl ... | bash`) | Client shell script (`scripts/install.sh`) | GitHub Releases API | User entrypoint is shell; asset metadata comes from release API. [CITED:https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release] | +| CLI self-update (`workpot update`) | CLI binary (`workpot-cli`) | Shared installer primitives | Same asset/verification logic as install flow with explicit exit codes. [VERIFIED: codebase] | +| DMG signing/notarization | CI secret-aware build stage | Apple notarization services | Tauri requires Apple credentials for notarization path. [CITED:https://v2.tauri.app/distribute/sign/macos/] | +| User documentation | `INSTALL.md` | `README.md` linkage | End-user flow must be decoupled from maintainer `docs/releasing.md`. [VERIFIED: codebase] | + +## Standard Stack + +### Core +| Library/Tool | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| GitHub Releases REST API (`/releases/latest`, assets) | API version `2022-11-28` | Resolve latest release + exact asset URLs/names | Official source of truth for release assets and metadata. [CITED:https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release] [CITED:https://docs.github.com/en/rest/releases/assets?apiVersion=2022-11-28#list-release-assets] | +| GitHub Actions `release` event (`types: [published]`) | Current docs | Trigger artifact build from published release | Matches existing `release-artifacts.yml` pattern and avoids manual sync races. [CITED:https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#release] [VERIFIED: codebase] | +| Tauri 2 macOS signing/notarization env contract | Tauri v2 docs | Sign/notarize/staple DMG path when secrets exist | Officially documented and compatible with conditional CI behavior. [CITED:https://v2.tauri.app/distribute/sign/macos/] | +| Existing repo release scripts/workflows | Current repo state | Version gating + release creation + upload orchestration | Already implemented and battle-tested in this repo. [VERIFIED: codebase] | + +### Supporting +| Library/Tool | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| `curl` + `jq` + `shasum` + `tar` + `hdiutil` (macOS built-ins + jq) | Local env: curl 8.7.1, jq 1.8.1 | Installer/update fetch + parse + verify + extract/mount | Use in `scripts/install.sh` and optional helper scripts. [VERIFIED: codebase] | +| `gh release upload` | gh 2.93.0 | Upload generated artifacts into existing release | Keep uploader path consistent with existing `release.yml`. [VERIFIED: codebase] | +| `xcrun notarytool` + `xcrun stapler` + `codesign` | Local env available | Verify/execute Apple notarization flow in CI/local | Use only in signed path (secrets present). [CITED:https://v2.tauri.app/distribute/sign/macos/] [VERIFIED: codebase] | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| GitHub REST latest release lookup | Hardcoded version/tag in script | Hardcoding violates D-05 and breaks one-line latest install. | +| Shared install/update primitives | Independent installer and updater logic | Duplicates checksum/parsing logic and drifts error semantics. | +| Conditional unsigned fallback | Hard fail when Apple secrets missing | Conflicts with D-18 and blocks fork/local release testing. | + +**Installation:** no external package installation is required for this phase baseline; rely on existing toolchain and repo scripts. [VERIFIED: codebase] + +## Package Legitimacy Audit + +No new third-party package additions are required by this research baseline. Therefore package-legitimacy gate is not applicable for Phase 06.1 planning unless the planner introduces new dependencies later. [VERIFIED: codebase] + +## Architecture Patterns + +### System Architecture Diagram + +```mermaid +flowchart TD + A[PR merges with version bump] --> B[release-publish.yml] + B -->|vX.Y.Z created| C[GitHub Release published] + C --> D[release-artifacts.yml trigger] + D --> E[release.yml build matrix] + E --> F[CLI tar.gz + sha256] + E --> G[Tauri app bundle -> DMG] + G --> H{Apple secrets present?} + H -->|yes| I[sign + notarize + staple] + H -->|no| J[unsigned DMG + warning] + I --> K[gh release upload] + J --> K + F --> K + K --> L[Release assets available] + L --> M[scripts/install.sh] + L --> N[workpot update] + M --> O[Installed CLI/tray] + N --> O +``` + +### Recommended Project Structure +```text +scripts/ +├── install.sh # User installer entrypoint (new) +├── release-assets.sh # Shared asset resolution + checksum helpers (new) +└── latest-released-version.sh + +crates/workpot-cli/src/ +├── main.rs # Add update command wiring +└── update.rs # Update flow + exit-code mapping (new) + +.github/workflows/ +├── release.yml # Add DMG lane + aarch64-only matrix +└── release-smoke.yml # Validate new artifact contract in PR +``` + +### Pattern 1: Release Asset Contract First +**What:** Define canonical asset names/patterns once (`workpot-macos-aarch64.tar.gz`, `.sha256`, `Workpot--aarch64.dmg`, DMG checksum) and make CI + installer + updater all enforce it. +**When to use:** Any workflow/script/code that selects release artifacts. +**Example:** +```bash +# Source: GitHub Releases REST docs + project workflow conventions +latest_json="$(curl -fsSL "https://api.github.com/repos/${OWNER}/${REPO}/releases/latest")" +asset_url="$(echo "$latest_json" | jq -r '.assets[] | select(.name=="workpot-macos-aarch64.tar.gz") | .browser_download_url')" +checksum_url="$(echo "$latest_json" | jq -r '.assets[] | select(.name=="workpot-macos-aarch64.tar.gz.sha256") | .browser_download_url')" +``` + +### Pattern 2: Atomic Update with Staging + Verify + Replace +**What:** Download to temp dir, verify checksum, then replace target binary/app in one final step. +**When to use:** `workpot update` and `install.sh`. +**Example:** +```bash +# Source: release integrity requirement D-17 +tmp_dir="$(mktemp -d)" +curl -fsSL "$asset_url" -o "$tmp_dir/asset" +curl -fsSL "$checksum_url" -o "$tmp_dir/asset.sha256" +(cd "$tmp_dir" && shasum -a 256 -c asset.sha256) +# only then copy/move into final install path +``` + +### Pattern 3: Secret-Gated DMG Signing Path +**What:** Branch release job by presence of Apple secrets; run signed path when available, unsigned fallback otherwise. +**When to use:** `release.yml` DMG generation job. +**Example:** +```yaml +# Source: Tauri macOS signing docs + D-18/D-19 +if: env.APPLE_CERTIFICATE != '' && env.APPLE_CERTIFICATE_PASSWORD != '' +# signed/notarized/stapled branch +``` + +### Anti-Patterns to Avoid +- **Workflow/doc drift:** keeping ROADMAP/INSTALL/docs/releasing inconsistent with locked D-14 (aarch64-only). +- **Two different checksum implementations:** one in installer, another in updater. +- **In-place overwrite before verification:** can brick valid installs on partial downloads. +- **Implicit app-kill on tray update:** violates D-23 (must fail with instruction). + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Release metadata discovery | HTML scraping GitHub release page | GitHub Releases REST endpoints | Stable schema + explicit fields (`assets`, `browser_download_url`). [CITED:https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release] | +| DMG signing/notarization flow | Custom ad-hoc Apple submission scripts from scratch | Tauri documented env-based signing/notarization pipeline | Reduces credential-handling mistakes and aligns with Tauri bundler behavior. [CITED:https://v2.tauri.app/distribute/sign/macos/] | +| Release trigger semantics | Manual release polling jobs | `on: release: types: [published]` workflows | Native event semantics already documented and used. [CITED:https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#release] | + +**Key insight:** Complexity here is contract consistency, not algorithms; custom paths fail by drift, not by performance. + +## Common Pitfalls + +### Pitfall 1: Locked-decision drift vs old roadmap text +**What goes wrong:** Planner follows `ROADMAP.md` line still mentioning x86_64 release artifacts. +**Why it happens:** Phase context newer than roadmap row. +**How to avoid:** Treat `06.1-CONTEXT.md` decisions D-14/D-15 as source of truth for this phase. +**Warning signs:** Any plan task keeps `macos-15-intel` runner or `workpot-macos-x86_64.tar.gz`. + +### Pitfall 2: Installer/updater contract divergence +**What goes wrong:** `install.sh` and `workpot update` resolve different assets or checksums. +**Why it happens:** Logic implemented twice. +**How to avoid:** Shared helper contract + identical asset-name constants. +**Warning signs:** Same version installs one artifact set but updater fetches another. + +### Pitfall 3: Non-atomic replacement +**What goes wrong:** Existing install overwritten before checksum validation or copy completes. +**Why it happens:** In-place writes. +**How to avoid:** Stage in temp dir, validate, then final move/copy. +**Warning signs:** Truncated binary/app after interrupted download. + +### Pitfall 4: Incorrect release-event assumptions +**What goes wrong:** Workflows miss pre-release/draft edge cases. +**Why it happens:** Wrong `release` activity type choice. +**How to avoid:** Keep `published` for this phase to trigger on actual publication. +**Warning signs:** Published release exists but artifact workflow didn’t run. + +## Code Examples + +Verified patterns from official sources: + +### GitHub release trigger +```yaml +# Source: https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#release +on: + release: + types: [published] +``` + +### Get latest release metadata +```bash +# Source: https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release +curl -L \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/${OWNER}/${REPO}/releases/latest" +``` + +### Tauri notarization env inputs +```bash +# Source: https://v2.tauri.app/distribute/sign/macos/ +export APPLE_API_ISSUER="..." +export APPLE_API_KEY="..." +export APPLE_API_KEY_PATH="/path/to/AuthKey_XXXX.p8" +# then run tauri build/bundle +``` + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| Release tarballs only | Tarballs + DMG + installer + updater contract | This phase | End-user install path becomes self-service. | +| Dual-arch matrix (`aarch64`, `x86_64`) | aarch64-only (locked for 06.1) | This phase decision D-14 | Lower CI complexity/cost; Intel intentionally deferred. | +| Maintainer-only release docs | Split user (`INSTALL.md`) vs maintainer (`docs/releasing.md`) docs | This phase | Reduces user friction and support load. | + +**Deprecated/outdated:** +- x86_64 artifact publication for 06.1 scope (deferred by locked decision D-14). [VERIFIED: codebase] + +## Assumptions Log + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|---------------| +| A1 | `hdiutil` mount/copy flow (`attach` + app copy + detach) is sufficient for all target DMG structures without extra edge handling. [ASSUMED] | Architecture Patterns | Installer/update tray path may fail on edge DMG layouts. | +| A2 | Existing CI secrets model can branch reliably between signed and unsigned DMG paths without additional repository settings changes. [ASSUMED] | Architecture Patterns | Release pipeline could block or silently skip expected signing path. | + +## Open Questions (RESOLVED) + +1. **Tray update replace strategy** + - **Decision:** Use staged copy to a temporary sibling path and atomic final swap (`mv`) only after checksum verification and post-copy validation. + - **Why:** Satisfies D-09 fail-safe semantics (existing install untouched on failure) and D-23 behavior (if app is running, abort before mutation with exit code 1). + - **Planning impact:** Implement as explicit staged pipeline in `workpot update`: detect running app -> verify assets -> stage copy -> atomic swap. + +2. **DMG checksum publication format** + - **Decision:** Publish a dedicated checksum asset `Workpot--aarch64.dmg.sha256` adjacent to the DMG release asset. + - **Why:** Gives deterministic lookup parity with CLI tarball checksum (`*.tar.gz.sha256`) and directly satisfies D-17 verification requirement. + - **Planning impact:** Plan 01 must define/upload this filename contract; Plan 02/03 consume it for update/install verification. + +## Environment Availability + +| Dependency | Required By | Available | Version | Fallback | +|------------|------------|-----------|---------|----------| +| `gh` CLI | Release upload in `release.yml` | ✓ | 2.93.0 | none | +| `jq` | Installer JSON parsing | ✓ | 1.8.1 | none (phase should fail early with guidance) | +| `curl` | Installer/update downloads | ✓ | 8.7.1 | none | +| `tar` | CLI artifact extraction | ✓ | bsdtar 3.5.3 | none | +| `hdiutil` | DMG mount/copy | ✓ | available | none | +| `shasum` | Checksum verification | ✓ | available | none | +| `codesign` | Signed DMG verification/build | ✓ | available | unsigned fallback (D-18) | +| `xcrun notarytool` | Notarization | ✓ | 1.1.2 | unsigned fallback (D-18) | +| Apple signing secrets (`APPLE_*`) | Signed/notarized CI path | ? | — | unsigned fallback (D-18) | + +**Missing dependencies with no fallback:** +- None detected on this machine. + +**Missing dependencies with fallback:** +- Apple signing secrets are environment-dependent; fallback is unsigned artifacts with explicit warning. [CITED:https://v2.tauri.app/distribute/sign/macos/] + +## Validation Architecture + +### Test Framework +| Property | Value | +|----------|-------| +| Framework | Rust test harness (`cargo test`) + shell workflow smoke | +| Config file | none (Cargo defaults) | +| Quick run command | `cargo test -p workpot-cli --all-targets` | +| Full suite command | `cargo test -p workpot-core -p workpot-cli -p workpot-tray --all-targets` | + +### Phase Requirements -> Test Map +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| SC-01 | release jobs emit aarch64 tarball+checksum and DMG path | workflow smoke | `gh workflow run release-smoke.yml` (or PR-triggered release-smoke) | ✅ | +| SC-02 | installer installs CLI/tray by flags/defaults and verifies checksums | integration shell test | `bash scripts/install.sh --help` (plus new smoke script) | ❌ Wave 0 | +| SC-03 | `workpot update` returns 0/1/2 semantics and no-op on current | CLI integration | `cargo test -p workpot-cli update_* -- --nocapture` (to be added) | ❌ Wave 0 | +| SC-04 | `INSTALL.md` covers install/update/uninstall/PATH | docs verification | `rg "install|update|uninstall|PATH" INSTALL.md` | ❌ Wave 0 | +| SC-05 | maintainer docs/workflows mention DMG+installer | docs/workflow check | `rg "dmg|install.sh|aarch64" docs/releasing.md .github/workflows/release*.yml` | ✅ | + +### Sampling Rate +- **Per task commit:** `cargo test -p workpot-cli --all-targets` +- **Per wave merge:** `cargo test -p workpot-core -p workpot-cli -p workpot-tray --all-targets` +- **Phase gate:** Full suite + release-smoke workflow green before `/gsd-verify-work` + +### Wave 0 Gaps +- [ ] `crates/workpot-cli/tests/update_smoke.rs` — covers SC-03 failure/success/no-op exit code matrix. +- [ ] `scripts/tests/install_smoke.sh` — covers SC-02 default/flag/global argument and checksum failure behavior. +- [ ] `INSTALL.md` — user-facing install/update/uninstall doc required for SC-04. + +## Security Domain + +### Applicable ASVS Categories + +| ASVS Category | Applies | Standard Control | +|---------------|---------|-----------------| +| V2 Authentication | no | N/A (local CLI/tray install flow) | +| V3 Session Management | no | N/A | +| V4 Access Control | yes | explicit global-path privilege checks + fail-on-permission-denied exit code 1 | +| V5 Input Validation | yes | strict parsing/validation for release JSON fields and CLI flags | +| V6 Cryptography | yes | SHA-256 checksum verification of downloaded assets (D-17) | + +### Known Threat Patterns for release/install stack + +| Pattern | STRIDE | Standard Mitigation | +|---------|--------|---------------------| +| Tampered release asset in transit | Tampering | Verify downloaded asset against published `.sha256`; fail closed | +| Partial download then overwrite | Tampering/DoS | stage + checksum + atomic replace | +| Privileged path write without permissions | Elevation of privilege | explicit permission detection, clear failure, no partial mutation | +| Release API/network outage | DoS | map to exit code 2 and leave install untouched | + +## Sources + +### Primary (HIGH confidence) +- [GitHub REST releases](https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release) - latest release semantics and endpoint contract. +- [GitHub REST release assets](https://docs.github.com/en/rest/releases/assets?apiVersion=2022-11-28#list-release-assets) - asset listing/download fields and behavior. +- [GitHub Actions release event](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#release) - release trigger activity types and caveats. +- [Tauri v2 macOS signing/notarization](https://v2.tauri.app/distribute/sign/macos/) - signing, notarization, and fallback guidance. +- Repository canonical files: `docs/releasing.md`, `.github/workflows/release*.yml`, `scripts/check-release-pr.sh`, `crates/workpot-cli/src/main.rs`, `src-tauri/tauri.conf.json`. [VERIFIED: codebase] + +### Secondary (MEDIUM confidence) +- [GitHub About Releases](https://docs.github.com/en/repositories/releasing-projects-on-github/about-releases) - release asset quotas and release model context. + +### Tertiary (LOW confidence) +- None. + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH - official docs + existing repo workflows align. +- Architecture: HIGH - locked decisions are explicit and map directly to existing release topology. +- Pitfalls: MEDIUM - mostly codebase-derived with two implementation assumptions logged. + +**Research date:** 2026-05-31 +**Valid until:** 2026-06-30 diff --git a/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-REVIEW.md b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-REVIEW.md new file mode 100644 index 0000000..ef9d25f --- /dev/null +++ b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-REVIEW.md @@ -0,0 +1,44 @@ +--- +phase: 06.1-release-distribution-and-install-github-release-tarballs-sta +reviewed: 2026-05-31T19:15:00Z +depth: standard +files_reviewed: 3 +files_reviewed_list: + - crates/workpot-cli/src/update.rs + - crates/workpot-cli/tests/update_smoke.rs + - scripts/install.sh +findings: + critical: 0 + warning: 0 + info: 0 + total: 0 +status: clean +--- + +# Phase 06.1: Code Review Report + +**Reviewed:** 2026-05-31T19:15:00Z +**Depth:** standard +**Files Reviewed:** 3 +**Status:** clean + +## Summary + +Standard-depth re-review of `crates/workpot-cli/src/update.rs`, `crates/workpot-cli/tests/update_smoke.rs`, and `scripts/install.sh` found no critical, warning, or info findings in scope. +Previously reported warnings `WR-01` through `WR-04` are fixed in the current implementation. + +## Narrative Findings (AI reviewer) + +No current findings in scoped files. + +Validated fixes: +- `WR-01` fixed: tray install now stages to temp path, swaps via move, and restores backup on placement failure. +- `WR-02` fixed: DMG mount cleanup now enforced with function-return trap in `stage_tray_app`. +- `WR-03` fixed: `--only-cli` and `--only-tray` now fail fast as mutually exclusive. +- `WR-04` fixed: explicit HTTP/curl timeout budgets added in both Rust updater and shell installer. + +--- + +_Reviewed: 2026-05-31T19:15:00Z_ +_Reviewer: Claude (gsd-code-reviewer)_ +_Depth: standard_ diff --git a/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-SECURITY.md b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-SECURITY.md new file mode 100644 index 0000000..40b81ec --- /dev/null +++ b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-SECURITY.md @@ -0,0 +1,89 @@ +--- +phase: 6.1 +slug: release-distribution-and-install-github-release-tarballs-sta +status: verified +threats_open: 0 +asvs_level: 1 +created: 2026-05-31 +--- + +# Phase 6.1 — Security + +> Per-phase security contract: threat register, accepted risks, and audit trail. + +--- + +## Trust Boundaries + +| Boundary | Description | Data Crossing | +|----------|-------------|---------------| +| GitHub release event → CI artifact jobs | Untrusted trigger payload and repo state drive release build/upload behavior | Release tag, checkout ref, artifact names | +| CI secret store → signing steps | Apple signing credentials cross into DMG build runtime | `APPLE_*` secrets, signing identity | +| GitHub Releases API → install/update clients | Downloaded tarballs and DMGs are untrusted until checksum-verified | Release assets, `.sha256` sidecars | +| User filesystem → install/update targets | Global paths require elevated write access | CLI binary, `.app` bundle | + +--- + +## Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation | Status | +|-----------|----------|-----------|-------------|------------|--------| +| T-06.1-01 | Tampering | `.github/workflows/release.yml` artifact set | mitigate | Exact aarch64 artifact env vars; smoke workflow validates contract and rejects drift | closed | +| T-06.1-02 | Elevation | Signing/notarization branch | mitigate | Full-secret gate for signed path; hard-fail on partial secrets; unsigned only when zero secrets | closed | +| T-06.1-03 | Repudiation | Maintainer release process | mitigate | Signed vs unsigned policy and release checklist in `docs/releasing.md` | closed | +| T-06.1-04 | Tampering | `crates/workpot-cli/src/update.rs` asset download | mitigate | SHA-256 verification via `download_verified_asset` before extract/replace | closed | +| T-06.1-05 | DoS | updater replace flow | mitigate | Temp staging, staged rename, backup/rollback; checksum failure preserves install | closed | +| T-06.1-06 | Elevation | global install targets (`workpot update`) | mitigate | Explicit `--global` flag; install failures map to exit code 1 | closed | +| T-06.1-07 | Tampering | `scripts/install.sh` download pipeline | mitigate | `verify_sha256` for CLI tarball and DMG before staging | closed | +| T-06.1-08 | Elevation | `--global` install path writes | mitigate | Explicit `--global` gate; temp staging; sudo-aware writes with rollback | closed | +| T-06.1-09 | DoS | incomplete install on failures | mitigate | `mktemp -d` staging; target mutation only after all verifications pass | closed | +| T-06.1-SC | Tampering | release asset publication/install chain | mitigate | Checksum artifacts in CI; smoke tests cover contract and fail-close checksum paths | closed | + +*Status: open · closed* +*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)* + +--- + +## Accepted Risks Log + +No accepted risks. + +--- + +## Unregistered Threat Flags + +None. SUMMARY files reported no new attack surface beyond the threat register. + +--- + +## Security Audit Trail + +| Audit Date | Threats Total | Closed | Open | Run By | +|------------|---------------|--------|------|--------| +| 2026-05-31 | 10 | 10 | 0 | gsd-security-auditor | + +### Evidence (2026-05-31) + +| Threat ID | Evidence | +|-----------|----------| +| T-06.1-01 | `.github/workflows/release.yml:120-122` (artifact env vars); `.github/workflows/release.yml:148,256-260` (tarball/DMG + checksum); `.github/workflows/release-smoke.yml:44-65` (contract validation, unexpected artifact exit 1) | +| T-06.1-02 | `.github/workflows/release.yml:186-227` (secret counting, `mode=unsigned` / `mode=signed`, partial-secret hard-fail); `.github/workflows/release.yml:229-248` (mutually exclusive signed/unsigned build steps) | +| T-06.1-03 | `docs/releasing.md:69-99` (signing policy, maintainer interpretation, release tag contract checklist) | +| T-06.1-04 | `crates/workpot-cli/src/update.rs:259,322,359-374,509-538` (`download_verified_asset`, `verify_checksum` before replace) | +| T-06.1-05 | `crates/workpot-cli/src/update.rs:364,289-302,443-477,535` (temp dirs, staged CLI/tray, checksum fail message); `crates/workpot-cli/tests/update_smoke.rs:299-317` (preserves install on mismatch) | +| T-06.1-06 | `crates/workpot-cli/src/main.rs:67-69,159-169` (`--global` flag, Install → exit 1); `crates/workpot-cli/src/update.rs:141-158,90` (global path gate); `crates/workpot-cli/tests/update_smoke.rs:224-237` | +| T-06.1-07 | `scripts/install.sh:161-180,341,362` (`verify_sha256` before `stage_cli_binary` / `stage_tray_app`) | +| T-06.1-08 | `scripts/install.sh:19,102-104,315-318,366-372` (explicit `--global`, user default, post-verify mutation); `scripts/install.sh:51-78,239-269` (sudo-aware writes, tray rollback); `scripts/tests/install_smoke.sh:240-263` | +| T-06.1-09 | `scripts/install.sh:320-322,366-372` (temp root, mutate only after verification); `scripts/tests/install_smoke.sh:283-299` (checksum fail leaves install absent) | +| T-06.1-SC | `.github/workflows/release.yml:148,260` (checksum generation); `.github/workflows/release-smoke.yml:47-50`; `scripts/tests/install_smoke.sh:56,71,283-299`; `crates/workpot-cli/tests/update_smoke.rs:299-317` | + +--- + +## Sign-Off + +- [x] All threats have a disposition (mitigate / accept / transfer) +- [x] Accepted risks documented in Accepted Risks Log +- [x] `threats_open: 0` confirmed +- [x] `status: verified` set in frontmatter + +**Approval:** verified 2026-05-31 diff --git a/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-VALIDATION.md b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-VALIDATION.md new file mode 100644 index 0000000..262af8b --- /dev/null +++ b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-VALIDATION.md @@ -0,0 +1,116 @@ +--- +phase: 06.1 +slug: release-distribution-and-install-github-release-tarballs-sta +status: validated +nyquist_compliant: true +wave_0_complete: true +created: 2026-05-31 +audited: 2026-05-31 +validated: 2026-05-31 +--- + +# Phase 06.1 - Validation Strategy + +> Per-phase validation contract for release artifact, installer, updater, and docs behavior. + +--- + +## Test Infrastructure + +| Property | Value | +|----------|-------| +| **Framework** | Rust test harness (`cargo test`) + shell smoke scripts + workflow/docs grep checks | +| **Config file** | none | +| **Quick run command** | `cargo test -p workpot-cli --test update_smoke -- --nocapture` | +| **Full suite command** | `cargo test -p workpot-cli --all-targets` | +| **Estimated runtime** | ~20-45 seconds local (install smoke ~40s); CI workflow run async | + +--- + +## Sampling Rate + +- **After every task commit:** run the task `` command(s) +- **After every plan wave:** run full local suite plus docs/workflow greps +- **Before `/gsd-verify-work`:** full local suite green + release smoke workflow green +- **Max feedback latency:** 60 seconds local; CI smoke async gate + +--- + +## Per-Task Verification Map + +| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status | +|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------| +| 06.1-01-T1 | 01 | 1 | SC-01 | T-06.1-01, T-06.1-SC | aarch64-only artifacts + DMG checksum contract | workflow static check | `rg -n "workpot-macos-aarch64\\.tar\\.gz|workpot-macos-aarch64\\.tar\\.gz\\.sha256|Workpot-.*-aarch64\\.dmg|Workpot-.*-aarch64\\.dmg\\.sha256" .github/workflows/release.yml .github/workflows/release-smoke.yml` | ✅ | ✅ green | +| 06.1-01-T2 | 01 | 1 | SC-01 | T-06.1-02 | secret-aware signed/unsigned branching | workflow/docs static check | `rg -n "APPLE_|notarytool|stapler|codesign|unsigned" .github/workflows/release.yml docs/releasing.md` | ✅ | ✅ green | +| 06.1-01-T3 | 01 | 1 | SC-05 | T-06.1-03 | smoke/docs assert same release contract | workflow/docs static check | `rg -n "install\\.sh|dmg|aarch64|release-smoke|release-artifacts" docs/releasing.md .github/workflows/release-smoke.yml` | ✅ | ✅ green | +| 06.1-02-T1 (RED) | 02 | 2 | SC-03 | T-06.1-04 | expected-fail test contract before implementation | tdd red gate | `bash -c '! cargo test -p workpot-cli --test update_smoke -- --nocapture'` | ✅ | ⏭️ superseded (GREEN landed) | +| 06.1-02-T2 (GREEN) | 02 | 2 | SC-03 | T-06.1-04, T-06.1-05, T-06.1-06 | checksum + exit taxonomy pass | integration | `cargo test -p workpot-cli --test update_smoke -- --nocapture` | ✅ | ✅ green (8/8) | +| 06.1-02-T3 (REFACTOR) | 02 | 2 | SC-03 | T-06.1-04, T-06.1-05, T-06.1-06 | behavior preserved after cleanup | regression | `cargo test -p workpot-cli --all-targets` | ✅ | ✅ green | +| 06.1-03-T1 | 03 | 2 | SC-02 | T-06.1-07, T-06.1-08 | install flags/paths/checksum flow in script | script static check | `rg -n "only-cli|only-tray|global|~/.local/bin|~/Applications|/usr/local/bin|/Applications|sha256|hdiutil" scripts/install.sh` | ✅ | ✅ green | +| 06.1-03-T2 | 03 | 2 | SC-02 | T-06.1-07, T-06.1-09 | installer smoke includes version parity and checksum fail-close | integration shell | `bash scripts/tests/install_smoke.sh --assert-version-match` | ✅ | ✅ green | +| 06.1-03-T3 | 03 | 2 | SC-04 | T-06.1-SC | docs expose script+DMG install/update/uninstall/PATH | docs static check | `rg -n "curl -fsSL|install.sh|dmg|update|uninstall|PATH|raw.githubusercontent.com|Releases" INSTALL.md README.md` | ✅ | ✅ green | + +*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky · ⏭️ superseded* + +--- + +## Requirement -> Validation Dimensions + +| Req ID | Observable behavior | Primary automated proof | Plan(s) | Coverage | +|--------|---------------------|-------------------------|---------|----------| +| SC-01 | Release emits aarch64 CLI + DMG + checksums with secret-aware signing policy | release workflow greps + release smoke workflow | 01 | COVERED | +| SC-02 | One-line installer installs correctly by mode and version parity | `bash scripts/tests/install_smoke.sh --assert-version-match` | 03 | COVERED | +| SC-03 | `workpot update` exits with 0/1/2 taxonomy and verifies checksums | `cargo test -p workpot-cli --test update_smoke -- --nocapture` | 02 | COVERED | +| SC-04 | INSTALL docs are complete and user-focused | `rg` docs contract check | 03 | COVERED | +| SC-05 | Maintainer flow and smoke pipeline enforce same artifact contract | docs/workflow greps + release-smoke run | 01 | COVERED | + +--- + +## Wave 0 Requirements + +- [x] `crates/workpot-cli/tests/update_smoke.rs` - updater contract tests for SC-03 (8 tests green) +- [x] `scripts/tests/install_smoke.sh` - installer matrix and version-parity checks for SC-02 +- [x] `INSTALL.md` - user-facing install/update/uninstall/PATH docs for SC-04 + +--- + +## Manual-Only Verifications + +| Behavior | Requirement | Why Manual | Test Instructions | +|----------|-------------|------------|-------------------| +| Signed/notarized DMG confirmation when Apple secrets are present | SC-01 | Requires CI secret context + Apple notarization service | Review release job logs for notarize/staple steps and run `spctl -a -vv Workpot.app` on downloaded artifact | +| Unsigned fallback warning when Apple secrets are absent | SC-01 | Requires fork/local CI without Apple credentials | Trigger release smoke path without secrets and verify explicit warning text in logs/docs | +| Published release contract end-to-end | SC-01, SC-05 | Requires live GitHub `release: published` event | Publish `vX.Y.Z` and confirm four aarch64 assets + installer URLs on Releases page | + +--- + +## Validation Sign-Off + +- [x] All tasks include `` verification commands +- [x] Sampling continuity defined across plans/waves +- [x] Requirements SC-01..SC-05 mapped to automated checks +- [x] No watch-mode commands in validation gates +- [x] Wave 0 artifacts complete and green +- [x] `nyquist_compliant: true` — all SC requirements have automated local/CI-static proof; signing/publish remain manual-only + +**Approval:** validated 2026-05-31 (local audit: 8/8 updater tests, install smoke green, static greps satisfied) + +--- + +## Validation Audit 2026-05-31 + +| Metric | Count | +|--------|-------| +| Gaps found | 0 (implementation); 9 stale ⬜ statuses in VALIDATION.md | +| Resolved | 9 status updates + wave 0 sign-off | +| Escalated | 0 | +| Tests added | 0 (coverage already present) | + +**Evidence run locally:** + +- `cargo test -p workpot-cli --test update_smoke -- --nocapture` → 8 passed +- `cargo test -p workpot-cli --all-targets` → green +- `bash scripts/tests/install_smoke.sh --assert-version-match` → all checks passed +- Static contract greps: release.yml, release-smoke.yml, releasing.md, install.sh, INSTALL.md, README.md + +**Note:** `rg` not on default PATH in audit shell; patterns verified via workspace grep with equivalent patterns. CI uses `rg` as documented. diff --git a/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-VERIFICATION.md b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-VERIFICATION.md new file mode 100644 index 0000000..447ac9d --- /dev/null +++ b/.planning/phases/06.1-release-distribution-and-install-github-release-tarballs-sta/06.1-VERIFICATION.md @@ -0,0 +1,128 @@ +--- +phase: 06.1-release-distribution-and-install-github-release-tarballs-sta +verified: 2026-05-31T18:08:36Z +status: human_needed +score: 8/8 must-haves verified +overrides_applied: 0 +human_verification: + - test: "Run release workflow with full APPLE_* secrets and inspect logs/artifacts" + expected: "Signed/notarized/stapled DMG path runs and uploads Workpot-X.Y.Z-aarch64.dmg + .sha256" + why_human: "Requires GitHub Actions secrets and Apple notarization service" + - test: "Publish a real vX.Y.Z release and inspect release assets + installer URL" + expected: "Release contains aarch64 CLI tarball/checksum, aarch64 DMG/checksum, and installer URL contract works end-to-end" + why_human: "Requires GitHub release event execution and remote artifact publication" +--- + +# Phase 06.1: Release & distribution Verification Report + +**Phase Goal:** Ship a complete macOS release path — GitHub artifacts, one-line install, self-update, and tray `.dmg` — so users never hand-place binaries. +**Verified:** 2026-05-31T18:08:36Z +**Status:** human_needed +**Re-verification:** No — initial verification + +## Goal Achievement + +### Observable Truths + +| # | Truth | Status | Evidence | +| --- | --- | --- | --- | +| 1 | Each `v*` release contract is aarch64-only CLI tarball/checksum plus versioned aarch64 DMG/checksum, with signed/unsigned policy gates | ✓ VERIFIED | `.github/workflows/release.yml` defines `workpot-macos-aarch64.tar.gz` + `.sha256`, `Workpot-\${version}-aarch64.dmg` + `.sha256`, and explicit APPLE-secret branching (unsigned fallback and partial-secret hard-fail). | +| 2 | One-line installer installs Workpot from latest release with correct version behavior | ✓ VERIFIED | `scripts/install.sh` resolves `releases/latest`; `scripts/tests/install_smoke.sh --assert-version-match` passed and validated installed CLI version equals fixture release version. | +| 3 | `workpot update` upgrades from latest release with explicit failure behavior | ✓ VERIFIED | `crates/workpot-cli/src/update.rs` fetches `releases/latest`, maps network/install failure kinds, and `cargo test -p workpot-cli --test update_smoke -- --nocapture` passed (7/7), covering already-current, network/install failure mapping, and tray-running failure guidance. | +| 4 | `INSTALL.md` gives equal prominence to script and DMG paths and documents update/uninstall/PATH | ✓ VERIFIED | `INSTALL.md` contains first-class script and DMG sections, update commands/flags, uninstall steps, and PATH troubleshooting without deferring to maintainer docs. | +| 5 | Maintainer flow references DMG + installer and smoke verifies contract | ✓ VERIFIED | `docs/releasing.md` includes artifact contract and installer URLs; `.github/workflows/release-smoke.yml` validates exact smoke artifact whitelist. | +| 6 | `workpot update` supports default both-targets plus `--only-cli`, `--only-tray`, and `--global` | ✓ VERIFIED | `crates/workpot-cli/src/main.rs` exposes update flags; smoke tests verify default target detection and deterministic flag/global behavior. | +| 7 | Exit taxonomy is deterministic: 0 success/already-current, 1 install/permission/running-app failures, 2 network/API failures | ✓ VERIFIED | `main.rs` maps `UpdateFailureKind::Install` to exit 1 and `Network` to exit 2; update smoke test asserts code(2) for metadata failure and code(1) for running tray/installation class errors. | +| 8 | Downloaded assets are checksum-verified and failures are fail-closed without partial mutation | ✓ VERIFIED | `scripts/install.sh` verifies tarball/DMG checksums before install mutation; `update.rs` verifies SHA-256 before replace and emits `checksum mismatch; leaving existing install untouched`; installer and updater smoke tests both validate fail-closed behavior. | + +**Score:** 8/8 truths verified + +### Required Artifacts + +| Artifact | Expected | Status | Details | +| --- | --- | --- | --- | +| `.github/workflows/release.yml` | Canonical artifact matrix + DMG/signing policy | ✓ VERIFIED | Substantive workflow with aarch64-only build jobs, checksum generation, and APPLE secret branch logic. | +| `.github/workflows/release-artifacts.yml` | Release trigger wiring into canonical workflow | ✓ VERIFIED | Calls `./.github/workflows/release.yml` with published release tag input. | +| `.github/workflows/release-smoke.yml` | PR smoke enforcement of release contract | ✓ VERIFIED | Runs reusable workflow in dry-run and verifies exact expected artifact names. | +| `docs/releasing.md` | Maintainer contract for release/smoke/installer flows | ✓ VERIFIED | Documents release artifact names, signing policy, smoke checks, and installer publication URLs. | +| `scripts/install.sh` | macOS installer entrypoint and checksum gate | ✓ VERIFIED | Implements target flags, user/global paths, latest-release asset lookup, checksum checks, and staged mutation behavior. | +| `scripts/tests/install_smoke.sh` | Automated installer contract checks | ✓ VERIFIED | Deterministic fixture-driven tests for default/flags/global/checksum-failure and optional version assertion. | +| `INSTALL.md` | User install/update/uninstall/PATH docs | ✓ VERIFIED | Equal script/DMG guidance, update flags, uninstall paths, and PATH troubleshooting. | +| `README.md` | Discoverable install-doc pointer | ✓ VERIFIED | Links users to `INSTALL.md` as primary install surface. | +| `crates/workpot-cli/src/main.rs` | `update` CLI wiring + exit mapping | ✓ VERIFIED | Defines `Update` subcommand, forwards to update service, and maps updater error categories to exits 1/2. | +| `crates/workpot-cli/src/update.rs` | Updater implementation and checksum-verified replacement | ✓ VERIFIED | Implements presence-based target selection, release fetch, checksum verification, and per-target update paths. | +| `crates/workpot-cli/tests/update_smoke.rs` | Updater behavior contract tests | ✓ VERIFIED | Covers target selection, flags/global, already-current, error taxonomy, checksum fail-closed, and tray replacement path. | + +### Key Link Verification + +| From | To | Via | Status | Details | +| --- | --- | --- | --- | --- | +| `.github/workflows/release-artifacts.yml` | `.github/workflows/release.yml` | `workflow_call` | ✓ WIRED | Uses `./.github/workflows/release.yml` and passes release tag from event payload. | +| `docs/releasing.md` | release workflows + installer contract | documented maintainer flow | ✓ WIRED | Explicit references to `release-artifacts`, `release-smoke`, DMG artifacts, and installer URLs. | +| `crates/workpot-cli/src/main.rs` | `crates/workpot-cli/src/update.rs` | clap dispatch | ✓ WIRED | `Commands::Update` dispatches to `update::run_update(UpdateArgs { ... })`. | +| `crates/workpot-cli/src/update.rs` | GitHub release assets | latest-release fetch + checksum verification | ✓ WIRED | Uses GitHub Releases latest endpoint and validates `.sha256` assets before replacement. | +| `scripts/install.sh` | GitHub release assets | latest release lookup/download | ✓ WIRED | Fetches release JSON, resolves asset URLs by name, downloads and verifies checksums before install. | +| `INSTALL.md` | `scripts/install.sh` | documented convenience/versioned URLs | ✓ WIRED | Provides both `raw.githubusercontent.com/.../scripts/install.sh` and release-tag installer URL forms. | + +### Data-Flow Trace (Level 4) + +| Artifact | Data Variable | Source | Produces Real Data | Status | +| --- | --- | --- | --- | --- | +| `scripts/install.sh` | `release_json`, asset URLs | `curl .../releases/latest` (or injected fixture JSON) | Yes | ✓ FLOWING | +| `crates/workpot-cli/src/update.rs` | `Release { tag_name, assets }` | GitHub latest release API (or fixture JSON) | Yes | ✓ FLOWING | +| `.github/workflows/release.yml` | artifact filenames/checksums | build outputs + `shasum` steps | Yes | ✓ FLOWING | + +### Behavioral Spot-Checks + +| Behavior | Command | Result | Status | +| --- | --- | --- | --- | +| Installer CLI surface available | `bash scripts/install.sh --help` | Usage and required flags printed | ✓ PASS | +| Installer contract (default/flags/global/checksum/version) | `bash scripts/tests/install_smoke.sh --assert-version-match` | All smoke checks passed | ✓ PASS | +| Updater contract and exit taxonomy | `cargo test -p workpot-cli --test update_smoke -- --nocapture` | 7 passed; 0 failed | ✓ PASS | + +### Probe Execution + +| Probe | Command | Result | Status | +| --- | --- | --- | --- | +| n/a | n/a | No declared probes in phase plans/summaries; no `scripts/**/probe-*.sh` found | ? SKIPPED | + +### Requirements Coverage + +| Requirement | Source Plan | Description | Status | Evidence | +| --- | --- | --- | --- | --- | +| SC-01 | `06.1-01-PLAN.md` | Release artifacts + signing policy contract | ✓ SATISFIED | Workflow artifact matrix and signing branch policy implemented and smoke-validated. | +| SC-02 | `06.1-03-PLAN.md` | Script install yields usable versioned CLI | ✓ SATISFIED | Installer smoke `--assert-version-match` confirms installed CLI version equals release version fixture. | +| SC-03 | `06.1-02-PLAN.md` | `workpot update` upgrades safely with clear failure modes | ✓ SATISFIED | Updater implementation and smoke tests cover success/current/network/install/checksum paths. | +| SC-04 | `06.1-03-PLAN.md` | INSTALL documentation completeness | ✓ SATISFIED | `INSTALL.md` includes script+DMG, update, uninstall, PATH guidance. | +| SC-05 | `06.1-01-PLAN.md` | Maintainer flow + smoke aligns with artifact contract | ✓ SATISFIED | `docs/releasing.md` + `release-smoke.yml` explicitly enforce same artifact/tag contract. | + +Note: `SC-*` IDs are phase-specific success-criteria identifiers and are not enumerated in `.planning/REQUIREMENTS.md` requirement namespace. + +### Anti-Patterns Found + +| File | Line | Pattern | Severity | Impact | +| --- | --- | --- | --- | --- | +| none | n/a | No `TBD`/`FIXME`/`XXX`, TODO/HACK placeholders, or empty-implementation stub markers in phase artifacts | ℹ️ Info | No blocker-level debt markers detected in verified files. | + +### Human Verification Required + +### 1. Signed/notarized release path + +**Test:** Trigger `release.yml`/`release-artifacts.yml` for a real release with full `APPLE_*` secrets configured. +**Expected:** Signed/notarized/stapled DMG path runs, and published release contains `Workpot-X.Y.Z-aarch64.dmg` + `.sha256`. +**Why human:** Apple notarization + GitHub Actions secret-backed behavior cannot be exercised in local static verification. + +### 2. Published release contract end-to-end + +**Test:** Publish a real `vX.Y.Z` release and validate assets plus installer URL contracts from release page. +**Expected:** Release exposes `workpot-macos-aarch64.tar.gz` + `.sha256`, `Workpot-X.Y.Z-aarch64.dmg` + `.sha256`, and versioned installer URL works. +**Why human:** Requires live GitHub release event and remote artifact publication checks. + +### Gaps Summary + +No code-level implementation gaps were found in phase 06.1 must-haves. Status remains `human_needed` because release publication and Apple-signing/notarization outcomes require live external-system verification. + +--- + +_Verified: 2026-05-31T18:08:36Z_ +_Verifier: Claude (gsd-verifier)_ diff --git a/.planning/phases/06.2-tray-ux-polish/.gitkeep b/.planning/phases/06.2-tray-ux-polish/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.planning/phases/06.2-tray-ux-polish/06.2-01-PLAN.md b/.planning/phases/06.2-tray-ux-polish/06.2-01-PLAN.md new file mode 100644 index 0000000..504520b --- /dev/null +++ b/.planning/phases/06.2-tray-ux-polish/06.2-01-PLAN.md @@ -0,0 +1,233 @@ +--- +phase: 06.2 +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - crates/workpot-core/src/infra/migrations/007_alias.sql + - crates/workpot-core/src/infra/migrations.rs + - crates/workpot-core/src/domain/repo.rs + - crates/workpot-core/src/services/catalog.rs + - crates/workpot-core/src/services/org.rs + - src-tauri/src/commands.rs +autonomous: true +requirements: [UX-POLISH] + +must_haves: + truths: + - "alias persists in SQLite across restarts" + - "RepoRecord carries alias as Option" + - "RepoDto carries alias as Option serialized to frontend" + - "TrayConfigDto carries stale_dirty_days" + - "set_alias IPC command persists alias via AppContext" + - "list_repos catalog query reads alias column" + artifacts: + - path: "crates/workpot-core/src/infra/migrations/007_alias.sql" + provides: "ALTER TABLE repos ADD COLUMN alias TEXT NULL" + contains: "alias" + - path: "crates/workpot-core/src/domain/repo.rs" + provides: "RepoRecord with alias field" + exports: ["alias"] + - path: "src-tauri/src/commands.rs" + provides: "set_alias command, alias in RepoDto, stale_dirty_days in TrayConfigDto" + exports: ["set_alias", "alias", "stale_dirty_days"] + key_links: + - from: "crates/workpot-core/src/infra/migrations/007_alias.sql" + to: "crates/workpot-core/src/infra/migrations.rs" + via: "include_str! MIGRATION_007" + pattern: "MIGRATION_007" + - from: "crates/workpot-core/src/services/catalog.rs" + to: "crates/workpot-core/src/domain/repo.rs" + via: "list_repos SELECT includes alias column at index 15" + pattern: "alias" + - from: "src-tauri/src/commands.rs" + to: "crates/workpot-core" + via: "record_to_dto maps record.alias to RepoDto.alias" + pattern: "alias: record.alias" +--- + + +Add the `alias` column to the SQLite schema and propagate it through the full Rust data pipeline: +migration -> RepoRecord -> catalog SELECT -> org.rs set_alias -> Tauri RepoDto + TrayConfigDto. + +This plan establishes the data layer so Wave 2 plans (tray UI, CLI) can consume alias without +discovering schema details themselves. + +Purpose: Aliases allow users to give repos human-friendly names; fuzzy search and display in both +tray and CLI must use alias when set (per locked decision, CONTEXT.md). + +Output: Migration 007, updated RepoRecord, updated catalog.rs list_repos query, new org::set_alias, +updated RepoDto + record_to_dto, stale_dirty_days in TrayConfigDto. + + + +@/Users/rubenlr/.claude/get-shit-done/workflows/execute-plan.md +@/Users/rubenlr/.claude/get-shit-done/templates/summary.md + + + +@.planning/ROADMAP.md +@.planning/phases/06.2-tray-ux-polish/06.2-CONTEXT.md +@.planning/phases/06.2-tray-ux-polish/06.2-PATTERNS.md +@crates/workpot-core/src/domain/repo.rs +@crates/workpot-core/src/infra/migrations.rs +@crates/workpot-core/src/infra/migrations/006_org.sql +@crates/workpot-core/src/services/catalog.rs +@crates/workpot-core/src/services/org.rs +@src-tauri/src/commands.rs + + + + + + Task 1: Migration 007 + RepoRecord.alias + catalog list_repos + + crates/workpot-core/src/infra/migrations/007_alias.sql, + crates/workpot-core/src/infra/migrations.rs, + crates/workpot-core/src/domain/repo.rs, + crates/workpot-core/src/services/catalog.rs + + + - crates/workpot-core/src/infra/migrations/006_org.sql (copy ADD COLUMN pattern) + - crates/workpot-core/src/infra/migrations.rs (copy static + M::up slice pattern) + - crates/workpot-core/src/domain/repo.rs (insert alias after tags field) + - crates/workpot-core/src/services/catalog.rs (find list_repos SELECT and row mapping, find register_manual return literal) + + + - After migration runs, `PRAGMA table_info(repos)` includes a row with name="alias", type="TEXT", notnull=0 + - RepoRecord constructed with alias: None compiles without error + - list_repos returns alias as Option from column index 15 + - register_manual return literal sets alias: None + - All existing unit tests in workpot-core still pass + + + Create `crates/workpot-core/src/infra/migrations/007_alias.sql` containing exactly: + `ALTER TABLE repos ADD COLUMN alias TEXT NULL;` + + In `migrations.rs`: add `static MIGRATION_007: &str = include_str!("migrations/007_alias.sql");` + and add `M::up(MIGRATION_007)` as the 7th entry in the `steps` array. + + In `domain/repo.rs`: add `pub alias: Option,` as the last field in `RepoRecord`, after + `pub tags: Vec`. Doc comment: `/// Display name override (06.2); None means use folder \`name\`.` + + In `catalog.rs` `list_repos`: extend the SELECT to include `, alias` after `notes` (column index + 15). In the row mapping closure add `alias: row.get(15)?,` after `notes: row.get(14)?,`. + In `register_manual` return literal add `alias: None,`. + + In `catalog.rs` `upsert_scan`: the INSERT/UPDATE does not set alias (alias is user-only data; scan + must not overwrite it). No change needed to upsert_scan. + + Every `RepoRecord { ... }` literal in workpot-core source and tests must add `alias: None`. The + canonical place is the `make_repo` / `sample_record` helper functions in test files — update them + so callers follow automatically. Search for `RepoRecord {` in the crate and patch each literal. + + + cargo test -p workpot-core 2>&1 | tail -5 + + + `cargo test -p workpot-core` exits 0. `007_alias.sql` exists. `RepoRecord` has `alias: + Option`. `list_repos` SELECT includes `alias` column. + + + + + Task 2: org::set_alias + Tauri set_alias command + RepoDto/TrayConfigDto alias fields + + crates/workpot-core/src/services/org.rs, + src-tauri/src/commands.rs + + + - crates/workpot-core/src/services/org.rs (copy set_notes pattern for set_alias) + - src-tauri/src/commands.rs lines 1-120 (RepoDto struct, record_to_dto, TrayConfigDto, validate_tag pattern for validate_alias) + - src-tauri/src/commands.rs lines 190-210 (set_notes command as set_alias template) + - crates/workpot-core/src/lib.rs (check AppContext public API surface to wire set_alias delegation) + + + - org::set_alias("", alias) returns Err(NotFound) + - org::set_alias(valid_path, None) sets alias to NULL in DB (clears alias) + - org::set_alias(valid_path, Some("")) returns Err(InvalidInput) — empty alias is rejected + - org::set_alias(valid_path, Some("x".repeat(65))) returns Err(InvalidInput) — alias > 64 chars + - org::set_alias(valid_path, Some("my-alias")) stores trimmed value; list_repos returns it + - Tauri set_alias command validates and persists alias via AppContext + - RepoDto has alias: Option; record_to_dto maps record.alias + - TrayConfigDto has stale_dirty_days: u32; tray_config_from maps config.stale_dirty_days + + + In `org.rs`: add `pub fn set_alias(conn: &Connection, repo_path: &str, alias: Option<&str>) -> + Result<()>`. Call `ensure_repo_exists`. If alias is Some, trim it; reject empty string with + `WorkpotError::InvalidInput("alias must not be empty".into())` and length > 64 with + `WorkpotError::InvalidInput("alias exceeds 64 characters".into())`. Execute + `UPDATE repos SET alias = ?1 WHERE path = ?2` with the trimmed value or NULL. Return + `Err(WorkpotError::NotFound)` when 0 rows updated. + + In `crates/workpot-core/src/lib.rs` (AppContext): add public delegation method + `pub fn set_alias(&self, repo_path: &str, alias: Option<&str>) -> Result<()>` that calls + `org::set_alias(&self.conn, repo_path, alias)`. + + In `commands.rs` `RepoDto` struct: add `pub alias: Option,` after `name`. In + `record_to_dto`: add `alias: record.alias,` after `name: record.name`. In `TrayConfigDto`: + add `pub stale_dirty_days: u32,` after `max_pinned`. In `tray_config_from`: add + `stale_dirty_days: config.stale_dirty_days,` after `max_pinned: config.max_pinned`. + + In `commands.rs`: add `fn validate_alias(alias: &str) -> Result<(), String>` — trim and check + non-empty, len <= 64 (same bounds as org::set_alias). Add + `#[tauri::command] pub fn set_alias(repo_path: String, alias: Option, + state: State<'_, Arc>>) -> Result<(), String>` — if alias is Some, call + `validate_alias(alias.trim())?` before acquiring lock; then `ctx.set_alias(&repo_path, + alias.as_deref().map(str::trim)).map_err(|e| e.to_string())`. + + Register `set_alias` in `src-tauri/src/lib.rs` (or wherever other commands are registered in + `.invoke_handler(tauri::generate_handler![...])`). + + + cargo test -p workpot-core -p workpot-cli 2>&1 | tail -5 + + + `cargo test -p workpot-core -p workpot-cli` exits 0. `set_alias` is exported from workpot-core + and registered as a Tauri command. `RepoDto.alias` and `TrayConfigDto.stale_dirty_days` fields + are present. `cargo build -p workpot-core` exits 0. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Tauri IPC → Rust commands | User-supplied alias string crosses from untrusted frontend | +| Rust → SQLite | Alias string is parameter-bound; no SQL injection surface | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-06.2-01-01 | Tampering | set_alias IPC input | mitigate | validate_alias rejects empty string and length > 64 before DB write; trimmed before persistence | +| T-06.2-01-02 | DoS | alias column unbounded string | mitigate | 64-char hard limit enforced in validate_alias and org::set_alias; rusqlite params! prevents injection | +| T-06.2-01-SC | Tampering | npm/pip/cargo installs | accept | No new external packages in this plan; existing workpot-core dependencies only | + + + +```bash +cargo test -p workpot-core -p workpot-cli +cargo build --workspace +``` +All tests pass. Workspace builds clean. `007_alias.sql` present. `RepoRecord.alias` compiles. + + + +- `crates/workpot-core/src/infra/migrations/007_alias.sql` exists containing `ALTER TABLE repos ADD COLUMN alias TEXT NULL;` +- `RepoRecord` struct has `pub alias: Option` field +- `catalog::list_repos` SELECT query includes `alias` at column 15 and maps it to `RepoRecord.alias` +- `org::set_alias` function rejects empty alias and alias > 64 chars, accepts None (clears) +- `RepoDto` has `pub alias: Option` field +- `TrayConfigDto` has `pub stale_dirty_days: u32` field +- `set_alias` registered in Tauri invoke_handler +- `cargo test -p workpot-core -p workpot-cli` exits 0 + + + +Create `.planning/phases/06.2-tray-ux-polish/06.2-01-SUMMARY.md` when done. + diff --git a/.planning/phases/06.2-tray-ux-polish/06.2-01-SUMMARY.md b/.planning/phases/06.2-tray-ux-polish/06.2-01-SUMMARY.md new file mode 100644 index 0000000..80a50cb --- /dev/null +++ b/.planning/phases/06.2-tray-ux-polish/06.2-01-SUMMARY.md @@ -0,0 +1,143 @@ +--- +phase: 06.2-tray-ux-polish +plan: 01 +subsystem: database +tags: [sqlite, migration, tauri, alias, ipc] + +requires: + - phase: 05-tags-prioritization + provides: org IPC patterns, RepoRecord org fields, migrations 006 +provides: + - Migration 007 alias column on repos + - RepoRecord.alias and catalog list_repos hydration + - org::set_alias and AppContext::set_alias + - Tauri set_alias command, RepoDto.alias, TrayConfigDto.stale_dirty_days +affects: [06.2-02, 06.2-03, 06.2-04, 06.2-05] + +tech-stack: + added: [] + patterns: + - "User-only alias column: upsert_scan does not overwrite alias" + - "set_alias validates trim, non-empty, max 64 before SQLite UPDATE" + +key-files: + created: + - crates/workpot-core/src/infra/migrations/007_alias.sql + - crates/workpot-core/tests/alias_migration_test.rs + modified: + - crates/workpot-core/src/domain/repo.rs + - crates/workpot-core/src/services/catalog.rs + - crates/workpot-core/src/services/org.rs + - crates/workpot-core/src/domain/config.rs + - src-tauri/src/commands.rs + - src-tauri/src/lib.rs + +key-decisions: + - "Alias is user-edited only; discovery upsert_scan leaves alias unchanged" + - "stale_dirty_days added to Config in plan 01 so TrayConfigDto can compile (plan 02 adds has_stale_dirty)" + +patterns-established: + - "org::set_alias mirrors set_notes with ensure_repo_exists and InvalidInput bounds" + +requirements-completed: [UX-POLISH] + +duration: 25min +completed: 2026-05-31 +--- + +# Phase 06.2 Plan 01: Alias schema and core DTO pipeline Summary + +**SQLite migration 007 adds nullable `alias`, propagated through RepoRecord, catalog, org::set_alias, and Tauri RepoDto/TrayConfigDto with IPC validation** + +## Performance + +- **Duration:** 25 min +- **Started:** 2026-05-31T20:05:00Z (approx) +- **Completed:** 2026-05-31T20:29:55Z +- **Tasks:** 2 +- **Files modified:** 16 + +## Accomplishments + +- Migration 007 and `list_repos` read `alias` at column index 15 +- `org::set_alias` with trim, empty/64-char rejection, NULL clear, NotFound on missing repo +- Tauri `set_alias` command registered; `RepoDto.alias` and `TrayConfigDto.stale_dirty_days` exposed + +## Task Commits + +Each task was committed atomically (TDD: test then feat per task): + +1. **Task 1: Migration 007 + RepoRecord.alias + catalog list_repos** + - RED: `5767d80` test(06.2-01): add failing tests for alias migration 007 + - GREEN: `a64345a` feat(06.2-01): migration 007 and RepoRecord.alias in catalog +2. **Task 2: org::set_alias + Tauri set_alias + DTO fields** + - RED: `49ff05f` test(06.2-01): add failing set_alias and tray DTO tests + - GREEN: `61bf197` feat(06.2-01): set_alias IPC and alias fields on DTOs + +**Plan metadata:** pending (docs commit after STATE/ROADMAP) + +## Files Created/Modified + +- `crates/workpot-core/src/infra/migrations/007_alias.sql` — `ALTER TABLE repos ADD COLUMN alias TEXT NULL` +- `crates/workpot-core/tests/alias_migration_test.rs` — migration and list_repos alias coverage +- `crates/workpot-core/src/services/org.rs` — `set_alias` persistence and validation +- `crates/workpot-core/src/domain/config.rs` — `stale_dirty_days` default 7 and validate 1–365 +- `src-tauri/src/commands.rs` — DTO fields, `validate_alias`, `set_alias` command +- `src-tauri/src/lib.rs` — invoke_handler registration + +## Decisions Made + +- Pulled `Config.stale_dirty_days` into this plan so `tray_config_from` compiles; plan 06.2-02 still owns `has_stale_dirty` service logic +- `bootstrap_test` user_version expectation bumped to 7 for migration count + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 2 - Missing Critical] Config.stale_dirty_days for TrayConfigDto** +- **Found during:** Task 2 (tray_config_from mapping) +- **Issue:** Plan 01 references `config.stale_dirty_days` but field was scheduled only in plan 02 +- **Fix:** Added serde default (7), Default impl, and validate bounds 1–365 in `config.rs` +- **Files modified:** `crates/workpot-core/src/domain/config.rs` +- **Committed in:** `61bf197` + +**2. [Rule 3 - Blocking] bootstrap migration version assertion** +- **Found during:** Task 1 verification (`cargo test -p workpot-core`) +- **Issue:** `migrations_apply` expected user_version 6 after adding migration 007 +- **Fix:** Updated expected version to 7 +- **Files modified:** `crates/workpot-core/tests/bootstrap_test.rs` +- **Committed in:** `a64345a` + +--- + +**Total deviations:** 2 auto-fixed (1 Rule 2, 1 Rule 3) +**Impact on plan:** Required for compile and test correctness; no scope beyond plan 01 success criteria + +## TDD Gate Compliance + +- Task 1: `5767d80` (test) → `a64345a` (feat) ✓ +- Task 2: `49ff05f` (test) → `61bf197` (feat) ✓ + +## Issues Encountered + +- `cli_smoke::search_filters_by_fuzzy_query` failed once under full parallel run; passed in isolation and on full re-run (flaky ordering) + +## User Setup Required + +None + +## Next Phase Readiness + +- Wave 2 can consume `RepoDto.alias`, `set_alias` IPC, and `TrayConfigDto.stale_dirty_days` +- Plan 06.2-02 should add `has_stale_dirty` without re-adding config field (already present) +- Frontend `types.ts` alias field is plan 06.2-04 + +## Self-Check: PASSED + +- FOUND: crates/workpot-core/src/infra/migrations/007_alias.sql +- FOUND: crates/workpot-core/tests/alias_migration_test.rs +- FOUND: 5767d80, a64345a, 49ff05f, 61bf197 in git log + +--- +*Phase: 06.2-tray-ux-polish* +*Completed: 2026-05-31* diff --git a/.planning/phases/06.2-tray-ux-polish/06.2-02-PLAN.md b/.planning/phases/06.2-tray-ux-polish/06.2-02-PLAN.md new file mode 100644 index 0000000..9953782 --- /dev/null +++ b/.planning/phases/06.2-tray-ux-polish/06.2-02-PLAN.md @@ -0,0 +1,169 @@ +--- +phase: 06.2 +plan: 02 +type: tdd +wave: 1 +depends_on: [] +files_modified: + - crates/workpot-core/src/domain/config.rs + - crates/workpot-core/src/services/stale_dirty.rs + - crates/workpot-core/tests/stale_dirty_test.rs +autonomous: true +requirements: [UX-POLISH] + +must_haves: + truths: + - "Config.stale_dirty_days exists with default 7, serde default fn, and validate() bounds check 1-365" + - "has_stale_dirty(repos, days, now) returns true only when at least one repo is dirty AND age >= threshold" + - "dirty + never-opened repo is treated as stale immediately (last_opened_at=None -> i64::MAX age)" + - "clean repo is never stale-dirty regardless of last_opened_at" + - "all edge cases covered by unit tests" + artifacts: + - path: "crates/workpot-core/src/domain/config.rs" + provides: "stale_dirty_days field with default and validation" + contains: "stale_dirty_days" + - path: "crates/workpot-core/src/services/stale_dirty.rs" + provides: "has_stale_dirty pure function" + exports: ["has_stale_dirty"] + - path: "crates/workpot-core/tests/stale_dirty_test.rs" + provides: "RED gate test file" + contains: "stale_dirty" + key_links: + - from: "crates/workpot-core/src/services/stale_dirty.rs" + to: "crates/workpot-core/src/domain/repo.rs" + via: "takes &[RepoRecord] slice" + pattern: "RepoRecord" + - from: "src-tauri/src/commands.rs" + to: "crates/workpot-core/src/services/stale_dirty.rs" + via: "has_stale_dirty called from update_tray_icon_state (Plan 04)" + pattern: "has_stale_dirty" +--- + + +TDD plan for the stale-dirty detection policy. + +This is pure business logic with well-defined inputs and outputs, making it an ideal TDD candidate. +The function must be correct before the tray icon state machine (Plan 04) can consume it. + +Purpose: Force-define the exact semantics of "stale-dirty" (including the never-opened fallback) +via tests before writing a line of implementation. This eliminates ambiguity from the locked +decision's edge case. + +Output: `Config.stale_dirty_days` field + `workpot_core::services::stale_dirty::has_stale_dirty` +pure function with full unit test coverage. + + + +@/Users/rubenlr/.claude/get-shit-done/workflows/execute-plan.md +@/Users/rubenlr/.claude/get-shit-done/templates/summary.md + + + +@.planning/ROADMAP.md +@.planning/phases/06.2-tray-ux-polish/06.2-CONTEXT.md +@.planning/phases/06.2-tray-ux-polish/06.2-PATTERNS.md +@crates/workpot-core/src/domain/config.rs +@crates/workpot-core/src/domain/repo.rs +@crates/workpot-core/src/services/repo_fuzzy.rs +@crates/workpot-core/src/lib.rs + + + + Stale-dirty detection logic + + crates/workpot-core/src/services/stale_dirty.rs, + crates/workpot-core/tests/stale_dirty_test.rs, + crates/workpot-core/src/domain/config.rs + + + Function signature: + `pub fn has_stale_dirty(repos: &[RepoRecord], stale_dirty_days: u32, now_secs: i64) -> bool` + + All inputs/outputs: + + 1. Empty repo list → false + 2. Single clean repo (is_dirty=Some(false)), opened 0 days ago → false (not dirty) + 3. Single dirty repo (is_dirty=Some(true)), opened 0 secs ago (now_secs=last_opened_at) → false (just opened, age=0 < threshold) + 4. Single dirty repo, opened exactly `threshold_secs` ago → true (age == threshold is stale) + 5. Single dirty repo, opened `threshold_secs - 1` seconds ago → false (age just under threshold) + 6. Single dirty repo, last_opened_at=None (never opened) → true (dirty + never-opened = immediately stale) + 7. Single bare repo (is_dirty=None), never opened → false (bare repos are not dirty) + 8. Multiple repos: only one is dirty and stale → true + 9. Multiple repos: all clean → false + 10. stale_dirty_days=1 with dirty repo opened 86_399 secs ago → false (1 second short of 1 day) + 11. stale_dirty_days=1 with dirty repo opened 86_400 secs ago → true (exactly 1 day) + + Config validation: + 12. Config.validate() with stale_dirty_days=0 → Err("stale_dirty_days 0 must be between 1 and 365") + 13. Config.validate() with stale_dirty_days=366 → Err("stale_dirty_days 366 must be between 1 and 365") + 14. Config.validate() with stale_dirty_days=7 → Ok(()) + 15. Config::default().stale_dirty_days == 7 + 16. TOML with no stale_dirty_days field deserializes to 7 (serde default) + + + RED: Write failing tests in `crates/workpot-core/tests/stale_dirty_test.rs` covering all 16 + cases. The `has_stale_dirty` function does not exist yet; tests will fail to compile (compile + error counts as RED). Also add stale_dirty_days tests to the existing config validate tests. + + Note: Plan 01 adds `alias: None` to RepoRecord — this TDD plan runs in Wave 1 parallel to Plan + 01. The make_repo helper in stale_dirty_test.rs must include `alias: None` in the RepoRecord + literal (field is present after Plan 01 lands; coordinate or add it defensively). + + Actually, since Plan 01 and Plan 02 run in parallel and both modify RepoRecord, this TDD plan + should define its own local `make_dirty_repo` / `make_clean_repo` helpers that include `alias: + None` from the start (matching the target state). If Plan 01 is not merged yet when Plan 02 + runs, the test file will fail to compile until alias is present — that is acceptable as the RED + phase of TDD. When Plan 01 lands first, the tests compile and fail on missing `has_stale_dirty` + function. + + GREEN: Add `stale_dirty_days` to `Config` struct with `fn default_stale_dirty_days() -> u32 { 7 }`, + `#[serde(default = "default_stale_dirty_days")]`, Default impl assignment, and validation bounds + check in `Config::validate()` (copy max_recent_days pattern at lines 134-138 of config.rs). + Create `crates/workpot-core/src/services/stale_dirty.rs` with the `has_stale_dirty` function. + Register in `src/services/mod.rs` (or wherever services are pub mod'd). Export from + `crates/workpot-core/src/lib.rs`. + + The function body: + - threshold_secs = stale_dirty_days as i64 * 86_400 + - repos.iter().any(|r| r.is_dirty == Some(true) && { let age = match r.last_opened_at { Some(t) => now_secs - t, None => i64::MAX }; age >= threshold_secs }) + + REFACTOR: If the function body is identical to the pattern in PATTERNS.md, no refactor needed. + Rename any unclear variable names if present. + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| config.toml → Config struct | stale_dirty_days is user-provided config value | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-06.2-02-01 | DoS | stale_dirty_days=0 causes division or infinite icon loop | mitigate | Config::validate() rejects 0; bounds 1-365 enforced before any consumer reads the value | +| T-06.2-02-02 | Tampering | stale-dirty policy tied to max_recent_days | mitigate | stale_dirty_days is a separate config field; validate() and default are independent from max_recent_days | +| T-06.2-02-SC | Tampering | npm/pip/cargo installs | accept | No new packages; pure Rust logic using existing workpot-core types | + + + +```bash +cargo test -p workpot-core stale_dirty 2>&1 | tail -10 +cargo test -p workpot-core 2>&1 | tail -5 +``` +All stale_dirty tests pass. Full workpot-core suite passes. + + + +- Failing test file committed as RED gate: `test(06.2-02): add failing tests for stale-dirty detection` +- Implementation committed as GREEN gate: `feat(06.2-02): implement has_stale_dirty and stale_dirty_days config` +- All 16 behavior cases pass +- `Config.stale_dirty_days` defaults to 7, validates 1-365 +- `cargo test -p workpot-core` exits 0 + + + +Create `.planning/phases/06.2-tray-ux-polish/06.2-02-SUMMARY.md` when done. + diff --git a/.planning/phases/06.2-tray-ux-polish/06.2-02-SUMMARY.md b/.planning/phases/06.2-tray-ux-polish/06.2-02-SUMMARY.md new file mode 100644 index 0000000..e86a6ad --- /dev/null +++ b/.planning/phases/06.2-tray-ux-polish/06.2-02-SUMMARY.md @@ -0,0 +1,107 @@ +--- +phase: 06.2-tray-ux-polish +plan: 02 +subsystem: core +tags: [rust, tdd, tray, stale-dirty, config] + +requires: + - phase: 06.2-01 + provides: RepoRecord.alias, Config.stale_dirty_days field and validate bounds +provides: + - has_stale_dirty pure policy on RepoRecord with injectable now_secs + - 16-case integration test suite in stale_dirty_test.rs +affects: + - 06.2-04 (tray icon state machine consumes policy) + - 06.2-09 (has_stale_dirty_dto bridge test) + +tech-stack: + added: [] + patterns: + - "Stale-dirty age: now_secs - last_opened_at; None last_opened_at => i64::MAX for dirty repos" + - "Threshold inclusive at boundary (age >= threshold_secs)" + +key-files: + created: + - crates/workpot-core/src/services/stale_dirty.rs + - crates/workpot-core/tests/stale_dirty_test.rs + modified: + - crates/workpot-core/src/services/mod.rs + - crates/workpot-core/src/lib.rs + +key-decisions: + - "has_stale_dirty takes now_secs parameter (testable; tray DTO helper uses SystemTime in plan 04)" + - "Config.stale_dirty_days left in plan 01; plan 02 only adds service + tests (no duplicate config work)" + +patterns-established: + - "Policy services as pure functions on domain types under services/" + +requirements-completed: [UX-POLISH] + +duration: 12min +completed: 2026-05-31 +--- + +# Phase 06.2 Plan 02: Stale-dirty detection Summary + +**Pure `has_stale_dirty` policy with 16 TDD cases; never-opened dirty repos stale immediately; config bounds already from 06.2-01.** + +## Performance + +- **Duration:** ~12 min +- **Started:** 2026-05-31T20:35:00Z (approx) +- **Completed:** 2026-05-31T20:47:00Z (approx) +- **Tasks:** 2 (RED + GREEN; no REFACTOR) +- **Files modified:** 4 + +## TDD Gates + +| Gate | Commit | Evidence | +|------|--------|----------| +| RED | `9252f6d` | `cargo test -p workpot-core stale_dirty` → E0432 unresolved `stale_dirty` module | +| GREEN | `6e1aefc` | `cargo test -p workpot-core --test stale_dirty_test` → 16/16 pass | + +## Accomplishments + +- `has_stale_dirty(repos, stale_dirty_days, now_secs)` — dirty + age ≥ threshold; bare repos excluded +- Never-opened dirty repos: `last_opened_at == None` → `i64::MAX` age (immediate stale) +- Full test matrix: empty list, boundary ±1s, multi-repo, 1-day threshold, config validate/serde +- Re-exported from `workpot_core` for tray/CLI consumers + +## Task Commits + +1. **RED: failing tests** — `9252f6d` (test) +2. **GREEN: implementation** — `6e1aefc` (feat) + +**Plan metadata:** `1532cec` (docs: complete plan) + +## Files Created/Modified + +- `crates/workpot-core/tests/stale_dirty_test.rs` — 16 behavior tests + `make_repo` helpers with `alias: None` +- `crates/workpot-core/src/services/stale_dirty.rs` — `has_stale_dirty` implementation +- `crates/workpot-core/src/services/mod.rs` — `pub mod stale_dirty` +- `crates/workpot-core/src/lib.rs` — `pub use has_stale_dirty` + +## Deviations from Plan + +### Auto-fixed Issues + +None — plan executed as written. + +### Intentional scope note + +**Config field (cases 12–16):** `stale_dirty_days` default, serde, and `validate()` were delivered in plan 06.2-01. Plan 02 tests those behaviors without re-adding config code (GREEN commit is service-only). + +## Self-Check + +``` +FOUND: crates/workpot-core/src/services/stale_dirty.rs +FOUND: crates/workpot-core/tests/stale_dirty_test.rs +FOUND: 9252f6d +FOUND: 6e1aefc +``` + +## Self-Check: PASSED + +## TDD Gate Compliance + +RED (`9252f6d`) precedes GREEN (`6e1aefc`). No REFACTOR commit (body matched PATTERNS.md; no cleanup needed). diff --git a/.planning/phases/06.2-tray-ux-polish/06.2-03-PLAN.md b/.planning/phases/06.2-tray-ux-polish/06.2-03-PLAN.md new file mode 100644 index 0000000..3155c1a --- /dev/null +++ b/.planning/phases/06.2-tray-ux-polish/06.2-03-PLAN.md @@ -0,0 +1,143 @@ +--- +phase: 06.2 +plan: 03 +type: tdd +wave: 1 +depends_on: [] +files_modified: + - crates/workpot-core/src/services/repo_fuzzy.rs + - crates/workpot-core/tests/repo_fuzzy_test.rs +autonomous: true +requirements: [UX-POLISH] + +must_haves: + truths: + - "fuzzy_score matches alias when alias is set and query matches alias but not name" + - "fuzzy_score still matches name when alias is set and query matches name" + - "alias uses name_bonus=true so alias prefix beats path-only matches" + - "repo with alias=None behaves identically to pre-alias behavior" + - "alias empty string (unwrap_or '') does not produce false positives" + artifacts: + - path: "crates/workpot-core/src/services/repo_fuzzy.rs" + provides: "alias_score in fuzzy_score, alias in make_repo helper" + contains: "alias_score" + - path: "crates/workpot-core/tests/repo_fuzzy_test.rs" + provides: "alias-focused test cases" + contains: "alias" + key_links: + - from: "crates/workpot-core/src/services/repo_fuzzy.rs" + to: "crates/workpot-core/src/domain/repo.rs" + via: "reads repo.alias.as_deref().unwrap_or('')" + pattern: "alias" +--- + + +TDD plan for alias-aware dual-field fuzzy matching. + +The fuzzy scorer currently matches name, path, branch, notes, and tags. Alias must be added as a +6th field with name_bonus=true so alias prefix queries beat path-only matches. + +Purpose: Define exact alias scoring behavior via tests before touching the scorer. Prevents alias +being display-only (which would violate the locked decision for fuzzy parity between tray and CLI). + +Output: Extended `fuzzy_score` function that includes `alias_score`, with test coverage for all +alias matching cases. + + + +@/Users/rubenlr/.claude/get-shit-done/workflows/execute-plan.md +@/Users/rubenlr/.claude/get-shit-done/templates/summary.md + + + +@.planning/ROADMAP.md +@.planning/phases/06.2-tray-ux-polish/06.2-CONTEXT.md +@.planning/phases/06.2-tray-ux-polish/06.2-PATTERNS.md +@crates/workpot-core/src/services/repo_fuzzy.rs +@crates/workpot-core/src/domain/repo.rs + + + + Fuzzy dual-field matching with alias + + crates/workpot-core/src/services/repo_fuzzy.rs, + crates/workpot-core/tests/repo_fuzzy_test.rs + + + New test helper: `fn aliased(name: &str, alias: &str) -> RepoRecord` — constructs a RepoRecord + with alias=Some(alias.to_string()) using the existing make_repo helper as base. + + Note on Wave 1 parallelism: Plan 01 adds `alias: None` to RepoRecord. This plan runs in parallel. + The `make_repo` helper in repo_fuzzy.rs (inline tests) must include `alias: None`. Add it + immediately when writing the RED test file. If Plan 01 is not yet merged, the compile error is + the RED gate. When Plan 01 is merged first, the RED gate is a runtime test failure on missing + alias_score in fuzzy_score. + + Test cases: + 1. aliased("workpot", "wp") — fuzzy_score("wp", repo) > 0 (alias match) + 2. aliased("workpot", "wp") — fuzzy_score("workpot", repo) > 0 (name still matches) + 3. aliased("some-long-name", "sln") — fuzzy_score("sln", repo) > 0; a repo named "sln" with no alias also scores > 0 + 4. aliased("alpha", "my-project") — fuzzy_score("my-project", repo) > 0 (alias is primary match) + 5. make_repo("workpot", "/tmp/workpot", None, None, vec![]) with alias=None — fuzzy_score("workpot", repo) same as before (no regression) + 6. aliased("alpha", "beta") — fuzzy_score("zzz", repo) == 0 (neither name nor alias matches) + 7. aliased("alpha", "beta") with query "lph" — matches name "alpha" via subsequence (name_score > 0) + 8. aliased("alpha", "beta") with query "bet" — matches alias "beta" via substring (alias_score > 0) + 9. Alias score with prefix match: aliased("x", "myproject") — fuzzy_score("myp", repo) > fuzzy_score("myp", make_repo("x", "/myp/x", ...)) — alias prefix bonus applies + 10. Alias-only repo with empty query "" → score == 1 (empty query matches all, unchanged) + + + RED: Add `alias` field to the existing `make_repo` helper inside `repo_fuzzy.rs` (the inline + #[cfg(test)] block), setting it to `None`. Create `crates/workpot-core/tests/repo_fuzzy_test.rs` + (or extend if it already exists) with the `aliased()` helper and all 10 test cases. Tests + referencing `alias_score` behavior will fail to compile or return wrong values since + `fuzzy_score` does not yet include alias scoring. + + GREEN: In `repo_fuzzy.rs` `fuzzy_score` function, add: + `let alias_score = score_field(&q, &repo.alias.as_deref().unwrap_or("").to_lowercase(), true);` + after `name_score`. Update `base_max` to include `alias_score`: + `let base_max = name_score.max(alias_score).max(path_score).max(branch_score).max(notes_score);` + + Also update the `make_repo` helper in the inline `#[cfg(test)]` block to add `alias: None,` + (required after Plan 01 adds the field). + + REFACTOR: Ensure the alias field reference uses `repo.alias.as_deref()` not `.clone()`. + No other refactor expected for this minimal change. + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| User query input → fuzzy scorer | Query already length-guarded at 256 chars (existing T-06-02-01) | +| Alias value from DB → scorer | Alias passed through as_deref/unwrap_or — no new trust surface | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-06.2-03-01 | Integrity | Alias-only search diverges tray vs CLI | mitigate | alias_score added to shared workpot-core fuzzy_score; both tray (TypeScript calls Rust) and CLI use same scorer | +| T-06.2-03-02 | DoS | Alias field unbounded in scorer | accept | Scorer is in-process; alias is already bounded at 64 chars by org::set_alias; scorer guard at 256 query chars still applies | +| T-06.2-03-SC | Tampering | npm/pip/cargo installs | accept | No new packages; pure Rust extension of existing scorer | + + + +```bash +cargo test -p workpot-core repo_fuzzy 2>&1 | tail -10 +cargo test -p workpot-core 2>&1 | tail -5 +``` +All repo_fuzzy tests pass including alias cases. Full workpot-core suite passes. + + + +- Failing test committed as RED gate: `test(06.2-03): add failing alias fuzzy match tests` +- Implementation committed as GREEN gate: `feat(06.2-03): add alias dual-field scoring to fuzzy_score` +- All 10 alias test cases pass +- All pre-existing repo_fuzzy tests still pass (no regression) +- `cargo test -p workpot-core` exits 0 + + + +Create `.planning/phases/06.2-tray-ux-polish/06.2-03-SUMMARY.md` when done. + diff --git a/.planning/phases/06.2-tray-ux-polish/06.2-03-SUMMARY.md b/.planning/phases/06.2-tray-ux-polish/06.2-03-SUMMARY.md new file mode 100644 index 0000000..bc75918 --- /dev/null +++ b/.planning/phases/06.2-tray-ux-polish/06.2-03-SUMMARY.md @@ -0,0 +1,96 @@ +--- +phase: 06.2-tray-ux-polish +plan: 03 +subsystem: search +tags: [fuzzy, alias, rust, tdd] + +requires: + - phase: 06.2-01 + provides: RepoRecord.alias field on core DTO +provides: + - alias_score in fuzzy_score with name_bonus=true + - 10 alias-focused integration tests in repo_fuzzy_test.rs +affects: [06.2-05, tray-filter, cli-search] + +tech-stack: + added: [] + patterns: + - "Alias scored via as_deref().unwrap_or('') — empty alias does not false-positive" + - "alias_score participates in base_max alongside name_score with name_bonus" + +key-files: + created: [] + modified: + - crates/workpot-core/src/services/repo_fuzzy.rs + - crates/workpot-core/tests/repo_fuzzy_test.rs + +key-decisions: + - "Alias uses same name_bonus as repo name so prefix queries on alias beat path-only matches" + +patterns-established: + - "aliased() test helper mirrors named() for dual-field fuzzy cases" + +requirements-completed: [UX-POLISH] + +duration: 12min +completed: 2026-05-31 +--- + +# Phase 06.2 Plan 03: Alias dual-field fuzzy Summary + +**Shared `fuzzy_score` now scores alias with name_bonus so tray and CLI find repos by nickname without diverging from name/path behavior** + +## Performance + +- **Duration:** 12 min +- **Started:** 2026-05-31T21:00:00Z (approx) +- **Completed:** 2026-05-31T21:12:00Z (approx) +- **Tasks:** 1 (TDD feature block) +- **Files modified:** 2 + +## Accomplishments + +- `alias_score` added to `fuzzy_score` with `name_bonus=true`, folded into `base_max` +- `aliased()` helper and 10 alias test cases in `repo_fuzzy_test.rs` +- All 23 integration + 11 unit `repo_fuzzy` tests pass + +## Task Commits + +TDD RED → GREEN: + +1. **RED:** `d27d3b4` test(06.2-03): add failing alias fuzzy match tests +2. **GREEN:** `dff06c0` feat(06.2-03): add alias dual-field scoring to fuzzy_score + +**Plan metadata:** `16ca243` docs(06.2-03): complete alias fuzzy dual-field plan + +## Files Created/Modified + +- `crates/workpot-core/src/services/repo_fuzzy.rs` — `alias_score` in `fuzzy_score` +- `crates/workpot-core/tests/repo_fuzzy_test.rs` — `aliased()` + 10 alias tests + +## Deviations from Plan + +None — plan executed exactly as written. + +## TDD Gate Compliance + +- RED commit `d27d3b4` present (3 tests failed: primary alias, substring, prefix bonus) +- GREEN commit `dff06c0` present after RED +- No REFACTOR commit (not needed) + +## Verification + +```bash +cargo test -p workpot-core --test repo_fuzzy_test # 23 passed +cargo test -p workpot-core --lib repo_fuzzy # 11 passed +cargo test -p workpot-core --lib # 31 passed +``` + +`cargo test -p workpot-core` exits 0 (all integration + lib tests). + +## Self-Check: PASSED + +- FOUND: crates/workpot-core/src/services/repo_fuzzy.rs +- FOUND: crates/workpot-core/tests/repo_fuzzy_test.rs +- FOUND: commit d27d3b4 +- FOUND: commit dff06c0 diff --git a/.planning/phases/06.2-tray-ux-polish/06.2-04-PLAN.md b/.planning/phases/06.2-tray-ux-polish/06.2-04-PLAN.md new file mode 100644 index 0000000..dcd759a --- /dev/null +++ b/.planning/phases/06.2-tray-ux-polish/06.2-04-PLAN.md @@ -0,0 +1,255 @@ +--- +phase: 06.2 +plan: 04 +type: execute +wave: 2 +depends_on: [06.2-01, 06.2-02] +files_modified: + - src/routes/+page.svelte + - src/lib/types.ts + - src-tauri/src/commands.rs + - src-tauri/src/tray.rs + - src-tauri/icons/tray-stale-dirty.png + - src-tauri/icons/tray-syncing-0.png + - src-tauri/icons/tray-syncing-1.png +autonomous: true +requirements: [UX-POLISH] + +must_haves: + truths: + - "plain click on a list row opens Cursor and closes the panel" + - "cmd+click on a row opens detail pane without launching Cursor" + - "info badge button on each row opens detail pane without launching" + - "bare repos (branch=null) show no branch span in the row" + - "row displays alias when set, folder name when alias is null" + - "menu-bar icon shows stale-dirty asset when has_stale_dirty is true after refresh" + - "menu-bar icon shows default asset when no repo is stale-dirty" + - "menu-bar icon shows syncing frame while git refresh is in progress" + - "any-dirty-only icon policy is removed" + artifacts: + - path: "src/routes/+page.svelte" + provides: "corrected click handlers, alias display, bare-branch omission, info badge" + contains: "detailRepo = repo" + - path: "src-tauri/src/commands.rs" + provides: "update_tray_icon_state replacing update_tray_dirty_icon" + contains: "update_tray_icon_state" + - path: "src-tauri/src/tray.rs" + provides: "TrayIcons with stale_dirty + syncing frames" + contains: "stale_dirty" + - path: "src/lib/types.ts" + provides: "RepoDto.alias and TrayConfigDto.stale_dirty_days TypeScript fields" + contains: "alias" + key_links: + - from: "src/routes/+page.svelte" + to: "src-tauri/src/commands.rs" + via: "invoke('open_in_cursor') from plain-click handler" + pattern: "open_in_cursor" + - from: "src-tauri/src/commands.rs" + to: "crates/workpot-core/src/services/stale_dirty.rs" + via: "has_stale_dirty called in update_tray_icon_state" + pattern: "has_stale_dirty" + - from: "src-tauri/src/tray.rs" + to: "src-tauri/icons/tray-stale-dirty.png" + via: "include_bytes!" + pattern: "tray-stale-dirty" +--- + + +Wire the corrected interaction model and new icon state machine into the tray surfaces. + +This plan depends on Plan 01 (alias in RepoDto) and Plan 02 (has_stale_dirty + stale_dirty_days +in Config). It also corrects the panel row click behavior, adds the info badge, removes +bare-branch placeholders, and replaces the any-dirty tray icon with the tri-state machine. + +Purpose: These are the highest-visibility UX corrections — wrong click behavior and noisy icon +are the two most-reported friction points. + +Output: Corrected +page.svelte row handlers, updated types.ts, updated TrayIcons struct with new +icon assets, update_tray_icon_state function replacing update_tray_dirty_icon. + + + +@/Users/rubenlr/.claude/get-shit-done/workflows/execute-plan.md +@/Users/rubenlr/.claude/get-shit-done/templates/summary.md + + + +@.planning/ROADMAP.md +@.planning/phases/06.2-tray-ux-polish/06.2-CONTEXT.md +@.planning/phases/06.2-tray-ux-polish/06.2-PATTERNS.md +@.planning/phases/06.2-tray-ux-polish/06.2-01-SUMMARY.md +@.planning/phases/06.2-tray-ux-polish/06.2-02-SUMMARY.md +@src/routes/+page.svelte +@src/lib/types.ts +@src-tauri/src/commands.rs +@src-tauri/src/tray.rs + + + + + + Task 1: Tray row interaction model + alias display + bare-branch omission + + src/routes/+page.svelte, + src/lib/types.ts + + + - src/routes/+page.svelte lines 518-600 (current onclick/ondblclick handlers and row template) + - src/lib/types.ts (full file — extend RepoDto and TrayConfigDto) + - .planning/phases/06.2-tray-ux-polish/06.2-PATTERNS.md section "+page.svelte" (target onclick pattern, info badge, bare-branch, alias display) + + + In `src/lib/types.ts`: + - Add `alias: string | null;` to `RepoDto` interface after `name`. + - Add `stale_dirty_days: number;` to `TrayConfigDto` interface after `max_pinned`. + + In `src/routes/+page.svelte` row button handlers (the `onclick` and `ondblclick` at lines + 528-537): + - Replace `onclick` to: set `selectedIndex = idx;` then if `e.metaKey` set + `detailRepo = repo;` (no launch), else call `void openSelected(false);` (plain click = launch + close). + - Remove `ondblclick` entirely. + + In the row body at line 559 where `{repo.name}` is rendered inside ``: + - Replace with `{repo.alias ?? repo.name}`. + + In the branch span at line 565 where `{repo.branch ?? "—"}` is rendered: + - Replace the entire `` element with a conditional block: only render the branch span + `{#if repo.branch}...{/if}`. When branch is null, render nothing (no `"—"` placeholder). + Preserve the existing CSS classes on the span. + + Add an info badge button after the closing `` of the dirty-dot span and before the + branch span. The badge button has `type="button"`, `aria-label="Open detail"`, stops + propagation with `onclick|stopPropagation`, and sets `selectedIndex = idx; detailRepo = repo;`. + Use classes: `ml-auto shrink-0 rounded px-1 text-xs text-neutral-400 hover:text-neutral-700 + dark:hover:text-neutral-200`. Content: `ⓘ`. + + Layout note: the branch span already has `ml-auto` — move `ml-auto` to the info badge button + and remove it from the branch span so the badge is rightmost and branch is to its left. + + In the `trayConfig` state type at line 51-55: add `stale_dirty_days: number;` to the inline + type annotation so the loaded config includes the new field. + + + npm run build 2>&1 | tail -10 + + + `npm run build` exits 0 (TypeScript compilation clean). Row has no `ondblclick`. Plain click + invokes openSelected(false). Cmd+click sets detailRepo without launching. Info badge button + present. Branch span is conditional on repo.branch truthy. Row shows `repo.alias ?? repo.name`. + `types.ts` has `alias: string | null` on RepoDto and `stale_dirty_days: number` on TrayConfigDto. + + + + + Task 2: Tray icon state machine — stale-dirty + syncing assets + update_tray_icon_state + + src-tauri/src/tray.rs, + src-tauri/src/commands.rs, + src-tauri/icons/tray-stale-dirty.png, + src-tauri/icons/tray-syncing-0.png, + src-tauri/icons/tray-syncing-1.png + + + - src-tauri/src/tray.rs (full file — TrayIcons struct, embedded_tray_icon helper, setup_tray icon registration) + - src-tauri/src/commands.rs lines 298-353 (update_tray_dirty_icon function + spawn_background_git_refresh) + - src-tauri/icons/tray-default.png (read file size to understand dimensions for new assets) + - .planning/phases/06.2-tray-ux-polish/06.2-PATTERNS.md section "tray.rs" (TrayIcons struct target, syncing_frame helper) + + + Create icon assets: copy `src-tauri/icons/tray-default.png` as a baseline to produce three new + icon files. Use the `convert` ImageMagick command or Bash to duplicate the existing PNG and + mark it with a colored overlay to produce visually distinct stale-dirty and syncing frames. + The exact approach: run `cp src-tauri/icons/tray-default.png src-tauri/icons/tray-stale-dirty.png` + and `cp src-tauri/icons/tray-default.png src-tauri/icons/tray-syncing-0.png` and + `cp src-tauri/icons/tray-default.png src-tauri/icons/tray-syncing-1.png`. These are + placeholder assets — a human checkpoint (Plan 09 or visual UAT) will replace them with + designed assets. The code path must be wired and compilable now. + + In `src-tauri/src/tray.rs`: + - Rename `TrayIcons.dirty` to `stale_dirty` and change its type to + `tauri::image::Image<'static>`. + - Add `pub syncing: Vec>,` as the third field. + - Add `impl TrayIcons { pub fn syncing_frame(&self, idx: usize) -> &tauri::image::Image<'static> + { &self.syncing[idx % self.syncing.len()] } }`. + - In `setup_tray`: replace `let dirty_icon = embedded_tray_icon(include_bytes!(...tray-dirty.png));` + with `let stale_dirty_icon = embedded_tray_icon(include_bytes!("../icons/tray-stale-dirty.png"));` + and add `let syncing_frame0 = embedded_tray_icon(include_bytes!("../icons/tray-syncing-0.png"));` + and `let syncing_frame1 = embedded_tray_icon(include_bytes!("../icons/tray-syncing-1.png"));`. + - Update `app.manage(TrayIcons { default: ..., stale_dirty: stale_dirty_icon, syncing: vec![syncing_frame0, syncing_frame1] })`. + + In `src-tauri/src/commands.rs`: + - Replace `update_tray_dirty_icon(app: &AppHandle, any_dirty: bool)` with + `pub(crate) fn update_tray_icon_state(app: &AppHandle, repos: &[RepoDto], stale_dirty_days: u32, syncing: bool)`. + - The function body: get tray by id "main"; get TrayIcons from app state; compute + `let icon = if syncing { icons.syncing_frame(0).clone() } else if has_stale_dirty_dto(repos, stale_dirty_days) { icons.stale_dirty.clone() } else { icons.default.clone() }`; + call `tray.set_icon(Some(icon))`. + - Add a private helper `fn has_stale_dirty_dto(repos: &[RepoDto], stale_dirty_days: u32) -> bool` + in commands.rs (module-private, not pub): compute `threshold_secs = stale_dirty_days as i64 * 86_400`; + compute `now` via `std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0)`; + return `repos.iter().any(|r| r.is_dirty == Some(true) && { let age = match r.last_opened_at { Some(t) => now - t, None => i64::MAX }; age >= threshold_secs })`. + Note: the core `has_stale_dirty` from Plan 02 takes `&[RepoRecord]`; this DTO-layer helper + avoids a lossy RepoRecord conversion. Plan 09 Task 2 validates that has_stale_dirty_dto + produces identical results to has_stale_dirty for equivalent inputs. + - In `spawn_background_git_refresh`: replace the `update_tray_dirty_icon(&app, s.any_dirty)` + call with `update_tray_icon_state(&app, &[], stale_dirty_days, false)` temporarily — but + the correct approach is to fetch the current repos list from state and pass it. Fetch repos + from the AppContext lock after persisting git refresh results, then call + `update_tray_icon_state(&app, &dtos, config.stale_dirty_days, false)` where `dtos` are + the repo_records_to_dtos result. Acquire the lock once to persist, then once more to fetch + repos and config for the icon update. Alternatively, persist and fetch in a single lock: after + `persist_git_refresh_results`, also call `ctx.list_repos()` and `ctx.config()` inside the same + lock scope. + - Add `syncing: true` call at the START of `spawn_background_git_refresh` before the rayon + work begins: call `update_tray_icon_state(&app, &[], 7, true)` (syncing=true; repos empty + is fine for syncing state since syncing overrides). + + + cargo build --workspace 2>&1 | grep -E "^error" ; grep -rn "update_tray_dirty_icon" src-tauri/src/ | grep -v "^Binary" ; grep -n "stale_dirty" src-tauri/src/tray.rs | head -5 + + + `cargo build --workspace` exits 0 (no lines matching `^error`). `grep -rn "update_tray_dirty_icon" src-tauri/src/` returns 0 matches (old name gone). `grep -n "stale_dirty" src-tauri/src/tray.rs` shows the field. `TrayIcons.dirty` field no longer exists; `TrayIcons.stale_dirty` and `TrayIcons.syncing` fields present. `update_tray_icon_state` present in commands.rs. Three new icon files exist in `src-tauri/icons/`. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Frontend click event → Tauri invoke | Plain click triggers open_in_cursor; cmd+click is UI-only state change | +| Tray icon state → macOS menu bar | Icon swap is local OS call; no user data exposed | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-06.2-04-01 | Tampering | onclick handler launches on every click including cmd+click | mitigate | Atomic replacement of onclick + removal of ondblclick; metaKey branch sets detailRepo only | +| T-06.2-04-02 | DoS | syncing icon cycle never terminates | mitigate | syncing=true is set at start of refresh and syncing=false is set in the Ok branch of spawn_background_git_refresh completion; Err branch also calls update with syncing=false via fallback | +| T-06.2-04-03 | Integrity | stale-dirty icon uses any_dirty policy after patch | mitigate | update_tray_dirty_icon call site in spawn_background_git_refresh replaced unconditionally; compiler error if old name referenced | +| T-06.2-04-SC | Tampering | npm/pip/cargo installs | accept | No new packages; Tauri + workpot-core already in workspace | + + + +```bash +cargo build --workspace 2>&1 | grep -E "^error" +npm run build 2>&1 | grep -E "error TS" +grep -rn "update_tray_dirty_icon" src-tauri/src/ +``` +Zero error lines in first two outputs. Third command returns zero matches (old function gone). + + + +- `src/routes/+page.svelte`: no `ondblclick`, plain click calls `openSelected(false)`, cmd+click sets `detailRepo`, info badge present, branch conditional on truthy, row shows alias ?? name +- `src/lib/types.ts`: `RepoDto.alias: string | null` and `TrayConfigDto.stale_dirty_days: number` present +- `src-tauri/src/tray.rs`: `TrayIcons` has `stale_dirty` and `syncing` fields; `syncing_frame` method present +- `src-tauri/src/commands.rs`: `update_tray_dirty_icon` removed (grep returns 0 matches); `update_tray_icon_state` present with syncing/stale-dirty/default branches +- Three icon PNG files exist in `src-tauri/icons/` +- `cargo build --workspace` exits 0 with no `^error` lines +- `npm run build` exits 0 with no TypeScript errors + + + +Create `.planning/phases/06.2-tray-ux-polish/06.2-04-SUMMARY.md` when done. + diff --git a/.planning/phases/06.2-tray-ux-polish/06.2-04-SUMMARY.md b/.planning/phases/06.2-tray-ux-polish/06.2-04-SUMMARY.md new file mode 100644 index 0000000..c383759 --- /dev/null +++ b/.planning/phases/06.2-tray-ux-polish/06.2-04-SUMMARY.md @@ -0,0 +1,126 @@ +--- +phase: 06.2-tray-ux-polish +plan: 04 +subsystem: ui +tags: [tauri, svelte, tray, stale-dirty, cursor-launch] + +requires: + - phase: 06.2-01 + provides: RepoDto.alias and TrayConfigDto.stale_dirty_days in Rust IPC + - phase: 06.2-02 + provides: has_stale_dirty core logic and config default + +provides: + - Corrected tray row click model (plain launch, cmd+detail, info badge) + - Alias-first row title and bare-repo branch omission + - Tri-state menu-bar icon (default / stale-dirty / syncing) + +affects: + - 06.2-06 (detail header uses alias from types) + - 06.2-07 (Storybook row stories) + - 06.2-09 (interaction + has_stale_dirty_dto bridge tests) + +tech-stack: + added: [] + patterns: + - "Row container is div[role=option] with nested info button (avoids invalid button nesting)" + - "DTO-layer has_stale_dirty_dto mirrors core stale policy for tray icon without RepoRecord conversion" + +key-files: + created: + - src-tauri/icons/tray-stale-dirty.png + - src-tauri/icons/tray-syncing-0.png + - src-tauri/icons/tray-syncing-1.png + modified: + - src/routes/+page.svelte + - src/lib/types.ts + - src-tauri/src/tray.rs + - src-tauri/src/commands.rs + +key-decisions: + - "Row outer element changed from button to div[role=option] so info badge can be a real button without invalid HTML" + - "Placeholder tray PNGs duplicate tray-default until Plan 09 / visual UAT supplies designed assets" + +patterns-established: + - "Plain click → openSelected(false); metaKey → detailRepo only; info badge → detailRepo with stopPropagation" + - "update_tray_icon_state: syncing overrides stale-dirty overrides default" + +requirements-completed: [UX-POLISH] + +duration: 22min +completed: 2026-05-31 +--- + +# Phase 06.2 Plan 04: Tray Interaction + Icon State Machine Summary + +**Tray rows use plain-click-to-open and cmd/info-for-detail; menu-bar icon reflects stale-dirty policy and git refresh syncing instead of any-dirty.** + +## Performance + +- **Duration:** 22 min +- **Started:** 2026-05-31T23:12:00Z +- **Completed:** 2026-05-31T23:34:00Z +- **Tasks:** 2 +- **Files modified:** 15 + +## Accomplishments + +- Fixed inverted row gestures: single click launches Cursor and closes panel; cmd+click and ⓘ open detail without launch +- Rows show `alias ?? name` and omit branch text when `branch` is null (no em dash placeholder) +- Replaced `update_tray_dirty_icon` with `update_tray_icon_state` (default / stale-dirty / syncing frames) + +## Task Commits + +1. **Task 1: Tray row interaction model + alias display + bare-branch omission** - `9082057` (feat) +2. **Task 2: Tray icon state machine — stale-dirty + syncing assets + update_tray_icon_state** - `da98d25` (feat) + +**Plan metadata:** pending (docs commit) + +## Files Created/Modified + +- `src/routes/+page.svelte` — click handlers, info badge, alias display, conditional branch +- `src/lib/types.ts` — `RepoDto.alias`, `TrayConfigDto.stale_dirty_days` +- `src-tauri/src/tray.rs` — `TrayIcons.stale_dirty`, `syncing`, `syncing_frame()` +- `src-tauri/src/commands.rs` — `update_tray_icon_state`, `has_stale_dirty_dto`, refresh hook +- `src-tauri/icons/tray-*.png` — placeholder assets (copies of default) + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] Nested button invalid in Svelte 5** +- **Found during:** Task 1 verify (`npm run build`) +- **Issue:** Info badge ` +

+ {repo.alias ?? repo.name} +

+ -

{repo.name}

- +

@@ -183,22 +201,16 @@ {:else if branches.length === 0}

No branches

{:else} -
    - {#each branches as b (b)} -
  • - - {b} - -
  • +
    + {#each branches as b (b.name)} + {/each} -
+ {/if}

+
+

Tags @@ -208,19 +220,33 @@ void handleRemoveTag(tag)} /> {/each} - { - if (tagInput.trim()) { - void handleAddTag(tagInput); - } - }} - /> +
+ { + if (tagInput.trim()) { + void handleAddTag(tagInput); + } + }} + /> + 0 && tagSuggestTags.length > 0} + prefix={tagInput.trim()} + onSelect={(tag) => { + void handleAddTag(tag); + }} + /> +
{#if tagError}

{tagError}

{/if} @@ -236,8 +262,10 @@ maxlength="500" bind:value={notesValue} placeholder="Add notes..." - class="w-full resize-none rounded-md border border-neutral-200 bg-transparent p-2 text-sm dark:border-neutral-700 focus:outline-none focus:ring-1 focus:ring-blue-500" - style="max-height: calc(5 * 1.5rem)" + autocomplete="off" + autocapitalize="off" + spellcheck="false" + class="max-h-[calc(5*1.5rem)] w-full resize-none rounded-md border border-neutral-200 bg-transparent p-2 text-sm dark:border-neutral-700 focus:outline-none focus:ring-1 focus:ring-blue-500" onblur={() => void handleNotesSave()} >

diff --git a/src/lib/components/DetailPane.test.ts b/src/lib/components/DetailPane.test.ts new file mode 100644 index 0000000..426374c --- /dev/null +++ b/src/lib/components/DetailPane.test.ts @@ -0,0 +1,117 @@ +import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import DetailPane from "./DetailPane.svelte"; +import type { RepoDto } from "../types"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn().mockResolvedValue([]), +})); + +const baseRepo: RepoDto = { + path: "/tmp/testrepo", + name: "testrepo", + alias: null, + branch: "main", + is_dirty: false, + parent_dir: "~/tmp", + last_opened_at: null, + git_state_error: null, + pinned: false, + pin_order: null, + notes: null, + tags: [], + branches: [], +}; + +function renderPane( + repo: RepoDto, + opts: { + allTags?: string[]; + onClose?: () => void; + onMutated?: () => void; + } = {}, +) { + const onClose = opts.onClose ?? vi.fn(); + const onMutated = opts.onMutated ?? vi.fn(); + const result = render(DetailPane, { + props: { + repo, + allTags: opts.allTags ?? [], + onClose, + onMutated, + }, + }); + return { ...result, onClose, onMutated }; +} + +describe("DetailPane", () => { + afterEach(() => { + cleanup(); + }); + + it("header_shows_alias_when_set", () => { + const { getByRole } = renderPane({ + ...baseRepo, + name: "folder", + alias: "myalias", + }); + expect(getByRole("heading", { level: 2 }).textContent).toBe("myalias"); + }); + + it("pin_toggle_shows_pinned_icon_and_aria_pressed", () => { + const pinned = renderPane({ ...baseRepo, pinned: true }); + const pinnedBtn = pinned.getByRole("button", { name: "Unpin" }); + expect(pinnedBtn.getAttribute("aria-pressed")).toBe("true"); + expect(pinnedBtn.textContent).toContain("📌"); + + cleanup(); + + const unpinned = renderPane({ ...baseRepo, pinned: false }); + const unpinnedBtn = unpinned.getByRole("button", { name: "Pin" }); + expect(unpinnedBtn.getAttribute("aria-pressed")).toBe("false"); + expect(unpinnedBtn.textContent).toContain("📍"); + }); + + it("tag_input_has_os_correction_disabled", () => { + const { container } = renderPane(baseRepo); + const input = container.querySelector( + 'input[placeholder="Add tag…"]', + ) as HTMLInputElement; + expect(input).toBeTruthy(); + expect(input.getAttribute("autocomplete")).toBe("off"); + expect(input.getAttribute("autocapitalize")).toBe("off"); + expect(input.getAttribute("autocorrect")).toBe("off"); + expect(input.getAttribute("spellcheck")).toBe("false"); + }); + + it("notes_textarea_has_os_correction_disabled", () => { + const { container } = renderPane(baseRepo); + const notes = container.querySelector( + 'textarea[placeholder="Add notes..."]', + ) as HTMLTextAreaElement; + expect(notes).toBeTruthy(); + expect(notes.getAttribute("autocomplete")).toBe("off"); + expect(notes.getAttribute("autocapitalize")).toBe("off"); + expect(notes.getAttribute("spellcheck")).toBe("false"); + }); + + it("tag_suggestions_exclude_tags_already_on_repo", async () => { + const { container } = renderPane( + { ...baseRepo, tags: ["backend"] }, + { allTags: ["backend", "frontend", "rust"] }, + ); + const input = container.querySelector( + 'input[placeholder="Add tag…"]', + ) as HTMLInputElement; + expect(input).toBeTruthy(); + await fireEvent.input(input, { target: { value: "fr" } }); + await waitFor(() => { + expect(container.querySelector('button[role="option"]')).toBeTruthy(); + }); + const options = [ + ...container.querySelectorAll('button[role="option"]'), + ].map((el) => el.textContent); + expect(options).toContain("#frontend"); + expect(options).not.toContain("#backend"); + }); +}); diff --git a/src/lib/components/DetailPaneHeader.stories.svelte b/src/lib/components/DetailPaneHeader.stories.svelte new file mode 100644 index 0000000..10a9fc7 --- /dev/null +++ b/src/lib/components/DetailPaneHeader.stories.svelte @@ -0,0 +1,44 @@ + + + + + + + + + diff --git a/src/lib/components/RepoListRow.stories.svelte b/src/lib/components/RepoListRow.stories.svelte new file mode 100644 index 0000000..3c41973 --- /dev/null +++ b/src/lib/components/RepoListRow.stories.svelte @@ -0,0 +1,95 @@ + + + + + + + + + + + + + diff --git a/src/lib/components/RepoListRow.svelte b/src/lib/components/RepoListRow.svelte new file mode 100644 index 0000000..3eefd36 --- /dev/null +++ b/src/lib/components/RepoListRow.svelte @@ -0,0 +1,116 @@ + + +
+ + + {#if repo.tags.length > 0} +
+ {#each repo.tags as tag (tag)} + void onTagRemove(tag) : undefined} + onFilter={onTagFilter ? () => onTagFilter(tag) : undefined} + /> + {/each} +
+ {/if} +
diff --git a/src/lib/components/RepoListRow.test.ts b/src/lib/components/RepoListRow.test.ts new file mode 100644 index 0000000..827ffa3 --- /dev/null +++ b/src/lib/components/RepoListRow.test.ts @@ -0,0 +1,109 @@ +import { cleanup, fireEvent, render } from "@testing-library/svelte"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import RepoListRow from "./RepoListRow.svelte"; +import type { RepoDto } from "../types"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn().mockResolvedValue(undefined), +})); + +const mockRepo: RepoDto = { + path: "/tmp/testrepo", + name: "testrepo", + alias: null, + branch: "main", + is_dirty: false, + parent_dir: "~/tmp", + last_opened_at: null, + git_state_error: null, + pinned: false, + pin_order: null, + notes: null, + tags: [], + branches: [], +}; + +function renderRow( + repo: RepoDto, + callbacks: { + onOpen?: () => void; + onDetail?: () => void; + selected?: boolean; + } = {}, +) { + const onOpen = callbacks.onOpen ?? vi.fn(); + const onDetail = callbacks.onDetail ?? vi.fn(); + const result = render(RepoListRow, { + props: { + repo, + selected: callbacks.selected ?? false, + onOpen, + onDetail, + }, + }); + return { ...result, onOpen, onDetail }; +} + +describe("RepoListRow", () => { + afterEach(() => { + cleanup(); + }); + + it("plain_click_calls_onOpen_not_onDetail", async () => { + const onOpen = vi.fn(); + const onDetail = vi.fn(); + const { getByRole } = renderRow(mockRepo, { onOpen, onDetail }); + const openBtn = getByRole("button", { name: "Open testrepo" }); + await fireEvent.click(openBtn); + expect(onOpen).toHaveBeenCalledOnce(); + expect(onDetail).not.toHaveBeenCalled(); + }); + + it("cmd_click_calls_onDetail_not_onOpen", async () => { + const onOpen = vi.fn(); + const onDetail = vi.fn(); + const { getByRole } = renderRow(mockRepo, { onOpen, onDetail }); + const openBtn = getByRole("button", { name: "Open testrepo" }); + await fireEvent.click(openBtn, { metaKey: true }); + expect(onDetail).toHaveBeenCalledOnce(); + expect(onOpen).not.toHaveBeenCalled(); + }); + + it("info_badge_click_calls_onDetail_not_onOpen", async () => { + const onOpen = vi.fn(); + const onDetail = vi.fn(); + const { getByLabelText } = renderRow(mockRepo, { onOpen, onDetail }); + const badge = getByLabelText("Open detail"); + await fireEvent.click(badge); + expect(onDetail).toHaveBeenCalledOnce(); + expect(onOpen).not.toHaveBeenCalled(); + }); + + it("branch_rendered_when_present", () => { + const { getByText } = renderRow({ ...mockRepo, branch: "main" }); + expect(getByText("main")).toBeTruthy(); + }); + + it("branch_omitted_when_null", () => { + const { container } = renderRow({ ...mockRepo, branch: null }); + expect(container.textContent).not.toContain("—"); + }); + + it("alias_shown_when_set", () => { + const { getByText } = renderRow({ + ...mockRepo, + name: "folder", + alias: "myalias", + }); + expect(getByText("myalias")).toBeTruthy(); + }); + + it("folder_name_shown_when_alias_null", () => { + const { getByText } = renderRow({ + ...mockRepo, + name: "folder", + alias: null, + }); + expect(getByText("folder")).toBeTruthy(); + }); +}); diff --git a/src/lib/components/SectionHeader.test.ts b/src/lib/components/SectionHeader.test.ts new file mode 100644 index 0000000..2b1d5c0 --- /dev/null +++ b/src/lib/components/SectionHeader.test.ts @@ -0,0 +1,19 @@ +import { cleanup, render } from "@testing-library/svelte"; +import { afterEach, describe, expect, it } from "vitest"; +import SectionHeader from "./SectionHeader.svelte"; + +describe("SectionHeader", () => { + afterEach(() => { + cleanup(); + }); + + it("renders label text", () => { + const { getByText } = render(SectionHeader, { props: { label: "Pinned" } }); + expect(getByText("Pinned")).toBeTruthy(); + }); + + it("renders different labels correctly", () => { + const { getByText } = render(SectionHeader, { props: { label: "Recent" } }); + expect(getByText("Recent")).toBeTruthy(); + }); +}); diff --git a/src/lib/components/TagAutocomplete.test.ts b/src/lib/components/TagAutocomplete.test.ts new file mode 100644 index 0000000..858dbae --- /dev/null +++ b/src/lib/components/TagAutocomplete.test.ts @@ -0,0 +1,137 @@ +import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import TagAutocomplete from "./TagAutocomplete.svelte"; + +function renderAutocomplete(opts: { + allTags?: string[]; + visible?: boolean; + prefix?: string; + onSelect?: (tag: string) => void; +}) { + const onSelect = opts.onSelect ?? vi.fn(); + return { + ...render(TagAutocomplete, { + props: { + allTags: opts.allTags ?? ["rust", "frontend", "backend"], + visible: opts.visible ?? true, + prefix: opts.prefix ?? "", + onSelect, + }, + }), + onSelect, + }; +} + +describe("TagAutocomplete", () => { + afterEach(() => { + cleanup(); + }); + + it("hidden_when_visible_false", () => { + const { queryByRole } = renderAutocomplete({ visible: false }); + expect(queryByRole("listbox")).toBeNull(); + }); + + it("shown_when_visible_true", () => { + const { getByRole } = renderAutocomplete({ visible: true }); + expect(getByRole("listbox")).toBeTruthy(); + }); + + it("shows_all_tags_when_filter_empty", () => { + const { getAllByRole } = renderAutocomplete({ + allTags: ["rust", "frontend"], + visible: true, + }); + const options = getAllByRole("option"); + expect(options.length).toBe(2); + }); + + it("filters_tags_by_input_value", async () => { + const { container, getAllByRole } = renderAutocomplete({ + allTags: ["rust", "frontend", "backend"], + visible: true, + }); + const input = container.querySelector("input") as HTMLInputElement; + await fireEvent.input(input, { target: { value: "front" } }); + await waitFor(() => { + const options = getAllByRole("option"); + expect(options.length).toBe(1); + expect(options[0].textContent).toContain("frontend"); + }); + }); + + it("click_on_option_calls_onSelect", async () => { + const onSelect = vi.fn(); + const { getAllByRole } = renderAutocomplete({ + allTags: ["rust"], + visible: true, + onSelect, + }); + const [option] = getAllByRole("option"); + await fireEvent.click(option!); + expect(onSelect).toHaveBeenCalledWith("rust"); + }); + + it("enter_key_selects_highlighted_option", async () => { + const onSelect = vi.fn(); + const { getByRole } = renderAutocomplete({ + allTags: ["rust", "frontend"], + visible: true, + onSelect, + }); + const listbox = getByRole("listbox"); + await fireEvent.keyDown(listbox, { key: "ArrowDown" }); + await fireEvent.keyDown(listbox, { key: "Enter" }); + expect(onSelect).toHaveBeenCalledWith("rust"); + }); + + it("arrow_down_moves_highlight_forward", async () => { + const { getByRole, getAllByRole } = renderAutocomplete({ + allTags: ["rust", "frontend"], + visible: true, + }); + const listbox = getByRole("listbox"); + await fireEvent.keyDown(listbox, { key: "ArrowDown" }); + await fireEvent.keyDown(listbox, { key: "ArrowDown" }); + const options = getAllByRole("option"); + expect(options[1]?.getAttribute("aria-selected")).toBe("true"); + }); + + it("arrow_up_does_not_go_below_zero", async () => { + const { getByRole, getAllByRole } = renderAutocomplete({ + allTags: ["rust", "frontend"], + visible: true, + }); + const listbox = getByRole("listbox"); + await fireEvent.keyDown(listbox, { key: "ArrowDown" }); + await fireEvent.keyDown(listbox, { key: "ArrowUp" }); + const options = getAllByRole("option"); + expect(options[0]?.getAttribute("aria-selected")).toBe("true"); + }); + + it("enter_with_no_highlight_submits_input_value", async () => { + const onSelect = vi.fn(); + const { container, getByRole } = renderAutocomplete({ + allTags: ["rust"], + visible: true, + onSelect, + }); + const input = container.querySelector("input") as HTMLInputElement; + await fireEvent.input(input, { target: { value: "newTag" } }); + const listbox = getByRole("listbox"); + await fireEvent.keyDown(listbox, { key: "Enter" }); + expect(onSelect).toHaveBeenCalledWith("newTag"); + }); + + it("prefix_filters_to_tags_starting_with_prefix", () => { + const { queryByText } = renderAutocomplete({ + allTags: ["rust", "frontend", "react"], + visible: true, + prefix: "re", + }); + // only "react" starts with "re" + expect(queryByText("#react")).toBeTruthy(); + expect(queryByText("#rust")).toBeNull(); + expect(queryByText("#frontend")).toBeNull(); + }); +}); diff --git a/src/lib/components/TagChip.test.ts b/src/lib/components/TagChip.test.ts new file mode 100644 index 0000000..e05449b --- /dev/null +++ b/src/lib/components/TagChip.test.ts @@ -0,0 +1,62 @@ +import { cleanup, fireEvent, render } from "@testing-library/svelte"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import TagChip from "./TagChip.svelte"; + +describe("TagChip", () => { + afterEach(() => { + cleanup(); + }); + + it("renders_tag_text_with_hash", () => { + const { getByText } = render(TagChip, { props: { tag: "backend" } }); + expect(getByText("#backend")).toBeTruthy(); + }); + + it("remove_button_hidden_when_onRemove_undefined", () => { + const { queryByLabelText } = render(TagChip, { props: { tag: "rust" } }); + expect(queryByLabelText("Remove tag rust")).toBeNull(); + }); + + it("remove_button_shown_when_onRemove_provided", () => { + const { getByLabelText } = render(TagChip, { + props: { tag: "rust", onRemove: vi.fn() }, + }); + expect(getByLabelText("Remove tag rust")).toBeTruthy(); + }); + + it("plain_click_calls_onFilter_not_onRemove", async () => { + const onFilter = vi.fn(); + const onRemove = vi.fn(); + const { getByText } = render(TagChip, { + props: { tag: "rust", onFilter, onRemove }, + }); + await fireEvent.click(getByText("#rust")); + expect(onFilter).toHaveBeenCalledOnce(); + expect(onRemove).not.toHaveBeenCalled(); + }); + + it("cmd_click_calls_onRemove_not_onFilter", async () => { + const onFilter = vi.fn(); + const onRemove = vi.fn(); + const { getByText } = render(TagChip, { + props: { tag: "rust", onFilter, onRemove }, + }); + await fireEvent.click(getByText("#rust"), { metaKey: true }); + expect(onRemove).toHaveBeenCalledOnce(); + expect(onFilter).not.toHaveBeenCalled(); + }); + + it("remove_button_click_calls_onRemove", async () => { + const onRemove = vi.fn(); + const { getByLabelText } = render(TagChip, { + props: { tag: "rust", onRemove }, + }); + await fireEvent.click(getByLabelText("Remove tag rust")); + expect(onRemove).toHaveBeenCalledOnce(); + }); + + it("plain_click_with_no_handlers_does_not_throw", async () => { + const { getByText } = render(TagChip, { props: { tag: "noop" } }); + await expect(fireEvent.click(getByText("#noop"))).resolves.not.toThrow(); + }); +}); diff --git a/src/lib/components/repoStoryFixtures.ts b/src/lib/components/repoStoryFixtures.ts new file mode 100644 index 0000000..f94351c --- /dev/null +++ b/src/lib/components/repoStoryFixtures.ts @@ -0,0 +1,24 @@ +import type { RepoDto } from "../types"; + +/** Storybook-only path prefix — not a real publicly writable directory. */ +export const STORY_REPO_PATH_PREFIX = "/Users/storybook/Developer"; + +export const storyRepoBase: RepoDto = { + path: `${STORY_REPO_PATH_PREFIX}/workpot-demo`, + name: "workpot", + alias: null, + branch: "main", + is_dirty: null, + parent_dir: "~/projects", + last_opened_at: null, + git_state_error: null, + pinned: false, + pin_order: null, + notes: null, + tags: [], + branches: ["main", "develop"], +}; + +export function storyRepo(overrides: Partial): RepoDto { + return { ...storyRepoBase, ...overrides }; +} diff --git a/src/lib/detailRepoSync.test.ts b/src/lib/detailRepoSync.test.ts index d7244b6..823e2d2 100644 --- a/src/lib/detailRepoSync.test.ts +++ b/src/lib/detailRepoSync.test.ts @@ -5,6 +5,7 @@ import type { RepoDto } from "./types"; function repo(name: string, tags: string[] = []): RepoDto { return { name, + alias: null, path: `/tmp/${name}`, branch: null, is_dirty: null, @@ -23,11 +24,9 @@ describe("resyncDetailRepo", () => { it("returns updated row when tags change after reload", () => { const before = repo("alpha", ["old"]); const after = [repo("alpha", ["new", "extra"])]; - expect(resyncDetailRepo(after, before.path)).toEqual(after[0]); - expect(resyncDetailRepo(after, before.path)?.tags).toEqual([ - "new", - "extra", - ]); + const synced = resyncDetailRepo(after, before.path); + expect(synced?.path).toBe(before.path); + expect(synced?.tags).toEqual(["new", "extra"]); }); it("returns null when repo was removed", () => { @@ -53,6 +52,8 @@ describe("resyncDetailIfOpen", () => { it("resyncs when detail pane is still open", () => { const open = repo("alpha", ["old"]); const repos = [repo("alpha", ["new"])]; - expect(resyncDetailIfOpen(repos, open)).toEqual(repos[0]); + const synced = resyncDetailIfOpen(repos, open); + expect(synced?.path).toBe(open.path); + expect(synced?.tags).toEqual(["new"]); }); }); diff --git a/src/lib/fuzzy.test.ts b/src/lib/fuzzy.test.ts index 91a4581..8a3f15d 100644 --- a/src/lib/fuzzy.test.ts +++ b/src/lib/fuzzy.test.ts @@ -6,6 +6,7 @@ function repo(partial: Partial & Pick): RepoDto { return { path: partial.path ?? `/Users/me/c/${partial.name}`, name: partial.name, + alias: partial.alias ?? null, branch: partial.branch ?? "main", is_dirty: partial.is_dirty ?? null, parent_dir: "", @@ -83,6 +84,24 @@ describe("fuzzyMatch", () => { expect(fuzzyMatch("backend", r)).toBe(true); }); + it("matches alias when name does not", () => { + const r = repo({ name: "workpot-core", alias: "wp" }); + expect(fuzzyMatch("wp", r)).toBe(true); + expect(fuzzyMatch("wp", repo({ name: "alpha", alias: null }))).toBe(false); + }); + + it("scores alias prefix above path-only match", () => { + const byAlias = repo({ name: "x", alias: "workpot", path: "/tmp/a" }); + const byPath = repo({ + name: "y", + alias: null, + path: "/tmp/workpot-extra", + }); + expect(fuzzyScore("work", byAlias)).toBeGreaterThan( + fuzzyScore("work", byPath), + ); + }); + it("does not match unrelated query on note-only repo", () => { const r = repo({ name: "x", diff --git a/src/lib/fuzzy.ts b/src/lib/fuzzy.ts index d1da141..ec31eb5 100644 --- a/src/lib/fuzzy.ts +++ b/src/lib/fuzzy.ts @@ -49,6 +49,7 @@ export function fuzzyScore(query: string, repo: RepoDto): number { const scores = [ scoreField(q, repo.name, true), + scoreField(q, repo.alias ?? "", true), scoreField(q, repo.path, false), scoreField(q, repo.branch ?? "", false), scoreField(q, repo.notes ?? "", false), diff --git a/src/lib/gitRefresh.test.ts b/src/lib/gitRefresh.test.ts index 6509d87..820a7da 100644 --- a/src/lib/gitRefresh.test.ts +++ b/src/lib/gitRefresh.test.ts @@ -25,20 +25,15 @@ describe("gitRefreshErrorMessage", () => { ); }); - it("reports partial failure", () => { - expect(gitRefreshErrorMessage(summary({ refreshed: 1, errors: 1 }))).toBe( - "Git refresh completed with 1 error(s).", - ); + it("returns null on partial failure (per-repo errors stay on rows)", () => { + expect( + gitRefreshErrorMessage(summary({ refreshed: 1, errors: 1 })), + ).toBeNull(); }); }); describe("shouldClearListErrorOnRefreshLoad", () => { - it("clears only when there are no errors", () => { - expect(shouldClearListErrorOnRefreshLoad(summary({ refreshed: 1 }))).toBe( - true, - ); - expect( - shouldClearListErrorOnRefreshLoad(summary({ refreshed: 1, errors: 1 })), - ).toBe(false); + it("always clears so cached list shows after refresh", () => { + expect(shouldClearListErrorOnRefreshLoad()).toBe(true); }); }); diff --git a/src/lib/gitRefresh.ts b/src/lib/gitRefresh.ts index 3ea767e..423881a 100644 --- a/src/lib/gitRefresh.ts +++ b/src/lib/gitRefresh.ts @@ -7,15 +7,10 @@ export function gitRefreshErrorMessage( if (summary.errors > 0 && summary.refreshed === 0) { return "Git refresh failed for all repositories."; } - if (summary.errors > 0 && summary.refreshed > 0) { - return `Git refresh completed with ${summary.errors} error(s).`; - } return null; } /** Whether `loadRepos` should clear the list error after refresh completes. */ -export function shouldClearListErrorOnRefreshLoad( - summary: GitRefreshSummary, -): boolean { - return summary.errors === 0; +export function shouldClearListErrorOnRefreshLoad(): boolean { + return true; } diff --git a/src/lib/listState.test.ts b/src/lib/listState.test.ts index 039ba47..33894f0 100644 --- a/src/lib/listState.test.ts +++ b/src/lib/listState.test.ts @@ -9,8 +9,8 @@ describe("trayListView", () => { }); }); - it("shows empty index message", () => { - expect(trayListView(null, 0, "", 0)).toEqual({ kind: "empty-index" }); + it("shows empty list message", () => { + expect(trayListView(null, 0, "", 0)).toEqual({ kind: "empty-list" }); }); it("shows no-match when filter excludes all rows", () => { diff --git a/src/lib/listState.ts b/src/lib/listState.ts index a1e1b4a..7665343 100644 --- a/src/lib/listState.ts +++ b/src/lib/listState.ts @@ -1,6 +1,6 @@ export type TrayListView = | { kind: "error"; message: string } - | { kind: "empty-index" } + | { kind: "empty-list" } | { kind: "no-match" } | { kind: "list" }; @@ -15,7 +15,7 @@ export function trayListView( return { kind: "error", message: error }; } if (reposLength === 0) { - return { kind: "empty-index" }; + return { kind: "empty-list" }; } if (filterQuery.trim().length > 0 && displayLength === 0) { return { kind: "no-match" }; diff --git a/src/lib/openSelection.test.ts b/src/lib/openSelection.test.ts index d7c2fbd..5d7d8d3 100644 --- a/src/lib/openSelection.test.ts +++ b/src/lib/openSelection.test.ts @@ -5,6 +5,7 @@ import type { RepoDto } from "./types"; function repo(name: string, path?: string): RepoDto { return { name, + alias: null, path: path ?? `/tmp/${name}`, branch: null, is_dirty: null, diff --git a/src/lib/openSelection.ts b/src/lib/openSelection.ts index c25d045..230de7f 100644 --- a/src/lib/openSelection.ts +++ b/src/lib/openSelection.ts @@ -2,7 +2,7 @@ import { filterAndSectionRepos, flatSectioned } from "./trayList"; import type { SectionConfig } from "./sort"; import type { RepoDto } from "./types"; -const DEFAULT_SECTION_CFG: SectionConfig = { +export const DEFAULT_SECTION_CFG: SectionConfig = { maxRecentDays: 14, minRecentCount: 3, }; diff --git a/src/lib/pinOrder.test.ts b/src/lib/pinOrder.test.ts index e6e7e80..410b582 100644 --- a/src/lib/pinOrder.test.ts +++ b/src/lib/pinOrder.test.ts @@ -10,6 +10,7 @@ function repo( return { path: partial.path ?? `/tmp/${partial.name}`, name: partial.name, + alias: partial.alias ?? null, branch: partial.branch ?? null, is_dirty: partial.is_dirty ?? null, parent_dir: "", diff --git a/src/lib/repoRow.test.ts b/src/lib/repoRow.test.ts index f8edc86..54f1ef7 100644 --- a/src/lib/repoRow.test.ts +++ b/src/lib/repoRow.test.ts @@ -6,6 +6,7 @@ function repo(partial: Partial & Pick): RepoDto { return { path: `/tmp/${partial.name}`, name: partial.name, + alias: partial.alias ?? null, branch: null, is_dirty: partial.is_dirty ?? null, parent_dir: "", diff --git a/src/lib/sort.test.ts b/src/lib/sort.test.ts index 76ea35d..54d30fb 100644 --- a/src/lib/sort.test.ts +++ b/src/lib/sort.test.ts @@ -6,6 +6,7 @@ function repo(partial: Partial & Pick): RepoDto { return { path: `/tmp/${partial.name}`, name: partial.name, + alias: partial.alias ?? null, branch: partial.branch ?? null, is_dirty: partial.is_dirty ?? null, parent_dir: "", diff --git a/src/lib/storybook/tauriCoreMock.ts b/src/lib/storybook/tauriCoreMock.ts new file mode 100644 index 0000000..d8b9702 --- /dev/null +++ b/src/lib/storybook/tauriCoreMock.ts @@ -0,0 +1,39 @@ +import type { BranchListItemDto } from "../types"; + +/** Storybook stub — no Tauri runtime. */ +export async function invoke(cmd: string): Promise { + if (cmd === "list_branches") { + const branches: BranchListItemDto[] = [ + { + name: "main", + presence: "checkout", + ahead: 0, + behind: 0, + }, + { + name: "develop", + presence: "local_remote", + ahead: 2, + behind: 0, + }, + { + name: "wip", + presence: "local_only", + ahead: null, + behind: null, + }, + { + name: "origin-only", + presence: "remote_only", + ahead: null, + behind: null, + }, + ]; + return branches; + } + return undefined; +} + +export async function listen(): Promise<() => void> { + return () => {}; +} diff --git a/src/lib/tagFilter.test.ts b/src/lib/tagFilter.test.ts index 3f8bfca..f0da7da 100644 --- a/src/lib/tagFilter.test.ts +++ b/src/lib/tagFilter.test.ts @@ -16,6 +16,7 @@ function repo( return { path: partial.path ?? `/Users/me/c/${partial.name}`, name: partial.name, + alias: partial.alias ?? null, branch: partial.branch ?? "main", is_dirty: partial.is_dirty ?? null, parent_dir: "", diff --git a/src/lib/tray/LaunchErrorBanner.svelte b/src/lib/tray/LaunchErrorBanner.svelte new file mode 100644 index 0000000..05fe0f5 --- /dev/null +++ b/src/lib/tray/LaunchErrorBanner.svelte @@ -0,0 +1,18 @@ + + + diff --git a/src/lib/tray/LaunchErrorBanner.test.ts b/src/lib/tray/LaunchErrorBanner.test.ts new file mode 100644 index 0000000..89b0d71 --- /dev/null +++ b/src/lib/tray/LaunchErrorBanner.test.ts @@ -0,0 +1,40 @@ +import { cleanup, fireEvent, render } from "@testing-library/svelte"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import LaunchErrorBanner from "./LaunchErrorBanner.svelte"; + +describe("LaunchErrorBanner", () => { + afterEach(() => { + cleanup(); + }); + + it("renders message text", () => { + const { getByText } = render(LaunchErrorBanner, { + props: { message: "Failed to open Cursor", onDismiss: vi.fn() }, + }); + expect(getByText("Failed to open Cursor")).toBeTruthy(); + }); + + it("has role alert", () => { + const { getByRole } = render(LaunchErrorBanner, { + props: { message: "Error", onDismiss: vi.fn() }, + }); + expect(getByRole("alert")).toBeTruthy(); + }); + + it("dismiss_button_calls_onDismiss", async () => { + const onDismiss = vi.fn(); + const { getByText } = render(LaunchErrorBanner, { + props: { message: "Error", onDismiss }, + }); + await fireEvent.click(getByText("Dismiss")); + expect(onDismiss).toHaveBeenCalledOnce(); + }); + + it("renders_long_message_without_truncating", () => { + const long = "A".repeat(200); + const { getByText } = render(LaunchErrorBanner, { + props: { message: long, onDismiss: vi.fn() }, + }); + expect(getByText(long)).toBeTruthy(); + }); +}); diff --git a/src/lib/tray/TrayApp.svelte b/src/lib/tray/TrayApp.svelte new file mode 100644 index 0000000..fe90e6a --- /dev/null +++ b/src/lib/tray/TrayApp.svelte @@ -0,0 +1,46 @@ + + + + + { + panel.selectedIndex = idx; + }} + onOpen={() => void panel.openSelected(false)} + onDetail={(repo, idx) => { + panel.selectedIndex = idx; + panel.openDetail(repo); + }} + onTagRemove={panel.removeTagFromRepo} + onTagFilter={panel.appendTagFilter} + detailRepo={panel.detailRepo} + focusTagOnDetailOpen={panel.focusTagOnDetailOpen} + onTagFocusDone={panel.clearTagFocusRequest} + onCloseDetail={panel.closeDetail} + onDetailMutated={() => void panel.refreshReposAndDetail()} +/> diff --git a/src/lib/tray/TrayApp.svelte.test.ts b/src/lib/tray/TrayApp.svelte.test.ts new file mode 100644 index 0000000..37512c5 --- /dev/null +++ b/src/lib/tray/TrayApp.svelte.test.ts @@ -0,0 +1,66 @@ +import { cleanup, render, waitFor } from "@testing-library/svelte"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import TrayApp from "./TrayApp.svelte"; + +const mount = vi.fn().mockResolvedValue(undefined); +const destroy = vi.fn(); +const onPanelKeydown = vi.fn(); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn().mockResolvedValue([]), +})); + +vi.mock("./createTrayPanel.svelte", () => ({ + createTrayPanel: () => ({ + mount, + destroy, + onPanelKeydown, + listMaxHeightPx: 492, + launchError: null, + dismissLaunchError: vi.fn(), + filterQuery: "", + allTags: [], + tagAutocompletePrefix: "", + onFilterKeydown: vi.fn(), + onTagAutocompleteSelect: vi.fn(), + bindFilterInput: vi.fn(), + listView: "flat" as const, + sectionedRepos: { pinned: [], dirty: [], recent: [], rest: [] }, + flatIndexByPath: new Map(), + selectedIndex: 0, + handlePinReorder: vi.fn(), + openSelected: vi.fn(), + openDetail: vi.fn(), + removeTagFromRepo: vi.fn(), + appendTagFilter: vi.fn(), + detailRepo: null, + focusTagOnDetailOpen: false, + clearTagFocusRequest: vi.fn(), + closeDetail: vi.fn(), + refreshReposAndDetail: vi.fn(), + }), +})); + +describe("TrayApp", () => { + afterEach(() => { + cleanup(); + mount.mockClear(); + destroy.mockClear(); + }); + + it("mounts tray panel on load", async () => { + render(TrayApp); + await waitFor(() => { + expect(mount).toHaveBeenCalledOnce(); + }); + }); + + it("destroys tray panel on unmount", async () => { + const { unmount } = render(TrayApp); + await waitFor(() => { + expect(mount).toHaveBeenCalledOnce(); + }); + unmount(); + expect(destroy).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/lib/tray/TrayFilterBar.svelte b/src/lib/tray/TrayFilterBar.svelte new file mode 100644 index 0000000..564d356 --- /dev/null +++ b/src/lib/tray/TrayFilterBar.svelte @@ -0,0 +1,48 @@ + + +
+
+ + +
+
diff --git a/src/lib/tray/TrayFilterBar.test.ts b/src/lib/tray/TrayFilterBar.test.ts new file mode 100644 index 0000000..00e3241 --- /dev/null +++ b/src/lib/tray/TrayFilterBar.test.ts @@ -0,0 +1,69 @@ +import { cleanup, fireEvent, render } from "@testing-library/svelte"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import TrayFilterBar from "./TrayFilterBar.svelte"; + +function renderBar( + opts: { + filterQuery?: string; + allTags?: string[]; + tagAutocompletePrefix?: string; + onFilterKeydown?: (e: KeyboardEvent) => void; + onTagSelect?: (tag: string) => void; + bindFilterInput?: (el: HTMLInputElement | null) => void; + } = {}, +) { + return render(TrayFilterBar, { + props: { + filterQuery: opts.filterQuery ?? "", + allTags: opts.allTags ?? [], + tagAutocompletePrefix: opts.tagAutocompletePrefix ?? "", + onFilterKeydown: opts.onFilterKeydown ?? vi.fn(), + onTagSelect: opts.onTagSelect ?? vi.fn(), + bindFilterInput: opts.bindFilterInput ?? vi.fn(), + }, + }); +} + +describe("TrayFilterBar", () => { + afterEach(() => { + cleanup(); + }); + + it("renders filter input with placeholder", () => { + const { getByPlaceholderText } = renderBar(); + expect(getByPlaceholderText("Filter repos…")).toBeTruthy(); + }); + + it("input_is_type_search", () => { + const { container } = renderBar(); + const input = container.querySelector("input"); + expect(input?.type).toBe("search"); + }); + + it("autocomplete_hidden_when_query_has_no_hash", () => { + const { queryByRole } = renderBar({ filterQuery: "workpot" }); + expect(queryByRole("listbox")).toBeNull(); + }); + + it("autocomplete_visible_when_query_contains_hash", () => { + const { getByRole } = renderBar({ + filterQuery: "#rust", + allTags: ["rust"], + }); + expect(getByRole("listbox")).toBeTruthy(); + }); + + it("keydown_on_input_calls_onFilterKeydown", async () => { + const onFilterKeydown = vi.fn(); + const { container } = renderBar({ onFilterKeydown }); + const input = container.querySelector("input") as HTMLInputElement; + await fireEvent.keyDown(input, { key: "ArrowDown" }); + expect(onFilterKeydown).toHaveBeenCalledOnce(); + }); + + it("bindFilterInput_called_with_input_element_on_mount", () => { + const bindFilterInput = vi.fn(); + renderBar({ bindFilterInput }); + expect(bindFilterInput).toHaveBeenCalled(); + }); +}); diff --git a/src/lib/tray/TrayListBody.svelte b/src/lib/tray/TrayListBody.svelte new file mode 100644 index 0000000..6bf46c5 --- /dev/null +++ b/src/lib/tray/TrayListBody.svelte @@ -0,0 +1,66 @@ + + +{#if listView.kind === "error"} + +{:else if listView.kind === "empty-list"} + +{:else if listView.kind === "no-match"} + +{:else} + +{/if} diff --git a/src/lib/tray/TrayListBody.test.ts b/src/lib/tray/TrayListBody.test.ts new file mode 100644 index 0000000..4f29115 --- /dev/null +++ b/src/lib/tray/TrayListBody.test.ts @@ -0,0 +1,92 @@ +import { cleanup, render } from "@testing-library/svelte"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import TrayListBody from "./TrayListBody.svelte"; +import type { TrayListView } from "$lib/listState"; +import type { SectionedRepos } from "$lib/sort"; +import type { RepoDto } from "$lib/types"; +import { + TRAY_EMPTY_LIST_MESSAGE, + TRAY_LIST_ERROR_FALLBACK, + TRAY_NO_MATCH_MESSAGE, +} from "./constants"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn().mockResolvedValue(undefined), +})); + +const emptySections: SectionedRepos = { + pinned: [], + dirty: [], + recent: [], + rest: [], +}; + +const noop = vi.fn(); + +function renderBody( + listView: TrayListView, + sections: SectionedRepos = emptySections, +) { + return render(TrayListBody, { + props: { + listView, + sectionedRepos: sections, + flatIndexByPath: new Map(), + onPinReorder: noop, + onSelectRow: noop, + onOpen: noop, + onDetail: noop as (repo: RepoDto, index: number) => void, + onTagRemove: noop, + onTagFilter: noop, + }, + }); +} + +describe("TrayListBody", () => { + afterEach(() => { + cleanup(); + }); + + it("error_view_shows_placeholder_with_error_message", () => { + const { getByText } = renderBody({ kind: "error", message: "Load failed" }); + expect(getByText("Load failed")).toBeTruthy(); + }); + + it("error_view_uses_error_fallback_when_message_empty", () => { + const { getByText } = renderBody({ kind: "error", message: "" }); + expect(getByText(TRAY_LIST_ERROR_FALLBACK)).toBeTruthy(); + }); + + it("empty_list_view_shows_default_empty_message", () => { + const { getByText } = renderBody({ kind: "empty-list" }); + expect(getByText(TRAY_EMPTY_LIST_MESSAGE)).toBeTruthy(); + }); + + it("empty_list_view_shows_custom_empty_message", () => { + const { getByText } = render(TrayListBody, { + props: { + listView: { kind: "empty-list" }, + emptyListMessage: "Nothing here yet.", + sectionedRepos: emptySections, + flatIndexByPath: new Map(), + onPinReorder: noop, + onSelectRow: noop, + onOpen: noop, + onDetail: noop as (repo: RepoDto, index: number) => void, + onTagRemove: noop, + onTagFilter: noop, + }, + }); + expect(getByText("Nothing here yet.")).toBeTruthy(); + }); + + it("no_match_view_shows_no_match_message", () => { + const { getByText } = renderBody({ kind: "no-match" }); + expect(getByText(TRAY_NO_MATCH_MESSAGE)).toBeTruthy(); + }); + + it("list_view_renders_listbox_not_placeholder", () => { + const { queryByRole } = renderBody({ kind: "list" }); + expect(queryByRole("listbox")).toBeTruthy(); + }); +}); diff --git a/src/lib/tray/TrayListPlaceholder.svelte b/src/lib/tray/TrayListPlaceholder.svelte new file mode 100644 index 0000000..039e226 --- /dev/null +++ b/src/lib/tray/TrayListPlaceholder.svelte @@ -0,0 +1,17 @@ + + +

+ {message} +

diff --git a/src/lib/tray/TrayListPlaceholder.test.ts b/src/lib/tray/TrayListPlaceholder.test.ts new file mode 100644 index 0000000..761cc49 --- /dev/null +++ b/src/lib/tray/TrayListPlaceholder.test.ts @@ -0,0 +1,41 @@ +import { cleanup, render } from "@testing-library/svelte"; +import { afterEach, describe, expect, it } from "vitest"; +import TrayListPlaceholder from "./TrayListPlaceholder.svelte"; + +describe("TrayListPlaceholder", () => { + afterEach(() => { + cleanup(); + }); + + it("renders message text", () => { + const { getByText } = render(TrayListPlaceholder, { + props: { message: "No repos indexed yet." }, + }); + expect(getByText("No repos indexed yet.")).toBeTruthy(); + }); + + it("tone_error_applies_red_class", () => { + const { container } = render(TrayListPlaceholder, { + props: { message: "Error loading repos.", tone: "error" }, + }); + const p = container.querySelector("p"); + expect(p?.className).toContain("text-red"); + }); + + it("tone_muted_applies_neutral_class", () => { + const { container } = render(TrayListPlaceholder, { + props: { message: "No match.", tone: "muted" }, + }); + const p = container.querySelector("p"); + expect(p?.className).toContain("text-neutral"); + }); + + it("defaults_to_muted_tone", () => { + const { container } = render(TrayListPlaceholder, { + props: { message: "Empty." }, + }); + const p = container.querySelector("p"); + expect(p?.className).toContain("text-neutral"); + expect(p?.className).not.toContain("text-red"); + }); +}); diff --git a/src/lib/tray/TrayPanelChrome.stories.svelte b/src/lib/tray/TrayPanelChrome.stories.svelte new file mode 100644 index 0000000..e481f28 --- /dev/null +++ b/src/lib/tray/TrayPanelChrome.stories.svelte @@ -0,0 +1,96 @@ + + + + + + + + + + + + + diff --git a/src/lib/tray/TrayPanelChrome.svelte b/src/lib/tray/TrayPanelChrome.svelte new file mode 100644 index 0000000..bfe6851 --- /dev/null +++ b/src/lib/tray/TrayPanelChrome.svelte @@ -0,0 +1,116 @@ + + +
+ {#if launchError && onDismissLaunchError} + + {/if} + + + +
+ {#if detailRepo && onCloseDetail && onDetailMutated} + + {:else} + + {/if} +
+
diff --git a/src/lib/tray/TrayPanelChrome.test.ts b/src/lib/tray/TrayPanelChrome.test.ts new file mode 100644 index 0000000..8a3d461 --- /dev/null +++ b/src/lib/tray/TrayPanelChrome.test.ts @@ -0,0 +1,107 @@ +import { cleanup, render } from "@testing-library/svelte"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import TrayPanelChrome from "./TrayPanelChrome.svelte"; +import type { SectionedRepos } from "$lib/sort"; +import type { RepoDto } from "$lib/types"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn().mockResolvedValue([]), +})); + +const emptySections: SectionedRepos = { + pinned: [], + dirty: [], + recent: [], + rest: [], +}; + +const baseRepo: RepoDto = { + path: "/tmp/testrepo", + name: "testrepo", + alias: null, + branch: "main", + is_dirty: false, + parent_dir: "~/tmp", + last_opened_at: null, + git_state_error: null, + pinned: false, + pin_order: null, + notes: null, + tags: [], + branches: [], +}; + +function renderChrome( + opts: { + launchError?: string | null; + detailRepo?: RepoDto | null; + } = {}, +) { + return render(TrayPanelChrome, { + props: { + listMaxHeightPx: 600, + launchError: opts.launchError ?? null, + onDismissLaunchError: vi.fn(), + filterQuery: "", + allTags: [], + tagAutocompletePrefix: null, + onFilterKeydown: vi.fn(), + onTagSelect: vi.fn(), + bindFilterInput: vi.fn(), + listView: { kind: "empty-list" as const }, + sectionedRepos: emptySections, + flatIndexByPath: new Map(), + selectedIndex: 0, + onPinReorder: vi.fn(), + onSelectRow: vi.fn(), + onOpen: vi.fn(), + onDetail: vi.fn() as (repo: RepoDto, index: number) => void, + onTagRemove: vi.fn(), + onTagFilter: vi.fn(), + detailRepo: opts.detailRepo ?? null, + onCloseDetail: vi.fn(), + onDetailMutated: vi.fn(), + }, + }); +} + +describe("TrayPanelChrome", () => { + afterEach(() => { + cleanup(); + }); + + it("renders filter input", () => { + const { getByPlaceholderText } = renderChrome(); + expect(getByPlaceholderText("Filter repos…")).toBeTruthy(); + }); + + it("launch_error_banner_hidden_when_no_error", () => { + const { queryByRole } = renderChrome({ launchError: null }); + expect(queryByRole("alert")).toBeNull(); + }); + + it("launch_error_banner_shown_when_error_set", () => { + const { getByRole, getByText } = renderChrome({ + launchError: "cursor: command not found", + }); + expect(getByRole("alert")).toBeTruthy(); + expect(getByText("cursor: command not found")).toBeTruthy(); + }); + + it("shows_list_body_when_no_detail_repo", () => { + const { queryByText } = renderChrome({ detailRepo: null }); + // placeholder message visible when no repos + expect(queryByText("No repos indexed yet.")).toBeTruthy(); + }); + + it("shows_detail_pane_when_detailRepo_provided", () => { + const { queryByRole } = renderChrome({ detailRepo: baseRepo }); + // list body hidden, detail pane visible (has heading for repo name) + expect(queryByRole("heading", { level: 2 })).toBeTruthy(); + }); + + it("hides_list_body_when_detail_pane_active", () => { + const { queryByText } = renderChrome({ detailRepo: baseRepo }); + expect(queryByText("No repos indexed yet.")).toBeNull(); + }); +}); diff --git a/src/lib/tray/TrayRepoList.svelte b/src/lib/tray/TrayRepoList.svelte new file mode 100644 index 0000000..1b6c37f --- /dev/null +++ b/src/lib/tray/TrayRepoList.svelte @@ -0,0 +1,116 @@ + + +
    + {#each SECTION_META as { key, label, draggable } (key)} + {#if sectionedRepos[key].length > 0} +
  • + +
  • + {#each sectionedRepos[key] as repo, i (repo.path)} + {@const idx = flatIndexByPath.get(repo.path) ?? 0} +
  • + { + e.preventDefault(); + void invoke("show_repo_context_menu", { + repoPath: repo.path, + isPinned: repo.pinned, + tags: repo.tags, + }); + }} + onRowDragStart={draggable + ? (e) => handleDragStart(e, i) + : undefined} + onRowDragOver={draggable ? (e) => e.preventDefault() : undefined} + onRowDrop={draggable ? (e) => handleDrop(e, i) : undefined} + onRowDragEnd={draggable ? clearDragSource : undefined} + onOpen={() => { + onSelectRow(idx); + onOpen(idx); + }} + onDetail={() => { + onSelectRow(idx); + onDetail(repo, idx); + }} + onTagRemove={(tag) => onTagRemove(repo.path, tag)} + {onTagFilter} + /> +
  • + {/each} + {/if} + {/each} +
diff --git a/src/lib/tray/TrayRepoList.test.ts b/src/lib/tray/TrayRepoList.test.ts new file mode 100644 index 0000000..7279fc5 --- /dev/null +++ b/src/lib/tray/TrayRepoList.test.ts @@ -0,0 +1,125 @@ +import { cleanup, render } from "@testing-library/svelte"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import TrayRepoList from "./TrayRepoList.svelte"; +import type { SectionedRepos } from "$lib/sort"; +import type { RepoDto } from "$lib/types"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn().mockResolvedValue(undefined), +})); + +function repo(name: string, overrides: Partial = {}): RepoDto { + return { + path: `/tmp/${name}`, + name, + alias: null, + branch: "main", + is_dirty: false, + parent_dir: "~/tmp", + last_opened_at: null, + git_state_error: null, + pinned: false, + pin_order: null, + notes: null, + tags: [], + branches: [], + ...overrides, + }; +} + +function renderList( + sectionedRepos: SectionedRepos, + opts: { + selectedIndex?: number; + onOpen?: (i: number) => void; + onDetail?: (repo: RepoDto, i: number) => void; + } = {}, +) { + const repos = [ + ...sectionedRepos.pinned, + ...sectionedRepos.dirty, + ...sectionedRepos.recent, + ...sectionedRepos.rest, + ]; + const flatIndexByPath = new Map(repos.map((r, i) => [r.path, i])); + const onOpen = opts.onOpen ?? vi.fn(); + const onDetail = opts.onDetail ?? vi.fn(); + return { + ...render(TrayRepoList, { + props: { + sectionedRepos, + flatIndexByPath, + selectedIndex: opts.selectedIndex ?? 0, + onPinReorder: vi.fn(), + onSelectRow: vi.fn(), + onOpen, + onDetail, + onTagRemove: vi.fn(), + onTagFilter: vi.fn(), + }, + }), + onOpen, + onDetail, + }; +} + +const empty: SectionedRepos = { pinned: [], dirty: [], recent: [], rest: [] }; + +describe("TrayRepoList", () => { + beforeAll(() => { + // jsdom does not implement scrollIntoView + Element.prototype.scrollIntoView = vi.fn(); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders_listbox_container", () => { + const { getByRole } = renderList({ ...empty, rest: [repo("workpot")] }); + expect(getByRole("listbox")).toBeTruthy(); + }); + + it("renders_repo_names_in_rest_section", () => { + const { getByText } = renderList({ + ...empty, + rest: [repo("workpot"), repo("myapp")], + }); + expect(getByText("workpot")).toBeTruthy(); + expect(getByText("myapp")).toBeTruthy(); + }); + + it("empty_sections_not_rendered", () => { + const { queryByText } = renderList({ ...empty, rest: [repo("workpot")] }); + expect(queryByText("Pinned")).toBeNull(); + expect(queryByText("Dirty")).toBeNull(); + expect(queryByText("Recent")).toBeNull(); + }); + + it("section_header_shown_for_non_empty_section", () => { + const { getByText } = renderList({ + ...empty, + pinned: [repo("pinned-repo", { pinned: true })], + }); + expect(getByText("Pinned")).toBeTruthy(); + }); + + it("multiple_sections_rendered_when_non_empty", () => { + const { getByText } = renderList({ + ...empty, + pinned: [repo("pinned-repo", { pinned: true })], + rest: [repo("other")], + }); + expect(getByText("Pinned")).toBeTruthy(); + expect(getByText("Rest")).toBeTruthy(); + }); + + it("selected_row_has_data_row_index_attribute", () => { + const { container } = renderList({ + ...empty, + rest: [repo("a"), repo("b")], + }); + const rows = container.querySelectorAll("[data-row-index]"); + expect(rows.length).toBe(2); + }); +}); diff --git a/src/lib/tray/constants.ts b/src/lib/tray/constants.ts new file mode 100644 index 0000000..7c97383 --- /dev/null +++ b/src/lib/tray/constants.ts @@ -0,0 +1,12 @@ +export const SECTION_META = [ + { key: "pinned" as const, label: "Pinned", draggable: true }, + { key: "dirty" as const, label: "Dirty", draggable: false }, + { key: "recent" as const, label: "Recent", draggable: false }, + { key: "rest" as const, label: "Rest", draggable: false }, +] as const; + +export const DEFAULT_MAX_VISIBLE_ROWS = 15; + +export const TRAY_EMPTY_LIST_MESSAGE = "No repos indexed yet."; +export const TRAY_NO_MATCH_MESSAGE = "No repos match"; +export const TRAY_LIST_ERROR_FALLBACK = "Could not load repos."; diff --git a/src/lib/tray/createTrayPanel.svelte.test.ts b/src/lib/tray/createTrayPanel.svelte.test.ts new file mode 100644 index 0000000..9b3026d --- /dev/null +++ b/src/lib/tray/createTrayPanel.svelte.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it, vi, beforeEach } from "vitest"; +import type { RepoDto } from "$lib/types"; +import { clearGitRefreshWatchdog } from "./gitRefreshWatchdog"; +import { createTrayPanel } from "./createTrayPanel.svelte"; + +const invoke = vi.fn(); +const unsubscribe = vi.fn(); +const subscribeTrayPanelEvents = vi.fn().mockResolvedValue(unsubscribe); +const focus = vi.fn(); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (...args: unknown[]) => invoke(...args), +})); + +vi.mock("@tauri-apps/api/window", () => ({ + getCurrentWindow: () => ({ hide: vi.fn().mockResolvedValue(undefined) }), +})); + +vi.mock("./trayPanelEvents", () => ({ + subscribeTrayPanelEvents: (...args: unknown[]) => + subscribeTrayPanelEvents(...args), +})); + +function repo(path: string): RepoDto { + return { + path, + name: path.split("/").pop()!, + alias: null, + branch: null, + is_dirty: null, + parent_dir: "", + last_opened_at: null, + git_state_error: null, + pinned: false, + pin_order: null, + notes: null, + tags: [], + branches: [], + }; +} + +describe("createTrayPanel", () => { + beforeEach(() => { + invoke.mockReset(); + subscribeTrayPanelEvents.mockClear(); + unsubscribe.mockClear(); + invoke.mockImplementation(async (cmd: string) => { + if (cmd === "list_repos") return [repo("/tmp/a")]; + if (cmd === "list_all_tags") return ["work"]; + if (cmd === "get_tray_config") { + return { + max_visible_rows: 10, + max_recent_days: 14, + min_recent_count: 3, + max_pinned: 5, + stale_dirty_days: 30, + }; + } + return undefined; + }); + }); + + afterEach(() => { + clearGitRefreshWatchdog(); + }); + + it("mount subscribes events, loads data, and focuses filter", async () => { + const panel = createTrayPanel(); + const input = document.createElement("input"); + input.focus = focus; + panel.bindFilterInput(input); + + await panel.mount(); + + expect(subscribeTrayPanelEvents).toHaveBeenCalledOnce(); + expect(invoke).toHaveBeenCalledWith("list_repos"); + expect(invoke).toHaveBeenCalledWith("list_all_tags"); + expect(invoke).toHaveBeenCalledWith("get_tray_config"); + expect(panel.allTags).toEqual(["work"]); + expect(panel.listMaxHeightPx).toBe(10 * 44 + 52); + expect(focus).toHaveBeenCalled(); + }); + + it("destroy unsubscribes panel events", async () => { + const panel = createTrayPanel(); + await panel.mount(); + panel.destroy(); + expect(unsubscribe).toHaveBeenCalledOnce(); + }); + + it("openDetail exposes detail state on panel", () => { + const panel = createTrayPanel(); + const r = repo("/tmp/detail"); + panel.openDetail(r); + expect(panel.detailRepo?.path).toBe(r.path); + expect(panel.detailRepo?.name).toBe(r.name); + panel.closeDetail(); + expect(panel.detailRepo).toBeNull(); + }); + + it("filterQuery delegates to list selection", () => { + const panel = createTrayPanel(); + panel.filterQuery = "tag:work"; + expect(panel.filterQuery).toBe("tag:work"); + }); + + it("removeTagFromRepo invokes remove_tag and refreshes", async () => { + const panel = createTrayPanel(); + await panel.mount(); + invoke.mockClear(); + invoke.mockImplementation(async (cmd: string) => { + if (cmd === "list_repos") return [repo("/tmp/a")]; + return undefined; + }); + + await panel.removeTagFromRepo("/tmp/a", "work"); + + expect(invoke).toHaveBeenCalledWith("remove_tag", { + repoPath: "/tmp/a", + tag: "work", + }); + expect(invoke).toHaveBeenCalledWith("list_repos"); + }); +}); diff --git a/src/lib/tray/createTrayPanel.svelte.ts b/src/lib/tray/createTrayPanel.svelte.ts new file mode 100644 index 0000000..46a9ad2 --- /dev/null +++ b/src/lib/tray/createTrayPanel.svelte.ts @@ -0,0 +1,162 @@ +import { invoke } from "@tauri-apps/api/core"; +import { toPinOrderPayload } from "$lib/pinOrder"; +import { createTrayConfig } from "./trayConfig.svelte"; +import { createTrayDetail } from "./trayDetail.svelte"; +import { + onGitRefreshComplete, + onGitRefreshFailed, + onPanelOpened, +} from "./trayGitRefreshHandlers"; +import { createTrayLaunch } from "./trayLaunch.svelte"; +import { createTrayListSelection } from "./trayListSelection.svelte"; +import { createTrayPanelKeyboard } from "./trayPanelKeyboard.svelte"; +import { clearGitRefreshWatchdog } from "./gitRefreshWatchdog"; +import { subscribeTrayPanelEvents } from "./trayPanelEvents"; +import { trayTrace } from "./trayTrace"; +import { createTrayRepoData } from "./trayRepoData.svelte"; +import { + handleRepoContextAction, + removeTag, + setPinOrder, + type TrayRepoActionsDeps, +} from "./trayRepoActions"; + +export function createTrayPanel() { + const config = createTrayConfig(); + const detail = createTrayDetail(); + const data = createTrayRepoData({ + onAfterRefresh: (repos) => detail.resync(repos), + }); + const list = createTrayListSelection({ + getRepos: () => data.repos, + getSectionCfg: () => config.sectionCfg, + getError: () => data.error, + }); + const launch = createTrayLaunch({ + getSelectedRepo: () => list.getSelectedRepo(), + getFilterQuery: () => list.filterQuery, + getSectionCfg: () => config.sectionCfg, + getRepos: () => data.repos, + refresh: (clearError) => data.refresh(clearError), + setSelectedIndex: (index) => { + list.selectedIndex = index; + }, + }); + const keyboard = createTrayPanelKeyboard({ list, detail, launch, data }); + + let unsubscribeEvents: (() => void) | null = null; + + const actionDeps: TrayRepoActionsDeps = { + invoke, + refresh: () => data.refresh(), + onError: (e) => data.setError(e), + openDetailWithTagFocus: (repo) => detail.openDetailWithTagFocus(repo), + }; + + async function removeTagFromRepo(repoPath: string, tag: string) { + await removeTag(repoPath, tag, actionDeps); + } + + async function handlePinReorder(items: ReturnType) { + await setPinOrder(items, actionDeps); + } + + const gitRefreshDeps = { + setSelectedIndex: (index: number) => { + list.selectedIndex = index; + }, + refresh: (clearError: boolean) => data.refresh(clearError), + setError: (message: string | null) => data.setListError(message), + focusFilter: () => keyboard.focusFilter(), + }; + + async function mount(): Promise { + trayTrace("mount start"); + unsubscribeEvents = await subscribeTrayPanelEvents({ + onPanelOpened: () => onPanelOpened(gitRefreshDeps), + onGitRefreshComplete: (summary) => + onGitRefreshComplete(summary, gitRefreshDeps), + onGitRefreshFailed: (message) => + onGitRefreshFailed(message, gitRefreshDeps), + onRepoContextAction: (payload) => { + void handleRepoContextAction(payload, data.repos, actionDeps); + }, + }); + + await Promise.all([ + data.loadRepos(), + data.loadAllTags(), + config.loadConfig(), + ]); + trayTrace("mount ready", { repos: data.repos.length }); + keyboard.focusFilter(); + } + + function destroy() { + clearGitRefreshWatchdog(); + unsubscribeEvents?.(); + unsubscribeEvents = null; + } + + return { + get filterQuery() { + return list.filterQuery; + }, + set filterQuery(value: string) { + list.filterQuery = value; + }, + get selectedIndex() { + return list.selectedIndex; + }, + set selectedIndex(value: number) { + list.selectedIndex = value; + }, + get detailRepo() { + return detail.detailRepo; + }, + get listView() { + return list.listView; + }, + get sectionedRepos() { + return list.sectionedRepos; + }, + get flatIndexByPath() { + return list.flatIndexByPath; + }, + get allTags() { + return data.allTags; + }, + get launchError() { + return launch.launchError; + }, + get listMaxHeightPx() { + return config.listMaxHeightPx; + }, + get tagAutocompletePrefix() { + return list.tagAutocompletePrefix; + }, + get focusTagOnDetailOpen() { + return detail.focusTagOnDetailOpen; + }, + clearTagFocusRequest: detail.clearTagFocusRequest, + openDetail: detail.openDetail, + moveSelection: list.moveSelection, + openSelected: launch.openSelected, + hidePanel: launch.hidePanel, + closeDetail: detail.closeDetail, + openDetailWithTagFocus: detail.openDetailWithTagFocus, + appendTagFilter: list.appendTagFilter, + onTagAutocompleteSelect: list.onTagAutocompleteSelect, + removeTagFromRepo, + handlePinReorder, + onFilterKeydown: keyboard.onFilterKeydown, + onPanelKeydown: keyboard.onPanelKeydown, + dismissLaunchError: launch.dismissLaunchError, + bindFilterInput: keyboard.bindFilterInput, + refreshReposAndDetail: () => data.refresh(), + mount, + destroy, + }; +} + +export type TrayPanel = ReturnType; diff --git a/src/lib/tray/gitRefreshWatchdog.test.ts b/src/lib/tray/gitRefreshWatchdog.test.ts new file mode 100644 index 0000000..54c9227 --- /dev/null +++ b/src/lib/tray/gitRefreshWatchdog.test.ts @@ -0,0 +1,52 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + armGitRefreshWatchdog, + clearGitRefreshWatchdog, +} from "./gitRefreshWatchdog"; + +describe("gitRefreshWatchdog", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + clearGitRefreshWatchdog(); + vi.useRealTimers(); + }); + + it("fires onTimeout after 90 seconds", () => { + const onTimeout = vi.fn(); + armGitRefreshWatchdog(onTimeout); + + vi.advanceTimersByTime(89_999); + expect(onTimeout).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(onTimeout).toHaveBeenCalledOnce(); + }); + + it("clearGitRefreshWatchdog cancels pending timeout", () => { + const onTimeout = vi.fn(); + armGitRefreshWatchdog(onTimeout); + clearGitRefreshWatchdog(); + + vi.advanceTimersByTime(90_000); + expect(onTimeout).not.toHaveBeenCalled(); + }); + + it("re-arm replaces previous watchdog", () => { + const first = vi.fn(); + const second = vi.fn(); + armGitRefreshWatchdog(first); + vi.advanceTimersByTime(30_000); + armGitRefreshWatchdog(second); + + vi.advanceTimersByTime(89_999); + expect(first).not.toHaveBeenCalled(); + expect(second).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/lib/tray/gitRefreshWatchdog.ts b/src/lib/tray/gitRefreshWatchdog.ts new file mode 100644 index 0000000..b93d45a --- /dev/null +++ b/src/lib/tray/gitRefreshWatchdog.ts @@ -0,0 +1,18 @@ +const GIT_REFRESH_TIMEOUT_MS = 90_000; + +let watchdog: ReturnType | null = null; + +export function armGitRefreshWatchdog(onTimeout: () => void): void { + clearGitRefreshWatchdog(); + watchdog = setTimeout(() => { + watchdog = null; + onTimeout(); + }, GIT_REFRESH_TIMEOUT_MS); +} + +export function clearGitRefreshWatchdog(): void { + if (watchdog !== null) { + clearTimeout(watchdog); + watchdog = null; + } +} diff --git a/src/lib/tray/trayConfig.svelte.test.ts b/src/lib/tray/trayConfig.svelte.test.ts new file mode 100644 index 0000000..2cd4c70 --- /dev/null +++ b/src/lib/tray/trayConfig.svelte.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { DEFAULT_SECTION_CFG } from "$lib/openSelection"; +import type { TrayConfigDto } from "$lib/types"; +import { DEFAULT_MAX_VISIBLE_ROWS } from "./constants"; +import { createTrayConfig } from "./trayConfig.svelte"; + +const invoke = vi.fn(); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (...args: unknown[]) => invoke(...args), +})); + +describe("createTrayConfig", () => { + beforeEach(() => { + invoke.mockReset(); + }); + + it("uses defaults before loadConfig", () => { + const config = createTrayConfig(); + expect(config.sectionCfg).toEqual(DEFAULT_SECTION_CFG); + expect(config.listMaxHeightPx).toBe(DEFAULT_MAX_VISIBLE_ROWS * 44 + 52); + }); + + it("loadConfig applies tray config from invoke", async () => { + const dto: TrayConfigDto = { + max_visible_rows: 8, + max_recent_days: 7, + min_recent_count: 2, + max_pinned: 5, + stale_dirty_days: 30, + }; + invoke.mockResolvedValueOnce(dto); + + const config = createTrayConfig(); + await config.loadConfig(); + + expect(invoke).toHaveBeenCalledWith("get_tray_config"); + expect(config.sectionCfg).toEqual({ + maxRecentDays: 7, + minRecentCount: 2, + }); + expect(config.listMaxHeightPx).toBe(8 * 44 + 52); + }); + + it("loadConfig keeps defaults when invoke fails", async () => { + invoke.mockRejectedValueOnce(new Error("ipc down")); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const config = createTrayConfig(); + await config.loadConfig(); + + expect(config.sectionCfg).toEqual(DEFAULT_SECTION_CFG); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); +}); diff --git a/src/lib/tray/trayConfig.svelte.ts b/src/lib/tray/trayConfig.svelte.ts new file mode 100644 index 0000000..615a714 --- /dev/null +++ b/src/lib/tray/trayConfig.svelte.ts @@ -0,0 +1,41 @@ +import { invoke } from "@tauri-apps/api/core"; +import { trayListMaxHeightPx } from "$lib/panelLayout"; +import { DEFAULT_SECTION_CFG } from "$lib/openSelection"; +import type { SectionConfig } from "$lib/sort"; +import type { TrayConfigDto } from "$lib/types"; +import { DEFAULT_MAX_VISIBLE_ROWS } from "./constants"; + +export function createTrayConfig() { + let trayConfig = $state(null); + + let maxVisibleRows = $derived( + trayConfig?.max_visible_rows ?? DEFAULT_MAX_VISIBLE_ROWS, + ); + let listMaxHeightPx = $derived(trayListMaxHeightPx(maxVisibleRows)); + let sectionCfg = $derived({ + maxRecentDays: + trayConfig?.max_recent_days ?? DEFAULT_SECTION_CFG.maxRecentDays, + minRecentCount: + trayConfig?.min_recent_count ?? DEFAULT_SECTION_CFG.minRecentCount, + }); + + async function loadConfig(): Promise { + try { + trayConfig = await invoke("get_tray_config"); + } catch (e) { + console.warn("get_tray_config failed", e); + } + } + + return { + get sectionCfg() { + return sectionCfg; + }, + get listMaxHeightPx() { + return listMaxHeightPx; + }, + loadConfig, + }; +} + +export type TrayConfig = ReturnType; diff --git a/src/lib/tray/trayContextAction.test.ts b/src/lib/tray/trayContextAction.test.ts new file mode 100644 index 0000000..88dfcb4 --- /dev/null +++ b/src/lib/tray/trayContextAction.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { resolveContextAction } from "./trayContextAction"; +import type { RepoDto } from "$lib/types"; + +function repo(overrides: Partial = {}): RepoDto { + return { + path: "/tmp/foo", + name: "foo", + alias: null, + branch: null, + is_dirty: null, + parent_dir: "", + last_opened_at: null, + git_state_error: null, + pinned: false, + pin_order: null, + notes: null, + tags: [], + branches: [], + ...overrides, + }; +} + +describe("resolveContextAction", () => { + it("toggles pin when repo exists", () => { + expect( + resolveContextAction("pin", repo({ pinned: false }), "/tmp/foo"), + ).toEqual({ + kind: "toggle_pin", + repoPath: "/tmp/foo", + pinned: true, + }); + expect( + resolveContextAction("pin", repo({ pinned: true }), "/tmp/foo"), + ).toEqual({ + kind: "toggle_pin", + repoPath: "/tmp/foo", + pinned: false, + }); + }); + + it("noop pin when repo missing", () => { + expect(resolveContextAction("pin", null, "/tmp/foo")).toEqual({ + kind: "noop", + }); + }); + + it("removes sole tag directly", () => { + expect( + resolveContextAction("remove_tag", repo({ tags: ["work"] }), "/tmp/foo"), + ).toEqual({ + kind: "remove_tag", + repoPath: "/tmp/foo", + tag: "work", + }); + }); + + it("opens detail for multi-tag remove", () => { + const r = repo({ tags: ["a", "b"] }); + expect(resolveContextAction("remove_tag", r, "/tmp/foo")).toEqual({ + kind: "open_detail_tag_focus", + repo: r, + }); + }); + + it("noop remove_tag when repo missing", () => { + expect(resolveContextAction("remove_tag", null, "/tmp/foo")).toEqual({ + kind: "noop", + }); + }); + + it("opens detail for add_tag", () => { + const r = repo(); + expect(resolveContextAction("add_tag", r, "/tmp/foo")).toEqual({ + kind: "open_detail_tag_focus", + repo: r, + }); + }); + + it("noop unknown action", () => { + expect(resolveContextAction("unknown", repo(), "/tmp/foo")).toEqual({ + kind: "noop", + }); + }); +}); diff --git a/src/lib/tray/trayContextAction.ts b/src/lib/tray/trayContextAction.ts new file mode 100644 index 0000000..61ce1b2 --- /dev/null +++ b/src/lib/tray/trayContextAction.ts @@ -0,0 +1,35 @@ +import type { RepoDto } from "$lib/types"; + +export type ContextAction = "pin" | "remove_tag" | "add_tag"; + +export type ContextCommand = + | { kind: "toggle_pin"; repoPath: string; pinned: boolean } + | { kind: "remove_tag"; repoPath: string; tag: string } + | { kind: "open_detail_tag_focus"; repo: RepoDto } + | { kind: "noop" }; + +export function resolveContextAction( + action: string, + repo: RepoDto | null, + repoPath: string, +): ContextCommand { + if (action === "pin") { + if (repo) { + return { kind: "toggle_pin", repoPath, pinned: !repo.pinned }; + } + return { kind: "noop" }; + } + if (action === "remove_tag") { + if (!repo) { + return { kind: "noop" }; + } + if (repo.tags.length === 1) { + return { kind: "remove_tag", repoPath, tag: repo.tags[0] }; + } + return { kind: "open_detail_tag_focus", repo }; + } + if (action === "add_tag" && repo) { + return { kind: "open_detail_tag_focus", repo }; + } + return { kind: "noop" }; +} diff --git a/src/lib/tray/trayDetail.svelte.test.ts b/src/lib/tray/trayDetail.svelte.test.ts new file mode 100644 index 0000000..be6b362 --- /dev/null +++ b/src/lib/tray/trayDetail.svelte.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import type { RepoDto } from "$lib/types"; +import { createTrayDetail } from "./trayDetail.svelte"; + +function repo(path: string, overrides: Partial = {}): RepoDto { + return { + path, + name: path.split("/").pop()!, + alias: null, + branch: null, + is_dirty: null, + parent_dir: "", + last_opened_at: null, + git_state_error: null, + pinned: false, + pin_order: null, + notes: null, + tags: [], + branches: [], + ...overrides, + }; +} + +describe("createTrayDetail", () => { + it("openDetail and closeDetail toggle detailRepo", () => { + const detail = createTrayDetail(); + const r = repo("/tmp/foo"); + + detail.openDetail(r); + expect(detail.detailRepo).toEqual(r); + + detail.closeDetail(); + expect(detail.detailRepo).toBeNull(); + }); + + it("openDetailWithTagFocus sets focus flag until cleared", () => { + const detail = createTrayDetail(); + const r = repo("/tmp/foo"); + + detail.openDetailWithTagFocus(r); + expect(detail.detailRepo).toEqual(r); + expect(detail.focusTagOnDetailOpen).toBe(true); + + detail.clearTagFocusRequest(); + expect(detail.focusTagOnDetailOpen).toBe(false); + }); + + it("resync updates detail repo when path still exists", () => { + const detail = createTrayDetail(); + const original = repo("/tmp/foo", { branch: "main" }); + detail.openDetail(original); + + const updated = repo("/tmp/foo", { branch: "feature" }); + detail.resync([updated, repo("/tmp/bar")]); + + expect(detail.detailRepo).toEqual(updated); + }); + + it("resync clears detail when repo path disappears", () => { + const detail = createTrayDetail(); + detail.openDetail(repo("/tmp/gone")); + detail.resync([repo("/tmp/other")]); + expect(detail.detailRepo).toBeNull(); + }); + + it("resync is noop when detail is closed", () => { + const detail = createTrayDetail(); + detail.resync([repo("/tmp/foo")]); + expect(detail.detailRepo).toBeNull(); + }); +}); diff --git a/src/lib/tray/trayDetail.svelte.ts b/src/lib/tray/trayDetail.svelte.ts new file mode 100644 index 0000000..71de839 --- /dev/null +++ b/src/lib/tray/trayDetail.svelte.ts @@ -0,0 +1,44 @@ +import { resyncDetailIfOpen } from "$lib/detailRepoSync"; +import type { RepoDto } from "$lib/types"; + +export function createTrayDetail() { + let detailRepo = $state(null); + let focusTagOnDetailOpen = $state(false); + + function openDetail(repo: RepoDto) { + detailRepo = repo; + } + + function closeDetail() { + detailRepo = null; + } + + function openDetailWithTagFocus(repo: RepoDto) { + detailRepo = repo; + focusTagOnDetailOpen = true; + } + + function clearTagFocusRequest() { + focusTagOnDetailOpen = false; + } + + function resync(repos: RepoDto[]) { + detailRepo = resyncDetailIfOpen(repos, detailRepo); + } + + return { + get detailRepo() { + return detailRepo; + }, + get focusTagOnDetailOpen() { + return focusTagOnDetailOpen; + }, + openDetail, + closeDetail, + openDetailWithTagFocus, + clearTagFocusRequest, + resync, + }; +} + +export type TrayDetail = ReturnType; diff --git a/src/lib/tray/trayGitRefreshHandlers.test.ts b/src/lib/tray/trayGitRefreshHandlers.test.ts new file mode 100644 index 0000000..952d007 --- /dev/null +++ b/src/lib/tray/trayGitRefreshHandlers.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { GitRefreshSummary } from "$lib/types"; +import { clearGitRefreshWatchdog } from "./gitRefreshWatchdog"; +import { + onGitRefreshComplete, + onGitRefreshFailed, + onPanelOpened, + type GitRefreshHandlerDeps, +} from "./trayGitRefreshHandlers"; + +afterEach(() => { + clearGitRefreshWatchdog(); +}); + +function deps( + overrides: Partial = {}, +): GitRefreshHandlerDeps { + return { + setSelectedIndex: vi.fn(), + refresh: vi.fn().mockResolvedValue(undefined), + setError: vi.fn(), + focusFilter: vi.fn(), + ...overrides, + }; +} + +describe("trayGitRefreshHandlers", () => { + it("onPanelOpened loads cached list and focuses filter", () => { + const d = deps(); + onPanelOpened(d); + expect(d.refresh).toHaveBeenCalledWith(true); + expect(d.focusFilter).toHaveBeenCalledOnce(); + }); + + it("onGitRefreshComplete resets selection and refreshes with clear flag", async () => { + const d = deps(); + const summary: GitRefreshSummary = { + refreshed: 2, + errors: 0, + any_dirty: false, + }; + onGitRefreshComplete(summary, d); + expect(d.setSelectedIndex).toHaveBeenCalledWith(0); + expect(d.refresh).toHaveBeenCalledWith(true); + await vi.waitFor(() => expect(d.setError).toHaveBeenCalledWith(null)); + }); + + it("onGitRefreshComplete clears list error on partial failure", async () => { + const d = deps(); + const summary: GitRefreshSummary = { + refreshed: 1, + errors: 2, + any_dirty: true, + }; + onGitRefreshComplete(summary, d); + expect(d.refresh).toHaveBeenCalledWith(true); + await vi.waitFor(() => expect(d.setError).toHaveBeenCalledWith(null)); + }); + + it("onGitRefreshComplete sets total failure message when all failed", async () => { + const d = deps(); + const summary: GitRefreshSummary = { + refreshed: 0, + errors: 3, + any_dirty: false, + }; + onGitRefreshComplete(summary, d); + await vi.waitFor(() => + expect(d.setError).toHaveBeenCalledWith( + "Git refresh failed for all repositories.", + ), + ); + }); + + it("onGitRefreshFailed sets error", () => { + const d = deps(); + onGitRefreshFailed("boom", d); + expect(d.setError).toHaveBeenCalledWith("boom"); + }); +}); diff --git a/src/lib/tray/trayGitRefreshHandlers.ts b/src/lib/tray/trayGitRefreshHandlers.ts new file mode 100644 index 0000000..9393c9d --- /dev/null +++ b/src/lib/tray/trayGitRefreshHandlers.ts @@ -0,0 +1,50 @@ +import { + gitRefreshErrorMessage, + shouldClearListErrorOnRefreshLoad, +} from "$lib/gitRefresh"; +import type { GitRefreshSummary } from "$lib/types"; +import { + armGitRefreshWatchdog, + clearGitRefreshWatchdog, +} from "./gitRefreshWatchdog"; +import { trayTrace } from "./trayTrace"; + +export interface GitRefreshHandlerDeps { + setSelectedIndex: (index: number) => void; + refresh: (clearError: boolean) => Promise; + setError: (message: string | null) => void; + focusFilter: () => void; +} + +export function onPanelOpened(deps: GitRefreshHandlerDeps): void { + trayTrace("panel-opened"); + void deps.refresh(true); + armGitRefreshWatchdog(() => { + trayTrace("git refresh watchdog fired (no git-refresh-complete)"); + deps.setError( + "Git refresh timed out waiting for git-refresh-complete. Check the terminal (RUST_LOG=debug just launch) and the tray webview console (right-click → Inspect).", + ); + }); + deps.focusFilter(); +} + +export function onGitRefreshComplete( + summary: GitRefreshSummary, + deps: GitRefreshHandlerDeps, +): void { + trayTrace("git-refresh-complete", summary); + clearGitRefreshWatchdog(); + deps.setSelectedIndex(0); + void deps.refresh(shouldClearListErrorOnRefreshLoad()).then(() => { + deps.setError(gitRefreshErrorMessage(summary)); + }); +} + +export function onGitRefreshFailed( + message: string, + deps: Pick, +): void { + trayTrace("git-refresh-failed", message); + clearGitRefreshWatchdog(); + deps.setError(message); +} diff --git a/src/lib/tray/trayLaunch.svelte.test.ts b/src/lib/tray/trayLaunch.svelte.test.ts new file mode 100644 index 0000000..36f2741 --- /dev/null +++ b/src/lib/tray/trayLaunch.svelte.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { DEFAULT_SECTION_CFG } from "$lib/openSelection"; +import type { RepoDto } from "$lib/types"; +import { createTrayLaunch } from "./trayLaunch.svelte"; + +const invoke = vi.fn(); +const hide = vi.fn().mockResolvedValue(undefined); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (...args: unknown[]) => invoke(...args), +})); + +vi.mock("@tauri-apps/api/window", () => ({ + getCurrentWindow: () => ({ hide }), +})); + +function repo(path: string): RepoDto { + return { + path, + name: path.split("/").pop()!, + alias: null, + branch: "main", + is_dirty: false, + parent_dir: "", + last_opened_at: 1, + git_state_error: null, + pinned: false, + pin_order: null, + notes: null, + tags: [], + branches: [], + }; +} + +function createLaunch( + overrides: { + selected?: RepoDto | undefined; + repos?: RepoDto[]; + filterQuery?: string; + } = {}, +) { + const repos = overrides.repos ?? [repo("/tmp/a"), repo("/tmp/b")]; + const refresh = vi.fn().mockResolvedValue(undefined); + const setSelectedIndex = vi.fn(); + return { + launch: createTrayLaunch({ + getSelectedRepo: () => overrides.selected ?? repos[0], + getFilterQuery: () => overrides.filterQuery ?? "", + getSectionCfg: () => DEFAULT_SECTION_CFG, + getRepos: () => repos, + refresh, + setSelectedIndex, + }), + refresh, + setSelectedIndex, + repos, + }; +} + +describe("createTrayLaunch", () => { + beforeEach(() => { + invoke.mockReset(); + hide.mockClear(); + invoke.mockResolvedValue(undefined); + }); + + it("openSelected foreground invokes open and hides panel", async () => { + const { launch } = createLaunch({ selected: repo("/tmp/a") }); + await launch.openSelected(false); + + expect(invoke).toHaveBeenCalledWith("open_in_cursor", { + path: "/tmp/a", + background: false, + }); + expect(hide).toHaveBeenCalledOnce(); + expect(launch.launchError).toBeNull(); + }); + + it("openSelected background refreshes and updates selection without hiding", async () => { + const repos = [repo("/tmp/a"), repo("/tmp/b")]; + const { launch, refresh, setSelectedIndex } = createLaunch({ + selected: repos[0], + repos, + }); + + await launch.openSelected(true); + + expect(invoke).toHaveBeenCalledWith("open_in_cursor", { + path: "/tmp/a", + background: true, + }); + expect(hide).not.toHaveBeenCalled(); + expect(refresh).toHaveBeenCalledWith(false); + expect(setSelectedIndex).toHaveBeenCalled(); + }); + + it("openSelected noop when no repo selected", async () => { + const { launch } = createLaunch({ repos: [], selected: undefined }); + await launch.openSelected(false); + expect(invoke).not.toHaveBeenCalled(); + expect(hide).not.toHaveBeenCalled(); + }); + + it("openSelected sets launchError on invoke failure", async () => { + invoke.mockRejectedValueOnce(new Error("cursor missing")); + const { launch } = createLaunch({ selected: repo("/tmp/a") }); + + await launch.openSelected(false); + + expect(launch.launchError).toBe("Error: cursor missing"); + expect(hide).not.toHaveBeenCalled(); + }); + + it("dismissLaunchError clears launchError", async () => { + invoke.mockRejectedValueOnce("fail"); + const { launch } = createLaunch({ selected: repo("/tmp/a") }); + await launch.openSelected(false); + expect(launch.launchError).toBe("fail"); + + launch.dismissLaunchError(); + expect(launch.launchError).toBeNull(); + }); +}); diff --git a/src/lib/tray/trayLaunch.svelte.ts b/src/lib/tray/trayLaunch.svelte.ts new file mode 100644 index 0000000..8aa172e --- /dev/null +++ b/src/lib/tray/trayLaunch.svelte.ts @@ -0,0 +1,65 @@ +import { invoke } from "@tauri-apps/api/core"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import type { SectionConfig } from "$lib/sort"; +import type { RepoDto } from "$lib/types"; +import { computeBackgroundOpenSelection } from "./trayLaunch"; + +export interface TrayLaunchDeps { + getSelectedRepo: () => RepoDto | undefined; + getFilterQuery: () => string; + getSectionCfg: () => SectionConfig; + getRepos: () => RepoDto[]; + refresh: (clearError: boolean) => Promise; + setSelectedIndex: (index: number) => void; +} + +async function hideTrayPanel(): Promise { + await getCurrentWindow().hide(); +} + +export function createTrayLaunch(deps: TrayLaunchDeps) { + let launchError = $state(null); + + function dismissLaunchError() { + launchError = null; + } + + async function openSelected(background: boolean): Promise { + const repo = deps.getSelectedRepo(); + if (!repo) { + return; + } + launchError = null; + try { + await invoke("open_in_cursor", { path: repo.path, background }); + if (background) { + const openedPath = repo.path; + const query = deps.getFilterQuery(); + await deps.refresh(false); + deps.setSelectedIndex( + computeBackgroundOpenSelection( + deps.getRepos(), + query, + openedPath, + deps.getSectionCfg(), + ), + ); + } else { + await hideTrayPanel(); + } + } catch (e) { + launchError = String(e); + } + } + + return { + get launchError() { + return launchError; + }, + openSelected, + dismissLaunchError, + hidePanel: hideTrayPanel, + }; +} + +export type TrayLaunch = ReturnType; diff --git a/src/lib/tray/trayLaunch.test.ts b/src/lib/tray/trayLaunch.test.ts new file mode 100644 index 0000000..3d81234 --- /dev/null +++ b/src/lib/tray/trayLaunch.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_SECTION_CFG } from "$lib/openSelection"; +import { computeBackgroundOpenSelection } from "./trayLaunch"; +import type { RepoDto } from "$lib/types"; + +function repo(name: string, path?: string): RepoDto { + return { + name, + alias: null, + path: path ?? `/tmp/${name}`, + branch: null, + is_dirty: null, + parent_dir: "", + last_opened_at: null, + git_state_error: null, + pinned: false, + pin_order: null, + notes: null, + tags: [], + branches: [], + }; +} + +describe("computeBackgroundOpenSelection", () => { + const repos = [repo("alpha"), repo("beta"), repo("gamma")]; + const sectionCfg = DEFAULT_SECTION_CFG; + + it("delegates to openSelection path lookup", () => { + expect( + computeBackgroundOpenSelection(repos, "", "/tmp/beta", sectionCfg), + ).toBe(1); + }); + + it("respects active filter when resolving selection", () => { + expect( + computeBackgroundOpenSelection(repos, "gam", "/tmp/gamma", sectionCfg), + ).toBe(0); + }); +}); diff --git a/src/lib/tray/trayLaunch.ts b/src/lib/tray/trayLaunch.ts new file mode 100644 index 0000000..a672090 --- /dev/null +++ b/src/lib/tray/trayLaunch.ts @@ -0,0 +1,18 @@ +import { selectionIndexAfterBackgroundOpen } from "$lib/openSelection"; +import type { SectionConfig } from "$lib/sort"; +import type { RepoDto } from "$lib/types"; + +/** Selection index after Cmd+Enter background open (testable without runes). */ +export function computeBackgroundOpenSelection( + repos: RepoDto[], + query: string, + openedPath: string, + sectionCfg: SectionConfig, +): number { + return selectionIndexAfterBackgroundOpen( + repos, + query, + openedPath, + sectionCfg, + ); +} diff --git a/src/lib/tray/trayListSelection.svelte.test.ts b/src/lib/tray/trayListSelection.svelte.test.ts new file mode 100644 index 0000000..dc9e958 --- /dev/null +++ b/src/lib/tray/trayListSelection.svelte.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_SECTION_CFG } from "$lib/openSelection"; +import type { RepoDto } from "$lib/types"; +import { createTrayListSelection } from "./trayListSelection.svelte"; + +function repo(name: string, overrides: Partial = {}): RepoDto { + const path = `/tmp/${name}`; + return { + path, + name, + alias: null, + branch: null, + is_dirty: null, + parent_dir: "", + last_opened_at: null, + git_state_error: null, + pinned: false, + pin_order: null, + notes: null, + tags: [], + branches: [], + ...overrides, + }; +} + +function createSelection(repos: RepoDto[], error: string | null = null) { + return createTrayListSelection({ + getRepos: () => repos, + getSectionCfg: () => DEFAULT_SECTION_CFG, + getError: () => error, + }); +} + +describe("createTrayListSelection", () => { + it("filterQuery change resets selectedIndex to 0", () => { + const list = createSelection([repo("a"), repo("b")]); + list.selectedIndex = 1; + list.filterQuery = "a"; + expect(list.selectedIndex).toBe(0); + }); + + it("moveSelection wraps around list bounds", () => { + const list = createSelection([repo("a"), repo("b"), repo("c")]); + expect(list.selectedIndex).toBe(0); + list.moveSelection(1); + expect(list.selectedIndex).toBe(1); + list.moveSelection(2); + expect(list.selectedIndex).toBe(0); + list.moveSelection(-1); + expect(list.selectedIndex).toBe(2); + }); + + it("getSelectedRepo returns repo at clamped index", () => { + const a = repo("alpha"); + const b = repo("beta"); + const list = createSelection([a, b]); + list.selectedIndex = 1; + expect(list.getSelectedRepo()).toEqual(b); + }); + + it("appendTagFilter and onTagAutocompleteSelect update filterQuery", () => { + const list = createSelection([repo("a", { tags: ["work"] })]); + list.filterQuery = "alpha #w"; + list.onTagAutocompleteSelect("work"); + expect(list.filterQuery).toBe("alpha #work "); + + list.filterQuery = ""; + list.appendTagFilter("personal"); + expect(list.filterQuery).toBe("#personal"); + }); + + it("listView reflects error, empty, no-match, and list states", () => { + const empty = createSelection([]); + expect(empty.listView).toEqual({ kind: "empty-list" }); + + const err = createSelection([], "load failed"); + expect(err.listView).toEqual({ kind: "error", message: "load failed" }); + + const noMatch = createSelection([repo("a")]); + noMatch.filterQuery = "zzz"; + expect(noMatch.listView).toEqual({ kind: "no-match" }); + + const visible = createSelection([repo("a")]); + expect(visible.listView).toEqual({ kind: "list" }); + }); + + it("flatIndexByPath maps repo paths to flat indices", () => { + const a = repo("a"); + const b = repo("b"); + const list = createSelection([a, b]); + expect(list.flatIndexByPath.get(a.path)).toBe(0); + expect(list.flatIndexByPath.get(b.path)).toBe(1); + }); +}); diff --git a/src/lib/tray/trayListSelection.svelte.ts b/src/lib/tray/trayListSelection.svelte.ts new file mode 100644 index 0000000..05b9e3c --- /dev/null +++ b/src/lib/tray/trayListSelection.svelte.ts @@ -0,0 +1,102 @@ +import { SvelteMap } from "svelte/reactivity"; +import { trayListView } from "$lib/listState"; +import { clampSelectionIndex, moveSelectionIndex } from "$lib/selection"; +import type { SectionConfig } from "$lib/sort"; +import { + appendTagToFilterQuery, + replaceTrailingTagAutocomplete, + trailingTagAutocompletePrefix, +} from "$lib/tagFilter"; +import { filterAndSectionRepos, flatSectioned } from "$lib/trayList"; +import type { RepoDto } from "$lib/types"; + +export interface TrayListSelectionDeps { + getRepos: () => RepoDto[]; + getSectionCfg: () => SectionConfig; + getError: () => string | null; +} + +export function createTrayListSelection(deps: TrayListSelectionDeps) { + let filterQuery = $state(""); + let rawSelectedIndex = $state(0); + + let sectionedRepos = $derived( + filterAndSectionRepos(deps.getRepos(), filterQuery, deps.getSectionCfg()), + ); + let flatVisible = $derived(flatSectioned(sectionedRepos)); + let flatIndexByPath = $derived( + new SvelteMap(flatVisible.map((r, i) => [r.path, i] as const)), + ); + let tagAutocompletePrefix = $derived( + trailingTagAutocompletePrefix(filterQuery), + ); + let listView = $derived( + trayListView( + deps.getError(), + deps.getRepos().length, + filterQuery, + flatVisible.length, + ), + ); + + function moveSelection(delta: number) { + rawSelectedIndex = moveSelectionIndex( + clampSelectionIndex(rawSelectedIndex, flatVisible.length), + delta, + flatVisible.length, + ); + } + + function appendTagFilter(tag: string) { + filterQuery = appendTagToFilterQuery(filterQuery, tag); + } + + function onTagAutocompleteSelect(tag: string) { + filterQuery = replaceTrailingTagAutocomplete(filterQuery, tag); + } + + function getSelectedRepo(): RepoDto | undefined { + return flatVisible[ + clampSelectionIndex(rawSelectedIndex, flatVisible.length) + ]; + } + + return { + get filterQuery() { + return filterQuery; + }, + set filterQuery(value: string) { + if (filterQuery !== value) { + rawSelectedIndex = 0; + } + filterQuery = value; + }, + get selectedIndex() { + return clampSelectionIndex(rawSelectedIndex, flatVisible.length); + }, + set selectedIndex(value: number) { + rawSelectedIndex = value; + }, + get sectionedRepos() { + return sectionedRepos; + }, + get flatVisible() { + return flatVisible; + }, + get flatIndexByPath() { + return flatIndexByPath; + }, + get tagAutocompletePrefix() { + return tagAutocompletePrefix; + }, + get listView() { + return listView; + }, + moveSelection, + appendTagFilter, + onTagAutocompleteSelect, + getSelectedRepo, + }; +} + +export type TrayListSelection = ReturnType; diff --git a/src/lib/tray/trayPanelEvents.test.ts b/src/lib/tray/trayPanelEvents.test.ts new file mode 100644 index 0000000..a6fce37 --- /dev/null +++ b/src/lib/tray/trayPanelEvents.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from "vitest"; +import { subscribeTrayPanelEvents, type ListenFn } from "./trayPanelEvents"; +import type { GitRefreshSummary } from "$lib/types"; + +function mockListen(): { + listen: ListenFn; + unsubs: ReturnType[]; + handlers: Map void>; +} { + const unsubs: ReturnType[] = []; + const handlers = new Map void>(); + + const listen: ListenFn = vi.fn(async (event, handler) => { + handlers.set(event, handler as (event: { payload: unknown }) => void); + const unsub = vi.fn(); + unsubs.push(unsub); + return unsub; + }); + + return { listen, unsubs, handlers }; +} + +describe("subscribeTrayPanelEvents", () => { + it("registers four listeners and unsubscribes all", async () => { + const { listen, unsubs, handlers } = mockListen(); + const onPanelOpened = vi.fn(); + const onGitRefreshComplete = vi.fn(); + const onGitRefreshFailed = vi.fn(); + const onRepoContextAction = vi.fn(); + + const unsubscribe = await subscribeTrayPanelEvents( + { + onPanelOpened, + onGitRefreshComplete, + onGitRefreshFailed, + onRepoContextAction, + }, + listen, + ); + + expect(listen).toHaveBeenCalledTimes(4); + + handlers.get("panel-opened")!({ payload: undefined }); + expect(onPanelOpened).toHaveBeenCalledOnce(); + + const summary: GitRefreshSummary = { + refreshed: 1, + errors: 0, + any_dirty: false, + }; + handlers.get("git-refresh-complete")!({ payload: summary }); + expect(onGitRefreshComplete).toHaveBeenCalledWith(summary); + + handlers.get("git-refresh-failed")!({ payload: "boom" }); + expect(onGitRefreshFailed).toHaveBeenCalledWith("boom"); + + const ctx = { action: "pin", repo_path: "/tmp/x" }; + handlers.get("repo-context-action")!({ payload: ctx }); + expect(onRepoContextAction).toHaveBeenCalledWith(ctx); + + unsubscribe(); + expect(unsubs.every((u) => u.mock.calls.length === 1)).toBe(true); + }); +}); diff --git a/src/lib/tray/trayPanelEvents.ts b/src/lib/tray/trayPanelEvents.ts new file mode 100644 index 0000000..7e097d4 --- /dev/null +++ b/src/lib/tray/trayPanelEvents.ts @@ -0,0 +1,43 @@ +import { listen } from "@tauri-apps/api/event"; +import type { UnlistenFn } from "@tauri-apps/api/event"; +import type { GitRefreshSummary } from "$lib/types"; +import { trayTrace } from "./trayTrace"; + +export type ListenFn = ( + event: string, + handler: (event: { payload: T }) => void, +) => Promise; + +export interface TrayPanelEventHandlers { + onPanelOpened: () => void; + onGitRefreshComplete: (summary: GitRefreshSummary) => void; + onGitRefreshFailed: (message: string) => void; + onRepoContextAction: (payload: { action: string; repo_path: string }) => void; +} + +/** Subscribe to tray Tauri events; returned fn unsubscribes all listeners. */ +export async function subscribeTrayPanelEvents( + handlers: TrayPanelEventHandlers, + listenFn: ListenFn = listen, +): Promise<() => void> { + trayTrace("registering tray event listeners"); + const unsubs = await Promise.all([ + listenFn("panel-opened", () => handlers.onPanelOpened()), + listenFn("git-refresh-complete", (event) => + handlers.onGitRefreshComplete(event.payload), + ), + listenFn("git-refresh-failed", (event) => + handlers.onGitRefreshFailed(event.payload), + ), + listenFn<{ action: string; repo_path: string }>( + "repo-context-action", + (event) => handlers.onRepoContextAction(event.payload), + ), + ]); + trayTrace("tray event listeners ready"); + return () => { + for (const fn of unsubs) { + fn(); + } + }; +} diff --git a/src/lib/tray/trayPanelKeyboard.svelte.test.ts b/src/lib/tray/trayPanelKeyboard.svelte.test.ts new file mode 100644 index 0000000..32923ff --- /dev/null +++ b/src/lib/tray/trayPanelKeyboard.svelte.test.ts @@ -0,0 +1,298 @@ +import { describe, expect, it, vi } from "vitest"; +import type { RepoDto } from "$lib/types"; +import { createTrayDetail } from "./trayDetail.svelte"; +import { createTrayLaunch } from "./trayLaunch.svelte"; +import { createTrayListSelection } from "./trayListSelection.svelte"; +import { createTrayPanelKeyboard } from "./trayPanelKeyboard.svelte"; +import { createTrayRepoData } from "./trayRepoData.svelte"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@tauri-apps/api/window", () => ({ + getCurrentWindow: () => ({ hide: vi.fn().mockResolvedValue(undefined) }), +})); + +function repo(name: string): RepoDto { + const path = `/tmp/${name}`; + return { + path, + name, + alias: null, + branch: null, + is_dirty: null, + parent_dir: "", + last_opened_at: null, + git_state_error: null, + pinned: false, + pin_order: null, + notes: null, + tags: [], + branches: [], + }; +} + +function panelKeyboard() { + const data = createTrayRepoData(); + const detail = createTrayDetail(); + const list = createTrayListSelection({ + getRepos: () => data.repos, + getSectionCfg: () => ({ maxRecentDays: 14, minRecentCount: 3 }), + getError: () => data.error, + }); + const launch = createTrayLaunch({ + getSelectedRepo: () => list.getSelectedRepo(), + getFilterQuery: () => list.filterQuery, + getSectionCfg: () => ({ maxRecentDays: 14, minRecentCount: 3 }), + getRepos: () => data.repos, + refresh: vi.fn().mockResolvedValue(undefined), + setSelectedIndex: (i) => { + list.selectedIndex = i; + }, + }); + return createTrayPanelKeyboard({ list, detail, launch, data }); +} + +describe("createTrayPanelKeyboard", () => { + it("onPanelKeydown ArrowDown moves selection", () => { + const list = createTrayListSelection({ + getRepos: () => [repo("a"), repo("b")], + getSectionCfg: () => ({ maxRecentDays: 14, minRecentCount: 3 }), + getError: () => null, + }); + const detail = createTrayDetail(); + const launch = createTrayLaunch({ + getSelectedRepo: () => list.getSelectedRepo(), + getFilterQuery: () => list.filterQuery, + getSectionCfg: () => ({ maxRecentDays: 14, minRecentCount: 3 }), + getRepos: () => [repo("a"), repo("b")], + refresh: vi.fn(), + setSelectedIndex: (i) => { + list.selectedIndex = i; + }, + }); + const kb = createTrayPanelKeyboard({ + list, + detail, + launch, + data: createTrayRepoData(), + }); + + const e = new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }); + const prevented = Object.assign(e, { + preventDefault: vi.fn(), + }) as KeyboardEvent; + kb.onPanelKeydown(prevented); + expect(list.selectedIndex).toBe(1); + expect(prevented.preventDefault).toHaveBeenCalled(); + }); + + it("onFilterKeydown ArrowDown at end of input moves selection", () => { + const list = createTrayListSelection({ + getRepos: () => [repo("a"), repo("b")], + getSectionCfg: () => ({ maxRecentDays: 14, minRecentCount: 3 }), + getError: () => null, + }); + const kb = createTrayPanelKeyboard({ + list, + detail: createTrayDetail(), + launch: createTrayLaunch({ + getSelectedRepo: () => list.getSelectedRepo(), + getFilterQuery: () => list.filterQuery, + getSectionCfg: () => ({ maxRecentDays: 14, minRecentCount: 3 }), + getRepos: () => [repo("a"), repo("b")], + refresh: vi.fn(), + setSelectedIndex: (i) => { + list.selectedIndex = i; + }, + }), + data: createTrayRepoData(), + }); + + const input = document.createElement("input"); + input.value = "tag:work"; + input.selectionStart = 8; + input.selectionEnd = 8; + const e = new KeyboardEvent("keydown", { + key: "ArrowDown", + bubbles: true, + }); + Object.defineProperty(e, "currentTarget", { value: input }); + const prevented = Object.assign(e, { + preventDefault: vi.fn(), + }) as KeyboardEvent; + + kb.onFilterKeydown(prevented); + expect(list.selectedIndex).toBe(1); + expect(prevented.preventDefault).toHaveBeenCalled(); + }); + + it("bindFilterInput enables focusFilter", () => { + const kb = panelKeyboard(); + const input = document.createElement("input"); + input.focus = vi.fn(); + kb.bindFilterInput(input); + kb.focusFilter(); + expect(input.focus).toHaveBeenCalled(); + }); + + it("onPanelKeydown ArrowRight opens detail for selected repo", () => { + const repos = [repo("a"), repo("b")]; + const list = createTrayListSelection({ + getRepos: () => repos, + getSectionCfg: () => ({ maxRecentDays: 14, minRecentCount: 3 }), + getError: () => null, + }); + const detail = createTrayDetail(); + const kb = createTrayPanelKeyboard({ + list, + detail, + launch: createTrayLaunch({ + getSelectedRepo: () => list.getSelectedRepo(), + getFilterQuery: () => list.filterQuery, + getSectionCfg: () => ({ maxRecentDays: 14, minRecentCount: 3 }), + getRepos: () => repos, + refresh: vi.fn(), + setSelectedIndex: (i) => { + list.selectedIndex = i; + }, + }), + data: createTrayRepoData(), + }); + + const e = new KeyboardEvent("keydown", { + key: "ArrowRight", + bubbles: true, + }); + kb.onPanelKeydown(e); + expect(detail.detailRepo?.path).toBe(repos[0].path); + }); + + it("onFilterKeydown delegates refresh shortcut to tray nav", () => { + const data = createTrayRepoData(); + const startRefresh = vi.spyOn(data, "startBackgroundRefresh"); + const list = createTrayListSelection({ + getRepos: () => [], + getSectionCfg: () => ({ maxRecentDays: 14, minRecentCount: 3 }), + getError: () => null, + }); + const kb = createTrayPanelKeyboard({ + list, + detail: createTrayDetail(), + launch: createTrayLaunch({ + getSelectedRepo: () => list.getSelectedRepo(), + getFilterQuery: () => list.filterQuery, + getSectionCfg: () => ({ maxRecentDays: 14, minRecentCount: 3 }), + getRepos: () => [], + refresh: vi.fn(), + setSelectedIndex: (i) => { + list.selectedIndex = i; + }, + }), + data, + }); + const input = document.createElement("input"); + const e = new KeyboardEvent("keydown", { + key: "r", + metaKey: true, + bubbles: true, + }); + Object.defineProperty(e, "currentTarget", { value: input }); + kb.onFilterKeydown(e); + expect(startRefresh).toHaveBeenCalledOnce(); + }); + + it("onPanelKeydown ignores repo-filter input target", () => { + const list = createTrayListSelection({ + getRepos: () => [repo("a"), repo("b")], + getSectionCfg: () => ({ maxRecentDays: 14, minRecentCount: 3 }), + getError: () => null, + }); + const kb = createTrayPanelKeyboard({ + list, + detail: createTrayDetail(), + launch: createTrayLaunch({ + getSelectedRepo: () => list.getSelectedRepo(), + getFilterQuery: () => list.filterQuery, + getSectionCfg: () => ({ maxRecentDays: 14, minRecentCount: 3 }), + getRepos: () => [repo("a"), repo("b")], + refresh: vi.fn(), + setSelectedIndex: (i) => { + list.selectedIndex = i; + }, + }), + data: createTrayRepoData(), + }); + + const input = document.createElement("input"); + input.id = "repo-filter"; + const e = new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }); + Object.defineProperty(e, "target", { value: input }); + kb.onPanelKeydown(e); + expect(list.selectedIndex).toBe(0); + }); + + it("onPanelKeydown Enter opens selected repo", async () => { + const repos = [repo("a")]; + const list = createTrayListSelection({ + getRepos: () => repos, + getSectionCfg: () => ({ maxRecentDays: 14, minRecentCount: 3 }), + getError: () => null, + }); + const launch = createTrayLaunch({ + getSelectedRepo: () => list.getSelectedRepo(), + getFilterQuery: () => list.filterQuery, + getSectionCfg: () => ({ maxRecentDays: 14, minRecentCount: 3 }), + getRepos: () => repos, + refresh: vi.fn(), + setSelectedIndex: (i) => { + list.selectedIndex = i; + }, + }); + const openSelected = vi.spyOn(launch, "openSelected"); + const kb = createTrayPanelKeyboard({ + list, + detail: createTrayDetail(), + launch, + data: createTrayRepoData(), + }); + + kb.onPanelKeydown( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + expect(openSelected).toHaveBeenCalledWith(false); + }); + + it("onPanelKeydown ignores detail form inputs", () => { + const repos = [repo("a")]; + const list = createTrayListSelection({ + getRepos: () => repos, + getSectionCfg: () => ({ maxRecentDays: 14, minRecentCount: 3 }), + getError: () => null, + }); + const detail = createTrayDetail(); + detail.openDetail(repos[0]); + const kb = createTrayPanelKeyboard({ + list, + detail, + launch: createTrayLaunch({ + getSelectedRepo: () => list.getSelectedRepo(), + getFilterQuery: () => list.filterQuery, + getSectionCfg: () => ({ maxRecentDays: 14, minRecentCount: 3 }), + getRepos: () => repos, + refresh: vi.fn(), + setSelectedIndex: (i) => { + list.selectedIndex = i; + }, + }), + data: createTrayRepoData(), + }); + + const notes = document.createElement("textarea"); + const e = new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }); + Object.defineProperty(e, "target", { value: notes }); + kb.onPanelKeydown(e); + expect(list.selectedIndex).toBe(0); + }); +}); diff --git a/src/lib/tray/trayPanelKeyboard.svelte.ts b/src/lib/tray/trayPanelKeyboard.svelte.ts new file mode 100644 index 0000000..93df027 --- /dev/null +++ b/src/lib/tray/trayPanelKeyboard.svelte.ts @@ -0,0 +1,97 @@ +import { shouldNavigateListOnFilterArrow } from "$lib/filterNavigation"; +import { applyTrayNavigationKey } from "$lib/trayKeyboard"; +import type { TrayDetail } from "./trayDetail.svelte"; +import type { TrayLaunch } from "./trayLaunch.svelte"; +import type { TrayListSelection } from "./trayListSelection.svelte"; +import type { TrayRepoData } from "./trayRepoData.svelte"; + +export interface TrayPanelKeyboardDeps { + list: TrayListSelection; + detail: TrayDetail; + launch: TrayLaunch; + data: TrayRepoData; +} + +export function createTrayPanelKeyboard(deps: TrayPanelKeyboardDeps) { + let filterInput = $state(null); + + function focusFilter() { + filterInput?.focus(); + } + + function bindFilterInput(el: HTMLInputElement | null) { + filterInput = el; + } + + function applyTrayNav(e: KeyboardEvent, mode: "filter" | "panel") { + const { list, detail, launch, data } = deps; + return applyTrayNavigationKey( + e, + { + detailRepo: detail.detailRepo, + getSelectedRepo: () => list.getSelectedRepo(), + }, + { + onRefresh: () => void data.startBackgroundRefresh(), + onCloseDetail: () => detail.closeDetail(), + onHidePanel: () => void launch.hidePanel(), + onOpenDetailForSelection: () => { + const repo = list.getSelectedRepo(); + if (repo) { + detail.openDetail(repo); + } + }, + onMoveSelection: list.moveSelection, + onOpenSelected: (background: boolean) => + void launch.openSelected(background), + }, + mode, + ); + } + + function onFilterKeydown(e: KeyboardEvent) { + if (applyTrayNav(e, "filter")) { + return; + } + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + const input = e.currentTarget as HTMLInputElement; + const start = input.selectionStart ?? 0; + const end = input.selectionEnd ?? 0; + if ( + shouldNavigateListOnFilterArrow( + e.key, + deps.list.filterQuery, + start, + end, + input.value.length, + ) + ) { + e.preventDefault(); + deps.list.moveSelection(e.key === "ArrowDown" ? 1 : -1); + } + } + } + + function onPanelKeydown(e: KeyboardEvent) { + if (e.target instanceof HTMLInputElement && e.target.id === "repo-filter") { + return; + } + if ( + deps.detail.detailRepo !== null && + (e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement) + ) { + return; + } + applyTrayNav(e, "panel"); + } + + return { + bindFilterInput, + focusFilter, + onFilterKeydown, + onPanelKeydown, + }; +} + +export type TrayPanelKeyboard = ReturnType; diff --git a/src/lib/tray/trayPanelStoryFixtures.ts b/src/lib/tray/trayPanelStoryFixtures.ts new file mode 100644 index 0000000..7dd85fb --- /dev/null +++ b/src/lib/tray/trayPanelStoryFixtures.ts @@ -0,0 +1,97 @@ +import { + STORY_REPO_PATH_PREFIX, + storyRepo, +} from "$lib/components/repoStoryFixtures"; +import { sectionSort } from "$lib/sort"; +import type { SectionedRepos } from "$lib/sort"; +import type { RepoDto, TrayConfigDto } from "$lib/types"; +import { DEFAULT_SECTION_CFG } from "$lib/openSelection"; +import type { TrayListView } from "$lib/listState"; + +export function storyTrayRepos(): RepoDto[] { + return [ + storyRepo({ + path: `${STORY_REPO_PATH_PREFIX}/workpot`, + name: "workpot", + branch: "main", + is_dirty: false, + pinned: true, + pin_order: 0, + tags: ["rust"], + last_opened_at: Math.floor(Date.now() / 1000) - 3600, + }), + storyRepo({ + path: `${STORY_REPO_PATH_PREFIX}/alpha`, + name: "alpha", + branch: "feat/ui", + is_dirty: true, + pinned: false, + tags: ["frontend"], + last_opened_at: Math.floor(Date.now() / 1000) - 7200, + }), + storyRepo({ + path: `${STORY_REPO_PATH_PREFIX}/beta`, + name: "beta", + branch: "develop", + is_dirty: false, + pinned: false, + last_opened_at: Math.floor(Date.now() / 1000) - 86400 * 3, + }), + storyRepo({ + path: `${STORY_REPO_PATH_PREFIX}/gamma`, + name: "gamma", + branch: null, + is_dirty: null, + pinned: false, + last_opened_at: null, + }), + storyRepo({ + path: `${STORY_REPO_PATH_PREFIX}/delta`, + name: "delta", + branch: "release", + is_dirty: false, + pinned: false, + last_opened_at: Math.floor(Date.now() / 1000) - 86400 * 10, + }), + ]; +} + +export function storySectionedRepos(repos = storyTrayRepos()): SectionedRepos { + return sectionSort(repos, DEFAULT_SECTION_CFG, Math.floor(Date.now() / 1000)); +} + +export function emptySectionedRepos(): SectionedRepos { + return { pinned: [], dirty: [], recent: [], rest: [] }; +} + +export function storyFlatIndexByPath( + sectioned: SectionedRepos = storySectionedRepos(), +): Map { + const flat = [ + ...sectioned.pinned, + ...sectioned.dirty, + ...sectioned.recent, + ...sectioned.rest, + ]; + return new Map(flat.map((r, i) => [r.path, i] as const)); +} + +export function storyTrayConfig(): TrayConfigDto { + return { + max_visible_rows: 15, + max_recent_days: 14, + min_recent_count: 3, + max_pinned: 5, + stale_dirty_days: 7, + }; +} + +export const storyListViews = { + emptyList: { kind: "empty-list" } satisfies TrayListView, + noMatch: { kind: "no-match" } satisfies TrayListView, + list: { kind: "list" } satisfies TrayListView, + error: { + kind: "error", + message: "SQLite database is locked", + } satisfies TrayListView, +}; diff --git a/src/lib/tray/trayRepoActions.test.ts b/src/lib/tray/trayRepoActions.test.ts new file mode 100644 index 0000000..1c2db11 --- /dev/null +++ b/src/lib/tray/trayRepoActions.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from "vitest"; +import type { RepoDto } from "$lib/types"; +import { + executeContextCommand, + handleRepoContextAction, + removeTag, + setPinOrder, + type TrayRepoActionsDeps, +} from "./trayRepoActions"; + +function repo(overrides: Partial = {}): RepoDto { + return { + path: "/tmp/foo", + name: "foo", + alias: null, + branch: null, + is_dirty: null, + parent_dir: "", + last_opened_at: null, + git_state_error: null, + pinned: false, + pin_order: null, + notes: null, + tags: [], + branches: [], + ...overrides, + }; +} + +function deps( + overrides: Partial = {}, +): TrayRepoActionsDeps { + return { + invoke: vi.fn().mockResolvedValue(undefined), + refresh: vi.fn().mockResolvedValue(undefined), + onError: vi.fn(), + openDetailWithTagFocus: vi.fn(), + ...overrides, + }; +} + +describe("trayRepoActions", () => { + it("mutateThenRefresh invokes, refreshes, and skips onError on success", async () => { + const d = deps(); + await removeTag("/tmp/foo", "work", d); + expect(d.invoke).toHaveBeenCalledWith("remove_tag", { + repoPath: "/tmp/foo", + tag: "work", + }); + expect(d.refresh).toHaveBeenCalledOnce(); + expect(d.onError).not.toHaveBeenCalled(); + }); + + it("mutateThenRefresh calls onError without refresh when invoke fails", async () => { + const d = deps({ + invoke: vi.fn().mockRejectedValue(new Error("fail")), + }); + await removeTag("/tmp/foo", "work", d); + expect(d.onError).toHaveBeenCalledOnce(); + expect(d.refresh).not.toHaveBeenCalled(); + }); + + it("executeContextCommand toggles pin via mutateThenRefresh", async () => { + const d = deps(); + await executeContextCommand( + { kind: "toggle_pin", repoPath: "/tmp/foo", pinned: true }, + d, + ); + expect(d.invoke).toHaveBeenCalledWith("set_pin", { + repoPath: "/tmp/foo", + pinned: true, + }); + expect(d.refresh).toHaveBeenCalledOnce(); + }); + + it("executeContextCommand opens detail with tag focus without invoke", async () => { + const r = repo(); + const d = deps(); + await executeContextCommand({ kind: "open_detail_tag_focus", repo: r }, d); + expect(d.openDetailWithTagFocus).toHaveBeenCalledWith(r); + expect(d.invoke).not.toHaveBeenCalled(); + }); + + it("executeContextCommand noop does nothing", async () => { + const d = deps(); + await executeContextCommand({ kind: "noop" }, d); + expect(d.invoke).not.toHaveBeenCalled(); + expect(d.refresh).not.toHaveBeenCalled(); + }); + + it("setPinOrder invokes set_pin_order", async () => { + const d = deps(); + const items = [{ path: "/a", order: 0 }]; + await setPinOrder(items, d); + expect(d.invoke).toHaveBeenCalledWith("set_pin_order", { items }); + }); + + it("handleRepoContextAction resolves pin from repos", async () => { + const d = deps(); + const repos = [repo({ path: "/tmp/foo", pinned: false })]; + await handleRepoContextAction( + { action: "pin", repo_path: "/tmp/foo" }, + repos, + d, + ); + expect(d.invoke).toHaveBeenCalledWith("set_pin", { + repoPath: "/tmp/foo", + pinned: true, + }); + }); +}); diff --git a/src/lib/tray/trayRepoActions.ts b/src/lib/tray/trayRepoActions.ts new file mode 100644 index 0000000..25fa8ea --- /dev/null +++ b/src/lib/tray/trayRepoActions.ts @@ -0,0 +1,91 @@ +import { resyncDetailRepo } from "$lib/detailRepoSync"; +import type { RepoDto } from "$lib/types"; +import { resolveContextAction, type ContextCommand } from "./trayContextAction"; + +export type TrayInvokeFn = ( + cmd: string, + args?: Record, +) => Promise; + +export interface TrayRepoActionsDeps { + invoke: TrayInvokeFn; + refresh: () => Promise; + onError: (e: unknown) => void; + openDetailWithTagFocus: (repo: RepoDto) => void; +} + +async function mutateThenRefresh( + invokeFn: () => Promise, + refresh: () => Promise, + onError: (e: unknown) => void, +): Promise { + try { + await invokeFn(); + await refresh(); + } catch (e) { + onError(e); + } +} + +export async function removeTag( + repoPath: string, + tag: string, + deps: TrayRepoActionsDeps, +): Promise { + await mutateThenRefresh( + () => deps.invoke("remove_tag", { repoPath, tag }) as Promise, + deps.refresh, + deps.onError, + ); +} + +export async function setPinOrder( + items: { path: string; order: number }[], + deps: TrayRepoActionsDeps, +): Promise { + await mutateThenRefresh( + () => deps.invoke("set_pin_order", { items }) as Promise, + deps.refresh, + deps.onError, + ); +} + +export async function executeContextCommand( + cmd: ContextCommand, + deps: TrayRepoActionsDeps, +): Promise { + switch (cmd.kind) { + case "toggle_pin": + await mutateThenRefresh( + () => + deps.invoke("set_pin", { + repoPath: cmd.repoPath, + pinned: cmd.pinned, + }) as Promise, + deps.refresh, + deps.onError, + ); + break; + case "remove_tag": + await removeTag(cmd.repoPath, cmd.tag, deps); + break; + case "open_detail_tag_focus": + deps.openDetailWithTagFocus(cmd.repo); + break; + case "noop": + break; + } +} + +export async function handleRepoContextAction( + payload: { action: string; repo_path: string }, + repos: RepoDto[], + deps: TrayRepoActionsDeps, +): Promise { + const { action, repo_path } = payload; + const repo = resyncDetailRepo(repos, repo_path); + await executeContextCommand( + resolveContextAction(action, repo, repo_path), + deps, + ); +} diff --git a/src/lib/tray/trayRepoData.svelte.test.ts b/src/lib/tray/trayRepoData.svelte.test.ts new file mode 100644 index 0000000..9fecd7b --- /dev/null +++ b/src/lib/tray/trayRepoData.svelte.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { RepoDto } from "$lib/types"; +import { createTrayRepoData } from "./trayRepoData.svelte"; + +const invoke = vi.fn(); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (...args: unknown[]) => invoke(...args), +})); + +function repo(path: string): RepoDto { + return { + path, + name: path.split("/").pop()!, + alias: null, + branch: null, + is_dirty: null, + parent_dir: "", + last_opened_at: null, + git_state_error: null, + pinned: false, + pin_order: null, + notes: null, + tags: [], + branches: [], + }; +} + +describe("createTrayRepoData", () => { + beforeEach(() => { + invoke.mockReset(); + }); + + it("loadRepos populates repos and clears error", async () => { + const repos = [repo("/tmp/a"), repo("/tmp/b")]; + invoke.mockResolvedValueOnce(repos); + + const data = createTrayRepoData(); + await data.loadRepos(); + + expect(invoke).toHaveBeenCalledWith("list_repos"); + expect(data.repos).toHaveLength(repos.length); + expect(data.repos.map((r) => r.path)).toEqual(repos.map((r) => r.path)); + expect(data.error).toBeNull(); + }); + + it("loadRepos sets error on invoke failure", async () => { + invoke.mockRejectedValueOnce(new Error("list failed")); + + const data = createTrayRepoData(); + await data.loadRepos(); + + expect(data.error).toBe("Error: list failed"); + expect(data.repos).toEqual([]); + }); + + it("loadAllTags falls back to empty array on failure", async () => { + invoke.mockRejectedValueOnce(new Error("tags failed")); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const data = createTrayRepoData(); + await data.loadAllTags(); + + expect(data.allTags).toEqual([]); + warn.mockRestore(); + }); + + it("refresh loads repos and tags then calls onAfterRefresh", async () => { + const repos = [repo("/tmp/x")]; + invoke + .mockResolvedValueOnce(repos) + .mockResolvedValueOnce(["work", "personal"]); + const onAfterRefresh = vi.fn(); + + const data = createTrayRepoData({ onAfterRefresh }); + await data.refresh(); + + expect(data.allTags).toEqual(["work", "personal"]); + expect(onAfterRefresh).toHaveBeenCalledWith(repos); + }); + + it("startBackgroundRefresh sets error when git refresh fails", async () => { + invoke.mockRejectedValueOnce("refresh boom"); + + const data = createTrayRepoData(); + await data.startBackgroundRefresh(); + + expect(invoke).toHaveBeenCalledWith("refresh_all_git_state"); + expect(data.error).toBe("refresh boom"); + }); + + it("setListError sets error without invoke", () => { + const data = createTrayRepoData(); + data.setListError("custom error"); + expect(data.error).toBe("custom error"); + }); +}); diff --git a/src/lib/tray/trayRepoData.svelte.ts b/src/lib/tray/trayRepoData.svelte.ts new file mode 100644 index 0000000..2675fee --- /dev/null +++ b/src/lib/tray/trayRepoData.svelte.ts @@ -0,0 +1,80 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { RepoDto } from "$lib/types"; +import { trayTrace } from "./trayTrace"; + +export interface TrayRepoDataOptions { + onAfterRefresh?: (repos: RepoDto[]) => void; +} + +export function createTrayRepoData(options: TrayRepoDataOptions = {}) { + let repos = $state([]); + let allTags = $state([]); + let error = $state(null); + + function setError(e: unknown) { + error = String(e); + } + + function setListError(message: string | null) { + error = message; + } + + async function loadRepos(clearError = true): Promise { + trayTrace("invoke list_repos"); + try { + repos = await invoke("list_repos"); + trayTrace("list_repos ok", { count: repos.length }); + if (clearError) { + error = null; + } + } catch (e) { + trayTrace("list_repos failed", e); + setError(e); + } + } + + async function loadAllTags(): Promise { + try { + allTags = await invoke("list_all_tags"); + } catch (e) { + console.warn("list_all_tags failed", e); + allTags = []; + } + } + + async function refresh(clearError = true): Promise { + await loadRepos(clearError); + await loadAllTags(); + options.onAfterRefresh?.(repos); + } + + async function startBackgroundRefresh(): Promise { + trayTrace("invoke refresh_all_git_state"); + try { + await invoke("refresh_all_git_state"); + } catch (e) { + trayTrace("refresh_all_git_state failed", e); + setError(e); + } + } + + return { + get repos() { + return repos; + }, + get allTags() { + return allTags; + }, + get error() { + return error; + }, + loadRepos, + loadAllTags, + refresh, + startBackgroundRefresh, + setError, + setListError, + }; +} + +export type TrayRepoData = ReturnType; diff --git a/src/lib/tray/trayTrace.ts b/src/lib/tray/trayTrace.ts new file mode 100644 index 0000000..9401049 --- /dev/null +++ b/src/lib/tray/trayTrace.ts @@ -0,0 +1,10 @@ +/** Dev-only tray diagnostics (visible in the panel webview inspector console). */ +export function trayTrace(message: string, detail?: unknown): void { + if (import.meta.env.DEV) { + if (detail === undefined) { + console.debug(`[workpot-tray] ${message}`); + } else { + console.debug(`[workpot-tray] ${message}`, detail); + } + } +} diff --git a/src/lib/trayKeyboard.test.ts b/src/lib/trayKeyboard.test.ts new file mode 100644 index 0000000..5d9a646 --- /dev/null +++ b/src/lib/trayKeyboard.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, it, vi } from "vitest"; +import { applyTrayNavigationKey } from "./trayKeyboard"; +import type { RepoDto } from "./types"; + +function repo(name: string): RepoDto { + return { + name, + alias: null, + path: `/tmp/${name}`, + branch: null, + is_dirty: null, + parent_dir: "", + last_opened_at: null, + git_state_error: null, + pinned: false, + pin_order: null, + notes: null, + tags: [], + branches: [], + }; +} + +function keyEvent( + key: string, + init: Partial = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { key, bubbles: true, ...init }); +} + +describe("applyTrayNavigationKey", () => { + const selected = repo("alpha"); + + it("triggers refresh on Cmd+R", () => { + const onRefresh = vi.fn(); + const e = keyEvent("r", { metaKey: true }); + const handled = applyTrayNavigationKey( + e, + { detailRepo: null, getSelectedRepo: () => selected }, + { + onRefresh, + onCloseDetail: vi.fn(), + onHidePanel: vi.fn(), + onOpenDetailForSelection: vi.fn(), + onMoveSelection: vi.fn(), + onOpenSelected: vi.fn(), + }, + "panel", + ); + expect(handled).toBe(true); + expect(onRefresh).toHaveBeenCalledOnce(); + }); + + it("closes detail on ArrowLeft when detail is open", () => { + const onCloseDetail = vi.fn(); + const e = keyEvent("ArrowLeft"); + applyTrayNavigationKey( + e, + { detailRepo: selected, getSelectedRepo: () => selected }, + { + onRefresh: vi.fn(), + onCloseDetail, + onHidePanel: vi.fn(), + onOpenDetailForSelection: vi.fn(), + onMoveSelection: vi.fn(), + onOpenSelected: vi.fn(), + }, + "panel", + ); + expect(onCloseDetail).toHaveBeenCalledOnce(); + }); + + it("suppresses ArrowDown in filter mode when detail is open", () => { + const onMoveSelection = vi.fn(); + const e = keyEvent("ArrowDown"); + const handled = applyTrayNavigationKey( + e, + { detailRepo: selected, getSelectedRepo: () => selected }, + { + onRefresh: vi.fn(), + onCloseDetail: vi.fn(), + onHidePanel: vi.fn(), + onOpenDetailForSelection: vi.fn(), + onMoveSelection, + onOpenSelected: vi.fn(), + }, + "filter", + ); + expect(handled).toBe(true); + expect(onMoveSelection).not.toHaveBeenCalled(); + expect(e.defaultPrevented).toBe(false); + }); + + it("moves selection on ArrowDown in panel mode", () => { + const onMoveSelection = vi.fn(); + const e = keyEvent("ArrowDown"); + applyTrayNavigationKey( + e, + { detailRepo: null, getSelectedRepo: () => selected }, + { + onRefresh: vi.fn(), + onCloseDetail: vi.fn(), + onHidePanel: vi.fn(), + onOpenDetailForSelection: vi.fn(), + onMoveSelection, + onOpenSelected: vi.fn(), + }, + "panel", + ); + expect(onMoveSelection).toHaveBeenCalledWith(1); + }); + + it("enter_calls_onOpenSelected_plain_open_without_meta", () => { + const onOpenSelected = vi.fn(); + const onMoveSelection = vi.fn(); + const e = keyEvent("Enter"); + const handled = applyTrayNavigationKey( + e, + { detailRepo: null, getSelectedRepo: () => selected }, + { + onRefresh: vi.fn(), + onCloseDetail: vi.fn(), + onHidePanel: vi.fn(), + onOpenDetailForSelection: vi.fn(), + onMoveSelection, + onOpenSelected, + }, + "panel", + ); + expect(handled).toBe(true); + expect(onOpenSelected).toHaveBeenCalledWith(false); + expect(onMoveSelection).not.toHaveBeenCalled(); + }); + + it("enter_with_meta_calls_onOpenSelected_as_background", () => { + const onOpenSelected = vi.fn(); + const e = keyEvent("Enter", { metaKey: true }); + applyTrayNavigationKey( + e, + { detailRepo: null, getSelectedRepo: () => selected }, + { + onRefresh: vi.fn(), + onCloseDetail: vi.fn(), + onHidePanel: vi.fn(), + onOpenDetailForSelection: vi.fn(), + onMoveSelection: vi.fn(), + onOpenSelected, + }, + "panel", + ); + expect(onOpenSelected).toHaveBeenCalledWith(true); + }); + + it("escape_in_detail_closes_and_hides_panel", () => { + const onCloseDetail = vi.fn(); + const onHidePanel = vi.fn(); + const e = keyEvent("Escape"); + const handled = applyTrayNavigationKey( + e, + { detailRepo: selected, getSelectedRepo: () => selected }, + { + onRefresh: vi.fn(), + onCloseDetail, + onHidePanel, + onOpenDetailForSelection: vi.fn(), + onMoveSelection: vi.fn(), + onOpenSelected: vi.fn(), + }, + "panel", + ); + expect(handled).toBe(true); + expect(onCloseDetail).toHaveBeenCalledOnce(); + expect(onHidePanel).toHaveBeenCalledOnce(); + }); + + it("moves_selection_on_arrow_up_and_tab_in_panel_mode", () => { + const onMoveSelection = vi.fn(); + const up = keyEvent("ArrowUp"); + applyTrayNavigationKey( + up, + { detailRepo: null, getSelectedRepo: () => selected }, + { + onRefresh: vi.fn(), + onCloseDetail: vi.fn(), + onHidePanel: vi.fn(), + onOpenDetailForSelection: vi.fn(), + onMoveSelection, + onOpenSelected: vi.fn(), + }, + "panel", + ); + expect(onMoveSelection).toHaveBeenCalledWith(-1); + + onMoveSelection.mockClear(); + const tab = keyEvent("Tab"); + applyTrayNavigationKey( + tab, + { detailRepo: null, getSelectedRepo: () => selected }, + { + onRefresh: vi.fn(), + onCloseDetail: vi.fn(), + onHidePanel: vi.fn(), + onOpenDetailForSelection: vi.fn(), + onMoveSelection, + onOpenSelected: vi.fn(), + }, + "panel", + ); + expect(onMoveSelection).toHaveBeenCalledWith(1); + }); + + it("escape_in_list_closes_detail_and_hides_panel", () => { + const onCloseDetail = vi.fn(); + const onHidePanel = vi.fn(); + const e = keyEvent("Escape"); + const handled = applyTrayNavigationKey( + e, + { detailRepo: null, getSelectedRepo: () => selected }, + { + onRefresh: vi.fn(), + onCloseDetail, + onHidePanel, + onOpenDetailForSelection: vi.fn(), + onMoveSelection: vi.fn(), + onOpenSelected: vi.fn(), + }, + "panel", + ); + expect(handled).toBe(true); + expect(onCloseDetail).toHaveBeenCalledOnce(); + expect(onHidePanel).toHaveBeenCalledOnce(); + }); + + it("arrow_right_opens_detail_for_selection", () => { + const onOpenDetailForSelection = vi.fn(); + const e = keyEvent("ArrowRight"); + const handled = applyTrayNavigationKey( + e, + { detailRepo: null, getSelectedRepo: () => selected }, + { + onRefresh: vi.fn(), + onCloseDetail: vi.fn(), + onHidePanel: vi.fn(), + onOpenDetailForSelection, + onMoveSelection: vi.fn(), + onOpenSelected: vi.fn(), + }, + "panel", + ); + expect(handled).toBe(true); + expect(onOpenDetailForSelection).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/lib/trayKeyboard.ts b/src/lib/trayKeyboard.ts new file mode 100644 index 0000000..7ff0992 --- /dev/null +++ b/src/lib/trayKeyboard.ts @@ -0,0 +1,120 @@ +import { shouldSuppressTrayListKeyWhenDetailOpen } from "./detailNavigation"; +import type { RepoDto } from "./types"; + +export function isTrayRefreshShortcut(metaKey: boolean, key: string): boolean { + return metaKey && (key === "r" || key === "R"); +} + +export interface TrayNavCtx { + detailRepo: RepoDto | null; + getSelectedRepo: () => RepoDto | undefined; +} + +export interface TrayNavActions { + onRefresh: () => void; + onCloseDetail: () => void; + onHidePanel: () => void; + onOpenDetailForSelection: () => void; + onMoveSelection: (delta: number) => void; + onOpenSelected: (background: boolean) => void; +} + +function handleDetailViewKeys( + e: KeyboardEvent, + actions: TrayNavActions, +): boolean { + if (e.key === "ArrowLeft") { + e.preventDefault(); + actions.onCloseDetail(); + return true; + } + if (e.key === "Escape") { + e.preventDefault(); + actions.onCloseDetail(); + actions.onHidePanel(); + return true; + } + return false; +} + +function handlePanelListKeys( + e: KeyboardEvent, + actions: TrayNavActions, +): boolean { + if (e.key === "ArrowDown") { + e.preventDefault(); + actions.onMoveSelection(1); + return true; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + actions.onMoveSelection(-1); + return true; + } + if (e.key === "Tab" && !e.shiftKey) { + e.preventDefault(); + actions.onMoveSelection(1); + return true; + } + return false; +} + +function handleListGlobalKeys( + e: KeyboardEvent, + actions: TrayNavActions, +): boolean { + if (e.key === "Escape") { + e.preventDefault(); + actions.onCloseDetail(); + actions.onHidePanel(); + return true; + } + if (e.key === "Enter") { + e.preventDefault(); + actions.onOpenSelected(e.metaKey); + return true; + } + return false; +} + +/** + * Shared tray list navigation for filter input and panel window handlers. + * Returns true when the caller should stop processing the event. + */ +export function applyTrayNavigationKey( + e: KeyboardEvent, + ctx: TrayNavCtx, + actions: TrayNavActions, + mode: "filter" | "panel", +): boolean { + if (isTrayRefreshShortcut(e.metaKey, e.key)) { + e.preventDefault(); + actions.onRefresh(); + return true; + } + + if (ctx.detailRepo !== null) { + if (handleDetailViewKeys(e, actions)) { + return true; + } + if (shouldSuppressTrayListKeyWhenDetailOpen(e.key, e.metaKey)) { + return true; + } + } + + if ( + e.key === "ArrowRight" && + ctx.detailRepo === null && + ctx.getSelectedRepo() + ) { + e.preventDefault(); + actions.onOpenDetailForSelection(); + return true; + } + + if (mode === "panel" && handlePanelListKeys(e, actions)) { + return true; + } + + return handleListGlobalKeys(e, actions); +} diff --git a/src/lib/trayList.test.ts b/src/lib/trayList.test.ts index d9d4e1b..e69993e 100644 --- a/src/lib/trayList.test.ts +++ b/src/lib/trayList.test.ts @@ -10,6 +10,7 @@ function repo(partial: Partial & Pick): RepoDto { return { path: partial.path ?? `/tmp/${partial.name}`, name: partial.name, + alias: partial.alias ?? null, branch: partial.branch ?? null, is_dirty: partial.is_dirty ?? null, parent_dir: "", diff --git a/src/lib/types.ts b/src/lib/types.ts index 004b087..5473a57 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -3,6 +3,7 @@ export interface TrayConfigDto { max_recent_days: number; min_recent_count: number; max_pinned: number; + stale_dirty_days: number; } export interface GitRefreshSummary { @@ -11,9 +12,23 @@ export interface GitRefreshSummary { any_dirty: boolean; } +export type BranchPresence = + | "checkout" + | "local_only" + | "remote_only" + | "local_remote"; + +export interface BranchListItemDto { + name: string; + presence: BranchPresence; + ahead: number | null; + behind: number | null; +} + export interface RepoDto { path: string; name: string; + alias: string | null; branch: string | null; is_dirty: boolean | null; parent_dir: string; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index a2b2be8..58b9da7 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,606 +1,5 @@ - - -
- {#if launchError} - - {/if} -
-
- - - {#if refreshing} - - {/if} -
-
- -
- {#if detailRepo} - { - focusTagOnDetailOpen = false; - }} - onClose={() => { - detailRepo = null; - }} - onMutated={() => refreshReposAndDetail()} - /> - {:else if listView.kind === "error"} -

{listView.message}

- {:else if listView.kind === "empty-index"} -

No repos indexed yet.

- {:else if listView.kind === "no-match"} -

No repos match

- {:else} -
    - {#each SECTION_META as { key, label, draggable } (key)} - {#if sectionedRepos[key].length > 0} -
  • - -
  • - {#each sectionedRepos[key] as repo, i (repo.path)} - {@const idx = rowIndex(repo)} -
  • - -
  • - {/each} - {/if} - {/each} -
- {/if} -
-
+ diff --git a/vite.config.ts b/vite.config.ts index d821fb3..ed1e7aa 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,12 +1,17 @@ +/// import { sveltekit } from "@sveltejs/kit/vite"; import tailwindcss from "@tailwindcss/vite"; +import { svelteTesting } from "@testing-library/svelte/vite"; import { defineConfig } from "vite"; +import { nonTestableCoverageGlobs } from "./scripts/coverage-exclusions.mjs"; const host = process.env.TAURI_DEV_HOST; const isCi = process.env.CI === "true" || process.env.CI === "1"; +const isVitest = Boolean(process.env.VITEST); export default defineConfig({ - plugins: [tailwindcss(), sveltekit()], + plugins: [tailwindcss(), sveltekit(), ...(isVitest ? [svelteTesting()] : [])], + resolve: isVitest ? { conditions: ["browser"] } : undefined, clearScreen: false, logLevel: isCi ? "warn" : "info", server: { @@ -25,12 +30,17 @@ export default defineConfig({ }, }, test: { + environment: "jsdom", coverage: { provider: "v8", reporter: ["lcov"], reportsDirectory: "coverage", include: ["src/**/*.{ts,svelte}"], - exclude: ["**/*.test.ts", "**/+layout.ts"], + exclude: [ + "**/*.test.ts", + "**/*.svelte.test.ts", + ...nonTestableCoverageGlobs, + ], }, }, });