diff --git a/.gitignore b/.gitignore index bf9d83a..ffb02a1 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,13 @@ release/ # Claude Code's per-repo scratch area. It holds nested git worktrees, so a single # `git add -A` would otherwise commit entire checkouts into this repo. .claude/ + +# A literal "~" directory, created by an unquoted ~ in a shell command and +# then swept in by `git add -A`. It held npm logs, not source. +~/ + +# A 584KB minified main-process bundle that `npm run package` drops beside +# package.json. The real output is dist/main/main.js (which package.json's +# "main" points at, and which dist/ already ignores); this copy is a build +# side-effect and `git add -A` swept it in. +apps/desktop/main.js diff --git a/apps/desktop/harness/.gitignore b/apps/desktop/harness/.gitignore new file mode 100644 index 0000000..c1de45e --- /dev/null +++ b/apps/desktop/harness/.gitignore @@ -0,0 +1,2 @@ +page/ +out/ diff --git a/apps/desktop/harness/README.md b/apps/desktop/harness/README.md new file mode 100644 index 0000000..494fedc --- /dev/null +++ b/apps/desktop/harness/README.md @@ -0,0 +1,37 @@ +# Headless render harness + +Runs the WHOLE desktop renderer in plain headless Chrome against realistic +fixtures — no Electron, no GitHub, no repo. This is how UI work on the desktop +app gets **seen** before it ships: every view, both themes, list and detail +states, driven by scripted scenes. + +```sh +cd apps/desktop +node esbuild.js # build the renderer bundles +harness/gen.sh # assemble harness/page from dist + shim.js +harness/shot.sh issues out/issues.png # screenshot a scene +harness/shot.sh 'issues~open31' out/detail.png # …with driver steps +harness/shot.sh 'prs~open106~click:.gh-subtab%5Bdata-sub%3Dfiles%5D' out/files.png +harness/shot.sh issues out/light.png light # light theme +``` + +## Functional checks + +Screenshots prove a surface renders. They do not prove the count badge tracks +the filter, that a disabled button is disabled, that a menu is dismissed on +navigation, or that two columns share an x — those are assertions. + +```sh +node harness/check.mjs # every case +node harness/check.mjs count palette # only cases matching these substrings +``` + +Each case names a scene and an assertion from `checks.js`, which runs INSIDE +the page after the scene driver finishes and returns a list of failures. The +runner exits non-zero if anything fails, so it can gate a commit. Add a case by +writing the assertion in `checks.js` and listing `[id, scene]` in `check.mjs`. + +`shim.js` fakes the preload's `window.gitstudio` bridge (see `shared/ipc.ts`) +with fixtures for the GitStudio repo itself. Unstubbed channels log +`[shim missing] ` to the console and resolve safely — add a fixture +when a view needs one. `harness/page/` and `harness/out/` are generated. diff --git a/apps/desktop/harness/check.mjs b/apps/desktop/harness/check.mjs new file mode 100755 index 0000000..c2cb014 --- /dev/null +++ b/apps/desktop/harness/check.mjs @@ -0,0 +1,548 @@ +#!/usr/bin/env node +// The functional half of the harness. +// +// Screenshots prove a surface renders; they cannot prove the count badge +// tracks the filter, that a disabled button is disabled, that a menu is +// dismissed on navigation, or that two columns share an x. This drives each +// scene in headless Chrome, runs the matching assertion from harness/checks.js +// INSIDE the page, and reports pass/fail. +// +// node harness/check.mjs # everything +// node harness/check.mjs count palette # only cases matching these substrings +// +// Exit code is non-zero if any case fails, so it can gate a commit. + +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const PAGE = process.env.GS_HARNESS_PAGE + ? resolve(process.env.GS_HARNESS_PAGE, "harness.html") + : resolve(HERE, "page/harness.html"); +const CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; + +/** id → the scene that sets up the state the assertion needs. */ +const CASES = [ + ["count-badge-filtered", "issues~text:Author~text:@mira-holt"], + ["count-badge-unfiltered", "issues"], + ["menu-dismissed-on-route", "notifications~text:Type~text:Releases"], + ["menu-closes-siblings", "issues~text:Author~text:Label"], + ["palette-selects-first", "code~palette~type:gitstudio"], + ["palette-selection-visible", "code~palette~type:gitstudio"], + ["palette-min-chars", "code~palette~type:gi"], + ["facet-labels-humanized", "notifications~text:Reason"], + ["facet-labels-aligned", "notifications~text:Reason"], + ["issues-closed-facet-hidden-on-open", "issues"], + ["issues-closed-facet-shown-on-closed", "issues~text:Closed"], + ["commit-disabled-when-empty", "changes"], + ["commit-enabled-after-typing", "changes~click:.dc-message~type:fix%3A%20a%20thing"], + ["changes-rows-share-left-edge", "changes"], + ["changes-toolbar-stable", "changes"], + ["whitespace-toggle-agrees-across-diff-views", "changes~text:spacing.ts", { extra: "ws=1" }], + ["ignoring-whitespace-keeps-real-changes", "changes~text:prs.ts", { extra: "ws=1" }], + ["ignoring-whitespace-stops-at-the-ends-of-a-line", "changes~text:spacing-inner.ts", { extra: "ws=1" }], + ["compare-no-self-compare", "compare"], + ["changes-status-column", "changes"], + ["log-no-blank-endgroup-rows", "actions~open9100~click:.gh-job-log"], + ["log-pane-has-its-own-ground", "actions~open9100~click:.gh-job-log"], + ["log-follow-survives-expand", "actions~open9100~click:.gh-job-log"], + ["highlighted-code-copies-as-real-spaces", "issues~open27"], + ["move-to-is-a-label-not-a-command", "projects"], + ["graph-search-count-is-not-a-fake-position", "graph"], + ["the-connect-gate-holds-every-control", "assistant~click:.topbar-assistant"], + ["send-needs-something-to-send", "assistant~click:.topbar-assistant", { extra: "ai=1" }], + ["a-quick-action-keeps-your-draft", "assistant~click:.topbar-assistant", { extra: "ai=1" }], + ["a-streaming-reply-never-moves-the-reader", "assistant~click:.topbar-assistant", { extra: "ai=1" }], + ["a-queued-job-that-starts-stops-saying-it-has-not", "actions~open9100~click:.gh-job-log"], + ["an-expired-artifact-cannot-be-downloaded", "actions~open9097"], + ["no-surface-scrolls-the-app-sideways", "prs~open106", { width: 880 }], + ["no-surface-scrolls-the-app-sideways", "branches", { width: 880 }], + ["no-surface-scrolls-the-app-sideways", "changes", { width: 880 }], + ["no-surface-scrolls-the-app-sideways", "actions~open9100", { width: 880 }], + ["a-tick-is-named-for-its-file", "changes", { extra: "staging=checkboxes" }], + ["create-pull-request-comes-back", "compare"], + ["a-plan-that-keeps-nothing-cannot-be-started", "rebase"], + ["a-dragged-commit-lands-where-the-line-says", "rebase"], + ["a-background-refresh-does-not-kill-forward", "changes"], + ["a-kept-view-comes-back-where-you-left-it", "issues", { extra: "many=1" }], + ["a-refresh-keeps-you-on-the-job-you-were-reading", "actions~open9100~click:.gh-job-log"], + ["line-controls-need-a-line-editor", "changes"], + ["the-dock-hands-the-keyboard-back", "changes"], + ["closing-the-dock-from-inside-it-still-lands-somewhere", "changes"], + ["an-open-dock-does-not-bury-a-footer", "rebase"], + ["a-conflict-with-no-text-is-not-offered-a-text-merge", "changes"], + ["the-output-filter-owns-its-consequences", "changes"], + ["a-dead-shell-says-it-is-dead", "changes"], + ["a-dead-shell-says-it-is-dead", "changes", { theme: "light" }], + ["the-agents-own-commit-does-not-erase-the-chat", "assistant~click:.topbar-assistant", { extra: "ai=1" }], + ["a-failed-turn-stops-looking-like-it-is-typing", "assistant~click:.topbar-assistant", { extra: "ai=1" }], + ["a-refresh-keeps-the-file-you-were-reading", "prs~open106~text:Commits~text:issues%3A%20full-page%20detail"], + ["one-notch-up-stops-the-tail", "actions~open9100~click:.gh-job-log"], + ["every-ansi-block-can-be-read", "actions~open9100~click:.gh-job-log"], + ["every-ansi-block-can-be-read", "actions~open9100~click:.gh-job-log", { theme: "light" }], + ["switch-account-starts-a-sign-in", "settings"], + ["a-half-filled-dialog-survives-a-file-save", "changes~click:.topbar-switch~text:Clone"], + ["a-nested-control-keeps-its-own-enter", "projects"], + ["the-gate-closes-as-well-as-it-opens", "assistant~click:.topbar-assistant", { extra: "ai=1" }], + ["stopping-a-run-closes-what-it-was-asking", "assistant~click:.topbar-assistant", { extra: "ai=1" }], + ["folding-a-log-group-keeps-the-keyboard", "actions~open9100~click:.gh-job-log"], + ["a-sparkle-action-does-not-destroy-a-running-turn", "assistant~click:.topbar-assistant", { extra: "ai=1" }], + ["quick-actions-close-while-the-agent-works", "assistant~click:.topbar-assistant", { extra: "ai=1" }], + ["a-declined-action-is-not-an-error", "assistant~click:.topbar-assistant", { extra: "ai=1" }], + ["two-fast-sends-start-one-turn", "assistant~click:.topbar-assistant", { extra: "ai=1" }], + ["every-undrawable-diff-says-which-nothing-it-is", "changes"], + ["run-detail-one-identity", "actions~open9100"], + ["run-detail-hides-dead-actions", "actions~open9100"], + ["run-detail-steps-visible", "actions~open9100"], + ["row-meta-columns-align", "explore~type:git~key:Enter"], + ["the-dock-reserve-tracks-the-dock", "changes~click:.dock-chevron"], + ["changing-the-theme-keeps-what-you-typed", "settings"], + ["a-compare-diff-that-fails-says-so", "compare~text:Changed%20files"], + ["placeholders-fit-their-field", "orgs"], + ["placeholders-fit-their-field", "branches"], + ["placeholders-fit-their-field", "issues"], + // The three shapes: file rows, branch rows, and repo rows in an org. + ["row-actions-name-their-object", "changes"], + ["row-actions-name-their-object", "branches"], + ["row-actions-name-their-object", "orgs~text:Repositories"], + ["row-actions-name-their-object", "notifications"], + ["coming-back-to-a-search-costs-no-requests", "explore~type:git~key:Enter"], + // Both shapes: a dialog, and a peek (whose card is focused with tabindex=-1). + ["a-modal-surface-holds-the-page-behind-it", "branches~text:New%20branch"], + // A branch row opens its PAGE now, not a peek — so the second Branches + // scene points at a surface that is still modal. + ["a-modal-surface-holds-the-page-behind-it", "branches~click:.gh-seg-btn:nth-child(3)~text:New%20tag"], + ["a-toast-is-reachable-over-a-dialog", "branches~text:Push~palette"], + [ + "escape-still-works-after-coming-back", + "issues~open31~click:%5Bdata-view%3D%22prs%22%5D~click:%5Bdata-view%3D%22issues%22%5D", + ], + ["code-hits-show-what-matched", "explore~click:.explore-tab:nth-child(4)~type:git~key:Enter"], + [ + "collapsing-one-job-leaves-the-others-alone", + "actions~open9100~click:.gh-job-head~click:.det-back~open9100", + ], + ["run-detail-no-duplicate-status", "actions~open9100"], + ["run-detail-failed-shows-rerun", "actions~open9097"], + ["step-bars-share-one-scale", "actions~open9097"], + ["workflow-rows-carry-state", "actions~text:Workflows"], + ["inbox-search-filters", "notifications~click:.gh-search-input~type:xterm"], + ["inbox-rows-share-left-edge", "notifications"], + ["inbox-state-segment", "notifications"], + ["row-meta-columns-align", "issues"], + ["row-meta-columns-align", "prs"], + ["row-meta-columns-align", "gists"], + ["row-meta-columns-align", "releases"], + ["row-meta-columns-align", "notifications"], + ["row-meta-columns-align", "actions"], + ["prs-state-segment", "prs"], + ["prs-author-avatar-labelled", "prs"], + ["explore-search-results", "explore~type:git~key:Enter"], + ["explore-numbers-formatted", "explore~type:git~key:Enter"], + ["explore-repo-page", "explore~type:git~key:Enter~text:GitStudioHQ/gitstudio"], + ["orgs-cards-not-clipped", "orgs"], + ["orgs-header-order", "orgs"], + ["actions-segment-does-not-slide", "actions"], + ["facets-do-not-shunt-their-neighbours", "issues"], + ["log-toolbar-toggles-are-labelled", "actions~open9100~click:.gh-job-log"], + ["toolbar-no-overflow", "actions", { width: 1150 }], + ["branch-divergence-paired", "branches"], + ["the-ref-manager-shows-one-kind-at-a-time", "branches", { arg: "local" }], + ["the-ref-manager-shows-one-kind-at-a-time", "branches", { arg: "remote" }], + ["the-ref-manager-shows-one-kind-at-a-time", "branches", { arg: "tags" }], + ["the-ref-manager-shows-one-kind-at-a-time", "branches", { arg: "stashes" }], + ["the-remote-list-has-no-phantom-origin-row", "branches"], + ["a-ref-opens-its-own-page", "branches"], + ["finished-branches-can-be-swept", "branches"], + ["the-ref-list-can-be-narrowed", "branches"], + ["branches-can-be-cut-by-how-recently-they-moved", "branches"], + ["worktrees-are-reachable", "branches"], + ["the-ref-list-answers-the-keyboard", "branches"], + ["branch-pull-is-an-action", "branches"], + ["org-members-are-people", "orgs~text:Members"], + ["org-people-are-chips", "orgs~text:Members"], + ["org-cards-fill-their-row", "orgs~text:Teams"], + ["dock-tabs-share-a-content-origin", "code~click:.term-tab.is-output"], + ["dock-empty-log-offers-nothing-inert", "code~click:.term-tab.is-output"], + ["rail-icons-are-distinguishable", "code"], + ["clone-form-one-field-shape", "code~palette~type:clone~text:Clone%20repository%E2%80%A6"], + ["search-empty-sits-with-the-search", "explore~type:zzzznotathing~key:Enter"], + ["menu-focus-ring-is-not-clipped", "code~click:.topbar-branch"], + ["rebase-rows-share-their-columns", "rebase"], + ["rebase-legend-does-not-wrap", "rebase"], + ["rebase-names-its-action-once", "rebase"], + ["compare-file-rows-name-first", "compare"], + ["compare-counts-are-filled", "compare"], + ["board-empty-column-yields-its-width", "projects"], + ["explore-people-are-a-directory", "explore~type:git~key:Enter~text:People"], + ["explore-code-hit-is-one-block", "explore~type:git~key:Enter~click:.explore-tab%3Anth-of-type(4)"], + ["graph-change-bars-share-a-left-edge", "graph"], + ["row-meta-columns-align", "mywork"], + ["drawer-holds-the-board-behind-it", "projects"], + ["rebase-actions-do-not-move-the-list", "rebase"], + ["file-rows-show-the-whole-name", "changes"], + ["file-rows-show-the-whole-name", "changes", { extra: "staging=checkboxes" }], + ["one-row-per-file-in-checkbox-mode", "changes", { extra: "staging=checkboxes" }], + ["segment-flip-keeps-the-keyboard", "releases"], + ["reviewers-rail-says-who-answered", "prs~open106"], + ["approve-opens-the-composer", "prs~open106"], + ["label-picker-stays-open", "issues~open31"], + ["mark-read-keeps-its-slot", "inbox"], + ["danger-dialogs-start-on-cancel", "branches"], + ["go-to-file-has-a-cursor", "explore~type:git~key:Enter~text:GitStudioHQ/gitstudio"], + ["detail-subtabs-are-a-tablist", "prs~open106"], + ["detail-subtabs-are-a-tablist", "gists~click:.sec-row:nth-of-type(2)"], + ["inbox-rows-are-controls", "inbox"], + ["the-focus-ring-can-be-seen", "issues~click:.gh-facet-btn"], + ["the-focus-ring-can-be-seen", "issues~click:.gh-facet-btn", { theme: "light" }], + ["a-dead-comparison-shows-nothing-not-the-last-one", "compare"], + ["latest-is-the-shipping-build-not-the-rc", "releases"], + ["squash-is-refused-only-where-git-would-refuse-it", "rebase"], + ["a-failed-submit-gives-the-form-back", "issues"], + ["amend-withdraws-its-prefill-after-a-repaint", "changes"], + ["a-failed-git-read-is-not-an-empty-repo", "changes"], + ["the-rail-always-has-a-tab-stop", "assistant"], + ["the-rail-always-has-a-tab-stop", "prs~open106"], + ["detail-pages-answer-back-keys", "prs~open106"], + ["an-abandoned-load-is-not-cached", "changes"], + ["a-code-hit-opens-its-file", "explore~type:git~key:Enter"], + ["clicking-a-repo-browses-it", "orgs"], + ["alt-tab-does-not-rebuild-the-app", "prs~open106"], + ["revisiting-a-view-costs-nothing", "changes"], + ["graph-ref-column-shows-a-name", "graph", { width: 1280 }], + ["graph-ref-column-shows-a-name", "graph", { width: 1300 }], + ["graph-ref-column-shows-a-name", "graph", { width: 1440 }], + ["graph-ref-column-shows-a-name", "graph", { width: 1512 }], + ["graph-ref-column-shows-a-name", "graph", { width: 1600 }], + ["graph-ref-column-shows-a-name", "graph", { width: 1920 }], + ["graph-ref-chips-are-painted", "graph"], + ["graph-ref-chips-are-painted", "graph", { theme: "light" }], + ["settings-controls-fit-their-content", "code~text:Settings"], + ["settings-has-a-rhythm", "code~text:Settings"], + ["rail-groups-survive-collapse", "code~click:.topbar-sidebar"], + ["status-facet-shows-its-states", "actions~text:Status"], + ["palette-hints-are-not-echoes", "code~palette~type:br"], + ["peek-identity-gets-room", "orgs~text:Members~click:.gh-org-member"], + ["amend-prefill-enables-committing", "changes~text:Amend%20last%20commit"], + ["amend-off-restores-the-composer", "changes"], + ["amend-survives-a-repaint", "changes~text:Amend%20last%20commit~text:Stage%20all"], + ["no-inline-event-handlers-anywhere", "issues~open31"], + ["route-churn-leaks-nothing", "code"], + ["nothing-runs-off-the-window", "changes", { width: 1000 }], + ["nothing-runs-off-the-window", "notifications", { width: 1000 }], + ["nothing-runs-off-the-window", "issues", { width: 1000 }], + ["nothing-runs-off-the-window", "actions", { width: 1000 }], + ["segmented-controls-never-clip", "notifications", { width: 1000 }], + ["segmented-controls-never-clip", "compare", { width: 1000 }], + ["segmented-controls-never-clip", "prs", { width: 1280 }], + ["a-row-keeps-its-name-before-its-badges", "releases", { width: 1000 }], + ["status-pills-never-wrap", "actions", { width: 1000 }], + ["status-pills-never-wrap", "releases", { width: 1000 }], + ["graph-details-opens-at-its-intended-width", "graph"], + ["focus-follows-you-into-a-detail-and-back", "issues"], + ["focus-follows-you-into-a-detail-and-back", "prs"], + ["no-nested-interactive-elements", "releases~open50"], + ["no-nested-interactive-elements", "issues"], + ["no-nested-interactive-elements", "code"], + ["pr-files-fits-the-window", "prs~open106~click:.gh-subtab%3Anth-of-type(4)"], + ["native-controls-follow-the-theme", "releases~text:New release"], + ["hover-actions-are-reachable", "explore~type:git~key:Enter~text:People~click:.explore-person-row"], + ["count-badge-tracks-the-filter", "actions~click:.gh-search-input~type:zzzz"], + ["count-badge-tracks-the-filter", "releases~click:.gh-search-input~type:zzzz"], + ["count-badge-tracks-the-filter", "issues"], + ["count-badge-tracks-the-filter", "prs"], + ["identity-chips-are-not-dead", "explore~type:git~key:Enter~text:GitStudioHQ/gitstudio"], + ["branch-switcher-checks-out", "code~click:.topbar-branch"], + ["staging-keeps-the-open-file", "changes~text:app.css"], + [ + "checkbox-tick-keeps-the-open-file", + "changes~text:app.css", + { extra: "staging=checkboxes" }, + ], + ["staging-does-not-blank-the-list", "changes~text:app.css"], + ["repo-manager-opens-from-the-repo-chip", "code~click:.topbar-switch~text:Manage%20repositories"], + ["settings-holds-preferences-not-repositories", "code~text:Settings"], + ["landing-is-the-working-tree", "changes"], + ["assistant-has-no-phantom-skeleton", "code~click:.topbar-assistant"], + ["menu-toggles-on-its-own-trigger", "orgs"], + ["pr-commit-rows-are-real-controls", "prs~open106~click:.gh-subtab%5Bdata-sub%3Dcommits%5D"], + ["palette-selection-reaches-the-a11y-tree", "code~palette"], + ["graph-selection-reaches-the-a11y-tree", "graph"], + ["graph-columns-keep-their-tracks", "graph"], + ["back-returns-to-the-list-you-opened-from", "mywork~open104"], + ["focus-survives-a-rebuild", "issues", { arg: ".gh-refresh" }], + ["focus-survives-a-rebuild", "releases", { arg: ".gh-seg-btn:not(.active)" }], + ["settings-checkbox-styled", "code~text:Settings"], + // These two moved with the list they assert about: the clone manager is its + // own surface now, not a card in Settings. + ["settings-local-copies", "code~click:.topbar-switch~text:Manage%20repositories"], + ["settings-copy-actions-one-shape", "code~click:.topbar-switch~text:Manage%20repositories"], + ["settings-icon-preview-is-not-a-control", "code~text:Settings"], + ["an-operation-is-ended-by-its-own-command", "changes", { extra: "op=merge" }], + ["an-operation-is-ended-by-its-own-command", "changes", { extra: "op=rebase" }], + ["an-operation-is-ended-by-its-own-command", "changes", { extra: "op=cherry-pick" }], + ["an-operation-is-ended-by-its-own-command", "changes", { extra: "op=revert" }], + ["the-banner-offers-only-what-git-would-accept", "changes", { extra: "op=cherry-pick", arg: "continue" }], + ["the-banner-offers-only-what-git-would-accept", "changes", { extra: "op=cherry-pick&skip=1", arg: "skip" }], + ["the-banner-offers-only-what-git-would-accept", "changes", { extra: "op=am&skip=1", arg: "skip" }], + ["a-banner-button-cannot-be-fired-twice", "changes", { extra: "op=merge" }], + ["the-composer-keeps-your-place-through-a-repaint", "changes"], + ["escape-closes-one-layer-at-a-time", "branches"], + ["arrow-left-does-not-navigate-out-from-under-a-peek", "prs~open106"], + ["a-detail-page-back-pops-the-history", "prs~open106"], + ["leaving-a-pr-for-its-pipeline-comes-back-to-the-pr", "prs~open106~text:Checks"], + ["a-detail-page-back-pops-the-history", "issues~open31"], + ["a-deleted-file-does-not-look-like-a-renamed-one", "prs~open106~text:Files"], + ["a-locked-token-still-reads-as-signed-in", "changes", { extra: "unlocked=0" }], + ["a-locked-token-still-reads-as-signed-in", "changes"], + ["no-view-hides-its-own-content-or-locks-out-the-keyboard", "changes"], + ["no-view-hides-its-own-content-or-locks-out-the-keyboard", "issues"], + ["no-view-hides-its-own-content-or-locks-out-the-keyboard", "prs"], + ["no-view-hides-its-own-content-or-locks-out-the-keyboard", "actions"], + ["no-view-hides-its-own-content-or-locks-out-the-keyboard", "releases"], + ["no-view-hides-its-own-content-or-locks-out-the-keyboard", "orgs"], + ["no-view-hides-its-own-content-or-locks-out-the-keyboard", "projects"], + ["no-view-hides-its-own-content-or-locks-out-the-keyboard", "gists"], + ["no-view-hides-its-own-content-or-locks-out-the-keyboard", "notifications"], + ["no-view-hides-its-own-content-or-locks-out-the-keyboard", "mywork"], + ["no-view-hides-its-own-content-or-locks-out-the-keyboard", "explore"], + ["no-view-hides-its-own-content-or-locks-out-the-keyboard", "branches"], + ["no-view-hides-its-own-content-or-locks-out-the-keyboard", "compare"], + ["no-view-hides-its-own-content-or-locks-out-the-keyboard", "code"], + ["no-view-hides-its-own-content-or-locks-out-the-keyboard", "graph"], + ["a-truncated-path-is-still-recoverable", "prs~open106~text:Files"], + ["a-large-commit-can-be-navigated", "prs~open106~text:Commits"], + ["creating-a-release-names-what-the-button-will-do", "releases"], + ["a-composer-does-not-lose-what-you-typed", "issues", { arg: "new issue" }], + ["a-composer-does-not-lose-what-you-typed", "releases", { arg: "new release" }], + ["the-editor-previews-with-the-real-renderer", "issues"], + ["hovering-a-repo-row-does-not-cover-its-description", "orgs"], + ["the-log-is-navigable-without-a-trackpad", "actions~open9100~click:.gh-job-log"], + ["the-log-can-jump-between-failures", "actions~open9097"], + ["a-failed-read-says-so-instead-of-showing-nothing", "prs~open106~text:Commits", { extra: "fail=pr:commits" }], + ["a-failed-read-says-so-instead-of-showing-nothing", "prs~open106~text:Checks", { extra: "fail=pr:checks" }], + ["a-failed-read-says-so-instead-of-showing-nothing", "prs~open106", { extra: "fail=pr:conversation" }], + ["the-commit-page-shows-what-changed", "prs~open106~text:Commits~text:issues%3A%20full-page%20detail"], + ["a-commit-opens-the-commit-not-the-graph", "prs~open106~text:Commits"], + ["a-commit-opens-the-commit-not-the-graph", "compare~text:Commits"], + ["a-surface-under-a-dialog-keeps-its-escape", "projects~click:.gh-card.clickable", { arg: "drawer" }], + ["a-surface-under-a-dialog-keeps-its-escape", "changes~bell", { arg: "popover" }], + ["the-code-viewer-back-is-a-navigation", "code"], + ["a-resizer-moves-the-way-you-press-it", "changes~bell"], + ["a-resizer-moves-the-way-you-press-it", "compare"], + ["a-resizer-moves-the-way-you-press-it", "graph"], + ["a-resizer-moves-the-way-you-press-it", "branches"], + ["a-resizer-moves-the-way-you-press-it", "changes~click:.dock-chevron"], + ["signing-out-does-not-leave-the-old-account-on-screen", "code~text:Settings", { arg: "Sign out" }], + ["signing-out-does-not-leave-the-old-account-on-screen", "code~text:Settings", { arg: "Switch account" }], + + // The log, the release and the issue — each of the three composers/readers + // the owner called out, now a routed page rather than a box inside one. + ["the-log-gets-the-window", "actions~open9100~click:.gh-job-log"], + ["the-log-page-names-the-job", "actions~open9100~click:.gh-job-log"], + ["picking-another-job-swaps-the-log", "actions~open9100~click:.gh-job-log"], + ["the-run-page-sends-logs-to-their-page", "actions~open9100"], + ["a-long-log-can-be-navigated-by-eye", "actions~open9097~click:.gh-job-log"], + ["the-log-never-scrolls-instead-of-you", "actions~open9100~click:.gh-job-log", { arg: "finished" }], + ["the-log-never-scrolls-instead-of-you", "actions~open9100~click:.gh-job-log", { arg: "live" }], + ["the-log-damps-the-wheel", "actions~open9100~click:.gh-job-log"], + ["searching-a-log-highlights-before-it-travels", "actions~open9100~click:.gh-job-log"], + ["a-diff-that-cannot-be-shown-says-why", "prs~open106~text:Commits~text:issues%3A%20full-page%20detail"], + ["a-diff-never-renders-as-an-unmarked-file", "prs~open106~text:Commits~text:issues%3A%20full-page%20detail"], + ["a-diff-never-renders-as-an-unmarked-file", "compare~text:Changed%20files"], + ["a-diff-that-cannot-be-shown-says-why", "prs~open106~text:Files", { arg: "prfiles" }], + ["a-diff-never-renders-as-an-unmarked-file", "changes~click:.dc-file"], + ["compare-has-the-same-diff-switch-as-everywhere-else", "compare~text:Changed%20files"], + ["a-commit-list-reads-like-a-list-of-commits", "prs~open106~text:Commits", { arg: "pr" }], + ["a-commit-list-reads-like-a-list-of-commits", "compare~click:.cmp-seg-btn", { arg: "compare" }], + ["a-commit-list-can-be-scrolled", "prs~open106~text:Commits"], + ["a-commit-list-can-be-scrolled", "compare~click:.cmp-seg-btn"], + ["a-draft-release-leads-with-publishing-it", "releases~open49", { arg: "draft" }], + ["a-draft-release-leads-with-publishing-it", "releases~open51", { arg: "published" }], + ["the-release-notes-get-the-window", "releases~text:New%20release"], + ["the-composer-says-when-it-will-create-a-tag", "releases~text:New%20release"], + ["generating-notes-keeps-what-you-wrote", "releases~text:New%20release"], + ["the-issue-body-gets-the-window", "issues~text:New%20issue"], + ["composing-an-issue-can-decide-who-it-is-for", "issues~text:New%20issue"], + ["the-commit-page-says-who-when-and-where", "prs~open106~text:Commits~text:issues%3A%20full-page%20detail"], + ["a-detail-page-clears-the-dock", "prs~open106"], + ["a-detail-page-clears-the-dock", "issues~open31"], + ["a-running-clone-can-always-be-left", "changes", { extra: "norepo=1" }], + ["a-graph-selection-never-outlives-its-rows", "graph"], + ["code-refresh-rereads-the-listing", "code"], + ["a-person-peeks-primary-action-is-not-dead", "prs~open106"], + ["a-recent-repository-can-be-forgotten", "changes", { extra: "norepo=1" }], + ["switch-account-starts-the-new-sign-in", "settings"], + ["the-graph-search-paints-before-it-travels", "graph"], + ["clearing-a-search-clears-the-results", "explore~type:git"], + ["a-branch-deep-link-shows-the-branch", "actions~open9094~click:.gh-branch-chip"], + ["the-logs-states-each-say-the-right-thing", "actions~open9097~click:.gh-job-log"], + ["the-logs-live-states-each-say-the-right-thing", "actions~open9101~click:.gh-job-log"], + ["a-stash-page-holds-one-commit", "branches~click:.gh-seg-btn:nth-child(4)~click:.sec-row"], + ["the-palette-keeps-your-place-when-results-arrive", "branches~palette"], + ["one-key-press-closes-one-layer", "branches"], + ["one-key-press-closes-one-layer", "issues"], + ["a-label-picker-batches-and-escape-discards", "issues~open31"], + ["a-label-picker-batches-and-escape-discards", "prs~open106"], + ["log-colours-survive-both-themes", "actions~open9097~click:.gh-job-log"], + ["log-colours-survive-both-themes", "actions~open9097~click:.gh-job-log", { theme: "light" }], + ["an-emptied-branch-list-blames-the-right-thing", "branches"], + ["the-branch-control-bar-stays-on-screen", "branches", { width: 820 }], + ["the-branch-control-bar-stays-on-screen", "branches", { width: 1000 }], + ["a-cancelled-run-is-not-drawn-as-a-failure", "actions"], + ["commit-is-dead-on-a-clean-tree", "changes", { extra: "clean=1" }], + ["a-segment-is-not-a-filter", "prs"], + ["clear-clears-the-filters-it-cannot-see", "issues"], + ["picking-a-filter-keeps-the-keyboard-where-it-was", "issues"], + ["picking-a-filter-keeps-the-keyboard-where-it-was", "prs"], + ["picking-a-filter-keeps-the-keyboard-where-it-was", "notifications"], + ["editing-a-release-leaves-the-latest-badge-alone", "releases~open50~text:Edit"], + ["a-diff-path-reads-forwards-and-cuts-from-the-left", "changes~click:.dc-file"], + ["growing-the-log-pane-fills-it", "actions~open9097~click:.gh-job-log"], + ["the-sort-offers-only-what-the-segment-can-do", "branches"], + ["a-long-log-line-scrolls-the-log-not-the-page", "actions~open9097~click:.gh-job-log"], + ["finished-branches-can-be-swept", "branches", { extra: "onfeature=1" }], + [ + "the-commit-page-actually-runs-its-verbs", + "prs~open106~text:Commits~text:issues%3A%20full-page%20detail", + ], + ["editing-a-pull-request-is-a-page-that-keeps-your-text", "prs~open106"], + ["the-files-tab-gives-the-diff-the-room", "prs~open106~text:Files", { arg: "open" }], + [ + "the-files-tab-gives-the-diff-the-room", + "prs~open106~text:Files~click:.pr-files-list%20.file-row:nth-child(5)", + { arg: "quiet" }, + ], +]; + +function run(scene, checkId, opts = {}) { + const width = opts.width ?? 1600; + const theme = opts.theme ?? "dark"; + const arg = opts.arg ? `&arg=${encodeURIComponent(opts.arg)}` : ""; + // The shim's own scene switches (staging=checkboxes, many=1, ask=1) — a mode + // reachable only through a pref still has to be assertable. + const extra = opts.extra ? `&${opts.extra}` : ""; + const url = `file://${PAGE}?scene=${scene}&theme=${theme}&check=${checkId}${arg}${extra}`; + return new Promise((res) => { + execFile( + CHROME, + [ + "--headless", + "--disable-gpu", + "--hide-scrollbars", + `--window-size=${width},1000`, + "--virtual-time-budget=12000", + "--dump-dom", + url, + ], + // A page that never lets virtual time run out (an unbounded animation, a + // self-rescheduling timer) hangs headless Chrome forever, and without a + // timeout that hangs the WHOLE suite with no clue which case did it. + { maxBuffer: 64 * 1024 * 1024, timeout: 90_000, killSignal: "SIGKILL" }, + (err, stdout) => { + if (err?.killed && !stdout) return res({ fails: ["timed out after 90s — the page never settled"] }); + if (err && !stdout) return res({ fails: [`chrome failed: ${err.message}`] }); + const m = /CHECK ([\s\S]*?)<\/title>/.exec(stdout); + if (!m) { + const t = /<title>([\s\S]*?)<\/title>/.exec(stdout); + return res({ fails: [`no verdict (title was ${JSON.stringify(t?.[1] ?? "")})`] }); + } + try { + const decoded = m[1] + .replace(/"/g, '"') + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/'/g, "'"); + res(JSON.parse(decoded)); + } catch (e) { + res({ fails: [`unparseable verdict: ${m[1].slice(0, 160)}`] }); + } + }, + ); + }); +} + +const filters = process.argv.slice(2); +const selected = filters.length + ? CASES.filter(([id]) => filters.some((f) => id.includes(f))) + : CASES; + +if (!existsSync(PAGE)) { + console.error("harness/page is not built — run: node esbuild.js && harness/gen.sh"); + process.exit(2); +} + +console.log(`running ${selected.length} functional checks\n`); +let failed = 0; +let pending = 0; +let fixed = 0; +/** + * Channels a scene asked for that the shim has no fixture for. + * + * A read with no fixture answers `undefined`, so the caller's `.ok` or + * `.length` throws and the control looks inert — a check can then PASS while + * silently exercising a throw instead of the path it was written for. The + * shim's own note on `commit:action` records this hiding the branch switcher's + * checkout; it also hid the pull request's label picker entirely. + * + * Collected across the run and printed once, as a note rather than a failure: + * most absences are legitimate, and turning them red would say nothing about + * which ones matter. + */ +const missedChannels = new Map(); +// Serial: each case is its own browser, and parallel Chromes fight over the GPU +// lock and produce flaky geometry. +for (const [id, scene, opts] of selected) { + const r = await run(scene, id, opts); + const fails = r.fails ?? []; + for (const ch of r.miss ?? []) { + if (!missedChannels.has(ch)) missedChannels.set(ch, new Set()); + missedChannels.get(ch).add(id); + } + // A check written BEFORE the thing it checks. `pending: true` says "this + // describes work that is not done yet" — so a spec can be committed as a + // failing check without turning the suite red and hiding real breakage. + // + // It is not a way to park an inconvenient failure: a pending check that + // starts PASSING is reported as such and must have its flag removed, so the + // list can only shrink. + const isPending = opts?.pending === true; + if (fails.length === 0) { + if (isPending) { + fixed++; + console.log(` \x1b[33mFIXED\x1b[0m ${id} — passing now; drop \`pending\` from check.mjs`); + } else { + console.log(` \x1b[32mPASS\x1b[0m ${id}`); + } + } else if (isPending) { + pending++; + console.log(` \x1b[36mTODO\x1b[0m ${id} (scene: ${scene})`); + for (const f of fails) console.log(` ${f}`); + } else { + failed++; + console.log(` \x1b[31mFAIL\x1b[0m ${id} (scene: ${scene})`); + for (const f of fails) console.log(` ${f}`); + } +} +if (missedChannels.size) { + console.log( + `\n\x1b[33m${missedChannels.size} channel(s) were asked for with no fixture\x1b[0m` + + ` — a read answers undefined there, so a check touching one may be passing over a throw:`, + ); + for (const [ch, ids] of [...missedChannels].sort()) { + const who = [...ids].slice(0, 3).join(", "); + console.log(` ${ch} (${ids.size} check${ids.size === 1 ? "" : "s"}: ${who}${ids.size > 3 ? ", …" : ""})`); + } +} + +const passed = selected.length - failed - pending - fixed; +const bits = [`${passed} passed`, `${failed} failed`]; +if (pending) bits.push(`${pending} pending`); +if (fixed) bits.push(`${fixed} newly passing`); +console.log(`\n${bits.join(", ")}`); +// A pending check that now passes is a FAILURE of the suite's bookkeeping, not +// of the app — but it must still be loud, or the pending list never shrinks. +process.exit(failed || fixed ? 1 : 0); diff --git a/apps/desktop/harness/checks.js b/apps/desktop/harness/checks.js new file mode 100644 index 0000000..815dbc9 --- /dev/null +++ b/apps/desktop/harness/checks.js @@ -0,0 +1,8818 @@ +// Functional checks — the half of the harness that screenshots cannot do. +// +// A screenshot proves a surface renders. It does not prove the count badge +// tracks the filter, that a menu is dismissed on navigation, that a disabled +// button is actually disabled, or that two columns share an x. Those are +// assertions, and they belong in code. +// +// Each case runs INSIDE the page, after the scene driver has finished its +// steps, and returns an array of failure strings (empty = pass). The runner +// (harness/check.mjs) drives one scene per case and collects the results. +// +// Keep assertions about BEHAVIOUR and MEASURABLE geometry. Anything about +// taste stays in the screenshot review. + +(function () { + const $ = (sel) => document.querySelector(sel); + const $$ = (sel, root) => [...(root || document).querySelectorAll(sel)]; + /** Let a click that re-renders behind an await actually land. */ + const settle = (ms = 250) => new Promise((r) => setTimeout(r, ms)); + /** + * Take CSS transitions out of the measurement. + * + * Headless Chrome runs on a virtual clock. `setTimeout` resolves without + * necessarily producing a frame, so anything driven by a transition — every + * resizable pane here sets its width through a variable — still holds its OLD + * geometry when the timeout returns. And `requestAnimationFrame` is not the + * escape hatch: on an idle page the virtual clock never advances to a frame + * at all, so awaiting one hangs the check until the suite reports "no + * verdict". + * + * So: remove the animation instead of waiting for it. A geometry check wants + * to know where a thing ENDS UP, never how it travelled — measuring mid-flight + * is the bug, not the timing. Call once, before the first measurement. + */ + let killedAnim = false; + const noAnimation = () => { + if (killedAnim) return; + killedAnim = true; + const st = document.createElement("style"); + st.textContent = + "*,*::before,*::after{transition:none!important;animation:none!important;" + + "scroll-behavior:auto!important}"; + document.head.appendChild(st); + }; + /** Accepts a selector OR an element, like probe.mjs's helper of the same name. */ + const text = (x) => { + const n = typeof x === "string" ? $(x) : x; + return (n?.textContent ?? "").trim(); + }; + const left = (el) => Math.round(el.getBoundingClientRect().left); + + /** Assertion helpers — each pushes a human-readable failure or nothing. */ + const check = (fails) => ({ + ok(cond, msg) { + if (!cond) fails.push(msg); + }, + eq(actual, expected, what) { + if (actual !== expected) fails.push(`${what}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + }, + match(actual, re, what) { + if (!re.test(actual ?? "")) fails.push(`${what}: ${JSON.stringify(actual)} does not match ${re}`); + }, + count(sel, n, what) { + const got = $$(sel).length; + if (got !== n) fails.push(`${what}: expected ${n} × "${sel}", got ${got}`); + }, + }); + + window.__GS_CHECKS = { + // ── the count badge reports what is on screen ──────────────────────────── + "count-badge-filtered": (f) => { + const c = check(f); + c.eq(text(".gh-head-count"), "2 of 8", "badge with an author filter applied"); + c.eq($$(".sec-row[data-num]").length, 2, "rows rendered"); + }, + "count-badge-unfiltered": (f) => { + const c = check(f); + c.eq(text(".gh-head-count"), "8", "badge with no filter"); + c.ok(!text(".gh-head-count").includes("of"), "unfiltered badge must not say 'of'"); + }, + /** + * The same rule as count-badge-filtered, stated as a PROPERTY so it can run + * on any list rather than only the one whose fixture numbers were baked in. + * Actions and Releases both kept advertising the unfiltered total directly + * above a "No matching …" empty state. + */ + "count-badge-tracks-the-filter": (f) => { + const c = check(f); + const badge = text(".gh-head-count"); + const shown = $$(".sec-row[data-num]").length; + c.ok(!!badge, "the header shows a count"); + const m = /^(\d[\d,]*)(?:\s+of\s+(\d[\d,]*))?$/.exec(badge); + c.ok(!!m, `the badge reads "N" or "N of M" (got "${badge}")`); + if (!m) return; + const n = Number(m[1].replace(/,/g, "")); + c.eq(n, shown, `the badge counts the rows actually rendered (${badge} vs ${shown} rows)`); + if (shown === 0) { + c.ok( + !!m[2], + `an empty filtered list must say "0 of N", not the pre-filter total ("${badge}")`, + ); + c.ok(!!$(".list-empty"), "and show an empty state"); + } + }, + + // ── overlays do not outlive the view that opened them ──────────────────── + "menu-dismissed-on-route": (f) => { + const c = check(f); + c.count(".dropdown", 0, "dropdowns left open after navigating"); + c.ok(!$(".notif-view"), "the Inbox should no longer be mounted"); + }, + "menu-closes-siblings": (f) => { + check(f).count(".dropdown", 1, "only one menu may be open at a time"); + }, + + // ── the palette runs what it highlights ────────────────────────────────── + "palette-selects-first": (f) => { + const c = check(f); + const rows = $$(".cmdk-row"); + c.ok(rows.length > 1, "palette should have rows"); + const idx = rows.findIndex((r) => r.classList.contains("is-selected")); + c.eq(idx, 0, "index of the selected row"); + c.match(rows[0]?.textContent, /Search GitHub for/, "first row"); + }, + "palette-selection-visible": (f) => { + const c = check(f); + const sel = $(".cmdk-row.is-selected"); + c.ok(!!sel, "a row is selected"); + if (!sel) return; + const parse = (rgb) => (rgb.match(/\d+/g) || []).slice(0, 3).map(Number); + const lum = (rgb) => { + const [r, g, b] = parse(rgb).map((v) => { + const x = v / 255; + return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4); + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + }; + const rowBg = getComputedStyle(sel).backgroundColor; + const panelBg = getComputedStyle($(".cmdk-card")).backgroundColor; + const a = lum(rowBg) + 0.05; + const b = lum(panelBg) + 0.05; + const ratio = a > b ? a / b : b / a; + const bar = getComputedStyle(sel).boxShadow; + c.ok( + ratio >= 1.35 || /inset/.test(bar), + `selection must be visible: ${ratio.toFixed(2)}:1 against the panel and no accent bar`, + ); + }, + "palette-min-chars": (f) => { + const groups = $$(".cmdk-group").map((g) => g.textContent); + check(f).ok( + !groups.includes("Search GitHub"), + `a 2-character query must not spend a search request (groups: ${groups.join(", ")})`, + ); + }, + + // ── facets speak the language of the rows ──────────────────────────────── + "facet-labels-humanized": (f) => { + const c = check(f); + const items = $$(".dropdown-item .dropdown-label").map((n) => n.textContent.trim()); + c.ok(items.length > 1, "the Reason menu should have options"); + c.ok(!items.includes("subscribed"), `raw API value in the menu: ${items.join(", ")}`); + c.ok(items.includes("watching"), `humanized label missing: ${items.join(", ")}`); + }, + "facet-labels-aligned": (f) => { + const c = check(f); + const labels = $$(".dropdown-item .dropdown-label"); + c.ok(labels.length > 2, "need several options to compare"); + const xs = [...new Set(labels.map(left))]; + c.eq(xs.length, 1, `option labels must share one left edge (found ${xs.join(", ")})`); + }, + "issues-closed-facet-hidden-on-open": (f) => { + const labels = $$(".gh-facet-btn").map((b) => b.textContent); + check(f).ok( + !labels.some((l) => l.includes("Closed as")), + "the closed-reason facet must not appear on the Open tab", + ); + }, + "issues-closed-facet-shown-on-closed": (f) => { + const labels = $$(".gh-facet-btn").map((b) => b.textContent); + check(f).ok( + labels.some((l) => l.includes("Closed as")), + `the closed-reason facet should appear on the Closed tab (got: ${labels.join(" | ")})`, + ); + }, + + // ── the commit box matches what it will do ─────────────────────────────── + "commit-disabled-when-empty": (f) => { + const c = check(f); + const btn = $(".dc-commit"); + c.ok(!!btn, "commit button exists"); + c.ok(btn?.hasAttribute("disabled"), "Commit must be disabled with an empty message"); + c.eq(text(".dc-branch-name"), "main", "branch label"); + c.eq(text(".dc-commit-label"), "Commit to main", "commit button label"); + }, + "commit-enabled-after-typing": (f) => { + const c = check(f); + const btn = $(".dc-commit"); + c.ok(!btn?.hasAttribute("disabled"), "Commit must enable once a message is typed"); + }, + + // ── Compare's default state is usable ──────────────────────────────────── + "compare-no-self-compare": (f) => { + const c = check(f); + const picks = $$(".ref-pick").map((b) => b.textContent.trim()); + c.ok(picks.length >= 2, "two ref pickers"); + c.ok(picks[0] !== picks[1], `base and compare must differ (both "${picks[0]}")`); + }, + + // ── Changes: the status letters form one column ────────────────────────── + "changes-status-column": (f) => { + const c = check(f); + const st = $$(".dc-file .file-status"); + c.ok(st.length >= 4, "need several files"); + const xs = [...new Set(st.map(left))]; + c.eq(xs.length, 1, `status letters must share one x (found ${xs.join(", ")})`); + c.ok( + $$(".group-label").some((h) => /Unstaged/i.test(h.textContent)), + "the unstaged group should be labelled 'Unstaged'", + ); + }, + + "changes-rows-share-left-edge": (f) => { + const c = check(f); + const names = $$(".dc-file .dc-file-name"); + c.ok(names.length >= 4, "need several files"); + const xs = [...new Set(names.map(left))]; + c.eq(xs.length, 1, `staged and unstaged rows must share one left edge (found ${xs.join(", ")})`); + }, + "changes-toolbar-stable": (f) => { + const c = check(f); + // Selection must not reflow the toolbar: hidden controls used to slide + // every button to their left ~160px sideways. + const btn = $(".dc-createpr"); + c.ok(!!btn, "Create pull request exists"); + const x = btn ? left(btn) : 0; + window.__gsToolbarX = x; + c.ok(x > 0, "toolbar rendered"); + for (const sel of [".dc-stagelines", ".dc-ws"]) { + const el_ = $(sel); + c.ok(!!el_, `${sel} must stay in the layout`); + c.ok(el_ ? !el_.hidden : false, `${sel} must be disabled rather than hidden`); + } + }, + + /** + * The whitespace toggle has to MEAN something, and mean the same thing in + * both diff renderings. Split computes in-process through the engine; + * Inline computes in Monaco's own worker, whose only whitespace knob is + * `ignoreTrimWhitespace`. The two used to derive that flag from the app's + * toggle separately and drifted apart, so the same file with the same + * setting showed a change in one view and none in the other. + * + * This drives the half that is measurable here — Monaco paints its diff + * decorations on a frame this harness starves, so the unified side is + * pinned by `packages/engine/test/whitespaceRule.test.ts` instead, where + * both surfaces now read the rule from one exported function. + * + * `?ws=1` adds a file whose only change is a re-indent and some trailing + * spaces; `.jb-stage-tick` is one per change block and is plain DOM. + */ + "whitespace-toggle-agrees-across-diff-views": async (f) => { + const c = check(f); + const ws = $(".dc-ws"); + c.ok(!!ws, "the toolbar has a whitespace toggle"); + if (!ws) return; + c.eq(ws.disabled, false, "and a file is open, so it is live"); + c.ok( + /leading and trailing/i.test(ws.title), + `the toggle must say what it actually ignores (title: “${ws.title}”)`, + ); + + const ticks = () => $$(".jb-stage-tick").length; + c.ok(ticks() > 0, "with whitespace shown, the re-indent is a change"); + + ws.click(); + await settle(1600); + c.eq(ws.getAttribute("aria-pressed"), "true", "the toggle reads as on"); + c.eq(ticks(), 0, "with it ignored, a whitespace-only file has no changes left"); + + ws.click(); + await settle(1600); + c.ok(ticks() > 0, "and turning it back off brings the change back"); + }, + + /** + * The case that separates the two rules the app could have picked. + * + * This file's ONLY change is a doubled space in the middle of a line. The + * engine's "all" mode collapses whitespace runs and would call it + * unchanged; Monaco has no such mode and will always draw it as a change. + * So if ignoring whitespace makes this file look clean in the split view, + * the split view and the unified view are once again describing the same + * file differently — which is the bug, in the other direction. + */ + "ignoring-whitespace-stops-at-the-ends-of-a-line": async (f) => { + const c = check(f); + const ws = $(".dc-ws"); + c.ok(!!ws, "the toolbar has a whitespace toggle"); + if (!ws) return; + const ticks = () => $$(".jb-stage-tick").length; + c.ok(ticks() > 0, "a doubled space inside a line is a change"); + ws.click(); + await settle(1600); + c.ok( + ticks() > 0, + "and stays one when whitespace is ignored — Monaco cannot hide it, so neither may we", + ); + }, + + /** + * The other half of the same rule: ignoring whitespace must not swallow a + * real edit. Without this, the check above passes on a toggle that simply + * throws the diff away. + */ + "ignoring-whitespace-keeps-real-changes": async (f) => { + const c = check(f); + const ws = $(".dc-ws"); + c.ok(!!ws, "the toolbar has a whitespace toggle"); + if (!ws) return; + const ticks = () => $$(".jb-stage-tick").length; + c.ok(ticks() > 0, "the file has a real change"); + ws.click(); + await settle(1600); + c.ok(ticks() > 0, "which survives ignoring whitespace"); + }, + + // ── the log pane ───────────────────────────────────────── + "log-no-blank-endgroup-rows": (f) => { + const c = check(f); + const lines = $$(".log-line"); + c.ok(lines.length > 5, "the log should have rendered lines"); + const blanks = lines.filter((l) => (l.querySelector(".log-text")?.textContent ?? l.textContent.replace(/^\s*\d+\s*/, "")).trim() === ""); + c.ok(blanks.length === 0, `${blanks.length} blank log rows rendered (endgroup markers)`); + }, + "log-pane-has-its-own-ground": (f) => { + const c = check(f); + const pane = $(".log-pane"); + c.ok(!!pane, "log pane is open"); + if (!pane) return; + const paneBg = getComputedStyle(pane).backgroundColor; + const pageBg = getComputedStyle(document.body).backgroundColor; + c.ok(paneBg !== pageBg, `the pane must not share the page background (${paneBg})`); + }, + /** + * Resizing is not scrolling. + * + * Changing the pane's size changes its scrollHeight, which the scroll + * listener reads as "the user scrolled away from the bottom" and silently + * turns following OFF, dumping the reader into the middle of the log. + * + * The check turns following ON first rather than assuming it: a FINISHED + * job's log does not follow anything now, and the invariant was never + * "follow is on" — it is "resizing does not change the mode". + */ + "log-follow-survives-expand": async (f) => { + const c = check(f); + // On a job that is still PRODUCING — following a finished producer is not + // a mode the pane will enter, and the invariant under test is about + // resizing, not about what can be followed. + const live = $$(".joblog-job").find((r) => /running/i.test(text(r))); + c.ok(!!live, "the run has a job still producing output"); + if (!live) return; + live.click(); + await settle(1600); + + const followBtn = $$(".log-tool").find((b) => /follow/i.test(b.title)); + c.ok(!!followBtn, "follow control exists"); + if (!followBtn) return; + if (!followBtn.classList.contains("is-on")) { + followBtn.click(); + await settle(300); + } + c.ok(followBtn.classList.contains("is-on"), "following can be turned on"); + + const expand = $$(".log-tool").find((b) => /full width|expand the pane/i.test(b.title)); + c.ok(!!expand, "the pane can be resized"); + if (!expand) return; + expand.click(); + await settle(400); + c.ok( + followBtn.classList.contains("is-on"), + "resizing the pane must not turn follow-tail off", + ); + c.eq(followBtn.getAttribute("aria-pressed"), "true", "and must not lie about it either"); + }, + + // ── Actions run detail ─────────────────────────────────────────────────── + "run-detail-one-identity": (f) => { + const c = check(f); + const crumb = text(".det-crumb"); + const title = text(".det-title-num"); + c.eq(crumb, "#411", "breadcrumb"); + c.eq(title.trim(), "#411", "title number"); + }, + "run-detail-hides-dead-actions": (f) => { + const c = check(f); + const visible = $$(".det-tb-actions button").filter((b) => !b.hidden && b.offsetParent !== null); + const labels = visible.map((b) => b.textContent.trim()).filter(Boolean); + c.ok(!labels.includes("Cancel"), `Cancel must be hidden on a finished run (got: ${labels.join(", ")})`); + c.ok(!labels.includes("Re-run failed"), "Re-run failed must be hidden on a success"); + }, + "run-detail-steps-visible": (f) => { + const c = check(f); + c.ok($$(".gh-job").length >= 2, "both jobs render"); + c.ok($$(".gh-step-row").length >= 4, "steps should be visible without clicking"); + }, + // Collapsing ONE job card used to silently redefine every other card. + // `open` was `expandedJobs.size === 0 || has(id)`, so the set meant both + // "nothing chosen yet ⇒ show all" and "exactly these" — and the first + // collapse left it empty (deleting an id it never held), so the next + // repaint re-opened the card you had just shut. The mirror case is worse: + // opening one job's log ADDS to the set, and every untouched sibling then + // collapses on the next repaint. + "collapsing-one-job-leaves-the-others-alone": (f) => { + const c = check(f); + const cards = $$(".gh-job"); + c.ok(cards.length >= 2, `the run has at least two jobs (got ${cards.length})`); + const shut = (card) => card.querySelector(".gh-job-steps")?.classList.contains("hidden"); + c.ok(shut(cards[0]), "the job I collapsed is still collapsed after leaving and coming back"); + c.ok( + cards.slice(1).every((card) => !shut(card)), + "and the jobs I never touched are still open — collapsing one must not close the rest", + ); + }, + + "run-detail-no-duplicate-status": (f) => { + const c = check(f); + const railLabels = $$(".det-prop-label").map((n) => n.textContent.trim().toLowerCase()); + c.ok(!railLabels.includes("status"), "the rail must not repeat the header's status pill"); + c.ok(!railLabels.includes("branch"), "the rail must not repeat the header's branch chip"); + }, + + "run-detail-failed-shows-rerun": (f) => { + const c = check(f); + const labels = $$(".det-tb-actions button") + .filter((b) => !b.hidden && b.offsetParent !== null) + .map((b) => b.textContent.trim()); + c.ok(labels.includes("Re-run failed"), `a FAILED run must offer Re-run failed (got: ${labels.join(", ")})`); + c.ok(!labels.includes("Cancel"), "a finished run must not offer Cancel"); + }, + "step-bars-share-one-scale": (f) => { + const c = check(f); + // The bars exist to be COMPARED, so they must be normalised across the + // whole run — per-job scaling drew a 11s step and a 7m step at the same + // length in adjacent cards. + const rows = $$(".gh-step-row"); + c.ok(rows.length >= 6, `expected steps from both jobs, got ${rows.length}`); + const pairs = rows + .map((r) => { + const secs = (() => { + const t = r.querySelector(".gh-step-dur")?.textContent?.trim() ?? ""; + const m = /^(?:(\d+)m\s*)?(?:(\d+)s)?$/.exec(t); + return m ? Number(m[1] ?? 0) * 60 + Number(m[2] ?? 0) : null; + })(); + const w = parseFloat(r.querySelector(".gh-step-bar")?.style.getPropertyValue("--w") ?? "0"); + return secs === null ? null : { secs, w }; + }) + .filter(Boolean) + .filter((p) => p.secs > 0); + c.ok(pairs.length >= 4, "need several timed steps"); + const longest = pairs.reduce((a, b) => (b.secs > a.secs ? b : a)); + const shortest = pairs.reduce((a, b) => (b.secs < a.secs ? b : a)); + c.ok( + longest.w > shortest.w, + `the longest step (${longest.secs}s) must draw wider than the shortest (${shortest.secs}s): ${longest.w}% vs ${shortest.w}%`, + ); + // One scale means width is monotonic in duration across ALL cards. + const sorted = [...pairs].sort((a, b) => a.secs - b.secs); + for (let i = 1; i < sorted.length; i++) { + c.ok( + sorted[i].w >= sorted[i - 1].w - 0.5, + `bar widths are not monotonic in duration: ${sorted[i - 1].secs}s→${sorted[i - 1].w}% then ${sorted[i].secs}s→${sorted[i].w}%`, + ); + } + }, + + "workflow-rows-carry-state": (f) => { + const c = check(f); + const rows = $$(".sec-row"); + c.ok(rows.length >= 3, "workflow rows render"); + for (const r of rows) { + const meta = r.querySelector(".sec-row-meta")?.textContent?.trim() ?? ""; + c.ok(meta.length > 0, `a workflow row must say something about its last run: "${r.textContent.trim().slice(0, 40)}"`); + // "never run" is only honest once the runs are actually loaded. + c.ok(!/never run/.test(meta), `"never run" claimed while runs were loaded: ${meta}`); + } + }, + + // ── Inbox ──────────────────────────────────────────────────────────────── + "inbox-search-filters": (f) => { + const c = check(f); + const rows = $$(".notif-row").length; + c.ok(rows > 0 && rows < 7, `search should narrow the list (got ${rows} of 7)`); + }, + "inbox-rows-share-left-edge": (f) => { + const c = check(f); + const titles = $$(".notif-line-title, .gh-row-title"); + c.ok(titles.length > 3, "need several rows"); + const xs = [...new Set(titles.map(left))]; + c.eq(xs.length, 1, `read and unread rows must share one left edge (found ${xs.join(", ")})`); + }, + "inbox-state-segment": (f) => { + const c = check(f); + const seg = $$(".gh-seg-btn").map((b) => b.textContent.trim()); + c.ok(seg.includes("Unread") && seg.includes("All"), `expected an Unread|All segment, got ${seg.join(", ")}`); + const active = $$(".gh-seg-btn.active").map((b) => b.textContent.trim()); + c.ok(active.length >= 1, "the segment must show which mode is active"); + }, + + // A code search exists to find a STRING. The result rendered the matching + // lines with nothing marking WHERE in them the string was, so the reader + // was left scanning by eye for the thing they had just asked the search to + // find. GitHub sends the offsets alongside the fragment; the row dropped + // them on the floor. + "code-hits-show-what-matched": (f) => { + const c = check(f); + const frags = $$(".explore-code-line"); + c.ok(frags.length > 0, `code results render their fragments (${frags.length})`); + const marks = $$(".explore-code-line mark"); + c.ok(marks.length > 0, "and mark the matched text inside them"); + for (const m of marks) { + c.ok(m.textContent.trim().length > 0, "each mark covers real text"); + } + // The mark must not eat the line: the surrounding code has to survive. + const line = frags[0]; + c.ok( + line.textContent.length > $$("mark", line).reduce((n, m) => n + m.textContent.length, 0), + "the rest of the line is still there around the highlight", + ); + }, + + // The checkbox model's promise is "the tick IS the index". A partially + // staged file (git's `MM`) arrived as two records for one path and was + // rendered as two rows — the same file listed twice, once ticked and once + // not, contradicting itself, and counted twice in "Changes (N)". Partial is + // a real third state and a checkbox has one. + "one-row-per-file-in-checkbox-mode": (f) => { + const c = check(f); + const rows = $$(".dc-file"); + c.ok(rows.length > 0, "the checklist has rows"); + const paths = rows.map((r) => r.title || r.textContent.trim()); + const dupes = paths.filter((p, i) => paths.indexOf(p) !== i); + c.eq(dupes.length, 0, `no file is listed twice (dupes: ${[...new Set(dupes)].join(", ")})`); + + // The partial file is the one the fixture stages half of. + const partialRow = rows.find((r) => (r.title || "").endsWith("renderer.ts")); + c.ok(!!partialRow, "the partially-staged file is present"); + const ck = partialRow?.querySelector(".dc-ck"); + c.ok(!!ck, "it has a tick"); + c.ok(ck?.indeterminate === true, "and the tick says PARTIAL, not in-or-out"); + + // The header count is files, not status records. + const head = $(".dc-list-head, .dc-checklist-head") ?? $$(".dc-file")[0]?.parentElement?.firstElementChild; + const label = head?.textContent ?? ""; + const m = /Changes \((\d+)\)/.exec(label); + if (m) c.eq(Number(m[1]), rows.length, `"Changes (N)" counts files, not records`); + }, + + // A kept-alive section is stashed OUT of the DOM while you are elsewhere, + // and restoring replays the cached DOM rather than rebuilding it — so + // nothing re-registers the page's Escape handler. Pruning the handler stack + // on `isConnected` at REGISTRATION time therefore dropped pages that were + // merely put away, and Escape and ← were dead on every detail page you + // came back to. + "escape-still-works-after-coming-back": async (f) => { + const c = check(f); + c.ok(!!$(".det-back"), "we are on a detail page"); + document.body.focus(); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + await settle(600); + c.ok(!$(".det-back"), "Escape leaves the detail page it came back to"); + }, + + // Toasts live in a persistent #toast-stack on the body, and `holdBackground` + // inerted every body child that was not the modal — including it. A toast + // raised over an open palette or dialog was then unclickable (aiming at its + // ✕ dismissed the LAYER and threw away what had been typed) and, for a + // screen reader, silent: an aria-live host inside an inert subtree + // announces nothing. A live region is not part of the page being held back. + "a-toast-is-reachable-over-a-dialog": (f) => { + const c = check(f); + const layer = $(".cmdk-overlay, .modal-overlay, .peek-overlay"); + c.ok(!!layer, "a modal surface is open"); + const stack = document.getElementById("toast-stack"); + c.ok(!!stack, "a toast is up (the host only exists once one is raised)"); + c.ok($$(".toast").length > 0, "and it is still on screen"); + if (!stack) return; + c.ok(!stack.hasAttribute("inert"), "and it is NOT inert while the surface is up"); + // Its ancestors too — inert inherits. + for (let n = stack.parentElement; n && n !== document.documentElement; n = n.parentElement) { + c.ok(!n.hasAttribute("inert"), `no ancestor is inert (${n.tagName.toLowerCase()})`); + } + }, + + // `aria-modal="true"` is a CLAIM. The Tab wrap in dialogs/peek acts only + // when focus sits exactly on the first or last focusable in the card, so + // any in-card re-render that destroyed the focused control — or a click on + // the card's own heading — dropped focus on <body>, and the next Tab walked + // into the app behind the scrim: reachable, focusable, clickable, invisible. + // A peek was worse still: its card is focused with tabindex="-1", which the + // wrap's own selector excludes, so the FIRST Tab escaped. + "a-modal-surface-holds-the-page-behind-it": (f) => { + const c = check(f); + const overlay = $(".peek-overlay, .modal-overlay, .cmdk-overlay"); + c.ok(!!overlay, "a modal surface is open"); + if (!overlay) return; + // Every other body child is inert — that is what stops Tab, the pointer + // and assistive tech at the surface. (Live regions are exempt by design; + // see holdBackground.) + const leaked = [...document.body.children].filter( + (el) => + el !== overlay && + !el.contains(overlay) && + !el.hasAttribute("inert") && + // Not rendered at all — `inert` on a <script> would mean nothing, and + // listing them buries the real leak in noise. + !/^(SCRIPT|STYLE|TEMPLATE|LINK|META)$/.test(el.tagName) && + el.id !== "toast-stack" && + !el.matches('[aria-live], [role="status"], [role="alert"], [role="log"]'), + ); + c.eq( + leaked.length, + 0, + `nothing behind the scrim stays interactive (leaked: ${leaked + .map((el) => el.id || el.className || el.tagName) + .join(", ")})`, + ); + // And the app's own root really is held. + const root = document.getElementById("root"); + if (root && !root.contains(overlay)) { + c.ok(root.hasAttribute("inert"), "the app root is inert while the surface is up"); + } + }, + + // Explore's page RESTORE used to re-fetch. Coming back from a result + // re-ran one search request per accumulated page, sequentially, on the + // premise that they were all in the 60s cache — true for a minute. Read a + // repo page for longer and every Back spent the search budget rebuilding + // scroll position; on the Code tab (~8 requests/minute) a return with eight + // pages loaded spent ALL of it, so the next query met the app's own + // "Search is catching its breath". + // + // Time is moved past the TTL here on purpose: with a warm cache the old + // code and the new one are indistinguishable, which is exactly why this + // went unnoticed. + "coming-back-to-a-search-costs-no-requests": async (f) => { + const c = check(f); + const more = [...$$(".explore-footer button")].find((b) => /load more/i.test(b.textContent)); + c.ok(!!more, "the list offers Load more"); + if (!more) return; + const before = $$(".explore-row").length; + more.click(); + await settle(900); + const two = [...$$(".explore-footer button")].find((b) => /load more/i.test(b.textContent)); + two?.click(); + await settle(900); + const loaded = $$(".explore-row").length; + c.ok(loaded > before, `more pages are loaded (${before} → ${loaded})`); + const pagesLoaded = Math.max(1, Math.round(loaded / Math.max(1, before))); + + // Age the cache past its 60s TTL, and count what the restore spends. + const realNow = Date.now; + let searches = 0; + const host = window.gitstudio; + const realInvoke = host.invoke.bind(host); + host.invoke = (ch, p) => { + if (typeof ch === "string" && ch.startsWith("search:")) searches++; + return realInvoke(ch, p); + }; + Date.now = () => realNow.call(Date) + 61_000; + try { + // Leave for a result, then come straight back. + $(".explore-row")?.click(); + await settle(900); + const back = $(".det-back, .peek-nav-btn, .gh-back"); + c.ok(!!back, "the result page offers a way back"); + back?.click(); + await settle(1400); + // At most the base search itself — a stale page-1 entry legitimately + // revalidates. What must NEVER happen again is the cost SCALING with + // how many pages were loaded, which is what made a long read poison + // the next query. + c.ok( + searches <= 1, + `the return does not spend a request per loaded page ` + + `(${pagesLoaded} pages loaded, ${searches} search requests spent)`, + ); + c.ok($$(".explore-row").length > 0, "and the results are still on screen"); + } finally { + Date.now = realNow; + host.invoke = realInvoke; + } + }, + + // Every row in a list carries the same button — four "Stage"s, three + // "Delete"s — and the `title` repeats the verb too ("Delete this branch"). + // So tabbing a list with a screen reader was "Stage, Stage, Stage, Stage": + // the one thing a person needs to know, WHICH file, was the one thing not + // said. Worst on the destructive ones, where the next Enter acts. + "row-actions-name-their-object": (f) => { + const c = check(f); + // `.row-more` too: a row whose actions live behind ONE overflow still has + // to name its object — "More actions for gitstudio", not "More actions" + // repeated down the page. The rule is about what a screen reader hears, + // not about which element the actions happen to sit in. + // `.sec-row-actions` too: the ref manager's verbs render AT REST in the + // shared row's own action slot, not inside a hover-revealed `.row-actions` + // cluster. The rule is about what a screen reader hears, not about which + // element the actions happen to sit in. + const btns = $$(".row-actions .row-btn, .sec-row-actions .row-btn, .row-more").filter( + (b) => b.offsetParent !== null, + ); + c.ok(btns.length >= 2, `the view has row actions (${btns.length})`); + if (btns.length < 2) return; + const names = btns.map( + (b) => (b.getAttribute("aria-label") || b.textContent || "").trim().toLowerCase(), + ); + const dupes = names.filter((n, i) => n && names.indexOf(n) !== i); + c.eq( + [...new Set(dupes)].length, + 0, + `no two row actions announce the same thing (repeated: ${[...new Set(dupes)] + .slice(0, 3) + .join(", ")})`, + ); + // And the name has to carry the object, not just the verb. + const bare = btns.filter((b) => { + const label = (b.getAttribute("aria-label") || "").trim(); + return label && label.toLowerCase() === (b.textContent || "").trim().toLowerCase(); + }); + c.eq(bare.length, 0, "an aria-label that only repeats the visible verb adds nothing"); + + // A ROW that is itself a control and CONTAINS these actions must carry + // its own name, or it derives one from its children and announces the + // object once per action: "app.css Stage app.css Discard app.css". + for (const b of btns) { + // From the PARENT: `closest` matches the element itself, so starting at + // the button found the button, and the check skipped every row instead + // of walking up to it. It passed over the exact defect it was written + // for — a file row whose name is derived from its children. + const row = b.parentElement?.closest("button, [role=button]"); + if (!row) continue; + const own = (row.getAttribute("aria-label") || "").trim(); + c.ok( + !!own, + `a row that contains its actions needs a name of its own (${(row.className || "").slice(0, 30)})`, + ); + // As a WORD. "Unstaged modified <path>" is a good row name and happens + // to contain "stage" inside "Unstaged"; a substring test called that a + // defect. What must not happen is the row RECITING its actions. + const verb = (b.textContent || "").trim().toLowerCase(); + if (own && verb) { + const asWord = new RegExp(`\\b${verb.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i"); + c.ok( + !asWord.test(own), + `and it must not recite its actions ("${own.slice(0, 48)}" contains "${verb}")`, + ); + } + } + }, + + // A placeholder is the field's label when the field is empty. Measured + // against its own box rather than eyeballed: "Filter this organization…" + // was 141px in a 145px input — four pixels of slack, clipping its own + // ellipsis at any larger text size. + "placeholders-fit-their-field": (f) => { + const c = check(f); + const inputs = $$("input[placeholder]").filter((i) => i.offsetParent !== null); + c.ok(inputs.length > 0, "the view has a field with a placeholder"); + const ctx = document.createElement("canvas").getContext("2d"); + for (const i of inputs) { + const cs = getComputedStyle(i); + ctx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`; + const text = Math.ceil(ctx.measureText(i.placeholder).width); + const room = + i.getBoundingClientRect().width - + parseFloat(cs.paddingLeft || "0") - + parseFloat(cs.paddingRight || "0"); + // A little headroom, so a slightly different font does not clip it. + c.ok( + text <= room - 6, + `"${i.placeholder}" fits its field (${text}px of ${Math.round(room)}px)`, + ); + } + }, + + // A file is in the changed list BECAUSE the two refs differ on it. When the + // diff could not be loaded the pane said "These two refs have identical + // content for this file." — asserting equality the app had no basis for, + // about the one file it had just told you was different. `undefined` from + // compare:fileDiff means no repo open or a rejected ref; it has never meant + // "no difference". + "a-compare-diff-that-fails-says-so": async (f) => { + const c = check(f); + const row = $(".cmp-file, .dc-file, .file-row"); + c.ok(!!row, "the comparison lists files"); + if (!row) return; + row.click(); + await settle(900); + const empty = $(".diff-empty"); + if (!empty) { + // The happy path: a real diff rendered. Nothing to assert here. + c.ok(!!$(".monaco-editor, .diff-surface"), "a diff is showing"); + return; + } + const said = (empty.textContent || "").toLowerCase(); + c.ok( + !said.includes("identical"), + `a file that could not be loaded must not be called identical ("${said.slice(0, 70)}")`, + ); + c.ok(empty.classList.contains("is-error"), "and it reads as a failure, not a result"); + }, + + // Changing the theme used to rebuild the WHOLE Settings view, so a value + // half-typed into any OTHER card — the git identity, an SSH passphrase, the + // clone folder — was destroyed by a ⌘K theme switch. The Appearance card + // updates its own two controls instead. Same rule the rest of the app + // already follows: a form is not the app's to throw away. + "changing-the-theme-keeps-what-you-typed": async (f) => { + const c = check(f); + const view = $(".settings-view"); + c.ok(!!view, "the Settings view is up"); + if (!view) return; + // A marker on a node the theme control does NOT own. If the view is + // rebuilt, this node is discarded with everything typed into the cards + // around it — which is what a ⌘K theme switch used to do to a half-typed + // git identity, SSH passphrase or clone folder. + const other = $$(".settings-card").find((n) => !n.contains($(".settings-seg"))); + c.ok(!!other, "and it has a card other than Appearance"); + const marker = "gs-survives-" + Date.now(); + (other ?? view).dataset.gsMarker = marker; + + const themeBtn = $$(".settings-seg-btn").find((b) => !b.classList.contains("active")); + c.ok(!!themeBtn, "and a theme control to change"); + if (!themeBtn) return; + themeBtn.click(); + await settle(700); + + c.ok(themeBtn.isConnected, "the control itself survived"); + c.ok(themeBtn.classList.contains("active"), "and took the change"); + c.ok( + !!document.querySelector(`[data-gs-marker="${marker}"]`), + "the OTHER cards were not rebuilt — nothing typed into them is lost", + ); + }, + + // The dock is an overlay footer: it does not shrink the scrollers above it, + // so long views add `--dock-reserve` to their bottom padding to clear it. + // Every path that changes the dock's height must republish that value — + // collapse, the keyboard resizer, setHeight, reclamp all did, and the + // POINTER DRAG did not, so dragging the dock taller put the end of every + // long list back underneath it. + "the-dock-reserve-tracks-the-dock": async (f) => { + const c = check(f); + const host = $(".main-stack"); + c.ok(!!host, "the dock's host is present"); + const body = $(".dock-body"); + c.ok(!!body, "the dock is open"); + if (!host || !body) return; + const read = () => parseFloat(getComputedStyle(host).getPropertyValue("--dock-reserve")) || 0; + const before = read(); + c.ok(before > 0, `an open dock reserves space (${before}px)`); + + // Drag the top edge upward — the same path a pointer takes. + const grip = $(".dock-resizer, .dock-grip, [class*=resizer]"); + c.ok(!!grip, "the dock offers a resize grip"); + if (!grip) return; + const at = grip.getBoundingClientRect(); + const opts = { bubbles: true, clientX: at.left + 4, pointerId: 1 }; + grip.dispatchEvent(new PointerEvent("pointerdown", { ...opts, clientY: at.top + 2 })); + window.dispatchEvent(new PointerEvent("pointermove", { ...opts, clientY: at.top - 120 })); + window.dispatchEvent(new PointerEvent("pointerup", { ...opts, clientY: at.top - 120 })); + await settle(250); + + const after = read(); + const h = parseFloat(getComputedStyle(body).height) || 0; + c.ok( + Math.abs(after - h) < 2, + `the reserve follows the drag (reserve ${Math.round(after)}px vs dock ${Math.round(h)}px)`, + ); + }, + + "row-meta-columns-align": (f) => { + const c = check(f); + // A row missing an optional datum must not slide its neighbours into a + // different column — the meta cluster packs right-to-left, so a dropped + // element shifts everything to its LEFT. Works for any list: whatever + // kinds of meta a list carries, each kind holds one column. + // Not every list stamps data-num (the Inbox keys by thread id). + const rows = $$(".sec-row, .notif-line").filter((r) => r.querySelector(".sec-row-meta")); + c.ok(rows.length >= 2, `the list renders rows (${rows.length})`); + if (rows.length < 2) return; + /** class-name → the right edges seen for it, one per row that has it. */ + const byKind = new Map(); + let counted = 0; + for (const r of rows) { + // Rows can carry two of a kind (an author stack AND an assignee + // stack), so the Nth of a kind is its own column. + const seen = new Map(); + for (const m of $$(".sec-row-meta > *", r)) { + const cls = m.className || m.tagName; + const n = (seen.get(cls) || 0) + 1; + seen.set(cls, n); + const kind = n === 1 ? cls : `${cls} #${n}`; + if (!byKind.has(kind)) byKind.set(kind, new Set()); + byKind.get(kind).add(Math.round(m.getBoundingClientRect().right)); + counted++; + } + } + c.ok(counted > 0, "rows carry meta at all"); + for (const [kind, edges] of byKind) { + // A kind only ONE row has can't be misaligned. + if (edges.size <= 1) continue; + c.eq(edges.size, 1, `"${kind}" must hold one column (right edges ${[...edges].join(", ")})`); + } + // The time column is right-aligned, so its RIGHT edge is the column. + const timeXs = new Set( + rows + .map((r) => r.querySelector(".sec-row-time")) + .filter(Boolean) + .map((el) => Math.round(el.getBoundingClientRect().right)), + ); + c.ok(timeXs.size <= 1, `times must share one right edge (found ${[...timeXs].join(", ")})`); + }, + + // ── Pull requests ──────────────────────────────────────────────────────── + "prs-state-segment": (f) => { + const c = check(f); + const seg = $$(".gh-seg-btn").map((b) => b.textContent.trim()); + for (const want of ["Open", "Merged", "Closed", "All"]) { + c.ok(seg.includes(want), `PR state segment missing "${want}" (got ${seg.join(", ")})`); + } + }, + "prs-author-avatar-labelled": (f) => { + const c = check(f); + const av = $(".sec-row .sec-avs .av"); + c.ok(!!av, "rows carry an avatar"); + c.match(av?.getAttribute("title") ?? av?.getAttribute("aria-label"), /Author|Assignee/, "avatar role label"); + }, + + // ── Explore ────────────────────────────────────────────────────────────── + "explore-search-results": (f) => { + const c = check(f); + c.ok($$(".explore-row").length >= 3, "search should return rows"); + c.match(text(".explore-footer-note"), /matches/, "footer states the total"); + }, + "explore-numbers-formatted": (f) => { + const c = check(f); + const stats = $$(".explore-stat").map((s) => s.textContent.replace(/\D+/g, "|")); + const raw = $$(".explore-stat").map((s) => s.textContent.trim()).filter((t) => /\d{4,}/.test(t.replace(/[,\s]/g, "")) && !t.includes(",")); + c.ok(raw.length === 0, `unformatted counts: ${raw.join(", ")}`); + void stats; + }, + "explore-repo-page": (f) => { + const c = check(f); + c.match(text(".explore-repo-title"), /gitstudio/i, "the repo page has a title"); + c.ok($$(".explore-tree-row").length >= 3, "the file tree renders"); + const ref = $$(".explore-ref-btn").map((b) => b.textContent.trim())[0] ?? ""; + c.ok(!/default branch/i.test(ref), `the ref switcher should name the branch, got "${ref}"`); + }, + + // ── Organizations ──────────────────────────────────────────────────────── + "orgs-cards-not-clipped": (f) => { + const c = check(f); + const subs = $$(".gh-org-grid .row-meta-sub"); + c.ok(subs.length >= 2, "org repo cards render"); + for (const s of subs) { + c.ok( + s.scrollWidth <= s.clientWidth + 1, + `card meta is clipped: "${s.textContent.trim()}" (${s.scrollWidth} > ${s.clientWidth})`, + ); + } + }, + "orgs-header-order": (f) => { + const c = check(f); + const head = $(".gh-org-head"); + c.ok(!!head, "org header exists"); + const identity = $(".gh-org-identity"); + const desc = $(".gh-org-desc"); + const actions = $(".gh-org-head .gh-detail-actions"); + // The description belongs to the identity — it describes the org, not the + // buttons. This used to assert `desc.top < actions.bottom`, which only + // reads correctly in a COLUMN header, and the column header was itself the + // bug: `.gh-detail-head` sets flex-direction: column and `.gh-org-head` + // never reset it, so the avatar, name, description and buttons stacked + // into four rows with 891px of empty space beside them. Assert the two + // things that are actually true of a correct header instead. + c.ok(!!(desc && identity && identity.contains(desc)), "the description sits inside the identity block"); + if (desc && actions) { + const d = desc.getBoundingClientRect(); + const a = actions.getBoundingClientRect(); + c.ok(d.right <= a.left + 1, `the description does not run under the actions (${Math.round(d.right)} vs ${Math.round(a.left)})`); + } + if (head && actions) { + // Row, not column: the actions share the identity's first line. + c.ok( + actions.getBoundingClientRect().top - head.getBoundingClientRect().top < 24, + "the actions sit on the header's first line, beside the identity", + ); + } + }, + + // ── header controls hold their ground ─────────────────────────────────── + "actions-segment-does-not-slide": async (f) => { + const c = check(f); + const segNow = () => $(".gh-head-tools .gh-seg, .gh-head-tools .seg"); + const optNow = (label) => + $$(".gh-head-tools button").find((b) => (b.textContent || "").trim() === label); + c.ok(!!segNow(), "the Runs/Workflows segment renders in the tools row"); + c.ok($$(".gh-facet-btn").length >= 3, "the Runs tab carries its facet pills"); + if (!segNow()) return; + const before = segNow().getBoundingClientRect().left; + const wf = optNow("Workflows"); + c.ok(!!wf, "the Workflows option is a button"); + if (!wf) return; + wf.click(); + // renderActions re-renders behind an await, so measuring now would read + // the tab we just left. + await settle(); + const seg2 = segNow(); + c.ok(!!seg2, "the segment survives the tab switch"); + if (!seg2) return; + c.eq($$(".gh-facet-btn").length, 0, "Workflows drops the run facets"); + // …and the segment must not travel with them. + const after = seg2.getBoundingClientRect().left; + c.ok( + Math.abs(after - before) <= 2, + `the segment must stay put across tabs (moved ${Math.round(after - before)}px)`, + ); + }, + "facets-do-not-shunt-their-neighbours": async (f) => { + const c = check(f); + const tools = $(".gh-head-tools"); + const btns = $$(".gh-facet-btn"); + c.ok(!!tools && btns.length >= 2, `the facet bar renders (${btns.length} pills)`); + if (!tools || btns.length < 2) return; + const seg = () => $(".gh-head-tools .gh-seg, .gh-head-tools .seg"); + const prim = () => $(".gh-head-tools .btn-primary"); + // Offsets measured from the tools ROW, not the viewport: the row itself + // may shift when the title block's content changes (the count badge goes + // from "8" to "2 of 8"), and that is the count telling the truth. What + // must not happen is the row's own controls sliding past each other. + const snap = () => { + const t = tools.getBoundingClientRect(); + return { + row: t.left, + seg: seg() ? seg().getBoundingClientRect().left - t.left : null, + pill: $$(".gh-facet-btn")[0].getBoundingClientRect().left - t.left, + prim: prim() ? t.right - prim().getBoundingClientRect().right : null, + }; + }; + const before = snap(); + const author = btns.find((b) => (b.textContent || "").includes("Author")); + c.ok(!!author, "an Author facet exists"); + if (!author) return; + author.click(); + await settle(60); + const opt = $$(".dropdown-item").find((r) => (r.textContent || "").includes("mira-holt")); + c.ok(!!opt, "the menu lists an author"); + opt?.click(); + await settle(); + // Prove the click DID something before asserting what didn't move. + c.ok(!!$(".gh-facet-btn.is-active"), "the author facet reads as active"); + const after = snap(); + c.eq(Math.round(after.seg), Math.round(before.seg), "the state segment holds its place"); + c.eq(Math.round(after.pill), Math.round(before.pill), "the first pill holds its place"); + c.eq(Math.round(after.prim), Math.round(before.prim), "the primary action holds its place"); + // The row used to be one right-anchored cluster: a widened pill shoved + // everything left of it. A small shift from the count badge is fine; a + // hundred-pixel one is the old defect coming back. + c.ok( + Math.abs(after.row - before.row) <= 40, + `the row barely moves (${Math.round(after.row - before.row)}px)`, + ); + }, + "log-toolbar-toggles-are-labelled": (f) => { + const c = check(f); + const bar = $(".log-toolbar"); + c.ok(!!bar, "the log toolbar renders"); + if (!bar) return; + const labels = $$(".log-tool.has-label", bar).map((b) => b.textContent.trim()); + c.ok(labels.includes("Timestamps"), "the timestamps toggle is named"); + c.ok(labels.includes("Follow"), "the follow toggle is named"); + for (const b of $$(".log-tool.has-label", bar)) { + c.ok(b.hasAttribute("aria-pressed"), `${b.textContent.trim()} reports its state`); + } + // The transient verbs stay glyphs, split off by a rule. + c.ok(!!$(".log-toolbar-div", bar), "state and actions are visually separated"); + // The rule is about the ACTION cluster past the spring (copy / save / + // expand). Match stepping lives with the counter it steps through, on the + // search side, where a chevron pair beside "3 of 40" is self-evident. + const bare = $$(".log-tool", bar).filter( + (b) => !b.classList.contains("has-label") && !b.classList.contains("log-match-step"), + ); + c.ok(bare.length <= 3, `at most three unlabelled glyph verbs (${bare.length})`); + for (const b of bare) c.ok(!!b.title, "every glyph verb still carries a title"); + for (const b of $$(".log-match-step", bar)) { + c.ok(!!b.title && !!b.getAttribute("aria-label"), "match stepping is named"); + } + }, + + // ── toolbars survive narrow windows ────────────────────────────────────── + "toolbar-no-overflow": (f) => { + const c = check(f); + const head = $(".gh-head"); + c.ok(!!head, "header exists"); + if (!head) return; + const right = head.getBoundingClientRect().right; + for (const el of $$(".gh-head .gh-facet-btn, .gh-head .gh-search, .gh-head .btn")) { + const r = el.getBoundingClientRect(); + c.ok( + r.right <= right + 1, + `"${el.textContent.trim().slice(0, 20)}" overflows the header (${Math.round(r.right)} > ${Math.round(right)})`, + ); + } + }, + + // ── branches: divergence is ONE fact, not two designs ─────────────────── + "branch-divergence-paired": (f) => { + const c = check(f); + const row = $$(".branch-row, .list-row").find( + (r) => (r.textContent || "").includes("feat/line-staging"), + ); + c.ok(!!row, "the diverged branch row renders"); + if (!row) return; + const pills = [...row.querySelectorAll(".ab-pill")]; + c.eq(pills.length, 2, "ahead and behind are both shown"); + if (pills.length !== 2) return; + // Neither may be a button: a count that is secretly a one-click network + // action is the defect this pair replaced. + for (const p of pills) { + c.ok(p.tagName !== "BUTTON", `an ${p.className} count must not be a button`); + } + const [a, b] = pills.map((p) => p.getBoundingClientRect()); + c.eq(Math.round(a.height), Math.round(b.height), "the pair shares a height"); + + // And a branch whose UPSTREAM IS GONE must not read as in sync. Git + // reports `[gone]`, `parseTrack` threw it away, and the row then showed + // the same nothing a perfectly-synced branch shows — about a remote that + // no longer exists, which is what every merged pull request leaves. + const goneRow = $$(".branch-row, .list-row").find((r) => + (r.textContent || "").includes("redesign/wave-1"), + ); + c.ok(!!goneRow, "the fixture has a branch whose upstream was deleted"); + if (!goneRow) return; + const gonePill = goneRow.querySelector(".ab-pill.gone"); + c.ok(!!gonePill, "and the row says the upstream is gone"); + c.match(text(gonePill), /gone/i, "in words, not just a colour"); + c.match(gonePill?.title, /no longer exists|finished/i, "with what that means on hover"); + const sa = getComputedStyle(pills[0]), sb = getComputedStyle(pills[1]); + c.eq(sa.fontSize, sb.fontSize, "the pair shares a font size"); + c.eq(sa.borderRadius, sb.borderRadius, "the pair shares a corner radius"); + c.ok(b.left - a.right < 12, `the pair sits together (gap ${Math.round(b.left - a.right)}px)`); + // …and they form a COLUMN. + // + // They used to sit beside the branch name, which put them at a different + // x on every row and made the pair unreadable down a list. They live in a + // fixed-width meta slot now, so the arrows line up — and a check that + // silently skipped when it could not find the old element (`if (name)`) + // would have stopped testing anything at all when that moved. + const tracks = $$(".branch-row .br-track").filter((t) => t.children.length); + c.ok(tracks.length >= 2, `more than one row shows divergence (${tracks.length})`); + if (tracks.length >= 2) { + const rights = tracks.map((t) => Math.round(t.getBoundingClientRect().right)); + c.ok( + Math.max(...rights) - Math.min(...rights) <= 1, + `the pairs share a right edge, so they read as a column (${[...new Set(rights)].join(", ")})`, + ); + } + }, + "branch-pull-is-an-action": (f) => { + const c = check(f); + const row = $$(".branch-row, .list-row").find( + (r) => (r.textContent || "").includes("feat/line-staging"), + ); + if (!row) return check(f).ok(false, "the diverged branch row renders"); + // `.sec-row-actions` too — the ref manager's verbs live in the shared + // row's own action slot now, rendered at rest rather than on hover. The + // demand is unchanged: Pull is an ACTION on the row, not a passive count + // in the badge strip. + const acts = ".row-actions button, .sec-row-actions button"; + const pull = [...row.querySelectorAll(acts)].find( + (b) => (b.textContent || "").trim() === "Pull", + ); + c.ok(!!pull, "Pull is one of the row's actions, not a count in the badges"); + const clean = $$(".branch-row, .list-row").find( + (r) => (r.textContent || "").includes("redesign/issues-detail"), + ); + if (clean) { + c.ok( + ![...clean.querySelectorAll(acts)].some( + (b) => (b.textContent || "").trim() === "Pull", + ), + "an up-to-date branch offers no Pull", + ); + } + }, + + // ── organizations: people look like people ────────────────────────────── + "org-members-are-people": (f) => { + const c = check(f); + const rows = $$(".gh-org-member"); + c.ok(rows.length >= 3, `members render (${rows.length})`); + for (const r of rows) { + const who = (r.textContent || "").trim().slice(0, 20); + c.ok( + !r.querySelector(".gh-avatar-fallback"), + `${who} must not fall back to the organization glyph`, + ); + c.ok(!!r.querySelector(".av"), `${who} has a person avatar`); + } + // Distinct people get distinct fallback hues, so a directory of + // avatarless members is still scannable. + const hues = new Set( + $$(".gh-org-member .av-fallback").map((a) => getComputedStyle(a).backgroundColor), + ); + c.ok(hues.size > 1 || hues.size === 0, "fallback avatars are not all one colour"); + }, + "org-cards-fill-their-row": (f) => { + const c = check(f); + const grid = $(".gh-org-grid"); + const cards = $$(".gh-org-grid > .list-row"); + c.ok(!!grid && cards.length > 0, "the grid renders cards"); + if (!grid || !cards.length) return; + const g = grid.getBoundingClientRect(); + // One team must not huddle in a 330px column beside 1200px of nothing. + const widest = Math.max(...cards.map((k) => k.getBoundingClientRect().width)); + c.ok( + widest >= g.width * 0.9, + `a lone card should span the row (${Math.round(widest)} of ${Math.round(g.width)}px)`, + ); + }, + "org-people-are-chips": (f) => { + const c = check(f); + const grid = $(".gh-org-grid"); + const cards = $$(".gh-org-member"); + if (!grid || !cards.length) return c.ok(false, "member chips render"); + const g = grid.getBoundingClientRect(); + for (const k of cards) { + const w = k.getBoundingClientRect().width; + c.ok(w <= 260, `a member chip stays compact (${Math.round(w)}px)`); + } + // …and they wrap from the left edge, sharing it with every other list. + c.eq( + Math.round(cards[0].getBoundingClientRect().left), + Math.round(g.left), + "the first chip starts at the grid's left edge", + ); + }, + + // ── the bottom dock: one shell for every tab ──────────────────────────── + "dock-tabs-share-a-content-origin": async (f) => { + const c = check(f); + const tabs = $$(".term-tab"); + const out = tabs.find((t) => (t.textContent || "").includes("Output")); + const term = tabs.find((t) => (t.textContent || "").includes("Terminal")); + c.ok(!!out && !!term, "the dock offers Output and Terminal"); + if (!out || !term) return; + out.click(); + await settle(60); + const outTop = $(".outputs-panel")?.getBoundingClientRect().top; + term.click(); + await settle(60); + const termTop = $(".term-group")?.getBoundingClientRect().top; + c.ok(outTop != null && termTop != null, "both surfaces measure"); + if (outTop == null || termTop == null) return; + // Output used to carry a 32px bar of its own, so the dock's content + // origin slid down as you switched to it. + c.ok( + Math.abs(outTop - termTop) <= 1, + `switching tabs must not move the content origin (${Math.round(outTop)} vs ${Math.round(termTop)})`, + ); + }, + "dock-empty-log-offers-nothing-inert": async (f) => { + const c = check(f); + const out = $$(".term-tab").find((t) => (t.textContent || "").includes("Output")); + c.ok(!!out, "the Output tab exists"); + out?.click(); + await settle(60); + c.ok(!!$(".outputs-empty"), "the empty log explains itself"); + // No "0 commands", and no filter/clear for a log with nothing in it. + c.eq(($(".outputs-count")?.textContent || "").trim(), "", "no count of nothing"); + for (const b of $$(".outputs-bar .mini-btn")) { + c.ok( + b.hidden || b.getBoundingClientRect().width === 0, + `"${b.textContent.trim()}" must not be offered on an empty log`, + ); + } + }, + "rail-icons-are-distinguishable": (f) => { + const c = check(f); + const items = $$(".nav-item, .rail-item, .side-item").filter((n) => + n.querySelector(".codicon"), + ); + c.ok(items.length >= 8, `the rail renders (${items.length} items)`); + const seen = new Map(); + for (const n of items) { + const g = n.querySelector(".codicon"); + const name = [...g.classList].find((k) => k.startsWith("codicon-")); + const label = (n.textContent || "").trim(); + if (seen.has(name)) c.ok(false, `${label} reuses ${name} (also ${seen.get(name)})`); + seen.set(name, label); + } + // The fork motif is fine on the entries that own it, and nowhere else. + const forks = items.filter((n) => { + const g = n.querySelector(".codicon"); + return ["codicon-source-control", "codicon-git-merge", "codicon-git-fork"].some((k) => + g.classList.contains(k), + ); + }); + c.eq(forks.length, 0, "no rail entry wears a borrowed fork glyph"); + }, + + // ── overlays: one form shape ──────────────────────────────────────────── + "clone-form-one-field-shape": (f) => { + const c = check(f); + const fields = $$(".clone-card .clone-field").filter((n) => n.offsetParent !== null); + c.ok(fields.length >= 3, `the clone form renders its fields (${fields.length})`); + if (fields.length < 3) return; + const lefts = new Set(); + const gaps = new Set(); + for (const fl of fields) { + const cap = fl.querySelector(".clone-field-label"); + const ctrl = fl.children[1]; + const name = (cap?.textContent || "?").trim(); + c.ok(!!cap, `${name} has a caption`); + c.ok(!!ctrl, `${name} has a control`); + if (!cap || !ctrl) continue; + const cr = cap.getBoundingClientRect(), tr = ctrl.getBoundingClientRect(); + lefts.add(Math.round(cr.left)); + lefts.add(Math.round(tr.left)); + gaps.add(Math.round(tr.top - cr.bottom)); + // The caption is ABOVE its control in every field — one row used to + // put the label to the LEFT of its value, beside a button. + c.ok(cr.bottom <= tr.top + 1, `${name}'s caption sits above its control`); + const st = getComputedStyle(cap); + c.eq(st.textTransform, "uppercase", `${name}'s caption uses the one caption style`); + } + c.eq(lefts.size, 1, `every caption and control shares one left edge (${[...lefts].join(", ")})`); + c.eq(gaps.size, 1, `every caption sits the same distance above its control (${[...gaps].join(", ")})`); + // …and no field is a bordered card holding another bordered box. + for (const fl of fields) { + c.eq( + getComputedStyle(fl).borderTopWidth, + "0px", + "a field is not a card wrapped around its own control", + ); + } + }, + + // ── empty states answer where the question was asked ──────────────────── + "search-empty-sits-with-the-search": (f) => { + const c = check(f); + const empty = $(".list-empty.is-inline"); + const search = $(".ex-search input, .gh-search input, input[type='text']"); + c.ok(!!empty, "the no-results state renders inline, not as a centred hero"); + c.ok(!!search, "the search box is on screen"); + if (!empty || !search) return; + const e = empty.getBoundingClientRect(), s2 = search.getBoundingClientRect(); + const title = empty.querySelector(".list-empty-title"); + const t = (title || empty).getBoundingClientRect(); + // It used to be centred: ~600px right of the box you typed in and ~290px + // below it. + c.ok( + Math.abs(t.left - s2.left) <= 24, + `it lines up with the search box (${Math.round(t.left - s2.left)}px off)`, + ); + c.ok( + t.top - s2.bottom <= 200, + `it sits near the control that emptied the list (${Math.round(t.top - s2.bottom)}px below)`, + ); + c.ok(!empty.querySelector(".list-empty-badge")?.offsetParent, "no hero badge inline"); + c.eq(getComputedStyle(empty).textAlign, "left", "inline copy reads left-aligned"); + }, + + // ── menus: the keyboard ring must be visible ──────────────────────────── + "menu-focus-ring-is-not-clipped": async (f) => { + const c = check(f); + const menu = $(".dropdown"); + c.ok(!!menu, "a menu is open"); + if (!menu) return; + // Arrow down so focus arrives from the keyboard — :focus-visible (which + // is what paints the ring) only applies then. + document.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true }), + ); + await settle(60); + const item = document.activeElement; + c.ok(!!item && item.classList.contains("dropdown-item"), "an item takes keyboard focus"); + if (!item || !item.classList.contains("dropdown-item")) return; + const st = getComputedStyle(item); + const w = parseFloat(st.outlineWidth) || 0; + const off = parseFloat(st.outlineOffset) || 0; + c.ok(w > 0, `the focused item paints a ring (outline-width ${st.outlineWidth})`); + // The APP's ring, not Chrome's. A menu row is tabindex="-1" (the bar has + // one roving tab stop), and the rule that strips the ring from + // tabindex="-1" LANDING targets must not reach a row you actually + // operate. Chrome's default paints `outline-style: auto`. + c.ok( + st.outlineStyle === "solid", + `and it is the app's ring, not Chrome's default (outline-style ${st.outlineStyle})`, + ); + const reach = w + Math.max(0, off); + const i = item.getBoundingClientRect(); + // The scrollport clips at the menu's PADDING box, so the ring has to fit + // inside the padding on every side it can reach. + const ms = getComputedStyle(menu); + const m = menu.getBoundingClientRect(); + const pad = { + l: parseFloat(ms.paddingLeft) || 0, + r: parseFloat(ms.paddingRight) || 0, + t: parseFloat(ms.paddingTop) || 0, + b: parseFloat(ms.paddingBottom) || 0, + }; + const bw = parseFloat(ms.borderLeftWidth) || 0; + c.ok( + i.left - reach >= m.left + bw - 0.5, + `the ring's left edge is inside the menu (needs ${reach}px, has ${Math.round(i.left - m.left - bw)}px)`, + ); + c.ok( + i.right + reach <= m.right - bw + 0.5, + `the ring's right edge is inside the menu (needs ${reach}px, has ${Math.round(m.right - bw - i.right)}px)`, + ); + const first = $$(".dropdown-item", menu)[0]; + if (first === item) { + c.ok( + reach <= pad.t + 0.5, + `the first item's ring fits above it (needs ${reach}px, padding is ${pad.t}px)`, + ); + } + void pad.b; + }, + + // ── rebase: one plan, one set of columns ──────────────────────────────── + "rebase-rows-share-their-columns": (f) => { + const c = check(f); + const rows = $$(".rb-row"); + c.ok(rows.length >= 3, `the plan renders (${rows.length} rows)`); + const base = $(".rb-row.rb-base"); + c.ok(!!base, "the anchor row renders"); + if (!base || rows.length < 3) return; + const subjLefts = new Set( + rows.map((r) => Math.round(r.querySelector(".rb-subj").getBoundingClientRect().left)), + ); + c.eq(subjLefts.size, 1, `every subject shares a left edge (${[...subjLefts].join(", ")})`); + // The ONTO badge stands in the action select's column, same width. + const onto = base.querySelector(".rb-onto").getBoundingClientRect(); + const act = rows[0].querySelector(".rb-action").getBoundingClientRect(); + c.eq(Math.round(onto.left), Math.round(act.left), "the anchor badge uses the action column"); + c.ok( + Math.abs(onto.width - act.width) <= 24, + `and roughly its width (${Math.round(onto.width)} vs ${Math.round(act.width)})`, + ); + }, + "rebase-legend-does-not-wrap": (f) => { + const c = check(f); + const items = $$(".rb-gloss > span"); + c.ok(items.length >= 5, `the legend explains every action (${items.length})`); + if (items.length < 5) return; + const hs = new Set(items.map((i) => Math.round(i.getBoundingClientRect().height))); + c.eq(hs.size, 1, `no gloss wraps to a second line (heights ${[...hs].join(", ")})`); + }, + // The view says what the button says. + "rebase-names-its-action-once": (f) => { + const c = check(f); + const btn = $$("button").find((b) => /start rebase/i.test(b.textContent || "")); + c.ok(!!btn, "the start button exists"); + if (!btn) return; + const label = btn.textContent.trim(); + const lead = ($(".rb-explain-lead")?.textContent || "").trim(); + c.ok( + !lead || lead.includes(label), + `the explainer must name the button exactly ("${label}" not found)`, + ); + }, + + // ── compare: paths keep the part that identifies them ─────────────────── + "compare-file-rows-name-first": (f) => { + const c = check(f); + const rows = $$(".cmp-file-scroll .file-row"); + c.ok(rows.length >= 4, `the changed-file list renders (${rows.length})`); + if (rows.length < 4) return; + const lefts = new Set(); + for (const r of rows) { + const name = r.querySelector(".dc-file-name"); + c.ok(!!name, "each row leads with the file name"); + if (!name) continue; + lefts.add(Math.round(name.getBoundingClientRect().left)); + // The name is the part that tells two files apart, so it never clips. + c.ok( + name.scrollWidth <= name.clientWidth + 1, + `"${name.textContent}" is not truncated (${name.scrollWidth} > ${name.clientWidth})`, + ); + c.ok(!!r.title && r.title.includes("/"), "the full path stays available as a title"); + } + c.eq(lefts.size, 1, `names share a left edge (${[...lefts].join(", ")})`); + }, + "compare-counts-are-filled": (f) => { + const c = check(f); + const tabs = $$(".cmp-seg-btn"); + c.eq(tabs.length, 2, "the view toggle offers Commits and Changed files"); + for (const t of tabs) { + const n = t.querySelector(".cmp-seg-count"); + const label = (t.textContent || "").replace(/\d+/g, "").trim(); + // An empty count badge is a box that says nothing. + c.ok(!!n && /\d/.test(n.textContent || ""), `"${label}" carries its count`); + } + c.ok(!!$(".cmp-seg-btn.active"), "one tab reads as active"); + }, + + // ── project board: width goes where the work is ───────────────────────── + "board-empty-column-yields-its-width": (f) => { + const c = check(f); + const cols = $$(".gh-col"); + c.ok(cols.length >= 3, `the board renders columns (${cols.length})`); + const empty = $$(".gh-col.is-empty"); + const full = cols.filter((k) => !k.classList.contains("is-empty")); + c.ok(empty.length >= 1 && full.length >= 2, "the fixture has both empty and filled columns"); + if (!empty.length || !full.length) return; + const ew = empty[0].getBoundingClientRect().width; + const fw = Math.min(...full.map((k) => k.getBoundingClientRect().width)); + c.ok(ew < fw, `an empty column is narrower than a filled one (${Math.round(ew)} vs ${Math.round(fw)})`); + // …but it is still a drop target, so it keeps a body and a placeholder. + c.ok(!!empty[0].querySelector(".gh-col-empty"), "the empty column keeps its drop zone"); + // Column names read like the rest of the app. + for (const n of $$(".gh-col-name")) { + const t = n.textContent.trim(); + c.ok( + !/^[A-Z][a-z]+ [A-Z][a-z]/.test(t), + `"${t}" should be sentence case like every other label`, + ); + } + }, + + // ── explore: people read as people, hits read as one hit ──────────────── + "explore-people-are-a-directory": (f) => { + const c = check(f); + const list = $(".sec-list"); + const rows = $$(".explore-person-row"); + c.ok(!!list && rows.length >= 3, `people results render (${rows.length})`); + if (!list || !rows.length) return; + const lw = list.getBoundingClientRect().width; + for (const r of rows) { + const w = r.getBoundingClientRect().width; + // A 40px row holding one login across a 1350px pane is ~93% empty. + c.ok(w <= Math.max(280, lw * 0.4), `a person chip stays compact (${Math.round(w)}px)`); + } + // Several fit on one line — that is what makes it a directory. + const tops = new Set(rows.map((r) => Math.round(r.getBoundingClientRect().top))); + c.ok(tops.size < rows.length, "chips share rows instead of stacking one per line"); + const foot = $(".explore-footer-note"); + if (foot) { + c.ok( + Math.abs(foot.getBoundingClientRect().left - rows[0].getBoundingClientRect().left) <= 24, + "the match count lines up with the results it counts", + ); + } + }, + "explore-code-hit-is-one-block": (f) => { + const c = check(f); + const rows = $$(".explore-code-row"); + c.ok(rows.length >= 1, `code results render (${rows.length})`); + if (!rows.length) return; + for (const r of rows) { + const pres = $$(".explore-code-frag", r); + // One hit, one block: three bordered boxes read as three hits. + c.eq(pres.length, 1, "each hit shows a single code block"); + } + const multi = rows.find((r) => $$(".explore-code-line", r).length > 1); + if (multi) { + c.ok( + !!multi.querySelector(".explore-code-gap"), + "non-adjacent fragments are separated the way a diff separates hunks", + ); + } + }, + + // ── commits: the CHANGES column is comparable ─────────────────────────── + "graph-change-bars-share-a-left-edge": (f) => { + const c = check(f); + // The graph is a Lit custom element; its rows live in a shadow root. + const host = $("gitstudio-graph"); + c.ok(!!host, "the graph element is mounted"); + const root = host?.shadowRoot; + if (!root) return; + const counts = [...root.querySelectorAll(".changes .ch-count")]; + const bars = [...root.querySelectorAll(".changes .ch-bar")]; + c.ok(counts.length >= 5, `the CHANGES column carries data (${counts.length} rows)`); + if (counts.length < 5) return; + // Counts run 1 → 17; left-aligned they stepped every bar right, so a + // column of proportion meters could not be compared down the page. + const lefts = new Set(bars.map((b) => Math.round(b.getBoundingClientRect().left))); + c.eq(lefts.size, 1, `every bar starts on one x (${[...lefts].join(", ")})`); + const rights = new Set(counts.map((n) => Math.round(n.getBoundingClientRect().right))); + c.eq(rights.size, 1, `every count ends on one x (${[...rights].join(", ")})`); + }, + + // ── the rail keeps its groups when it loses its words ─────────────────── + "rail-groups-survive-collapse": (f) => { + const c = check(f); + const rail = $(".nav-rail"); + c.ok(!!rail && rail.classList.contains("collapsed"), "the rail is collapsed to icons"); + if (!rail) return; + const seps = $$(".nav-divider", rail); + c.ok(seps.length >= 2, `the three groups are still separated (${seps.length} rules)`); + for (const sep of seps) { + const after = getComputedStyle(sep, "::after"); + // Hiding the label AND the rule left nothing but a slightly bigger gap. + c.ok(after.display !== "none", "a collapsed divider still draws its rule"); + c.ok(!!sep.title, "and names its group on hover"); + } + // …and the icons are distinguishable, which is the other half of reading + // fifteen destinations as icons alone. + const names = $$(".nav-item .codicon", rail).map( + (g) => [...g.classList].find((k) => k.startsWith("codicon-")), + ); + c.eq(new Set(names).size, names.length, "no two rail icons are the same glyph"); + }, + + // ── the Status filter shows the states it filters by ──────────────────── + "status-facet-shows-its-states": (f) => { + const c = check(f); + const items = $$(".dropdown-item"); + c.ok(items.length >= 5, `the Status menu opened (${items.length} items)`); + const named = items.filter((i) => /Success|Failure|In progress|Queued|Cancelled/.test(i.textContent || "")); + c.eq(named.length, 5, "all five states are listed"); + const hues = new Set(); + for (const i of named) { + const lead = i.querySelector(".run-lead .codicon, .run-lead"); + const label = (i.textContent || "").trim(); + c.ok(!!lead, `"${label}" carries the same lead icon the rows use`); + if (lead) hues.add(getComputedStyle(lead).color); + } + // Success green, failure red, running blue — grey for all five would mean + // the menu had been repainted by the generic muted-glyph rule. + c.ok(hues.size >= 3, `the states keep their colours (${hues.size} distinct)`); + const lefts = new Set(named.map((i) => Math.round(i.querySelector(".dropdown-label").getBoundingClientRect().left))); + c.eq(lefts.size, 1, `the labels form one column (${[...lefts].join(", ")})`); + }, + + // ── the palette's hint column says something new ──────────────────────── + "palette-hints-are-not-echoes": (f) => { + const c = check(f); + const rows = $$(".cmdk-row"); + c.ok(rows.length >= 5, `the palette lists results (${rows.length})`); + // A hint that restates its own group header is noise: "branch" three + // times under BRANCHES & TAGS, "view" six times under GO TO. + let group = ""; + for (const n of $(".cmdk-list").children) { + if (n.classList.contains("cmdk-group")) { group = n.textContent.trim().toLowerCase(); continue; } + const hint = (n.querySelector(".cmdk-hint")?.textContent || "").trim().toLowerCase(); + if (!hint) continue; + c.ok( + !group.includes(hint), + `"${hint}" just repeats its group header (${group})`, + ); + } + // The list fades rather than slicing its last row in half. + const list = $(".cmdk-list"); + const scrolls = list.scrollHeight > list.clientHeight + 1; + const masked = getComputedStyle(list).webkitMaskImage !== "none"; + c.eq(masked, scrolls, scrolls ? "a scrollable list fades its edge" : "a short list must not fade"); + }, + + // ── a peek is about its subject, not its buttons ──────────────────────── + "peek-identity-gets-room": (f) => { + const c = check(f); + const head = $(".peek-head"); + c.ok(!!head, "a peek is open"); + if (!head) return; + const id = head.querySelector(".peek-titlewrap"); + const acts = head.querySelector(".peek-actions"); + c.ok(!!id && !!acts, "the header has an identity and an action cluster"); + if (!id || !acts) return; + const i = id.getBoundingClientRect(), a = acts.getBoundingClientRect(); + // Three buttons plus a close X used to take ~540px of a 700px card. + c.ok( + i.width >= 260, + `the identity keeps a floor (${Math.round(i.width)}px beside ${Math.round(a.width)}px of actions)`, + ); + const title = head.querySelector(".peek-title"); + if (title) { + c.ok( + title.scrollWidth <= title.clientWidth + 1, + `"${title.textContent}" is not truncated by its own buttons`, + ); + } + // The face is the subject's, not a generic account glyph. + c.ok( + !!head.querySelector(".av"), + "the peek shows the same avatar as the row that opened it", + ); + }, + + // ── the composer's amend state is one state ───────────────────────────── + "amend-prefill-enables-committing": (f) => { + const c = check(f); + const msg = $(".dc-message"); + const commit = $(".dc-commit"); + const push = $(".dc-push"); + c.ok(!!msg && !!commit, "the composer renders"); + if (!msg || !commit) return; + // Ticking Amend prefills the previous message. Assigning `.value` fires + // no input event, so the buttons stayed greyed out telling you to write + // a message that was already sitting in front of you. + c.ok(msg.value.trim().length > 0, "amending an empty composer prefills the last message"); + c.eq(text(".dc-commit-label"), "Amend commit", "and the button says what it will do"); + c.eq(commit.disabled, false, "Commit is available"); + if (push) c.eq(push.disabled, false, "and so is Commit & Push — both hang off the same sync"); + c.eq(commit.title, "", "with no stale 'write a message first' tooltip"); + }, + "amend-off-restores-the-composer": async (f) => { + const c = check(f); + const toggle = $$(".dc-toggle").find((b) => /Amend/.test(b.textContent || "")); + const msg = $(".dc-message"); + c.ok(!!toggle && !!msg, "the composer renders"); + if (!toggle || !msg) return; + const startLabel = text(".dc-commit-label"); + c.match(startLabel, /^Commit to /, `it starts naming the branch ("${startLabel}")`); + toggle.click(); + await settle(800); + c.eq(text(".dc-commit-label"), "Amend commit", "ticking Amend relabels"); + c.ok(msg.value.trim().length > 0, "and prefills the last message"); + toggle.click(); + await settle(600); + // Two separate bugs met here. The label was written from a `curBranch` + // captured before HEAD resolved, so un-ticking produced a bare "Commit" + // beside a branch line still reading "main". And the prefill was never + // withdrawn, leaving the LAST COMMIT'S text in the box with amend off — + // a fully armed button about to create a new commit carrying the + // previous one's exact message, indistinguishable from something typed. + c.eq(text(".dc-commit-label"), startLabel, "un-ticking restores the branch name"); + c.eq(msg.value, "", "and takes the prefilled message back"); + c.eq($(".dc-commit").disabled, true, "so committing is unarmed again"); + }, + "amend-survives-a-repaint": (f) => { + const c = check(f); + // Staging a file re-runs showChangesView(), which rebuilds this subtree. + // The label used to be rewritten unconditionally afterwards, so the + // toggle stayed lit while the button read "Commit to main" — and the + // click still sent amend:true. The button promised a new commit and + // rewrote the last one. + const toggle = $(".dc-toggle"); + c.ok(!!toggle, "the amend toggle renders"); + c.eq(toggle?.classList.contains("is-on"), true, "amend is still on after the repaint"); + c.eq( + text(".dc-commit-label"), + "Amend commit", + "so the button must still say Amend — a label that disagrees with the flag rewrites history silently", + ); + c.ok(($(".dc-message")?.value || "").trim().length > 0, "and the prefilled message survived"); + }, + + // ── nothing in this app carries an inline event handler ───────────────── + "no-inline-event-handlers-anywhere": (f) => { + const c = check(f); + // The app wires everything with addEventListener, so an `onclick=` in the + // DOM means one of two things, both bad: an innerHTML template that + // interpolated something, or markup that reached the DOM without passing + // the sanitizer. The markdown sanitizer had exactly that hole. + const bad = []; + for (const el of $$("*")) { + for (const a of el.attributes) { + if (/^on[a-z]+$/i.test(a.name)) bad.push(`${el.tagName.toLowerCase()}[${a.name}]`); + } + } + c.eq(bad.length, 0, `inline handlers found: ${bad.slice(0, 6).join(", ")}`); + }, + + // ── route churn must not accumulate DOM ───────────────────────────────── + "route-churn-leaks-nothing": async (f) => { + const c = check(f); + const nav = (name) => $$(".nav-item").find((n) => (n.textContent || "").trim() === name); + const views = ["Issues", "Actions", "Code"]; + c.ok(views.every((v) => !!nav(v)), "the rail offers the views this walks"); + const perRound = []; + for (let round = 0; round < 3; round++) { + const counts = {}; + for (const v of views) { + nav(v)?.click(); + await settle(200); + counts[v] = document.querySelectorAll("*").length; + } + perRound.push(counts); + } + // A view rendered for the third time must weigh exactly what it did the + // first time. Anything else is a node the route change did not take away. + for (const v of views) { + const sizes = [...new Set(perRound.map((r) => r[v]))]; + c.eq(sizes.length, 1, `${v} renders the same DOM every time (saw ${sizes.join(", ")})`); + } + }, + + // ── a narrow window loses room, never controls ────────────────────────── + "nothing-runs-off-the-window": (f) => { + const c = check(f); + // Run narrow (see check.mjs). Below ~1005px the Changes toolbar simply + // rendered past the window edge — no scrollbar, no overflow menu — so + // "Stage all" and "Stash", the view's primary actions, were unreachable. + const off = $$("button, .gh-search, .gh-facet-btn, .gh-seg, .cmp-seg") + .filter((n) => { + const r = n.getBoundingClientRect(); + return r.width > 0 && (r.right > innerWidth + 1 || r.left < -1); + }) + .map((n) => `${(n.textContent || "").trim().slice(0, 18) || n.className}@${Math.round(n.getBoundingClientRect().right)}`); + c.eq(off.length, 0, `off-screen controls at ${innerWidth}px: ${off.slice(0, 6).join(", ")}`); + c.ok( + document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1, + `the page must not scroll sideways (${document.documentElement.scrollWidth} > ${document.documentElement.clientWidth})`, + ); + }, + "segmented-controls-never-clip": (f) => { + const c = check(f); + const segs = $$(".gh-seg, .cmp-seg"); + c.ok(segs.length > 0, "the view has a segmented control"); + for (const s of segs) { + // These are `overflow: hidden`, so shrinking does not compress the + // options — it deletes them. The Inbox's "All" was not painted at all + // below 1280px, leaving no way off the Unread filter. + c.ok( + s.scrollWidth <= s.clientWidth + 1, + `a segmented control is clipped (${s.scrollWidth} into ${s.clientWidth}) — an option is unreachable`, + ); + } + }, + "a-row-keeps-its-name-before-its-badges": (f) => { + const c = check(f); + const rows = $$(".sec-row").filter((r) => r.querySelector(".gh-state-pill")); + c.ok(rows.length > 0, "a row with badges renders"); + for (const r of rows) { + const t = r.querySelector(".sec-row-title"); + if (!t) continue; + const w = t.getBoundingClientRect().width; + // A release collapsed to "D…" beside full-size Draft and Pre-release + // pills: the row stopped saying which release it was. + c.ok(w >= 50, `"${(t.textContent || "").slice(0, 20)}" shrank to ${Math.round(w)}px`); + } + }, + "status-pills-never-wrap": (f) => { + const c = check(f); + const pills = $$(".gh-pill, .gh-state-pill"); + c.ok(pills.length > 0, "the view shows pills"); + for (const p of pills) { + const r = p.getBoundingClientRect(); + // "attempt 2" broke between the word and the number and doubled its + // row's height. A status chip shrinks or truncates; it never wraps. + c.ok(r.height <= 24, `"${(p.textContent || "").trim().slice(0, 16)}" is ${Math.round(r.height)}px tall — it wrapped`); + } + }, + "graph-details-opens-at-its-intended-width": async (f) => { + const c = check(f); + await settle(400); + const d = $(".graph-details"); + c.ok(!!d, "the details column renders"); + if (!d) return; + // It clamped its own default away against a container that had not been + // laid out yet, then could only ever shrink — so it opened at its 320px + // floor every time, and a width you dragged to never came back. + const w = Math.round(d.getBoundingClientRect().width); + c.ok(w > 320, `the column opens at its default, not its floor (got ${w}px)`); + }, + + // ── the keyboard follows you ──────────────────────────────────────────── + "focus-follows-you-into-a-detail-and-back": async (f) => { + const c = check(f); + const rows = $$(".sec-row[data-num]"); + c.ok(rows.length >= 3, `the list renders rows (${rows.length})`); + if (rows.length < 3) return; + rows[2].focus(); + const opened = document.activeElement?.getAttribute("data-num"); + c.ok(!!opened, "a row can take focus"); + rows[2].click(); + // Wait for the page rather than guessing: a PR detail loads more than an + // issue and a fixed delay made this pass or fail on timing. + for (let i = 0; i < 80 && !$(".det-view"); i++) await settle(50); + // …and then for focus to actually move: `focusNewPage` waits for the page + // to be attached AND titled, which on a loaded machine takes longer than + // any fixed delay would guess. + for (let i = 0; i < 60; i++) { + const a = document.activeElement; + if (a && a !== document.body && a.closest?.(".det-view")) break; + await settle(50); + } + // Arrive: the keyboard belongs to the page that just replaced the list. + // It used to land on <body>, so the next Tab started above the nav rail. + const active = document.activeElement; + c.ok(!!active && active !== document.body, "focus is not on <body> after opening"); + c.ok( + !!active?.closest?.(".det-view"), + `focus is inside the detail page (was ${active?.tagName}.${String(active?.className).slice(0, 30)})`, + ); + document.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }), + ); + for (let i = 0; i < 40 && !$(".sec-row[data-num]"); i++) await settle(50); + await settle(400); + // Return: on the row you opened, not nowhere — so arrowing continues + // from where you were reading instead of from the top. + c.ok($$(".sec-row").length > 0, "the list came back"); + // Give the armed restore a moment: it polls for the row on animation + // frames, and the list it is waiting for renders asynchronously. + for (let i = 0; i < 30; i++) { + if (document.activeElement?.getAttribute("data-num") === opened) break; + await settle(50); + } + const back = document.activeElement; + c.eq( + back?.getAttribute("data-num"), + opened, + `focus returns to the row you opened (landed on ${back?.tagName}.${String(back?.className).slice(0, 30)})`, + ); + }, + + // ── nothing interactive nests inside anything interactive ─────────────── + "no-nested-interactive-elements": (f) => { + const c = check(f); + // A real <button> or <a> containing another interactive element is + // invalid HTML with real consequences: the outer element's accessible + // name swallows the inner one, assistive tech cannot reach the inner + // control, and one click can dispatch on both. + // + // Scoped deliberately to REAL elements. The app also has a + // `.list-row.is-clickable[role=button]` pattern — a clickable row with a + // hover action cluster inside — which is non-conformant ARIA but is a + // considered, guarded convention here (every inner handler stops + // propagation) and is used by most list surfaces. Flagging it would + // demand a redesign of every list, not a bug fix; the unambiguous case + // is the one that gets caught. + const bad = []; + for (const outer of $$("button, a[href]")) { + for (const inner of $$('button, [role="button"], a[href], input, select, textarea', outer)) { + if (inner === outer) continue; + bad.push( + `${outer.tagName.toLowerCase()}.${String(outer.className).split(" ")[0]} > ${inner.tagName.toLowerCase()}.${String(inner.className).split(" ")[0]}`, + ); + } + } + c.eq(bad.length, 0, `nested interactives: ${[...new Set(bad)].slice(0, 5).join(", ")}`); + }, + + // ── a page fits the window it is drawn in ─────────────────────────────── + "pr-files-fits-the-window": async (f) => { + const c = check(f); + await settle(1200); + const sc = $(".det-scroll"); + c.ok(!!sc, "the detail scroller exists"); + if (!sc) return; + // The column used to grow to its content (1787px into an 804px port) + // inside a container whose overflow is hidden — no scrollbar, no wheel. + // Everything below the diff was permanently unreachable. + c.ok( + sc.scrollHeight <= sc.clientHeight + 2, + `nothing is clipped away (${sc.scrollHeight} into ${sc.clientHeight})`, + ); + const threads = $(".pr-threads"); + if (threads) { + const t = threads.getBoundingClientRect(); + c.ok(t.top < innerHeight, `the review panel is on screen (top ${Math.round(t.top)} of ${innerHeight})`); + } + const list = $(".pr-files-list"); + if (list) { + c.ok( + list.getBoundingClientRect().bottom <= innerHeight + 2, + "and the file list ends inside the window, so its own scrollbar works", + ); + } + }, + + // ── native controls follow the app's theme, not the OS ────────────────── + "native-controls-follow-the-theme": (f) => { + const c = check(f); + const scheme = getComputedStyle(document.body).colorScheme; + // Without this a checkbox rendered in the OS palette: a solid white block + // on a near-black card, with UNCHECKED reading brighter than checked. + c.ok( + scheme === "dark" || scheme === "light", + `the body declares a single color-scheme (got "${scheme}")`, + ); + for (const input of $$('input[type="checkbox"], input[type="radio"]')) { + const s = getComputedStyle(input).colorScheme; + if (s === "normal" || getComputedStyle(input).appearance === "none") continue; + c.eq(s, scheme, "a native control inherits the app's scheme"); + } + }, + + // ── hover actions can actually be revealed ────────────────────────────── + "hover-actions-are-reachable": async (f) => { + const c = check(f); + const acts = $$(".row-actions").filter((a) => a.querySelector("button")); + c.ok(acts.length > 0, "the view has hover actions"); + for (const a of acts.slice(0, 4)) { + const row = a.parentElement; + const btn = a.querySelector("button"); + if (!btn || !row) continue; + // Transitions do not advance under a virtual-time budget, so read the + // resolved value rather than an interpolated one. + a.style.transition = "none"; + btn.focus(); + await settle(60); + const o = Number(getComputedStyle(a).opacity); + c.ok( + o > 0.9, + `focusing "${btn.textContent.trim().slice(0, 16)}" must reveal its row's actions (opacity ${o}) — invisible controls that still take clicks and Tab stops`, + ); + } + }, + + // ── a control that reads like a door opens one ────────────────────────── + "identity-chips-are-not-dead": async (f) => { + const c = check(f); + const chip = $(".det-person"); + c.ok(!!chip, "the rail shows an identity chip"); + if (!chip) return; + c.eq(chip.tagName, "BUTTON", "it is a button"); + c.ok(/open|explore|profile/i.test(chip.title || ""), `its tooltip promises navigation ("${chip.title}")`); + const before = text(".det-crumb") + "|" + (location.hash || ""); + chip.click(); + await settle(900); + const after = text(".det-crumb") + "|" + (location.hash || ""); + // It had a pointer cursor and a tooltip saying it would open the account, + // and clicking it did nothing at all. + c.ok(before !== after, `clicking it navigates (crumb stayed "${before}")`); + }, + + // ── the branch switcher switches branches ─────────────────────────────── + "branch-switcher-checks-out": async (f) => { + const c = check(f); + const items = $$(".dropdown-item"); + c.ok(items.length >= 3, `the branch menu opened (${items.length} rows)`); + if (items.length < 3) return; + // Every row used to call revealInGraph — so clicking a branch under a + // chip whose tooltip reads "switch branch" left you on the branch you + // were on and dropped you in the Commits view instead. The app's most + // load-bearing control did something other than its name, every time. + const other = items.find( + (i) => /fix\/log-stream/.test(i.textContent || "") && !i.classList.contains("is-current"), + ); + c.ok(!!other, "a branch other than the current one is listed"); + if (!other) return; + c.match(other.title || "", /check out/i, "the row says it will check out"); + const activeBefore = text(".nav-item.active"); + other.click(); + await settle(800); + c.ok( + document.body.innerHTML.includes("Checked out"), + "clicking it actually checks out", + ); + c.eq( + text(".nav-item.active"), + activeBefore, + "and does not navigate you somewhere else while doing it", + ); + }, + + // ── staging keeps your place ──────────────────────────────────────────── + // Staging used to `bust("status")` and repaint, which deletes the very + // cache entry the repaint would have drawn from — so the file list blanked + // to a 6-row skeleton and the diff pane went back to its empty state, on + // every stage, unstage, discard and stash. It now re-reads into the same + // entry, so a real tree is on screen the whole time. + "staging-does-not-blank-the-list": async (f) => { + const c = check(f); + const before = $$(".dc-file").length; + c.ok(before > 0, `the list has files to begin with (${before})`); + const row = $(".dc-file.active") ?? $(".dc-file"); + const btn = row?.querySelector(".row-actions button"); + c.ok(!!btn, "the row offers an action"); + if (!btn) return; + btn.click(); + // Mid-flight: the moment the operation returns is exactly when the + // skeleton used to appear. + await settle(140); + c.eq($$(".sk-list").length, 0, "no skeleton is painted over the list"); + c.ok($$(".dc-file").length > 0, "and real rows stay on screen throughout"); + await settle(900); + c.ok($$(".dc-file").length > 0, "the list is still populated once it settles"); + }, + + "staging-keeps-the-open-file": async (f) => { + const c = check(f); + const row = $(".dc-file.active"); + c.ok(!!row, "a file is selected"); + if (!row) return; + const path = row.title; + const btn = row.querySelector(".row-actions button"); + c.ok(!!btn, "the row offers an action"); + if (!btn) return; + btn.click(); + await settle(1200); + // Every stage / unstage / discard / refresh ends in showChangesView(), + // which replaces the whole subtree — so the diff you were reading closed, + // the row deselected, and the list jumped to the top. Staging one file in + // a list of forty meant finding your place again every single time. + const still = $(".dc-file.active"); + c.ok(!!still, "a file is still selected after the action"); + c.eq(still?.title, path, "and it is the same file you had open"); + }, + + // The SAME guarantee, in the checkbox model, driven by the tick — which is + // that model's whole interaction. The check above only ever ran on the + // split model, so the checkbox branch's early `return` skipped the reopen + // restore entirely and nothing here noticed: ticking any box threw away the + // diff you were reading. Ticking a DIFFERENT row than the open one is the + // case that matters — the open file itself is untouched by the action. + "checkbox-tick-keeps-the-open-file": async (f) => { + const c = check(f); + const row = $(".dc-file.active"); + c.ok(!!row, "a file is selected"); + if (!row) return; + const path = row.title; + const other = $$(".dc-file").find((r) => r.title !== path && r.querySelector(".dc-ck")); + c.ok(!!other, "another row offers a tick"); + if (!other) return; + other.querySelector(".dc-ck").click(); + await settle(1400); + const still = $(".dc-file.active"); + c.ok(!!still, "a file is still selected after the tick"); + c.eq(still?.title, path, "and it is the same file you had open"); + c.ok( + !$(".dc-stagelines")?.disabled, + "and its line-staging button is still live, not the empty state's", + ); + }, + + // ── repositories are an object you manage, not a preference ───────────── + "repo-manager-opens-from-the-repo-chip": (f) => { + const c = check(f); + // The clone list used to live 480px down the Settings page, with Open, + // Reveal in Finder and Delete from disk on each row. Choosing a + // repository is the most frequent thing anyone does in a Git client, and + // nothing on a preferences page should be able to Trash 2GB of work. + c.ok(!!$(".repo-manager-card"), "the repository manager opens as its own surface"); + c.eq(text(".modal-title"), "Repositories", "and says what it is"); + c.ok($$(".settings-copy").length >= 3, `it lists the clones (${$$(".settings-copy").length})`); + const acts = $$(".repo-manager-card .modal-actions button").map((b) => b.textContent.trim()); + c.ok(acts.some((a) => /Open repository/.test(a)), "with a way to open one"); + c.ok(acts.some((a) => /Clone repository/.test(a)), "and a way to get another"); + }, + "settings-holds-preferences-not-repositories": (f) => { + const c = check(f); + c.eq($$(".settings-copy").length, 0, "Settings no longer lists every clone on the machine"); + c.ok( + $$("button").some((b) => /Manage repositories/.test(b.textContent || "")), + "but still points at where they live", + ); + // The actual preference — where clones land — stays. + c.ok( + $$(".settings-field-label").some((n) => /clone folder/i.test(n.textContent || "")), + "and keeps the clone-folder preference", + ); + }, + + // ── the app does not open on a file tree ──────────────────────────────── + "landing-is-the-working-tree": (f) => { + const c = check(f); + const rail = $$(".nav-item").map((n) => (n.textContent || "").trim()); + c.ok(rail.length > 6, `the rail renders (${rail.length})`); + // Code — a read-only file tree of HEAD — held the first slot and was the + // default view, in an app whose user already has those files open in an + // editor. It is the one view nothing else navigates to. + c.eq(rail[0], "Changes", `the first destination is the working tree (got "${rail[0]}")`); + c.ok(rail.indexOf("Code") > 2, `Code is demoted, not removed (position ${rail.indexOf("Code")})`); + c.eq(text(".nav-item.active"), "Changes", "and that is where the app opens"); + }, + + // ── nothing pretends to be loading ────────────────────────────────────── + "assistant-has-no-phantom-skeleton": async (f) => { + const c = check(f); + await settle(1200); + const w = $(".assistant-view"); + c.ok(!!w, "the Assistant view mounts"); + if (!w) return; + // mountSection puts a skeleton in the container; the Assistant renders + // synchronously and used to APPEND to it, leaving a six-row shimmer + // pinned above its own header — 278px of the pane, loading nothing, for + // as long as you left it open. + c.ok(!w.querySelector(".sk-list"), "and discards the mount placeholder"); + c.eq( + [...w.children][0]?.className.split(" ")[0], + "assistant-head", + "so the header is the first thing in it", + ); + }, + + // ── a menu closes on its own trigger ──────────────────────────────────── + // Approving is a public, named act on someone else's work. The toolbar + // button posted it on the FIRST click, 8px from a button that merely opens a + // menu — and that menu carried a second "Approve" doing the same thing. + // Flipping Releases↔Tags rebuilds the whole list, so the button you pressed + // is destroyed mid-click and focus fell to <body>: the keyboard was simply + // ejected from the control it was operating. focusReturn's rescue finds the + // equivalent control in the rebuilt DOM and puts the keyboard back on it. + // The Changes list reserved 140px of every row for buttons that are + // invisible until you hover, which in a 320px file list left the FILENAME + // 40px. The name did not ellipsise either, so it painted straight over the + // status letter beside it — same pixels, mid-glyph. + // Choosing an action revealed a consequence line UNDER the row, growing it + // ~17px the instant you chose — which shoved every row below it, including + // the next row's action dropdown: the very control you reach for next moved + // before your hand got there. + // A modal surface has to actually HOLD the page behind it. The Projects + // drawer set aria-modal="true" — a claim, not a mechanism — and every card + // on the board behind it stayed in the tab order, so Tab walked straight + // out of the dialog into a board the user could not see. + "drawer-holds-the-board-behind-it": async (f) => { + const c = check(f); + const card = $$(".gh-card").find((x) => $$("button", x).length); + c.ok(!!card, "the board has cards"); + if (!card) return; + card.click(); + await settle(500); + const scrim = $(".gh-drawer-scrim"); + c.ok(!!scrim, "clicking a card opens the drawer"); + if (!scrim) return; + const drawer = $(".gh-drawer"); + c.eq(drawer && drawer.getAttribute("aria-modal"), "true", "it claims to be modal"); + // Everything that is NOT the drawer must be inert, so the claim is true. + const outside = [...document.body.children].filter((el) => el !== scrim && !el.contains(scrim)); + c.ok(outside.length > 0, "there is a page behind it"); + const live = outside.filter((el) => !el.hasAttribute("inert")); + c.eq(live.length, 0, `nothing behind the drawer is still reachable (${live.length} live)`); + const focused = document.activeElement; + c.ok(!!focused && scrim.contains(focused), "focus starts inside the drawer"); + // …and closing it hands the page back. An `inert` that outlives its + // dialog freezes the whole app. + const close = $(".gh-drawer-close"); + c.ok(!!close, "the drawer has a close button"); + if (!close) return; + close.click(); + await settle(400); + const stuck = [...document.body.children].filter((el) => el.hasAttribute("inert")); + c.eq(stuck.length, 0, `closing releases the page (${stuck.length} still inert)`); + }, + + "rebase-actions-do-not-move-the-list": async (f) => { + const c = check(f); + const rows = $$(".rb-row"); + c.ok(rows.length >= 3, `the plan lists its commits (${rows.length})`); + const sel = $$(".rb-action")[1]; + c.ok(!!sel, "each row carries an action picker"); + if (!sel || rows.length < 3) return; + const before = rows.map((r) => Math.round(r.getBoundingClientRect().top)); + const pickerBefore = Math.round($$(".rb-action")[2].getBoundingClientRect().top); + sel.value = "squash"; + sel.dispatchEvent(new Event("change", { bubbles: true })); + await settle(300); + const now = $$(".rb-row"); + c.eq(now.length, rows.length, "the plan keeps its rows"); + const after = now.map((r) => Math.round(r.getBoundingClientRect().top)); + c.eq(after.join(","), before.join(","), "no row moves"); + c.eq( + Math.round($$(".rb-action")[2].getBoundingClientRect().top), + pickerBefore, + "and the NEXT row's picker is exactly where you left it", + ); + c.ok( + $$(".rb-row")[1].querySelector(".rb-consequence") !== null, + "the consequence is still shown — inline, not on a line of its own", + ); + }, + + "file-rows-show-the-whole-name": (f) => { + const c = check(f); + const rows = $$(".dc-file"); + c.ok(rows.length >= 3, `the list has files (${rows.length})`); + const xs = new Set(); + for (const r of rows) { + const name = $$(".dc-file-name", r)[0]; + const st = $$(".file-status", r)[0]; + if (!name || !st) continue; + c.ok( + name.scrollWidth <= name.clientWidth + 1, + `"${text(name)}" is not truncated (${name.scrollWidth} into ${name.clientWidth}px)`, + ); + c.ok( + Math.round(name.getBoundingClientRect().right) <= Math.round(st.getBoundingClientRect().left), + `"${text(name)}" does not run into its status letter`, + ); + xs.add(Math.round(st.getBoundingClientRect().left)); + } + // The reservation existed to keep the letters scannable as a column; the + // overlay has to keep that. + c.eq(xs.size, 1, `every status letter sits in ONE column (${[...xs].join(", ")})`); + }, + + "segment-flip-keeps-the-keyboard": async (f) => { + const c = check(f); + const tags = $$(".gh-seg-btn").find((b) => text(b) === "Tags"); + c.ok(!!tags, "the Releases/Tags segment renders"); + if (!tags) return; + tags.focus(); + c.eq(document.activeElement, tags, "the keyboard starts on the button"); + tags.click(); + await settle(700); + const now = document.activeElement; + c.ok(now !== document.body, "focus does NOT fall to <body>"); + c.eq(text(now), "Tags", "it lands on the same control in the rebuilt list"); + c.ok(now.classList.contains("active"), "which is now the selected one"); + }, + + // GitHub drops a reviewer from `requestedReviewers` the moment they SUBMIT, + // so a rail built from that list alone showed only the people who had not + // answered — and told you each of them had "not yet submitted". Anyone who + // had actually approved or blocked appeared nowhere in the rail at all, + // which is the one question the section exists to answer. + "reviewers-rail-says-who-answered": async (f) => { + const c = check(f); + await settle(700); // the verdicts arrive from the cached conversation + const prop = $$(".det-prop").find((p) => + /reviewers/i.test(text($$(".det-prop-label", p)[0]) || ""), + ); + c.ok(!!prop, "the rail has a Reviewers section"); + if (!prop) return; + const chips = $$(".det-person", prop); + c.ok(chips.length >= 2, `it lists reviewers (${chips.length})`); + const cls = (n) => chips.filter((x) => x.classList.contains(n)); + c.ok(cls("is-pending").length >= 1, "someone still owes a review"); + c.ok(cls("is-approved").length >= 1, "and someone who APPROVED is shown as approved"); + c.ok(cls("is-blocking").length >= 1, "and someone blocking is shown as blocking"); + for (const chip of chips) { + c.ok(!!chip.title, `${text(chip)} says what its state means`); + } + // The claim has to be true of each chip, not just present. + for (const chip of cls("is-approved")) { + c.ok(/approved/i.test(chip.title), `"${chip.title}" reads as approved`); + c.ok(!/not yet submitted/i.test(chip.title), "and is NOT called unsubmitted"); + } + }, + + "approve-opens-the-composer": async (f) => { + const c = check(f); + const approve = $$(".mini-btn").find((b) => text(b) === "Approve"); + c.ok(!!approve, "the toolbar offers Approve"); + const menuApproves = $$(".dropdown-item").filter((b) => text(b) === "Approve"); + c.eq(menuApproves.length, 0, "no second Approve is already on screen"); + if (!approve) return; + approve.click(); + await settle(300); + const modal = $(".review-modal"); + c.ok(!!modal, "it opens the review composer instead of posting"); + if (!modal) return; + c.ok(!!$$("textarea", modal)[0], "the composer carries the review body"); + const chosen = $$(".review-verdict.is-selected", modal).map((r) => r.dataset.event); + c.eq(chosen.join(","), "APPROVE", "with Approve preselected"); + }, + + // Labelling is a multi-select. The picker used to close — and fire a + // request — after every single tick, so three labels meant opening the menu + // three times and re-finding your place in it. + "label-picker-stays-open": async (f) => { + const c = check(f); + const edit = $$(".det-prop").find((p) => /labels/i.test(text($$(".det-prop-label", p)[0]) || "")); + c.ok(!!edit, "the rail has a Labels section"); + const btn = edit && $$(".det-prop-edit", edit)[0]; + c.ok(!!btn, "with a visible way to change it"); + if (!btn) return; + // Visible at REST, not only on hover: this was the section's only + // affordance and it was invisible until the pointer swept the heading. + btn.style.transition = "none"; + c.ok(Number(getComputedStyle(btn).opacity) > 0.3, "the edit control is visible at rest"); + btn.click(); + await settle(400); + const rows = $$('.dropdown-item[role="menuitemcheckbox"]'); + c.ok(rows.length >= 2, `the picker lists the repo's labels as tickable rows (${rows.length})`); + if (rows.length < 2) return; + const before = rows[0].getAttribute("aria-checked"); + rows[0].click(); + await settle(150); + c.eq($$(".dropdown").length, 1, "ticking one does NOT close the menu"); + c.ok(rows[0].getAttribute("aria-checked") !== before, "and the tick flips under your finger"); + rows[1].click(); + await settle(150); + c.eq($$(".dropdown").length, 1, "a second pick keeps it open too"); + }, + + // Under "Unread only" the row was REMOVED on the spot: the list collapsed + // under the pointer and the next row's own Mark-read button slid into the + // pixel you had just clicked. + "mark-read-keeps-its-slot": async (f) => { + const c = check(f); + const rows = $$(".notif-row"); + c.ok(rows.length >= 2, `the inbox lists threads (${rows.length})`); + if (rows.length < 2) return; + const second = rows[1]; + const y = second.getBoundingClientRect().top; + const btn = $$("button", rows[0]).find((b) => text(b) === "Mark read"); + c.ok(!!btn, "an unread row offers Mark read"); + if (!btn) return; + btn.click(); + await settle(400); + c.eq($$(".notif-row").length, rows.length, "the row keeps its place in the list"); + c.ok( + Math.abs(second.getBoundingClientRect().top - y) < 2, + `nothing below it moves (${Math.round(second.getBoundingClientRect().top - y)}px)`, + ); + c.ok(rows[0].classList.contains("notif-read"), "the row reads as spent instead"); + }, + + // Enter dismisses most dialogs. On a destructive confirm that used to mean + // Enter DELETED, because the destroy button held focus on open. + "danger-dialogs-start-on-cancel": async (f) => { + const c = check(f); + const del = $$("button").find((b) => /delete|discard|remove/i.test(text(b) || "")); + c.ok(!!del, "the view offers a destructive action"); + if (!del) return; + del.click(); + await settle(400); + const card = $(".modal-card"); + c.ok(!!card, "it asks first"); + if (!card) return; + const focused = document.activeElement; + c.ok(!!focused && card.contains(focused), "focus lands inside the dialog"); + const destroy = $$(".btn-danger", card)[0]; + c.ok(!destroy || focused !== destroy, "but NOT on the button that destroys"); + }, + + // Arrow keys were dead and Enter fired the top row while nothing on screen + // said the top row was special. + "go-to-file-has-a-cursor": async (f) => { + const c = check(f); + const btn = $$(".mini-btn").find((b) => /go to file/i.test(text(b) || "")); + c.ok(!!btn, "the repo page offers Go to file"); + if (!btn) return; + btn.click(); + await settle(500); + const input = $(".gotofile-input"); + c.ok(!!input, "it opens the picker"); + if (!input) return; + c.eq(input.getAttribute("role"), "combobox", "the field is a combobox"); + const rows = $$(".gotofile-row"); + c.ok(rows.length >= 2, `it lists files (${rows.length})`); + if (rows.length < 2) return; + c.eq($$(".gotofile-row.is-sel").length, 1, "exactly one row is marked as the cursor"); + c.ok(rows[0].classList.contains("is-sel"), "starting at the top"); + const id = input.getAttribute("aria-activedescendant"); + c.ok(!!id && rows[0].id === id, "and the cursor reaches the accessibility tree"); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + await settle(80); + c.ok(rows[1].classList.contains("is-sel"), "ArrowDown moves it"); + c.ok(!rows[0].classList.contains("is-sel"), "and leaves the row it came from"); + }, + + "menu-toggles-on-its-own-trigger": async (f) => { + const c = check(f); + const p = $(".gh-picker"); + c.ok(!!p, "the header picker renders"); + if (!p) return; + p.click(); + await settle(350); + c.eq($$(".dropdown").length, 1, "clicking opens it"); + p.click(); + await settle(350); + // The outside-dismiss ran on a capturing mousedown, so the trigger closed + // the menu and its own click immediately opened a NEW one. The menu + // appeared not to respond, and anything typed into its filter was lost. + c.eq($$(".dropdown").length, 0, "clicking it again closes it"); + c.eq(p.getAttribute("aria-expanded"), "false", "and says so"); + }, + + // ── a row that looks clickable is clickable ───────────────────────────── + "pr-commit-rows-are-real-controls": (f) => { + const c = check(f); + const rows = $$(".clist-row"); + c.ok(rows.length > 0, `the Commits tab renders rows (${rows.length})`); + for (const r of rows) { + // The original defect: rows had a pointer cursor, a hover background + // and an :active depress — every signal of a control — and did nothing, + // while being invisible to the keyboard. The demand is unchanged; the + // row is no longer ONE button, because a row that is a button cannot + // also hold a copy button (a control inside a control has no accessible + // name of its own, and Space activates the wrong one). + const controls = [...r.querySelectorAll("button")]; + c.ok(controls.length >= 2, "a row's parts are real controls"); + for (const b of controls) { + const name = (b.textContent || "").trim() || b.title || b.getAttribute("aria-label") || ""; + c.ok(!!name, `every control in the row has an accessible name (.${b.className})`); + c.ok(b.tabIndex >= 0, "and can be reached by keyboard"); + } + c.ok(!!r.querySelector(".clist-subject"), "the subject opens the commit"); + c.ok(!!r.querySelector(".clist-sha"), "the sha is there to take"); + c.match(text(r.querySelector(".clist-meta")) || "", /committed/, "and it says who, and when"); + } + }, + + // ── keyboard surfaces announce their selection ────────────────────────── + "palette-selection-reaches-the-a11y-tree": async (f) => { + const c = check(f); + const input = $(".cmdk-input"); + c.ok(!!input, "the palette is open"); + if (!input) return; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + await settle(250); + // Focus stays in the input (correctly — you keep typing), so without + // activedescendant a screen reader hears nothing as you arrow through. + const id = input.getAttribute("aria-activedescendant"); + c.ok(!!id, "the input points at the active row"); + c.eq($(".cmdk-row[aria-selected='true']")?.id, id, "and that row is marked selected"); + c.eq(input.getAttribute("role"), "combobox", "the input is a combobox"); + }, + "graph-selection-reaches-the-a11y-tree": async (f) => { + const c = check(f); + await settle(900); + const host = $("gitstudio-graph"); + const sr = host?.shadowRoot; + c.ok(!!sr, "the graph element is mounted"); + if (!sr) return; + const sc = sr.querySelector(".scroller"); + sc.focus(); + sc.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + await settle(400); + c.ok(!!sc.getAttribute("aria-activedescendant"), "the grid points at the selected row"); + c.ok(!!sc.getAttribute("aria-rowcount"), "and reports how many rows there are"); + }, + + // ── hiding one column moves only that column ──────────────────────────── + "graph-columns-keep-their-tracks": async (f) => { + const c = check(f); + await settle(900); + const host = $("gitstudio-graph"); + const sr = host?.shadowRoot; + c.ok(!!sr, "the graph element is mounted"); + if (!sr) return; + const keys = ["graph", "refs", "subject", "changes", "author", "date", "sha"]; + const widths = () => + Object.fromEntries( + keys.map((k) => [k, Math.round(sr.querySelector(".ch-" + k)?.getBoundingClientRect().width || 0)]), + ); + const base = widths(); + c.ok(base.subject > 100, `the columns render (subject ${base.subject}px)`); + for (const hide of ["date", "refs", "changes"]) { + host.classList.add("hide-" + hide); + await settle(300); + const now = widths(); + host.classList.remove("hide-" + hide); + // Cells were placed by source order, so removing one slid every later + // cell up a track: hiding Date made the SHA column vanish while the + // menu still showed SHA as checked. + c.eq(now[hide], 0, `hiding ${hide} collapses ${hide}`); + for (const k of keys) { + if (k === hide || k === "subject") continue; + c.eq(now[k], base[k], `hiding ${hide} must not resize ${k}`); + } + } + }, + + // ── back goes where you came from ─────────────────────────────────────── + "back-returns-to-the-list-you-opened-from": async (f) => { + const c = check(f); + await settle(1200); + const back = text(".det-back"); + // Inbox and My Work open items that LIVE in other sections, so the detail + // used to claim it belonged there: "← Issues", the rail switching under + // you, and Escape landing in a list you had never opened. + c.eq(back, "My Work", `the back button names where you came from (got "${back}")`); + document.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }), + ); + await settle(1100); + c.eq(text(".nav-item.active"), "My Work", "and Escape returns there"); + c.ok($$(".mywork-group").length > 0, "with its grouping intact"); + }, + + // ── a rebuild does not cost you the keyboard ──────────────────────────── + "focus-survives-a-rebuild": async (f) => { + const c = check(f); + // Most surfaces rebuild a whole subtree in response to a click — refresh, + // staging, flipping a sub-tab, changing a rebase action. The node you + // clicked is detached, focus falls to <body>, and the next Tab starts at + // the top of the window. You have not gone anywhere; the control still + // exists, as a new element with the same identity. + // ONE control per scene. Two in a row interfere: the first rebuild's + // rescue is still settling when the second click starts, and the check + // then measures a race rather than the rule. The two scenes this runs on + // cover both shapes — a rebuild-in-place (refresh) and a rebuild that + // swaps the list (a segment). + const sel = window.__GS_ARG || ".gh-refresh"; + const b = $(sel); + c.ok(!!b, `the view offers ${sel}`); + if (!b) return; + b.focus(); + const before = (b.textContent || "").trim() + "|" + (b.className || "").split(" ")[0]; + b.click(); + await settle(1400); + const a = document.activeElement; + c.ok(a && a !== document.body, "focus is not dropped on <body>"); + if (a && a !== document.body) { + const after = (a.textContent || "").trim() + "|" + (a.className || "").split(" ")[0]; + c.eq(after, before, "focus lands back on the same control"); + } + }, + + // ── settings ───────────────────────────────────────────────────────────── + // Three detail pages hand-rolled the same tab bar as plain buttons carrying + // an `active` CLASS: a reader heard N unrelated buttons and could not tell + // which page was showing, and arrow keys did nothing. + "detail-subtabs-are-a-tablist": (f) => { + const c = check(f); + const bar = $("[role=tablist]"); + c.ok(!!bar, "the sub-tab bar is a tablist"); + if (!bar) return; + c.ok(!!bar.getAttribute("aria-label"), "the tablist is named"); + const tabs = $$("[role=tab]", bar); + c.ok(tabs.length >= 2, `it holds its tabs (${tabs.length})`); + const on = tabs.filter((t) => t.getAttribute("aria-selected") === "true"); + c.eq(on.length, 1, "exactly one tab reports itself selected"); + c.eq( + tabs.filter((t) => t.tabIndex === 0).length, + 1, + "one roving tab stop, so Tab reaches the bar and arrows move inside it", + ); + c.ok(on[0] && on[0].tabIndex === 0, "the tab stop is the SELECTED tab"); + c.ok(!!$("[role=tabpanel]"), "the panel the tabs control is marked as one"); + }, + + // A comment pasted into the MIDDLE of a selector list split it in two and + // handed the first five selectors the next rule's declaration — so every + // segmented control, checkbox and field label in Settings silently took + // `width: min(720px, 92vw)`. A 720px bordered rail around 277px of buttons + // reads as a broken control, and nothing in the source said why. + // The shared graph package paints from a `--vscode-*` vocabulary the desktop + // has to supply. It supplied it in the DARK block only, so on the light page + // `--gs-amber` resolved to nothing and a tag chip rendered as bare body + // text — no ink, no pill, and nothing in the source saying why. + // Every other list in the app builds a row you can reach and operate. The + // Inbox's rows carried the hover, the pointer and an accessible NAME but no + // role, no tab stop and no keys — so a keyboard user could read the Inbox + // and open nothing in it, and Tab skipped the whole list. + "inbox-rows-are-controls": (f) => { + const c = check(f); + const rows = $$(".notif-row"); + c.ok(rows.length >= 3, `the Inbox lists threads (${rows.length})`); + for (const r of rows) { + c.eq(r.getAttribute("role"), "button", `"${text(r).slice(0, 22)}" is a control`); + c.ok(!!r.getAttribute("aria-label"), "and carries its own name"); + } + // One roving tab stop: Tab reaches the list, arrows move inside it. + const stops = rows.filter((r) => r.tabIndex === 0); + c.eq(stops.length, 1, `the list is ONE tab stop (${stops.length})`); + c.ok(stops[0] === rows[0], "and Tab lands on the first thread"); + }, + + // The ring is the one thing on screen whose whole job is to be seen. It was + // the accent mixed with `transparent`, which lowers ALPHA rather than + // lightness — so it composited toward the page behind it and measured + // 2.19-2.90:1 on the light ground, under the 3:1 WCAG asks of a focus + // indicator. + "the-focus-ring-can-be-seen": async (f) => { + const c = check(f); + const menu = $(".dropdown"); + c.ok(!!menu, "a menu is open to focus something in"); + if (!menu) return; + document.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true }), + ); + await settle(80); + const item = document.activeElement; + c.ok(!!item && item.classList.contains("dropdown-item"), "an item takes keyboard focus"); + if (!item || !item.classList.contains("dropdown-item")) return; + // EVERY focusable surface, not just this one. Checking only the menu row + // is how the sidebar kept its 2.19:1 ring through a pass that was meant to + // replace every diluted outline in the app: the check was looking at the + // surface that had already been fixed. + for (const el of [...$$(".nav-item"), ...$$(".list-row"), ...$$(".sec-row")].slice(0, 6)) { + const ring = getComputedStyle(el).outlineColor; + c.ok( + !/rgba\([^)]*,\s*0?\.\d+\s*\)/.test(ring), + `${el.className.split(" ")[0]} has an opaque ring, not an alpha wash (${ring})`, + ); + } + const st = getComputedStyle(item); + const nums = (col) => (col.match(/[\d.]+/g) || []).slice(0, 3).map(Number); + const lin = (v) => { + v /= 255; + return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + }; + const lum = (col) => { + const [r, g, b] = nums(col); + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); + }; + // color() / color-mix values do not parse as 0-255 triples; a ring that + // still carries alpha is exactly the bug, so demand a plain opaque colour. + c.ok( + /^rgba?\(/.test(st.outlineColor) && !/rgba\([^)]*,\s*0?\.\d+\s*\)/.test(st.outlineColor), + `the ring is an opaque colour, not an alpha wash (${st.outlineColor})`, + ); + const a = lum(st.outlineColor); + const b = lum(getComputedStyle(menu).backgroundColor); + const ratio = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); + c.ok(ratio >= 3, `it clears 3:1 against what it sits on (${Math.round(ratio * 100) / 100}:1)`); + }, + + // The branch/tag column is the reason to open Commits rather than read a + // plain log, and between roughly 1300 and 1550px it carried no readable text + // at all: the track pinned to its 60px structural floor, which is less than + // one chip's own furniture. Worse, it was non-monotonic — WIDENING the window + // past host 760 brought the date and sha columns back and made the refs + // column narrower. Invisible at the default size, appearing when you maximise. + // "Clicking around causes slow screen loading and reloading." It did: + // revisiting the five local views fired 21 IPC calls and flashed 8 skeletons, + // for data that had not changed. Two of those calls — whether AI is + // configured, and whether you are signed in to GitHub — fired on EVERY route + // into Changes and Compare and cannot change between two clicks. + // Alt-tab away and back. The window-focus refresh is a real need — you may + // have edited files in another app — but it fired unconditionally, and its + // refresh drops the whole cache, clears every kept-alive view and + // force-rebuilds. So switching to a browser and back rebuilt the app from + // nothing AND ejected you from whatever detail page you were reading, since + // the forced re-route carried no target. Settings had to be hand-excluded + // from it to stop the sign-in card being destroyed mid-flow. + // Two routes to the same object must mean the same thing. A repo row in + // Organizations looked identical to a repo row in Explore, but clicking it + // CLONED the repository to disk and replaced the app's entire working + // context — no confirmation, nothing on the row to warn you — while the + // Explore row merely browsed. The destructive one was the default. + // Code search exists to find ONE file among thousands. Clicking a hit + // navigated to `repo/<fullName>` and threw the path away, so the answer to + // "open this result" was the repository root with the result gone. + // Click into a section, get impatient, click away. The half-painted view — + // skeleton and all — was stashed in the keep-alive cache and restored on + // every later visit, so Issues came back permanently empty for the rest of + // the session and only the header refresh button could recover it. + // The rail is a tablist with a roving tab stop. "The active item is the stop" + // has no answer for a route that is not a rail item at all — the Assistant is + // reached from the top bar, a detail page has no rail entry — so on those + // routes every one of the 17 destinations got tabIndex -1 and the entire + // navigation rail left the keyboard's reach. + // The most dangerous sentence this app can print is "Working tree clean · + // No changes to commit" over a working tree full of uncommitted work. It + // could: GitProcess.run RESOLVES on a non-zero exit, so a failing + // `git status` returned {stdout:"", code:128} on the SUCCESS path, and + // parsing "" gave []. A corrupt .git/index or a held index.lock rendered a + // broken repo as a healthy, empty one — and "No branches yet" for a repo + // full of branches. + // Ticking Amend fills the box with the PREVIOUS commit's message. Un-ticking + // withdraws it — but the flag recording "the app put this here, the user did + // not" was render-local while every sibling piece of composer state was not. + // Any repaint (stage, unstage, discard, Refresh, a route change, a file + // saved in your editor) lost it, so the withdrawal almost never happened: + // the toggle, the button label and the branch line all returned to the + // new-commit shape while the box kept someone else's message, and committing + // duplicated its subject. + // Every create/edit flow collected the text, CLOSED the dialog, then sent it. + // A rejected request answered several minutes of writing with a toast over an + // empty screen. The form comes back now, carrying what was typed and the + // reason it failed. + // A squash folds into the nearest kept commit BELOW — the list is + // newest-first, and git melds into the entry before it in the todo file. So + // the commit that cannot be squashed is the LAST kept one. The guard checked + // the first, which refused the most ordinary interactive rebase there is + // (fold my latest commit into the one before it) and accepted a squash on + // the oldest, which git cannot execute — letting an impossible plan reach + // the force-push dialog. + // "Latest" answers "which version is current?". github.com's rule is the + // newest published NON-pre-release; taking the newest published thing awards + // it to a release candidate whenever one exists, pointing everyone at the RC + // instead of the build they should be running. + // `last` was only reassigned on the SUCCESS path, so the base===head early + // return left it holding the PREVIOUS comparison — and both panes went on + // rendering those commits and files as the answer for refs that were never + // compared. The counts said one thing, the rows below showed another. + "a-dead-comparison-shows-nothing-not-the-last-one": async (f) => { + const c = check(f); + await settle(700); + const picks = $$(".ref-pick"); + c.ok(picks.length >= 2, "two ref pickers"); + if (picks.length < 2) return; + const headName = text(picks[1]).trim(); + // Make base === head, which is a comparison with no answer. + picks[0].click(); + await settle(400); + const same = $$(".dropdown-item").find((r) => text(r).trim() === headName); + c.ok(!!same, `the base menu offers ${headName}`); + if (!same) return; + same.click(); + await settle(900); + + c.ok(!!$(".list-empty"), "it says there is nothing to compare"); + // The empty state alone does not prove it: `runCompare` paints that + // directly. The stale `last` only surfaces when something calls + // renderBody() AFTERWARDS — which switching the segment does. That is the + // repro: land in the dead comparison, then click Commits, and the previous + // comparison's rows come back as the answer. + for (const seg of $$(".cmp-seg-btn")) { + seg.click(); + await settle(400); + c.eq( + $$(".clist-row").length, + 0, + `"${text(seg).trim()}" shows none of the previous comparison's commits`, + ); + c.eq($$(".file-row").length, 0, `"${text(seg).trim()}" shows none of its files`); + } + const pr = $(".cmp-pr-btn"); + c.ok(!pr || pr.hidden, "and does not offer a pull request from a branch to itself"); + }, + + "latest-is-the-shipping-build-not-the-rc": (f) => { + const c = check(f); + const rows = $$(".sec-row"); + c.ok(rows.length >= 3, `releases are listed (${rows.length})`); + const pillsOf = (r) => $$(".gh-pill, [class*=state-]", r).map((p) => text(p)).filter(Boolean); + const latest = rows.filter((r) => pillsOf(r).includes("Latest")); + c.eq(latest.length, 1, `exactly one release is Latest (${latest.length})`); + if (!latest.length) return; + const pills = pillsOf(latest[0]); + c.ok( + !pills.includes("Pre-release"), + `and it is not a pre-release (${text($$(".sec-row-title", latest[0])[0])}: ${pills.join(", ")})`, + ); + c.ok(!pills.includes("Draft"), "nor a draft"); + // The fixture deliberately carries a PUBLISHED rc newer than the newest + // stable — without one this check cannot fail. + const rc = rows.find((r) => /RC/i.test(text($$(".sec-row-title", r)[0] || r))); + c.ok(!!rc, "the fixture still has a published release candidate to be fooled by"); + if (rc) c.ok(pillsOf(rc).includes("Pre-release"), "which is marked as a pre-release"); + }, + + "squash-is-refused-only-where-git-would-refuse-it": async (f) => { + const c = check(f); + const sels = () => $$(".rb-action"); + c.ok(sels().length >= 3, `the plan lists commits (${sels().length})`); + if (sels().length < 3) return; + const set = (i, v) => { + const el = sels()[i]; + el.value = v; + el.dispatchEvent(new Event("change", { bubbles: true })); + }; + + // The newest commit HAS somewhere to fold into: the one below it. + set(0, "squash"); + await settle(300); + c.eq(sels()[0].value, "squash", "the newest commit can be squashed"); + + // The oldest has nothing below it, and git would reject the plan. + const last = sels().length - 1; + set(last, "squash"); + await settle(300); + c.eq(sels()[last].value, "pick", "the oldest commit cannot"); + c.ok( + /oldest/i.test(text(".rb-banner") || ""), + `and it says which end is the problem ("${text(".rb-banner")}")`, + ); + }, + + "a-failed-submit-gives-the-form-back": async (f) => { + const c = check(f); + const orig = window.gitstudio.invoke.bind(window.gitstudio); + window.gitstudio.invoke = (ch, p) => { + if (ch === "issue:create") { + return Promise.resolve({ ok: false, message: "Validation failed: title is too long" }); + } + return orig(ch, p); + }; + const nb = $$("button").find((b) => /new issue/i.test(text(b))); + c.ok(!!nb, "the New issue action is present"); + if (!nb) return; + nb.click(); + await settle(900); + // Title and body, wherever the composer lives. It was two `.modal-input`s + // in a modal and is a routed page now — the RULE is that a rejected + // submit gives you your text back with the reason WHERE the text is, not + // that the form is a particular element. + const titleOf = () => $(".isc-title"); + const bodyOf = () => $(".isc-form .md-text"); + c.ok(!!titleOf() && !!bodyOf(), "the form has a title and a body"); + if (!titleOf() || !bodyOf()) return; + titleOf().value = "my title"; + titleOf().dispatchEvent(new Event("input", { bubbles: true })); + bodyOf().value = "my body text"; + bodyOf().dispatchEvent(new Event("input", { bubbles: true })); + await settle(150); + $$(".isc-actions button").find((b) => /create/i.test(text(b))).click(); + await settle(900); + c.ok(!!$(".isc-form"), "the form is still there after a rejected submit"); + c.eq((titleOf() || {}).value, "my title", "the title survives"); + c.eq((bodyOf() || {}).value, "my body text", "and so does the body"); + c.ok( + /too long/.test(text(".isc-error") || ""), + "and the form says why it failed, where the text still is", + ); + window.gitstudio.invoke = orig; + }, + + "amend-withdraws-its-prefill-after-a-repaint": async (f) => { + const c = check(f); + const amend = () => $$(".dc-toggle").find((b) => /Amend/.test(text(b))); + const go = (n) => $$(".nav-item").find((b) => text(b).trim() === n); + c.ok(!!amend() && !!go("Commits") && !!go("Changes"), "the composer and rail are present"); + if (!amend() || !go("Commits") || !go("Changes")) return; + + amend().click(); + await settle(700); + const prefill = $(".dc-message").value; + c.ok(prefill.length > 0, `ticking Amend prefills the last message ("${prefill.slice(0, 30)}")`); + + // A repaint — the thing that used to defeat the withdrawal. + go("Commits").click(); + await settle(600); + go("Changes").click(); + await settle(800); + c.eq($(".dc-message").value, prefill, "the prefill survives while Amend is still ON"); + + amend().click(); + await settle(500); + c.eq($(".dc-message").value, "", "un-ticking withdraws it even across the repaint"); + c.ok( + $(".dc-commit").hasAttribute("disabled") || $(".dc-commit").disabled, + "and Commit is not armed with a message the user never wrote", + ); + }, + + "a-failed-git-read-is-not-an-empty-repo": async (f) => { + const c = check(f); + const orig = window.gitstudio.invoke.bind(window.gitstudio); + window.gitstudio.invoke = (ch, p) => { + if (ch === "status" || ch === "branches:list") { + return Promise.reject(new Error("fatal: index file corrupt")); + } + return orig(ch, p); + }; + const go = (n) => $$(".nav-item").find((b) => text(b) === n); + c.ok(!!go("Branches") && !!go("Changes"), "both views are reachable"); + if (!go("Branches") || !go("Changes")) return; + + go("Branches").click(); + await settle(1200); + const branchText = (text(".view-host") || "").replace(/\s+/g, " "); + c.ok( + !/no branches yet/i.test(branchText), + `a failed ref read is NOT reported as an empty repo (${branchText.slice(0, 70)})`, + ); + c.ok(/couldn't list branches/i.test(branchText), "it says the read failed"); + c.ok($$(".list-empty.is-error button, .list-error button").length > 0, "and offers a retry"); + + go("Changes").click(); + await settle(1500); + const changesText = (text(".view-host") || "").replace(/\s+/g, " "); + const toasts = $$(".toast").map((t) => text(t)); + // Either it refuses to claim the tree is clean, or it keeps the last known + // tree AND says it could not confirm it. Silently claiming "clean" is the + // one outcome that is never acceptable. + const claimsClean = /working tree clean/i.test(changesText); + c.ok( + !claimsClean || toasts.length > 0, + `it never silently claims a clean tree (clean=${claimsClean}, toasts=${toasts.length})`, + ); + window.gitstudio.invoke = orig; + }, + + "the-rail-always-has-a-tab-stop": (f) => { + const c = check(f); + const items = $$(".nav-item"); + c.ok(items.length > 5, `the rail has destinations (${items.length})`); + const stops = items.filter((b) => b.tabIndex === 0); + c.eq(stops.length, 1, `exactly one is in the Tab order (${stops.length})`); + c.ok( + items.every((b) => b.hasAttribute("aria-selected")), + "and every item reports its selected state", + ); + }, + + // The app's own shortcut sheet advertises "Esc or ←" on detail pages. The + // arrow was implemented nowhere, and Esc unhooked itself on the next KEYDOWN + // after the page detached — so switching section, typing anything, and + // coming back left a restored page whose Esc was dead. + "detail-pages-answer-back-keys": async (f) => { + const c = check(f); + c.ok(!!$(".det-title"), "a detail page is open"); + if (!$(".det-title")) return; + // Left arrow, as documented. + document.body.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true, cancelable: true }), + ); + await settle(500); + c.ok(!$(".det-title"), "left arrow goes back"); + c.ok($$(".sec-row").length > 0, "and lands on the list"); + }, + + "an-abandoned-load-is-not-cached": async (f) => { + const c = check(f); + const orig = window.gitstudio.invoke.bind(window.gitstudio); + let calls = 0; + window.gitstudio.invoke = (ch, p) => { + if (ch === "issue:list") { + calls++; + return new Promise((r) => setTimeout(() => r(orig(ch, p)), 300)); + } + return orig(ch, p); + }; + const go = (n) => $$(".nav-item").find((b) => text(b) === n); + c.ok(!!go("Issues") && !!go("Changes"), "both sections are in the rail"); + if (!go("Issues") || !go("Changes")) return; + // The timing matters and is the whole repro: leave BEFORE the first + // response lands (120ms < 300ms), so the container is detached while the + // section's `if (!view.isConnected) return` guard is still pending. The + // guard then bails, the half-painted DOM is what got cached, and nothing + // ever paints it again. + go("Issues").click(); + await settle(120); + go("Changes").click(); + await settle(500); + go("Issues").click(); + await settle(4000); // far past the injected delay + c.ok($$(".sec-row").length > 0, `the section recovers (${$$(".sec-row").length} rows)`); + c.eq( + $$(".skeleton, .sk-row, .list-loading").length, + 0, + "and is not stuck on the skeleton it was abandoned in", + ); + // The abandoned request's answer is still cached, so returning costs + // nothing extra — the fix must not turn one fetch into two. + c.ok(calls <= 1, `and it did not refetch what was already in flight (${calls})`); + window.gitstudio.invoke = orig; + }, + + "a-code-hit-opens-its-file": async (f) => { + const c = check(f); + const tab = $$(".explore-tab").find((t) => /code/i.test(text(t))); + c.ok(!!tab, "Explore has a Code tab"); + if (!tab) return; + tab.click(); + await settle(600); + const rows = $$(".explore-code-row"); + c.ok(rows.length > 0, `it returns code hits (${rows.length})`); + if (!rows.length) return; + const wanted = text($$(".explore-row-head", rows[0])[0] || rows[0]).trim(); + c.ok(wanted.includes("/"), `the hit names a path (${wanted})`); + rows[0].click(); + await settle(800); + const title = text(".explore-repo-title"); + const leaf = wanted.split("/").pop(); + c.eq(title, leaf, `it opens the FILE, not the repo root (landed on "${title}")`); + c.ok( + (text(".explore-repo-eyebrow") || "").includes("/"), + "and says which repository and folder it came from", + ); + }, + + "clicking-a-repo-browses-it": async (f) => { + const c = check(f); + const row = $$(".gh-org-repo")[0]; + c.ok(!!row, "the org lists repositories"); + if (!row) return; + c.ok( + /browse/i.test(row.getAttribute("aria-label") || ""), + `the row says it browses (${row.getAttribute("aria-label")})`, + ); + // Adopting the repo is still available — as something you choose by name. + // + // WHERE it lives is free to change and has: the pair of hover-revealed + // buttons became one overflow, because the pair was 129px wide covering + // 128px of the description on a 441px card. What must hold is that + // adopting the repo is something you CHOOSE by name and that the row's + // own click does not do it — so look in both places. + let actions = $$(".row-btn", row).map((b) => text(b)); + const more = row.querySelector(".row-more"); + if (more) { + more.click(); + await settle(220); + actions = actions.concat($$(".dropdown [role='menuitem'], .dropdown button").map((b) => text(b))); + // Leave the page as it was found; an open menu breaks the click below. + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + await settle(160); + } + c.ok( + actions.some((a) => /^open\b/i.test(a.trim())), + `cloning is a named action, not the default (${actions.join(", ")})`, + ); + const orig = window.gitstudio.invoke.bind(window.gitstudio); + const seen = []; + window.gitstudio.invoke = (ch, p) => { + seen.push(ch); + return orig(ch, p); + }; + row.click(); + await settle(500); + window.gitstudio.invoke = orig; + c.eq( + seen.filter((ch) => /clone|repo:open/i.test(ch)).join(", "), + "", + `and a plain click clones nothing (${[...new Set(seen)].join(", ")})`, + ); + }, + + "alt-tab-does-not-rebuild-the-app": async (f) => { + const c = check(f); + const title = text(".det-title"); + c.ok(!!title, "a detail page is open"); + if (!title) return; + const orig = window.gitstudio.invoke.bind(window.gitstudio); + let seen = []; + window.gitstudio.invoke = (ch, p) => { + seen.push(ch); + return orig(ch, p); + }; + window.dispatchEvent(new Event("focus")); + await settle(800); + c.ok(!!$(".det-title"), "focus does not eject you from the detail page"); + c.eq(text(".det-title"), title, "and it is still the SAME page"); + // Nothing changed on disk, so the cost is the two reads that establish + // that — not a rebuild of everything. + const noisy = seen.filter((ch) => ch !== "status" && ch !== "head:get"); + c.eq( + noisy.join(", "), + "", + `an unchanged repo costs only the probe (${seen.length} calls: ${[...new Set(seen)].join(", ")})`, + ); + seen = []; + window.dispatchEvent(new Event("focus")); + await settle(600); + c.ok(seen.length <= 2, `and a second focus costs the same (${seen.length})`); + window.gitstudio.invoke = orig; + }, + + "revisiting-a-view-costs-nothing": async (f) => { + const c = check(f); + const orig = window.gitstudio.invoke.bind(window.gitstudio); + const seen = []; + // Real git answers in 40-300ms; the fixtures answer instantly, which hides + // every loading behaviour there is. Slow it down so a skeleton has time to + // be seen — that is the point of the test. + window.gitstudio.invoke = (ch, p) => { + seen.push(ch); + return new Promise((r) => setTimeout(() => r(orig(ch, p)), 120)); + }; + const go = (n) => $$(".nav-item").find((b) => text(b) === n); + const views = ["Commits", "Branches", "Compare", "Changes"]; + c.ok(views.every(go), "the local views are all in the rail"); + if (!views.every(go)) return; + // Warm every view once, the way a user who has been working would have. + for (const v of views) { + go(v).click(); + await settle(520); + } + // Wait PAST the status TTL (3s) before the second lap. Without this the + // revisit lands inside the TTL, gget answers without IPC, and the check + // passes for a reason that has nothing to do with the fix — which is + // exactly what it did on the first attempt. The complaint being tested is + // "click away and come back", not "click twice quickly". + await settle(3400); + seen.length = 0; + const flashed = []; + for (const v of views) { + go(v).click(); + await settle(30); // early enough that a skeleton would still be up + const sk = $$(".skeleton, .sk-row, .loading-state, .spinner, .list-loading").length; + if (sk) flashed.push(`${v} (${sk})`); + await settle(520); + } + c.eq(flashed.join(", "), "", "no view flashes a skeleton on a REVISIT"); + // Session facts must not be re-asked per route. + for (const ch of ["ai:settings", "github:status"]) { + c.eq( + seen.filter((x) => x === ch).length, + 0, + `${ch} is not re-fetched on every route (it cannot change between clicks)`, + ); + } + c.ok( + seen.length <= 8, + `and a full lap costs few calls, not one per view per datum (${seen.length}: ${[...new Set(seen)].join(", ")})`, + ); + window.gitstudio.invoke = orig; + }, + + "graph-ref-column-shows-a-name": async (f) => { + const c = check(f); + await settle(900); + const host = $("gitstudio-graph"); + c.ok(!!host && !!host.shadowRoot, "the graph renders"); + if (!host || !host.shadowRoot) return; + const nm = host.shadowRoot.querySelector(".refs .nm"); + c.ok(!!nm, "a ref chip carries a name element"); + if (!nm) return; + const shown = Math.round(nm.getBoundingClientRect().width); + c.ok( + shown >= nm.scrollWidth - 1, + `"${nm.textContent}" is fully readable at ${window.innerWidth}px (${shown} of ${nm.scrollWidth}px)`, + ); + const track = host.shadowRoot.querySelector(".ch-refs"); + if (track) { + c.ok( + track.getBoundingClientRect().width > 60, + "and the track is above the structural floor, which cannot fit a chip", + ); + } + }, + + "graph-ref-chips-are-painted": (f) => { + const c = check(f); + const host = $("gitstudio-graph"); + c.ok(!!host && !!host.shadowRoot, "the graph renders with an open shadow root"); + if (!host || !host.shadowRoot) return; + const chips = [...host.shadowRoot.querySelectorAll(".chip")]; + c.ok(chips.length >= 2, `it draws ref chips (${chips.length})`); + const named = chips.filter((x) => (x.textContent || "").trim() && !x.classList.contains("chip-overflow")); + for (const chip of named) { + const st = getComputedStyle(chip); + // A chip is a PILL: it has to have a ground of its own. Transparent means + // the token behind it resolved to nothing. + const bg = st.backgroundColor; + const transparent = bg === "rgba(0, 0, 0, 0)" || bg === "transparent"; + c.ok( + !transparent || chip.classList.contains("chip-current"), + `"${(chip.textContent || "").trim()}" has a ground (${bg})`, + ); + } + // And the amber consumer specifically. Asserting only "has a ground" and + // "not the body colour" was too weak: after the first fix the chip was + // the modified-file BLUE and satisfied both, so this check passed while + // the tuned amber still never shipped. Name the hue. + const tag = chips.find((x) => x.classList.contains("chip-tag")); + c.ok(!!tag, "a tag chip is on screen to check"); + if (!tag) return; + const st = getComputedStyle(tag); + c.ok( + st.backgroundColor !== "rgba(0, 0, 0, 0)", + `the tag chip keeps its pill (${st.backgroundColor})`, + ); + const [r, g, b] = (st.color.match(/[\d.]+/g) || []).slice(0, 3).map(Number); + c.ok(r > b && g > b, `its ink is AMBER — red and green above blue (rgb ${r}, ${g}, ${b})`); + c.ok( + st.color !== getComputedStyle(document.body).color, + "and it is not simply the page's body colour", + ); + }, + + "settings-controls-fit-their-content": (f) => { + const c = check(f); + const segs = $$(".settings-seg"); + c.ok(segs.length >= 2, `Settings has segmented controls (${segs.length})`); + for (const seg of segs) { + const track = seg.getBoundingClientRect().width; + const btns = $$(".settings-seg-btn", seg).reduce( + (a, b) => a + b.getBoundingClientRect().width, + 0, + ); + c.ok(btns > 0, "the segment has buttons"); + // The track is its buttons plus its own 1px borders — never a rail with + // hundreds of pixels of nothing inside it. + c.ok( + track - btns < 12, + `the track fits its buttons (${Math.round(track)}px around ${Math.round(btns)}px)`, + ); + } + // The same broken list also cost these their top margin, so a card read + // as one undifferentiated block. + const body = $(".settings-card-body"); + c.ok(!!body, "a settings card renders"); + if (!body) return; + const spaced = $$(".settings-card-body > * + .settings-seg"); + for (const el of spaced) { + c.ok( + parseFloat(getComputedStyle(el).marginTop) > 0, + "a control that follows something is pushed off from it", + ); + } + }, + + "settings-has-a-rhythm": (f) => { + const c = check(f); + const labels = $$(".settings-card-body > .settings-field-label"); + c.ok(labels.length >= 1, `a card groups its fields under labels (${labels.length})`); + for (const lab of labels) { + const prev = lab.previousElementSibling; + const next = lab.nextElementSibling; + if (!prev || !next) continue; + const name = lab.textContent.trim(); + const above = lab.getBoundingClientRect().top - prev.getBoundingClientRect().bottom; + const below = next.getBoundingClientRect().top - lab.getBoundingClientRect().bottom; + // "App icon" used to sit as far from the sentence explaining it as + // that sentence sat from the control above: a flat list, no groups. + c.ok( + above > below + 2, + `"${name}" must sit closer to what it introduces than to what precedes it (${Math.round(above)} above, ${Math.round(below)} below)`, + ); + } + // The column is a form's width; the prose inside keeps a reading one. + const scroll = $(".settings-scroll"); + const card = $(".settings-card"); + if (scroll && card) { + const cw = card.getBoundingClientRect().width; + c.ok(cw >= 900, `the card column is a form's width, not an article's (${Math.round(cw)}px)`); + } + for (const p of $$(".settings-sub")) { + const w = p.getBoundingClientRect().width; + c.ok(w <= 780, `a settings paragraph keeps a reading measure (${Math.round(w)}px)`); + } + }, + "settings-checkbox-styled": (f) => { + const c = check(f); + const box = $('.settings-check input[type="checkbox"]'); + c.ok(!!box, "the ask-where checkbox exists"); + if (!box) return; + c.eq(getComputedStyle(box).appearance, "none", "checkbox must not be the native control"); + }, + "settings-local-copies": (f) => { + const c = check(f); + const rows = $$(".settings-copy"); + c.ok(rows.length >= 4, "local copies list renders"); + const open = $(".settings-copy.is-current"); + c.ok(!!open, "the open repo is marked"); + c.ok(!open?.querySelector('button[title^="Open "]'), "the open repo must not offer Open"); + const missing = $(".settings-copy.is-missing"); + c.ok(!!missing, "a missing clone is listed rather than dropped"); + c.ok(!missing?.querySelector('button[title^="Open "]'), "a missing clone must not offer Open"); + }, + // Every row's actions have the SAME shape, whatever the row's state: two + // rows both badged MANAGED used to carry different icon sets because one + // was also, invisibly, in recents. + "settings-copy-actions-one-shape": (f) => { + const c = check(f); + const rows = $$(".settings-copy"); + if (!rows.length) return c.ok(false, "local copies render"); + const kebabs = []; + for (const r of rows) { + const acts = r.querySelector(".settings-copy-acts"); + const who = (r.querySelector(".settings-copy-name")?.textContent || "").trim().slice(0, 24); + const more = acts?.querySelector(".settings-copy-more"); + c.ok(!!more, `${who} has an overflow menu`); + if (more) kebabs.push(more.getBoundingClientRect().right); + // No icon-only verb clusters: one labelled action plus the menu. + const bare = [...(acts?.querySelectorAll("button") || [])].filter( + (b) => !b.classList.contains("settings-copy-more") && !b.textContent.trim(), + ); + c.eq(bare.length, 0, `${who} offers no unlabelled icon buttons`); + } + // Two rows with the same badge offer the same actions. + const shapeOf = (r) => + [...r.querySelectorAll(".settings-copy-acts button")] + .map((b) => b.textContent.trim() || "more") + .join("|"); + const byBadge = new Map(); + for (const r of rows) { + // The WHOLE badge set — a row can be RECENT *and* MISSING, and those + // two facts together are what licenses a different action set. + const badge = $$(".settings-copy-badge", r).map((b) => b.textContent.trim()).join(" "); + if (!badge) continue; + if (byBadge.has(badge)) { + c.eq(shapeOf(r), byBadge.get(badge), `both ${badge} rows offer the same actions`); + } else byBadge.set(badge, shapeOf(r)); + } + c.ok( + new Set(kebabs.map(Math.round)).size === 1, + `the overflow buttons form one column (${[...new Set(kebabs.map(Math.round))].join(", ")})`, + ); + }, + "settings-icon-preview-is-not-a-control": (f) => { + const c = check(f); + const prev = $(".settings-logo-preview"); + const seg = $(".settings-logo-row .settings-seg"); + c.ok(!!prev && !!seg, "the app-icon row renders"); + if (!prev || !seg) return; + const s = getComputedStyle(prev); + c.eq(s.borderTopWidth, "0px", "a preview must not be bordered like the buttons beside it"); + c.eq(s.pointerEvents, "none", "a preview must not be clickable"); + const p = prev.getBoundingClientRect(), g = seg.getBoundingClientRect(); + c.ok(p.left - g.right >= 14, `it stands off the segment (${Math.round(p.left - g.right)}px)`); + // The card's two segmented controls keep one left edge. + const themeSeg = $$(".settings-seg")[0]; + if (themeSeg && themeSeg !== seg) { + c.eq( + Math.round(themeSeg.getBoundingClientRect().left), + Math.round(g.left), + "both segmented controls share a left edge", + ); + } + }, + + /** + * The Changes banner names four operations and had two ways out. Three of + * the four therefore aborted with `git merge --abort`, which fails outright + * because MERGE_HEAD does not exist during a cherry-pick, a revert, or a + * rebase. The banner said the right thing and its only control did nothing. + * + * Parameterised by `?op=` — the check runs once per operation. + */ + "an-operation-is-ended-by-its-own-command": async (f) => { + const c = check(f); + const op = new URLSearchParams(location.search).get("op") || "merge"; + const banner = $(".dc-opbanner"); + c.ok(!!banner, `${op} in progress puts a banner on screen`); + if (!banner) return; + c.ok(banner.textContent.toLowerCase().includes(op), `the banner names the operation (${op})`); + const abort = [...banner.querySelectorAll("button")].find((b) => /abort/i.test(b.textContent)); + c.ok(!!abort, "it offers an Abort"); + if (!abort) return; + const before = window.__GS_INVOKED.length; + abort.click(); + await settle(300); + // Abort ASKS now. Working through a conflicted merge by hand and then + // pressing Abort — which sits right beside Continue — discards every + // resolution, and none of them were ever committed, so nothing can bring + // them back. It was the only irreversible click in the app that did not + // confirm. + const modal = $(".modal-ok"); + c.ok(!!modal, "Abort asks before discarding the resolutions"); + if (!modal) return; + c.ok( + /resolved|abandon/i.test(text($(".modal-message")) || ""), + "and says what is lost, not just that something will happen", + ); + modal.click(); + await settle(300); + const sent = window.__GS_INVOKED.slice(before).map((r) => r.channel).filter((ch) => /:(abort|continue)$/.test(ch)); + const family = { merge: "merge", rebase: "rebase", "cherry-pick": "cherryPick", revert: "revert" }[op]; + c.eq(sent[0], `${family}:abort`, `Abort ends the ${op}, not something else`); + }, + + /** + * `showChangesView()` rebuilds the composer on every stage, unstage, + * discard, Refresh and filesystem-watcher tick. The rebuilt textarea is a + * NEW element, so focus fell to <body> and the caret to 0: type a paragraph + * of commit message, let a build tool touch one file, and your next + * keystroke landed at the start of the first word. + */ + "the-composer-keeps-your-place-through-a-repaint": async (f) => { + const c = check(f); + const ta = $(".dc-message"); + c.ok(!!ta, "the Changes view has a composer"); + if (!ta) return; + ta.focus(); + ta.value = "fix: the thing that was broken"; + ta.dispatchEvent(new Event("input", { bubbles: true })); + // Caret in the MIDDLE — restoring to the end would hide the bug. + ta.setSelectionRange(5, 5); + ta.dispatchEvent(new Event("select", { bubbles: true })); + + const refresh = $('.changes-view button[title="Refresh"]'); + c.ok(!!refresh, "and something that repaints it"); + if (!refresh) return; + refresh.click(); + await settle(1400); + + const now = $(".dc-message"); + c.ok(!!now, "the composer is still there after the repaint"); + if (!now) return; + c.eq(now.value, "fix: the thing that was broken", "with the draft intact"); + c.eq(document.activeElement, now, "the keyboard is still in it"); + c.eq(now.selectionStart, 5, "and the caret is where you left it, not at 0"); + }, + + /** + * A peek and a dialog opened from inside it both listen for Escape on + * `document`, in the capture phase, and `stopPropagation()` does not stop a + * sibling listener on the same node. The peek registered first, so it ran + * first: one Escape closed the dialog AND the card that opened it. + */ + "escape-closes-one-layer-at-a-time": async (f) => { + const c = check(f); + // The stack this used to test — a ref PEEK, its menu, then a dialog — no + // longer exists: a ref is a routed page now, not a modal. The rule is + // unchanged and the new stack is a sharper case of it, because a detail + // PAGE wires Escape to go BACK. A dialog over one must consume Escape + // first, or a single press would close the dialog and leave the page too: + // two layers dismissed by one key, which is exactly what this guards. + const tagsSeg = $$(".gh-seg-btn")[2]; + c.ok(!!tagsSeg, "the ref manager has a Tags segment"); + if (!tagsSeg) return; + tagsSeg.click(); + await settle(500); + const row = $(".sec-row"); + c.ok(!!row, "the view has a row to drill into"); + if (!row) return; + row.click(); + await settle(1200); + + const page = $(".refdetail-view"); + c.ok(!!page, "clicking it opens the ref's page"); + if (!page) return; + const del = $$(".det-tb-actions button").find((b) => /delete/i.test(text(b))); + c.ok(!!del, "whose top bar offers something that opens a dialog"); + if (!del) return; + del.click(); + await settle(700); + const dlg = $(".modal-overlay"); + c.ok(!!dlg, "a dialog opens above the page"); + if (!dlg) return; + + // CANCELABLE. A KeyboardEvent constructed without it makes + // `preventDefault()` a no-op, so a synthetic Escape tests a path no real + // keypress takes — and every handler downstream of "did someone claim + // this key" then behaves differently than it does for a user. + document.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }), + ); + await settle(500); + c.ok(!$(".modal-overlay"), "one Escape closes the dialog"); + c.ok(!!$(".refdetail-view"), "and leaves the page that opened it on screen"); + }, + + /** + * The same rule, on the two surfaces that had also forgotten it: the + * Projects issue drawer and the notifications popover. Three independent + * copies of the same three guards is why they diverged; they now share + * `ownsEscape()`, and this check is what keeps a fourth surface from + * getting it wrong quietly. + * + * `?arg=` names the surface: its own selector, and how to open it. + */ + "a-surface-under-a-dialog-keeps-its-escape": async (f) => { + const c = check(f); + const which = window.__GS_ARG || "drawer"; + const sel = which === "drawer" ? ".gh-drawer" : ".notif-pop"; + + const surface = $(sel); + c.ok(!!surface, `the ${which} is open`); + if (!surface) return; + + // Anything in it that opens a DIALOG. Ordered, because several of these + // verbs are routes now rather than modals — "Edit" on an issue opens the + // composer page — and this check is about Escape between LAYERS, so it + // needs a candidate that genuinely stacks one. + const nameOf = (b) => (b.getAttribute("aria-label") || b.title || b.textContent || "").trim(); + const wanted = [/close issue|mark all|delete|rename/i, /new |create|add /i, /edit/i]; + const buttons = [...surface.querySelectorAll("button")]; + let opener; + for (const re of wanted) { + opener = buttons.find((b) => re.test(nameOf(b))); + if (opener) break; + } + c.ok(!!opener, `and offers something that opens a dialog (${which})`); + if (!opener) return; + opener.click(); + await settle(700); + const dlg = $(".modal-overlay"); + c.ok(!!dlg, `a dialog opens above the ${which}`); + if (!dlg) return; + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + await settle(500); + c.ok(!$(".modal-overlay"), "one Escape closes the dialog"); + c.ok(!!$(sel), `and leaves the ${which} that opened it on screen`); + }, + + /** + * The Code file viewer's own "Back" called `showCodeView()` directly, + * repainting the listing without telling the navigation history anything. + * So the top-bar Back chevron still pointed at whatever you were doing + * BEFORE you opened the file, and Forward pointed at the file you had just + * left — every other hop in this view routes through `routeView`. + */ + "the-code-viewer-back-is-a-navigation": async (f) => { + const c = check(f); + const fileRow = [...$$(".code-listing .file-row")].find((r) => + /^README\.md/.test((r.textContent || "").trim()), + ); + c.ok(!!fileRow, "the listing offers a file"); + if (!fileRow) return; + fileRow.click(); + await settle(1200); + c.ok(!!$(".code-file-view"), "which opens in the file viewer"); + + const pageBack = [...$$(".code-file-view button")].find((b) => /^back$/i.test((b.textContent || "").trim())); + c.ok(!!pageBack, "and the viewer offers its own Back"); + if (!pageBack) return; + pageBack.click(); + await settle(1200); + c.ok(!$(".code-file-view"), "which returns to the listing"); + c.ok(!!$(".code-listing"), "showing the folder again"); + + // The point, asserted by DESTINATION rather than by the chevron's + // enabled-ness: the chevron is live either way, because OPENING the file + // recorded an entry. What the missing entry changes is where it goes. + // With the hop recorded, the top-bar Back returns to the file you were + // just looking at; without it, Back steps over the file entirely. + const chev = [...$$(".topbar-nav")].find((b) => (b.getAttribute("aria-label") || "") === "Back"); + c.ok(!!chev && !chev.disabled, "the top-bar Back is live"); + if (!chev || chev.disabled) return; + chev.click(); + await settle(1200); + c.ok(!!$(".code-file-view"), "and it returns to the file you just left, not past it"); + }, + + /** + * A keyboard resizer must MOVE, monotonically, in the direction you press. + * The terminal list's mirrored the value inside `set` while `get` returned + * it un-mirrored, so the two disagreed: from 168px, → gave 268, → again + * gave 168, forever. Two keystrokes returned you to the start and nothing + * between the two widths was reachable at all. + * + * Asserted on EVERY resizer in the app, not just the one that was broken — + * the helper takes an `inverted` flag precisely because getting this wrong + * is easy, and three of the five dividers are on the far side of their + * handle. + */ + "a-resizer-moves-the-way-you-press-it": async (f) => { + const c = check(f); + // Geometry, so the transitions have to go — see `noAnimation`. Without + // this the rect read back after a keypress is the one from before it, and + // whether the check passes comes down to how the virtual clock happened + // to schedule that run. + noAnimation(); + const handles = $$('[role="separator"]'); + c.ok(handles.length > 0, "the view has a resizer"); + let measured = 0; + const press = (h, key) => h.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })); + for (const h of handles) { + // A collapsed pane's divider says so, and answers no key by design. + if (h.getAttribute("aria-disabled") === "true") continue; + const label = h.getAttribute("aria-label") || "(unlabelled)"; + const r0 = h.getBoundingClientRect(); + // A divider inside a hidden pane has a [0,0,0,0] rect: it is IN the DOM + // and enabled, but nothing about it can be measured. Skipping it + // silently is how this check passed for four scenes while the one + // divider it was written for was never driven at all. + if (r0.width === 0 && r0.height === 0) continue; + measured++; + + const horizontal = h.getAttribute("aria-orientation") === "horizontal"; + // The key names a DIRECTION ON SCREEN: → moves a vertical divider + // right, ↑ moves a bottom-anchored horizontal one up. Asserting on + // `aria-valuenow` alone cannot see this — a divider whose value grows + // as the handle walks the other way is still perfectly monotonic, which + // is exactly the bug this check was written for and exactly the bug it + // did not catch. + const key = horizontal ? "ArrowUp" : "ArrowRight"; + const axis = (rect) => (horizontal ? rect.top : rect.left); + const wanted = horizontal ? "up" : "right"; + + h.focus(); + // Measured AFTER a settle, not immediately: the panes these dividers + // move are sized through a CSS variable with a transition, so a + // `getBoundingClientRect` in the same task returns the pre-press + // geometry every time and the handle looks frozen. (That is why the + // first version of this check asserted on `aria-valuenow` instead — and + // why it could not tell a divider walking the wrong way from a correct + // one.) + const seen = [axis(r0)]; + for (let i = 0; i < 3; i++) { + press(h, key); + await settle(60); + seen.push(axis(h.getBoundingClientRect())); + } + const moved = seen[seen.length - 1] - seen[0]; + const atStop = + h.getAttribute("aria-valuenow") === h.getAttribute("aria-valuemin") || + h.getAttribute("aria-valuenow") === h.getAttribute("aria-valuemax"); + if (moved === 0) { + c.ok(atStop, `${label}: ${key} moves the handle (it is at ${seen[0]}px, range ${h.getAttribute("aria-valuemin")}–${h.getAttribute("aria-valuemax")})`); + continue; + } + c.ok( + horizontal ? moved < 0 : moved > 0, + `${label}: ${key} moves the handle ${wanted}, not the other way (${seen.join(" → ")})`, + ); + // …and every step goes the same way. A handle that oscillates can end + // up displaced in the right direction by luck. + const steps = seen.slice(1).map((v, i) => v - seen[i]).filter((d) => d !== 0); + c.ok( + steps.every((d) => (horizontal ? d < 0 : d > 0)), + `${label}: every press moves it ${wanted} (${seen.join(" → ")})`, + ); + } + c.ok(measured > 0, "at least one divider in this scene could actually be measured"); + }, + + /** + * The banner's forward controls, in the two shapes that decide them. + * + * `canContinue` / `canSkip` come from the host now, and the banner's job is + * to render them faithfully. Both halves of that had been wrong: an enabled + * Continue on an operation git would refuse, and a Skip that hard-resets + * offered at a pause the user asked for. `?skip=1` is the emptied-patch + * shape; without it the same scene is the ordinary one. + */ + "the-banner-offers-only-what-git-would-accept": async (f) => { + const c = check(f); + const arg = (window.__GS_ARG || "continue").split(":"); + const wantSkip = arg[0] === "skip"; + const banner = $(".dc-opbanner"); + c.ok(!!banner, "the banner renders"); + if (!banner) return; + const btns = [...banner.querySelectorAll("button")]; + const byText = (re) => btns.find((b) => re.test((b.textContent || "").trim())); + const cont = byText(/^Continue$/); + const skip = byText(/^Skip/); + c.ok(!!byText(/^Abort$/), "Abort is always there"); + + if (wantSkip) { + c.ok(!!skip, "an emptied patch offers Skip — git's own way out"); + c.ok(!cont || cont.disabled, "and does not offer a Continue git would refuse"); + c.ok( + !skip || !skip.classList.contains("btn-primary"), + "Skip is never the primary button: it discards work", + ); + } else { + c.ok(!!cont && !cont.disabled, "an ordinary stop offers Continue"); + c.ok(!skip, "and no Skip, which would discard the commit"); + } + }, + + /** + * Pressing a banner button twice must not run it twice. `serialize()` in + * the main process QUEUES the second call rather than dropping it, so an + * enabled button really did discard two patches on a double-click. + */ + "a-banner-button-cannot-be-fired-twice": async (f) => { + const c = check(f); + const banner = $(".dc-opbanner"); + c.ok(!!banner, "the banner renders"); + if (!banner) return; + const abort = [...banner.querySelectorAll("button")].find((b) => /^Abort$/.test((b.textContent || "").trim())); + c.ok(!!abort, "with an Abort"); + if (!abort) return; + const before = window.__GS_INVOKED.length; + // Three clicks on Abort. It opens a confirm now, so the second and third + // land on the scrim — which must not stack three dialogs, and answering + // once must not send the command three times. + abort.click(); + abort.click(); + abort.click(); + await settle(400); + c.eq($$(".modal-ok").length, 1, "three clicks open ONE dialog"); + $(".modal-ok")?.click(); + await settle(400); + const sent = window.__GS_INVOKED.slice(before).map((r) => r.channel).filter((ch) => /:(abort|continue|skip)$/.test(ch)); + c.eq(sent.length, 1, `three clicks send ONE command, not ${sent.length} (${sent.join(", ")})`); + }, + + /** + * A detail page's own Back must POP the history, not push onto it. + * + * Measured on the shipping build: after pressing `.det-back`, FORWARD is + * disabled — which only happens if the press appended an entry rather than + * stepping back over one. So the one control that should restore your place + * is the control that destroys it, on every detail page in the app. + * + * The cause is that `SectionTarget.from` is `{view,label}` and can only name + * a LIST, so every consumer calls `nav(view,{list:true})`. It structurally + * cannot say "return to Pull Request #106" — which is why leaving a PR for + * a pipeline and pressing back lands you in the Actions list. + * + * Asserted on the history STATE, not on what rendered: landing on the right + * view by luck is not the same as having gone back. + */ + "a-detail-page-back-pops-the-history": async (f) => { + const c = check(f); + const chev = () => [...$$(".topbar-nav")].find((b) => (b.getAttribute("aria-label") || "") === "Back"); + const fwd = () => [...$$(".topbar-nav")].find((b) => (b.getAttribute("aria-label") || "") === "Forward"); + c.ok(!!chev() && !!fwd(), "the top bar has Back and Forward"); + if (!chev() || !fwd()) return; + c.eq(fwd().disabled, true, "Forward starts disabled — nothing has been gone back over"); + + const back = $(".det-back"); + c.ok(!!back, "the detail page offers its own Back"); + if (!back) return; + back.click(); + await settle(1000); + + c.ok(!$(".gh-detail, .det-main"), "it leaves the detail page"); + // The point. A pop leaves somewhere to go forward TO; a push does not. + c.eq( + fwd().disabled, + false, + "Forward is live after Back — pressing Back must step over an entry, not append one", + ); + }, + + /** + * Leaving a pull request for one of its pipelines, then pressing Back, must + * return to THE PULL REQUEST — not to the Actions list. + * + * The owner's words: "going from pr checks tab to a pipeline, then back + * arrow should send u back to pr not to pipelines view". Two separate + * defects made that impossible: the back button pushed instead of popping, + * and `SectionTarget.from` was `{view,label}` so it could only ever name a + * LIST — there was no way to say "Pull Request #106" at all. + * + * This check could not be written before now: no scene in the repo had a + * check row with a `detailsUrl`, so `.gh-check-row.is-link` did not exist + * anywhere and the journey was unreachable. + */ + "leaving-a-pr-for-its-pipeline-comes-back-to-the-pr": async (f) => { + const c = check(f); + const link = [...$$(".gh-check-row.is-link")].find((r) => /build/i.test(r.textContent || "")); + c.ok(!!link, "the PR has a check row that links to a run"); + if (!link) return; + + const before = window.__GS_ROUTES.length; + link.click(); + await settle(1400); + const went = window.__GS_ROUTES.slice(before).map((r) => r.view); + // Either CI surface counts as "in-app". A check row that names a JOB now + // goes straight to that job's log page rather than to the run page, + // which would put a list of jobs between you and the row you clicked — + // what the destination must NOT be is github.com. + c.ok( + went.some((v) => v === "actions" || v === "joblog"), + `it opens the run in-app (went: ${went.join(" → ") || "nowhere"})`, + ); + + const back = $(".det-back"); + c.ok(!!back, "the run page offers a Back"); + if (!back) return; + c.ok( + /pull request/i.test(back.textContent || ""), + `and it NAMES the pull request rather than the section ` + + `(says ${JSON.stringify((back.textContent || "").trim())})`, + ); + + back.click(); + await settle(1400); + c.ok(!!$(".det-view"), "pressing it lands on a detail page"); + const crumb = $(".det-crumb"); + c.eq( + (crumb?.textContent || "").trim(), + "#106", + "and that page is the pull request you left, not the Actions list", + ); + }, + + /** + * Clicking a commit must open THAT COMMIT, not eject you into the graph. + * + * Eight call sites answer "show me this commit" with `nav("graph",{sha})` + * plus a `reveal(sha)` that returns silently when the sha is outside the + * loaded page — and dead-ends entirely when the object is not in the clone. + * The owner hit it three separate ways: from a PR's commit list, from + * Compare, and from a release tag. + * + * Asserted on the ROUTE, not the DOM: "it went somewhere else" is invisible + * to a check that can only see what rendered. + * + * `?arg=` names the selector to click. + */ + "a-commit-opens-the-commit-not-the-graph": async (f) => { + const c = check(f); + const sel = window.__GS_ARG || ".clist-subject"; + const row = $(sel); + c.ok(!!row, `the view offers a commit row (${sel})`); + if (!row) return; + const before = window.__GS_ROUTES.length; + row.click(); + await settle(900); + const went = window.__GS_ROUTES.slice(before); + c.ok(went.length > 0, "clicking it navigates somewhere"); + if (!went.length) return; + const dest = went[went.length - 1]; + c.ok( + dest.view !== "graph", + `it must not land on the commit graph — that shows a row, not the changed files ` + + `(went to "${dest.view}")`, + ); + c.ok( + !!dest.target && typeof dest.target.sha === "string" && dest.target.sha.length >= 7, + "and it carries the sha of the commit that was clicked", + ); + }, + + /** + * Being signed in must not read as "Sign in". + * + * `github:status` deliberately does NOT decrypt the token — that raises the + * OS keychain prompt on every launch — so a signed-in user gets + * `{connected: true, login: undefined}` until some real request unlocks it. + * The chip branched on `connected && login`, which put that state in the + * ELSE: it told a signed-in user to sign in, then flipped to their name + * once anything else made a request. Two strings one character apart that + * mean opposite things. + * + * `?unlocked=0` is that launch state. + */ + "a-locked-token-still-reads-as-signed-in": async (f) => { + const c = check(f); + const chip = $(".topbar-acct"); + c.ok(!!chip, "the top bar has an account chip"); + if (!chip) return; + c.ok( + chip.classList.contains("is-connected"), + "a connected account reads as connected even before the token is unlocked", + ); + c.ok( + !/^sign in$/i.test(text(chip)), + `it must not tell a signed-in user to sign in (says ${JSON.stringify(text(chip))})`, + ); + c.match(chip.title, /signed in/i, "and the tooltip agrees"); + + // And a FAILED question is not an answer. Break the channel and re-ask: + // the chip must keep saying what it last knew, because a dropped IPC or a + // moment offline is not someone signing out — and this chip is the only + // place in the window that would have claimed otherwise. + const orig = window.gitstudio.invoke.bind(window.gitstudio); + window.gitstudio.invoke = (ch, p) => + ch === "github:status" ? Promise.reject(new Error("offline")) : orig(ch, p); + try { + await window.__gsSyncAccountChip?.(); + await settle(300); + const now = $(".topbar-acct"); + c.ok( + now?.classList.contains("is-connected"), + `a failed status question does not sign you out (says ${JSON.stringify(text(now))})`, + ); + c.ok(!/^sign in$/i.test(text(now)), "and does not offer to sign you in"); + } finally { + window.gitstudio.invoke = orig; + } + }, + + /** + * Three defect classes, measured on every view rather than found one + * screenshot at a time. + * + * Each of these was a real bug on some surface this session, and each is + * the kind that spreads: a hover control painted over a row's own text + * (Organizations, 129px over 128px of description), a scrollable region + * that Tab cannot reach (the job log, no tabindex at all against a + * 16-line port), and a control with no accessible name. + * + * Fixing them per-surface is how they came back. A check that walks all of + * them is the only version that holds. + */ + "no-view-hides-its-own-content-or-locks-out-the-keyboard": async (f) => { + const c = check(f); + noAnimation(); + const rows = $$(".list-row, .sec-row, .file-row, .gh-row, .cmt-file").slice(0, 40); + // Hover everything first: these controls only exist on hover, which is + // exactly what makes them easy to miss. + for (const row of rows) row.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + await settle(180); + + const covered = []; + for (const row of rows) { + const label = row.querySelector("[class*=title], [class*=name], .row-meta-title"); + if (!label) continue; + const L = label.getBoundingClientRect(); + if (L.width === 0) continue; + for (const b of row.querySelectorAll("button")) { + const R = b.getBoundingClientRect(); + if (R.width === 0) continue; + const ox = Math.min(L.right, R.right) - Math.max(L.left, R.left); + const oy = Math.min(L.bottom, R.bottom) - Math.max(L.top, R.top); + if (ox > 4 && oy > 4) covered.push(text(label).slice(0, 30)); + } + } + c.eq( + [...new Set(covered)].length, + 0, + `a control is painted over the row's own text: ${[...new Set(covered)].slice(0, 3).join(", ")}`, + ); + + const unnamed = $$("button") + .slice(0, 200) + .filter((b) => b.offsetParent !== null) + .filter((b) => !(b.getAttribute("aria-label") || b.textContent || b.title || "").trim()) + .map((b) => b.className.split(" ")[0] || "(button)"); + c.eq( + [...new Set(unnamed)].length, + 0, + `a control announces nothing: ${[...new Set(unnamed)].slice(0, 4).join(", ")}`, + ); + + // A region you can scroll must be reachable without a pointer. Monaco is + // excluded: it owns its own keyboard handling and its inner scroller is + // an implementation detail of an editor that IS focusable. + const stuck = $$('[role="log"], .log-scroll, [class*=scroll]') + .filter((x) => !x.closest(".monaco-editor") && !/monaco/.test(x.className)) + .filter((x) => x.scrollHeight > x.clientHeight + 40) + .filter((x) => x.tabIndex < 0 && !x.querySelector("[tabindex]:not([tabindex='-1'])")) + .map((x) => x.className.split(" ")[0]); + c.eq( + [...new Set(stuck)].length, + 0, + `a scrollable region cannot be reached by Tab: ${[...new Set(stuck)].slice(0, 3).join(", ")}`, + ); + }, + + /** + * A truncated path must always be recoverable, and a rename must say what + * it was renamed FROM. + * + * The file column is 268px, so the directory is elided — deliberately from + * the left, because the tail distinguishes. That is only safe if the full + * path survives somewhere, and `previousFilename` was being DROPPED at the + * mapper, so an `R` row could say a file was renamed and never say from + * what — the one fact that makes a rename readable. GitHub sends it in the + * response already. + */ + "a-truncated-path-is-still-recoverable": async (f) => { + const c = check(f); + const rows = $$(".file-row"); + c.ok(rows.length >= 5, `the PR lists its files (${rows.length})`); + if (!rows.length) return; + + for (const row of rows) { + const meta = row.querySelector(".dc-file-meta"); + const name = text(row.querySelector(".dc-file-name")); + c.ok(!!meta?.title, `${name}: carries its full path`); + if (!meta?.title) continue; + // The title must be the WHOLE path, not the same elision the row shows. + c.ok( + !meta.title.startsWith("…") && meta.title.includes(name), + `${name}: the tooltip is the full path, not the truncation again (${meta.title})`, + ); + } + + // A rename names both sides. + const renamed = rows.find((r) => /status-R/.test(r.className)); + c.ok(!!renamed, "the PR contains a rename"); + if (renamed) { + const t = renamed.querySelector(".dc-file-meta")?.title || ""; + c.match(t, /→/, `a rename says what it came from (${t})`); + } + }, + + /** + * The commit page has to work at a real size. + * + * It shipped verified against a SEVEN-file fixture, which says nothing. A + * 420-file merge — an ordinary size for a codemod or a lockfile bump — puts + * 13,027px of file list in a 566px column. + * + * Measured before building anything: rendering all 420 rows costs 25ms, so + * virtualisation was NOT the problem and building it would have been the + * wrong work. Having no way to ASK for a file was the problem. + */ + "a-large-commit-can-be-navigated": async (f) => { + const c = check(f); + const merge = $$(".clist-row").find((r) => /Merge the generated/.test(text(r))); + c.ok(!!merge, "the PR lists a large merge commit"); + if (!merge) return; + merge.querySelector(".clist-subject").click(); + await settle(1600); + + const rows = () => $$(".cmt-file").filter((r) => !r.hidden); + c.ok(rows().length > 300, `the commit page lists all of its files (${rows().length})`); + c.match(text(".cmt-statbar"), /420 files?\b/, "and says how many"); + + const filter = $(".cmt-filter"); + c.ok(!!filter, "at this size the list can be filtered, not just scrolled"); + if (!filter) return; + + // Every term must match somewhere in the path, so two remembered + // fragments narrow better than one exact prefix. + filter.value = "engine module-003"; + filter.dispatchEvent(new Event("input", { bubbles: true })); + await settle(200); + c.eq(rows().length, 1, "two terms narrow to the one file"); + c.match(text(".cmt-filter-count"), /1 of 420/, "and the count says what was hidden"); + + // Escape in a filter means "undo the filter" before it means "leave". + filter.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + await settle(200); + c.ok(rows().length > 300, "Escape clears the filter rather than leaving the page"); + }, + + /** + * Publishing is a decision, not a checkbox. + * + * "same is for publishing releases". The form expressed the most + * consequential choice on it — private draft, or announced to everyone + * watching the repository — as a checkbox reading "Draft (don't publish + * yet)" beside a button reading "Create". The quietest control on the form + * decided the loudest thing it does. + * + * Two named buttons instead, and the empty-tag refusal now SAYS something: + * the form rendered a `.modal-note-error` slot only after a rejected + * submit, so the first failure had nowhere to be reported and simply moved + * focus. + */ + "creating-a-release-names-what-the-button-will-do": async (f) => { + const c = check(f); + const opener = [...$$("button")].find((b) => /new release|create release/i.test(text(b))); + c.ok(!!opener, "the view offers New release"); + if (!opener) return; + opener.click(); + await settle(900); + + // The composer is a PAGE now (views/releaseCompose.ts), not a modal — the + // demands below are unchanged, only where they are looked for. + c.ok(!!$(".relc-form"), "it opens the release composer"); + const labels = $$(".relc-actions button").map((b) => text(b)); + c.ok( + labels.some((l) => /^publish/i.test(l)), + `the primary action says it publishes (${labels.join(", ") || "no buttons"})`, + ); + c.ok(labels.some((l) => /draft/i.test(l)), "and drafting is its own named button"); + // The old checkbox must be gone — two ways to say the same thing is worse + // than either alone. + const checks = $$(".relc-check").map((x) => text(x)); + c.ok( + !checks.some((x) => /^draft/i.test(x)), + `draft is not ALSO a checkbox (${checks.join(", ") || "none"})`, + ); + + // A tag is required, and the refusal has to be legible. + const publish = $$(".relc-actions button").find((b) => /^publish/i.test(text(b))); + c.ok(!!publish, "the publish button exists"); + if (!publish) return; + publish.click(); + await settle(400); + c.ok(!!$(".relc-form"), "an empty tag does not submit"); + const note = $(".relc-error"); + c.ok(!!note && !note.hidden, "and the form says why"); + c.match(text(note), /tag/i, "naming the field that is missing"); + const tag = $(".relc-form .gh-combo-input"); + c.ok(!!tag, "the tag field is findable"); + if (!tag) return; + c.eq(tag.getAttribute("aria-invalid"), "true", "and marks it for assistive tech"); + + // Fixing it withdraws the complaint, rather than leaving it accusing a + // field that is now correct. + tag.value = "v2.0.0"; + tag.dispatchEvent(new Event("input", { bubbles: true })); + await settle(200); + c.ok($(".relc-error").hidden, "typing a tag clears the message"); + c.eq(tag.getAttribute("aria-invalid"), null, "and the invalid mark"); + }, + + /** + * Escape must not destroy what you typed. + * + * "editing a release is complete garbage compared to github ui ux, same is + * for publishing releases" / "Same goes for issues creating and editing". + * + * Both were a bare textarea in a modal, and Escape discarded everything + * without a word — Escape being the key people press to mean "never mind" + * everywhere else in the app. A confirm dialog is the obvious answer and + * the wrong one: it makes leaving expensive instead of making the text + * safe, and still loses everything to a route change or a restart. + * + * `?arg=` names the button that opens the composer. + */ + "a-composer-does-not-lose-what-you-typed": async (f) => { + const c = check(f); + const want = window.__GS_ARG || "new issue"; + const opener = () => + [...$$("button")].find((b) => new RegExp(want, "i").test((b.textContent || "").trim())); + c.ok(!!opener(), `the view offers "${want}"`); + if (!opener()) return; + + opener().click(); + await settle(700); + const ta = $(".md-text"); + c.ok(!!ta, "the composer uses the shared markdown editor"); + if (!ta) return; + + const typed = "something worth keeping"; + ta.value = typed; + ta.dispatchEvent(new Event("input", { bubbles: true })); + await settle(650); // longer than the draft's debounce + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + await settle(500); + c.ok(!$(".modal-card"), "Escape closes the form"); + + opener().click(); + await settle(700); + const again = $(".md-text"); + c.ok(!!again, "the form re-opens"); + if (!again) return; + c.eq(again.value, typed, "and what you typed is still there"); + }, + + /** + * Preview has to render through the SAME renderer as the published body, or + * the two drift and the preview becomes a lie you check against. + */ + "the-editor-previews-with-the-real-renderer": async (f) => { + const c = check(f); + const opener = [...$$("button")].find((b) => /new issue/i.test((b.textContent || "").trim())); + c.ok(!!opener, "the view offers New issue"); + if (!opener) return; + opener.click(); + await settle(700); + + const ta = $(".md-text"); + c.ok(!!ta, "the composer uses the shared editor"); + if (!ta) return; + const tabs = $$(".md-tab").map((t) => text(t)); + c.ok(tabs.includes("Write") && tabs.includes("Preview"), `it has Write and Preview (${tabs.join(", ")})`); + c.ok($$(".md-tool").length >= 6, "and a toolbar"); + + ta.value = "## Heading\n\n- one\n- two\n\n**bold** and `code`"; + ta.dispatchEvent(new Event("input", { bubbles: true })); + await settle(300); + $$(".md-tab").find((t) => text(t) === "Preview").click(); + await settle(400); + + const pv = $(".md-preview"); + c.ok(!!pv && !pv.hidden, "Preview shows"); + if (!pv) return; + // Real markdown structure, not escaped text or a plain dump. + c.ok(!!pv.querySelector("h2"), "a heading renders as a heading"); + c.eq(pv.querySelectorAll("li").length, 2, "list items render as list items"); + c.ok(!!pv.querySelector("strong") && !!pv.querySelector("code"), "inline marks render"); + }, + + /** + * Pointing at a row must not hide what the row says. + * + * "org repos view is trash and still has old buttons showing on hover." + * Measured on the shipping build: a pair of hover-revealed text buttons + * 129px wide, overlaying 128px of the description on a 441px card — about a + * third of the content — and revealed by the SAME gesture that makes you + * look at the card. So the description vanished exactly when you went to + * read it. A fade had been added to soften that; the buttons still won. + * + * Asserted as geometry, not as "the buttons are gone": any future control + * that overlays the content fails this the same way. + */ + "hovering-a-repo-row-does-not-cover-its-description": async (f) => { + const c = check(f); + const rows = $$(".gh-org-grid .list-row"); + c.ok(rows.length > 0, "the org lists repositories"); + if (!rows.length) return; + + for (const row of rows.slice(0, 3)) { + const name = text(row.querySelector("[class*=title]")) || "(row)"; + row.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + row.dispatchEvent(new PointerEvent("pointerover", { bubbles: true })); + await settle(120); + + const desc = row.querySelector("[class*=desc], .row-meta-sub"); + if (!desc) continue; + const d = desc.getBoundingClientRect(); + + // Anything positioned over the row's own text, whenever it appears. + const covering = [...row.querySelectorAll("button, .row-actions")].filter((b) => { + const r = b.getBoundingClientRect(); + if (r.width === 0) return false; + const overlap = Math.min(d.right, r.right) - Math.max(d.left, r.left); + const vertical = Math.min(d.bottom, r.bottom) - Math.max(d.top, r.top); + return overlap > 4 && vertical > 4; + }); + c.eq( + covering.length, + 0, + `${name}: ${covering.length} control(s) sit on top of the description — ` + + `it must reserve its width, not take it on hover`, + ); + } + }, + + /** + * The log has to be readable with a keyboard, and big enough to read. + * + * "scrolling the logs is still trash ux, its too fast and not easy to use + * and practical at all, it just looks kinda pretty." + * + * "Too fast" was measurable and it was not the scroll speed: the pane was + * 337px against a 20px line height — SIXTEEN lines — so an ordinary + * trackpad flick of 2,000-4,000px is six to twelve screenfuls with nothing + * readable on the way past. Overscrolling at the bottom then carried the + * whole run page away, and the scroller had no tabindex at all, so there + * was no keyboard alternative: a trackpad was the only way through 50,000 + * lines. + */ + "the-log-is-navigable-without-a-trackpad": async (f) => { + const c = check(f); + const s = $$(".log-scroll").pop(); + c.ok(!!s, "the job log is open"); + if (!s) return; + + // A document's worth of lines, not a slit. + const lines = Math.floor(s.clientHeight / 20); + c.ok(lines >= 24, `the log shows a readable number of lines at once (${lines})`); + c.eq( + getComputedStyle(s).overscrollBehavior, + "contain", + "reaching the end must not scroll the page out from under the log", + ); + + // It can take the keyboard, and says what it is. + c.eq(s.getAttribute("role"), "log", "it is announced as a log"); + // tabIndex 0, not merely focusable. `-1` still accepts a programmatic + // `.focus()`, so asserting on activeElement alone passes on a scroller + // that Tab can never reach — which was the actual defect: no keyboard + // route to the log at all. + c.eq(s.tabIndex, 0, "and it is reachable by Tab, not just by script"); + s.focus(); + c.eq(document.activeElement, s, "and takes focus"); + + const key = (k, shift) => + s.dispatchEvent(new KeyboardEvent("keydown", { key: k, shiftKey: !!shift, bubbles: true })); + + // Home/End reach both ends; a page moves by a SCREENFUL, so the step + // follows whatever height the pane happens to have. + key("Home"); + await settle(120); + c.eq(s.scrollTop, 0, "Home reaches the top"); + const page = (Math.floor(s.clientHeight / 20) - 1) * 20; + key("PageDown"); + await settle(140); + c.eq(s.scrollTop, page, `PageDown moves one screenful (${page}px)`); + key("ArrowUp"); + await settle(120); + c.eq(s.scrollTop, page - 20, "and an arrow moves one line"); + key("End"); + await settle(140); + c.ok(s.scrollTop > page, "End reaches the bottom"); + }, + + /** + * `n` walks the failures — the question actually being asked of a CI log. + * The error chip could already do it, but only by mouse and only forwards, + * and it centred the line without marking it, which in a wall of monospace + * is most of what "not practical" means. + */ + "the-log-can-jump-between-failures": async (f) => { + const c = check(f); + // The FAILING job's log — the scene opens whichever comes first, and on a + // failed run that is usually the job that passed. Asking for "the log + // with errors in it" is what the check actually means. + const failing = $$(".gh-job-card, .gh-job").find((j) => /failure/i.test(j.textContent || "")); + const opener = (failing || document).querySelector(".gh-job-log"); + if (opener) { + opener.click(); + await settle(1500); + } + const s = $$(".log-scroll").pop(); + c.ok(!!s, "a job log is open"); + if (!s) return; + const chip = $$(".log-chip-err").find((x) => !x.hidden); + c.ok(!!chip, "the log reports that it contains errors"); + c.match(text(chip), /\d+ error/, "and how many"); + + s.focus(); + s.scrollTop = 0; + await settle(100); + s.dispatchEvent(new KeyboardEvent("keydown", { key: "n", bubbles: true })); + await settle(400); + + c.ok(s.scrollTop > 0, "pressing n moves to a failure"); + const hit = $(".log-line.is-hit"); + c.ok(!!hit, "and marks the line it landed on, so it can be found"); + if (hit) { + c.match( + text(hit), + /error|exit code|✗|FAIL/i, + `the marked line is the failure (got ${JSON.stringify(text(hit).slice(0, 60))})`, + ); + } + }, + + /** + * A failed read must not render as an empty result. + * + * Four `.catch(() => [])` sites in the bridge turned a rate limit, a dropped + * connection or a 500 into "This PR has no commits yet." — beside a rail + * reading 14 — with no way to retry. The renderer's errorState-with-Retry + * branches were already written and could never run. + * + * Unwritable until now: the harness had no way to make a channel fail, so + * every error path in the app was unreachable from a check. `?fail=` is that + * switch, and `?arg=` names the sub-tab to open. + */ + "a-failed-read-says-so-instead-of-showing-nothing": async (f) => { + const c = check(f); + const body = text(".gh-subcontent") || text(".det-main"); + // The empty state's own words. Seeing them here means the app has + // concluded "there are none" from a request that never answered. + c.ok( + !/no commits yet|has no files|nothing here/i.test(body), + `a failed request must not read as an empty result (body: ${JSON.stringify(body.slice(0, 90))})`, + ); + c.ok( + /couldn't load|could not load|failed/i.test(body), + "it says the read failed", + ); + const retry = [...$$("button")].find((b) => /retry|try again/i.test(b.textContent || "")); + c.ok(!!retry, "and offers a way to try again"); + }, + + /** + * A deleted file and a renamed one must not read the same. + * + * GitHub sends WORDS — added, removed, modified, renamed, copied — and the + * row took `status.charAt(0).toUpperCase()`, which collapses "removed" and + * "renamed" onto the same "R", in the same amber, on the one screen where + * telling them apart is the entire point. "changed" and "copied" both landed + * on C. + * + * Unwritable until now: every file in the fixture was "modified", so the + * collision could not occur in any scene. + */ + "a-deleted-file-does-not-look-like-a-renamed-one": async (f) => { + const c = check(f); + const rows = $$(".file-row"); + c.ok(rows.length >= 5, `the PR lists its files (${rows.length})`); + if (!rows.length) return; + + const letters = rows + .map((r) => (r.className.match(/status-([A-Z])/) || [])[1]) + .filter(Boolean); + c.ok(letters.includes("D"), `a removed file is D (saw ${letters.join("")})`); + c.ok(letters.includes("R"), "a renamed file is R"); + c.ok(letters.includes("A"), "an added file is A"); + // The count in the tab must match what is listed — three files behind a + // tab reading "Files (9)" is the app contradicting itself. + const tab = [...$$(".gh-subtab")].find((b) => /^Files/.test((b.textContent || "").trim())); + if (tab) { + const claimed = Number((tab.textContent || "").replace(/\D+/g, "")); + c.eq(rows.length, claimed, `the tab says ${claimed} files and the list shows ${rows.length}`); + } + }, + + /** + * The commit page answers the question the graph could not: what changed. + * + * "it teleports u to the commit graph which tells u nothing about the + * changed files". So the page has to actually list them, with their status + * and their counts, and selecting one has to show that file's diff. + */ + "the-commit-page-shows-what-changed": async (f) => { + const c = check(f); + c.ok(!!$(".cmt-view"), "the commit page is showing"); + const rows = $$(".cmt-file"); + c.ok(rows.length >= 5, `it lists the changed files (${rows.length})`); + if (!rows.length) return; + + // The diffstat exists and carries the numbers — the WORDING is free to + // change and did ("7 files changed" became "7 files" when the stat bar + // moved into a 260px column beside the diff, where "changed" bought + // nothing next to the ± counts). + const stat = text(".cmt-statbar"); + c.match(stat, /\d+ files?\b/, "with a diffstat naming how many files"); + c.match(stat, /\+[\d,]+/, "including lines added"); + c.match(stat, /−[\d,]+/, "and lines removed"); + + // Statuses are distinguishable — a deleted file and a renamed one must not + // read the same, which is a defect this app has had elsewhere. + const letters = new Set(rows.map((r) => text(r.querySelector(".cmt-file-status")))); + c.ok(letters.size >= 3, `with more than one kind of change (${[...letters].join(", ")})`); + + // The shared directory is shown ONCE, not repeated down every row — the + // filename is the part worth the width. + const prefix = text(".cmt-prefix"); + if (prefix) { + const repeated = rows.filter((r) => text(r.querySelector(".cmt-file-path")).startsWith(prefix)); + c.eq(repeated.length, 0, `the shared prefix ${JSON.stringify(prefix)} is not repeated in the rows`); + } + + // Selecting a file shows THAT file's diff. + const target = rows[2] || rows[0]; + const want = text(target.querySelector(".cmt-file-path")); + target.click(); + await settle(900); + c.ok(target.classList.contains("is-current"), "the selected row is marked"); + const shown = text(".cmt-diff .diffmode-bar, .cmt-diff"); + c.ok( + shown.includes(want.split("/").pop()), + `and the diff pane shows ${JSON.stringify(want)} (pane says ${JSON.stringify(shown.slice(0, 60))})`, + ); + }, + + /** + * A PAGE-level key handler sits underneath every floating layer, so any + * open layer outranks it. + * + * `wireDetailEsc` answers ← as well as Escape, and it was moved from a + * four-selector DOM whitelist to `ownsEscape()` — which cannot see a peek, + * because a peek registers as a "surface". So ← started navigating the page + * BACK out from under an open peek and throwing the peek away with it. + */ + "arrow-left-does-not-navigate-out-from-under-a-peek": async (f) => { + const c = check(f); + const detail = $(".gh-detail, .det-main"); + c.ok(!!detail, "a detail page is showing (the surface that owns ← as Back)"); + if (!detail) return; + + const author = detail.querySelector(".gh-meta-author"); + c.ok(!!author, "with an author chip that drills into a peek"); + if (!author) return; + author.click(); + await settle(900); + const peek = $(".peek-overlay"); + c.ok(!!peek, "a peek is open over the page"); + if (!peek) return; + + // Dispatched on the FOCUSED element, not on `document`. The handler asks + // `e.target.closest(...)` to keep ← inside tablists and toolbars, and + // `document` has no `closest` — a synthetic event aimed there throws + // inside the listener before the rule under test is ever reached, and the + // check passes on a broken build for a reason that has nothing to do + // with the fix. + const target = document.activeElement && document.activeElement !== document.body + ? document.activeElement + : $(".peek-card") || $(".peek-overlay"); + c.ok(!!target, "something inside the peek has the keyboard"); + target.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true })); + await settle(700); + c.ok(!!$(".peek-overlay"), "← does not throw the peek away"); + }, + + /** + * Signing out — by EITHER button — has to make the whole window stop + * claiming the account is still there. + * + * Sign out dropped the caches; Switch account, one line above it, dropped + * neither; and neither touched the top-bar chip, which asks `github:status` + * exactly once at construction and is only rebuilt on `repo:changed`. So + * the chip kept the previous account's name and avatar for the rest of the + * session while Settings one click away said "Not connected". + * + * Parameterised by `?arg=` — the check runs once per button. + */ + "signing-out-does-not-leave-the-old-account-on-screen": async (f) => { + const c = check(f); + const which = window.__GS_ARG || "Sign out"; + const chip = $(".topbar-acct"); + c.ok(!!chip, "the top bar has an account chip"); + if (!chip) return; + c.ok(chip.classList.contains("is-connected"), "which starts signed in"); + + const btn = [...$$(".settings-actions button, .settings-card button")].find( + (b) => (b.textContent || "").trim() === which, + ); + c.ok(!!btn, `Settings offers "${which}"`); + if (!btn) return; + btn.click(); + await settle(1500); + + const now = $(".topbar-acct"); + c.ok(!!now, "the chip is still there"); + if (!now) return; + c.eq( + now.classList.contains("is-connected"), + false, + `after "${which}" the chip no longer claims an account`, + ); + c.ok( + !/antonarnaudov/i.test(`${now.textContent} ${now.title}`), + `and does not still name them (text: ${JSON.stringify(now.textContent)}, title: ${JSON.stringify(now.title)})`, + ); + }, + + // ── The log page ───────────────────────────────────────────────────────── + // + // "scrolling the logs is still trash as initially reported, scrolling is + // too fast and the log window is too small, pls do it properly, you havent + // even touched that part." + // + // Measured before this: 523px of log in a 913px window, inside a run page + // that itself scrolled 1,048px — two nested scroll contexts, and the log + // getting whatever height was left over. A wheel flick moves 2,000-4,000px, + // which against a 16-line port is six to twelve screenfuls of nothing you + // can read on the way past. That IS "scrolling is too fast". + "the-log-gets-the-window": (f) => { + const c = check(f); + noAnimation(); + const scroll = $(".log-scroll"); + c.ok(!!scroll, "a log is open"); + if (!scroll) return; + const h = scroll.getBoundingClientRect().height; + c.ok( + h >= window.innerHeight * 0.65, + `the log takes the window (${Math.round(h)}px of ${window.innerHeight}px)`, + ); + // And nothing scrolls BEHIND it: a page that also scrolls is the other + // half of the complaint, because the wheel then means two things. + const page = $(".det-scroll"); + c.ok(!!page, "the page has its scroll container"); + if (page) { + c.ok( + page.scrollHeight <= page.clientHeight + 2, + `the page itself does not scroll (${page.scrollHeight} vs ${page.clientHeight})`, + ); + } + }, + "the-log-page-names-the-job": (f) => { + const c = check(f); + const current = $(".joblog-job.is-current"); + c.ok(!!current, "the rail marks which job is open"); + if (!current) return; + c.eq(current.getAttribute("aria-current"), "true", "and says so to assistive tech"); + const name = text(current.querySelector(".joblog-job-name")); + c.ok(!!name, "the current job has a name"); + c.ok( + text(".det-crumb").includes(name), + `the crumb names the log on screen (crumb ${JSON.stringify(text(".det-crumb"))}, job ${JSON.stringify(name)})`, + ); + }, + "picking-another-job-swaps-the-log": async (f) => { + const c = check(f); + const rows = $$(".joblog-job"); + c.ok(rows.length > 1, "the run has more than one job to switch between"); + if (rows.length < 2) return; + const other = rows.find((r) => !r.classList.contains("is-current")); + c.ok(!!other, "one of them is not the open one"); + if (!other) return; + const wanted = text(other.querySelector(".joblog-job-name")); + other.click(); + await settle(900); + c.ok(other.classList.contains("is-current"), "clicking it makes it the current job"); + c.ok(text(".det-crumb").includes(wanted), "and the crumb follows"); + const pane = $(".log-pane"); + c.ok(!!pane, "a log pane is still on screen"); + c.match( + pane?.getAttribute("aria-label"), + new RegExp(wanted.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), + "and it is that job's log", + ); + }, + // The run page must not ALSO host logs. Two entry points to the same log + // left the same card in visibly different states, and the inline pane is + // exactly the 523px box the report was about. + "the-run-page-sends-logs-to-their-page": async (f) => { + const c = check(f); + c.ok(!$(".log-pane"), "the run page holds no log pane of its own"); + const btn = $(".gh-job-log"); + c.ok(!!btn, "a job card offers its log"); + if (!btn) return; + const before = window.__GS_ROUTES.length; + btn.click(); + await settle(900); + const went = window.__GS_ROUTES.slice(before).map((r) => r.view); + c.ok(went.includes("joblog"), `it routes to the log page (went: ${went.join(" → ") || "nowhere"})`); + }, + + // ── The release composer ───────────────────────────────────────────────── + "the-release-notes-get-the-window": (f) => { + const c = check(f); + noAnimation(); + const ta = $(".relc-form .md-text"); + c.ok(!!ta, "the composer uses the shared markdown editor"); + if (!ta) return; + const h = ta.getBoundingClientRect().height; + c.ok( + h >= 320, + `the notes take the page rather than a modal's leftovers (${Math.round(h)}px)`, + ); + // The four things a release IS, all present on one page. + c.ok($$(".relc-form .gh-combo-input").length >= 2, "tag and target are both pickers"); + c.ok(!!$(".relc-title"), "the title has its own field"); + c.ok($$(".relc-check").length >= 2, "pre-release and latest are both askable"); + }, + /** + * A tag name means one of two very different things, and the composer has + * to say which: releasing a tag that exists, or CREATING one on whatever + * Target says. Nothing else on the form distinguishes them, and it is not + * undoable from here. + */ + "the-composer-says-when-it-will-create-a-tag": async (f) => { + const c = check(f); + const tag = $(".relc-form .gh-combo-input"); + c.ok(!!tag, "the tag field exists"); + if (!tag) return; + tag.value = "v9.9.9-brand-new"; + tag.dispatchEvent(new Event("input", { bubbles: true })); + await settle(200); + c.match(text(".relc-note"), /new tag/i, "a tag nothing has says it will be created"); + c.match(text(".relc-note"), /v9\.9\.9-brand-new/, "naming it"); + + tag.value = "ext-v1.11.1"; // in the fixture's release:tags + tag.dispatchEvent(new Event("input", { bubbles: true })); + await settle(200); + c.match(text(".relc-note"), /existing tag|points at/i, "an existing tag says it is existing"); + c.ok(!/will create/i.test(text(".relc-note")), "and does not promise to create it"); + }, + /** + * "Generate release notes" must never overwrite writing someone already + * did — that is the one thing a generate button can do that is worse than + * not existing. + */ + "generating-notes-keeps-what-you-wrote": async (f) => { + const c = check(f); + const tag = $(".relc-form .gh-combo-input"); + const ta = $(".relc-form .md-text"); + c.ok(!!tag && !!ta, "the composer is open"); + if (!tag || !ta) return; + tag.value = "v2.0.0"; + tag.dispatchEvent(new Event("input", { bubbles: true })); + const mine = "Read this first: upgrade notes."; + ta.value = mine; + ta.dispatchEvent(new Event("input", { bubbles: true })); + await settle(200); + + const gen = $(".relc-gen"); + c.ok(!!gen, "the composer offers to generate notes"); + if (!gen) return; + gen.click(); + await settle(900); + c.ok(ta.value.includes(mine), "what was already written survives"); + c.match(ta.value, /What's Changed/i, "and GitHub's notes are added"); + }, + + // ── The issue composer ─────────────────────────────────────────────────── + "the-issue-body-gets-the-window": (f) => { + const c = check(f); + noAnimation(); + const ta = $(".isc-form .md-text"); + c.ok(!!ta, "the composer uses the shared markdown editor"); + if (!ta) return; + c.ok( + ta.getBoundingClientRect().height >= 320, + `the description takes the page (${Math.round(ta.getBoundingClientRect().height)}px)`, + ); + c.ok(!!$(".isc-title"), "the title has its own field"); + }, + /** + * The sidebar the modal could never have. Labels, assignees and milestone + * used to mean a second trip through the issue's own page AFTER GitHub had + * already announced it to everyone watching. + */ + "composing-an-issue-can-decide-who-it-is-for": async (f) => { + const c = check(f); + const rail = $(".isc-view .det-rail"); + c.ok(!!rail, "the composer has a sidebar"); + if (!rail) return; + const sections = $$(".det-prop-label", rail).map((x) => text(x).toLowerCase()); + for (const want of ["labels", "assignees", "milestone"]) { + c.ok(sections.some((s) => s.includes(want)), `it offers ${want} (has: ${sections.join(", ")})`); + } + + const add = $$(".isc-add").find((b) => /label/i.test(text(b))); + c.ok(!!add, "labels can be picked"); + if (!add) return; + add.click(); + await settle(400); + const item = $$(".dropdown-item, .dropdown button")[0]; + c.ok(!!item, "the picker lists this repository's labels"); + if (!item) return; + const picked = text(item); + item.click(); + await settle(300); + c.ok( + text(".isc-chips").includes(picked.trim()), + `picking one shows it (${JSON.stringify(text(".isc-chips"))} should contain ${JSON.stringify(picked.trim())})`, + ); + + // And it must ride along WITH the create — a second request can fail on + // its own, and an issue that exists without the labels its author chose + // has already been announced. + const title = $(".isc-title"); + title.value = "A thing that broke"; + title.dispatchEvent(new Event("input", { bubbles: true })); + const before = window.__GS_INVOKED.length; + $$(".isc-actions button").find((b) => /create issue/i.test(text(b))).click(); + await settle(700); + const sent = window.__GS_INVOKED.slice(before).find((r) => r.channel === "issue:create"); + c.ok(!!sent, "it sends the issue"); + c.ok( + Array.isArray(sent?.payload?.labels) && sent.payload.labels.length > 0, + `with the labels attached (sent ${JSON.stringify(sent?.payload?.labels)})`, + ); + }, + + /** + * The diff is what the Files tab is FOR. + * + * The review panel took 42% of the pane unconditionally, so on a file with + * nothing to discuss the diff got 354px of a 913px window — the same "the + * code diff view itself is super small like its not important at all" the + * commit page was reported for. It folds now, and opens by itself only when + * this file has an unresolved thread. + * + * `?arg=` is "open" for the file that HAS a thread, "quiet" for one without. + */ + "the-files-tab-gives-the-diff-the-room": (f) => { + const c = check(f); + noAnimation(); + const want = window.__GS_ARG || "quiet"; + const panel = $(".pr-threads"); + const diff = $(".pr-diff-surface"); + c.ok(!!panel && !!diff, "the Files tab has a diff and a review panel"); + if (!panel || !diff) return; + const dh = diff.getBoundingClientRect().height; + const ph = panel.getBoundingClientRect().height; + + if (want === "open") { + c.ok(panel.classList.contains("is-open"), "a file with an open thread shows it"); + c.match(text(".pr-threads-head"), /open of|comments \(/i, "and says how many"); + } else { + c.ok(!panel.classList.contains("is-open"), "a file with nothing to discuss stays folded"); + c.ok(ph < 60, `folded, the panel is one row (${Math.round(ph)}px)`); + c.ok(dh > 500, `so the diff gets the pane (${Math.round(dh)}px)`); + } + // Either way the panel must still SAY what it holds — folding is not + // hiding, and a resolved thread you cannot find is a thread you lose. + c.ok(text(".pr-threads-head").length > 0, "the fold names what is inside it"); + c.ok( + !!$(".pr-threads-head[aria-expanded]"), + "and reports its state to assistive tech", + ); + }, + + /** + * "lacks visual info who commited it, when and did it come from this branch + * or it got merged in from another". + * + * All three, on one line each. The WHEN is asserted against a real clock + * because it silently was not one: `relTime` and `absTime` take epoch + * SECONDS and this page passed milliseconds, so every commit ever opened + * read "authored just now" — the negative delta is clamped to zero — with a + * hover date in the year 57000. A time that is always "just now" is not a + * time, and nothing on screen said so. + */ + "the-commit-page-says-who-when-and-where": (f) => { + const c = check(f); + const ident = $(".cmt-identity"); + c.ok(!!ident, "the page names who is responsible"); + if (!ident) return; + const names = $$(".cmt-who-name", ident).map((x) => text(x)); + c.ok(names.length > 0, "an author is named"); + // The fixture commit was cherry-picked: author and committer differ, which + // is exactly the case a single "author" line hides. + c.ok(names.length >= 2, `a differing committer is named too (${names.join(", ")})`); + c.match(text(ident), /authored/, "and what each of them did"); + c.match(text(ident), /committed/, "including the committer's verb"); + + const when = $$(".cmt-who-when"); + c.ok(when.length > 0, "with a time"); + for (const w of when) { + c.ok( + !/just now/i.test(text(w)), + `a commit hours old must not read "just now" (got ${JSON.stringify(text(w))})`, + ); + c.match(text(w), /\d+\s*(m|h|d|mo|y) ago/, "a real elapsed time"); + // The hover date has to be a date a person could have lived through. + const year = Number((w.title.match(/\b(\d{4})\b/) || [])[1]); + c.ok( + year >= 2000 && year <= 2100, + `and an absolute date that is not from another era (title ${JSON.stringify(w.title)})`, + ); + } + + c.ok(!!$(".cmt-where"), "the page says where the commit lives"); + c.match( + text(".cmt-where"), + /on |only on|not on|merge/i, + `naming the branch situation (got ${JSON.stringify(text(".cmt-where"))})`, + ); + }, + + /** + * The page's git verbs are the reason it beats github.com's commit page — + * and every one of them was a no-op that claimed to have worked. + * + * `act()` sent `{action, sha}` and discarded the reply. For "Create branch + * here…" and "Create tag here…" the main process needs a NAME; with none it + * finds no argv to run and answers `{ok: true}`, so both items reported + * success having done nothing — under a label whose ellipsis promised a + * prompt that never opened. Cherry-pick and revert, the two that routinely + * fail on conflicts, said nothing either way. + */ + "the-commit-page-actually-runs-its-verbs": async (f) => { + const c = check(f); + const more = $$(".det-tb-actions button").find((x) => + /actions for this commit/i.test(x.getAttribute("aria-label") || ""), + ); + c.ok(!!more, "the page carries an actions menu"); + if (!more) return; + more.click(); + await settle(200); + const items = $$(".dropdown-item"); + c.ok(items.length > 0, "the menu opens"); + + const named = items.find((i) => /tag this commit/i.test(text(i))); + c.ok(!!named, "it offers to tag the commit"); + if (named) { + // The ellipsis is a promise. Pressing it must open something that asks + // for the name, not fire a request the main process will discard. + c.match(text(named), /\u2026/, "and says so with an ellipsis"); + named.click(); + await settle(300); + const asked = $(".modal input, .modal-input, .prompt-input, .modal"); + c.ok(!!asked, "pressing it asks for the name instead of silently doing nothing"); + } + }, + + /** + * Every long scroller clears the dock. + * + * The dock's body FLOATS in `.dock-overlay` — absolute, bottom: 0 — so + * opening it never reflows the view above, which means it COVERS the bottom + * of whatever is behind it. `--dock-reserve` exists for exactly that, and + * three scrollers already added it; `.det-scroll` did not, so with the dock + * open the last screenful of EVERY detail page — issues, pull requests, + * releases, commits, the log — could not be brought into view at all. + */ + "a-detail-page-clears-the-dock": async (f) => { + const c = check(f); + const mount = $(".dock-mount"); + const sc = $(".det-scroll"); + c.ok(!!mount && !!sc, "a detail page is showing, with the dock present"); + if (!mount || !sc) return; + c.eq(getComputedStyle(sc).paddingBottom, "0px", "collapsed, it reserves nothing"); + + // The dock publishes its height on its host; simulate it being open. + const host = mount.parentElement; + host.style.setProperty("--dock-reserve", "240px"); + await settle(250); + c.eq( + getComputedStyle(sc).paddingBottom, + "240px", + "open, the page reserves room so its last screenful can be scrolled clear", + ); + host.style.removeProperty("--dock-reserve"); + }, + + /** + * A running clone can always be left. + * + * setBusy disabled every control INCLUDING Cancel, and the modal's + * `canDismiss: () => !busy` blocked Escape and the backdrop — so a clone + * against a slow remote, or one waiting on a credential prompt that never + * arrives, left no way out of the app short of quitting it. There is no + * channel to stop git, and the clone finishes perfectly well without its + * card (the success path opens the repository and toasts either way), so + * the dialog can be dismissed and Cancel becomes "Hide" rather than + * pretending to cancel something it cannot. + */ + "a-running-clone-can-always-be-left": async (f) => { + const c = check(f); + const open = $$("button").find((b) => /clone/i.test(text(b))); + c.ok(!!open, "the welcome screen offers to clone"); + if (!open) return; + open.click(); + await settle(900); + const card = $(".modal-card"); + c.ok(!!card, "the clone dialog opens"); + if (!card) return; + + // A clone that never answers — the case that trapped the app. + const inv = window.gitstudio.invoke; + window.gitstudio.invoke = async (ch, p) => + ch === "clone:start" ? new Promise(() => {}) : inv(ch, p); + const url = card.querySelector("input"); + url.value = "https://github.com/o/r.git"; + url.dispatchEvent(new Event("input", { bubbles: true })); + await settle(700); + const go = [...card.querySelectorAll("button")].find( + (b) => /^clone/i.test(text(b)) && !b.disabled, + ); + c.ok(!!go, "it can be submitted"); + if (!go) { + window.gitstudio.invoke = inv; + return; + } + go.click(); + await settle(900); + + c.ok(card.className.includes("is-busy"), "the card is busy"); + const cancel = [...card.querySelectorAll("button")].find((b) => + /cancel|hide/i.test(text(b)), + ); + c.ok(!!cancel, "there is still a button to leave by"); + c.ok(!cancel?.disabled, "and it is not disabled while the clone runs"); + c.match( + text(cancel || { textContent: "" }), + /hide/i, + "labelled honestly — git cannot be stopped, so it does not say Cancel", + ); + + // And Escape works, which it did not. + document.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }), + ); + await settle(700); + window.gitstudio.invoke = inv; + c.ok(!$(".modal-card"), "Escape leaves the dialog, rather than being swallowed"); + }, + + /** + * A selection never outlives the rows it was made in. + * + * Refresh reloads the graph from the FIRST page, so after paging deep and + * selecting something near the bottom the selected sha was simply gone — + * yet `selectedSha` stayed set, no `.row.selected` existed anywhere in the + * DOM, and `aria-activedescendant` went on naming an id that was not + * there, which a screen reader announces as a row that does not exist. + */ + "a-graph-selection-never-outlives-its-rows": async (f) => { + const c = check(f); + const el = document.querySelector("gitstudio-graph"); + const sr = el?.shadowRoot; + c.ok(!!sr, "the graph is mounted"); + if (!sr || !el) return; + const grid = () => sr.querySelector("[role=grid]"); + const rows = [...sr.querySelectorAll(".row")]; + c.ok(rows.length > 1, "it has rows"); + if (rows.length < 2) return; + + rows[rows.length - 1].click(); + await settle(700); + const chosen = sr.querySelector(".row.selected")?.dataset.sha; + c.ok(!!chosen, "a row is selected"); + c.ok(!!grid()?.getAttribute("aria-activedescendant"), "and announced to assistive tech"); + + // Exactly what a Refresh does after deep paging: a row set without it. + el.rows = el.rows.filter((r) => r.sha !== chosen); + await settle(600); + + const aria = grid()?.getAttribute("aria-activedescendant"); + c.eq( + sr.querySelectorAll(".row.selected").length, + 0, + "nothing is drawn as selected once the row is gone", + ); + c.ok( + !aria || !!sr.querySelector(`#${CSS.escape(aria)}`), + `and aria-activedescendant does not name a row that is not there (${aria})`, + ); + }, + + /** + * The Code view's Refresh refreshes the FILE LIST. + * + * The listing is read through `gget("repo:tree", …)`, so a Refresh that + * only re-ran the view was answered from the cache: the commit bar and the + * README — which fetch separately — updated while the file list beneath + * them did not. That is the one thing the button is pressed for. + */ + "code-refresh-rereads-the-listing": async (f) => { + const c = check(f); + const calls = []; + const inv = window.gitstudio.invoke; + window.gitstudio.invoke = async (ch, p) => { + if (ch === "repo:tree") calls.push(1); + return inv(ch, p); + }; + const before = calls.length; + const r = $$("button").find((b) => + /refresh/i.test(b.getAttribute("aria-label") || b.title || ""), + ); + c.ok(!!r, "the Code view has a Refresh"); + if (!r) return; + r.click(); + await settle(1400); + window.gitstudio.invoke = inv; + c.ok( + calls.length > before, + `it asks git for the tree again (${calls.length - before} call(s))`, + ); + }, + + /** + * A person peek's primary action works from wherever it was opened. + * + * `memberCard` is opened by every person chip in the app — an issue's + * author, a reviewer, a commit's committer — and its PRIMARY button routes + * into Explore. But the router it used was a module-level variable set only + * by the Organizations view's own render, so until you had visited + * Organizations in that session the app's most-reachable primary button did + * nothing at all: no route, no error, no toast. + * + * The scene deliberately never goes near Organizations. + */ + "a-person-peeks-primary-action-is-not-dead": async (f) => { + const c = check(f); + const routes = []; + window.__GS_ROUTES = routes; + const who = $(".gh-meta-author"); + c.ok(!!who, "the pull request names its author as a chip"); + if (!who) return; + who.click(); + await settle(900); + const peek = $$("[class*=peek]")[0]; + c.ok(!!peek, "clicking it opens the person peek"); + if (!peek) return; + const full = [...peek.querySelectorAll("button")].find((b) => + /view full profile/i.test(text(b)), + ); + c.ok(!!full, "the peek offers the full profile"); + if (!full) return; + full.click(); + await settle(900); + const last = routes[routes.length - 1]; + c.ok(!!last, "pressing it routes somewhere"); + c.eq(last?.view, "explore", "into Explore"); + c.match(String(last?.target?.id ?? ""), /^user\//, "at that person's page"); + }, + + /** + * The welcome screen: a recent can be forgotten, and its controls nest. + * + * This is the first thing anyone sees and the only screen shown after + * closing a repository, and it was unreachable in this harness until the + * `norepo=1` switch — so nothing had ever checked it. A recent whose folder + * has been deleted or moved renders identically to a live one; opening it + * toasts "not inside a Git repository" and the row stays, with no way to + * get rid of it from the one screen you can see. + */ + "a-recent-repository-can-be-forgotten": async (f) => { + const c = check(f); + c.ok(!!$(".welcome-recent"), "the welcome screen is showing"); + const rows = $$(".recent-card-row"); + c.ok(rows.length > 0, `it lists recent repositories (${rows.length})`); + if (!rows.length) return; + + // A control inside a control has no accessible name of its own and Space + // activates the wrong one. + c.eq($$(".recent-card button").length, 0, "no button is nested inside the card button"); + const forget = rows[0].querySelector(".recent-card-forget"); + c.ok(!!forget, "each recent offers to be forgotten"); + if (!forget) return; + c.ok(!!forget.getAttribute("aria-label"), "and the control is named"); + c.match( + forget.getAttribute("aria-label") ?? "", + /not touched|forget/i, + "saying it forgets the entry rather than deleting the folder", + ); + + // Forgetting must not also OPEN the repository — the card behind it does. + const sent = []; + const inv = window.gitstudio.invoke; + window.gitstudio.invoke = async (ch, p) => { + if (ch === "repos:removeRecent") { + sent.push(p); + return []; + } + if (ch === "repo:openPath") sent.push("OPENED"); + return inv(ch, p); + }; + forget.click(); + await settle(900); + window.gitstudio.invoke = inv; + c.eq(sent.length, 1, `exactly one call, and it is the forget (${JSON.stringify(sent)})`); + c.ok(sent[0] !== "OPENED", "not an open"); + }, + + /** + * "Switch account" switches — it does not just sign you out. + * + * It ran the sign-out code and stopped there, without even the toast the + * neighbouring Sign out gives you, so the button labelled Switch was a + * quieter Sign out that left you on a signed-out card with nothing started + * and no account to switch to. The verb has two halves. + */ + "switch-account-starts-the-new-sign-in": async (f) => { + const c = check(f); + const sw = $$("button").find((b) => /switch account/i.test(text(b))); + c.ok(!!sw, "the account card offers to switch"); + if (!sw) return; + sw.click(); + await settle(1800); + const flow = $(".gh-flow"); + c.ok(!!flow, "a sign-in flow is on screen"); + c.match( + text(flow || { textContent: "" }), + /code|github\.com\/login\/device/i, + `and it is the device flow, already started (${JSON.stringify(text(flow || { textContent: "" }).slice(0, 60))})`, + ); + }, + + /** + * The Commits graph: typing paints, Enter travels, and j/k move. + * + * `computeMatches` selected the first match and scrolled to it on EVERY + * keystroke — and selecting emits `{type:"select"}`, which the host answers + * by re-fetching the commit and replacing the details pane. So typing three + * characters threw away the diff you were reading, moved the selection + * three times and made three requests before you finished the word. The + * same principle was already written down one method below, for appended + * pages; a keystroke is the same event, more often. + * + * And j/k did nothing here — the one list in the app where the keys its own + * cheat sheet promises were not wired. + */ + "the-graph-search-paints-before-it-travels": async (f) => { + const c = check(f); + const host = document.querySelector("gitstudio-graph"); + const sr = host?.shadowRoot; + c.ok(!!sr, "the graph is mounted"); + if (!sr) return; + const rows = [...sr.querySelectorAll(".row")]; + c.ok(rows.length > 2, `it has rows (${rows.length})`); + if (rows.length < 3) return; + const sel = () => { + const r = sr.querySelector(".row.selected"); + return r ? (r.dataset.sha || "").slice(0, 12) : "-"; + }; + + rows[0].click(); + await settle(700); + const picked = sel(); + c.ok(picked !== "-", "a commit can be selected"); + + const input = sr.querySelector(".gh-input"); + c.ok(!!input, "the graph has a search box"); + if (input) { + input.value = "engine"; + input.dispatchEvent(new Event("input", { bubbles: true })); + await settle(800); + c.eq(sel(), picked, "typing does not move the selection out from under you"); + input.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }), + ); + await settle(800); + c.ok(sel() !== picked, "and Enter is what travels"); + } + + // j / k, at the element that actually carries the handler. + const grid = sr.querySelector("[role=grid]"); + c.ok(!!grid, "the rows live in a grid"); + if (!grid) return; + rows[0].click(); + await settle(500); + const from = sel(); + const K = (k) => + grid.dispatchEvent(new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true })); + K("j"); + await settle(400); + c.ok(sel() !== from, "j moves down, as the cheat sheet promises"); + K("k"); + await settle(400); + c.eq(sel(), from, "and k comes back"); + }, + + /** + * Clearing a search clears the RESULTS, however long its debounce. + * + * Explore's code search waits for Enter — `debounceMs: 100_000`, because + * every keystroke there costs a rate-limited request. But the ✕ and Escape + * went through the same debounce, so the box emptied and the results below + * it sat there for a hundred seconds: the field said one thing and the list + * another, with no way to make them agree short of pressing Enter on an + * empty query. + */ + "clearing-a-search-clears-the-results": async (f) => { + const c = check(f); + const code = $$(".explore-tab").find((b) => text(b).trim() === "Code"); + c.ok(!!code, "Explore has a Code tab"); + if (!code) return; + code.click(); + await settle(900); + const field = $(".explore-search"); + const inp = field?.querySelector("input"); + c.ok(!!inp && inp.value.length > 0, "it is showing results for a query"); + c.ok($$(".explore-code-row, .explore-code-hit").length > 0, "and there are rows"); + + field.querySelector(".gh-search-clear").click(); + await settle(1000); + c.eq(inp.value, "", "the ✕ empties the box"); + c.eq( + $$(".explore-code-row, .explore-code-hit").length, + 0, + "and the results go with it, rather than waiting out the debounce", + ); + c.ok(!!$(".list-empty"), "leaving the start state"); + }, + + /** + * A deep link SHOWS the ref it names. + * + * The link cleared the search box, and only that. `branchAge` defaults to + * "active", so a link to any branch untouched for three months — from a + * graph ref chip, or from a run's branch chip, which is where a stale + * branch's run lives — landed on a list that did not contain it, with + * nothing on screen saying why. The facets could do the same, and they + * persist per segment across launches. + */ + "a-branch-deep-link-shows-the-branch": (f) => { + const c = check(f); + c.ok(!!$(".branches-view"), "the chip routes to Branches"); + const refs = $$(".sec-row").map((r) => r.dataset.ref); + c.ok( + refs.includes("spike/monaco-swap"), + `the branch it named is in the list (${refs.join(", ")})`, + ); + const age = $$(".branches-facets .gh-seg-btn").find((b) => b.classList.contains("active")); + c.match( + text(age || { textContent: "" }), + /^All/, + "and the age cut was widened rather than hiding it", + ); + }, + + /** + * The log's four states, and what each one may say and do. + * + * A state table rather than a browse, because this surface was rewritten in + * one night and the owner reported it twice in anger. "Not producing" + * covers two OPPOSITE situations — finished, and not started — which want + * opposite words; and the Follow button and the "Jump to latest" pill are + * both claims about a tail that may not exist. + * + * finished : Follow disabled and says the job is over; no pill, ever. + * queued : Follow disabled and says it has not started; no pill. + * live+tail : Follow armed; no pill, because you are AT the tail. + * live+away : Follow off; the pill appears, because the tail moved on. + */ + "the-logs-states-each-say-the-right-thing": async (f) => { + const c = check(f); + const followBtn = () => + $$(".log-toolbar button").find((b) => + /follow/i.test(b.getAttribute("aria-label") || b.title || ""), + ); + const pill = () => $(".log-jump"); + + // ── finished ── + const b0 = followBtn(); + c.ok(!!b0 && !!pill(), "the log pane is up"); + if (!b0) return; + c.ok(b0.disabled, "finished: Follow is disabled"); + c.match(b0.title, /finished/i, "finished: and says the job is over"); + c.ok(!/hasn.t started/i.test(b0.title), "finished: not 'hasn\u2019t started'"); + c.ok(pill().hidden, "finished: no jump pill — nothing is moving"); + + // Pressing it must not silently perform End under a Follow label. + const sc = $(".log-scroll"); + sc.scrollTop = 0; + sc.dispatchEvent(new Event("scroll")); + await settle(250); + b0.click(); + await settle(300); + c.eq(sc.scrollTop, 0, "finished: a disabled Follow moves nothing"); + }, + + /** The other three cells, on the live run — queued, tailing, and away. */ + "the-logs-live-states-each-say-the-right-thing": async (f) => { + const c = check(f); + const followBtn = () => + $$(".log-toolbar button").find((b) => + /follow/i.test(b.getAttribute("aria-label") || b.title || ""), + ); + const pill = () => $(".log-jump"); + + // ── queued: nothing to follow YET, which is not the same sentence ── + const queued = $$(".joblog-job").find((r) => /ubuntu/i.test(text(r))); + c.ok(!!queued, "the run has a queued job"); + if (queued) { + queued.click(); + await settle(1200); + const b = followBtn(); + c.ok(b?.disabled, "queued: Follow is disabled"); + c.match(b?.title ?? "", /hasn.t started/i, "queued: and says it has not started"); + c.ok(!/finished/i.test(b?.title ?? ""), "queued: never 'finished'"); + c.ok(pill()?.hidden !== false, "queued: no jump pill"); + } + + // ── live: armed at the tail, so no pill; away from it, so a pill ── + const live = $$(".joblog-job").find((r) => /windows/i.test(text(r))); + c.ok(!!live, "the run has a live job"); + if (!live) return; + live.click(); + await settle(1400); + const b = followBtn(); + c.ok(!b?.disabled, "live: Follow is available"); + c.eq(b?.getAttribute("aria-pressed"), "true", "live: and armed"); + c.ok(pill()?.hidden, "live+tail: no pill — you are AT the tail"); + + b.click(); + await settle(400); + c.eq(b.getAttribute("aria-pressed"), "false", "live: it can be turned off"); + c.ok( + pill()?.hidden, + "live+tail+off: still no pill — the tail has not moved on without you", + ); + + const sc = $(".log-scroll"); + sc.scrollTop = 0; + sc.dispatchEvent(new Event("scroll")); + await settle(400); + c.ok(pill()?.hidden === false, "live+away: NOW the pill appears"); + }, + + /** + * A stash's page holds ONE commit, and says so. + * + * It asked `ref:log` for 30, and `git log stash@{0}` walks the stash + * commit's ancestry — so a section headed "The commit it holds", singular, + * filled with the WIP commit, then git's internal "index on <branch>: …" + * commit (the stash's second parent, an implementation detail no UI should + * show), then the whole branch history it was taken from. + */ + "a-stash-page-holds-one-commit": (f) => { + const c = check(f); + const kind = text($(".rd-kind") || { textContent: "" }); + c.eq(kind, "stash", "the page is a stash's"); + c.match(text(".rd-section-head"), /the commit it holds/i, "and says it holds one commit"); + c.eq($$(".clist-row").length, 1, "so it shows exactly one"); + }, + + /** + * The palette does not throw away your arrow keys when a search lands. + * + * Search groups are PREPENDED, and to stop the highlight sliding downward + * as rows arrived above it the palette reset the selection to row 0 every + * time a group resolved — which fires ~300ms after you stop typing, i.e. + * exactly while you are arrowing. The two presses were discarded and Enter + * fired the top row, which is "Search GitHub for …": a whole different + * destination from the one under the highlight a moment earlier. + */ + "the-palette-keeps-your-place-when-results-arrive": async (f) => { + const c = check(f); + const inp = $(".cmdk-card input"); + c.ok(!!inp, "the palette is open"); + if (!inp) return; + const K = (k) => + inp.dispatchEvent(new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true })); + const idx = () => $$(".cmdk-row").findIndex((r) => r.classList.contains("is-selected")); + + // A query matching BOTH local commands and the search fixtures, so a + // group really does arrive after the local list is already on screen. + inp.value = "git"; + inp.dispatchEvent(new Event("input", { bubbles: true })); + await settle(120); + const localRows = $$(".cmdk-row").length; + c.ok(localRows > 2, `the local list is up (${localRows} rows)`); + + K("ArrowDown"); + await settle(60); + K("ArrowDown"); + await settle(60); + const chose = idx(); + c.ok(chose > 0, `two presses move the highlight down (row ${chose})`); + + await settle(2000); + c.ok( + $$(".cmdk-row").length > localRows, + `a search group arrived (${localRows} → ${$$(".cmdk-row").length} rows)`, + ); + c.ok( + idx() > 0, + `and the highlight is still where the reader put it, not back at row 0 (row ${idx()})`, + ); + }, + + /** + * One key press, one layer — and the keyboard is never dropped. + * + * Three ways the app lost track of its own floating layers: + * · Home and End inside a searchable menu's FILTER FIELD were claimed by + * the menu and yanked focus onto a row, so the next Enter activated it — + * in the branch switcher, a checkout. + * · ⌘K over an open dropdown left the menu on screen belonging to nothing, + * and one Escape then closed both layers and dropped focus on <body>. + * · "?" only checked for a text field, and the shortcuts sheet's first + * focusable is a button — so "?" opened a second identical sheet over the + * first, and a third, each needing its own Escape. + */ + "one-key-press-closes-one-layer": async (f) => { + const c = check(f); + const K = (key, extra = {}) => + document.dispatchEvent( + new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...extra }), + ); + + // ? does not stack. + K("?"); + await settle(500); + c.eq($$(".modal-card").length, 1, "? opens the shortcuts sheet"); + K("?"); + await settle(400); + K("?"); + await settle(400); + c.eq($$(".modal-card").length, 1, "and pressing it again does not open a second one"); + K("Escape"); + await settle(400); + c.eq($$(".modal-card").length, 0, "one Escape closes it"); + + // Home in a menu's filter belongs to the filter. + const facet = $$(".gh-facet-btn")[0]; + c.ok(!!facet, "the view has a menu with a filter"); + if (!facet) return; + facet.focus(); + facet.click(); + await settle(500); + const input = $(".dropdown input"); + if (input) { + input.focus(); + input.dispatchEvent( + new KeyboardEvent("keydown", { key: "Home", bubbles: true, cancelable: true }), + ); + await settle(250); + c.ok( + document.activeElement === input, + `Home stays in the filter field (went to ${document.activeElement?.tagName})`, + ); + } + + // ⌘K takes over cleanly and hands the keyboard back. + c.ok(!!$(".dropdown"), "the menu is open"); + K("k", { metaKey: true }); + await settle(700); + c.ok(!$(".dropdown"), "opening the palette closes the menu underneath it"); + c.ok(!!$(".cmdk-card"), "and the palette is up"); + K("Escape"); + await settle(600); + c.ok(!$(".cmdk-card"), "one Escape closes the palette"); + c.ok( + document.activeElement !== document.body, + "and the keyboard goes back to the control that opened the menu, not to <body>", + ); + }, + + /** + * A label picker batches its ticks, and Escape discards them. + * + * The issue's picker batched correctly but committed on EVERY dismissal, + * because `onClose` could not tell one from another — so Escape, the key + * that means "back out" everywhere else in this app, was the key that wrote + * to GitHub, and there was no way to change your mind after the first tick. + * The pull request's picker was worse: its items were not `checkable`, so + * openMenu took the close-then-act path and every single tick closed the + * menu and fired its own request. + */ + "a-label-picker-batches-and-escape-discards": async (f) => { + const c = check(f); + const sent = []; + const inv = window.gitstudio.invoke; + window.gitstudio.invoke = async (ch, p) => { + if (/setLabels/.test(ch)) { + sent.push(p); + return { ok: true }; + } + return inv(ch, p); + }; + const open = async () => { + const ed = $$(".det-prop-edit").find((b) => + /edit labels/i.test(b.getAttribute("aria-label") || b.title || ""), + ); + if (!ed) return []; + ed.click(); + await settle(900); + return $$(".dropdown .dropdown-item"); + }; + + let rows = await open(); + c.ok(rows.length >= 2, `the picker opens with the repo's labels (${rows.length})`); + if (rows.length < 2) return; + + rows[0].click(); + await settle(220); + rows[1].click(); + await settle(220); + c.eq(sent.length, 0, "ticking sends nothing — the selection is batched"); + c.ok(!!$(".dropdown"), "and the menu stays open so you can tick more than one"); + + // Escape means back out. + document.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }), + ); + await settle(600); + c.eq(sent.length, 0, "Escape discards the whole selection"); + + // Dismissing any other way commits, once. + rows = await open(); + if (rows.length < 2) return; + rows[0].click(); + await settle(200); + rows[1].click(); + await settle(200); + document.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + await settle(700); + c.eq(sent.length, 1, "clicking away commits the batch — once, not once per tick"); + c.eq(sent[0]?.labels?.length, 2, "and sends the whole selection"); + window.gitstudio.invoke = inv; + }, + + /** + * A log's ANSI colours are legible in BOTH themes, backgrounds included. + * + * `--log-c*` is adjusted for the ground the text sits on, which in light + * means ANSI "bright white" as a foreground is dark — correct on white + * paper. But light also mapped "black" to the same #24292f, so a span + * setting bright-white ON black, which CI tools really do emit, rendered as + * one solid invisible block at 1.00:1. And `.log-bg-8` through `-15` did + * not exist at all, though the parser emits them for SGR 100–107 and for + * any bright 256-colour background — so those were silently dropped. + */ + "log-colours-survive-both-themes": async (f) => { + const c = check(f); + const win = $(".log-window"); + c.ok(!!win, "a log is open"); + if (!win) return; + const mk = (cls) => { + const s = document.createElement("span"); + s.className = cls; + s.textContent = "XX"; + const line = document.createElement("div"); + line.className = "log-line"; + line.appendChild(s); + win.appendChild(line); + return s; + }; + const pairs = [ + ["log-fg-15 log-bg-0", "bright white on black"], + ["log-fg-0 log-bg-15", "black on bright white"], + ["log-fg-7 log-bg-4", "white on blue"], + ]; + const spans = pairs.map(([cls]) => mk(cls)); + // Bright backgrounds, which had no rules at all. + const brights = [8, 9, 12, 15].map((n) => mk(`log-bg-${n}`)); + await settle(200); + + // Not "the two differ" — light mapped bright-white to #24292f and black to + // #3b4048, which DO differ and are still 1.34:1, a solid block you cannot + // read. And not a contrast floor either: ANSI white on ANSI bright-blue + // really is 1.11:1, and a terminal renders the author's choice faithfully + // rather than second-guessing it. + // + // The contract is exactness. A span that sets its own background is a + // block of terminal colour, so both themes must render the author's pair + // the SAME way — the true ANSI values, whatever the page around it is. + const TRUE = { 0: "rgb(59, 64, 72)", 7: "rgb(171, 178, 191)", 15: "rgb(255, 255, 255)" }; + const expect = [ + [spans[0], TRUE[15], TRUE[0]], + [spans[1], TRUE[0], TRUE[15]], + [spans[2], TRUE[7], "rgb(97, 175, 239)"], + ]; + for (let i = 0; i < expect.length; i++) { + const [sp, fg, bg] = expect[i]; + const cs = getComputedStyle(sp); + c.eq(cs.color, fg, `${pairs[i][1]}: the foreground is the ANSI colour asked for`); + c.eq(cs.backgroundColor, bg, `${pairs[i][1]}: and so is the background`); + } + for (let i = 0; i < brights.length; i++) { + const bg = getComputedStyle(brights[i]).backgroundColor; + c.ok( + bg !== "rgba(0, 0, 0, 0)" && bg !== "transparent", + `a bright background is actually painted (log-bg class ${i}, got ${bg})`, + ); + } + }, + + /** + * An empty list says which control emptied it — never that the repo is bare. + * + * `branchesEmpty` only knew about the search box, so the two narrowing + * controls added with this view — the Active/Stale/All cut and the facet + * bar — fell through to "No branches yet: every repository has at least + * one, this read found none", printed over a repository with ninety of + * them. A list that blames the wrong thing sends people to look for a + * problem that is not there. + */ + "an-emptied-branch-list-blames-the-right-thing": async (f) => { + const c = check(f); + const facet = $$(".gh-facet-btn")[0]; + c.ok(!!facet, "the branch list has facets"); + if (!facet) return; + facet.click(); + await settle(350); + const items = $$(".dropdown .dropdown-item"); + c.ok(items.length > 1, "the facet offers values"); + if (items.length < 2) return; + // The last value is the least likely to match everything. + items[items.length - 1].click(); + await settle(700); + if ($$(".sec-row").length > 0) return; // nothing to assert about + + const empty = $(".list-empty"); + c.ok(!!empty, "an emptied list says something"); + if (!empty) return; + const desc = text(empty.querySelector(".list-empty-desc")); + c.ok( + !/no branches yet|at least one/i.test(desc), + `it does not claim the repository is empty (${JSON.stringify(desc)})`, + ); + c.match(desc, /filter/i, "it names the control that emptied it"); + c.ok( + !!empty.querySelector(".list-empty-action"), + "and offers to undo that control", + ); + }, + + /** + * The branch view's control bar wraps rather than walking off the page. + * + * Five kind segments plus "Delete N finished…" is ~736px in a row with no + * wrap and no ancestor that scrolls sideways. Below ~950px the sweep button + * rendered past the window edge — 132px out at 820px — unreachable by any + * means. + */ + "the-branch-control-bar-stays-on-screen": (f) => { + const c = check(f); + const sweep = $(".branches-sweep"); + const seg = $(".branches-segbar .gh-seg"); + c.ok(!!sweep && !!seg, "the bar holds its segments and the sweep"); + if (!sweep || !seg) return; + for (const [name, e] of [["the segments", seg], ["the sweep button", sweep]]) { + const r = e.getBoundingClientRect(); + c.ok( + r.right <= window.innerWidth + 1, + `${name} stays inside the window at ${window.innerWidth}px (right edge ${Math.round(r.right)})`, + ); + } + c.ok( + document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1, + "and the page does not scroll sideways instead", + ); + }, + + /** + * A cancelled run is not a failed one. + * + * `runLead`'s buckets lumped cancelled, timed_out, action_required and + * stale in with failure — while the run's own page and the status filter + * both drew cancelled muted. So the same run was an urgent red error in the + * list and a non-event everywhere else, and a run somebody had deliberately + * stopped pulled the eye like a broken build. + */ + "a-cancelled-run-is-not-drawn-as-a-failure": (f) => { + const c = check(f); + const rows = $$(".sec-row"); + const lead = (needle) => { + const r = rows.find((x) => new RegExp(needle, "i").test(text(x))); + return r ? r.querySelector(".run-lead") : null; + }; + const failed = lead("actions: stream"); + const cancelled = lead("CodeMirror"); + const ok = lead("release: extension"); + c.ok(!!failed && !!cancelled && !!ok, "the list holds a success, a failure and a cancellation"); + if (!failed || !cancelled || !ok) return; + + c.ok(failed.classList.contains("is-failure"), "a failed run is drawn as one"); + c.ok( + !cancelled.classList.contains("is-failure"), + `a cancelled run is not (${cancelled.className})`, + ); + // And the colours really differ, not just the class names. + const col = (e) => getComputedStyle(e).color; + c.ok( + col(cancelled) !== col(failed), + `cancelled and failed read differently at a glance (both ${col(failed)})`, + ); + c.ok(col(cancelled) !== col(ok), "and a cancellation is not drawn as a success either"); + }, + + /** + * Commit is dead on a clean tree — except when amending. + * + * The enable rule gated on the message text alone, so on a repository with + * nothing to commit — the state a repository spends most of its life in — + * Commit was a live accent button whose only possible outcome was git's + * "nothing to commit". Amending is the real exception: rewording the last + * commit is a commit with nothing staged. + */ + "commit-is-dead-on-a-clean-tree": async (f) => { + const c = check(f); + const ta = $$("textarea")[0]; + const btn = $(".dc-commit"); + c.ok(!!ta && !!btn, "the composer is there"); + if (!ta || !btn) return; + // The scene runs with ?clean=1, so this really is an empty working tree. + c.eq($$(".dc-file").length, 0, "the working tree is clean"); + c.match(text(".list-empty"), /clean/i, "and the list says so"); + + ta.value = "a message"; + ta.dispatchEvent(new Event("input", { bubbles: true })); + await settle(350); + c.ok(btn.disabled, "a message alone does not arm Commit over a clean tree"); + c.match(btn.title, /nothing to commit/i, "and the button says why"); + + const amend = $$(".dc-toggle").find((t) => /amend/i.test(text(t))); + c.ok(!!amend, "the composer offers Amend"); + if (!amend) return; + amend.click(); + await settle(600); + const after = $(".dc-commit"); + c.ok(!after.disabled, "amending a clean tree is legitimate and stays available"); + c.match(text(after), /amend/i, "and the button says which commit it makes"); + }, + + /** + * A segment is not a filter, and its menus describe only what it holds. + * + * Pull requests fetch Merged and Closed together, so both segments read + * from a superset. Two things followed: the count badge switched to its + * narrowed "N of M" form — accent, "N shown of M loaded" tooltip — with no + * filter set at all ("0 of 5" above "No closed pull requests"); and the + * facet menus offered authors and labels that belong to the OTHER segment, + * every one of which filters the visible list to nothing. + */ + "a-segment-is-not-a-filter": async (f) => { + const c = check(f); + const segs = $$(".gh-seg-btn"); + c.ok(segs.length >= 3, "the view has segments"); + if (segs.length < 3) return; + + for (const s of segs) { + s.click(); + await settle(600); + const badge = text(".gh-head-count"); + const rows = $$(".sec-row").length; + c.ok( + !/ of /.test(badge), + `${text(s)}: the count is plain when nothing is filtering it (${JSON.stringify(badge)})`, + ); + c.eq(Number(badge.replace(/\D/g, "")) || 0, rows, `${text(s)}: and counts the rows shown`); + } + + // Now the menus. On a segment holding one row, every option offered must + // match something in it. + const merged = segs.find((s) => /merged/i.test(text(s))); + if (!merged) return; + merged.click(); + await settle(600); + const shown = $$(".sec-row").length; + const author = $$(".gh-facet-btn").find((x) => /author/i.test(text(x))); + c.ok(!!author, "the bar offers an author filter"); + if (!author || shown === 0) return; + author.click(); + await settle(400); + const opts = $$(".dropdown .dropdown-item").map((i) => text(i)); + // "Anyone" plus at most one real author per visible row. + c.ok( + opts.length <= shown + 1, + `it offers only authors present in this segment (${shown} row(s), ${opts.length} options: ${opts.join(", ")})`, + ); + }, + + /** + * "Clear every filter" must clear the ones it cannot see. + * + * A view may drop a spec on some segments — Issues hides "Closed as" on + * Open, because a closed reason can only match a closed issue. `clear()` + * deleted only the keys of the specs currently IN the bar, so a value set on + * Closed survived a button whose own tooltip reads "Clear every filter", + * and silently narrowed the list again the moment you switched back. + */ + "clear-clears-the-filters-it-cannot-see": async (f) => { + const c = check(f); + const seg = (n) => $$(".gh-seg-btn")[n]; + const pick = async (facetLabel, value) => { + const b = $$(".gh-facet-btn").find((x) => text(x).startsWith(facetLabel)); + if (!b) return false; + b.click(); + await settle(400); + const r = $$(".dropdown .dropdown-item").find((i) => text(i).includes(value)); + if (!r) return false; + r.click(); + await settle(600); + return true; + }; + + // Set a filter that only exists on the Closed segment. + seg(1)?.click(); + await settle(600); + c.ok(await pick("Closed as", "Not planned"), "the Closed segment offers a closed reason"); + const narrowed = $$(".sec-row").length; + const total = $$(".gh-seg-btn")[1] ? narrowed : 0; + c.ok(narrowed >= 0, `it narrows the list (${narrowed} rows)`); + + // Leave for a segment that does not show it, and press Clear there. + seg(0)?.click(); + await settle(600); + c.ok(await pick("Label", "bug"), "the Open segment has a filter of its own"); + const clear = $(".gh-facet-clear"); + c.ok(!!clear, "Clear is offered"); + if (!clear) return; + clear.click(); + await settle(700); + c.ok(!$(".gh-facet-clear"), "and the bar reports itself fully cleared"); + + // Come back. Nothing may be filtering. + seg(1)?.click(); + await settle(700); + c.eq( + $$(".gh-facet-btn.is-active").length, + 0, + "no filter survived the clear on the segment that could not show it", + ); + c.ok( + !/ of /.test(text(".gh-head-count")), + `and the count is not the narrowed form (${JSON.stringify(text(".gh-head-count"))})`, + ); + void total; + }, + + /** + * Filtering a list must not throw the keyboard out of the page. + * + * `openMenu` restores focus to the trigger BEFORE running the item's + * action, and the facet's action rebuilds the whole bar — destroying the + * button that was just refocused. Focus landed on <body>, so the next Tab + * restarted at the top of the window, past the entire nav rail. The generic + * focus rescue cannot recover it: it matches a replacement by title, + * aria-label or text, and picking a value changes all three at once. + */ + "picking-a-filter-keeps-the-keyboard-where-it-was": async (f) => { + const c = check(f); + const first = $$(".gh-facet-btn")[0]; + c.ok(!!first, "the view has a facet bar"); + if (!first) return; + + first.focus(); + first.click(); + await settle(400); + const rows = $$(".dropdown .dropdown-item"); + c.ok(rows.length > 1, "its menu offers values"); + if (rows.length < 2) return; + rows[1].focus(); + rows[1].click(); + await settle(700); + + c.ok( + document.activeElement !== document.body, + "picking a value leaves the keyboard somewhere real, not on <body>", + ); + c.ok( + !!document.activeElement && $$(".gh-facet-btn").includes(document.activeElement), + `and on the facet bar it came from (${document.activeElement?.tagName}.${String(document.activeElement?.className).slice(0, 30)})`, + ); + + // Clear is the other half: it sits last in the bar and removes itself. + const clear = $(".gh-facet-clear"); + c.ok(!!clear, "a filter is now set, so Clear is offered"); + if (!clear) return; + clear.focus(); + clear.click(); + await settle(800); + c.ok( + document.activeElement !== document.body, + "and clearing does not drop the keyboard either", + ); + }, + + /** + * Editing a release must not move the repository's "Latest" badge. + * + * "Set as the latest release" was initialised from `!init.prerelease`, so it + * arrived pre-ticked for EVERY published non-pre-release. Opening an old + * release to fix a typo in its notes and pressing Save therefore moved the + * badge onto it — silently, outward, and visible to everyone reading the + * repo. The default has to be where the badge already is. + */ + "editing-a-release-leaves-the-latest-badge-alone": async (f) => { + const c = check(f); + const boxes = $$(".relc-form input[type=\"checkbox\"]"); + c.ok(boxes.length >= 2, "the composer offers the pre-release and latest switches"); + if (boxes.length < 2) return; + // `/latest/i` alone matches the PRE-RELEASE box, whose own description + // reads "It never becomes the latest release" — the concatenated-text + // trap, and it made this check read a control it was not about. + const latest = boxes.find((b) => + /set as the latest/i.test(text(b.closest("label") || b.parentElement || b)), + ); + c.ok(!!latest, "one of them is the latest switch"); + if (!latest) return; + // The scene opens release 50 — published, not a pre-release, and NOT the + // one holding the badge (51 is). Its box must be clear. + c.eq( + latest.checked, + false, + "a release that is not the latest does not arrive asking to become it", + ); + // And the badge is still elsewhere, so the checkbox is offering a real + // change rather than describing the status quo. + c.ok(!latest.disabled, "the switch is available — it just is not pre-ticked"); + }, + + /** + * The diff's file path keeps its characters in order AND cuts from the left. + * + * Two properties that fight each other. A path is truncated from the LEFT + * because the filename is the part that identifies it, and the stylesheet + * does that with `direction: rtl` — which also reorders NEUTRAL characters + * at the string's edges. A leading dot is neutral, so every dotfile path in + * the app drew as "github/workflows/ci.yml.", naming a file that does not + * exist. An inner LTR isolate fixes the order; this asserts it did not cost + * the truncation, because reverting to plain LTR would fix the dot and cut + * the wrong end. + */ + "a-diff-path-reads-forwards-and-cuts-from-the-left": async (f) => { + const c = check(f); + const p = $(".diffmode-path"); + c.ok(!!p, "the diff toolbar names the file"); + if (!p) return; + const t = p.querySelector(".diffmode-path-text"); + c.ok(!!t, "the path text is isolated from the rtl box around it"); + if (!t) return; + c.eq(getComputedStyle(t).direction, "ltr", "the text itself runs left to right"); + + // A dotfile keeps its dot where it was written. + t.textContent = ".github/workflows/ci.yml"; + await settle(150); + c.ok( + text(p).startsWith("."), + `a leading dot stays in front (${JSON.stringify(text(p))})`, + ); + + // And a path too long for the box loses its START, not its filename. + p.style.maxWidth = "180px"; + t.textContent = "apps/desktop/src/renderer/views/deeply/nested/verylongname.ts"; + await settle(150); + const node = t.firstChild; + const r = document.createRange(); + r.setStart(node, 0); + r.setEnd(node, 1); + const firstLeft = r.getBoundingClientRect().left; + r.setStart(node, node.length - 1); + r.setEnd(node, node.length); + const lastRight = r.getBoundingClientRect().right; + const bb = p.getBoundingClientRect(); + c.ok( + firstLeft < bb.left - 1, + `the beginning of the path is what gets cut (first char at ${Math.round(firstLeft)}, box starts ${Math.round(bb.left)})`, + ); + c.ok( + lastRight <= bb.right + 1, + `and the filename stays inside the box (last char at ${Math.round(lastRight)}, box ends ${Math.round(bb.right)})`, + ); + p.style.maxWidth = ""; + }, + + /** + * Growing the pane fills it with log, not with a blank band. + * + * The virtual window is sized from `scroll.clientHeight`, and the only + * things that called render() were scroll events, the keyboard, the toolbar + * and the tail. A height change producing none of those — resizing the + * window, entering fullscreen, dragging the terminal dock down — left the + * window the size it was, so the log stopped mid-pane with empty space + * below it until you happened to scroll. + * + * Driven through the window's resize event: a ResizeObserver callback is + * delivered with the rendering steps, and those do not run on an idle + * headless page — the observer is wired for the panes the window event + * cannot see, but this is the path that can be proven. + */ + "growing-the-log-pane-fills-it": async (f) => { + const c = check(f); + noAnimation(); + const row = $$(".joblog-job").find((r) => /test/i.test(text(r))); + if (row) { + row.click(); + await settle(1400); + } + const sc = $(".log-scroll"); + const pane = $(".log-pane"); + c.ok(!!sc && !!pane, "a log is open"); + if (!sc || !pane) return; + // Only meaningful on a log taller than its pane — otherwise every line is + // rendered whatever the height, and this passes on any build at all. + c.ok( + sc.scrollHeight > sc.clientHeight + 200, + `the fixture log is longer than the pane (${sc.scrollHeight} in ${sc.clientHeight})`, + ); + const before = $$(".log-line").length; + pane.style.height = `${pane.getBoundingClientRect().height + 600}px`; + window.dispatchEvent(new Event("resize")); + await settle(500); + c.ok( + sc.clientHeight > 0, + "the pane really did grow", + ); + c.ok( + $$(".log-line").length > before, + `the extra height is filled with log (${before} rows before, ${$$(".log-line").length} after)`, + ); + // And nothing below the last rendered row is empty space inside the port. + const rows = $$(".log-line"); + const last = rows[rows.length - 1]; + const bottomGap = sc.getBoundingClientRect().bottom - last.getBoundingClientRect().bottom; + c.ok( + bottomGap < 40, + `no blank band under the last line (${Math.round(bottomGap)}px)`, + ); + }, + + /** + * A control may only offer what the list beneath it can actually do. + * + * The sort button rendered on every segment and offered all four orders + * everywhere, but only Local applies all four: a RefInfo carries no + * divergence from the default branch, so "Most ahead" reordered nothing on + * Remotes and Tags — while the button relabelled itself and stood there + * naming an order the list was not in. Stashes and Worktrees apply no sort + * at all, so every one of the four was inert. + */ + "the-sort-offers-only-what-the-segment-can-do": async (f) => { + const c = check(f); + const seg = (n) => $$(".gh-seg-btn")[n]; + const opts = async () => { + const b = $(".branches-sort"); + if (!b) return null; + b.click(); + await settle(300); + const o = $$(".dropdown-item").map((i) => text(i)); + document.body.click(); + await settle(150); + return o; + }; + + // Local: everything, including the two that need a divergence. + const local = await opts(); + c.ok(!!local, "Local has a sort control"); + for (const w of ["Recently committed", "Name", "Most ahead", "Stalest first"]) { + c.ok((local || []).includes(w), `Local offers ${w}`); + } + + // Tags: a date and a name, and nothing that could answer "most ahead". + seg(2)?.click(); + await settle(400); + const tags = await opts(); + c.ok(!!tags, "Tags has a sort control"); + c.ok(!(tags || []).includes("Most ahead"), `Tags does not offer an order it cannot apply (${(tags || []).join(", ")})`); + c.ok((tags || []).includes("Name"), "Tags still offers the orders it can"); + + // Stashes are a STACK — stash@{0} is the newest and the numbering is the + // order — and worktrees are a handful of paths. Neither has one to pick. + seg(3)?.click(); + await settle(400); + c.ok(!$(".branches-sort"), "Stashes offers no sort at all"); + seg(4)?.click(); + await settle(400); + c.ok(!$(".branches-sort"), "Worktrees offers no sort at all"); + }, + + /** + * A long line scrolls the LOG, never the page. + * + * `.log-window` is `min-width: max-content` so a long line can be scrolled + * to rather than wrapped. But a flex item's default `min-width: auto` + * refuses to shrink below its content, and `.det-body` — the item carrying + * every detail page's body — had no `min-width: 0`, so that intrinsic width + * propagated all the way up instead. One 400-character CI log line took + * .det-body from 1384px to 3192px inside a `.det-scroll` that clips on + * `overflow-x: hidden`: the page silently widened, Follow / Copy / Save / + * Expand went off-screen, and there was nothing to scroll back with. + */ + "a-long-log-line-scrolls-the-log-not-the-page": async (f) => { + const c = check(f); + const win = $(".log-window"); + const sc = $(".log-scroll"); + const pane = $(".log-pane"); + c.ok(!!win && !!sc && !!pane, "a log is open"); + if (!win || !sc || !pane) return; + const R = (e) => e.getBoundingClientRect(); + const rightBefore = Math.round(R(pane).right); + const wide = document.createElement("div"); + wide.className = "log-line"; + wide.textContent = "E".repeat(400); + win.appendChild(wide); + await settle(200); + c.eq(Math.round(R(pane).right), rightBefore, "the pane does not grow past where it was"); + c.ok( + R(pane).right <= window.innerWidth + 1, + `and stays inside the window (right ${Math.round(R(pane).right)} of ${window.innerWidth})`, + ); + // The width has to go SOMEWHERE — the scroller is the right place. + c.ok( + sc.scrollWidth > sc.clientWidth, + `the log scroller takes the overflow instead (${sc.scrollWidth} in ${sc.clientWidth})`, + ); + // And the toolbar is still reachable, which is the thing that was lost. + const tools = $$(".log-toolbar button, .log-tools button"); + c.ok(tools.length > 0, "the toolbar is present"); + for (const t of tools) { + c.ok( + R(t).right <= window.innerWidth + 1, + `${t.getAttribute("aria-label") || text(t) || "a control"} is still on screen`, + ); + } + }, + + /** + * Two ways to hold 20,000 lines in your head. + * + * A CI log is mostly ##[group] CONTENTS, so once you scroll past the header + * that named them you are reading 400 lines with no idea which step + * produced them; and errors are invisible until you happen to scroll onto + * one. The strip names the group the top of the port is inside, and the + * ticks put every error on a map of the whole log — that is what "scrolling + * is too fast" costs you when there is nothing to aim at. + */ + "a-long-log-can-be-navigated-by-eye": async (f) => { + const c = check(f); + noAnimation(); + // Read the failing job — the one with something to find. + const row = $$(".joblog-job").find((r) => /test/i.test(text(r))); + if (row) { + row.click(); + await settle(1400); + } + const s = $(".log-scroll"); + c.ok(!!s, "a log is open"); + if (!s) return; + + // Headless Chrome composites nothing on an idle page, so a programmatic + // scrollTop never produces the scroll event a real wheel would. Send it — + // the pane's repaint path is what is under test, not the compositor. + const scrollTo = async (top) => { + s.scrollTop = top; + s.dispatchEvent(new Event("scroll")); + await settle(300); + }; + + // At the very top there is no group above you, so the strip stays out of + // the way rather than repeating the header you can already see. + await scrollTo(0); + c.ok($(".log-groupbar")?.hidden !== false, "at the top the strip stays out of the way"); + + // Inside a group it names that group. + await scrollTo(700); + const bar = $(".log-groupbar"); + c.ok(bar && !bar.hidden, "scrolled into a step, the strip appears"); + c.ok(text(bar).length > 2, `and names the step (${JSON.stringify(text(bar))})`); + // The strip must sit at the TOP of the log, not somewhere down the page: + // as a sticky LAST child it stuck only at the very end of the log, which + // is nowhere anyone reading looks. + // + // Directly ABOVE the scroller, in a strip reserved for it — not ON the + // scroller's first row. It was an opaque overlay pinned to `top: 0` over + // 20px log rows, so while it showed, which is most of a CI log, the first + // line in the port was entirely hidden behind it and ArrowUp revealed + // nothing. Reserved permanently, so appearing does not shift the log. + const bb = bar?.getBoundingClientRect(); + const sb = s.getBoundingClientRect(); + c.ok(!!bb, "the strip has a box"); + c.ok( + bb && Math.abs(bb.bottom - sb.top) < 2, + `sits directly above the log, covering none of it (${Math.round((bb?.bottom ?? 0) - sb.top)}px of overlap)`, + ); + // And prove it against the row that is actually first in the port. + const rows = $$(".log-line") + .map((r) => [r.getBoundingClientRect().top, r]) + .filter(([y]) => y >= sb.top - 1) + .sort((x, y) => x[0] - y[0]); + if (bb && rows.length) { + c.ok( + bb.bottom <= rows[0][0] + 1, + `the first visible line is readable, not behind the strip (${Math.round(bb.bottom - rows[0][0])}px)`, + ); + } + + // Standing ON a group's own header needs no reminder of it — and this is + // the assertion that catches naming the group you have already LEFT: the + // strip is fed the first RENDERED line, which carries 30 lines of + // overscan above the fold, so it named the previous step for the first + // 30 lines of every new one. + // Walk down a line at a time until a step header IS the top row. Pixel + // arithmetic is not reliable here (the rows are virtualized and the + // scroller re-anchors), so step and look. + const topRow = () => { + const y = s.getBoundingClientRect().top; + return $$(".log-line").find((r) => r.getBoundingClientRect().bottom > y + 2); + }; + let landed = false; + for (let t = 700; t <= 1500 && !landed; t += 20) { + await scrollTo(t); + const r = topRow(); + if (!r || !/build bundles/i.test(text(r))) continue; + landed = true; + const onHeader = $(".log-groupbar"); + c.ok( + onHeader?.hidden !== false, + `standing on a step header, the strip does not repeat it ` + + `(says ${JSON.stringify(text(onHeader))})`, + ); + } + c.ok(landed, "the log has a step header to stand on"); + await scrollTo(700); + + // Pressing it goes back to the step's own header. + bar.click(); + s.dispatchEvent(new Event("scroll")); + await settle(400); + c.ok(s.scrollTop < 700, `clicking it returns to the step header (now ${Math.round(s.scrollTop)})`); + + // And the errors are on a map of the whole log, each one a click. + const ticks = $$(".log-errtick"); + c.ok(ticks.length > 0, "every error has a tick on the map"); + for (const t of ticks) c.ok(!!t.title, "each tick says which line it is"); + + // The map has to span the LOG, not the box around it. It is positioned + // against .log-body's padding box, and the group strip's reserved height + // is padding — so the map ran 19px taller than the scroller and every + // tick sat a few pixels above the line it pointed at. A map that does not + // line up is worse than no map. + const map = $(".log-errmap"); + c.ok(!!map, "the map exists"); + if (map) { + // Every tick INSIDE the map. `top` was set to the raw percentage, and + // `top: 100%` on a 3px box puts the whole box below the track — so an + // error on the log's LAST line, which is where a failing job's error + // usually is, drew its tick outside the map on the pane's border. + const track = map.getBoundingClientRect(); + const out = ticks.filter((t) => { + const r = t.getBoundingClientRect(); + return r.bottom > track.bottom + 0.5 || r.top < track.top - 0.5; + }); + c.eq(out.length, 0, `every error tick sits inside the map (${out.length} of ${ticks.length} outside)`); + const mb = map.getBoundingClientRect(); + const sb = s.getBoundingClientRect(); + c.ok( + Math.abs(mb.top - sb.top) <= 4, + `the map starts where the log does (${Math.round(mb.top - sb.top)}px off)`, + ); + c.ok( + Math.abs(mb.height - sb.height) <= 8, + `and is as tall as the log (${Math.round(mb.height - sb.height)}px difference)`, + ); + } + }, + + /** + * Editing a pull request is the same two fields as an issue, and it was the + * last surface still doing it in a modal — one with no draft key at all, so + * Escape, a route change or a window focus took the paragraph you had + * written and said nothing. + */ + "editing-a-pull-request-is-a-page-that-keeps-your-text": async (f) => { + const c = check(f); + const opener = $(".det-title-edit"); + c.ok(!!opener, "the pull request offers to edit its title and description"); + if (!opener) return; + opener.click(); + await settle(900); + + c.ok(!!$(".isc-form"), "it opens the composer page, not a modal"); + c.ok(!$(".modal-card"), "and nothing is modal about it"); + const title = $(".isc-title"); + const ta = $(".isc-form .md-text"); + c.ok(!!title && !!ta, "with the title and the description on it"); + if (!title || !ta) return; + c.ok(title.value.length > 0, "the title arrives filled in"); + c.ok(ta.value.length > 0, "and so does the description"); + // The sidebar belongs to the pull request's own page; two sets of label + // controls would duplicate and then disagree. + c.ok(!$(".isc-view .det-rail"), "editing offers no second set of label controls"); + + const typed = ta.value + "\n\nand one more thing"; + ta.value = typed; + ta.dispatchEvent(new Event("input", { bubbles: true })); + await settle(650); // longer than the draft debounce + + const back = $(".det-back"); + c.ok(!!back, "the page offers a way back"); + back.click(); + await settle(900); + const opener2 = $(".det-title-edit"); + c.ok(!!opener2, "leaving lands back on the pull request"); + if (!opener2) return; + opener2.click(); + await settle(900); + c.eq($(".isc-form .md-text")?.value, typed, "and what was typed survived leaving"); + // Restoring OVER text GitHub already has must be visible and undoable — + // silently rewriting a published body would read as the app editing + // behind your back. + const note = $(".isc-restored"); + c.ok(note && !note.hidden, "the page says the text was restored, rather than doing it silently"); + const discard = note && [...note.querySelectorAll("button")][0]; + c.ok(!!discard, "and offers the version on GitHub back"); + if (!discard) return; + discard.click(); + await settle(300); + c.ok( + $(".isc-form .md-text")?.value !== typed, + "taking it drops the restored draft", + ); + }, + + /** + * "same is for publishing releases." + * + * A draft's page offered Edit, Copy link, Delete and Open on GitHub — and + * publishing, the one thing the word "draft" exists to prompt, three clicks + * deep behind a kebab whose icon says nothing. `?arg=` is "draft" or + * "published". + */ + "a-draft-release-leads-with-publishing-it": async (f) => { + const c = check(f); + const top = $$(".det-tb-actions button").map((b) => (b.textContent || b.title || "").trim()); + if ((window.__GS_ARG || "draft") === "draft") { + c.ok( + top.some((t) => /^publish release/i.test(t)), + `a draft leads with publishing (top bar: ${top.join(", ")})`, + ); + const btn = $$(".det-tb-actions button").find((b) => /^publish release/i.test(text(b))); + c.ok(btn?.classList.contains("btn-primary"), "and it carries the primary weight"); + c.match(btn?.title, /notified|visible/i, "saying what publishing does"); + } else { + c.ok( + !top.some((t) => /^publish release/i.test(t)), + `a published release does not offer to publish itself (${top.join(", ")})`, + ); + } + }, + + /** + * The log must never scroll instead of you. + * + * "this retarded auto scrolling and fast scrolling in the logs window is + * driving me insane and has to go, following active logging is one thing, + * but scrolling super fast or instead of me is pure ragebait." + * + * Three separate faults wore that one sentence: + * - `follow` started true for EVERY pane, so opening a finished log — a + * document nobody has read yet — slammed it to the last line. + * - reaching the bottom silently RE-ARMED following, so reading to the + * end of a live log meant the next 4s poll yanked you away again. + * - a 20px line against a trackpad flick's 2,000-4,000px of momentum is + * a hundred-plus lines going past unreadably. + * + * `?arg=` is "finished" or "live". + */ + "the-log-never-scrolls-instead-of-you": async (f) => { + const c = check(f); + noAnimation(); + const which = window.__GS_ARG || "finished"; + const followBtn = () => $$(".log-tool").find((b) => /follow/i.test(b.title)); + const s0 = $(".log-scroll"); + c.ok(!!s0, "a log is open"); + if (!s0) return; + + if (which === "finished") { + // A document starts at its beginning. + c.eq(Math.round(s0.scrollTop), 0, "a finished log opens where the log starts"); + c.ok(!followBtn()?.classList.contains("is-on"), "and is not following anything"); + c.ok($(".log-jump")?.hidden !== false, "with no pill offering a latest that is not moving"); + c.match(text($$(".log-line")[0]), /^\s*1(?!\d)/, "the first line on screen is line 1"); + return; + } + + // A RUNNING job is the one case where following is right. + const row = $$(".joblog-job").find((r) => /running/i.test(text(r))); + c.ok(!!row, "the run has a job still producing output"); + if (!row) return; + row.click(); + await settle(1800); + const s = $(".log-scroll"); + c.ok(followBtn()?.classList.contains("is-on"), "a running job's log follows the tail"); + c.ok( + s.scrollTop + s.clientHeight >= s.scrollHeight - 40, + "and sits at the newest output", + ); + + const scrollTo = async (top) => { + s.scrollTop = top; + s.dispatchEvent(new Event("scroll")); + await settle(300); + }; + + // Reading away from the tail stops it — that is the reader saying "stop + // moving". + await scrollTo(200); + c.ok(!followBtn()?.classList.contains("is-on"), "scrolling away stops the tail"); + c.ok($(".log-jump")?.hidden === false, "and offers to take you back"); + + // Reading back TO the tail must not silently restart it. This is the + // whole "scrolling instead of me" complaint: it used to re-arm here, and + // the next poll moved the page under the reader. + await scrollTo(s.scrollHeight); + c.ok( + !followBtn()?.classList.contains("is-on"), + "reaching the bottom does NOT silently start following again", + ); + c.ok($(".log-jump")?.hidden === true, "and the pill goes, because there is nothing to jump to"); + }, + /** + * Typing in the log's search box must HIGHLIGHT, not travel. + * + * It jumped the viewport to the first match on every keystroke, so typing + * "err" hard-scrolled to three different places before the word was + * finished — the other half of "scrolling instead of me". Enter goes. + */ + "searching-a-log-highlights-before-it-travels": async (f) => { + const c = check(f); + noAnimation(); + const s = $(".log-scroll"); + const inp = $(".log-search input") || $(".log-pane input"); + c.ok(!!s && !!inp, "the log has a search box"); + if (!s || !inp) return; + s.scrollTop = 0; + s.dispatchEvent(new Event("scroll")); + await settle(200); + + inp.value = "bundling"; + inp.dispatchEvent(new Event("input", { bubbles: true })); + await settle(500); + c.eq(Math.round(s.scrollTop), 0, "typing does not move the viewport"); + c.match(text(".log-match-count"), /\d+ match/, "but it counts what it found"); + c.ok($$(".log-hit").length > 0, "and paints the hits where they are"); + + inp.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await settle(400); + c.ok(s.scrollTop > 0, "Enter is what travels"); + c.match(text(".log-match-count"), /^1 of \d+/, "landing on the FIRST match, not the second"); + }, + + /** + * A wheel notch must move a readable number of lines, not a screenful. + * Native pixel deltas are tuned for prose; a wall of 20px monospace is + * scanned, not read. + */ + "the-log-damps-the-wheel": async (f) => { + const c = check(f); + noAnimation(); + const s = $(".log-scroll"); + c.ok(!!s, "a log is open"); + if (!s) return; + s.scrollTop = 400; + s.dispatchEvent(new Event("scroll")); + await settle(200); + const before = s.scrollTop; + const ev = new WheelEvent("wheel", { deltaY: 400, deltaMode: 0, bubbles: true, cancelable: true }); + s.dispatchEvent(ev); + await settle(200); + const moved = s.scrollTop - before; + c.ok(ev.defaultPrevented, "the pane takes the wheel event rather than leaving it native"); + c.ok(moved > 0, `it still scrolls (moved ${Math.round(moved)}px)`); + c.ok( + moved < 400, + `but less than the raw delta — 400px of wheel moved ${Math.round(moved)}px`, + ); + // And a synthetic monster delta can never teleport more than a screenful. + const huge = new WheelEvent("wheel", { deltaY: 40000, deltaMode: 0, bubbles: true, cancelable: true }); + const at = s.scrollTop; + s.dispatchEvent(huge); + await settle(200); + c.ok( + s.scrollTop - at <= s.clientHeight + 2, + `one event never moves more than a screenful (moved ${Math.round(s.scrollTop - at)} of ${s.clientHeight})`, + ); + }, + + /** + * "the two diff views, inline and side to side, sometimes dont work and + * dont show the diffs either on both views or just on one of them." + * + * One cause sat underneath both modes: `showAt` in main returned `git + * show`'s stdout bare. A binary file therefore arrived as two empty strings + * (or a wall of U+FFFD), the panel mounted two editors over nothing, and + * the app looked broken over a PNG behaving exactly as a PNG does. + */ + "a-diff-that-cannot-be-shown-says-why": async (f) => { + const c = check(f); + // The commit page and the PR's Files tab list their files differently; + // the DEMAND is the same on both, and both producers had to be taught to + // flag a binary rather than hand the editor two empty strings. + const rowSel = window.__GS_ARG === "prfiles" ? ".file-row" : ".cmt-file"; + // No \b after "png": on the commit page the row's own text runs the + // extension straight into the word "binary", and g-then-b is not a word + // boundary. + const bin = $$(rowSel).find((r) => /\.png/i.test(text(r))); + c.ok(!!bin, `the list has a binary file in it (${rowSel})`); + if (!bin) return; + bin.click(); + await settle(1400); + c.ok(!$(".monaco-editor"), "no editor is mounted over content that has none"); + c.match(text(".list-empty-title"), /binary/i, "the panel names what it is"); + c.match(text(".list-empty-desc"), /nothing to diff|binary/i, "and why there is no diff"); + c.match(text(".list-empty-desc"), /\.png/, "naming the file it is talking about"); + + // And a text file beside it still renders, so this is not a panel that + // gave up on everything. + // No \b here either: a row's textContent concatenates the file name + // straight into its directory ("issues.tsapps/desktop/..."), so every + // extension is followed by a word character. + const txt = $$(rowSel).find((r) => /\.ts/i.test(text(r))); + c.ok(!!txt, "there is a text file too"); + if (!txt) return; + txt.click(); + await settle(1400); + c.ok(!!$(".diffmode-body"), "a text file still gets the diff panel"); + c.ok(!$(".list-empty-title"), "with no 'nothing to show' over it"); + }, + + /** + * Compare renders its diff through the same panel as everywhere else, so it + * has the same Inline/Split control. It used to own a separate class with + * no chrome at all and let Monaco's width heuristic decide invisibly — + * while the segmented control already in Compare's header (two-dot vs + * three-dot RANGE) made it look as though the switch was there. + */ + "compare-has-the-same-diff-switch-as-everywhere-else": async (f) => { + const c = check(f); + const row = $$(".file-row")[0]; + c.ok(!!row, "Compare lists changed files"); + if (!row) return; + row.click(); + await settle(1600); + + const seg = $$(".diffmode-seg .cmp-mode-btn").map((b) => text(b)); + c.ok(seg.includes("Inline") && seg.includes("Split"), `Compare offers both modes (${seg.join(", ") || "none"})`); + c.ok(!!$(".diffmode-path"), "and names the file above the diff"); + + // Moving Compare onto the shared panel changed what sits in its pane, and + // the two rules that FILL that pane and take the pointer off the editor + // mid-drag were still aimed at the class the old surface had. A rule that + // matches nothing is invisible: the pane looked right and the divider + // drag started selecting text inside Monaco. + const wrap = $(".cmp-diffpane > .diffmode-wrap"); + c.ok(!!wrap, "the panel is the pane's own child, so the fill rule can reach it"); + if (wrap) { + const pane = $(".cmp-diffpane").getBoundingClientRect(); + const w = wrap.getBoundingClientRect(); + c.ok( + Math.abs(w.height - pane.height) < 3 && Math.abs(w.width - pane.width) < 3, + `and it fills the pane (${Math.round(w.width)}x${Math.round(w.height)} of ${Math.round(pane.width)}x${Math.round(pane.height)})`, + ); + } + document.body.classList.add("resizing-h"); + const guarded = $(".cmp-diffpane .diffmode-body"); + c.eq( + guarded ? getComputedStyle(guarded).pointerEvents : "", + "none", + "and while the divider is dragged the editor does not take the pointer", + ); + document.body.classList.remove("resizing-h"); + + // The switch has to actually switch. + const inline = $$(".diffmode-seg .cmp-mode-btn").find((b) => /inline/i.test(text(b))); + inline.click(); + await settle(1200); + c.ok(inline.classList.contains("active"), "pressing Inline selects Inline"); + c.eq(inline.getAttribute("aria-pressed"), "true", "and says so to assistive tech"); + const split = $$(".diffmode-seg .cmp-mode-btn").find((b) => /split/i.test(text(b))); + c.ok(!split.classList.contains("active"), "and deselects Split"); + c.ok(!!$(".diffmode-body")?.firstElementChild, "with an editor still in the body"); + }, + + /** + * "the bare commits view in compare and pr are not improved as i requested, + * you can take example of how they look in github and follow similar ui". + * + * They were bare: a subject and one grey line reading "author · sha · 3h + * ago". Everything below except the avatar was already in the response and + * dropped one layer down, in the mappers. + * + * `?arg=` is "pr" or "compare" — the SAME renderer draws both, which is the + * other half of the point: they had drifted to opposite sort orders and + * Compare's rows still told assistive tech they would "reveal in the graph" + * long after the click had been changed to open the commit. + */ + "a-commit-list-reads-like-a-list-of-commits": async (f) => { + const c = check(f); + const rows = $$(".clist-row"); + c.ok(rows.length > 1, `the list rendered (${rows.length} rows)`); + if (rows.length < 2) return; + + // Grouped by the day the work happened, github-style. + const days = $$(".clist-day").map((d) => text(d)); + c.ok(days.length > 0, "commits are grouped under the day they were made"); + for (const d of days) c.match(d, /^Commits on /, `a day heading names itself (${d})`); + + // Every row: a face, a subject that opens it, and a sha you can take. + for (const r of rows.slice(0, 4)) { + const av = r.querySelector(".av"); + c.ok(!!av, "each row shows who wrote it"); + c.match(av?.getAttribute("aria-label"), /author/i, "and says whose face that is"); + const subj = r.querySelector(".clist-subject"); + c.ok(!!subj && text(subj).length > 0, "and what it says"); + c.match(subj?.title, /open commit/i, "the subject opens the commit"); + const sha = r.querySelector(".clist-sha"); + c.ok(!!sha, "and carries its sha"); + c.match(sha?.getAttribute("aria-label"), /copy the full sha/i, "which is copyable"); + c.match(text(r.querySelector(".clist-meta")), /committed/, "with when it landed"); + } + + // OLDEST FIRST — the order the work was done in, on both surfaces. + const times = $$(".clist-when").map((t) => Date.parse(t.title)).filter((n) => !Number.isNaN(n)); + c.ok(times.length > 1, "the rows carry real timestamps"); + const ascending = times.every((t, i) => i === 0 || t >= times[i - 1]); + c.ok(ascending, "and read oldest first, the order the work was done in"); + + // Nothing may still claim the old destination. + const stale = rows.filter((r) => /reveal in the (commit )?graph/i.test(r.innerHTML)); + c.eq(stale.length, 0, "no row still says it reveals a commit in the graph"); + + // Clicking a subject opens the commit PAGE. + const before = window.__GS_ROUTES.length; + rows[0].querySelector(".clist-subject").click(); + await settle(1200); + const went = window.__GS_ROUTES.slice(before).map((r) => r.view); + c.ok(went.includes("commit"), `it opens the commit (went: ${went.join(" → ") || "nowhere"})`); + }, + + /** + * The unified view depends on a WORKER; the side-by-side one does not. + * + * Split (`DiffView`) computes its diff in-process. Inline is Monaco's + * native diff editor, which computes in the editor web worker — so when + * that worker is missing, cold, crashed, or answering for a model that has + * since been disposed, the editor mounts, paints the modified text, and + * shows no diff at all. On a deleted file it shows nothing whatsoever. And + * every error it produces was swallowed as worker noise, so the surface + * simply looked broken: "sometimes dont work and dont show the diffs either + * on both views or just on one of them". + * + * This harness runs from file://, where the blob worker's importScripts is + * blocked — which makes it the exact environment the fallback exists for. + */ + "a-diff-never-renders-as-an-unmarked-file": async (f) => { + const c = check(f); + const inline = $$(".cmp-mode-btn").find((b) => /inline/i.test(text(b))); + c.ok(!!inline, "the panel offers the unified view"); + if (!inline) return; + inline.click(); + // Longer than the grace period the panel waits for the worker. + await settle(3600); + + // Whatever happened, the reader must be looking at a real DIFF — not at + // an editor that mounted and painted the file with nothing marked, which + // is exactly what a silent worker produces and is indistinguishable from + // a working diff if you only ask whether an editor exists. + const fellBack = $$(".jb-pane-body").length === 2; + const marked = $$(".line-insert, .line-delete, .char-insert, .char-delete").length; + c.ok( + fellBack || marked > 0, + `the changes are actually marked (fellBack=${fellBack}, marked=${marked})`, + ); + + // If it fell back, it has to SAY so — silently showing a different view + // than the one whose button is lit is its own kind of broken. + if (fellBack) { + c.match( + text(".diff-truncated-note"), + /side by side|didn't come back/i, + "the fallback says which view this is", + ); + const active = $$(".cmp-mode-btn.active").map((b) => text(b)); + c.ok( + active.includes("Split"), + `and the segment marks the view actually rendered (${active.join(", ")})`, + ); + } + + // Asking for a mode explicitly clears a note about a render that is gone + // — the FALLBACK note, and only that one. The truncation warning shares + // its styling but not its meaning: it is about the file's CONTENT, true + // in either mode, and it used to share the class too, so one press of the + // toggle permanently deleted "this file is too large to diff in full" + // from every file over the 512KB cap. + const split = $$(".cmp-mode-btn").find((b) => /split/i.test(text(b))); + split.click(); + await settle(900); + c.ok(!$(".diff-fallback-note"), "choosing a mode clears the stale explanation"); + // A note that survives must be the truncation one, saying so. + const kept = $(".diff-truncated-note"); + if (kept) { + c.match( + text(kept), + /too large|first part/i, + `only a warning about the FILE may outlive a mode change (${JSON.stringify(text(kept))})`, + ); + } + + // And the toggle is still live. The guard compared the click to the + // STORED preference rather than to what is on screen, so after a fallback + // — stored "inline", showing Split — pressing Inline matched and returned, + // leaving the button inert for the rest of the session. + const backToInline = $$(".cmp-mode-btn").find((b) => /inline/i.test(text(b))); + if (backToInline) { + backToInline.click(); + await settle(900); + const now = $$(".cmp-mode-btn.active").map((b) => text(b)); + c.ok( + now.length > 0, + `pressing Inline is not a no-op — the segment still says what is rendered (${now.join(", ")})`, + ); + } + }, + + /** + * A list that can grow without bound must sit inside something that + * scrolls. + * + * "actualy i cant scroll at all on the commits view in compare and pr." + * Compare's scroller was a single rule — `.cmp-commits { overflow-y: auto }` + * — and it was deleted along with the old row styles when both surfaces + * moved to the shared `commitList()`. The list still rendered, and with a + * fixture of three commits it still FIT, so nothing looked wrong: a branch + * with more commits than the pane is tall simply could not be reached. + * + * Asserted structurally, not by overflowing: a check that needs the content + * to be long enough is a check that passes on whatever the fixture happens + * to hold. Walk up from the list and require a real scroller before the + * view host. + */ + "a-commit-list-can-be-scrolled": async (f) => { + const c = check(f); + noAnimation(); + const list = $(".clist"); + c.ok(!!list, "the commits list rendered"); + if (!list) return; + + let node = list; + let scroller = null; + const walked = []; + while (node && node !== document.body) { + const ov = getComputedStyle(node).overflowY; + walked.push(`${(node.className || node.tagName).toString().split(" ")[0]}:${ov}`); + if (ov === "auto" || ov === "scroll") { + scroller = node; + break; + } + if (node.classList.contains("view-host")) break; // past the view + node = node.parentElement; + } + c.ok( + !!scroller, + `something between the list and the view host scrolls (walked ${walked.join(" → ")})`, + ); + if (!scroller) return; + // And it must be able to grow: a scroller pinned to its content height + // scrolls in principle and never in practice. + c.ok( + getComputedStyle(scroller).minHeight !== "auto" || + scroller.getBoundingClientRect().height < scroller.scrollHeight + 1, + "and it is height-constrained, so it will actually scroll when the list grows", + ); + + // Then prove it. The fixtures are long enough to overflow on purpose — + // three commits FIT, which is why the missing scroller went unnoticed. + c.ok( + scroller.scrollHeight > scroller.clientHeight, + `the list is longer than its pane (${scroller.scrollHeight} vs ${scroller.clientHeight})`, + ); + scroller.scrollTop = 400; + await settle(200); + c.ok(scroller.scrollTop > 0, `and it moves when scrolled (at ${Math.round(scroller.scrollTop)})`); + + // The last row must be reachable, not cut off under the pane's edge. + const rows = $$(".clist-row"); + const last = rows[rows.length - 1]; + last.scrollIntoView({ block: "nearest" }); + await settle(200); + const r = last.getBoundingClientRect(); + const box = scroller.getBoundingClientRect(); + c.ok( + r.bottom <= box.bottom + 2 && r.top >= box.top - 2, + "and the last commit can be brought fully into view", + ); + }, + + /** + * The ref manager, per KIND. + * + * "also local, remote, tag and stashes views inside branches should also be + * enhanced." It was the last view still hand-rolling its own chrome: four + * collapsible groups of four incompatible row shapes, no title, no count, + * no Refresh, no facets, no routed detail. Remote branches, tags and + * stashes had NO row actions at all — their entire verb set required + * opening a modal first — and Fetch, the action that makes every + * ahead/behind number on the screen true, was a menu item inside one local + * branch's hover-revealed kebab. + * + * `?arg=` is which segment to check. + */ + "the-ref-manager-shows-one-kind-at-a-time": async (f) => { + const c = check(f); + noAnimation(); + const want = window.__GS_ARG || "local"; + + // The shared header: a title, a live count, Refresh — and FETCH, at the + // surface, named so it cannot be confused with Refresh. + c.eq(text(".list-head-title"), "Branches", "the view says what it is"); + c.ok(!!$(".gh-head-count"), "and how many are on screen"); + const tools = $$(".gh-head-tools button, .gh-acct button").map((b) => text(b) || b.title); + c.ok( + tools.some((t) => /^fetch/i.test(t)), + `Fetch is a header button, not a menu item (${tools.join(", ")})`, + ); + c.ok(tools.some((t) => /refresh/i.test(t)), "Refresh is still its own control"); + const fetchBtn = $$("button").find((b) => /^fetch$/i.test(text(b))); + c.match(fetchBtn?.title, /remote/i, "and Fetch says it goes to the network"); + + // One kind per screen. + const segs = $$(".gh-seg-btn").map((b) => text(b)); + for (const kind of ["Local", "Remotes", "Tags", "Stashes"]) { + c.ok(segs.some((s) => s.startsWith(kind)), `${kind} has its own segment (${segs.join(" | ")})`); + } + for (const s of segs) c.match(s, /\(\d+\)/, `each segment carries its count (${s})`); + + const idx = { local: 1, remote: 2, tags: 3, stashes: 4 }[want]; + $$(".gh-seg-btn")[idx - 1].click(); + await settle(500); + + const rows = $$(".sec-row"); + c.ok(rows.length > 0, `${want} has rows`); + if (!rows.length) return; + + // EVERY kind carries verbs, at rest. Remote branches, tags and stashes + // had none at all — this is the heart of the ask. + for (const r of rows.slice(0, 3)) { + const acts = [...r.querySelectorAll(".sec-row-actions button")]; + c.ok(acts.length >= 1, `a ${want} row carries its own verbs (${acts.length})`); + for (const b of acts) { + const name = (b.textContent || "").trim() || b.getAttribute("aria-label") || b.title; + c.ok(!!name, "and every one of them has a name"); + // At rest, not on hover: an action revealed only by a pointer cannot + // be reached by keyboard or by touch at all. + c.ok(Number(getComputedStyle(b).opacity) > 0, `${name} is visible without hovering`); + } + c.ok(!!r.querySelector(".sec-row-time"), "and says when it last moved"); + } + + // Right-click MIRRORS the menu; it is a shortcut, never a verb's only door. + c.ok(!!rows[0].querySelector(".lv-menu-btn"), "the overflow menu is a real button on the row"); + }, + + /** + * A remote's own HEAD ref shortens to the bare remote NAME — "origin", not + * "origin/HEAD" — so the `endsWith("/HEAD")` guard never fired and the list + * carried a phantom row called "origin" offering to check out nothing. + */ + "the-remote-list-has-no-phantom-origin-row": async (f) => { + const c = check(f); + $$(".gh-seg-btn")[1].click(); + await settle(500); + const names = $$(".sec-row").map((r) => (r.dataset.ref || "").trim()); + c.ok(names.length > 0, "the remotes segment has rows"); + c.ok( + !names.includes("origin"), + `no bare remote name is listed as a branch (${names.join(", ")})`, + ); + for (const n of names) { + c.ok(n.includes("/"), `every remote row names a branch on a remote (${n})`); + } + }, + + /** + * A ref is a PLACE. Its history was a modal peek — no route, no back-stack + * entry, gone on Escape — and for a remote branch, a tag or a stash that + * modal was the ONLY door to every action it had. + */ + "a-ref-opens-its-own-page": async (f) => { + const c = check(f); + const before = window.__GS_ROUTES.length; + $(".sec-row").click(); + await settle(1200); + const went = window.__GS_ROUTES.slice(before).map((r) => r.view); + c.ok(went.includes("refdetail"), `a row opens the ref's page (went: ${went.join(" → ") || "nowhere"})`); + c.ok(!$(".modal-overlay"), "and nothing modal is involved"); + c.ok(!!$(".rd-title"), "the page names the ref"); + const back = $(".det-back"); + c.ok(!!back, "with a way back"); + c.match(text(back), /branches/i, "that names where it came from"); + // Its verbs live in the top bar, where a page's verbs live. + const verbs = $$(".det-tb-actions button").map((b) => text(b) || b.title); + c.ok(verbs.length >= 2, `the page carries the ref's actions (${verbs.join(", ")})`); + c.ok(!!$(".rd-history"), "and what is on the ref"); + }, + + /** + * "What is safe to delete" — a question a branch list is opened to answer + * at least as often as "what do I switch to", and one this view could not + * answer at all. A branch is finished when every commit on it is already in + * the default branch, or when the upstream it tracked has been deleted: + * exactly what a merged pull request leaves behind. + */ + "finished-branches-can-be-swept": async (f) => { + const c = check(f); + const sweep = $(".branches-sweep"); + c.ok(!!sweep && !sweep.hidden, "the local list offers to clear the finished branches"); + if (!sweep) return; + c.match(text(sweep), /delete \d+ finished/i, "and says how many it means"); + c.match(sweep.title, /default branch|upstream/i, "and what it counts as finished"); + + // The confirm must NAME them. A squash-merged branch does not look merged + // to git, so a bulk delete that does not show its list is one nobody + // should press. + sweep.click(); + await settle(600); + const dlg = $(".modal-card"); + c.ok(!!dlg, "it asks first"); + if (!dlg) return; + const body = text(dlg); + c.match(body, /redesign\/wave-1/, "naming every branch it will delete"); + c.match(body, /squash/i, "and warning that a squash-merge does not look merged to git"); + c.match(body, /local/i, "and that only the local copies go"); + + // THE DEFAULT BRANCH IS NOT IN THAT LIST. `merged` is "zero commits ahead + // of the default branch", which the default branch satisfies against + // itself — so main qualified, and this confirm listed it by name among + // the branches that really were done. Reachable only with ?onfeature=1, + // because every other fixture keeps main checked out and `!b.current` + // hides the bug. + // + // Read the NAMES, not the whole card: `text()` concatenates the title + // straight onto the message, so "…branches?main" hides `main` from any + // word-boundary match and the assertion passes on a broken build. + const listed = text(".modal-message").split("\n\n")[0].split("\n").map((x) => x.trim()); + c.ok( + !listed.includes("main"), + `the default branch is never swept (dialog listed: ${listed.join(", ")})`, + ); + + // It is not the SEGMENT that offers this on other kinds. + const cancel = [...dlg.querySelectorAll("button")].find((b) => /cancel/i.test(text(b))); + cancel?.click(); + await settle(300); + $$(".gh-seg-btn")[2].click(); + await settle(400); + const after = $(".branches-sweep"); + c.ok(after?.hidden !== false, "and it is offered only where branches are"); + }, + + /** + * The list can be ASKED things. + * + * You could not ask this view what is ahead, what has no upstream, which + * remote branches you already have, how recently anything moved, or — the + * one that matters — what is safe to delete. Every facet is client-side + * over a whole-set read, so changing one is a re-render, not a refetch, and + * the state is kept per KIND because a Standing filter means nothing on the + * tags screen. + */ + "the-ref-list-can-be-narrowed": async (f) => { + const c = check(f); + noAnimation(); + const facets = () => $$(".branches-facets .gh-facet-btn").map((b) => text(b)); + c.ok(facets().includes("Standing"), `local branches can be filtered by standing (${facets().join(", ")})`); + c.ok(facets().includes("Remote"), "and by which remote they track"); + + const before = $$(".sec-row").length; + c.ok(before > 1, "there is more than one row to narrow"); + const btn = $$(".branches-facets .gh-facet-btn").find((b) => /standing/i.test(text(b))); + btn.click(); + await settle(400); + const opt = $$(".dropdown-item").find((i) => /gone|merged/i.test(text(i))); + c.ok(!!opt, "the menu offers the states the rows actually wear"); + if (!opt) return; + opt.click(); + await settle(500); + const after = $$(".sec-row").length; + c.ok(after < before, `choosing one narrows the list (${before} → ${after})`); + c.match(text(".gh-head-count"), /of/, "and the count says it is narrowed"); + + // Per KIND: the tags screen must not inherit a branch filter. + $$(".gh-seg-btn")[2].click(); + await settle(500); + c.ok( + !facets().includes("Standing"), + `the tags screen has its own filters (${facets().join(", ") || "none"})`, + ); + c.ok($$(".sec-row").length > 0, "and is not narrowed by a filter set on another kind"); + }, + + /** + * Active / Stale / All — github.com's own cut at three months, and the + * difference between "what I am working on" and "everything this clone has + * ever touched". + */ + "branches-can-be-cut-by-how-recently-they-moved": async (f) => { + const c = check(f); + const seg = $$(".branches-facets .gh-seg-btn").map((x) => text(x)); + const named = (word) => $$(".branches-facets .gh-seg-btn").find((x) => text(x).startsWith(word)); + c.ok( + !!named("Active") && !!named("Stale") && !!named("All"), + `the age cut is offered (${seg.join(", ") || "none"})`, + ); + // Each carries how many it would show. A cut you cannot size before + // pressing is a cut you press twice — and the count has to be measured + // AFTER the search and the facets, or it names a list you cannot get to. + for (const w of ["Active", "Stale", "All"]) { + const btn = named(w); + if (btn) c.match(text(btn), /\(\d+\)/, `${w} says how many`); + } + const active = $$(".sec-row").length; + const all = named("All"); + c.eq( + Number((text(named("Active")) .match(/\((\d+)\)/) || [])[1]), + active, + "and Active's count is the list you are looking at", + ); + // Stale is the half of this control that does work — Active is just "the + // list". It must be non-empty in the fixture, or every assertion here + // passes by filtering nothing out of nothing. + const staleN = Number((text(named("Stale")).match(/\((\d+)\)/) || [])[1]); + c.ok(staleN > 0, `the fixture has branches old enough to be stale (${staleN})`); + named("Stale").click(); + await settle(400); + c.eq($$(".sec-row").length, staleN, "pressing Stale shows exactly the stale ones"); + // And they really are old — not merely a different subset. + const ages = $$(".sec-row .sec-row-time").map((x) => text(x)); + c.ok( + ages.length > 0 && ages.every((t) => /mo|y/.test(t)), + `each of them last moved months ago (${ages.join(", ")})`, + ); + + all.click(); + await settle(400); + c.ok($$(".sec-row").length >= active, "All shows at least what Active did"); + c.eq( + $$(".sec-row").length, + Number((text(named("All")).match(/\((\d+)\)/) || [])[1]), + "and All's count was the truth about All", + ); + c.eq( + $$(".sec-row").length, + active + staleN, + "and Active plus Stale is All — no branch falls between the two cuts", + ); + + // And the sort is a real control, not a fixed order. + const sort = $(".branches-sort"); + c.ok(!!sort, "the list says how it is ordered"); + c.match(text(sort), /recently committed/i, "and starts on recency"); + sort.click(); + await settle(400); + const opts = $$(".dropdown-item").map((i) => text(i)); + c.ok(opts.some((o) => /name/i.test(o)), `it offers other orders (${opts.join(", ")})`); + const byName = $$(".dropdown-item").find((i) => /^name$/i.test(text(i))); + byName.click(); + await settle(500); + c.match(text(".branches-sort"), /name/i, "and picking one says so"); + const names = $$(".sec-row").map((r) => r.dataset.ref || ""); + const sorted = [...names].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); + c.eq(names.join("|"), sorted.join("|"), "and the rows are actually in that order"); + }, + + /** + * Worktrees. `worktree:list/add/remove/open` have been in the IPC contract + * since it was written with no caller in any view — and no fixture, so the + * capability existed and nothing could reach it. + */ + "worktrees-are-reachable": async (f) => { + const c = check(f); + const seg = $$(".gh-seg-btn").find((b) => /worktrees/i.test(text(b))); + c.ok(!!seg, "worktrees have a segment when there is more than one"); + if (!seg) return; + c.match(text(seg), /\(\d+\)/, "with its count"); + seg.click(); + await settle(500); + const rows = $$(".sec-row"); + c.ok(rows.length > 1, `they are listed (${rows.length})`); + const current = rows.find((r) => /this window/i.test(text(r))); + c.ok(!!current, "and the one this window has open says so"); + for (const r of rows) { + c.ok(!!r.querySelector(".sec-row-actions button"), "each carries its verbs"); + c.ok((r.dataset.ref || "").includes("/"), "and names the path it lives at"); + } + }, + + /** + * The keyboard. Nothing in this view had a shortcut: not the filter, not + * Fetch, not a row's own verb. + */ + "the-ref-list-answers-the-keyboard": async (f) => { + const c = check(f); + const input = $(".branches-view .gh-search input") || $(".branches-view input"); + c.ok(!!input, "the view has a search box"); + if (!input) return; + input.blur(); + const row = $(".sec-row"); + row.focus(); + // "/" focuses the search, the way every list people already know does. + row.dispatchEvent(new KeyboardEvent("keydown", { key: "/", bubbles: true, cancelable: true })); + await settle(300); + c.eq(document.activeElement, input, "“/” puts the keyboard in the search box"); + + // ⌘Enter runs the focused row's PRIMARY verb. + input.blur(); + const target = $$(".sec-row").find((r) => r.querySelector(".sec-row-actions .row-btn:not(.lv-menu-btn)")); + c.ok(!!target, "a row has a primary verb to run"); + if (!target) return; + const verb = target.querySelector(".sec-row-actions .row-btn:not(.lv-menu-btn)"); + let fired = false; + verb.addEventListener("click", () => (fired = true), { once: true }); + target.focus(); + target.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true, cancelable: true }), + ); + await settle(300); + c.ok(fired, `⌘Enter runs the row's verb (${text(verb)})`); + }, + + // Five defects that were introduced BY the fixes made earlier in this same + // session. Each one is a control that a fix left correct in the state it + // was written for and wrong in the neighbouring state — which is why they + // are pinned here rather than trusted to a re-read. + + // The ` ` replace in highlight.ts matched nothing: Monaco writes the + // CHARACTER (`sb.appendCharCode(0xA0)`), not the entity, so every + // highlighted block still copied with non-breaking spaces for indentation + // while the line that was supposed to fix it sat there looking right. + "highlighted-code-copies-as-real-spaces": async (f) => { + const c = check(f); + const code = $('pre > code[class*="language-"]'); + c.ok(!!code, "the issue body has a highlighted fence"); + if (!code) return; + c.ok(code.querySelectorAll("span").length > 4, "and it really was tokenized"); + const t = code.textContent || ""; + c.eq((t.match(/\u00a0/g) || []).length, 0, "no non-breaking spaces survive into the text"); + c.ok((t.match(/ {2}/g) || []).length > 0, "the indentation is REAL spaces"); + }, + + // `separator: items.length > 0` is false for a DRAFT item, which has no + // "Open on GitHub" above it — so openMenu rendered the group label as an + // ordinary command button: focusable, clickable, and wired to nothing. + "move-to-is-a-label-not-a-command": async (f) => { + const c = check(f); + const kebab = $(".gh-card-kebab"); + c.ok(!!kebab, "a board card has a kebab"); + if (!kebab) return; + kebab.click(); + await settle(400); + const menu = $(".dropdown"); + c.ok(!!menu, "the menu opens"); + if (!menu) return; + const items = $$(".dropdown-item", menu).map((b) => (text(b) || "").trim()); + const seps = $$('[role="separator"]', menu).map((s2) => (text(s2) || "").trim()); + c.ok(seps.includes("Move to"), "“Move to” is a group label"); + c.ok(!items.includes("Move to"), "and NOT a command that does nothing"); + c.ok(items.length > 1, "the statuses it introduces are there"); + }, + + // Typing a query no longer travels to a match, so `matchIdx` is -1 — and + // `matchIdx + 1` rendered that as "0/12": a position that cannot exist, + // reading as "found nothing" directly beside a list of twelve. + "graph-search-count-is-not-a-fake-position": async (f) => { + const c = check(f); + const g = $("gitstudio-graph"); + c.ok(!!g, "the graph is mounted"); + if (!g) return; + const root = g.shadowRoot || g; + const input = root.querySelector("input"); + c.ok(!!input, "the graph has a search box"); + if (!input) return; + const readout = () => + [...new Set($$(".gheader *", root).map((n) => (n.textContent || "").trim()))].find( + (t) => /match|\d\/\d/.test(t) && t.length < 24, + ); + input.focus(); + input.value = "e"; + input.dispatchEvent(new Event("input", { bubbles: true })); + await settle(900); + const before = readout(); + c.ok(!!before, "a search says how many it found"); + c.ok(!/^0\//.test(before || ""), `it never reads as position zero (got “${before}”)`); + c.ok(/match/.test(before || ""), "before travelling it states a COUNT"); + // And once you do travel, it becomes a real position. + input.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }), + ); + await settle(600); + c.ok(/^1\/\d/.test(readout() || ""), "Enter makes it a 1-based position"); + }, + + // The Assistant. Every control on it was unreachable until the fixture + // learned to report a connected model — the gate held the whole surface, + // so none of this had ever been driven. + + "the-connect-gate-holds-every-control": async (f) => { + const c = check(f); + c.ok(!!$(".assistant-empty .btn"), "the connect prompt is up"); + c.eq($(".assistant-input").disabled, true, "the composer is off"); + c.eq($(".assistant-send").disabled, true, "Send is off"); + // These stayed live in front of the gate, and running one ended by + // clearing the busy state off Send — talking a gated composer back into + // looking usable. + const chips = $$(".assistant-chip"); + c.ok(chips.length > 0, "there are quick actions to check"); + c.ok(chips.every((b) => b.disabled), "and the quick actions are off too"); + // …as are the two chat-management controls. Their handlers each return + // on `gated`, which is correct and invisible: pressing New chat did + // nothing, and there was nothing to say why. + const chatBtns = $$(".assistant-iconbtn"); + c.ok(chatBtns.length >= 2, "the header's chat controls exist"); + c.ok(chatBtns.every((b) => b.disabled), "and they are off while the gate is closed"); + c.ok( + chatBtns.every((b) => /connect a model/i.test(b.title || "")), + "each saying why, not merely greyed", + ); + }, + + // A turn that FAILS mid-sentence. `is-streaming` draws a blinking caret + // after the last line, and the throw path did not remove it — so the + // partial reply went on looking like it was still being typed, for as long + // as the chat stayed open, with an error message underneath it. + "a-failed-turn-stops-looking-like-it-is-typing": async (f) => { + const c = check(f); + const input = $(".assistant-input"); + const send = $(".assistant-send"); + c.ok(!!input && !!send, "the assistant is live"); + if (!input || !send) return; + + const inv = window.gitstudio.invoke; + let rid = null; + let failTurn = null; + window.gitstudio.invoke = (ch, p) => { + if (ch === "ai:chatSend") { + rid = p.requestId; + // HELD OPEN, so the deltas below arrive while the turn is still + // live. Rejecting straight away tears the listeners down in + // `finally` before anything can be streamed into it, and then the + // check proves nothing. + return new Promise((_res, rej) => (failTurn = rej)); + } + return inv(ch, p); + }; + try { + input.value = "go"; + input.dispatchEvent(new Event("input", { bubbles: true })); + send.click(); + await settle(400); + c.ok(!!rid && !!failTurn, "a turn started"); + if (!rid || !failTurn) return; + // Half an answer arrives AS DELTAS, then the request rejects. It has to + // be deltas: an `assistant` event is a SETTLED step and clears + // `is-streaming` on its own, so driving this with one tests nothing. + // `onDelta` creates the streaming block synchronously — only the text + // inside it waits for an animation frame this harness never gives. + window.__gsEmit("ai:delta", { requestId: rid, delta: "Here is half an ans" }); + await settle(300); + c.eq($$(".assistant-msg.is-streaming").length, 1, "a reply is being typed"); + failTurn(new Error("the model went away")); + await settle(1200); + + c.ok(!!$(".assistant-error"), "the failure is reported"); + c.eq($$(".assistant-thinking").length, 0, "and the thinking indicator is gone"); + c.eq( + $$(".assistant-msg.is-streaming").length, + 0, + "and nothing is left looking like it is still being typed", + ); + } finally { + window.gitstudio.invoke = inv; + } + }, + + // `refreshAll` re-routes the current view with its history target, and the + // file watcher fires it on ANY save anywhere in the repository — so a build + // touching one file swapped the diff you were reading for file #1, while + // you were reading it. `SectionTarget.file` exists for exactly this. + "a-refresh-keeps-the-file-you-were-reading": async (f) => { + const c = check(f); + const rows = () => $$(".cmt-file"); + const nameOf = (r) => (text(r?.querySelector(".cmt-file-path")) || "").trim(); + c.ok(rows().length >= 3, `the commit changed several files (${rows().length})`); + if (rows().length < 3) return; + + rows()[2].click(); + await settle(1400); + const chosen = nameOf($(".cmt-file.is-current")); + c.ok(!!chosen, "a file is open"); + c.ok(chosen !== nameOf(rows()[0]), "and it is not the first one"); + + window.__gsEmit("repo:filesChanged", { gitDir: true }); + await settle(2600); + c.eq(nameOf($(".cmt-file.is-current")), chosen, "a background refresh leaves it open"); + }, + + // "scrolling super fast or instead of me is pure ragebait" — still true in + // one state. The dead band deciding "still at the bottom" is two lines + // deep and the wheel is damped to 0.45, so ONE notch on a trackpad moves + // less than that and left `follow` armed. Four seconds later the tail poll + // pulled the reader back down, with nothing to say why. + "one-notch-up-stops-the-tail": async (f) => { + const c = check(f); + const live = $$(".joblog-job").find((r) => /running/i.test(text(r))); + c.ok(!!live, "the run has a job still producing output"); + if (!live) return; + live.click(); + await settle(1600); + + const follow = $$(".log-tool").find((b) => /follow/i.test(b.title)); + const scroll = $(".log-scroll"); + c.ok(!!follow && !!scroll, "the pane has a follow control and a scroller"); + if (!follow || !scroll) return; + if (!follow.classList.contains("is-on")) { + follow.click(); + await settle(300); + } + scroll.scrollTop = scroll.scrollHeight; + await settle(200); + c.ok(follow.classList.contains("is-on"), "following, and at the tail"); + + // One small notch — deliberately smaller than the dead band. + scroll.dispatchEvent( + new WheelEvent("wheel", { deltaY: -12, deltaMode: 0, bubbles: true, cancelable: true }), + ); + await settle(400); + c.ok(!follow.classList.contains("is-on"), "one notch upward stops the tail"); + c.eq(follow.getAttribute("aria-pressed"), "false", "and says so"); + }, + + // An ANSI run that sets a BACKGROUND and no foreground. `clsOf` emits + // `log-bg-N` alone for those, so the text took the page's default ink — and + // then, once that was fixed, the palette's WHITE, which is invisible on the + // bright half of a dark-theme palette. Measured across all sixteen: black + // ink wins on fourteen of them. + "every-ansi-block-can-be-read": async (f) => { + const c = check(f); + const body = $(".log-body"); + c.ok(!!body, "the log pane is up"); + if (!body) return; + const lum = (col) => { + const p = (col.match(/[\d.]+/g) || []).slice(0, 3).map(Number).map((v) => { + v = v > 1 ? v / 255 : v; + return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + }); + return 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2]; + }; + const worst = []; + for (let i = 0; i < 16; i++) { + const sp = document.createElement("span"); + sp.className = `log-bg-${i}`; + sp.textContent = "X"; + body.appendChild(sp); + const st = getComputedStyle(sp); + const l1 = lum(st.color), l2 = lum(st.backgroundColor); + worst.push({ i, r: (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05) }); + sp.remove(); + } + const bad = worst.filter((w) => w.r < 3); + c.eq( + bad.length, + 0, + `every ANSI block's default ink is readable (worst ${Math.min(...worst.map((w) => w.r)).toFixed(2)}:1 on block ${worst.slice().sort((a, b) => a.r - b.r)[0].i})`, + ); + }, + + // "Switch account" must actually start a sign-in. + // + // It signs you out and then looks for the Sign-in button on the rebuilt + // card — but `showSettingsView` returns as soon as the card's SHELL is in + // the DOM, and everything the card shows arrives in an async body it does + // not await. So it searched a card still holding a loading spinner, found + // nothing, started nothing, and left you signed out: a quieter Sign out + // under a label promising the opposite. + "switch-account-starts-a-sign-in": async (f) => { + const c = check(f); + const sw = $$("button").find((b) => /switch account/i.test(text(b) || "")); + c.ok(!!sw, "the account card offers to switch"); + if (!sw) return; + + const inv = window.gitstudio.invoke.bind(window.gitstudio); + let connected = true; + const calls = []; + window.gitstudio.invoke = (ch, p) => { + calls.push(ch); + // SLOW, deliberately. `github:status` is a network call in the real + // app, and the race only opens while it is outstanding — the fixture + // answers synchronously, so an unstubbed check watches the card paint + // before the handler looks at it and goes green over the live defect. + // The pre-existing check for this button did exactly that. + if (ch === "github:status") + return new Promise((r) => + setTimeout( + () => r(connected ? { connected: true, login: "antonarnaudov" } : { connected: false }), + 400, + ), + ); + if (ch === "github:disconnect") { + connected = false; + return Promise.resolve({ ok: true }); + } + return inv(ch, p); + }; + try { + sw.click(); + await settle(2600); + c.ok( + calls.includes("github:deviceStart"), + "it starts a new sign-in rather than stopping at the sign-out", + ); + c.ok(!!text($(".gh-flow"))?.trim(), "and the device-flow card is on screen"); + } finally { + window.gitstudio.invoke = inv; + } + }, + + // A background rebuild is not a dismissal the user asked for. + // + // Any file saved anywhere in the open repository fires the watcher, and the + // overlay sweep that follows takes every layer down. The clone dialog and + // its destination sheet declared no `hasUnsavedWork`, so a build touching + // one file destroyed a half-typed clone URL, a repository picked from the + // list, or a clone already in flight. + "a-half-filled-dialog-survives-a-file-save": async (f) => { + const c = check(f); + const up = () => !!$(".modal-card"); + const input = $(".modal-input"); + c.ok(up() && !!input, "the clone dialog is open"); + if (!input) return; + input.value = "https://github.com/someone/a-repo.git"; + input.dispatchEvent(new Event("input", { bubbles: true })); + await settle(300); + + const heard = window.__gsEmit("repo:filesChanged", { gitDir: true }); + c.eq(heard, 1, "the app is listening for the watcher"); + await settle(2200); + + c.ok(up(), "the dialog is still there"); + c.eq( + $(".modal-input")?.value, + "https://github.com/someone/a-repo.git", + "and so is what you had typed into it", + ); + }, + + // A control NESTED inside a clickable row must keep its own Enter. + // + // Several rows bind keydown on themselves without checking `e.target`, so + // Enter anywhere inside ran the ROW's action: on a project card the kebab + // opened the issue instead of the item menu (making "Move to" unreachable + // by keyboard), and on a release asset the Delete button DOWNLOADED the + // asset — the destructive control unreachable, and a different action + // silently taken in its place. `orgs.ts` and `common.ts` already guard. + "a-nested-control-keeps-its-own-enter": async (f) => { + const c = check(f); + const outer = $(".gh-card") || $(".sec-row"); + c.ok(!!outer, "there is a clickable row"); + if (!outer) return; + const inner = outer.querySelector("button:not(:disabled)"); + c.ok(!!inner && inner !== outer, "with a control nested inside it"); + if (!inner) return; + + // A synthetic keydown produces no native click, so the row's action + // firing is the only thing observable — and the only thing at issue. + inner.focus(); + inner.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }), + ); + await settle(600); + c.ok( + !$(".gh-drawer-scrim") && !$(".det-view") && !$(".modal-card"), + "Enter on the inner control does not run the row's own action", + ); + + // …and the row itself still answers Enter, which is the half a careless + // guard would break. + outer.focus(); + outer.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }), + ); + await settle(900); + c.ok( + !!$(".gh-drawer-scrim") || !!$(".det-view") || !!$(".modal-card"), + "but Enter on the row itself still opens it", + ); + }, + + // The gate must close as well as open. The listener returned early when the + // Assistant was ungated, so it only ever OPENED: removing the last model + // left the composer live and the header still advertising a connection + // that no longer existed, and the next message went to a provider the app + // had just been told about. + "the-gate-closes-as-well-as-it-opens": async (f) => { + const c = check(f); + const gated = () => !!$(".assistant-empty .btn"); + const input = () => $(".assistant-input"); + c.eq(gated(), false, "it starts connected"); + + const inv = window.gitstudio.invoke.bind(window.gitstudio); + let enabled = true; + window.gitstudio.invoke = (ch, p) => { + if (ch === "ai:settings") + return Promise.resolve( + enabled + ? { + enabled: true, + connections: [{ id: "c1", label: "Claude", usable: true }], + defaultId: "c1", + agent: { permission: "write", thinking: "medium", modelId: "claude-opus-5" }, + } + : { enabled: false, connections: [], defaultId: null }, + ); + return inv(ch, p); + }; + try { + // The last model is removed in Settings. + enabled = false; + window.dispatchEvent(new CustomEvent("gs:ai-changed")); + await settle(1200); + c.eq(gated(), true, "removing the last model re-gates it"); + c.eq(input()?.disabled, true, "and the composer closes"); + c.eq(text($(".assistant-model")), "", "and it stops naming a connection that is gone"); + + // …and connected again. + enabled = true; + window.dispatchEvent(new CustomEvent("gs:ai-changed")); + await settle(1400); + c.eq(gated(), false, "connecting one lifts it again"); + c.eq(input()?.disabled, false, "and the composer opens"); + } finally { + window.gitstudio.invoke = inv; + } + }, + + // Stop must take the approval dialog with it. `onConfirm` opened a modal + // and awaited it forever; nothing in the cancel path closed it, so pressing + // Stop ended the turn in the main process and left "Approve destructive + // action" on screen — and its Approve button then posted an approval for a + // run that no longer existed. + "stopping-a-run-closes-what-it-was-asking": async (f) => { + const c = check(f); + const input = $(".assistant-input"); + const send = $(".assistant-send"); + c.ok(!!input && !!send, "the assistant is live"); + if (!input || !send) return; + + const inv = window.gitstudio.invoke.bind(window.gitstudio); + let rid = null; + const confirms = []; + window.gitstudio.invoke = (ch, p) => { + if (ch === "ai:chatSend") { rid = p.requestId; return new Promise(() => {}); } + if (ch === "ai:agentConfirm") { confirms.push(p); return Promise.resolve({ ok: true }); } + if (ch === "ai:cancel") return Promise.resolve({ ok: true }); + return inv(ch, p); + }; + try { + input.value = "commit it"; + input.dispatchEvent(new Event("input", { bubbles: true })); + send.click(); + await settle(600); + c.ok(!!rid, "a turn is running"); + if (!rid) return; + + window.__gsEmit("ai:confirmRequest", { + requestId: rid, + callId: "c1", + tool: "git_reset", + title: "Reset", + summary: "Move this branch to HEAD~3 (hard reset).", + mode: "destructive", + }); + await settle(700); + c.ok(!!$(".modal-ok"), "the agent's approval dialog is up"); + + $(".assistant-send")?.click(); // Stop + await settle(900); + c.ok(!$(".modal-ok"), "and Stop takes it away with the run"); + + // Nothing may be answered on behalf of a turn that is over. + $(".modal-ok")?.click(); + await settle(400); + c.eq(confirms.length, 0, `no approval is posted for a dead run (${confirms.length})`); + } finally { + window.gitstudio.invoke = inv; + } + }, + + // Folding a group in the job log rebuilds the whole window of rows, so the + // row the keypress came from is destroyed by its own handler: focus fell to + // <body> and the next Enter went nowhere. Folding one section of a build + // log by keyboard ended the keyboard's involvement with it. + "folding-a-log-group-keeps-the-keyboard": async (f) => { + const c = check(f); + const grp = $(".log-groupline"); + c.ok(!!grp, "the log has a foldable group"); + if (!grp) return; + grp.focus(); + c.eq(document.activeElement, grp, "the header can be focused"); + c.eq(grp.getAttribute("aria-expanded"), "true", "and starts open"); + + grp.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); + await settle(700); + c.eq($(".log-groupline")?.getAttribute("aria-expanded"), "false", "Enter folds it"); + c.ok(document.activeElement !== document.body, "and does not drop the keyboard"); + c.ok( + document.activeElement?.classList?.contains("log-groupline"), + "leaving it on the header, so the next Enter unfolds it", + ); + }, + + // A ✨ action fired while the agent is working must not rebuild the view. + // + // `registerAssistantTab` routed with `force: true`, which drops the + // kept-alive mount and calls renderAssistant again: the running turn's + // transcript and Stop button were destroyed, a SECOND ai:chatSend started + // against the same chat, and the first run's cancel lived on in a discarded + // closure — alive in the main process, writing into a detached node, with + // nothing on screen able to stop it. + // + // The guard written for this was gated on `wrap.isConnected`, and every ✨ + // action fires from ANOTHER view, where a keep-alive Assistant is parked + // detached — so it was unreachable in every real case and its toast had + // never once been shown. + "a-sparkle-action-does-not-destroy-a-running-turn": async (f) => { + const c = check(f); + const input = $(".assistant-input"); + const send = $(".assistant-send"); + c.ok(!!input && !!send, "the assistant is live"); + if (!input || !send) return; + + const inv = window.gitstudio.invoke.bind(window.gitstudio); + let rid = null; + let sends = 0; + window.gitstudio.invoke = (ch, p) => { + if (ch === "ai:chatSend") { + sends++; + if (sends === 1) rid = p.requestId; + return new Promise(() => {}); + } + return inv(ch, p); + }; + try { + input.value = "long running task"; + input.dispatchEvent(new Event("input", { bubbles: true })); + send.click(); + await settle(500); + window.__gsEmit("ai:agentEvent", { + requestId: rid, + kind: "assistant", + text: "Answer in progress…", + }); + await settle(300); + c.eq($$(".assistant-msg").length, 1, "the agent has answered something"); + c.eq(send.title, "Stop", "and the run is stoppable"); + + // Go somewhere else and fire a ✨ action from there. + $('[data-view="issues"]')?.click(); + await settle(900); + $("[data-num]")?.click(); + await settle(1200); + const spark = $(".ai-mini"); + c.ok(!!spark, "the issue page offers a ✨ action"); + if (!spark) return; + spark.click(); + await settle(1600); + + c.eq(sends, 1, `it starts no second turn (${sends})`); + c.ok( + $$(".toast, .toast-msg").some((t) => /still working/i.test(text(t) || "")), + "and says why it did not", + ); + c.eq($$(".assistant-msg").length, 1, "the running turn's answer survives"); + c.eq($$(".assistant-bubble").length, 1, "and so does the message that started it"); + c.eq($(".assistant-send")?.title, "Stop", "and it is still stoppable"); + } finally { + window.gitstudio.invoke = inv; + } + }, + + // A quick action during a RUN hit `runGoal`'s `if (running) return` — a + // chip that looked live and answered with silence. + "quick-actions-close-while-the-agent-works": async (f) => { + const c = check(f); + const input = $(".assistant-input"); + const send = $(".assistant-send"); + const chips = $$(".assistant-chip"); + c.ok(!!input && chips.length > 0, "a live composer and quick actions"); + if (!input || !send || !chips.length) return; + c.ok(chips.every((b) => !b.disabled), "the chips start available"); + + const inv = window.gitstudio.invoke; + window.gitstudio.invoke = (ch, p) => + ch === "ai:chatSend" ? new Promise(() => {}) : inv(ch, p); + try { + input.value = "go"; + input.dispatchEvent(new Event("input", { bubbles: true })); + send.click(); + await settle(800); + c.ok($$(".assistant-chip").every((b) => b.disabled), "and close while a turn runs"); + c.ok( + /still working/i.test($(".assistant-chip")?.title || ""), + "with a reason on them", + ); + } finally { + window.gitstudio.invoke = inv; + } + }, + + "send-needs-something-to-send": async (f) => { + const c = check(f); + const input = $(".assistant-input"); + const send = $(".assistant-send"); + c.ok(!!input && !input.disabled, "the composer is live"); + if (!input) return; + c.eq(send.disabled, true, "Send is off over an empty composer"); + input.value = "explain the failing test"; + input.dispatchEvent(new Event("input", { bubbles: true })); + await settle(200); + c.eq(send.disabled, false, "typing turns it on"); + // The composer grows with the text instead of reading it through a + // two-row slot with 180px of empty box underneath. + const grown = parseInt(input.style.height || "0", 10); + c.ok(grown > 0, `it sizes to its content (${grown}px)`); + input.value = ""; + input.dispatchEvent(new Event("input", { bubbles: true })); + await settle(200); + c.eq(send.disabled, true, "and off again when you clear it"); + }, + + "a-quick-action-keeps-your-draft": async (f) => { + const c = check(f); + const input = $(".assistant-input"); + const chip = $(".assistant-chip"); + c.ok(!!input && !!chip, "a live composer and a quick action"); + if (!input || !chip) return; + input.value = "my half-written question"; + input.dispatchEvent(new Event("input", { bubbles: true })); + await settle(150); + chip.click(); + await settle(900); + // The chip supplies its OWN goal; clearing the box threw away a message + // the user was in the middle of writing, in exchange for running + // something else. + c.eq(input.value, "my half-written question", "the draft survives the chip"); + const bubble = $(".assistant-bubble"); + c.ok(!!bubble && !/half-written/.test(text(bubble)), "and the CHIP's goal is what ran"); + }, + + // The complaint the owner made about the job log, in the other surface that + // streams: "scrolling super fast or instead of me is pure ragebait". + "a-streaming-reply-never-moves-the-reader": async (f) => { + const c = check(f); + const t = $(".assistant-transcript"); + const input = $(".assistant-input"); + const send = $(".assistant-send"); + c.ok(!!t && !!input, "the assistant is live"); + if (!t || !input) return; + + // Hold the turn open so it keeps streaming while we read back through it. + const inv = window.gitstudio.invoke; + let rid = null; + window.gitstudio.invoke = (ch, p) => { + if (ch === "ai:chatSend") { + rid = p.requestId; + return new Promise(() => {}); + } + return inv(ch, p); + }; + input.value = "go"; + input.dispatchEvent(new Event("input", { bubbles: true })); + send.click(); + await settle(500); + c.ok(!!rid, "a turn is running"); + if (!rid) return; + + // `assistant` events render synchronously. Deltas go through + // requestAnimationFrame, which this harness starves, so they never paint. + const say = (n) => + window.__gsEmit("ai:agentEvent", { + requestId: rid, + kind: "assistant", + text: `Paragraph ${n}. ${"word ".repeat(40)}`, + }); + for (let i = 0; i < 25; i++) say(i); + await settle(300); + c.ok(t.scrollHeight > t.clientHeight + 20, "the transcript overflows, so it can scroll"); + + t.scrollTop = 0; // the reader scrolls up to re-read + await settle(120); + for (let i = 25; i < 35; i++) say(i); + await settle(300); + c.eq(t.scrollTop, 0, "more output must NOT move a reader who scrolled up"); + + // And the other half of the table: a reader at the tail is still carried. + t.scrollTop = t.scrollHeight; + await settle(120); + for (let i = 35; i < 40; i++) say(i); + await settle(300); + c.ok( + t.scrollHeight - t.scrollTop - t.clientHeight <= 24, + "but a reader AT the bottom is kept there", + ); + }, + + // A queued job that the runner picks up. `setProducing` flips `notStarted`, + // which the empty-log note reads — but it never re-rendered, so the note + // kept saying "This job hasn't started yet." for as long as the job ran, + // until the first line of output happened to arrive. + // + // (Its sibling regression — `append` not re-evaluating the jump pill — has + // no check, deliberately. The pill's condition reads `scrollHeight`, which + // on this virtualized pane comes from spacer divs sized inside a render + // this harness's starved rAF never completes, so the pill reads the same on + // a fixed and a broken build. A check that cannot fail on the old + // behaviour is worse than none: it reports coverage it does not have.) + "a-queued-job-that-starts-stops-saying-it-has-not": async (f) => { + const c = check(f); + const inv = window.gitstudio.invoke; + let started = false; + window.gitstudio.invoke = (ch, p) => { + // An EMPTY log — the only state where the note stays on screen long + // enough to go stale. + if (ch === "actions:jobLogChunk") return Promise.resolve({ text: "", totalLength: 0 }); + // …and, once `started`, a runner that has picked the job up. + if (ch === "actions:runDetail" && started) { + return inv(ch, p).then((d) => ({ + ...d, + jobs: (d.jobs || []).map((j) => + /queued|waiting/i.test(j.status) ? { ...j, status: "in_progress" } : j, + ), + })); + } + return inv(ch, p); + }; + try { + const queued = $$(".joblog-job").find((r) => /queued|waiting/i.test(text(r))); + c.ok(!!queued, "the run has a queued job"); + if (!queued) return; + queued.click(); + await settle(1200); + c.ok( + /hasn't started|has not started/i.test(text($(".log-empty")) || ""), + "while queued it says it has not begun", + ); + + started = true; + await settle(6000); // the rail poll re-reads runDetail + queued.click(); // the "stuck" path: re-clicking starts the tail + await settle(1500); + const note = text($(".log-empty")) || ""; + c.ok(!!note, "an empty running log still says something"); + c.ok( + !/hasn't started|has not started/i.test(note), + `a running job stops claiming it has not begun (“${note}”)`, + ); + c.ok(/waiting for/i.test(note), "and says what it IS doing instead"); + } finally { + window.gitstudio.invoke = inv; + } + }, + + // The run page's Artifacts section, which had no fixture until now — so + // `actions:artifacts` answered undefined and this whole surface has been + // invisible to every check ever run. It turns out to be right; pinning it + // is what keeps it that way. + "an-expired-artifact-cannot-be-downloaded": async (f) => { + const c = check(f); + const rows = $$(".gh-artifact-row"); + c.ok(rows.length >= 3, `the run lists its artifacts (${rows.length})`); + if (rows.length < 3) return; + + const live = rows.find((r) => !/expired/i.test(text(r) || "")); + const dead = rows.find((r) => /expired/i.test(text(r) || "")); + c.ok(!!live && !!dead, "one live artifact and one expired one"); + if (!live || !dead) return; + + const btn = (r) => r.querySelector("button"); + c.eq(btn(live)?.disabled, false, "a live artifact can be downloaded"); + // GitHub deletes the blob when an artifact expires; the button would 410. + c.eq(btn(dead)?.disabled, true, "an expired one cannot"); + c.ok(/expired/i.test(btn(dead)?.title || ""), "and says why, rather than just greying out"); + // Sizes are for humans — the raw byte count is not a size. + c.ok(/\d+(\.\d+)?\s?(KB|MB|GB)/.test(text(live) || ""), "sizes are formatted"); + }, + + // NOTHING may push the app sideways at the window's own minimum width. + // + // `minWidth: 880` is what the main process allows the window to become, so + // every surface has to survive it. The PR detail header's action cluster — + // Checkout, Approve, Review, Merge, ⋯, Open on GitHub — could not shrink + // below its labels and had no wrap, so it overflowed the body and the + // topbar slid off to reveal it. + "no-surface-scrolls-the-app-sideways": async (f) => { + const c = check(f); + const win = window.innerWidth; + // The BODY's scroll width: `html` is clipped, so measuring the document + // element alone reports the window's width whatever is overflowing. + c.ok( + document.body.scrollWidth <= win + 2, + `the page fits its window (body ${document.body.scrollWidth} vs ${win})`, + ); + const actions = $(".det-tb-actions"); + if (actions) { + c.ok( + Math.round(actions.getBoundingClientRect().right) <= win + 2, + `the header's actions stay inside it (right ${Math.round(actions.getBoundingClientRect().right)})`, + ); + } + // Nothing may be off the left edge either — that is what a slid topbar + // looks like once the overflow has been scrolled to. + const bar = $(".topbar"); + if (bar) c.ok(Math.round(bar.getBoundingClientRect().left) >= -2, "the topbar has not slid"); + + // …and the rows THEMSELVES, which the body measure above cannot see. + // `.sec-row` is `overflow: visible` inside a clipping ancestor, so a row + // whose content does not fit does not scroll — it renders outside the + // window and is unreachable by pointer and keyboard alike, while + // `document.body.scrollWidth` goes on reporting a page that fits. + // Measured on a branch list at 880: content 717px in a 648px row, with + // the row's actions ending at x=941. + const spilled = $$(".sec-row").filter((r) => r.scrollWidth > r.clientWidth + 2); + c.eq( + spilled.length, + 0, + `no row overflows its own width (${spilled.length} of ${$$(".sec-row").length})`, + ); + const past = $$(".sec-row-actions").filter( + (a) => Math.round(a.getBoundingClientRect().right) > win + 2, + ); + c.eq(past.length, 0, `no row's actions render outside the window (${past.length})`); + }, + + // Every tick in the one-list staging model said "Not included" or "Included + // in the commit" — so several shared one name. Useless to a screen reader + // ("not included" — WHAT isn't?), and actively harmful to the focus rescue, + // which matches on `title`: ticking the fourth file moved the keyboard to + // the first file with the same state. + "a-tick-is-named-for-its-file": async (f) => { + const c = check(f); + const ticks = () => $$(".dc-ck:not(.dc-ck-master)"); + c.ok(ticks().length >= 4, `the list has several files (${ticks().length})`); + if (ticks().length < 4) return; + + const titles = ticks().map((t) => t.title || ""); + c.eq(new Set(titles).size, titles.length, "every tick has its own name"); + c.ok(titles.every((t) => /[./]/.test(t)), "each naming a file"); + + const target = ticks()[3]; + target.focus(); + target.click(); + await settle(1400); + c.eq( + ticks().indexOf(document.activeElement), + 3, + "and ticking one leaves the keyboard on THAT file, not another", + ); + }, + + // "Create pull request" never came back once base and compare had been the + // same ref. The swr answer ("GitHub could take a PR") was written straight + // to `prBtn.hidden`, and the base===head path then hid the button on its + // own — but nothing ever un-hid it: the swr callback had already delivered + // its cached answer and never fires again. Picking your own current branch + // as the base ONCE removed the view's whole purpose for the session. + "create-pull-request-comes-back": async (f) => { + const c = check(f); + const pr = () => $(".cmp-pr-btn"); + const picks = () => $$(".compare-bar .ref-pick"); + c.ok(!!pr() && picks().length >= 2, "the compare bar and its PR button are there"); + if (!pr() || picks().length < 2) return; + c.eq(pr().hidden, false, "it starts available"); + + const headLabel = (text(picks()[1]) || "").trim(); + const pickBase = async (want) => { + picks()[0].click(); + await settle(600); + const item = $$(".dropdown-item").find((b) => + want === "same" ? (text(b) || "").trim() === headLabel : (text(b) || "").trim() !== headLabel, + ); + item?.click(); + await settle(1600); + }; + + await pickBase("same"); + c.eq(pr().hidden, true, "and hides when base and compare are the same ref"); + c.ok(/both/i.test(text($(".cmp-body")) || ""), "with the body saying why"); + + await pickBase("different"); + c.eq(pr().hidden, false, "…and comes back when they differ again"); + }, + + // A plan that keeps NOTHING is not a rebase. Dropping every commit and + // pressing Start erases the whole range and then offers to force-push it — + // `git reset --hard` wearing a rebase's clothes. The preview said + // "5 → 0 commits" while the button beside it stayed lit, and the force-push + // confirm downstream talks about rewriting history, not deleting all of it. + "a-plan-that-keeps-nothing-cannot-be-started": async (f) => { + const c = check(f); + const start = () => + $$(".rb-foot button").find((b) => /start rebase/i.test(text(b) || "")); + c.ok(!!start(), "the footer offers to start the rebase"); + if (!start()) return; + c.eq(start().disabled, false, "an ordinary plan can be started"); + + for (const sel of $$(".rb-action")) { + sel.value = "drop"; + sel.dispatchEvent(new Event("change", { bubbles: true })); + } + await settle(700); + c.ok(/→ 0 commit/.test(text($(".rb-preview")) || ""), "the preview says nothing survives"); + c.eq(start().disabled, true, "and Start is closed"); + c.ok( + /keeps no commits/i.test(start().title || ""), + "with a reason that names what the plan would do", + ); + c.ok(/reset/i.test(start().title || ""), "and points at the tool that means it"); + }, + + // Where a dragged commit LANDS must be where the line said it would. + // + // The indicator was a fixed line under the hovered row and the insert was + // always `move(from, i)`. Those agree only when you drag DOWN: dragging up, + // `splice(i, 0, …)` puts the commit ABOVE the row while the line underneath + // promised below. Every upward drag landed one row off from where the app + // said — on the view whose entire job is to say where commits will land. + // It also made position 0 unreachable with a pointer. + "a-dragged-commit-lands-where-the-line-says": async (f) => { + const c = check(f); + const subj = () => + $$(".rb-row:not(.rb-base) .rb-subj").map((n) => (text(n) || "").slice(0, 18)); + const rowsOf = () => $$(".rb-row:not(.rb-base)"); + c.ok(rowsOf().length >= 4, "the plan has enough commits to reorder"); + if (rowsOf().length < 4) return; + + // A real DataTransfer — a plain object is rejected by the DragEvent ctor. + const drag = (fromIdx, ontoIdx, where) => { + const rows = rowsOf(); + const dt = new DataTransfer(); + const fire = (type, el, y) => { + const e = new DragEvent(type, { bubbles: true, cancelable: true, clientY: y }); + Object.defineProperty(e, "dataTransfer", { value: dt }); + el.dispatchEvent(e); + }; + const r = rows[ontoIdx].getBoundingClientRect(); + const y = r.top + r.height * (where === "before" ? 0.25 : 0.75); + fire("dragstart", rows[fromIdx], 0); + fire("dragover", rows[ontoIdx], y); + const painted = rows[ontoIdx].classList.contains( + where === "before" ? "drag-over-top" : "drag-over", + ); + fire("drop", rows[ontoIdx], y); + return painted; + }; + + // UP, onto the top half of row 1 → lands AT 1. + let before = subj(); + let moved = before[3]; + c.ok(drag(3, 1, "before"), "the line is drawn above the row"); + await settle(500); + c.eq(subj().indexOf(moved), 1, "dragging up onto the top half lands above that row"); + + // UP, onto the bottom half of row 1 → lands AT 2. + before = subj(); + moved = before[3]; + c.ok(drag(3, 1, "after"), "the line is drawn below the row"); + await settle(500); + c.eq(subj().indexOf(moved), 2, "dragging up onto the bottom half lands below it"); + + // DOWN, onto the bottom half of row 3 → lands AT 3. + before = subj(); + moved = before[0]; + drag(0, 3, "after"); + await settle(500); + c.eq(subj().indexOf(moved), 3, "dragging down onto the bottom half lands below it"); + + // And the position a fixed bottom-line could never reach: the very top. + before = subj(); + moved = before[2]; + drag(2, 0, "before"); + await settle(500); + c.eq(subj().indexOf(moved), 0, "the first position is reachable by pointer"); + }, + + // The forward-truncate ran BEFORE the "is this the same place" check, so + // every route that reached it discarded the forward entries — including the + // one `refreshAll` performs, which the file watcher fires on every save. + // Forward died seconds after going Back, constantly, for no visible reason. + "a-background-refresh-does-not-kill-forward": async (f) => { + const c = check(f); + const navs = () => $$(".topbar-nav"); + const [back, fwd] = navs(); + c.ok(!!back && !!fwd, "the top bar has back and forward"); + if (!back || !fwd) return; + + $('[data-view="branches"]')?.click(); + await settle(900); + $('[data-view="issues"]')?.click(); + await settle(900); + back.click(); + await settle(1000); + c.eq(fwd.disabled, false, "going back arms Forward"); + + // A file is saved somewhere — the watcher fires and the app refreshes. + const heard = window.__gsEmit("repo:filesChanged", { gitDir: true }); + c.eq(heard, 1, "the app is listening for the watcher"); + await settle(2400); + c.eq(navs()[1].disabled, false, "and a refresh must not take Forward away"); + }, + + // Keep-alive views park their DOM so returning to one restores what you + // had — "the rendered DOM (scroll, expanded state)", per the comment that + // has said so since it was written. Detaching a node zeroes every + // scrollTop inside it, so the one thing named first was the one thing that + // did not survive: every return landed at the top of a long list. + "a-kept-view-comes-back-where-you-left-it": async (f) => { + const c = check(f); + const pick = () => + $$("*").find((n) => n.scrollHeight > n.clientHeight + 200 && n.clientHeight > 150); + const sc = pick(); + c.ok(!!sc, "the list is long enough to scroll"); + if (!sc) return; + sc.scrollTop = 600; + sc.dispatchEvent(new Event("scroll")); + await settle(300); + c.eq(Math.round(sc.scrollTop), 600, "and it scrolled"); + + $('[data-view="changes"]')?.click(); + await settle(900); + $('[data-view="issues"]')?.click(); + await settle(1400); + + const back = pick(); + c.ok(back === sc, "the parked DOM was re-attached, not rebuilt"); + c.eq(Math.round(back?.scrollTop ?? -1), 600, "and it comes back where you left it"); + }, + + // The job log has one route for a whole RUN and a rail of jobs inside it. + // The history entry kept whichever job the page was entered with, and + // refreshAll re-routes to that entry — so any refresh silently swapped the + // reader onto a different job's output, mid-read. + "a-refresh-keeps-you-on-the-job-you-were-reading": async (f) => { + const c = check(f); + const rows = $$(".joblog-job"); + c.ok(rows.length > 1, "the run has more than one job"); + if (rows.length < 2) return; + + const other = rows.find((r) => !r.classList.contains("is-current")); + c.ok(!!other, "and one of them is not the one that opened"); + if (!other) return; + const wanted = text(other.querySelector(".joblog-job-name")) || ""; + other.click(); + await settle(1400); + c.ok( + $(".joblog-job.is-current") === other, + `the rail moved to “${wanted}”`, + ); + + // Something touches the disk — the file watcher fires refreshAll. + window.__gsEmit("repo:filesChanged", { gitDir: true }); + await settle(2600); + + const nowOn = text($(".joblog-job.is-current .joblog-job-name")) || ""; + c.eq(nowOn, wanted, "and a refresh leaves you on it"); + c.ok( + (text($(".det-crumb")) || "").includes(wanted), + "with the crumb still naming it", + ); + }, + + // "Stage lines" and the whitespace toggle were enabled by the click that + // SELECTED the row, before the diff had even been asked for. Over a binary, + // a conflict, a truncated file or a failed read they stayed lit above a + // pane with no editor in it, and answered a press with "select some lines + // first" — advice that cannot be followed about a control that could never + // work on that file. + "line-controls-need-a-line-editor": async (f) => { + const c = check(f); + const row = $(".dc-file"); + c.ok(!!row, "there is a file to open"); + if (!row) return; + const stage = $$("button").find((b) => /stage lines|unstage lines/i.test(text(b) || "")); + c.ok(!!stage, "the toolbar has a line-staging control"); + if (!stage) return; + + const inv = window.gitstudio.invoke; + try { + // A real text diff: the control applies. + row.click(); + await settle(1200); + c.eq(stage.disabled, false, "over a real diff it is available"); + + // A binary: there is no line editor at all. + window.gitstudio.invoke = (ch, p) => + ch === "file:diff" + ? Promise.resolve({ + path: "logo.png", + leftLabel: "HEAD", + rightLabel: "Working Tree", + leftText: "", + rightText: "", + conflicted: false, + binary: true, + }) + : inv(ch, p); + row.click(); + await settle(1200); + c.ok(!!$(".diff-empty"), "the pane says why it cannot draw one"); + c.eq(stage.disabled, true, "and the line control is closed, not lit over nothing"); + c.ok(/no line-by-line/i.test(stage.title || ""), "with a reason on it"); + } finally { + window.gitstudio.invoke = inv; + } + }, + + // Opening the dock takes the keyboard into it; closing it used to drop the + // keyboard on the floor. Focus stayed on the xterm textarea inside the now + // hidden panel, so the view behind had no keyboard at all until you reached + // for the mouse. + "the-dock-hands-the-keyboard-back": async (f) => { + const c = check(f); + const row = $(".dc-file"); + c.ok(!!row, "there is something to be focused on"); + if (!row) return; + row.focus(); + c.eq(document.activeElement, row, "the keyboard starts on the list"); + + const chev = $(".dock-chevron"); + c.ok(!!chev, "the dock can be opened"); + if (!chev) return; + chev.click(); + await settle(900); + c.ok(document.activeElement !== row, "opening the dock takes the keyboard"); + + chev.click(); + await settle(700); + c.eq(document.activeElement, row, "and closing it hands the keyboard back"); + }, + + // …and when there is nothing to hand it back TO. The dock is often opened + // from inside itself — the chevron, the tab strip — so nothing outside was + // remembered, and closing it while the keyboard was in the terminal left + // focus on <body>: the next Tab starts from the top of the window and no + // shortcut bound to a view can fire. + "closing-the-dock-from-inside-it-still-lands-somewhere": async (f) => { + const c = check(f); + const chev = $(".dock-chevron"); + c.ok(!!chev, "the dock can be opened"); + if (!chev) return; + chev.click(); // opened from INSIDE the dock — nothing outside remembered + await settle(800); + const tab = $$("button").find((b) => /^Terminal$/i.test((text(b) || "").trim())); + tab?.click(); + await settle(1000); + + const ta = $(".xterm-helper-textarea"); + c.ok(!!ta, "the terminal has its input"); + if (!ta) return; + ta.focus(); + c.eq(document.activeElement, ta, "the keyboard is in the terminal"); + + chev.click(); + await settle(900); + const now = document.activeElement; + c.ok(now !== document.body, "closing it does not drop the keyboard on <body>"); + c.ok(now !== ta, "nor leave it in the terminal that just went away"); + c.ok( + now?.classList?.contains("view-host") || $(".view-host")?.contains(now), + `it lands in the view behind (got ${now?.className || now?.tagName})`, + ); + }, + + // An open dock OVERLAYS the view area and publishes its height as + // `--dock-reserve`. Two surfaces ignored it: the Rebase footer, which is + // `position: sticky; bottom: 0`, and the Assistant's composer. The Start + // button, the rebase preview, the composer and every quick action sat + // behind an open terminal, with no scroll that reached them and no dock + // size that revealed them. + "an-open-dock-does-not-bury-a-footer": async (f) => { + const c = check(f); + const chev = $(".dock-chevron"); + c.ok(!!chev, "the dock can be opened"); + if (!chev) return; + chev.click(); + await settle(900); + + // Published on the dock's HOST, which is the main stack — not on :root. + const host = $(".main-stack"); + c.ok(!!host, "the dock's host is present"); + if (!host) return; + const reserve = + parseFloat(getComputedStyle(host).getPropertyValue("--dock-reserve")) || 0; + c.ok(reserve > 40, `the dock reserves real space (${reserve}px)`); + + const foot = $(".rb-foot"); + c.ok(!!foot, "the Rebase view has its footer"); + if (!foot) return; + const r = foot.getBoundingClientRect(); + const body = $(".dock-body"); + const dockTop = body ? body.getBoundingClientRect().top : window.innerHeight; + // The footer's own bottom edge must clear the dock. It used to sit under + // it by exactly the dock's height. + c.ok( + r.bottom <= dockTop + 2, + `the footer clears the dock (footer bottom ${Math.round(r.bottom)}, dock top ${Math.round(dockTop)})`, + ); + }, + + // Not every conflict is a content conflict. A binary one, and a + // modify/delete one, both opened the three-pane text merge — over decoded + // bytes in the first case, and over one deliberately blank pane that never + // said the word "deleted" in the second. + "a-conflict-with-no-text-is-not-offered-a-text-merge": async (f) => { + const c = check(f); + const row = $(".dc-file"); + c.ok(!!row, "there is a file to open"); + if (!row) return; + + const MODEL = { + path: "logo.png", + hasBase: true, + base: "", + ours: "", + theirs: "", + result: "", + oursLabel: "Current change (your branch)", + theirsLabel: "Incoming change", + }; + const CELLS = [ + { name: "binary", model: { ...MODEL, binary: true }, want: /binary/i }, + { + name: "modify/delete", + model: { ...MODEL, path: "notes.md", ours: "kept\n", missingSide: "theirs" }, + want: /deleted/i, + }, + { + name: "ordinary content", + model: { ...MODEL, path: "a.ts", base: "b\n", ours: "o\n", theirs: "t\n" }, + want: null, + }, + ]; + + const inv = window.gitstudio.invoke; + try { + for (const cell of CELLS) { + window.gitstudio.invoke = (ch, p) => { + if (ch === "file:diff") + return Promise.resolve({ + path: cell.model.path, + leftLabel: "HEAD", + rightLabel: "Working Tree", + leftText: "x", + rightText: "y", + conflicted: true, + }); + if (ch === "conflict:model") return Promise.resolve(cell.model); + return inv(ch, p); + }; + row.click(); + await settle(900); + + const btns = $$(".merge-bar-actions .mini-btn"); + const sides = btns.map((b) => text(b) || ""); + c.ok(btns.length >= 2, `${cell.name}: both side buttons are offered`); + if (cell.name === "modify/delete") { + // Taking the side that has no file DELETES it. That button was + // labelled "Take <side>" with the tooltip "Replace the file with + // …" — the wrong verb for the only irreversible thing on this bar — + // and it carried `is-danger`, which was styled for menu items only, + // so it was pixel-identical to the button beside it that KEEPS the + // file. + const del = btns.find((b) => /delete the file/i.test(text(b) || "")); + c.ok(!!del, "the deleting side says it deletes"); + const keep = btns.find((b) => b !== del && /take /i.test(text(b) || "")); + if (del && keep) { + c.ok( + getComputedStyle(del).color !== getComputedStyle(keep).color, + "and does not look identical to the one that keeps it", + ); + } + } + if (cell.want) { + const note = $(".merge-notext"); + c.ok(!!note, `${cell.name}: an explanation instead of a merge editor`); + c.ok(cell.want.test(text(note) || ""), `${cell.name}: which names what happened`); + // "Mark resolved" saves the RESULT PANE, and there is no result + // pane here — leaving it would be a button with nothing behind it. + c.ok(!$(".merge-resolve"), `${cell.name}: no "Mark resolved" over nothing to save`); + } else { + c.ok(!$(".merge-notext"), `${cell.name}: still gets the real merge editor`); + c.ok(!!$(".merge-resolve"), `${cell.name}: and can still be marked resolved`); + } + } + } finally { + window.gitstudio.invoke = inv; + } + }, + + // "Errors only" is a CSS filter over the rows. With nothing failed it hid + // every one of them and left a blank panel beside a count still reading + // "4 commands" — and Clear hid the toggle while leaving it switched ON, so + // everything logged afterwards was filtered away by a control the reader + // could no longer see. + "the-output-filter-owns-its-consequences": async (f) => { + const c = check(f); + $(".dock-chevron")?.click(); + await settle(600); + const tab = $$("button").find((b) => /^Output$/i.test((text(b) || "").trim())); + c.ok(!!tab, "the dock has an Output tab"); + if (!tab) return; + tab.click(); + await settle(700); + + // In the shape `GitLogEntry` actually has. This check first emitted + // invented field names (`code`, `ms`, no `actionId`) and counted four + // rows — and that reading was an artifact of the wrong fixture. With the + // real shape, four commands sharing an `actionId` are ONE action and + // collapse into one group, which is what the panel is for. + const log = (i, over = {}) => + window.__gsEmit("git:log", { + id: 100 + i, + args: ["status", "--porcelain"], + command: "git status --porcelain", + durationMs: 12, + exitCode: 0, + failed: false, + at: Date.now(), + ...over, + }); + + // Four IDENTICAL commands under one action coalesce into a single row + // with a ×N badge. That is what keeps the status poller from flooding + // this pane, and it is why counting rows is not counting commands. + for (let i = 0; i < 4; i++) log(i, { action: "Refresh", actionId: 7 }); + await settle(600); + c.eq($$(".outputs-row").length, 1, "an identical repeat coalesces"); + c.eq(text($(".outputs-rep")), "×4", "and says how many times it ran"); + + // Four DIFFERENT commands do not. + for (let i = 10; i < 14; i++) { + log(i, { action: `Thing ${i}`, actionId: 20 + i, args: ["rev-parse", `HEAD~${i}`], command: `git rev-parse HEAD~${i}` }); + } + await settle(600); + c.ok( + $$(".outputs-row").length >= 5, + `distinct commands each get a row (${$$(".outputs-row").length})`, + ); + + const fail = $(".outputs-failbtn"); + const wrap = $(".outputs-wrap"); + c.ok(!!fail && !!wrap, "there is an Errors-only toggle"); + if (!fail || !wrap) return; + fail.click(); + await settle(400); + c.eq( + $$(".outputs-row").filter((r) => r.offsetParent !== null).length, + 0, + "with nothing failed, the filter hides every row", + ); + c.eq( + $$(".outputs-empty").filter((e) => !e.hidden).length, + 1, + "so it says a filter emptied the list, rather than showing a blank panel", + ); + + // Clear, with the filter still on. + const clear = $$(".outputs-bar button").find((b) => /clear/i.test(text(b) || "")); + c.ok(!!clear, "and a Clear"); + if (!clear) return; + clear.click(); + await settle(400); + c.ok(fail.hidden, "Clear hides the controls, there being nothing to filter"); + c.ok( + !wrap.classList.contains("failures-only"), + "and turns the filter OFF rather than hiding it switched on", + ); + c.eq(fail.getAttribute("aria-pressed"), "false", "the button agrees"); + }, + + // A shell that has exited wrote one line of text and changed nothing else: + // the tab kept its live label, the cursor kept blinking, and `onData` kept + // posting every keystroke to a PTY that was gone — silently eaten, with no + // error and no way to tell a dead terminal from a working one. + "a-dead-shell-says-it-is-dead": async (f) => { + const c = check(f); + $(".dock-chevron")?.click(); + await settle(600); + const tab = $$("button").find((b) => /^Terminal$/i.test((text(b) || "").trim())); + c.ok(!!tab, "the dock has a Terminal tab"); + if (!tab) return; + tab.click(); + await settle(1400); + + const created = (window.__GS_INVOKED || []).filter((r) => r.channel === "terminal:create"); + c.ok(created.length > 0, "a shell was opened"); + const row = $(".term-side-row"); + c.ok(!!row, "and it has a row in the side list"); + if (!row) return; + c.ok(!row.classList.contains("is-exited"), "which does not start out dead"); + + const heard = window.__gsEmit("terminal:exit", { id: "pty-1", exitCode: 0 }); + c.eq(heard, 1, "the panel is listening for its shell to exit"); + await settle(800); + + c.ok($(".term-side-row")?.classList.contains("is-exited"), "the row marks itself exited"); + c.ok(!!$(".term-side-dead"), "and says so in words, not only by opacity"); + c.ok( + /exited/i.test($(".term-side-row")?.title || ""), + "the tooltip agrees with the row", + ); + + // READABLE while it recedes. This receded with a blanket `opacity: 0.62`, + // which multiplies with whatever each child already uses — so the badge, + // already at --app-muted, took both and measured 2.41:1 in light. The + // row's NAME is the only thing that says which shell died. + const lum = (c2) => { + const p = (c2.match(/\d+/g) || []).map(Number).map((v) => { + v /= 255; + return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + }); + return 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2]; + }; + const ratio = (a, b) => { + const l1 = lum(a), l2 = lum(b); + return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); + }; + // `css` is a PROBE helper and does not exist in a check — the third time + // this has caught me. `getComputedStyle` is the one that works here. + /** The nearest ancestor that actually PAINTS a ground. `document.body`'s + * is transparent in this app, and measuring against it makes every + * contrast come out as 1.00:1 — a check that fails on everything is as + * useless as one that passes on everything. */ + const groundOf = (el2) => { + for (let n = el2; n; n = n.parentElement) { + const bg = getComputedStyle(n).backgroundColor; + const p = (bg.match(/[\d.]+/g) || []).map(Number); + if (p.length < 4 || p[3] > 0.9) return bg; + } + return "rgb(255,255,255)"; + }; + const groundEl = $(".term-side") || $(".term-side-row") || document.body; + const ground = groundOf(groundEl); + /** The colour actually on screen, opacity folded in. + * + * `getComputedStyle(el).color` does NOT account for an ancestor's + * `opacity` — that is a paint-time composite — so measuring the colour + * alone reports the same ratio for a row at `opacity: 1` and the same + * row at `0.62`, and a check built on it passes on both. Walk up + * multiplying, then blend toward the ground by what is left. */ + const painted = (el2, groundEl) => { + // Only the opacities BETWEEN the text and the surface it sits on. Going + // further up folds in things that fade the whole panel — and in this + // harness `.dock-body` sits at `opacity: 0` behind a transition that + // never completes, which drove every measurement to 1.00:1. + let a = 1; + for (let n = el2; n && n !== groundEl; n = n.parentElement) { + a *= parseFloat(getComputedStyle(n).opacity || "1"); + } + const fg = (getComputedStyle(el2).color.match(/\d+/g) || []).map(Number); + const bg = (ground.match(/\d+/g) || []).map(Number); + return `rgb(${fg.map((v, i) => Math.round(bg[i] + (v - bg[i]) * a)).join(",")})`; + }; + const label = $(".term-side-row.is-exited .term-side-label"); + const badge = $(".term-side-dead"); + if (label) { + const r = ratio(painted(label, groundEl), ground); + c.ok(r >= 4.5, `the dead shell's NAME stays readable (${r.toFixed(2)}:1)`); + } + if (badge) { + const r = ratio(painted(badge, groundEl), ground); + c.ok(r >= 4.5, `and so does the "exited" badge (${r.toFixed(2)}:1)`); + } + }, + + // The agent's OWN work destroying the record of it. Approving a commit fires + // the file watcher, whose refreshAll() re-routed the view the agent was + // streaming into — transcript, tool steps and Stop button all gone, while + // the run carried on in the main process with nothing on screen to stop it. + "the-agents-own-commit-does-not-erase-the-chat": async (f) => { + const c = check(f); + const t = $(".assistant-transcript"); + const input = $(".assistant-input"); + const send = $(".assistant-send"); + c.ok(!!t && !!input && !!send, "the assistant is live"); + if (!t || !input || !send) return; + + const inv = window.gitstudio.invoke; + let rid = null; + window.gitstudio.invoke = (ch, p) => { + if (ch === "ai:chatSend") { + rid = p.requestId; + return new Promise(() => {}); // the turn is still running + } + return inv(ch, p); + }; + try { + input.value = "commit my work"; + input.dispatchEvent(new Event("input", { bubbles: true })); + send.click(); + await settle(600); + c.ok(!!rid, "a turn is running"); + if (!rid) return; + window.__gsEmit("ai:agentEvent", { + requestId: rid, + kind: "assistant", + text: "Committing now.", + }); + await settle(300); + c.eq($$(".assistant-msg").length, 1, "the agent has said something"); + c.ok(send.classList.contains("is-cancel"), "and there is a Stop button"); + + // The commit lands: the watcher fires, and refreshAll() runs. + const heard = window.__gsEmit("repo:filesChanged", { gitDir: true }); + c.eq(heard, 1, "the app is listening for the watcher"); + await settle(2500); + + c.eq($$(".assistant-bubble").length, 1, "your message survives"); + c.eq($$(".assistant-msg").length, 1, "the answer survives"); + c.ok( + $(".assistant-send")?.classList.contains("is-cancel"), + "and the run is still stoppable", + ); + c.ok($(".assistant-transcript") === t, "the transcript was not rebuilt under it"); + } finally { + window.gitstudio.invoke = inv; + } + }, + + // Declining an action is a DECISION, not a failure. The agent emits + // `tool_denied` and then a `tool_result` carrying the sentence it feeds + // back to the MODEL — "The user declined to run this action. Do not retry + // it" — and that was rendered like any other failure: a red step whose body + // instructed the person who had just made the decision not to retry it. + "a-declined-action-is-not-an-error": async (f) => { + const c = check(f); + const input = $(".assistant-input"); + const send = $(".assistant-send"); + c.ok(!!input && !!send, "the assistant is live"); + if (!input || !send) return; + + const inv = window.gitstudio.invoke; + let rid = null; + window.gitstudio.invoke = (ch, p) => { + if (ch === "ai:chatSend") { + rid = p.requestId; + return new Promise(() => {}); + } + return inv(ch, p); + }; + try { + input.value = "commit this"; + input.dispatchEvent(new Event("input", { bubbles: true })); + send.click(); + await settle(600); + c.ok(!!rid, "a turn is running"); + if (!rid) return; + + // One tool that is DENIED, and one that genuinely FAILS — the two must + // not look alike, which is the whole finding. + const emit = (e) => window.__gsEmit("ai:agentEvent", { requestId: rid, ...e }); + emit({ kind: "tool_call", tool: "git_commit", callId: "c1", args: { message: "wip" } }); + emit({ kind: "tool_call", tool: "git_stage", callId: "c2", args: { paths: ["a.ts"] } }); + await settle(300); + emit({ kind: "tool_denied", callId: "c1", tool: "git_commit" }); + emit({ + kind: "tool_result", + callId: "c1", + tool: "git_commit", + isError: true, + text: "The user declined to run this action. Do not retry it; adapt or stop and explain.", + }); + emit({ kind: "tool_result", callId: "c2", tool: "git_stage", isError: true, text: "fatal: pathspec did not match" }); + await settle(500); + + const denied = $('.assistant-tool[data-call="c1"]'); + const failed = $('.assistant-tool[data-call="c2"]'); + c.ok(!!denied && !!failed, "both steps rendered"); + if (!denied || !failed) return; + + c.ok(denied.classList.contains("is-denied"), "the declined step is marked declined"); + c.ok(!denied.classList.contains("is-error"), "and NOT marked as an error"); + c.ok(/declined/i.test(text(denied) || ""), "it says so in a word the reader owns"); + // The decisive one: the model-facing instruction must not be on screen. + c.ok( + !/do not retry/i.test(text(denied) || ""), + "and the sentence meant for the MODEL is not shown to the person", + ); + // The genuine failure still reads as one. + c.ok(failed.classList.contains("is-error"), "a real failure is still an error"); + } finally { + window.gitstudio.invoke = inv; + } + }, + + // `running` is the only thing stopping a second turn, and `runGoal` awaits + // the connection gate before it does anything else. With the flag set after + // that await, two quick presses both read `running === false`, both + // suspended, and both started a turn into the same chat. + "two-fast-sends-start-one-turn": async (f) => { + const c = check(f); + const input = $(".assistant-input"); + const send = $(".assistant-send"); + c.ok(!!input && !!send, "the composer is live"); + if (!input || !send) return; + + const inv = window.gitstudio.invoke; + let sends = 0; + window.gitstudio.invoke = (ch, p) => { + if (ch === "ai:chatSend") { + sends++; + return new Promise(() => {}); // hold the turn open + } + return inv(ch, p); + }; + input.value = "go"; + input.dispatchEvent(new Event("input", { bubbles: true })); + // Synchronously, with no await between them — that is the whole point. + send.click(); + send.click(); + send.click(); + await settle(900); + c.eq(sends, 1, `three fast clicks start ONE turn, not ${sends}`); + c.eq($$(".assistant-bubble").length, 1, "and post one message, not three"); + window.gitstudio.invoke = inv; + }, + + // A STATE TABLE over what the diff panel does with a file it cannot draw + // line by line. Every cell must SAY which of the several different nothings + // it is showing — the whole point of the panel's `showEmpty(kind)` — and no + // cell may mount an editor over two empty strings, which is the shape of + // every "sometimes the diff doesn't show" report this project has had. + "every-undrawable-diff-says-which-nothing-it-is": async (f) => { + const c = check(f); + const surface = $(".dc-diff") || $(".diff-surface") || $(".cmp-diff"); + c.ok(!!surface, "the Changes view has a diff surface"); + if (!surface) return; + + const row = $(".dc-file"); + c.ok(!!row, "there is a file to open"); + if (!row) return; + + const base = { + path: "src/thing.ts", + leftLabel: "HEAD", + rightLabel: "Working Tree", + leftText: "", + rightText: "", + conflicted: false, + }; + const CELLS = [ + { + name: "binary", + diff: { ...base, path: "logo.png", binary: true }, + want: /binary/i, + }, + { + name: "too large, nothing came back", + diff: { ...base, truncated: true }, + want: /too large/i, + }, + { + name: "empty on both sides", + diff: { ...base }, + want: /empty/i, + }, + { + name: "too large, the readable part matches", + diff: { ...base, leftText: "same\n", rightText: "same\n", truncated: true }, + want: /too large/i, + }, + { + name: "identical sides (a rename)", + diff: { ...base, leftText: "same\n", rightText: "same\n" }, + want: /renamed|file mode/i, + }, + ]; + + const inv = window.gitstudio.invoke; + for (const cell of CELLS) { + window.gitstudio.invoke = (ch, p) => + ch === "file:diff" ? Promise.resolve(cell.diff) : inv(ch, p); + row.click(); + await settle(700); + const note = $(".diff-empty", surface); + c.ok(!!note, `${cell.name}: says something rather than drawing nothing`); + if (!note) continue; + const said = text(note) || ""; + c.ok(cell.want.test(said), `${cell.name}: names what it is (“${said.slice(0, 70)}”)`); + // The decisive one: no editor may be mounted over two empty strings. + c.ok(!$(".monaco-editor", surface), `${cell.name}: no editor over nothing`); + } + window.gitstudio.invoke = inv; + }, + }; +})(); diff --git a/apps/desktop/harness/gen.sh b/apps/desktop/harness/gen.sh new file mode 100755 index 0000000..3cc06c0 --- /dev/null +++ b/apps/desktop/harness/gen.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Assemble the headless-render harness page from the built renderer bundles + +# the fixtures shim. Run `npm run build` (or `node esbuild.js`) first. +# +# gen.sh [outDir] +# +# The optional outDir lets a second page exist alongside the default one, so a +# verification run can use a freshly built bundle while something else is still +# reading the old one. shot.sh and probe.mjs both honour $GS_HARNESS_PAGE. +set -e +HARNESS="$(cd "$(dirname "$0")" && pwd)" +# BUILD FIRST. This script only COPIES the bundle, and forgetting the build +# before it is the single most expensive mistake in this harness: a check runs +# against the previous bundle, and reports a fix as not working (or, worse, a +# reverted fix as still working, which is a negative test that lies). Set +# GS_NO_BUILD=1 to skip it when you have just built by hand. +if [ -z "$GS_NO_BUILD" ]; then + (cd "$HARNESS/.." && node esbuild.js >/dev/null) +fi +DIST="$(cd "$HARNESS/../dist/renderer" && pwd)" +PAGE="${1:-$HARNESS/page}" +rm -rf "$PAGE" +mkdir -p "$PAGE" +cp "$DIST/renderer.js" "$DIST/renderer.css" "$DIST/theme-boot.js" "$PAGE/" +cp "$DIST"/brand-*.svg "$DIST"/icon*.png "$PAGE/" 2>/dev/null || true +cp "$HARNESS/shim.js" "$PAGE/shim.js" +cp "$HARNESS/checks.js" "$PAGE/checks.js" +cat > "$PAGE/harness.html" <<'HTML' +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <link rel="stylesheet" href="./renderer.css" /> + <title>GitStudio harness + + + +
Loading GitStudio…
+ + + + + +HTML +echo "harness page at $PAGE/harness.html" diff --git a/apps/desktop/harness/probe.mjs b/apps/desktop/harness/probe.mjs new file mode 100755 index 0000000..860e9e5 --- /dev/null +++ b/apps/desktop/harness/probe.mjs @@ -0,0 +1,139 @@ +#!/usr/bin/env node +// Ask a rendered scene a question. +// +// shot.sh shows you what a surface LOOKS like; check.mjs asserts a named +// invariant. Neither lets you simply measure something — "how wide is that +// column", "does that button have an accessible name", "what does the focus +// ring compute to in light theme" — without first editing the shared checks +// file, which is impossible when several people are investigating at once. +// +// node harness/probe.mjs '' '' [--theme=light] [--width=1150] +// [--extra=staging=checkboxes] +// +// The body runs INSIDE the driven page after its steps have played, with +// `await` available; whatever it returns is JSON-printed. Helpers in scope: +// +// $(sel[, root]) querySelector +// $$(sel[, root]) querySelectorAll as an array +// box(sel|el) {x,y,w,h,right,bottom} rounded, or null +// css(sel|el, ...props) computed styles as an object +// text(sel|el) trimmed textContent +// settle(ms) await a repaint/timeout +// +// Examples: +// node harness/probe.mjs issues 'return $$(".sec-row").length' +// node harness/probe.mjs 'prs~open106' 'return box(".det-title")' +// node harness/probe.mjs changes 'return css(".dc-file", "color", "font-size")' --theme=light +// node harness/probe.mjs code 'return $$("button").filter(b=>!b.textContent.trim()&&!b.title).length' + +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const PAGE = process.env.GS_HARNESS_PAGE + ? resolve(process.env.GS_HARNESS_PAGE, "harness.html") + : resolve(HERE, "page/harness.html"); +const CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; + +const argv = process.argv.slice(2); +const flags = Object.fromEntries( + argv.filter((a) => a.startsWith("--")).map((a) => { + // Split on the FIRST "=" only: --extra=staging=checkboxes is one flag whose + // value is itself a query fragment. + const raw = a.slice(2); + const at = raw.indexOf("="); + return at < 0 ? [raw, "true"] : [raw.slice(0, at), raw.slice(at + 1)]; + }), +); +const positional = argv.filter((a) => !a.startsWith("--")); +const scene = positional[0]; +const body = positional[1]; + +if (!scene || !body) { + console.error("usage: node harness/probe.mjs '' '' [--theme=light] [--width=N]"); + process.exit(2); +} +if (!existsSync(PAGE)) { + console.error("harness/page is not built — run: node esbuild.js && harness/gen.sh"); + process.exit(2); +} + +const theme = flags.theme ?? "dark"; +const width = Number(flags.width ?? 1600); +const height = Number(flags.height ?? 1000); + +// The helpers are prepended to the probe body so every caller has the same +// vocabulary; keeping them here (not in the shim) means the shim stays the +// scene DRIVER and this file owns the inspection language. +const PRELUDE = ` +const $ = (s, r) => (r || document).querySelector(s); +const $$ = (s, r) => [...(r || document).querySelectorAll(s)]; +const _el = (x) => (typeof x === "string" ? $(x) : x); +const box = (x) => { + const n = _el(x); if (!n) return null; + const r = n.getBoundingClientRect(); + return { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), + h: Math.round(r.height), right: Math.round(r.right), bottom: Math.round(r.bottom) }; +}; +const css = (x, ...props) => { + const n = _el(x); if (!n) return null; + const s = getComputedStyle(n); const o = {}; + for (const p of props) o[p] = s.getPropertyValue(p) || s[p]; + return o; +}; +const text = (x) => { const n = _el(x); return n ? (n.textContent || "").trim() : null; }; +const settle = (ms = 200) => new Promise((r) => setTimeout(r, ms)); +`; + +const probe = encodeURIComponent(PRELUDE + "\n" + body); +// --extra=k=v[&k=v] appends the shim's own scene switches (staging=checkboxes, +// many=1, ask=1) so a mode reachable only through a pref can still be measured. +const extra = flags.extra ? `&${flags.extra}` : ""; +const url = `file://${PAGE}?scene=${scene}&theme=${theme}&probe=${probe}${extra}`; + +execFile( + CHROME, + [ + "--headless", + "--disable-gpu", + "--hide-scrollbars", + `--window-size=${width},${height}`, + "--virtual-time-budget=12000", + "--dump-dom", + url, + ], + // Same guard check.mjs carries: a page that never lets virtual time run out + // hangs headless Chrome forever, and a probe that never returns is worse than + // one that fails — it hangs whatever asked the question. + { maxBuffer: 64 * 1024 * 1024, timeout: 90_000, killSignal: "SIGKILL" }, + (err, stdout) => { + if (err && !stdout) { + console.error("chrome failed:", err.message); + process.exit(1); + } + const m = /PROBE ([\s\S]*?)<\/title>/.exec(stdout); + if (!m) { + const t = /<title>([\s\S]*?)<\/title>/.exec(stdout); + console.error(`no probe result (title was ${JSON.stringify(t?.[1] ?? "")})`); + process.exit(1); + } + const decoded = m[1] + .replace(/"/g, '"') + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/'/g, "'") + .replace(/&/g, "&"); + try { + const v = JSON.parse(decoded); + if (v && typeof v === "object" && v.error) { + console.error(v.error); + process.exit(1); + } + console.log(JSON.stringify(v, null, 2)); + } catch { + console.log(decoded); + } + }, +); diff --git a/apps/desktop/harness/shim.js b/apps/desktop/harness/shim.js new file mode 100644 index 0000000..32a3d2e --- /dev/null +++ b/apps/desktop/harness/shim.js @@ -0,0 +1,1603 @@ +// Headless-render harness: fakes the preload's `window.gitstudio` bridge with +// realistic fixtures so the WHOLE desktop renderer runs in plain Chrome. +// Scene selection via ?scene=<view>[.step[.step…]] — steps are driver actions +// run after the repo screen mounts (e.g. "issues.open18" opens issue #18). +(() => { + const now = Date.now(); + const S = (h) => Math.floor(now / 1000) - h * 3600; // epoch-secs, h hours ago + const ISO = (h) => new Date(now - h * 3600e3).toISOString(); + + const params = new URLSearchParams(location.search); + // Steps separated by "~" (not ".") so CSS selectors in click: steps survive. + const scene = (params.get("scene") || "issues").split("~"); + const view = scene[0]; + const steps = scene.slice(1); + const theme = params.get("theme") || "dark"; + + // Pre-seed prefs so the app boots straight into the scene's view, terminal + // collapsed, fixed rail width — deterministic screenshots. + localStorage.setItem( + "gitstudio.ui.prefs", + JSON.stringify({ + currentView: view === "inbox" ? "notifications" : view, + themeMode: theme, + railWidth: 216, + railCollapsed: false, + terminalOpen: false, + // ?staging=checkboxes drives the one-list model (issue #16). Without a way + // in, the ticks that ARE the staging model in that mode were unreachable + // from the harness and never looked at. + stagingModel: params.get("staging") === "checkboxes" ? "checkboxes" : "split", + }), + ); + + // ── cast ── + const me = "antonarnaudov"; + const u = (login) => ({ login, avatarUrl: null }); + const label = (name, color) => ({ name, color }); + const L = { + bug: label("bug", "d73a4a"), + ux: label("ux", "bfdadc"), + enhancement: label("enhancement", "a2eeef"), + desktop: label("desktop app", "5319e7"), + extension: label("extension", "0e8a16"), + help: label("help wanted", "008672"), + good: label("good first issue", "7057ff"), + perf: label("performance", "fbca04"), + }; + + const issues = [ + { number: 31, title: "Split views make Issues and PRs unreadable on a 13\" screen", state: "open", user: u("mira-holt"), labels: [L.ux, L.desktop], assignees: [u(me)], comments: 6, h: 5, assoc: "MEMBER", + reactions: { total: 9, plusOne: 6, minusOne: 0, laugh: 0, hooray: 2, confused: 0, heart: 1, rocket: 0, eyes: 0 }, + body: "On a MacBook Air the list pane and detail pane fight for space — the list truncates every title and the detail wraps the action buttons onto three rows.\n\n**Expected**: reading an issue should use the full width, like Linear does.\n\n**Actual**: two cramped panes, both scrolling independently." }, + { number: 30, title: "Workflow logs: streaming stops after ~400 lines", state: "open", user: u("s-ohta"), labels: [L.bug], assignees: [], comments: 2, h: 9, + body: "Long jobs stop appending output. Re-opening the run shows the full log, so it's a streaming bug, not a data bug." }, + { number: 29, title: "Notifications: mark-as-done needs a keyboard shortcut", state: "open", user: u("jparks"), labels: [L.enhancement, L.ux], assignees: [u("mira-holt")], comments: 4, h: 16, + body: "Triaging 40 notifications with the mouse is painful. `e` to archive like every inbox, please." }, + { number: 28, title: "Rebase drag-to-reorder flickers when dropping on the last row", state: "open", user: u(me), labels: [L.bug, L.desktop], assignees: [u(me)], comments: 1, h: 28, + body: "Repro:\n1. Open Rebase with 6+ commits\n2. Drag the first commit to the end\n3. Drop marker jumps for a frame\n\nSuspect the placeholder index is off by one when `after === rows.length`." }, + { number: 27, title: "Support per-line staging, not just blocks", state: "open", user: u("dkovachev"), labels: [L.enhancement, L.help], assignees: [], comments: 9, h: 40, + body: "`applySelectedChanges` promotes the whole block a selection touches. JetBrains lets you tick single lines. This needs engine work — see the staging notes in the wiki.\n\nThe shape it should have:\n\n```python\ndef stage(lines):\n for n in lines:\n if guard(n):\n apply(n)\n return True\n```\n\nNote the INDENTATION — this fence exists so a check can prove a copied snippet carries real spaces." }, + { number: 26, title: "Release drafting: attach assets from the app", state: "open", user: u("mira-holt"), labels: [L.enhancement], assignees: [u("s-ohta")], comments: 3, h: 51, + body: "Creating a release works, but uploading a .dmg still means a browser round-trip." }, + { number: 25, title: "Graph: avatars blur on non-retina displays", state: "open", user: u("jparks"), labels: [L.bug, L.perf], assignees: [], comments: 2, h: 70, + body: "Half-pixel alignment again. The gutter drawer draws avatars at y+0.5 on 1x DPR." }, + { number: 23, title: "Onboarding: first-run tour of the six local views", state: "open", user: u("dkovachev"), labels: [L.good, L.ux], assignees: [], comments: 0, h: 90, + body: "" }, + { number: 22, title: "Add a built-in terminal multiplexer", state: "closed", stateReason: "not_planned", user: u("jparks"), labels: [L.enhancement], assignees: [], comments: 3, h: 120, + body: "Out of scope — GitStudio ships one terminal, not a tmux clone." }, + { number: 21, title: "Stash view: show untracked files included in a stash", state: "closed", user: u("s-ohta"), labels: [L.bug], assignees: [u(me)], comments: 5, h: 130, + body: "Fixed by reading the third parent commit when present." }, + { number: 19, title: "Checkout from the graph checks out the commit, not the branch", state: "closed", user: u("mira-holt"), labels: [L.bug], assignees: [u(me)], comments: 8, h: 200, + body: "Detached HEAD surprise. Fixed with checkout-ref." }, + ]; + + const issueComments = { + 31: [ + { id: 1, author: u(me), createdAt: ISO(4), body: "Agreed — this is the #1 usability debt in the app. The plan:\n\n1. Lists go **full width** with richer rows\n2. Opening an item replaces the list with a **full detail view** (Esc / ← goes back)\n3. Properties move to a right rail with inline editing\n\nSame pattern for Issues, PRs, Actions, Releases." }, + { id: 2, author: u("mira-holt"), createdAt: ISO(3.5), updatedAt: ISO(3.4), authorAssociation: "MEMBER", body: "Yes. Also please keep keyboard flow: `↑↓` in the list, `Enter` to open, `Esc` back, `c` to comment." }, + { id: 3, author: u("jparks"), createdAt: ISO(3), authorAssociation: "FIRST_TIME_CONTRIBUTOR", reactions: { total: 4, plusOne: 3, minusOne: 0, laugh: 0, hooray: 0, confused: 0, heart: 1, rocket: 0, eyes: 0 }, body: "While you're in there — the *Open on GitHub* buttons everywhere feel like the app giving up. If the data's already on screen, let me act on it in place." }, + { id: 4, author: u(me), createdAt: ISO(2), body: "> the app giving up\n\nFair. In-app actions become primary; the GitHub link stays as a small escape hatch on every detail view.\n\nDuring Tuesday's GitHub outage the local half of the app kept working fine — the redesign should make the GitHub half feel just as solid." }, + ], + }; + + const prs = [ + { number: 106, title: "desktop: full-page detail views for Issues (kills the split pane)", body: "First section converted to the new list ⇄ detail navigation.\n\n- `sec-*` full-width list rows\n- `det-*` detail page with a property rail\n- Esc / ⌘[ walk back through real history", state: "open", draft: false, htmlUrl: "https://github.com/GitStudioHQ/gitstudio/pull/106", user: u(me), createdAt: ISO(3), updatedAt: ISO(1), head: { ref: "redesign/issues-detail", sha: "a1b2c3d" }, base: { ref: "main", sha: "9f8e7d6" }, labels: [L.desktop, L.ux], comments: 4, additions: 612, deletions: 348, changedFiles: 9, assignees: [u(me), u("mira-holt")], mergedAt: null, closedAt: null, mergedBy: null, reviewComments: 7, commits: 14, requestedReviewers: [u("dkovachev")], milestone: { number: 3, title: "Desktop 1.6" }, authorAssociation: "OWNER", headRepoFullName: null, reactions: { total: 5, plusOne: 4, minusOne: 0, laugh: 0, hooray: 1, confused: 0, heart: 0, rocket: 0, eyes: 0 } }, + { number: 104, title: "actions: stream job logs over IPC with backpressure", body: "", state: "open", draft: false, htmlUrl: "", user: u("s-ohta"), createdAt: ISO(7), updatedAt: ISO(2), head: { ref: "fix/log-stream", sha: "b2c3d4e" }, base: { ref: "main", sha: "9f8e7d6" }, labels: [L.bug], comments: 2, additions: 210, deletions: 64, changedFiles: 4, reviewComments: 2, commits: 5, requestedReviewers: [u(me)], authorAssociation: "MEMBER", headRepoFullName: "s-ohta/gitstudio" }, + { number: 103, title: "engine: per-line staging groundwork (hunk splitting)", body: "", state: "open", draft: true, htmlUrl: "", user: u("dkovachev"), createdAt: ISO(20), updatedAt: ISO(6), head: { ref: "feat/line-staging", sha: "c3d4e5f" }, base: { ref: "main", sha: "9f8e7d6" }, labels: [L.enhancement], comments: 11, additions: 1240, deletions: 180, changedFiles: 17 }, + { number: 101, title: "ci: run desktop tests on windows-latest too", body: "", state: "open", draft: false, htmlUrl: "", user: u("jparks"), createdAt: ISO(30), updatedAt: ISO(26), head: { ref: "ci/windows", sha: "d4e5f6a" }, base: { ref: "main", sha: "9f8e7d6" }, labels: [], comments: 1, additions: 48, deletions: 3, changedFiles: 2 }, + { number: 99, title: "release: extension 1.11.1", body: "", state: "closed", draft: false, htmlUrl: "", user: u(me), createdAt: ISO(50), updatedAt: ISO(44), head: { ref: "release/1.11.1", sha: "e5f6a7b" }, base: { ref: "main", sha: "9f8e7d6" }, labels: [], comments: 0, additions: 12, deletions: 4, changedFiles: 3, mergedAt: ISO(44), closedAt: ISO(44), mergedBy: u("mira-holt"), commits: 3, reviewComments: 1, authorAssociation: "OWNER" }, + ]; + + const prConversation = { + 106: [ + { kind: "comment", author: "mira-holt", createdAt: ISO(2.5), body: "Tried the branch — night and day. Full-width rows read like a real tracker now." }, + { kind: "review", state: "APPROVED", author: "s-ohta", createdAt: ISO(2), body: "Navigation history integration is clean. Ship it." }, + { kind: "review", state: "CHANGES_REQUESTED", author: "jparks", createdAt: ISO(1.8), body: "The Esc handler swallows the key while a menu is open — see the comment on issues.ts." }, + { kind: "comment", author: me, createdAt: ISO(1.2), body: "PRs view converts next on this pattern, then Actions." }, + ], + }; + // NINE files, because the PR says "Files (9)" — three made the count a lie and + // hid every capped-list notice. Across three directories, and covering every + // status GitHub sends: with all three "modified", the bug where "removed" and + // "renamed" both rendered as an amber R was not expressible at all. + const prFiles = { + 106: [ + { filename: "apps/desktop/src/renderer/views/issues.ts", status: "modified", additions: 402, deletions: 260 }, + { filename: "apps/desktop/src/renderer/views/common.ts", status: "modified", additions: 118, deletions: 30 }, + { filename: "apps/desktop/src/renderer/views/issueDetail.ts", status: "renamed", additions: 12, deletions: 4, previousFilename: "apps/desktop/src/renderer/issueDetail.ts" }, + { filename: "apps/desktop/src/renderer/legacySplit.ts", status: "removed", additions: 0, deletions: 231 }, + { filename: "apps/desktop/src/renderer/detailShell.ts", status: "added", additions: 188, deletions: 0 }, + { filename: "apps/desktop/src/renderer/styles/app.css", status: "modified", additions: 92, deletions: 58 }, + { filename: "apps/desktop/harness/checks.js", status: "modified", additions: 41, deletions: 0 }, + { filename: "packages/webview-ui/src/detail.css", status: "copied", additions: 22, deletions: 0 }, + { filename: "apps/desktop/assets/issue-empty.png", status: "added", additions: 0, deletions: 0 }, + ], + }; + // `detailsUrl` on at least one row is load-bearing for the harness, not + // decoration: without it `.gh-check-row.is-link` cannot exist in ANY scene, so + // "leave a PR for its pipeline and press back" — a bug the owner hit — was + // literally unreachable by the test suite. One GitHub-Actions URL (opens the + // run in-app) and one external CI URL (opens a browser), because the two take + // different code paths. + const prChecks = { + 106: [ + { + name: "build / desktop (macos)", + status: "completed", + conclusion: "success", + detailsUrl: "https://github.com/GitStudioHQ/gitstudio/actions/runs/9100/job/1", + }, + { name: "build / desktop (windows)", status: "completed", conclusion: "success" }, + { name: "test / renderer", status: "completed", conclusion: "success" }, + { + name: "codecov/patch", + status: "completed", + conclusion: "failure", + detailsUrl: "https://app.circleci.com/pipelines/github/GitStudioHQ/gitstudio/4102", + }, + { name: "lint", status: "in_progress", conclusion: "" }, + ], + 104: [ + { + name: "build / desktop (macos)", + status: "completed", + conclusion: "failure", + detailsUrl: "https://github.com/GitStudioHQ/gitstudio/actions/runs/9097/job/1", + }, + ], + }; + // FOURTEEN, because the tab says "Commits (14)". Two made the count a lie and + // meant no capped-list notice was ever reachable. The third is the 420-file + // merge, so the commit page can be driven at a real size from a real route. + const prCommits = { + 106: [ + // The four states a commit row has to be able to draw: a plain one, one + // with a BODY behind the disclosure, a MERGE, and a VERIFIED signature. + { sha: "a1b2c3d4", shortSha: "a1b2c3d", message: "issues: full-page detail as a routed state", body: "The split view could not show a body, a timeline and a rail at once on a\n13\" screen, so all three were cropped.\n\nCloses #31.", author: me, login: me, date: ISO(3), verified: true }, + { sha: "b2c3d4e5", shortSha: "b2c3d4e", message: "common: sectionList + detailShell primitives", author: me, login: me, date: ISO(2.6) }, + { sha: "f00dbabe", shortSha: "f00dbab", message: "Merge the generated-module migration", author: me, login: me, date: ISO(2.4), isMerge: true }, + // Dated across two days, so the day grouping is a thing the screenshot + // actually shows rather than a code path nobody looks at. + ...Array.from({ length: 11 }, (_, i) => ({ + sha: `c${i}d4e5f6`, + shortSha: `c${i}d4e5f`, + message: [ + "css: list + detail tokens share one scale", + "prs: files tab reads the diff from the engine", + "actions: stream job logs with backpressure", + "graph: keep the mount alive across routes", + "settings: one measure for every card", + "inbox: group by repository, not by hour", + "compare: drop the either/or body", + "code: middle-truncate paths in the crumb", + "gists: a real empty state", + "orgs: repositories before teams", + "releases: latest is the shipping build", + ][i], + author: i % 3 === 0 ? "mira-holt" : i % 3 === 1 ? "s-ohta" : me, + login: i % 3 === 0 ? "mira-holt" : i % 3 === 1 ? "s-ohta" : me, + // The tail of the list falls on the PREVIOUS day, so the day grouping + // is something a screenshot shows rather than a code path nobody sees. + date: ISO(2.2 + i * 3), + verified: i === 2, + })), + ], + }; + + const mkRun = (o) => Object.assign({ + runNumber: 0, runAttempt: 1, displayTitle: o.name, headSha: "9f8e7d6aa11", updatedAt: o.createdAt, + runStartedAt: o.createdAt, actor: u(me), triggeringActor: null, workflowId: 1, + workflowPath: ".github/workflows/desktop.yml", headCommitMessage: "release: extension 1.11.1", + headCommitAuthor: "Anton Arnaudov", pullRequests: [], + }, o); + const runs = [ + mkRun({ id: 9101, runNumber: 412, name: "Desktop CI", workflowId: 1, displayTitle: "issues: full-page detail as a routed state", status: "in_progress", conclusion: "", branch: "redesign/issues-detail", event: "push", createdAt: ISO(0.4), updatedAt: ISO(0.1), runStartedAt: ISO(0.39), htmlUrl: "", actor: u(me) }), + mkRun({ id: 9100, runNumber: 411, name: "Desktop CI", workflowId: 1, displayTitle: "release: extension 1.11.1", status: "completed", conclusion: "success", branch: "main", event: "push", createdAt: ISO(3), updatedAt: ISO(2.8), runStartedAt: ISO(2.99), htmlUrl: "", actor: u(me), pullRequests: [{ number: 106 }] }), + mkRun({ id: 9099, runNumber: 233, name: "Extension CI", workflowId: 2, displayTitle: "test: drive update-refs end-to-end", status: "completed", conclusion: "success", branch: "main", event: "push", createdAt: ISO(5), updatedAt: ISO(4.9), htmlUrl: "", actor: u("mira-holt") }), + mkRun({ id: 9097, runNumber: 410, runAttempt: 2, name: "Desktop CI", workflowId: 1, displayTitle: "actions: stream job logs over IPC", status: "completed", conclusion: "failure", branch: "fix/log-stream", event: "pull_request", createdAt: ISO(8), updatedAt: ISO(7.7), htmlUrl: "", actor: u("s-ohta"), triggeringActor: u(me), pullRequests: [{ number: 104 }] }), + mkRun({ id: 9095, runNumber: 88, name: "Nightly release", workflowId: 3, displayTitle: "Nightly release", status: "completed", conclusion: "success", branch: "main", event: "schedule", createdAt: ISO(26), updatedAt: ISO(25.7), htmlUrl: "", actor: u("renderbot") }), + // CANCELLED — somebody stopped it, which is not a failure and is a state + // the fixture had none of, so the list's icon bucket for it was never seen. + mkRun({ id: 9094, runNumber: 409, name: "Desktop CI", workflowId: 1, displayTitle: "spike: try CodeMirror instead of Monaco", status: "completed", conclusion: "cancelled", branch: "spike/monaco-swap", event: "push", createdAt: ISO(30), updatedAt: ISO(29.8), runStartedAt: ISO(29.9), htmlUrl: "", actor: u("s-ohta") }), + ]; + + const notifications = [ + { id: "n1", title: "Split views make Issues and PRs unreadable on a 13\" screen", type: "Issue", reason: "assign", repo: "GitStudioHQ/gitstudio", repoAvatarUrl: null, updatedAt: ISO(1), unread: true, htmlUrl: "https://github.com/GitStudioHQ/gitstudio/issues/31", subjectKind: "issue", subjectNumber: 31 }, + { id: "n2", title: "desktop: full-page detail views for Issues (kills the split pane)", type: "PullRequest", reason: "review_requested", repo: "GitStudioHQ/gitstudio", repoAvatarUrl: null, updatedAt: ISO(2), unread: true, htmlUrl: "https://github.com/GitStudioHQ/gitstudio/pull/106", subjectKind: "pull", subjectNumber: 106 }, + { id: "n3", title: "v6.2 breaks xterm addon-fit measurements", type: "Issue", reason: "subscribed", repo: "xtermjs/xterm.js", repoAvatarUrl: null, updatedAt: ISO(7), unread: true, htmlUrl: "https://github.com/xtermjs/xterm.js/issues/5120" }, + { id: "n4", title: "Nightly release failed: notarization timeout", type: "CheckSuite", reason: "ci_activity", repo: "GitStudioHQ/gitstudio", repoAvatarUrl: null, updatedAt: ISO(20), unread: false, htmlUrl: "", subjectKind: "other" }, + { id: "n6", title: "Extension 1.11.1", type: "Release", reason: "subscribed", repo: "GitStudioHQ/gitstudio", repoAvatarUrl: null, updatedAt: ISO(40), unread: true, htmlUrl: "https://github.com/GitStudioHQ/gitstudio/releases/51", subjectKind: "release", subjectNumber: 51 }, + { id: "n7", title: "release: extension 1.11.1", type: "Commit", reason: "author", repo: "GitStudioHQ/gitstudio", repoAvatarUrl: null, updatedAt: ISO(41), unread: false, htmlUrl: "https://github.com/GitStudioHQ/gitstudio/commit/9f8e7d6", subjectKind: "commit", subjectSha: "9f8e7d6" }, + { id: "n5", title: "engine: per-line staging groundwork (hunk splitting)", type: "PullRequest", reason: "mention", repo: "GitStudioHQ/gitstudio", repoAvatarUrl: null, updatedAt: ISO(30), unread: false, htmlUrl: "https://github.com/GitStudioHQ/gitstudio/pull/103" }, + ]; + + const releases = [ + // A PUBLISHED pre-release, newer than the newest stable — the ordinary shape + // of a repo mid-release-cycle, and the one that exposes whether "Latest" + // follows github.com's rule (newest published NON-pre-release) or just picks + // the newest published thing. The only prerelease here used to also be a + // draft, so the distinction was never exercised. + { id: 52, tagName: "desktop-v1.7.0-rc.1", targetCommitish: "main", name: "Desktop 1.7.0 RC 1", draft: false, prerelease: true, htmlUrl: "", author: u(me), createdAt: ISO(8), publishedAt: ISO(8), assets: [], body: "Release candidate — please test." }, + { id: 51, tagName: "ext-v1.11.1", targetCommitish: "main", name: "Extension 1.11.1", draft: false, prerelease: false, htmlUrl: "", author: u(me), createdAt: ISO(40), publishedAt: ISO(40), assets: [ { id: 1, name: "gitstudio-1.11.1.vsix", label: null, contentType: "application/zip", size: 4830210, downloadCount: 1240, downloadUrl: "", createdAt: ISO(40), updatedAt: ISO(40) } ], body: "### Fixes\n- Drive the update-refs end-to-end through the shipping runner\n- Graph: keep-alive across view switches" }, + { id: 50, tagName: "desktop-v1.5.1", targetCommitish: "main", name: "Desktop 1.5.1", draft: false, prerelease: false, htmlUrl: "", author: u(me), createdAt: ISO(60), publishedAt: ISO(58), assets: [ { id: 2, name: "GitStudio-1.5.1-arm64.dmg", label: null, contentType: "application/x-apple-diskimage", size: 128400000, downloadCount: 356, downloadUrl: "", createdAt: ISO(58), updatedAt: ISO(58) }, { id: 3, name: "GitStudio-1.5.1-x64.dmg", label: null, contentType: "application/x-apple-diskimage", size: 131200000, downloadCount: 121, downloadUrl: "", createdAt: ISO(58), updatedAt: ISO(58) } ], body: "### Highlights\n- Drag a commit in the graph to reorder it\n- Rebase carries other branches when asked" }, + { id: 49, tagName: "desktop-v1.6.0-beta.1", targetCommitish: "main", name: "Desktop 1.6.0 beta 1", draft: true, prerelease: true, htmlUrl: "", author: u(me), createdAt: ISO(12), publishedAt: null, assets: [], body: "The redesign preview build." }, + ]; + + const branches = [ + { name: "main", current: true, upstream: "origin/main", ahead: 2, behind: 0, subject: "release: extension 1.11.1", date: S(40) }, + // aheadDefault/behindDefault are divergence from the DEFAULT branch, which + // is a different question from the upstream pair — and `aheadDefault === 0` + // is what "merged, safe to delete" means. + { name: "redesign/issues-detail", current: false, aheadDefault: 5, behindDefault: 0, upstream: "origin/redesign/issues-detail", ahead: 0, behind: 0, subject: "issues: full-page detail as a routed state", date: S(1) }, + { name: "fix/log-stream", current: false, aheadDefault: 2, behindDefault: 12, upstream: undefined, ahead: 0, behind: 0, subject: "actions: stream job logs with backpressure", date: S(8) }, + // The state every merged pull request leaves behind: the upstream is gone, + // and without the flag the row reads "0 ahead, 0 behind" — in sync with a + // remote that does not exist. + { name: "redesign/wave-1", current: false, aheadDefault: 0, behindDefault: 40, merged: true, upstream: "origin/redesign/wave-1", ahead: 0, behind: 0, gone: true, subject: "issues: section pages land", date: S(56) }, + { name: "feat/line-staging", current: false, aheadDefault: 18, behindDefault: 3, upstream: "origin/feat/line-staging", ahead: 3, behind: 5, subject: "engine: hunk splitting groundwork", date: S(20) }, + // STALE — past the 90-day line the Active/Stale cut is drawn at. Every + // branch above is hours old, so before these two the Stale segment read + // "(0)" and every check of that cut passed by testing an empty filter + // against an empty result. One is merged (so it also lands in the sweep), + // one is not (so "Stale" cannot be mistaken for "finished"). + { name: "spike/monaco-swap", current: false, aheadDefault: 4, behindDefault: 210, upstream: undefined, ahead: 0, behind: 0, subject: "spike: try CodeMirror instead of Monaco", date: S(24 * 140) }, + { name: "chore/deps-2024", current: false, aheadDefault: 0, behindDefault: 190, merged: true, upstream: "origin/chore/deps-2024", ahead: 0, behind: 0, subject: "chore: bump every dependency", date: S(24 * 200) }, + ]; + + // ?onfeature=1 → HEAD is a feature branch, so the DEFAULT branch is in the + // list without being current. That is the only state in which the sweep's + // worst bug is reachable: `merged` means "zero commits ahead of the default + // branch", which the default branch satisfies against itself, so main was + // offered for deletion by name — and every other fixture here keeps main + // checked out, where `!b.current` hides it. + if (params.get("onfeature")) { + for (const b of branches) b.current = b.name === "redesign/issues-detail"; + // And main then carries what the bridge really computes for it. `merged` is + // `%(ahead-behind:<default>)`'s ahead === 0, and main measured against main + // is 0/0 — so the flag is not a fixture convenience here, it is the value + // the app receives. Without it this scene renders a main that no filter + // could ever have swept, and the check passes on a broken build. + const m = branches.find((b) => b.name === "main"); + if (m) { m.merged = true; m.aheadDefault = 0; m.behindDefault = 0; } + } + + const workflows = [ + { id: 1, name: "Desktop CI", path: ".github/workflows/desktop.yml", state: "active", htmlUrl: "" }, + { id: 2, name: "Extension CI", path: ".github/workflows/extension.yml", state: "active", htmlUrl: "" }, + { id: 3, name: "Nightly release", path: ".github/workflows/nightly.yml", state: "active", htmlUrl: "" }, + ]; + + const orgs = [ + { login: "GitStudioHQ", name: "GitStudio", avatarUrl: null, description: "The open-source Git workspace — desktop app + VS Code extension.", htmlUrl: "https://github.com/GitStudioHQ" }, + ]; + const orgRepos = [ + { name: "gitstudio", fullName: "GitStudioHQ/gitstudio", htmlUrl: "", description: "Monorepo: desktop app, VS Code extension, engine, git-service.", private: false, fork: false, archived: false, language: "TypeScript", stargazersCount: 2140, pushedAt: ISO(1) }, + { name: "gistudio.dev", fullName: "GitStudioHQ/gistudio.dev", htmlUrl: "", description: "Marketing site + error collector.", private: false, fork: false, archived: false, language: "TypeScript", stargazersCount: 84, pushedAt: ISO(60) }, + { name: "design", fullName: "GitStudioHQ/design", htmlUrl: "", description: "Brand + product design assets.", private: true, fork: false, archived: false, language: null, stargazersCount: 0, pushedAt: ISO(120) }, + ]; + + const gists = [ + { id: "g1", description: "GitStudio release checklist", public: false, htmlUrl: "", owner: u(me), createdAt: ISO(300), updatedAt: ISO(48), fileCount: 1, comments: 0, files: [{ filename: "RELEASE.md", language: "Markdown", type: "text/markdown", size: 2400, rawUrl: "", content: "# Release checklist\n\n1. `npm run check-types`\n2. Tag + push\n3. Notarize", truncated: false }] }, + // Two files, so the gist detail's FILE TABS have something to render — the + // single-file fixture never exercised that path at all. + { id: "g2", description: "zsh: git aliases", public: true, htmlUrl: "", owner: u(me), createdAt: ISO(1000), updatedAt: ISO(700), fileCount: 2, comments: 2, files: [{ filename: "aliases.zsh", language: "Shell", type: "text/plain", size: 812, rawUrl: "", content: "alias gs='git status'\nalias gl='git log --oneline -20'", truncated: false }, { filename: "functions.zsh", language: "Shell", type: "text/plain", size: 460, rawUrl: "", content: "gco() { git checkout \"$@\"; }", truncated: false }] }, + ]; + + const projects = [ + { id: "p1", number: 1, title: "Desktop redesign", shortDescription: "Linear-grade UX for every surface", url: "", itemCount: 14, closed: false, updatedAt: ISO(2) }, + { id: "p2", number: 2, title: "v2.0", shortDescription: "", url: "", itemCount: 32, closed: false, updatedAt: ISO(50) }, + ]; + const board = { + field: { id: "f1", name: "Status", options: [ { id: "o1", name: "Todo", color: "GRAY" }, { id: "o2", name: "In progress", color: "YELLOW" }, { id: "o3", name: "Done", color: "GREEN" } ] }, + items: [ + { id: "i1", type: "ISSUE", title: "Split views make Issues and PRs unreadable", number: 31, state: "OPEN", url: null, author: "mira-holt", statusOptionId: "o2", statusName: "In progress", updatedAt: ISO(2) }, + { id: "i2", type: "PULL_REQUEST", title: "Full-page detail views for Issues", number: 106, state: "OPEN", url: null, author: me, statusOptionId: "o2", statusName: "In progress", updatedAt: ISO(1) }, + { id: "i3", type: "ISSUE", title: "Notifications keyboard triage", number: 29, state: "OPEN", url: null, author: "jparks", statusOptionId: "o1", statusName: "Todo", updatedAt: ISO(16) }, + { id: "i4", type: "ISSUE", title: "Graph avatars blur on 1x displays", number: 25, state: "OPEN", url: null, author: "jparks", statusOptionId: "o1", statusName: "Todo", updatedAt: ISO(70) }, + { id: "i5", type: "ISSUE", title: "Stash view untracked files", number: 21, state: "CLOSED", url: null, author: "s-ohta", statusOptionId: "o3", statusName: "Done", updatedAt: ISO(130) }, + ], + }; + + const changedFiles = [ + { path: "apps/desktop/src/renderer/views/issues.ts", status: "M", staged: true }, + { path: "apps/desktop/src/renderer/views/common.ts", status: "M", staged: true }, + { path: "apps/desktop/src/renderer/styles/app.css", status: "M", staged: false }, + { path: "apps/desktop/src/renderer/views/prs.ts", status: "M", staged: false }, + { path: "docs/redesign.md", status: "A", staged: false }, + // A PARTIALLY-staged file: git's `MM` — a staged edit plus a newer unstaged + // one — which the parser correctly reports as two records for one path. + // Without one in the fixture, the checkbox model's duplicate-row bug was + // invisible to every check here. + { path: "apps/desktop/src/renderer/renderer.ts", status: "M", staged: true }, + { path: "apps/desktop/src/renderer/renderer.ts", status: "M", staged: false }, + ]; + // ?ws=1 adds a file whose ONLY change is whitespace — one re-indented line + // and one with trailing spaces. Split computes in-process and Inline computes + // in Monaco's worker, so the whitespace toggle is the one setting the two + // implementations can read differently, and nothing here could see them + // disagree without such a file. It is behind a switch because every other + // check counts the rows in this list. + if (params.get("ws")) { + changedFiles.push({ path: "packages/engine/src/spacing.ts", status: "M", staged: false }); + changedFiles.push({ path: "packages/engine/src/spacing-inner.ts", status: "M", staged: false }); + } + + /** Serial for the PTY ids `terminal:create` hands out. */ + let ptySeq = 0; + + const fixtures = { + // ?norepo=1 → NO repository open, which is the welcome screen: the first + // thing anyone sees, the only screen shown after closing a repo, and + // unreachable in this harness until now — which is why nothing had ever + // checked it. + "repo:current": params.get("norepo") + ? undefined + : { root: "/Users/anton/Developer/GitStudioHQ/gitstudio", name: "gitstudio" }, + "repo:recent": [ + { root: "/Users/anton/Developer/GitStudioHQ/gitstudio", name: "gitstudio" }, + { root: "/Users/anton/Developer/GitStudioHQ/gistudio.dev", name: "gistudio.dev" }, + ], + // `github:status` is DYNAMIC below, not here: a fixture that never changes + // cannot express signing out, which is why nothing could see that the + // top-bar chip kept naming the account you had just left. + "sync:status": { branch: "main", upstream: "origin/main", ahead: 2, behind: 0, noUpstream: false }, + // Every KIND of ref, because the Branches view has one screen per kind and + // the fixture used to hold local heads ONLY — so the remote, tag and stash + // row shapes were never once rendered, screenshotted or checked. + "refs:list": [ + ...branches.map((b) => ({ + type: "head", + name: b.name, + fullName: "refs/heads/" + b.name, + sha: "abc123", + isCurrent: b.current, + upstream: b.upstream, + gone: b.gone, + date: b.date, + subject: b.subject, + })), + // The remote's own HEAD: `%(refname:short)` of it is the bare remote NAME + // ("origin"), which is why the old `endsWith("/HEAD")` guard never fired + // and a phantom row called "origin" sat in the list offering to check out + // nothing. Its symref names the DEFAULT branch, which IS worth keeping. + { type: "remote", name: "origin", fullName: "refs/remotes/origin/HEAD", sha: "9f8e7d6", isCurrent: false, symref: "origin/main" }, + { type: "remote", name: "origin/main", fullName: "refs/remotes/origin/main", sha: "9f8e7d6", isCurrent: false, date: S(40), subject: "release: extension 1.11.1" }, + { type: "remote", name: "origin/redesign/issues-detail", fullName: "refs/remotes/origin/redesign/issues-detail", sha: "a1b2c3d", isCurrent: false, date: S(1), subject: "issues: full-page detail as a routed state" }, + { type: "remote", name: "origin/feat/line-staging", fullName: "refs/remotes/origin/feat/line-staging", sha: "18c9d0e", isCurrent: false, date: S(20), subject: "engine: hunk splitting groundwork" }, + { type: "remote", name: "origin/chore/dependabot-bump", fullName: "refs/remotes/origin/chore/dependabot-bump", sha: "77aa88b", isCurrent: false, date: S(200), subject: "build(deps): bump electron to 33.4.11" }, + // Annotated and lightweight — the distinction nothing has ever carried. + { type: "tag", name: "ext-v1.11.1", fullName: "refs/tags/ext-v1.11.1", sha: "e5f6a7b", isCurrent: false, objectType: "tag", date: S(40), subject: "Extension 1.11.1" }, + { type: "tag", name: "desktop-v1.5.1", fullName: "refs/tags/desktop-v1.5.1", sha: "d4e5f6a", isCurrent: false, objectType: "tag", date: S(58), subject: "Desktop 1.5.1" }, + { type: "tag", name: "nightly", fullName: "refs/tags/nightly", sha: "9f8e7d6", isCurrent: false, objectType: "commit", date: S(26), subject: "release: extension 1.11.1" }, + ], + "head:get": { detached: false, branch: "main", sha: "9f8e7d6" }, + // More than one, so the Worktrees segment exists at all — the four + // worktree channels have been in the IPC contract since it was written + // with no caller in any view, and no fixture either. + "worktree:list": [ + { path: "/Users/anton/Developer/GitStudioHQ/gitstudio", head: "9f8e7d6aa11", branch: "main", current: true }, + { path: "/Users/anton/Developer/GitStudioHQ/gitstudio-wave2", head: "a1b2c3d4e5f", branch: "redesign/issues-detail" }, + { path: "/Users/anton/Developer/GitStudioHQ/gitstudio-hotfix", head: "77aa88b9c0d", branch: "fix/log-stream", prunable: true }, + ], + "branches:list": branches, + // The Rebase view had no fixture, so every screenshot of it was its ERROR + // state — the one surface nobody could actually look at. + // Compare had no fixture either — every shot of it was "Couldn't compare + // these refs". + // Compare's file DIFF. Without it every file in a comparison rendered the + // "nothing to show" state, so the pane the view exists for was never + // exercised — and the state it fell into was a positive claim ("identical + // content") the app had no basis for. A fixture the harness cannot express + // is a defect the harness cannot catch. + "compare:refs": { + // 27 rows listed against 27 ahead — a count that is silently a cap is + // worse than no count, so the two must agree unless the note says why. + ahead: 27, + behind: 2, + commits: [ + { sha: "18c9d0e1f2736485a1b2", shortSha: "18c9d0e", subject: "engine: hunk splitting groundwork", author: "Mira Holt", date: S(20 * 60) }, + { sha: "27b8c9d0e1f263748596", shortSha: "27b8c9d", subject: "engine: split a hunk on a selection boundary", author: "Anton Arnaudov", date: S(18 * 60) }, + { sha: "36a7b8c9d0e152637485", shortSha: "36a7b8c", subject: "changes: stage the lines a selection touches", author: "Sora Ohta", date: S(9 * 60), body: "Translates the selection through the index\u2192working diff first, so the\nranges match the side git is being asked about.", isMerge: false }, + // Enough rows that the list OVERFLOWS its pane. Compare's scroller was + // deleted with the old row styles and nothing noticed, because three + // commits fit — the surface has to be taller than the box to prove it. + ...Array.from({ length: 24 }, (_, i) => ({ + sha: `4${i}b7c8d9e0f1a2b3c4d5`, + shortSha: `4${i}b7c8d`, + subject: [ + "engine: fold adjacent hunks before scoring", + "engine: keep the trailing newline out of the span", + "changes: reuse the index text across ticks", + "graph: lanes survive a reordered parent", + ][i % 4], + author: ["Mira Holt", "Anton Arnaudov", "Sora Ohta"][i % 3], + date: S((8 - i * 0.25) * 60), + isMerge: i % 8 === 7, + })), + ], + files: [ + { path: "packages/engine/src/hunks.ts", status: "M" }, + { path: "packages/engine/src/hunkSplit.ts", status: "A" }, + { path: "apps/desktop/src/renderer/diffPanel.ts", status: "M" }, + { path: "apps/desktop/src/renderer/legacyHunks.ts", status: "D" }, + { path: "packages/engine/test/hunkSplit.test.ts", status: "A" }, + ], + }, + "rebase:load": { + ok: true, + base: "origin/main", + branch: "feat/line-staging", + inProgress: false, + baseCommit: { shortSha: "9f8e7d6", subject: "release: extension 1.11.1" }, + // NEWEST FIRST, the order `loadCommits` returns (`git log --topo-order`, + // no --reverse) and the order the hint bar promises. Listed oldest-first + // this fixture put every fold target on the wrong side: a `fixup!` row + // said it folded into the commit ABOVE the one its own subject names, and + // the "oldest commit has nothing below it" guard fired on the NEWEST + // commit — while the screenshot ran 20h → 2h downward under a hint + // reading "Newest first". + commits: [ + { sha: "5485767869c930415263", shortSha: "5485767", author: "Anton Arnaudov", subject: "wip: notes to self", rel: "2h ago" }, + { sha: "45968797c9d041526374", shortSha: "4596879", author: "Sora Ohta", subject: "changes: stage the lines a selection touches", rel: "9h ago" }, + { sha: "36a7b8c9d0e152637485", shortSha: "36a7b8c", author: "Anton Arnaudov", subject: "fixup! engine: split a hunk on a selection boundary", rel: "16h ago" }, + { sha: "27b8c9d0e1f263748596", shortSha: "27b8c9d", author: "Anton Arnaudov", subject: "engine: split a hunk on a selection boundary", rel: "18h ago" }, + { sha: "18c9d0e1f2736485a1b2", shortSha: "18c9d0e", author: "Mira Holt", subject: "engine: hunk splitting groundwork", rel: "20h ago" }, + ], + }, + "stash:list": [ { sha: "77aa88", ref: "stash@{0}", message: "WIP: palette streaming groups", time: S(30) } ], + // ?clean=1 → a CLEAN working tree. The app must handle it — it is the state + // a repository spends most of its life in — and nothing else in this shim + // can produce it, so the Changes view's empty state, its composer's enable + // rule and its toolbar were all only ever exercised with work present. + "status": params.get("clean") ? [] : changedFiles, + // `?op=merge|rebase|cherry-pick|revert` puts the Changes banner on screen. + // Without a fixture the banner NEVER rendered in the harness, which is why + // no check could see that its Abort ran `git merge --abort` on every one of + // the four operations it names. + // `?op=merge|rebase|cherry-pick|revert|am` puts the Changes banner on + // screen, `&conflicts=N` gives it conflicts, `&skip=1` puts it in the + // "nothing left to commit" shape where Skip is the way out. + // + // The whole GitOpState shape, `kind`/`canContinue`/`canSkip` included: the + // host decides those now, and a fixture that returns only the old booleans + // makes the banner render NOTHING — which is exactly what this fixture's + // own check caught when the banner was rewritten. + "git:opState": (() => { + const op = params.get("op") || ""; + const conflicts = Number(params.get("conflicts") || 0) || 0; + const emptied = params.get("skip") === "1"; + // Mirrors gitBridge's own rules: there is no `merge --skip`, and a rebase + // only offers Skip on the apply backend's emptied patch. + const canSkip = emptied && (op === "cherry-pick" || op === "revert" || op === "am"); + return { + merging: op === "merge", + rebasing: op === "rebase", + amApplying: op === "am", + cherryPicking: op === "cherry-pick", + reverting: op === "revert", + conflicts, + kind: op || null, + canContinue: !!op && conflicts === 0 && !emptied, + canSkip, + nothingToCommit: emptied, + }; + })(), + "diff:files": changedFiles, + "notifications:unreadCount": 3, + "notifications:list": notifications, + "issue:list": issues, + "issue:labels": Object.values(L).map((l) => ({ ...l, description: null })), + // The pull request's own label list. Missing entirely, so `doLabels` read + // undefined off it and threw into the unhandled-rejection boundary — the + // PR label picker could not be opened in the harness at all, which is why + // nothing had ever checked it. + "pr:labels": Object.values(L).map((l) => ({ ...l, description: null })), + "issue:milestones": [ + { number: 5, title: "Desktop 1.6 — the redesign", state: "open", openIssues: 6, closedIssues: 3 }, + { number: 4, title: "Extension 1.12", state: "open", openIssues: 2, closedIssues: 1 }, + ], + "pr:list": prs, + "pr:reviewers": [u(me), u("mira-holt"), u("s-ohta"), u("dkovachev"), u("jparks")], + "pr:branches": branches.map((b) => ({ name: b.name, isDefault: b.name === "main" })), + "actions:runs": runs, + "actions:workflows": workflows, + "release:list": releases, + "release:tags": [ { name: "ext-v1.11.1", sha: "e5f6a7b" }, { name: "desktop-v1.5.1", sha: "d4e5f6a" } ], + "orgs:list": orgs, + "gist:list": gists, + "project:list": projects, + "git:identity": { name: "Anton Arnaudov", email: "anton@gitstudio.dev" }, + "github:myWork": [ + { kind: "review-requested", type: "pr", number: 104, title: "actions: stream job logs over IPC with backpressure", state: "open", draft: false, updatedAt: ISO(2), comments: 2, author: "s-ohta" }, + { kind: "review-requested", type: "pr", number: 103, title: "engine: per-line staging groundwork (hunk splitting)", state: "open", draft: true, updatedAt: ISO(6), comments: 11, author: "dkovachev" }, + { kind: "assigned", type: "issue", number: 31, title: "Split views make Issues and PRs unreadable on a 13\" screen", state: "open", draft: false, updatedAt: ISO(2), comments: 6, author: "mira-holt" }, + { kind: "assigned", type: "issue", number: 28, title: "Rebase drag-to-reorder flickers when dropping on the last row", state: "open", draft: false, updatedAt: ISO(28), comments: 1, author: me }, + { kind: "my-prs", type: "pr", number: 106, title: "desktop: full-page detail views for Issues (kills the split pane)", state: "open", draft: false, updatedAt: ISO(1), comments: 4, author: me }, + { kind: "mentions", type: "issue", number: 27, title: "Support per-line staging, not just blocks", state: "open", draft: false, updatedAt: ISO(40), comments: 9, author: "dkovachev" }, + ], + "app:info": { version: "1.5.1", platform: "darwin" }, + "github:userInfo": { login: "antonarnaudov", name: "Anton Arnaudov", avatarUrl: null, bio: "Building GitStudio — the open-source Git workspace.", company: "@GitStudioHQ", location: "Sofia, Bulgaria", blog: "gistudio.dev", htmlUrl: "https://github.com/antonarnaudov", followers: 412, following: 63, publicRepos: 24, createdAt: ISO(3000), type: "User", twitter: "antonarnaudov", email: null }, + // The graph is the app's centrepiece and was unreviewable with an empty + // fixture. This is a realistic small history: a merged feature branch, a + // second lane still open, ref chips on the tips, and a tagged release. + "graph:load": (() => { + const seg = (from, to, color) => ({ fromColumn: from, toColumn: to, color }); + const ref = (name, kind) => ({ name, kind }); + const row = (o) => ({ + sha: o.sha, + shortSha: o.sha.slice(0, 7), + column: o.column || 0, + color: o.color || 0, + isMerge: !!o.isMerge, + segments: o.segments || [seg(o.column || 0, o.column || 0, o.color || 0)], + subject: o.subject, + author: o.author || "Anton Arnaudov", + authorEmail: "anton@gitstudio.dev", + authorDate: Math.floor(Date.now() / 1000) - (o.h || 1) * 3600, + refs: o.refs || [], + }); + const rows = [ + row({ sha: "9f8e7d6c5b4a39281706", subject: "release: extension 1.11.1", h: 1, + refs: [ref("main", "currentHead"), ref("origin/main", "remoteHead"), ref("ext-v1.11.1", "tag")] }), + row({ sha: "a1b2c3d4e5f60718293a", subject: "Merge pull request #106 from redesign/issues-detail", h: 3, + isMerge: true, segments: [seg(0, 0, 0), seg(1, 0, 1)] }), + row({ sha: "b2c3d4e5f6a71829304b", subject: "issues: full-page detail as a routed state", h: 5, + column: 1, color: 1, segments: [seg(0, 0, 0), seg(1, 1, 1)], + refs: [ref("redesign/issues-detail", "head")] }), + row({ sha: "c3d4e5f6a7b829304c5d", subject: "common: sectionList + secRow primitives", h: 8, + column: 1, color: 1, segments: [seg(0, 0, 0), seg(1, 1, 1)], author: "Mira Holt" }), + row({ sha: "d4e5f6a7b8c930415d6e", subject: "actions: stream job logs with backpressure", h: 26, + segments: [seg(0, 0, 0), seg(1, 1, 1)], author: "S. Ohta" }), + row({ sha: "e5f6a7b8c9d041526e7f", subject: "engine: hunk splitting groundwork", h: 30, + segments: [seg(0, 0, 0), seg(1, 1, 1)], author: "D. Kovachev" }), + row({ sha: "f6a7b8c9d0e152637f80", subject: "release: extension 1.11.0, desktop 1.5.1", h: 48, + refs: [ref("desktop-v1.5.1", "tag")] }), + row({ sha: "07b8c9d0e1f263748091", subject: "feat(ext): drag a commit in the graph to reorder it", h: 52 }), + row({ sha: "18c9d0e1f2736485a1b2", subject: "feat(git-service): let a rebase carry other branches with it", h: 70, author: "Mira Holt" }), + row({ sha: "29d0e1f2837495b2c3d4", subject: "fix(graph): avatars blur on non-retina displays", h: 96, author: "J. Parks" }), + ]; + return { rows, head: "9f8e7d6c5b4a39281706", totalColumns: 2, hasMore: false, nextSkip: rows.length }; + })(), + "repo:headCommit": { sha: "9f8e7d6", shortSha: "9f8e7d", author: "Anton Arnaudov", authorEmail: "anton@gitstudio.dev", date: S(40), subject: "release: extension 1.11.1", message: "release: extension 1.11.1", total: 512 }, + "repo:tree": [], + "ssh:keys": [], + // (the real fixture is above — an empty array here shadowed it) + }; + + // E1: mutable settings so the Repositories card + destination sheet are + // exercisable in the harness (Change… picks a canned folder). + const settingsState = { cloneDir: null, askWhereEveryTime: params.get("ask") === "1" }; + const SETTINGS_DEFAULT = "/Users/demo/GitStudio"; + const settingsView = () => { + const dir = settingsState.cloneDir || SETTINGS_DEFAULT; + return { + cloneDir: dir, + cloneDirDisplay: dir.startsWith("/Users/demo") ? "~" + dir.slice("/Users/demo".length) : dir, + cloneDirIsDefault: !settingsState.cloneDir, + askWhereEveryTime: settingsState.askWhereEveryTime, + }; + }; + + let localCopies = [ + { + root: "/Users/demo/GitStudio/gitstudio", + name: "gitstudio", + origin: "GitStudioHQ/gitstudio", + managed: true, + recent: true, + current: true, + missing: false, + }, + { + root: "/Users/demo/GitStudio/gistudio.dev", + name: "gistudio.dev", + origin: "GitStudioHQ/gistudio.dev", + managed: true, + recent: true, + current: false, + missing: false, + }, + { + root: "/Users/demo/GitStudio/design", + name: "design", + origin: "GitStudioHQ/design", + managed: true, + recent: false, + current: false, + missing: false, + }, + { + root: "/Users/demo/Code/experiments", + name: "experiments", + origin: "antonarnaudov/experiments", + managed: false, + recent: true, + current: false, + missing: false, + }, + { + root: "/Users/demo/Code/old-prototype", + name: "old-prototype", + managed: false, + recent: true, + current: false, + missing: true, + }, + ]; + + const dynamic = { + // A READ that the fallback used to answer with a mutation shape. Present so + // the AI-gating path is exercised instead of silently failing open. + // ?ai=1 → a CONNECTED model. Without this the Assistant is permanently + // behind its "Connect a model" gate, which means the composer, the quick + // actions, the transcript, the tool steps and the whole streaming path have + // never been reachable from a scene — six real defects lived there through + // four sweeps because nothing could drive them. + // A real PTY id, so `terminal:exit` and `terminal:data` can be aimed at a + // specific shell. Without this, `terminal:create` fell into the mutation + // fallback and answered `{ok:true}` — the panel stored `session.id` as + // undefined, so no push event could ever be matched to it and the whole + // terminal surface was half-driveable at best. + "terminal:create": () => ({ id: `pty-${++ptySeq}`, cols: 80, rows: 24 }), + // Void, fire-and-forget, and called on EVERY route — so with no fixture it + // appeared in the "asked for with no fixture" report of every single check. + // That report's whole value is that it only lists real gaps; one entry on + // every line trains you to skip it. + "terminal:resize": () => undefined, + "appearance:dockIcon": () => undefined, + // A REAL gap: the run page's Artifacts section read undefined and rendered + // whatever that produced, unchecked, for as long as this harness has run. + "actions:artifacts": (runId) => + runId === 9097 + ? [ + { id: 1, name: "desktop-macos-arm64", sizeBytes: 84_213_760, expired: false, createdAt: ISO(1) }, + { id: 2, name: "renderer-coverage", sizeBytes: 1_240_400, expired: false, createdAt: ISO(1) }, + { id: 3, name: "old-build-logs", sizeBytes: 402_100, expired: true, createdAt: ISO(40) }, + ] + : [], + "ai:settings": () => + params.get("ai") + ? { + enabled: true, + connections: [{ id: "c1", label: "Claude (BYOK)", usable: true }], + defaultId: "c1", + agent: { permission: "write", thinking: "medium", modelId: "claude-opus-5" }, + } + : { enabled: false, connections: [], defaultId: null }, + "ai:models": () => + params.get("ai") + ? [ + { id: "claude-opus-5", label: "Claude Opus 5" }, + { id: "claude-sonnet-5", label: "Claude Sonnet 5" }, + ] + : [], + "ai:chatCurrent": () => + params.get("ai") && params.get("chat") + ? { + id: "chat1", + title: "Why did the build break?", + connectionId: "c1", + turns: [ + { role: "user", text: "Why did the build break?" }, + { role: "assistant", text: "The renderer bundle grew past the limit.\n\n```sh\nnpm run build\n```" }, + ], + } + : null, + "ai:chatList": () => + params.get("ai") + ? [ + { id: "chat1", title: "Why did the build break?", updatedAt: Date.now() - 6e5 }, + { id: "chat2", title: "Rename the staging helpers", updatedAt: Date.now() - 9e6 }, + ] + : [], + "ai:chatNew": () => ({ id: "chat-new", title: "New chat", connectionId: "c1", turns: [] }), + "ai:chatGet": ({ id }) => ({ id, title: "Earlier chat", connectionId: "c1", turns: [{ role: "user", text: "hello" }] }), + "ai:chatSetCurrent": () => ({ ok: true }), + "settings:get": () => settingsView(), + "settings:update": (patch) => { + if (patch && patch.cloneDir === null) settingsState.cloneDir = null; + else if (patch && typeof patch.cloneDir === "string") settingsState.cloneDir = patch.cloneDir; + if (patch && typeof patch.askWhereEveryTime === "boolean") settingsState.askWhereEveryTime = patch.askWhereEveryTime; + return settingsView(); + }, + "settings:pickCloneDir": () => { + settingsState.cloneDir = "/Volumes/Work/src"; + return settingsView(); + }, + "clone:pickDir": () => "/Volumes/Work/src", + "orgs:repoDetail": (fullName) => ({ + fullName, + description: "The open-source Git workspace — desktop app + VS Code extension.", + htmlUrl: "https://github.com/" + fullName, + cloneUrl: "https://github.com/" + fullName + ".git", + sshUrl: "git@github.com:" + fullName + ".git", + defaultBranch: "main", + openIssuesCount: 31, + forksCount: 96, + stargazersCount: 2140, + topics: ["git", "electron", "typescript", "vscode-extension"], + license: "MIT", + language: "TypeScript", + private: false, + archived: false, + fork: false, + pushedAt: ISO(1), + createdAt: ISO(900), + homepage: "https://gistudio.dev", + }), + // E4: entity pages — remote tree/file/readme at a ref, branches, paths. + "ghrepo:branches": () => [ + { name: "main", sha: "9f8e7d6", protected: true }, + { name: "redesign/issues-detail", sha: "a1b2c3d", protected: false }, + { name: "fix/log-stream", sha: "b2c3d4e", protected: false }, + ], + "ghrepo:paths": () => ({ + paths: [ + "README.md", + "package.json", + "apps/desktop/src/main/main.ts", + "apps/desktop/src/renderer/renderer.ts", + "apps/desktop/src/renderer/logView.ts", + "apps/desktop/src/renderer/views/explore.ts", + "packages/engine/src/lane.ts", + ], + truncated: false, + total: 7, + }), + "ghrepo:tree": (req) => { + if (!req.path) { + return [ + { name: "apps", path: "apps", type: "dir" }, + { name: "packages", path: "packages", type: "dir" }, + { name: "docs", path: "docs", type: "dir" }, + { name: "README.md", path: "README.md", type: "file", size: 8214 }, + { name: "package.json", path: "package.json", type: "file", size: 1620 }, + ]; + } + if (req.path === "apps") return [{ name: "desktop", path: "apps/desktop", type: "dir" }]; + return [{ name: "index.ts", path: req.path + "/index.ts", type: "file", size: 420 }]; + }, + "ghrepo:file": (req) => ({ + path: req.path, + text: "export function createLogPane(o: LogPaneOpts): LogPane {\n const el = document.createElement(\"div\");\n el.className = \"log-pane\";\n return { el, append, reset, finish };\n}\n", + truncated: false, + binary: false, + size: 420, + }), + "ghrepo:readme": () => ({ + name: "README.md", + text: "# GitStudio\n\nThe open-source Git workspace — a desktop app and a VS Code extension that share one engine.\n\n## Why\n\nBecause a Git client should let you *read* a repository, not just launch a browser.\n\n- Full-page sections, no split panes\n- Everything GitHub does, in the app\n- Works when GitHub doesn't\n", + }), + "users:repos": () => [ + { name: "gitstudio", fullName: "GitStudioHQ/gitstudio", htmlUrl: "", description: "The open-source Git workspace — desktop app + VS Code extension.", private: false, fork: false, archived: false, language: "TypeScript", stargazersCount: 2140, pushedAt: ISO(1) }, + { name: "gistudio.dev", fullName: "GitStudioHQ/gistudio.dev", htmlUrl: "", description: "Marketing site and the error-report collector.", private: false, fork: false, archived: false, language: "TypeScript", stargazersCount: 84, pushedAt: ISO(5) }, + { name: "dotfiles", fullName: "antonarnaudov/dotfiles", htmlUrl: "", description: null, private: false, fork: false, archived: false, language: "Shell", stargazersCount: 3, pushedAt: ISO(200) }, + ], + "users:orgs": () => [ + { login: "GitStudioHQ", name: "GitStudio", avatarUrl: null, description: "The open-source Git workspace.", htmlUrl: "https://github.com/GitStudioHQ" }, + ], + // E3: global search. Returns a canned page shaped by the query so the + // Explore surfaces (results, sort, load-more footer) are exercisable. + "search:repos": (req) => { + const q = (req.query || "").toLowerCase(); + const all = [ + { id: 1, fullName: "GitStudioHQ/gitstudio", owner: "GitStudioHQ", ownerAvatarUrl: null, description: "The open-source Git workspace — desktop app + VS Code extension.", language: "TypeScript", stars: 2140, forks: 96, openIssues: 31, updatedAt: ISO(2), pushedAt: ISO(1), private: false, fork: false, archived: false, topics: ["git", "electron"], license: "MIT", htmlUrl: "https://github.com/GitStudioHQ/gitstudio", defaultBranch: "main" }, + { id: 2, fullName: "libgit2/libgit2", owner: "libgit2", ownerAvatarUrl: null, description: "A cross-platform, linkable library implementation of Git.", language: "C", stars: 9800, forks: 2400, openIssues: 380, updatedAt: ISO(20), pushedAt: ISO(6), private: false, fork: false, archived: false, topics: ["git"], license: "GPL-2.0", htmlUrl: "https://github.com/libgit2/libgit2", defaultBranch: "main" }, + { id: 3, fullName: "desktop/desktop", owner: "desktop", ownerAvatarUrl: null, description: "Focus on what matters instead of fighting with Git.", language: "TypeScript", stars: 12400, forks: 9600, openIssues: 1200, updatedAt: ISO(30), pushedAt: ISO(12), private: false, fork: false, archived: false, topics: ["git", "electron"], license: "MIT", htmlUrl: "https://github.com/desktop/desktop", defaultBranch: "development" }, + { id: 4, fullName: "jesseduffield/lazygit", owner: "jesseduffield", ownerAvatarUrl: null, description: "Simple terminal UI for git commands.", language: "Go", stars: 48200, forks: 1700, openIssues: 420, updatedAt: ISO(9), pushedAt: ISO(3), private: false, fork: false, archived: false, topics: ["git", "tui"], license: "MIT", htmlUrl: "https://github.com/jesseduffield/lazygit", defaultBranch: "master" }, + { id: 5, fullName: "old/archived-git-tool", owner: "old", ownerAvatarUrl: null, description: "No longer maintained.", language: "Ruby", stars: 12, forks: 3, openIssues: 0, updatedAt: ISO(900), pushedAt: ISO(900), private: false, fork: false, archived: true, htmlUrl: "https://github.com/old/archived-git-tool", topics: [], license: null, defaultBranch: "master" }, + ].filter((r) => !q || (r.fullName + " " + (r.description || "")).toLowerCase().includes(q.split(" ")[0])); + return { items: all, totalCount: 1284, incomplete: false, hasMore: true }; + }, + "search:users": (req) => { + const org = req.kind === "orgs"; + const items = org + ? [ + { login: "GitStudioHQ", avatarUrl: null, htmlUrl: "https://github.com/GitStudioHQ", type: "Organization" }, + { login: "github", avatarUrl: null, htmlUrl: "https://github.com/github", type: "Organization" }, + { login: "libgit2", avatarUrl: null, htmlUrl: "https://github.com/libgit2", type: "Organization" }, + ] + : [ + { login: "antonarnaudov", avatarUrl: null, htmlUrl: "https://github.com/antonarnaudov", type: "User" }, + { login: "mira-holt", avatarUrl: null, htmlUrl: "https://github.com/mira-holt", type: "User" }, + { login: "s-ohta", avatarUrl: null, htmlUrl: "https://github.com/s-ohta", type: "User" }, + ]; + return { items, totalCount: items.length, incomplete: false, hasMore: false }; + }, + "search:code": (req) => ({ + items: [ + // Fragments carry the match OFFSETS, exactly as GitHub's + // text-match+json returns them — the row marks those ranges. + { name: "logView.ts", path: "apps/desktop/src/renderer/logView.ts", repoFullName: "GitStudioHQ/gitstudio", htmlUrl: "https://github.com/GitStudioHQ/gitstudio", fragments: [{ text: "export function createLogPane(o: LogPaneOpts): LogPane {", ranges: [[16, 19]] }, { text: " const el = document.createElement(\"div\");", ranges: [[24, 27]] }] }, + { name: "index.ts", path: "src/git/index.ts", repoFullName: "libgit2/libgit2", htmlUrl: "https://github.com/libgit2/libgit2", fragments: [{ text: "int git_repository_open(git_repository **out, const char *path)", ranges: [[4, 7]] }] }, + ], + totalCount: 2, + incomplete: false, + hasMore: false, + }), + // E2: the local-copies manager. Mutable so Remove/Delete are exercisable. + "repos:local": () => localCopies, + "repos:reveal": () => true, + "repos:removeRecent": (root) => { + const hit = localCopies.find((c) => c.root === root); + if (hit) hit.recent = false; + localCopies = localCopies.filter((c) => c.recent || c.managed); + return localCopies; + }, + "repos:trash": (root) => { + localCopies = localCopies.filter((c) => c.root !== root); + return { ok: true, changed: true }; + }, + "issue:detail": (n) => { + const it = issues.find((i) => i.number === n); + if (!it) return undefined; + return { issue: iss(it), comments: issueComments[n] || [], assignees: it.assignees.map((a) => a.login) }; + }, + "pr:detail": (n) => { + const pr = prs.find((p) => p.number === n); + if (!pr) return undefined; + return { pr, files: prFiles[n] || [], checks: n === 106 ? "pending" : "success" }; + }, + "pr:conversation": (n) => prConversation[n] || [], + "pr:files": (n) => prFiles[n] || [], + "pr:checks": (n) => prChecks[n] || [], + "pr:commits": (n) => prCommits[n] || [], + // Per-run job sets. Every run used to return the same two jobs, so the + // success path, the failure path and a many-job matrix were all + // unreviewable — the fixture answered every question the same way. + "actions:runDetail": (id) => { + const run = runs.find((r) => r.id === id) || runs[0]; + const step = (name, n, concl, from, to) => ({ + name, number: n, status: concl === "in_progress" ? "in_progress" : "completed", + conclusion: concl === "in_progress" ? "" : concl, + startedAt: ISO(from), completedAt: concl === "in_progress" ? "" : ISO(to), + }); + const job = (o) => ({ + id: o.id, runId: id, runAttempt: run.runAttempt || 1, name: o.name, + status: o.status, conclusion: o.conclusion, htmlUrl: "", + createdAt: ISO(0.42), startedAt: ISO(o.from), completedAt: o.to ? ISO(o.to) : "", + runnerName: o.runner ?? "GitHub Actions 8", runnerGroupName: "Default", + labels: o.labels, workflowName: run.name, headBranch: run.branch, steps: o.steps, + }); + // A FAILED run: one job green, one red with a failing step. + if (run.conclusion === "failure") { + return { run, jobs: [ + job({ id: 21, name: "lint", status: "completed", conclusion: "success", from: 0.4, to: 0.36, + labels: ["ubuntu-latest"], steps: [ + step("Checkout", 1, "success", 0.4, 0.397), + step("npm ci", 2, "success", 0.397, 0.37), + step("eslint", 3, "success", 0.37, 0.36), + ] }), + job({ id: 22, name: "test (ubuntu-latest)", status: "completed", conclusion: "failure", from: 0.4, to: 0.2, + labels: ["ubuntu-latest"], steps: [ + step("Checkout", 1, "success", 0.4, 0.397), + step("npm ci", 2, "success", 0.397, 0.33), + step("Renderer tests", 3, "failure", 0.33, 0.2), + step("Upload artifacts", 4, "skipped", 0.2, 0.2), + ] }), + ] }; + } + // A SCHEDULED run: a single job, all green — the quiet happy path. + if (run.event === "schedule") { + return { run, jobs: [ + job({ id: 31, name: "nightly", status: "completed", conclusion: "success", from: 0.5, to: 0.2, + labels: ["ubuntu-latest"], runner: "GitHub Actions 3", steps: [ + step("Checkout", 1, "success", 0.5, 0.497), + step("Build", 2, "success", 0.497, 0.31), + step("Notarize", 3, "success", 0.31, 0.2), + ] }), + ] }; + } + return { run, jobs: [ + { id: 1, runId: id, runAttempt: 1, name: "build (macos-latest)", status: "completed", conclusion: "success", htmlUrl: "", createdAt: ISO(0.42), startedAt: ISO(0.4), completedAt: ISO(0.2), runnerName: "GitHub Actions 8", runnerGroupName: "Default", labels: ["macos-latest"], workflowName: "Desktop CI", headBranch: "main", steps: [ + { name: "Checkout", status: "completed", conclusion: "success", number: 1, startedAt: ISO(0.4), completedAt: ISO(0.395) }, + { name: "npm ci", status: "completed", conclusion: "success", number: 2, startedAt: ISO(0.395), completedAt: ISO(0.33) }, + { name: "Build bundles", status: "completed", conclusion: "success", number: 3, startedAt: ISO(0.33), completedAt: ISO(0.25) }, + { name: "Renderer tests", status: "completed", conclusion: "success", number: 4, startedAt: ISO(0.25), completedAt: ISO(0.2) }, + ] }, + { id: 2, runId: id, runAttempt: 1, name: "build (windows-latest)", status: "in_progress", conclusion: "", htmlUrl: "", createdAt: ISO(0.42), startedAt: ISO(0.3), completedAt: "", runnerName: "", runnerGroupName: "", labels: ["windows-latest"], workflowName: "Desktop CI", headBranch: "main", steps: [ + { name: "Checkout", status: "completed", conclusion: "success", number: 1, startedAt: ISO(0.3), completedAt: ISO(0.29) }, + { name: "npm ci", status: "in_progress", conclusion: "", number: 2, startedAt: ISO(0.29), completedAt: "" }, + ] }, + // QUEUED — no runner has picked it up. A third state the fixture had + // none of, and one the log pane must not describe as either finished or + // producing: Follow has nothing to follow YET, which is a different + // sentence from having nothing left to follow. + { id: 3, runId: id, runAttempt: 1, name: "build (ubuntu-latest)", status: "queued", conclusion: "", htmlUrl: "", createdAt: ISO(0.42), startedAt: "", completedAt: "", runnerName: "", runnerGroupName: "", labels: ["ubuntu-latest"], workflowName: "Desktop CI", headBranch: "main", steps: [] }, + ], + }; + }, + // The OAuth Device Flow. Absent, so "Sign in with GitHub" — and the + // "Switch account" that now starts it — could only ever render "Couldn't + // start sign-in", and the whole flow was untestable. + "github:deviceStart": () => ({ + ok: true, + userCode: "WDJB-MJHT", + verificationUri: "https://github.com/login/device", + verificationUriComplete: "https://github.com/login/device?user_code=WDJB-MJHT", + deviceCode: "fixture-device-code", + interval: 5, + expiresIn: 900, + }), + "github:devicePoll": () => ({ state: "pending" }), + "orgs:repos": () => orgRepos, + "orgs:teams": () => [ { name: "Core", slug: "core", description: "Maintainers", privacy: "closed", htmlUrl: "" } ], + "orgs:members": () => [u(me), u("mira-holt"), u("s-ohta"), u("dkovachev"), u("jparks")].map((p) => ({ ...p, htmlUrl: "" })), + "project:board": () => board, + // HONOURS maxCount, as `refLog` does (it clamps to 1..100). Ignoring it hid + // the fact that a stash's page asked for the whole ancestry of stash@{0} — + // git's internal "index on …" commit included — under a heading reading + // "The commit it holds", singular. + "ref:log": (req) => { + const all = [ + { sha: "a1", shortSha: "a1b2c3d", subject: "issues: full-page detail as a routed state", author: "Anton Arnaudov", date: S(1) }, + { sha: "b2", shortSha: "b2c3d4e", subject: "common: sectionList + detailShell primitives", author: "Anton Arnaudov", date: S(3) }, + { sha: "c3", shortSha: "c3d4e5f", subject: "css: list + detail tokens", author: "Mira Holt", date: S(6) }, + ]; + return all.slice(0, Math.min(Math.max((req && req.maxCount) || 25, 1), 100)); + }, + "gist:detail": (id) => gists.find((g) => g.id === id), + "release:detail": (id) => releases.find((r) => r.id === id), + // GitHub's own changelog, as the composer's "Generate release notes" asks + // for it. Echoes the tag so a check can prove the answer landed in the + // editor rather than some other text happening to be there. + // Creating a release answers with the new release's ID, so the composer + // can land ON it rather than on a list of every release. + "release:create": () => ({ ok: true, changed: true, id: 53 }), + "release:generateNotes": (req) => ({ + name: `Release ${req.tagName}`, + body: `## What's Changed\n* Reorder commits by dragging in the graph by @antonarnaudov in #18\n* Carry other branches through a rebase by @mira-holt in #21\n\n**Full Changelog**: https://github.com/GitStudioHQ/gitstudio/compare/ext-v1.11.1...${req.tagName}`, + }), + "pr:reviewThreads": () => [ + { id: "t1", path: "apps/desktop/src/renderer/views/issues.ts", line: 42, isResolved: false, isOutdated: false, + comments: [ + { id: "c1", author: u("mira-holt"), body: "Could this reuse `secRow` from common.ts instead of building the row by hand?", createdAt: ISO(1.4) }, + { id: "c2", author: u(me), body: "Good catch — switched to `secRow` in the next push.", createdAt: ISO(1.1) }, + ] }, + { id: "t2", path: "apps/desktop/src/renderer/views/issues.ts", line: 118, isResolved: true, isOutdated: false, + comments: [ { id: "c3", author: u("s-ohta"), body: "This `replaceChildren` runs twice on refresh.", createdAt: ISO(2) } ] }, + ], + "actions:jobLogChunk": (req) => { + const TS = "2026-08-25T10:00:42.1234567Z "; + const lines = []; + // Every job used to return the same failing log, so a green job's log + // ended in "exit code 1" and the success path could not be reviewed. + const failing = req.jobId === 22; + lines.push(TS + "##[group]Run actions/checkout@v4"); + lines.push(TS + "Syncing repository: GitStudioHQ/gitstudio"); + lines.push(TS + "\u001b[36;1mgit version 2.47.0\u001b[0m"); + lines.push(TS + "##[endgroup]"); + lines.push(TS + "##[group]Run npm ci"); + for (let i = 0; i < 40; i++) lines.push(TS + "npm \u001b[2mtiming\u001b[0m package " + i + " fetched in \u001b[32m" + (20 + i) + "ms\u001b[0m"); + lines.push(TS + "npm \u001b[33mWARN\u001b[0m deprecated example@1.0.0"); + lines.push(TS + "##[endgroup]"); + lines.push(TS + "##[group]Build bundles"); + lines.push(TS + "\u001b[1m[build]\u001b[0m started"); + for (let i = 0; i < 60; i++) lines.push(TS + " bundling module " + i + "/60 …"); + lines.push(TS + "\u001b[32m[build] finished\u001b[0m"); + lines.push(TS + "##[endgroup]"); + if (failing) { + lines.push(TS + "##[group]Renderer tests"); + lines.push(TS + " \u001b[31m✗\u001b[0m issues › list renders every row"); + lines.push(TS + " expected 8 rows, got 2"); + lines.push(TS + "##[endgroup]"); + lines.push(TS + "##[error]Process completed with exit code 1."); + } else { + lines.push(TS + "\u001b[32mAll checks passed\u001b[0m"); + lines.push(TS + "Job completed in 11m 24s"); + } + const full = lines.join("\n") + "\n"; + const off = Math.max(0, req.offset || 0); + return { text: full.slice(off), totalLength: full.length, reset: off > full.length, truncated: false }; + }, + // The graph's CHANGES column asks for these lazily, per visible row. With + // no fixture the column header sat over five empty cells — a labelled + // column promising data that never came. + "commit:rowStats": (shas) => + (shas || []).map((sha, i) => ({ + sha, + files: [3, 1, 11, 6, 2, 8, 4, 17, 5, 1][i % 10], + additions: [64, 9, 402, 121, 18, 233, 77, 918, 145, 4][i % 10], + deletions: [12, 0, 96, 340, 3, 41, 512, 77, 22, 1][i % 10], + })), + // Every graph/branch mutation funnels through here (checkout, cherry-pick, + // revert, reset, branch, tag). Without it `commit:action` fell through to + // the missing-channel path and returned undefined, so the caller's + // `result.ok` threw and the click looked inert — which is exactly how the + // branch switcher's checkout hid while it was being tested. + "commit:action": (req) => ({ + ok: true, + changed: true, + message: `${req?.action ?? "action"} ok`, + }), + "pr:fileDiff": (req) => (/\.(png|jpe?g|gif|ico|pdf|zip|dmg|vsix|woff2?)$/i.test(req.path) + ? { + // A binary in a pull request used to come back as the SAME + // placeholder string on both sides, which is two identical texts — + // and the unified view collapses a zero-change diff to a single + // "N hidden lines" band, so it rendered completely empty while the + // side-by-side view showed the placeholder twice. + path: req.path, + leftLabel: "main", + rightLabel: "redesign/issues-detail", + leftText: "", + rightText: "", + conflicted: false, + binary: true, + } + : { + path: req.path, + leftLabel: "main", + rightLabel: "redesign/issues-detail", + leftText: 'const view = ghTwoPane();\nconst listEl = view.listEl;\nconst detail = view.detailEl;\n\nfunction select(it, row) {\n row.classList.add("active");\n showDetail(detail, it.number);\n}\n', + rightText: 'const { view, listEl } = sectionList();\n\nfunction open(it) {\n nav("issues", { number: it.number });\n}\n', + conflicted: false, + }), + }; + + // IssueInfo body normalizer (fixtures store a trimmed shape). + function iss(it) { + return { number: it.number, title: it.title, body: it.body || "", state: it.state, htmlUrl: "https://github.com/GitStudioHQ/gitstudio/issues/" + it.number, user: it.user, createdAt: ISO(it.h), updatedAt: ISO(it.h / 2), comments: it.comments, labels: it.labels, assignees: it.assignees, milestone: it.milestone || null, + closedAt: it.state === "closed" ? ISO(it.h / 3) : null, + closedBy: it.state === "closed" ? u(me) : null, + stateReason: it.stateReason || (it.state === "closed" ? "completed" : null), + authorAssociation: it.assoc || "CONTRIBUTOR", + reactions: it.reactions }; + } + // The list handler must return IssueInfo shapes too. + fixtures["issue:list"] = issues.map(iss); + // ?many=1 → synthesize a capped-size list (300) to exercise the cap notice. + if (params.get("many")) { + const synth = []; + for (let i = 0; i < 300; i++) { + synth.push(iss({ number: 400 + i, title: `Synthetic issue #${400 + i} for pagination testing`, state: "open", user: u("renderbot"), labels: i % 3 ? [L.bug] : [L.ux], assignees: [], comments: i % 7, h: i + 1 })); + } + fixtures["issue:list"] = synth; + } + + // ── Code browser: a real tree, so the file viewer is reachable ───────────── + // + // Without these two the Code view painted "Empty repository" in every run, + // and no check could reach the file viewer at all — which is exactly why its + // Back button spent months bypassing the navigation history unnoticed. + const TREE = { + "": [ + { name: "apps", path: "apps", type: "tree" }, + { name: "packages", path: "packages", type: "tree" }, + { name: "README.md", path: "README.md", type: "blob", size: 4213 }, + { name: "package.json", path: "package.json", type: "blob", size: 1187 }, + { name: "tsconfig.json", path: "tsconfig.json", type: "blob", size: 642 }, + ], + apps: [ + { name: "desktop", path: "apps/desktop", type: "tree" }, + { name: "extension", path: "apps/extension", type: "tree" }, + ], + "apps/desktop": [ + { name: "src", path: "apps/desktop/src", type: "tree" }, + { name: "esbuild.js", path: "apps/desktop/esbuild.js", type: "blob", size: 3902 }, + ], + packages: [ + { name: "git-service", path: "packages/git-service", type: "tree" }, + { name: "webview-ui", path: "packages/webview-ui", type: "tree" }, + ], + }; + const FILES = { + "README.md": "# GitStudio\n\nA Git client that shows you what is about to happen.\n\n## Building\n\n npm install\n npm run build\n", + "package.json": '{\n "name": "gitstudio",\n "private": true,\n "workspaces": ["apps/*", "packages/*"]\n}\n', + "tsconfig.json": '{\n "compilerOptions": {\n "target": "ES2022",\n "strict": true\n }\n}\n', + "apps/desktop/esbuild.js": 'const esbuild = require("esbuild");\n\nesbuild.build({ entryPoints: ["src/main/main.ts"] });\n', + }; + dynamic["repo:tree"] = (req) => TREE[(req && req.path) || ""] || []; + dynamic["repo:file"] = (req) => { + const path = (req && req.path) || ""; + return path in FILES ? { path, text: FILES[path] } : undefined; + }; + + // ── commit:details — so the commit PAGE is reachable at all ────────────── + // + // Three shapes, because the page branches on all three: an ordinary commit + // whose committer differs from its author (a cherry-pick — the case a single + // "author" line hides), a MERGE with two parents, and a sha the repository + // does not have, which is the honest dead-end for a fork's commit. + const commitFiles = (spec) => + spec.map(([status, path, additions, deletions, oldPath]) => ({ + path, + status, + additions, + deletions, + ...(oldPath ? { oldPath } : {}), + })); + const commits = { + // The compare view's own commits, so "open a commit from Compare" is a + // scene that lands on a real page rather than the honest-but-untestable + // "this commit isn't in your clone". + "18c9d0e1": { + kind: "commit", + sha: "18c9d0e1f2736485a1b2c3d4e5f60718293a4b5c", + shortSha: "18c9d0e", + parents: ["29d0e1f2736485a1b2c3d4e5f60718293a4b5c6d"], + author: "mira-holt", + authorEmail: "mira@gitstudio.dev", + authorDate: Math.floor(Date.now() / 1000) - 20 * 3600, + committer: "mira-holt", + committerEmail: "mira@gitstudio.dev", + committerDate: Math.floor(Date.now() / 1000) - 20 * 3600, + subject: "engine: hunk splitting groundwork", + body: "Extracts the split point search so the selection path can reuse it.", + refs: [], + files: commitFiles([ + ["M", "packages/engine/src/hunks.ts", 84, 12], + ["A", "packages/engine/test/hunks.test.ts", 121, 0], + ]), + hasRemote: true, + }, + a1b2c3d4: { + kind: "commit", + sha: "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + shortSha: "a1b2c3d", + parents: ["b2c3d4e5f60718293a4b5c6d7e8f901234567890"], + author: me, + authorEmail: "anton@gitstudio.dev", + authorDate: Math.floor(Date.now() / 1000) - 3 * 3600, + // Committer differs: this was cherry-picked by someone else. + committer: "mira-holt", + committerEmail: "mira@gitstudio.dev", + committerDate: Math.floor(Date.now() / 1000) - 2 * 3600, + subject: "issues: full-page detail as a routed state", + body: + "The split view could not show a body, a timeline and a rail at once on\n" + + "a 13\" screen, so all three were cropped.\n\nCloses #31.", + refs: [{ name: "redesign/wave-2", kind: "currentHead" }], + files: commitFiles([ + ["M", "apps/desktop/src/renderer/views/issues.ts", 402, 260], + ["A", "apps/desktop/src/renderer/views/common.ts", 188, 0], + ["R", "apps/desktop/src/renderer/views/issueDetail.ts", 12, 4, "apps/desktop/src/renderer/issueDetail.ts"], + ["D", "apps/desktop/src/renderer/legacySplit.ts", 0, 231], + ["M", "apps/desktop/src/renderer/styles/app.css", 96, 31], + ["M", "apps/desktop/harness/checks.js", 41, 0], + ["A", "apps/desktop/assets/issue-empty.png", -1, -1], + ]), + hasRemote: true, + }, + b2c3d4e5: { + kind: "commit", + sha: "b2c3d4e5f60718293a4b5c6d7e8f901234567890", + shortSha: "b2c3d4e", + // A MERGE — two parents, so the page's parent chips have to handle plural. + parents: [ + "c3d4e5f60718293a4b5c6d7e8f90123456789012", + "d4e5f60718293a4b5c6d7e8f9012345678901234", + ], + author: me, + authorEmail: "anton@gitstudio.dev", + authorDate: Math.floor(Date.now() / 1000) - 26 * 3600, + committer: me, + committerEmail: "anton@gitstudio.dev", + committerDate: Math.floor(Date.now() / 1000) - 26 * 3600, + subject: "Merge branch 'main' into redesign/wave-2", + body: "", + refs: [ + { name: "main", kind: "head" }, + { name: "origin/main", kind: "remoteHead" }, + { name: "desktop-v1.6.0", kind: "tag" }, + ], + files: commitFiles([["M", "apps/desktop/src/renderer/renderer.ts", 14, 2]]), + hasRemote: true, + }, + }; + // A commit the size of a real merge. The page had only ever been driven + // against seven files, and "it works at seven" says nothing about the column + // width, the scrolling, or the cost of building every row up front. + const bigFiles = []; + const AREAS = ["src/main", "src/renderer/views", "src/renderer/styles", "packages/engine/src", "test"]; + for (let i = 0; i < 420; i++) { + const area = AREAS[i % AREAS.length]; + const st = ["M", "M", "M", "A", "D", "R"][i % 6]; + bigFiles.push({ + path: `apps/desktop/${area}/generated/module-${String(i).padStart(3, "0")}.ts`, + status: st, + additions: st === "D" ? 0 : (i * 7) % 340, + deletions: st === "A" ? 0 : (i * 3) % 180, + ...(st === "R" ? { oldPath: `apps/desktop/${area}/old/module-${i}.ts` } : {}), + }); + } + commits.f00dbabe = { + kind: "commit", + sha: "f00dbabe1234567890abcdef1234567890abcdef", + shortSha: "f00dbab", + parents: ["a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", "b2c3d4e5f60718293a4b5c6d7e8f901234567890"], + author: me, + authorEmail: "anton@gitstudio.dev", + authorDate: Math.floor(Date.now() / 1000) - 7200, + committer: me, + committerEmail: "anton@gitstudio.dev", + committerDate: Math.floor(Date.now() / 1000) - 7200, + subject: "Merge the generated-module migration", + body: "420 files, which is an ordinary size for a codemod or a lockfile bump.", + refs: [], + files: bigFiles, + hasRemote: true, + }; + + dynamic["commit:details"] = (sha) => commits[String(sha).slice(0, 8)]; + // "did this come from the branch I am on, or was it merged in" — the first + // question a reader has about a commit, which the page could not answer. + dynamic["commit:branches"] = (sha) => { + const key = String(sha).slice(0, 8); + if (key === "b2c3d4e5") return { branches: ["main", "redesign/wave-2"], onCurrent: true, current: "main" }; + if (key === "f00dbabe") return { branches: ["redesign/wave-2"], onCurrent: false, current: "main" }; + return { branches: ["redesign/wave-2", "main"], onCurrent: true, current: "redesign/wave-2" }; + }; + + // The diff pane's header must name the file that was ASKED for. A fixed path + // here showed one file's name over another file's diff, which reads as a bug + // in the page rather than in the fixture — and it hid the fact that the + // commit page was requesting the right path all along. + // The CHANGES view's diff — the most-used diff surface in the app, and it had + // no fixture at all, so every scene that clicked a changed file landed on the + // empty state and nothing about it was ever checked. + dynamic["file:diff"] = (req) => { + const path = (req && req.path) || "apps/desktop/src/renderer/views/issues.ts"; + const name = path.split("/").pop() || path; + // Whitespace-ONLY: line 2 is re-indented, line 3 gains trailing spaces. + // With the toggle off both views must show two changed lines; with it on + // both must show none. Any other combination means the split view and the + // unified view are running different rules over the same file. + // The DISCRIMINATING file: its only change is a doubled space INSIDE a + // line. Monaco cannot ignore that — `ignoreTrimWhitespace` reaches only the + // ends of a line — so a split view that hides it is a split view that + // disagrees with the unified view about the same file. This is the fixture + // that fails if the toggle ever sends the engine's "all" mode again. + if (/spacing-inner\.ts$/.test(path)) { + const left = "export function pad(n: number): string {\n return \" \".repeat(n);\n}\n"; + const right = "export function pad(n: number): string {\n return \" \".repeat(n);\n}\n"; + return { + path, + leftLabel: `HEAD ${path}`, + rightLabel: `Working Tree ${path}`, + leftText: left, + rightText: right, + conflicted: false, + indexText: left, + }; + } + if (/spacing\.ts$/.test(path)) { + const left = "export function pad(n: number): string {\n return \" \".repeat(n);\n}\n"; + const right = "export function pad(n: number): string {\n return \" \".repeat(n);\n} \n"; + return { + path, + leftLabel: `HEAD ${path}`, + rightLabel: `Working Tree ${path}`, + leftText: left, + rightText: right, + conflicted: false, + indexText: left, + }; + } + if (/\.(png|jpe?g|gif|ico|pdf|zip|dmg|vsix|woff2?)$/i.test(path)) { + return { + path, + leftLabel: `HEAD ${path}`, + rightLabel: `Working Tree ${path}`, + leftText: "", + rightText: "", + conflicted: false, + binary: true, + }; + } + return { + path, + leftLabel: `HEAD ${path}`, + rightLabel: `Working Tree ${path}`, + leftText: `// ${name}\nexport function render(list) {\n return list.map(row);\n}\n`, + rightText: `// ${name}\nexport function render(list, opts) {\n // keep the selection across a repaint\n return list.map((r) => row(r, opts));\n}\n`, + conflicted: false, + // A working-tree diff carries the INDEX text too — it is the third text + // the staging ticks need to say whether each change is already staged. + indexText: `// ${name}\nexport function render(list) {\n return list.map(row);\n}\n`, + }; + }; + + dynamic["compare:fileDiff"] = (req) => { + const path = (req && req.path) || "packages/engine/src/hunks.ts"; + const name = path.split("/").pop() || path; + // A BINARY file has no text diff. Mounting an editor over two empty strings + // is what "the diff doesn't show" looked like; the panel says so now, and + // this is the fixture that exercises it. + if (/\.(png|jpe?g|gif|ico|pdf|zip|dmg|vsix|woff2?)$/i.test(path)) { + return { + path, + leftLabel: `${(req && req.base) || "main"} ${path}`, + rightLabel: `${(req && req.head) || "HEAD"} ${path}`, + leftText: "", + rightText: "", + conflicted: false, + binary: true, + }; + } + return { + path, + leftLabel: `${(req && req.base) || "main"} ${path}`, + rightLabel: `${(req && req.head) || "HEAD"} ${path}`, + leftText: `// ${name}\nexport function computeHunks(a: string, b: string): Hunk[] {\n return diff(a, b);\n}\n`, + rightText: `// ${name}\nexport function computeHunks(a: string, b: string): Hunk[] {\n // split on a selection boundary (issue #20)\n return diff(a, b).flatMap(splitOnSelection);\n}\n`, + conflicted: false, + }; + }; + + // Auth is a state, not a constant. `github:disconnect` flips it, so a check + // can drive Sign out / Switch account and see what the app does about it. + let connected = params.get("signedout") !== "1"; + // `?unlocked=0` is the state a real launch starts in: the token FILE exists, + // so you are connected, but it has not been decrypted yet (decrypting raises + // the OS keychain prompt), so the login name is not known. The chip used to + // render that as "Sign in". + const nameKnown = params.get("unlocked") !== "0"; + dynamic["github:status"] = () => + connected + ? { + connected: true, + ...(nameKnown ? { login: me } : {}), + repo: { owner: "GitStudioHQ", repo: "gitstudio" }, + } + : { connected: false }; + dynamic["github:disconnect"] = () => { + connected = false; + return { ok: true, changed: true }; + }; + + // Every routeView the app performs, in order. Created HERE so production + // never has it — the renderer only pushes when the array exists. + window.__GS_ROUTES = []; + + const missing = new Set(); + + // ── What the app SENT, and what it sent WITH ──────────────────────────── + // + // This recorded channel NAMES only, so no check could ever assert a payload + // — "did Take theirs ask about the right path", "did the composer send the + // body it was showing", "was this refetched or served from cache". Records + // are `{channel, payload}` now, `calls` counts per channel for the caching + // assertions, and both are exposed for checks to read. + const invoked = []; + const calls = Object.create(null); + window.__GS_INVOKED = invoked; + window.__GS_CALLS = calls; + /** Channel names in order — what the old array was, for a name-only check. */ + window.__gsSent = (re) => + invoked.map((r) => r.channel).filter((c) => (re ? re.test(c) : true)); + + // `?fail=a:b,c:d` makes those channels REJECT. Every error path in the app — + // the errorState-with-Retry branches, the toasts, the empty-vs-failed + // distinction — was unreachable from the harness, which is why several + // surfaces launder a read failure into a confident empty state and no check + // noticed. + const failing = new Set((params.get("fail") || "").split(",").filter(Boolean)); + + /** channel → listeners, for `on()` / `__gsEmit()`. */ + const listeners = {}; + + window.gitstudio = { + invoke(channel, payload) { + invoked.push({ channel, payload }); + calls[channel] = (calls[channel] || 0) + 1; + if (failing.has(channel)) { + return Promise.reject(new Error(`${channel} failed (harness ?fail=)`)); + } + if (channel in dynamic) { + try { return Promise.resolve(dynamic[channel](payload)); } catch (e) { return Promise.reject(e); } + } + if (channel in fixtures) { + // issue:list respects the state filter so Open/Closed/All work. + if (channel === "issue:list" && payload && payload.state && payload.state !== "all") { + return Promise.resolve(fixtures[channel].filter((i) => i.state === payload.state)); + } + return Promise.resolve(fixtures[channel]); + } + missing.add(channel); + console.error("[shim missing]", channel, JSON.stringify(payload)); + // Mutations: pretend success so flows continue; reads: undefined. + // Anchored at the END of the channel name (or before a capitalised word), + // because a plain substring test matched ":set" inside "ai:settings" — a + // READ answered with `{ ok: true }`, which is why aiEnabled() memoised + // `undefined` and re-asked over IPC on every route for months. + if ( + /:(set|create|edit|comment|merge|rerun|cancel|dispatch|markRead|markAllRead|apply|update|upload|delete|approve|review)(?=$|[A-Z])/.test( + channel, + ) || + // The rest of the app's MUTATION verbs. Everything not listed here fell + // through to `undefined`, so the caller's `r.ok` threw and the control + // looked inert — the shim's own note on `commit:action` records that + // this is how the branch switcher's checkout hid while being tested. + // A mutation with no fixture should still let the flow continue; a READ + // with no fixture should not be invented, and still answers undefined. + /:(push|pull|pullFf|fetch|pop|drop|save|stage|abort|continue|skip|checkout|rename|resolve|takeSide|add|remove|open|openPath|close|kill|resize|write|install|download|check|connect|addItem|moveItem|start|test|rebase|markReady|replyThread|requestReviewers|agentRun|agentConfirm|chatSend|chatNew|chatDelete|chatSetCurrent|mcpInstall|devicePoll|deviceStart)(?=$|[A-Z])/.test( + channel, + ) || + // The one mutation whose VERB is the domain rather than the action. + channel === "stage:lines" + ) { + return Promise.resolve({ ok: true, changed: false }); + } + return Promise.resolve(undefined); + }, + // A REAL subscription registry. This returned a no-op unsubscribe and threw + // the listener away, so every push-driven path in the app — streamed agent + // deltas and tool steps, log tails, file-change notices, the unread badge — + // was unreachable from a scene. A probe or check drives them with + // `__gsEmit(channel, payload)`. + on(channel, fn) { + (listeners[channel] || (listeners[channel] = [])).push(fn); + return () => { + const a = listeners[channel] || []; + const i = a.indexOf(fn); + if (i >= 0) a.splice(i, 1); + }; + }, + }; + + /** Deliver a main-process push event to everything listening for it. + * Returns how many listeners saw it, so a probe can tell "nothing happened" + * from "nothing was listening". */ + window.__gsEmit = (channel, payload) => { + const a = (listeners[channel] || []).slice(); + for (const fn of a) { + try { fn(payload); } catch (e) { console.error("[shim emit]", channel, e); } + } + return a.length; + }; + + // ── scene driver ── + const q = (sel) => document.querySelector(sel); + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + async function until(fn, timeout = 8000) { + const t0 = Date.now(); + for (;;) { + const v = fn(); + if (v) return v; + if (Date.now() - t0 > timeout) throw new Error("timeout: " + fn); + await wait(60); + } + } + async function drive() { + // Either screen. With ?norepo=1 the app boots to the WELCOME screen and + // `.screen.repo` never appears, so the driver timed out and every probe + // against that scene came back with no result at all — which is why the + // first thing anyone sees had never been driven here. + await until(() => q(".screen.repo") || q(".welcome-recent") || q(".screen.welcome")); + // Some views are NOT in the app's TABS list — Settings lives in the rail's + // footer — and `prefs.currentView` is validated against TABS, so seeding it + // silently fell back to "changes". The `settings` scene therefore screenshot + // and checked the CHANGES view for as long as this harness has existed, and + // Settings had no coverage at all. Click the rail item when the seed did not + // take, so a scene name always means the view it names. + const rail = q(`[data-view="${view}"]`); + if (rail && rail.getAttribute("aria-current") !== "page" && !rail.classList.contains("active")) { + rail.click(); + await wait(250); + } + for (const step of steps) { + await wait(250); + if (step === "bell") { + const bell = q('[aria-label*="otification"], .topbar-bell, [title*="otification"]'); + if (bell) bell.click(); + } else if (step.startsWith("open")) { + const num = step.slice(4); + const row = await until(() => q(`[data-num="${num}"]`)); + row.click(); + } else if (step.startsWith("click:")) { + const sel = decodeURIComponent(step.slice(6)); + const elx = await until(() => q(sel)); + elx.click(); + } else if (step.startsWith("scroll:")) { + const sel = decodeURIComponent(step.slice(7)); + const target = await until(() => q(sel)); + target.scrollIntoView({ block: "center" }); + } else if (step.startsWith("type:")) { + // Type into the focused input (palette, search fields). + const val = decodeURIComponent(step.slice(5)); + const editable = (n) => n && (n.tagName === "INPUT" || n.tagName === "TEXTAREA"); + const inp = editable(document.activeElement) + ? document.activeElement + : await until(() => + q("input:focus") || q("textarea:focus") || q(".cmdk-card input") || q("input") || q("textarea"), + ); + inp.value = val; + inp.dispatchEvent(new Event("input", { bubbles: true })); + } else if (step.startsWith("text:")) { + // Click the first button/row whose visible text contains the needle, + // PREFERRING one inside the view over one in the navigation rail. + // + // Document order put the rail first, so `text:Commits` inside a pull + // request clicked the rail's Commits item and navigated to the graph — + // a scene that reads as "open the PR's Commits tab" and silently did + // the opposite. Any needle that names both a section and a sub-tab hits + // this: Commits, Files, Checks, Releases, Issues. + const needle = decodeURIComponent(step.slice(5)).toLowerCase(); + const SEL = "button, [role=option], [role=tab], .list-row, .cmdk-row"; + const matches = (root) => + Array.from(root.querySelectorAll(SEL)).find((b) => + (b.textContent || "").toLowerCase().includes(needle), + ); + const hit = await until(() => { + const host = document.querySelector("#view-host, .view-host, main") || document; + return matches(host) || matches(document); + }); + hit.click(); + } else if (step === "palette") { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true, bubbles: true })); + } else if (step === "esc") { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true })); + } else if (step.startsWith("key:")) { + const key = decodeURIComponent(step.slice(4)); + // Dispatch on the FOCUSED element when there is one: a real keypress + // goes to what has focus and bubbles up, which is what handlers on an + // input (Enter-to-search) actually listen for. + const target = document.activeElement && document.activeElement !== document.body + ? document.activeElement + : window; + target.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true })); + } + await wait(350); + } + await wait(600); + // Functional mode: run the named assertion and publish the verdict in the + // title, which is the one channel --dump-dom always carries back. + // Probe mode: evaluate an arbitrary expression against the driven scene and + // publish the result in the title. This is how an investigator inspects a + // surface — geometry, computed styles, aria, focus — without having to add + // a named case to the shared checks file first. + const probe = params.get("probe"); + if (probe) { + let out; + try { + // eslint-disable-next-line no-new-func + out = await new Function(`"use strict"; return (async () => { ${probe} })()`)(); + } catch (e) { + out = { error: String((e && e.stack) || e) }; + } + let text; + try { + text = JSON.stringify(out === undefined ? null : out); + } catch { + text = JSON.stringify(String(out)); + } + document.title = "PROBE " + (text.length > 60000 ? text.slice(0, 60000) + "…" : text); + return; + } + + const checkId = params.get("check"); + if (checkId) { + // A case may be parameterised (?arg=...): one assertion, several shapes. + window.__GS_ARG = params.get("arg") || undefined; + const suite = window.__GS_CHECKS || {}; + const fn = suite[checkId]; + if (!fn) { + document.title = "CHECK " + JSON.stringify({ id: checkId, fails: ["no such check"] }); + return; + } + const fails = []; + try { + // A check may return a promise: some assertions have to CLICK something + // and wait, and several of the views re-render behind an await (a + // ghGate, a fetch), so a synchronous measurement right after a click + // reads the OLD dom and passes for the wrong reason. + await fn(fails); + } catch (e) { + fails.push("threw: " + (e && e.message ? e.message : String(e))); + } + // Report the channels this scene asked for and the shim could not answer. + // NOT as failures — most are legitimately absent — but as a note the + // runner prints once at the end. A read with no fixture returns undefined + // and the caller's `.ok`/`.length` throws, so a check can pass while + // silently exercising a throw instead of the path it was written for. + // That is exactly how the pull request's label picker went unchecked. + document.title = + "CHECK " + JSON.stringify({ id: checkId, fails, miss: [...missing].sort() }); + return; + } + document.title = "SCENE-READY"; + } + window.addEventListener("DOMContentLoaded", () => { drive().catch((e) => console.error("[driver]", e)); }); +})(); diff --git a/apps/desktop/harness/shot.sh b/apps/desktop/harness/shot.sh new file mode 100755 index 0000000..b192ef0 --- /dev/null +++ b/apps/desktop/harness/shot.sh @@ -0,0 +1,30 @@ +#!/bin/sh +# shot.sh <scene> <out.png> [theme] — screenshot one harness scene headlessly. +# +# A scene is "<view>[~step[~step…]]" — steps run after the repo screen mounts: +# open<N> click the list row with data-num="<N>" (open a detail) +# click:<selector> click the first match (URL-encode [ ] = as %5B %5D %3D) +# esc dispatch Escape (detail → list) +# palette open the ⌘K palette +# bell open the notifications popover +# +# A 4th argument is appended to the query string, for the scene switches the +# shim reads directly (e.g. "staging=checkboxes", "many=1", "ask=1"). +# Examples: +# ./shot.sh issues out/issues.png +# ./shot.sh 'issues~open31' out/detail.png light +# ./shot.sh 'prs~open106~click:.gh-subtab%5Bdata-sub%3Dfiles%5D' out/files.png +set -e +HARNESS="$(cd "$(dirname "$0")" && pwd)" +SCENE="${1:-issues}" +OUT="${2:-$HARNESS/out/$SCENE.png}" +THEME="${3:-dark}" +EXTRA="${4:-}" +mkdir -p "$(dirname "$OUT")" +"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ + --headless --disable-gpu --hide-scrollbars \ + --window-size=1600,1000 --force-device-scale-factor=2 \ + --virtual-time-budget=9000 \ + --screenshot="$OUT" \ + "file://${GS_HARNESS_PAGE:-$HARNESS/page}/harness.html?scene=$SCENE&theme=$THEME${EXTRA:+&$EXTRA}" 2>&1 | grep -viE 'devtools|gpu|fontations|dawn|install' || true +echo "wrote $OUT" diff --git a/apps/desktop/src/main/aiBridge.ts b/apps/desktop/src/main/aiBridge.ts index 2bfe3cf..a135310 100644 --- a/apps/desktop/src/main/aiBridge.ts +++ b/apps/desktop/src/main/aiBridge.ts @@ -15,9 +15,8 @@ // the trade-off that replaces it, and migrateLegacyKeys() for the one-way import // of keys written by the old scheme. -import { app, safeStorage } from "electron"; +import { app } from "electron"; import { readFile, writeFile, mkdir, unlink } from "node:fs/promises"; -import { existsSync } from "node:fs"; import { randomUUID } from "node:crypto"; import { join } from "node:path"; import { SecretStore } from "@gitstudio/secret-store/secretStore"; @@ -149,7 +148,10 @@ export class AiBridge { * row, so it runs whenever the settings view renders — it must never decrypt. */ private hasKeyFile(id: string): boolean { - return this.secrets().has(id) || existsSync(this.legacyKeyPath(id)); + // Only the keychain-free store counts: a leftover pre-1.4 keyring blob is + // unreadable by design (see loadKey), so it must not make a connection + // look `usable` when every real request would then fail. + return this.secrets().has(id); } private async loadKey(id: string): Promise<string | undefined> { @@ -157,39 +159,13 @@ export class AiBridge { if (current !== undefined) { return current; } - return this.adoptLegacyKey(id); - } - - /** - * Move a pre-1.4 safeStorage key into the keychain-free store, once. - * - * This is the last code path in the app that can raise an OS password prompt, - * so it is deliberately confined to `loadKey` — reached only when the user - * runs an AI task or opens the model picker, never from a status probe or - * startup. After it succeeds the legacy blob is deleted, so a given key can - * cost at most one prompt, ever. If the user dismisses that prompt the - * decrypt throws and AI just stays unavailable; nothing is lost and the next - * attempt can try again. - */ - private async adoptLegacyKey(id: string): Promise<string | undefined> { - const legacy = this.legacyKeyPath(id); - let buf: Buffer; - try { - buf = await readFile(legacy); - } catch { - return undefined; // no key at all - } - let key: string; - try { - key = safeStorage.isEncryptionAvailable() - ? safeStorage.decryptString(buf) - : buf.toString("utf8"); - } catch { - return undefined; - } - await this.secrets().set(id, key); - await unlink(legacy).catch(() => {}); - return key; + // NO KEYRING, EVER. A pre-1.4 safeStorage blob could only be read through + // the OS keychain, whose ACL is bound to the app's code signature — every + // rebuilt/re-signed binary raised the macOS password prompt again. That + // migration is gone: any leftover blob is deleted unread; re-enter the key + // once in Settings → AI models and it lands in the prompt-free store. + await unlink(this.legacyKeyPath(id)).catch(() => {}); + return undefined; } private async storeKey(id: string, key: string): Promise<void> { @@ -840,7 +816,14 @@ export class AiBridge { } /** A short, human-readable summary of what a tool call will do, for the confirm UI. */ -function summarizeArgs(tool: GitTool, args: Record<string, unknown>): string { +/** The sentence a human reads before approving an agent's write. + * + * Exported for its test: this is the LAST gate before an automated actor does + * something to the repository, and the destructive cases have to say what is + * lost. They once read weaker than the app's own confirm dialogs for the same + * operations — "Reset (hard) to abc123." asked you to approve, in git's + * vocabulary, the destruction of every uncommitted change you had. */ +export function summarizeArgs(tool: GitTool, args: Record<string, unknown>): string { switch (tool.name) { case "git_commit": return `Commit staged changes:\n“${String(args.message ?? "").split("\n")[0]}”`; @@ -854,12 +837,37 @@ function summarizeArgs(tool: GitTool, args: Record<string, unknown>): string { return `Switch to “${String(args.ref ?? "")}”.`; case "git_stash_save": return `Stash working-tree changes${args.message ? ` (“${String(args.message)}”)` : ""}.`; + // The three destructive ones say what the app's OWN confirm dialogs say for + // the same operations. They used to be the weakest text in the app for the + // most dangerous thing in it: "Reset (hard) to abc123." asked you to + // approve, in git's vocabulary, the deletion of every uncommitted change + // you had — while pressing Discard on one file by hand spelled out that it + // could not be undone. The agent's confirm is the LAST gate before an + // automated actor does it, so it should read stronger, not weaker. case "git_discard": - return `Permanently discard changes to: ${asList(args.paths)}`; + return ( + `Discard your changes to: ${asList(args.paths)}\n\n` + + "Their current contents are lost. This can't be undone, and files git " + + "isn't tracking are deleted from disk outright." + ); case "git_delete_branch": - return `Delete branch “${String(args.name ?? "")}”${args.force ? " (force)" : ""}.`; - case "git_reset": - return `Reset (${String(args.mode ?? "")}) to ${String(args.ref ?? "")}.`; + return ( + `Delete branch “${String(args.name ?? "")}”.` + + (args.force + ? "\n\nForced: commits on it that are not merged anywhere else go with it." + : "") + ); + case "git_reset": { + const mode = String(args.mode ?? ""); + const to = String(args.ref ?? ""); + const consequence = + mode === "hard" + ? "\n\nEvery uncommitted change in your working tree is destroyed. This can't be undone." + : mode === "soft" + ? "\n\nThe commits are undone; their changes stay staged." + : "\n\nThe commits are undone; their changes stay in your working tree."; + return `Move this branch to ${to} (${mode || "mixed"} reset).${consequence}`; + } default: return `${tool.title}: ${JSON.stringify(args)}`; } diff --git a/apps/desktop/src/main/appSettings.ts b/apps/desktop/src/main/appSettings.ts new file mode 100644 index 0000000..841e8cd --- /dev/null +++ b/apps/desktop/src/main/appSettings.ts @@ -0,0 +1,86 @@ +// App-wide user settings, persisted as userData/app-settings.json (the same +// tolerant-JSON pattern as errorReporter's store). Electron-free by design — +// every path is injected — so the store unit-tests under plain node. +// +// Today it holds the clone preferences (the "Repositories" Settings card): +// • cloneDir — where one-click opens and clones land by default +// • askWhereEveryTime — force the destination sheet on every clone/open + +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { AppSettingsView } from "../shared/ipc"; + +interface Persisted { + cloneDir?: string; + askWhereEveryTime?: boolean; +} + +export class AppSettings { + private constructor( + private readonly file: string, + private readonly data: Persisted, + private readonly defaultCloneDir: string, + private readonly home: string, + ) {} + + /** + * Load (or initialize) the settings store. `userDataDir` is where the JSON + * lives; `defaultCloneDir`/`home` come from Electron in production and from + * temp dirs in tests. + */ + static async load( + userDataDir: string, + o: { defaultCloneDir: string; home: string }, + ): Promise<AppSettings> { + const file = join(userDataDir, "app-settings.json"); + let data: Persisted = {}; + try { + const raw = JSON.parse(await readFile(file, "utf8")) as unknown; + if (raw && typeof raw === "object") { + const r = raw as Record<string, unknown>; + if (typeof r.cloneDir === "string" && r.cloneDir.trim()) data.cloneDir = r.cloneDir; + if (typeof r.askWhereEveryTime === "boolean") data.askWhereEveryTime = r.askWhereEveryTime; + } + } catch { + data = {}; // missing / unreadable / malformed — start fresh + } + return new AppSettings(file, data, o.defaultCloneDir, o.home); + } + + /** The effective default clone parent — the preference, or the built-in. */ + effectiveCloneDir(): string { + return this.data.cloneDir ?? this.defaultCloneDir; + } + + askWhereEveryTime(): boolean { + return this.data.askWhereEveryTime ?? false; + } + + view(): AppSettingsView { + const dir = this.effectiveCloneDir(); + return { + cloneDir: dir, + cloneDirDisplay: dir.startsWith(this.home) ? `~${dir.slice(this.home.length)}` : dir, + cloneDirIsDefault: this.data.cloneDir === undefined, + askWhereEveryTime: this.askWhereEveryTime(), + }; + } + + /** Apply a patch (null cloneDir resets to the default) and persist. */ + async update(patch: { cloneDir?: string | null; askWhereEveryTime?: boolean }): Promise<AppSettingsView> { + if (patch.cloneDir === null) delete this.data.cloneDir; + else if (typeof patch.cloneDir === "string" && patch.cloneDir.trim()) { + this.data.cloneDir = patch.cloneDir; + } + if (typeof patch.askWhereEveryTime === "boolean") { + this.data.askWhereEveryTime = patch.askWhereEveryTime; + } + try { + await mkdir(join(this.file, ".."), { recursive: true }); + await writeFile(this.file, JSON.stringify(this.data, null, 2), "utf8"); + } catch { + /* best-effort — settings still apply for this session */ + } + return this.view(); + } +} diff --git a/apps/desktop/src/main/autoUpdate.ts b/apps/desktop/src/main/autoUpdate.ts index 18e8d57..6ed45f2 100644 --- a/apps/desktop/src/main/autoUpdate.ts +++ b/apps/desktop/src/main/autoUpdate.ts @@ -1,28 +1,52 @@ -// Update handling. Two mechanisms, because the platforms genuinely differ: +// Update handling: poll → ask → pull → apply, with the user in charge. // -// Windows / Linux(AppImage) — electron-updater downloads in the background -// and installs on quit. It now TELLS the user when an update is staged, -// instead of replacing the app under them with no indication at all. +// The manager polls for a newer app-v* release (on startup and every few +// hours), tells the RENDERER when one exists (update:available), and then +// waits: nothing downloads until the user confirms in-app. After the +// confirmed download it reports update:ready, and `update:install` applies it. // -// macOS — Squirrel.Mac cannot apply an update to an unsigned build, and both -// arch runners emit an identically-named latest-mac.yml, so the release -// deliberately ships no mac feed (see release-desktop.yml). Rather than -// stay silent — which left mac users on an old build forever with no hint -// a newer one existed — ask GitHub what the latest desktop release is and -// post one notification pointing at the download. +// Two apply mechanisms, because the platforms genuinely differ: +// +// Windows / Linux(AppImage) — electron-updater downloads the delta and +// quitAndInstall() restarts straight into the new version. If the user +// declines the restart, it still installs on quit (autoInstallOnAppQuit). +// +// macOS — Squirrel.Mac cannot apply an update to an unsigned build, and the +// release ships no mac feed (see release-desktop.yml). Instead the manager +// downloads the right DMG from the GitHub release into ~/Downloads (with +// progress) and `update:install` opens it — one drag to Applications. // // Everything here is best-effort: no network, no releases, or no // electron-updater must never affect startup. -import { app, Notification, shell } from "electron"; +import { app, shell } from "electron"; +import { createWriteStream } from "node:fs"; +import { rename, unlink } from "node:fs/promises"; +import { join } from "node:path"; +import type { IpcEvents, UpdateCheckResult } from "../shared/ipc"; export interface AutoUpdateOptions { isDev: boolean; + /** Push an event to the renderer (main.ts's `send`). */ + send: <E extends keyof IpcEvents>(event: E, data: IpcEvents[E]) => void; +} + +export interface UpdateManager { + /** Poll now. `userInitiated` responses always carry the full status. */ + check(userInitiated?: boolean): Promise<UpdateCheckResult>; + /** Start the user-confirmed download. */ + download(): Promise<{ ok: boolean; message?: string }>; + /** Apply a ready update: restart into it, or open the downloaded installer. */ + install(): Promise<{ ok: boolean; message?: string }>; } -/** Where a mac user goes to get the new build. */ +/** Where a mac user goes if the in-app download can't find an asset. */ const RELEASES_PAGE = "https://github.com/GitStudioHQ/gitstudio/releases/latest"; const RELEASES_API = "https://api.github.com/repos/GitStudioHQ/gitstudio/releases"; +/** Re-poll cadence while the app stays open. */ +const POLL_MS = 4 * 60 * 60 * 1000; +/** Delay the startup check so it never competes with first paint / repo load. */ +const FIRST_CHECK_DELAY_MS = 20_000; /** Compare dotted numeric versions. > 0 when `a` is newer than `b`. */ export function compareVersions(a: string, b: string): number { @@ -37,101 +61,308 @@ export function compareVersions(a: string, b: string): number { return 0; } +interface RawAsset { + name?: string; + browser_download_url?: string; + size?: number; +} +interface RawRelease { + tag_name?: string; + draft?: boolean; + prerelease?: boolean; + assets?: RawAsset[]; +} + /** The newest non-draft desktop release tag in a GitHub releases payload. */ -export function latestDesktopVersion( - releases: Array<{ tag_name?: string; draft?: boolean; prerelease?: boolean }>, -): string | undefined { +export function latestDesktopVersion(releases: RawRelease[]): string | undefined { + return latestDesktopRelease(releases)?.version; +} + +/** As {@link latestDesktopVersion}, but keeps the release (for its assets). */ +export function latestDesktopRelease( + releases: RawRelease[], +): { version: string; release: RawRelease } | undefined { if (!Array.isArray(releases)) { return undefined; } // The repo also tags the VS Code extension (`ext-v*`) — only `app-v*` is us. - const tags = releases + const desktop = releases .filter((r) => !r.draft && !r.prerelease && typeof r.tag_name === "string") - .map((r) => r.tag_name as string) - .filter((t) => t.startsWith("app-v")) - .map((t) => t.replace(/^app-v/, "")); - if (tags.length === 0) { + .filter((r) => (r.tag_name as string).startsWith("app-v")) + .map((r) => ({ version: (r.tag_name as string).replace(/^app-v/, ""), release: r })); + if (desktop.length === 0) { return undefined; } - return tags.reduce((best, t) => (compareVersions(t, best) > 0 ? t : best)); + return desktop.reduce((best, r) => (compareVersions(r.version, best.version) > 0 ? r : best)); } -function notify(title: string, body: string, onClick?: () => void): void { - try { - if (!Notification.isSupported()) { - return; - } - const n = new Notification({ title, body }); - if (onClick) { - n.on("click", onClick); - } - n.show(); - } catch { - // A notification must never be the reason the app misbehaves. - } +/** Pick the mac installer asset for this machine from a release's asset list. + * electron-builder names them `GitStudio-<version>-<arch>.dmg` (zip sibling). */ +export function pickMacAsset( + assets: RawAsset[], + arch: string, +): { name: string; url: string; size: number } | undefined { + const wantArch = arch === "arm64" ? "arm64" : "x64"; + const candidates = (assets ?? []).filter( + (a): a is Required<Pick<RawAsset, "name" | "browser_download_url">> & RawAsset => + typeof a.name === "string" && typeof a.browser_download_url === "string", + ); + const byExt = (ext: string): (typeof candidates)[number] | undefined => + candidates.find((a) => a.name.endsWith(ext) && a.name.includes(`-${wantArch}`)); + const hit = byExt(".dmg") ?? byExt(".zip"); + return hit ? { name: hit.name, url: hit.browser_download_url, size: hit.size ?? 0 } : undefined; } -async function checkMacUpdate(): Promise<void> { - try { - const res = await fetch(RELEASES_API, { - headers: { Accept: "application/vnd.github+json" }, - signal: AbortSignal.timeout(10_000), - }); - if (!res.ok) { - return; - } - const payload = (await res.json()) as Array<{ - tag_name?: string; - draft?: boolean; - prerelease?: boolean; - }>; - const version = latestDesktopVersion(payload); - if (!version || compareVersions(version, app.getVersion()) <= 0) { - return; - } - notify( - `GitStudio ${version} is available`, - `You're on ${app.getVersion()}. Click to open the download page.`, - () => void shell.openExternal(RELEASES_PAGE), - ); - } catch { - // Offline, rate-limited, or no releases yet — stay quiet. - } -} +export function initAutoUpdate(opts: AutoUpdateOptions): UpdateManager { + const current = app.getVersion(); + const send = opts.send; + + // ── shared state machine ── + let state: "idle" | "available" | "downloading" | "ready" = "idle"; + let availableVersion: string | undefined; + /** Versions already announced this session — background polls stay quiet. */ + const announced = new Set<string>(); + /** mac: the asset chosen at check time; the downloaded installer's path. */ + let macAsset: { name: string; url: string; size: number } | undefined; + let readyPath: string | undefined; -export function initAutoUpdate(opts: AutoUpdateOptions): void { + const disabled: UpdateCheckResult = { + status: "disabled", + current, + message: "Updates are disabled in development builds.", + }; if (opts.isDev) { - return; + return { + check: async () => disabled, + download: async () => ({ ok: false, message: disabled.message }), + install: async () => ({ ok: false, message: disabled.message }), + }; } - if (process.platform === "darwin") { - void checkMacUpdate(); - return; - } + const announce = (version: string, userInitiated: boolean): void => { + if (!userInitiated && announced.has(version)) { + return; + } + announced.add(version); + send("update:available", { version, current }); + }; - // Imported lazily so a missing electron-updater (e.g. a `--dir` smoke build - // that skips optional deps) never crashes startup. - void import("electron-updater") - .then(({ autoUpdater }) => { - // Download in the background and install on quit (autoInstallOnAppQuit - // defaults to true). - autoUpdater.autoDownload = true; - autoUpdater.on("error", () => { - // A repo with no published releases yields a 404 here, which is - // expected until the first `app-v*` tag ships installers. + const sendProgress = (() => { + let lastPct = -1; + return (percent: number): void => { + const p = Math.max(0, Math.min(100, Math.floor(percent))); + if (p !== lastPct) { + lastPct = p; + send("update:progress", { percent: p }); + } + }; + })(); + + // ── macOS: GitHub poll + installer download ── + const macCheck = async (userInitiated: boolean): Promise<UpdateCheckResult> => { + if (state === "downloading") return { status: "downloading", current, version: availableVersion }; + if (state === "ready") return { status: "ready", current, version: availableVersion }; + try { + const res = await fetch(RELEASES_API, { + headers: { Accept: "application/vnd.github+json" }, + signal: AbortSignal.timeout(10_000), }); - autoUpdater.on("update-downloaded", (info: { version?: string }) => { - const v = info?.version ? ` ${info.version}` : ""; - notify( - `GitStudio${v} is ready to install`, - "It will be applied the next time you quit GitStudio.", - ); + if (!res.ok) { + return { status: "error", current, message: `GitHub responded ${res.status}.` }; + } + const latest = latestDesktopRelease((await res.json()) as RawRelease[]); + if (!latest || compareVersions(latest.version, current) <= 0) { + return { status: "uptodate", current }; + } + state = "available"; + availableVersion = latest.version; + macAsset = pickMacAsset(latest.release.assets ?? [], process.arch); + announce(latest.version, userInitiated); + return { status: "available", current, version: latest.version }; + } catch (e) { + return { + status: "error", + current, + message: e instanceof Error ? e.message : "The update check failed.", + }; + } + }; + + const macDownload = async (): Promise<{ ok: boolean; message?: string }> => { + if (state === "ready" && readyPath && availableVersion) { + send("update:ready", { version: availableVersion, kind: "installer", path: readyPath }); + return { ok: true }; + } + if (state !== "available" || !availableVersion) { + return { ok: false, message: "No update is waiting to download." }; + } + if (!macAsset) { + // The release exists but has no matching mac asset (e.g. a partial + // upload) — send the user to the release page rather than dead-ending. + void shell.openExternal(RELEASES_PAGE); + return { ok: false, message: "Couldn't find a macOS download — opened the releases page." }; + } + state = "downloading"; + const dest = join(app.getPath("downloads"), macAsset.name); + const tmp = `${dest}.part`; + try { + const res = await fetch(macAsset.url, { + redirect: "follow", + signal: AbortSignal.timeout(15 * 60_000), }); - autoUpdater.checkForUpdates().catch(() => { - // No release feed yet — stay silent. + if (!res.ok || !res.body) { + throw new Error(`Download failed (${res.status}).`); + } + const total = Number(res.headers.get("content-length")) || macAsset.size || 0; + const file = createWriteStream(tmp); + const reader = res.body.getReader(); + let got = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + got += value.byteLength; + if (total > 0) sendProgress((got / total) * 100); + if (!file.write(Buffer.from(value))) { + await new Promise<void>((r) => file.once("drain", () => r())); + } + } + await new Promise<void>((resolve, reject) => + file.end((err?: Error | null) => (err ? reject(err) : resolve())), + ); + await rename(tmp, dest); + sendProgress(100); + state = "ready"; + readyPath = dest; + send("update:ready", { version: availableVersion, kind: "installer", path: dest }); + return { ok: true }; + } catch (e) { + state = "available"; + void unlink(tmp).catch(() => {}); + return { ok: false, message: e instanceof Error ? e.message : "The download failed." }; + } + }; + + const macInstall = async (): Promise<{ ok: boolean; message?: string }> => { + if (state !== "ready" || !readyPath) { + return { ok: false, message: "No downloaded update to open." }; + } + const err = await shell.openPath(readyPath); + if (err) { + shell.showItemInFolder(readyPath); + return { ok: false, message: err }; + } + return { ok: true }; + }; + + // ── Windows / Linux: electron-updater, gated on confirmation ── + type Updater = typeof import("electron-updater").autoUpdater; + let updater: Updater | undefined; + let updaterWired = false; + const getUpdater = async (): Promise<Updater | undefined> => { + if (updater) return updater; + try { + // Imported lazily so a missing electron-updater (e.g. a `--dir` smoke + // build that skips optional deps) never crashes startup. + updater = (await import("electron-updater")).autoUpdater; + } catch { + return undefined; + } + if (!updaterWired) { + updaterWired = true; + // The whole point: nothing downloads until the user says yes. + updater.autoDownload = false; + updater.autoInstallOnAppQuit = true; + updater.on("error", () => { + // A repo with no published installers yields a 404 here — expected + // until the first app-v* release; surfaced via check() results instead. + }); + updater.on("download-progress", (p: { percent: number }) => sendProgress(p.percent)); + updater.on("update-downloaded", (info: { version?: string }) => { + state = "ready"; + const v = info?.version ?? availableVersion ?? ""; + send("update:ready", { version: v, kind: "restart" }); }); - }) - .catch(() => { - // electron-updater not installed in this build; updates are disabled. - }); + } + return updater; + }; + + const elCheck = async (userInitiated: boolean): Promise<UpdateCheckResult> => { + if (state === "downloading") return { status: "downloading", current, version: availableVersion }; + if (state === "ready") return { status: "ready", current, version: availableVersion }; + const u = await getUpdater(); + if (!u) { + return { status: "disabled", current, message: "Updates aren't available in this build." }; + } + try { + const r = await u.checkForUpdates(); + const version = r?.updateInfo?.version; + if (version && compareVersions(version, current) > 0) { + state = "available"; + availableVersion = version; + announce(version, userInitiated); + return { status: "available", current, version }; + } + return { status: "uptodate", current }; + } catch (e) { + return { + status: "error", + current, + message: e instanceof Error ? e.message : "The update check failed.", + }; + } + }; + + const elDownload = async (): Promise<{ ok: boolean; message?: string }> => { + if (state === "ready" && availableVersion) { + send("update:ready", { version: availableVersion, kind: "restart" }); + return { ok: true }; + } + if (state !== "available") { + return { ok: false, message: "No update is waiting to download." }; + } + const u = await getUpdater(); + if (!u) { + return { ok: false, message: "Updates aren't available in this build." }; + } + state = "downloading"; + try { + await u.downloadUpdate(); + // update-downloaded flips state to ready + notifies. + return { ok: true }; + } catch (e) { + state = "available"; + return { ok: false, message: e instanceof Error ? e.message : "The download failed." }; + } + }; + + const elInstall = async (): Promise<{ ok: boolean; message?: string }> => { + if (state !== "ready") { + return { ok: false, message: "No downloaded update to install." }; + } + const u = await getUpdater(); + if (!u) { + return { ok: false, message: "Updates aren't available in this build." }; + } + // Restart straight into the new version. + setImmediate(() => u.quitAndInstall()); + return { ok: true }; + }; + + const isMac = process.platform === "darwin"; + const manager: UpdateManager = { + check: (userInitiated = false) => (isMac ? macCheck(userInitiated) : elCheck(userInitiated)), + download: () => (isMac ? macDownload() : elDownload()), + install: () => (isMac ? macInstall() : elInstall()), + }; + + // Poll: shortly after startup (never competing with first paint), then on an + // interval for as long as the app stays open. Background polls announce a + // version once; the rest is the user's call. + setTimeout(() => void manager.check(false), FIRST_CHECK_DELAY_MS); + const timer = setInterval(() => { + if (state === "idle" || state === "available") void manager.check(false); + }, POLL_MS); + timer.unref?.(); + + return manager; } diff --git a/apps/desktop/src/main/cloneBridge.ts b/apps/desktop/src/main/cloneBridge.ts index af5088c..de90fd4 100644 --- a/apps/desktop/src/main/cloneBridge.ts +++ b/apps/desktop/src/main/cloneBridge.ts @@ -11,6 +11,10 @@ import { dialog } from "electron"; import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { validateTargetName } from "../shared/cloneName"; + +export { validateTargetName }; import { join } from "node:path"; import type { CloneProgress, CloneRequest, CloneResult, GhRepoBrief } from "../shared/ipc"; import type { GitHubClient } from "./githubClient"; @@ -28,17 +32,19 @@ export function killActiveClones(): void { activeClones.clear(); } -/** Native "choose a folder" dialog; returns the absolute path or undefined. */ -export async function pickCloneDir(): Promise<string | undefined> { +/** Native "choose a folder" dialog; returns the absolute path or undefined. + * `defaultPath` (the configured clone folder) is where it opens. */ +export async function pickCloneDir(defaultPath?: string): Promise<string | undefined> { const r = await dialog.showOpenDialog({ properties: ["openDirectory", "createDirectory"], title: "Choose a folder to clone into", + ...(defaultPath ? { defaultPath } : {}), }); return r.canceled || !r.filePaths[0] ? undefined : r.filePaths[0]; } /** Derive the target folder name from an explicit override or the URL's last segment. */ -function targetName(req: CloneRequest): string { +export function targetName(req: CloneRequest): string { const explicit = req.name?.trim(); if (explicit) return explicit; // Strip a trailing slash, then a trailing ".git", and take the last path segment. @@ -69,11 +75,24 @@ export async function startClone( } const name = targetName(req); if (!name) { - return { ok: false, message: "Couldn't derive a folder name from the URL." }; + return { ok: false, code: "bad-name", message: "Couldn't derive a folder name from the URL." }; + } + const nameProblem = validateTargetName(name); + if (nameProblem) { + return { ok: false, code: "bad-name", message: nameProblem }; } // A target dir starting with "-" would be read by git as an option, not a path. if (name.startsWith("-")) { - return { ok: false, message: "Couldn't derive a safe folder name from the URL." }; + return { ok: false, code: "bad-name", message: "Couldn't derive a safe folder name from the URL." }; + } + // Pre-check the destination so a collision is a clean, coded failure instead + // of git's stderr (which the UI used to have to string-match). + if (existsSync(join(req.parentDir, name))) { + return { + ok: false, + code: "dest-exists", + message: `${join(req.parentDir, name)} already exists — pick another folder name or destination.`, + }; } return new Promise<CloneResult>((resolve) => { diff --git a/apps/desktop/src/main/ghRepoOpen.ts b/apps/desktop/src/main/ghRepoOpen.ts new file mode 100644 index 0000000..e8a6c08 --- /dev/null +++ b/apps/desktop/src/main/ghRepoOpen.ts @@ -0,0 +1,118 @@ +// One-click "open this GitHub repo as a NORMAL repo" — the whole point of a +// native GitHub app. No destination dialogs, no manual clone step: +// +// 1. If a local clone already exists (any recent repo, or the managed +// folder) whose remote matches, open it INSTANTLY. +// 2. Otherwise clone it into the managed folder (~/GitStudio) with streamed +// progress, then open it. +// +// Either way the app flips to the full repo experience — Code, Commits, +// Branches, PRs — exactly as if the user had opened a local folder. + +import { app } from "electron"; +import { execFile } from "node:child_process"; +import { mkdir } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import type { CloneProgress } from "../shared/ipc"; +import { startClone } from "./cloneBridge"; +import { parseGitHubRemote } from "./githubRemote"; +import type { RepoStore } from "./repoStore"; + +/** Where implicit clones live. Fixed and predictable (GitHub Desktop keeps + * ~/Documents/GitHub); users who care about placement use Clone… instead. */ +export function managedReposDir(): string { + return join(app.getPath("home"), "GitStudio"); +} + +/** `git -C root remote get-url origin`, parsed — or undefined. */ +function originOf(root: string): Promise<{ owner: string; repo: string } | undefined> { + return new Promise((resolve) => { + execFile( + "git", + ["-C", root, "remote", "get-url", "origin"], + { timeout: 5_000 }, + (err, stdout) => resolve(err ? undefined : parseGitHubRemote(stdout.trim())), + ); + }); +} + +async function matches(root: string, fullName: string): Promise<boolean> { + const o = await originOf(root); + return !!o && `${o.owner}/${o.repo}`.toLowerCase() === fullName.toLowerCase(); +} + +export interface GhOpenResult { + ok: boolean; + root?: string; + /** True when this open had to clone first (the renderer words its toast). */ + cloned?: boolean; + message?: string; + /** Machine-readable failure mode — the renderer branches on THIS, never on + * message text (the old /already exists/i match was a fragile seam). */ + code?: "collision" | "clone-failed" | "open-failed" | "bad-name"; +} + +export async function openGitHubRepo( + fullName: string, + repos: RepoStore, + onProgress: (p: CloneProgress) => void, + /** Injectable for tests (app.getPath needs a live Electron app). */ + managed: string = managedReposDir(), + /** Per-action destination override (the "Choose location…" sheet). */ + dest?: string, + /** Per-action folder-name override. */ + nameOverride?: string, +): Promise<GhOpenResult> { + const [owner, repo] = fullName.split("/", 2); + if (!owner || !repo) { + return { ok: false, code: "bad-name", message: "That doesn't look like an owner/repo name." }; + } + + // 1. An existing clone wins — recents first (where the user actually works), + // then the managed folder's two naming schemes. + const candidates = [ + ...repos.recentRepos().map((r) => r.root), + join(managed, repo), + join(managed, `${owner}-${repo}`), + ]; + // Probe candidates in PARALLEL (each probe is a git subprocess; a long + // recents list on a slow volume serially stalled the "Preparing…" card), + // then honor the original preference order when picking. + const unique = [...new Set(candidates)].filter((root) => existsSync(root)); + const results = await Promise.all(unique.map((root) => matches(root, fullName))); + const hitRoot = unique.find((_, i) => results[i]); + if (hitRoot) { + const info = await repos.open(hitRoot); + return info + ? { ok: true, root: hitRoot, cloned: false } + : { ok: false, code: "open-failed", message: `Found a clone at ${hitRoot}, but it couldn't be opened.` }; + } + + // 2. No clone anywhere — make one in the chosen destination (an explicit + // override wins; otherwise the configured default folder). + const parent = dest ?? managed; + await mkdir(parent, { recursive: true }); + // Prefer the explicit name, then the plain repo name; fall back to + // owner-repo when a DIFFERENT project already took that folder. + let name = nameOverride?.trim() || repo; + if (!nameOverride && existsSync(join(parent, name))) name = `${owner}-${repo}`; + if (existsSync(join(parent, name))) { + return { + ok: false, + code: "collision", + message: `${join(parent, name)} already exists but isn't this repository — pick another destination or folder name.`, + }; + } + const result = await startClone( + { url: `https://github.com/${fullName}.git`, parentDir: parent, name }, + onProgress, + ); + if (!result.ok || !result.root) { + return { ok: false, code: "clone-failed", message: result.message || "The clone failed." }; + } + const info = await repos.open(result.root); + return info + ? { ok: true, root: result.root, cloned: true } + : { ok: false, code: "open-failed", message: `Cloned to ${result.root}, but it couldn't be opened.` }; +} diff --git a/apps/desktop/src/main/gitBridge.ts b/apps/desktop/src/main/gitBridge.ts index 6000a7a..605cb3b 100644 --- a/apps/desktop/src/main/gitBridge.ts +++ b/apps/desktop/src/main/gitBridge.ts @@ -6,13 +6,16 @@ // graphPanel performs (now factored into @gitstudio/host-bridge/graphWire and // shared by both hosts). -import { readFile, readdir, writeFile, stat } from "node:fs/promises"; -import { join, resolve, sep } from "node:path"; +import { readFile, readdir, writeFile, stat, lstat, readlink, realpath } from "node:fs/promises"; +import { continueRebase, skipRebase, abortRebase } from "@gitstudio/git-service/RebaseRunner"; +import type { RebaseOutcome } from "@gitstudio/git-service/RebaseRunner"; +import { ExpectedError } from "./expectedError"; +import { join, resolve, sep, dirname } from "node:path"; import { homedir } from "node:os"; import { computeGraphLayout } from "@gitstudio/engine/graph/layout"; import type { GraphInputCommit } from "@gitstudio/engine/graph/layout"; import { computeHunks, applySelectedChanges } from "@gitstudio/engine/staging/applyLineChanges"; -import type { LineRange } from "@gitstudio/engine/staging/applyLineChanges"; +import type { LineRange, Hunk } from "@gitstudio/engine/staging/applyLineChanges"; import { buildWireRows } from "@gitstudio/host-bridge/graphWire"; import { commitBlockerMessage } from "@gitstudio/git-service/StagingProvider"; import { stashBlockerMessage } from "@gitstudio/git-service/StashProvider"; @@ -51,6 +54,7 @@ import type { SyncStatus, TreeEntry, WorktreeInfo, + CommitBranches, } from "../shared/ipc"; import type { WireRef } from "@gitstudio/host-bridge/graphProtocol"; import type { CommitFileChange } from "@gitstudio/host-bridge/git"; @@ -73,6 +77,20 @@ export function safeArg(v: unknown): v is string { return typeof v === "string" && v.length > 0 && !v.startsWith("-"); } +/** + * The guard for a value that reaches git as a PATHSPEC, always after `--`. + * + * A leading dash is legal there — git stops reading options at the separator — + * and `safeArg` was refusing it, so a conflicted file named `-fix.patch` (or + * anything in a `--generated/` directory) could not be resolved at all: every + * button the conflict view offered answered "That value isn't a valid git + * reference", about a file the same view had just listed. The real hazards for + * a path are emptiness and a NUL, which no filename can contain. + */ +export function safePath(v: unknown): v is string { + return typeof v === "string" && v.length > 0 && !v.includes("\0"); +} + /** Standard rejection for an unsafe ref/name reaching a mutation. */ const UNSAFE_REF_RESULT: CommitActionResult = { ok: false, @@ -80,6 +98,13 @@ const UNSAFE_REF_RESULT: CommitActionResult = { message: "That value isn't a valid git reference.", }; +/** Standard rejection for an unusable path reaching a mutation. */ +const UNSAFE_PATH_RESULT: CommitActionResult = { + ok: false, + changed: false, + message: "That isn't a usable file path.", +}; + /** * Resolves a renderer-supplied repo-relative path and REFUSES anything that * escapes the repository root ("../../…" or an absolute path). safeArg alone @@ -96,6 +121,42 @@ export function containedPath(root: string, rel: string): string | undefined { return undefined; } +/** + * A git command that exited non-zero did not succeed with no output. + * + * `GitProcess.run` RESOLVES with the exit code (the caller decides), so a read + * whose command failed comes back as `{ stdout: "", code: 128 }` on the success + * path — and a parser handed "" returns an empty list. That empty list then + * rendered as "Working tree clean · No changes to commit" over a working tree + * full of uncommitted work, and as "No branches yet" in a repo full of branches. + * A held `index.lock`, a corrupt `.git/index` or `.git/packed-refs`, wrong + * permissions, or the folder moving out from under the app all produce exactly + * that. It is the single most dangerous sentence this app can print: it reads as + * "your changes are already committed". + * + * The renderer already knows what to do with a failure — Changes has an + * errorState with a Retry, and Commits shows "Couldn't load history" precisely + * because `graph:load` never swallowed. Those paths were unreachable, not + * missing. + */ +function mustSucceed(result: { stdout: string; stderr?: string; code?: number }, what: string): string { + if (result.code !== undefined && result.code !== 0) { + const detail = (result.stderr ?? "").trim().split("\n")[0]; + // ExpectedError, not Error. Git failing here means the REPOSITORY is in a + // state git refuses to read — a corrupt index, a held index.lock, wrong + // permissions, the folder moved — which is a condition the user is in, not + // a defect in this app. As a plain Error every one of those filed a crash + // report, and the status read runs on a watcher: a single stuck lock would + // have produced a report per tick. The renderer is unaffected; it receives + // the same message and shows the same error state with its Retry. + throw new ExpectedError( + detail ? `${what}: ${detail}` : `${what} (git exited ${result.code})`, + ); + } + return result.stdout; +} + + export class GitBridge { /** sha → record, accumulated as the graph pages stream in (for details). */ private records = new Map<string, CommitRecord>(); @@ -271,6 +332,11 @@ export class GitBridge { } try { const refs = await ctx.refs.listRefs(); + // Copy the whole shape through. This mapper used to drop date, subject, + // objectType and symref on the floor — every one of them already parsed + // one layer down — which is why a remote branch or a tag reached the UI + // as a name and a sha with nothing to sort by, nothing to read, and no + // way to tell an annotated tag from a lightweight one. return refs.map((r) => ({ type: r.type, name: r.name, @@ -278,6 +344,11 @@ export class GitBridge { sha: r.sha, isCurrent: r.isCurrent, upstream: r.upstream, + ...(r.gone ? { gone: true } : {}), + ...(r.date ? { date: r.date } : {}), + ...(r.subject ? { subject: r.subject } : {}), + ...(r.objectType ? { objectType: r.objectType } : {}), + ...(r.symref ? { symref: r.symref } : {}), })); } catch { return []; @@ -301,6 +372,34 @@ export class GitBridge { // ── Commit details ───────────────────────────────────────────────────────── + /** + * Which local branches contain this commit. + * + * FULL refnames, never `%(refname:short)`: the short form is the shortest + * UNAMBIGUOUS name, so a branch colliding with a tag comes back as + * `heads/release` — this repo has been bitten by that before, badly enough + * that a rebase wrote a junk branch. + */ + async commitBranches(sha: string): Promise<CommitBranches> { + const ctx = this.ctx(); + if (!ctx || !safeArg(sha)) return { branches: [], onCurrent: false }; + const [contains, head] = await Promise.all([ + ctx.process.run(["branch", "--contains", sha, "--format=%(refname)"]), + ctx.process.run(["symbolic-ref", "--quiet", "--short", "HEAD"]), + ]); + if (contains.code !== 0) return { branches: [], onCurrent: false }; + const branches = contains.stdout + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.startsWith("refs/heads/")) + .map((l) => l.slice("refs/heads/".length)); + const current = head.code === 0 ? head.stdout.trim() || undefined : undefined; + const onCurrent = !!current && branches.includes(current); + // HEAD's own branch first — it is the one the reader is oriented by. + branches.sort((a, b) => (a === current ? -1 : b === current ? 1 : a.localeCompare(b))); + return { branches, onCurrent, ...(current ? { current } : {}) }; + } + async commitDetails(sha: string): Promise<CommitDetailsPayload | undefined> { const ctx = this.ctx(); if (!ctx) { @@ -360,8 +459,13 @@ export class GitBridge { return []; } const out: RowStat[] = []; + // The cap is a runaway guard, not a page size: the graph asks for exactly + // the rows in view plus its overscan, and a tall window at compact row + // height passes 60 easily. Truncating there meant the rows past it were + // never answered — and the client marked them pending regardless, so their + // CHANGES cells stayed blank. Sized above any real viewport. await Promise.all( - shas.slice(0, 60).map(async (sha) => { + shas.slice(0, 250).map(async (sha) => { let record = this.records.get(sha); if (!record) { for await (const c of ctx.log.streamCommits({ @@ -402,10 +506,16 @@ export class GitBridge { ): Promise<ChangedFile[]> { const range = record.parents.length > 0 ? `${record.parents[0]}..${record.sha}` : record.sha; + // -z, always. Without it git C-QUOTES any path outside ASCII — "café.txt" + // arrives as `"caf\303\251.txt"`, quotes and octal escapes included, and + // that string is then what the row shows AND what every later `-- <path>` + // is given, so the file's diff comes back empty. Verified against real git. + // (`core.quotepath=false` fixes the escapes but not a path containing a tab + // or a newline, which -z handles too.) const args = record.parents.length > 0 - ? ["diff", "--name-status", "-M", range] - : ["show", "--name-status", "-M", "--format=", record.sha]; + ? ["diff", "--name-status", "-M", "-z", range] + : ["show", "--name-status", "-M", "-z", "--format=", record.sha]; const result = await ctx.process.run(args); return parseNameStatus(result.stdout); } @@ -417,15 +527,13 @@ export class GitBridge { if (!ctx) { return []; } - try { - const result = await ctx.process.run(["status", "--porcelain=v1", "-z"]); - return parsePorcelainStatus(result.stdout); - } catch { - // A held index.lock, a repo deleted under us, a corrupt index — return an - // empty working tree rather than rejecting into the renderer (which would - // leave the Changes view stuck on its skeleton). - return []; - } + // NOT wrapped in a catch that returns []. The old comment here claimed a + // rejection "would leave the Changes view stuck on its skeleton" — that was + // not true even when it was written: showChangesView catches a rejected + // status and renders "Couldn't read the working tree" with a Retry. What the + // swallow actually did was render a broken repo as a clean one. + const result = await ctx.process.run(["status", "--porcelain=v1", "-z"]); + return parsePorcelainStatus(mustSucceed(result, "Couldn't read the working tree")); } async diffFiles(): Promise<ChangedFile[]> { @@ -448,28 +556,76 @@ export class GitBridge { if (req.sha) { const right = await showAt(ctx, req.sha, rel); const parent = await parentOf(ctx, req.sha); - const left = parent ? await showAt(ctx, parent, rel) : ""; + const left = parent ? await showAt(ctx, parent, rel) : { text: "", absent: true }; return { path: rel, leftLabel: parent ? `${parent.slice(0, 7)} ${rel}` : `(new) ${rel}`, rightLabel: `${req.sha.slice(0, 7)} ${rel}`, - leftText: left, - rightText: right, + leftText: left.text, + rightText: right.text, conflicted: false, + ...diffKind(left, right), }; } // Working-tree diff: is it conflicted? const conflicted = await ctx.conflict.isConflicted(rel).catch(() => false); - const headText = await ctx.staging.headContent(rel).catch(() => ""); - const workingText = await readWorking(ctx, rel); + // Under HEAD's OWN name for it — a staged rename means HEAD has only the + // old path, and an empty left pane renders a rename as a brand-new file. + const headName = await headSideName(ctx, rel).catch(() => rel); + // Through `showAt`, which CLASSIFIES and CAPS — the same reader the commit + // and compare diffs use for their sides. + // + // This was a raw `headContent`: no binary test, and no cap. The working + // side is capped at FILE_CAP_BYTES, so on any file bigger than that the two + // panes were read to different lengths and everything past the cap showed + // up as DELETED LINES — a diff of a file nobody had touched, claiming its + // entire tail had been removed. And a file that is binary in HEAD went to + // the editor as decoded bytes on the left of whatever is on disk now. + const head: { text: string; binary?: boolean; truncated?: boolean; absent?: boolean } = headName + ? await showAt(ctx, "HEAD", headName).catch(() => ({ text: "" })) + : { text: "" }; + const headText = head.text; + // A DELETED file is not a file we failed to read. `readWorking` falls back + // to the index and then HEAD when the path is gone — a fallback + // conflictModel needs and this does not — so a deletion produced a right + // pane identical to the left one: both panes the same, zero change markers, + // the app showing a file as unchanged that is not on disk at all. Ask + // whether it exists rather than inferring it from a failed read. + const abs = containedPath(ctx.root, rel); + // lstat, not stat: a DANGLING symlink exists as a link but `stat` follows it + // and fails, so the file was reported "(deleted)" when it is right there. + const lst = abs ? await lstat(abs).catch(() => undefined) : undefined; + const gone = !abs || !lst; + // A symlink's content is its TARGET. `readFile` follows the link, so the + // right pane showed the pointed-at file's text — and a rename of the link + // rendered as that file's whole contents appearing from nowhere. + const isLink = !!lst?.isSymbolicLink(); + const working = gone + ? { text: "" } + : isLink + ? { text: await readlink(abs!).catch(() => "") } + : await readWorking(ctx, rel); return { path: rel, leftLabel: `HEAD ${rel}`, - rightLabel: `Working Tree ${rel}`, + rightLabel: gone ? `(deleted) ${rel}` : `Working Tree ${rel}`, + ...(gone ? { deleted: true } : {}), + // Which side the file is missing from — the only way to tell an added + // binary from a deleted one, since both sides' text is empty either way. + ...(gone && headName + ? { onlySide: "deleted" as const } + : !headName || head.absent + ? { onlySide: "added" as const } + : {}), leftText: headText, - rightText: workingText, + rightText: working.text, conflicted, + // The Changes view is the most-used diff surface in the app and was the + // ONLY producer that did not classify its reads, so a PNG or a generated + // bundle opened here went to the editor as text. BOTH sides — a cap or a + // binary on the left is exactly as disqualifying as one on the right. + ...diffKind(head, working), // Read alongside HEAD and the working tree so the ticks describe the same // revision as the panes. A conflicted file has no meaningful index entry // to stage against, so it gets no ticks. @@ -518,8 +674,67 @@ export class GitBridge { if (!ctx) { return undefined; } - const workingText = await readWorking(ctx, path); + const work = await readWorking(ctx, path); + const workingText = work.text; const versions = await ctx.conflict.getConflictVersions(path, { workingText }); + // WHICH STAGES the index actually holds. `git ls-files -u` lists one row + // per stage: 1 = the merge base, 2 = "ours", 3 = "theirs". A MODIFY/DELETE + // conflict — one side changed the file, the other removed it — has only + // one of 2 and 3, and the missing one comes back from `getConflictVersions` + // as an empty string. That is indistinguishable from a side that emptied + // the file, so the three-pane editor drew it as an ordinary content merge + // with one blank pane and never said the word "deleted" anywhere. + // `-z` and an exact path comparison, NOT a pathspec — the convention this + // file writes down at length in `conflictTakeSide`. A pathspec is + // glob-capable and environment-steerable, so a filename containing `*` or + // `[` would read another file's stages; and without `-z`, `core.quotePath` + // C-quotes every non-ASCII path while the renderer sends the raw one. + const staged = await ctx.process.run(["ls-files", "-u", "-z"]); + const stages = new Set( + staged.code === 0 + ? staged.stdout + .split("\0") + .map((rec) => /^\d{6} [0-9a-f]+ (\d)\t([\s\S]*)$/.exec(rec)) + .filter((m): m is RegExpExecArray => !!m && m[2] === path) + .map((m) => m[1]) + : [], + ); + // BOTH sides deleted it — git's `DD`. Listed with stage 1 and neither 2 nor + // 3. It fell into the first arm below and was reported as "ours is + // missing", which drew it as a modify/delete and offered a "Take theirs" + // button for a side that has nothing to take — `conflictTakeSide` then + // refuses it, correctly, with a message the panel had already contradicted. + const bothDeleted = stages.size > 0 && !stages.has("2") && !stages.has("3"); + const missingSide = bothDeleted + ? undefined + : stages.size > 0 && !stages.has("2") + ? ("ours" as const) + : stages.size > 0 && !stages.has("3") + ? ("theirs" as const) + : undefined; + // A conflicted BINARY has no line-by-line merge to make. The panel opened + // the three-pane text editor over whatever the bytes decoded to. + // The working copy was CAPPED, so `result` — the text the merge editor + // seeds its result pane with, and the text "Mark resolved" writes back to + // the file — is only the first 512KB of it. Resolving would have truncated + // the file to the cap and staged that as the answer, silently deleting + // everything past it. There is no text merge to be had here. + const truncated = work.truncated === true; + const binary = + work.binary === true || + versions.ours.includes("\0") || + versions.theirs.includes("\0") || + replacementRatio(versions.ours) > 0.3 || + replacementRatio(versions.theirs) > 0.3; + // WHICH operation, because it decides what the two sides MEAN. During a + // rebase git replays your commits onto the upstream, so stage 2 ("ours") is + // the UPSTREAM and stage 3 ("theirs") is the commit of yours being replayed + // — the exact opposite of a merge, and the opposite of what the hardcoded + // labels asserted. Someone taking "your version" out of a rebase conflict + // was discarding their own work and keeping the branch they were rebasing + // onto, with the button, its tooltip and the toast all agreeing it had done + // the other thing. + const op = await this.opState(); return { path, hasBase: versions.hasBase, @@ -527,8 +742,11 @@ export class GitBridge { ours: versions.ours, theirs: versions.theirs, result: workingText, - oursLabel: "Current Change (ours)", - theirsLabel: "Incoming Change (theirs)", + ...(binary ? { binary: true } : {}), + ...(truncated ? { truncated: true } : {}), + ...(missingSide ? { missingSide } : {}), + ...(bothDeleted ? { bothDeleted: true } : {}), + ...sideLabels(op.kind), }; } @@ -548,8 +766,47 @@ export class GitBridge { // ── Working-tree staging + commit (Changes view) ──────────────────────────── + /** + * Stage one path. + * + * The marker guard is NOT only a bulk-action nicety. `git add` on a conflicted + * file is how you tell git the conflict is resolved, so adding one that still + * contains `<<<<<<<` marks it resolved with the markers in it — and the next + * commit carries them into the tree, where they compile as garbage and read, + * in the history, as a deliberate change. `stageAll()` has refused this since + * it was written; per-file Stage did not, and per-file Stage is the button + * people actually press while resolving. + * + * A modify/delete conflict (UD / DU) is deliberately still allowed through: + * it has no markers to find — git leaves one side's file in the tree and asks + * you to choose — and choosing is exactly what pressing Stage on that one + * file means. That is the distinction `stageAll()` draws too, and the reason + * it refuses those in bulk while this permits them singly. + */ async stage(path: string): Promise<CommitActionResult> { - return this.staged(async (ctx) => ctx.staging.stageFile(path)); + return this.staged(async (ctx) => { + // Only when git says the path is UNMERGED. `stageAll` draws the same line + // and for the same reason: a marker check applied to every file refuses + // to stage a perfectly ordinary one that happens to contain + // marker-shaped lines — a merge tool's test fixture, documentation about + // conflicts — and refuses it forever, with no way to override. + // + // On an unmerged path the markers mean what they say, and `git add` there + // is the act of declaring the conflict resolved. + const st = await ctx.process.run(["status", "--porcelain=v1", "-z", "--", path]); + const unmerged = st.code === 0 && parsePorcelainStatus(st.stdout).some((f) => f.conflicted); + if (unmerged && (await this.hasConflictMarkers(ctx, path))) { + return { + ok: false, + changed: false, + expected: true, + message: + `${path} still contains conflict markers. Staging it would mark the conflict ` + + `resolved and commit the markers — resolve them first.`, + }; + } + return ctx.staging.stageFile(path); + }); } async unstage(path: string): Promise<CommitActionResult> { return this.staged(async (ctx) => ctx.staging.unstageFile(path)); @@ -562,16 +819,149 @@ export class GitBridge { // match any file(s) known to git". const st = await ctx.process.run(["status", "--porcelain=v1", "-z", "--", path]); const untracked = st.code === 0 && st.stdout.startsWith("??"); - return untracked - ? ctx.staging.cleanFiles([path]) - : ctx.staging.discardChanges(path); + if (untracked) return ctx.staging.cleanFiles([path]); + // An UNMERGED path is a third case. `git checkout -- <path>` refuses it + // outright — "error: path 'x' is unmerged" — so Discard on a conflicted + // row asked a frightening question and then failed with raw git stderr, + // leaving the reader unsure whether anything had happened. `--merge` + // recreates the conflict from the index, which is what "discard my + // changes to this file" means while a merge is in progress: your edits + // go, the conflict comes back, and you can start it again. + const conflicted = + st.code === 0 && parsePorcelainStatus(st.stdout).some((f) => f.conflicted); + if (conflicted) { + const r = await ctx.process.run(["checkout", "--merge", "--", path]); + return r.code === 0 + ? { ok: true, changed: true } + : { ok: false, changed: false, expected: true, message: r.stderr.trim() }; + } + return ctx.staging.discardChanges(path); }); } + /** + * Stage everything — except a conflict you have not actually resolved. + * + * `git add -A` marks an unmerged path RESOLVED. It does not care whether the + * file still contains `<<<<<<<`. So during a conflicted merge, "Stage all" + * followed by Commit was two clicks that produced a commit with conflict + * markers in the source — and, because staging cleared the unmerged entries, + * the app's conflict count dropped to zero and re-enabled Continue, so nothing + * on screen suggested anything was wrong. + * + * The naive guard — "exclude every unmerged path" — is worse: someone who + * resolved a conflict properly in another editor would find that file could + * never be staged and Continue disabled forever, with nothing explaining why. + * So resolutions are staged and only marker-bearing files are held back, by + * name, so the message says what to go and fix. + */ async stageAll(): Promise<CommitActionResult> { - return this.staged(async (ctx) => ctx.process.run(["add", "-A"])); + return this.staged(async (ctx) => { + const st = await ctx.process.run(["status", "--porcelain=v1", "-z"]); + const unmerged = + st.code === 0 ? parsePorcelainStatus(st.stdout).filter((f) => f.conflicted) : []; + const unresolved: string[] = []; + const needsChoice: string[] = []; + for (const f of unmerged) { + // A modify/delete conflict (UD / DU) never contains markers — git leaves + // one side's file in the tree and asks you to choose keep-or-delete. So + // "no markers" cannot mean "resolved" here, and staging it silently + // picks a side on the user's behalf. Those need a decision, not a bulk + // action, and per-file staging still makes one. + const modifyDelete = f.conflictKind === "UD" || f.conflictKind === "DU"; + if (modifyDelete) { + unresolved.push(f.path); + needsChoice.push(f.path); + continue; + } + // A BINARY conflict cannot contain markers either — and the whole guard + // below is "no markers means somebody resolved it". So the one kind of + // conflict the app itself refuses to open a text merge for was the one + // kind "Stage all" waved straight through, marking it resolved with + // whichever side happened to be in the worktree. It needs a decision + // for the same reason a modify/delete does. + // A BINARY conflict cannot contain markers either — and the whole guard + // here is "no markers means somebody resolved it". So the one kind of + // conflict the app itself refuses to open a text merge for was the one + // kind "Stage all" waved straight through, marking it resolved with + // whichever side happened to be in the worktree. It needs a decision + // for the same reason a modify/delete does. + if ((await readWorking(ctx, f.path)).binary) { + unresolved.push(f.path); + needsChoice.push(f.path); + continue; + } + if (await this.hasConflictMarkers(ctx, f.path)) { + unresolved.push(f.path); + } + } + if (unresolved.length === 0) return ctx.process.run(["add", "-A"]); + // An explicit ALLOW-LIST, not `:!` exclusions: verified against real git, + // `add -A -- . ':!path'` stages the excluded path anyway, so the exclusion + // would have been silent and this guard would have done nothing at all. + const hold = new Set(unresolved); + const allow = [...new Set(parsePorcelainStatus(st.stdout).map((f) => f.path))].filter( + (p) => !hold.has(p), + ); + if (allow.length > 0) { + const add = await ctx.process.run(["add", "-A", "--", ...allow]); + if (add.code !== 0) return add; + } + // Two different reasons to hold a file back, needing two different things + // done to them — say which is which rather than one vague sentence. + const marked = unresolved.filter((p) => !needsChoice.includes(p)); + const list = (paths: string[]): string => { + const head = paths.slice(0, 3).join(", "); + return paths.length > 3 ? `${head} and ${paths.length - 3} more` : head; + }; + const parts: string[] = []; + if (marked.length) { + parts.push( + `${marked.length} still contain${marked.length === 1 ? "s" : ""} conflict markers ` + + `(${list(marked)}) — staging a file with markers in it tells git the conflict is settled`, + ); + } + if (needsChoice.length) { + parts.push( + `${needsChoice.length} ${needsChoice.length === 1 ? "is a" : "are"} modify/delete ` + + `conflict${needsChoice.length === 1 ? "" : "s"} (${list(needsChoice)}) — one side edited ` + + `the file and the other deleted it, so you have to choose keep or delete`, + ); + } + return { + ok: false, + changed: true, + expected: true, + message: `Staged everything else. ${parts.join(". ")}.`, + }; + }); + } + + /** Does this working-tree file still carry `<<<<<<<` conflict markers? */ + private async hasConflictMarkers( + ctx: { root: string }, + path: string, + ): Promise<boolean> { + try { + const buf = await readFile(join(ctx.root, path), "utf8"); + return /^<{7}[ \t]/m.test(buf) && /^>{7}[ \t]/m.test(buf); + } catch { + // Unreadable (deleted by one side, binary, permissions) — not our call to + // make here; let git decide when the user stages it explicitly. + return false; + } } async unstageAll(): Promise<CommitActionResult> { - return this.staged(async (ctx) => ctx.process.run(["reset"])); + // A PATHSPEC, always. `git reset` with no pathspec is not the inverse of + // "stage everything" — it is also `git merge --quit`: it clears MERGE_HEAD + // and ends the merge. So unchecking everything mid-merge silently abandoned + // it, and the next Commit recorded a ONE-PARENT commit carrying the merged + // content, with no second parent and no way to abort. `git merge --abort` + // afterwards answers "There is no merge to abort". + // + // `-- .` rather than a list of paths from `status`: `parsePorcelainStatus` + // reports only the new half of a rename, so resetting the listed paths + // would strand the `D old-name` half of every renamed file staged. + return this.staged(async (ctx) => ctx.process.run(["reset", "-q", "HEAD", "--", "."])); } async commit(req: { message: string; amend?: boolean }): Promise<CommitActionResult> { const ctx = this.ctx(); @@ -581,6 +971,23 @@ export class GitBridge { if (!req.message.trim() && !req.amend) { return { ok: false, changed: false, message: "A commit message is required." }; } + // A plain commit does NOT finish a `git am`, it derails it: the session + // stays open on disk, the remaining patches are never applied, and the + // patch's own author and message are replaced by yours. git's own answer is + // `git am --continue`, which reuses the patch's metadata. Every other + // mid-operation state is left alone — committing IS how you finish a merge, + // and `commit` then `--continue` is a legitimate way through a rebase. + const am = await this.amInProgress(); + if (am) { + return { + ok: false, + changed: false, + expected: true, + message: + "A patch series is part-applied (git am). Use Continue in the banner above — a plain commit " + + "would leave the rest of the series unapplied and put your name on someone else's patch.", + }; + } return this.serialize(async () => { const r = await ctx.staging.commit(req.message, { amend: req.amend }); if (r.ok) { @@ -677,11 +1084,17 @@ export class GitBridge { if (!abs) { return []; } + // Do not OFFER what cannot be done safely. Reading with "utf8" succeeds on a + // PNG — it just mangles it — so the catch below never fired for the case + // that mattered, and the row listed hunks whose staging destroyed the file. + if (!(await lineStageable(ctx, rel)).ok) { + return []; + } try { const text = await readFile(abs, "utf8"); return await listUnstagedHunks(ctx, rel, text); } catch { - return []; // binary, deleted, unreadable — the row simply offers nothing + return []; // deleted or unreadable — the row simply offers nothing } } @@ -696,6 +1109,8 @@ export class GitBridge { } return this.serialize(async () => { try { + const safe = await lineStageable(ctx, req.path); + if (!safe.ok) return { ok: false, changed: false, expected: true, message: safe.why }; const text = await readFile(abs, "utf8"); const r = await stageHunks(ctx, req.path, text, [req.index]); if (!r.ok) { @@ -788,6 +1203,11 @@ export class GitBridge { return this.staged(async (ctx) => ctx.worktrees.add(path, ref, { newBranch })); } async worktreeRemove(opts: { path: string; force?: boolean }): Promise<CommitActionResult> { + // `git worktree remove` builds its argv as ["worktree", "remove", path] + // with no `--`, so a path beginning with "-" would reach git as an option. + // The paths come from git's own worktree list today, but this is the same + // guard every other ref-taking mutation on this bridge already applies. + if (!safeArg(opts.path)) return UNSAFE_REF_RESULT; return this.staged(async (ctx) => ctx.worktrees.remove(opts.path, { force: opts.force })); } @@ -814,8 +1234,13 @@ export class GitBridge { sha: c.sha, shortSha: c.sha.slice(0, 7), subject: c.subject, + // `git log`'s pretty format already parses %b and %P into the record; + // dropping them here is why Compare's commit rows could not show a + // commit's reasoning or mark a merge. + body: c.body, author: c.author, date: c.authorDate, + isMerge: (c.parents?.length ?? 0) > 1, }); } } catch { @@ -826,19 +1251,26 @@ export class GitBridge { // 3-dot (base...head) = "what head introduced since the merge-base"; // 2-dot (base head) = the literal difference between the two tips. const range = threeDot ? [`${base}...${head}`] : [base, head]; - const r = await ctx.process.run(["diff", "--name-status", "-M", ...range]); + const r = await ctx.process.run(["diff", "--name-status", "-M", "-z", ...range]); files = parseNameStatus(r.stdout); } catch { files = []; } const behind = await this.revCount(ctx, `${head}..${base}`); - return { commits, files, ahead: commits.length, behind }; + // `commits.length` is the CAP (400), not the answer. It was reported as + // `ahead` beside `behind`, which is a real count from rev-list — so a + // comparison of 900 commits read "400 ahead · 12 behind", with the lie + // wearing the same authority as the truth. Count it properly and say when + // the list below is only the first page of it. + const ahead = await this.revCount(ctx, `${base}..${head}`); + return { commits, files, ahead, behind, commitsTruncated: ahead > commits.length }; } async compareFileDiff(req: { base: string; head: string; path: string; + leftPath?: string; mode?: CompareMode; }): Promise<FileDiff | undefined> { const ctx = this.ctx(); @@ -859,15 +1291,20 @@ export class GitBridge { leftRef = req.base; } } - const left = await showAt(ctx, leftRef, req.path); + // A RENAME's left side lives under the OLD name. Asking the base for the + // new one returns nothing, and a 12-line edit then renders as a brand-new + // file — the diff "not showing" what actually changed. + const leftPath = req.leftPath && safeArg(req.leftPath) ? req.leftPath : req.path; + const left = await showAt(ctx, leftRef, leftPath); const right = await showAt(ctx, req.head, req.path); return { path: req.path, - leftLabel: `${threeDot ? req.base + " (merge-base)" : req.base} ${req.path}`, + leftLabel: `${threeDot ? req.base + " (merge-base)" : req.base} ${leftPath}`, rightLabel: `${req.head} ${req.path}`, - leftText: left, - rightText: right, + leftText: left.text, + rightText: right.text, conflicted: false, + ...diffKind(left, right), }; } @@ -995,7 +1432,9 @@ export class GitBridge { if (r.stdout.includes("\0") || replacementRatio(r.stdout) > 0.3) { return { path: rel, text: "", binary: true }; } - if (r.stdout.length > FILE_CAP_BYTES) { + // Bytes, not UTF-16 code units — see `showAt` for why the distinction + // matters when two sides of one diff are capped by different rulers. + if (Buffer.byteLength(r.stdout, "utf8") > FILE_CAP_BYTES) { return { path: rel, text: "", truncated: true }; } return { path: rel, text: r.stdout }; @@ -1035,12 +1474,41 @@ export class GitBridge { if ((name && name.startsWith("-")) || (email && email.startsWith("-"))) { return { ok: false, changed: false, message: "Name and email can't start with “-”." }; } + // An identity is a PAIR. git refuses to commit without both + // ("Please tell me who you are"), so a half-filled card is not a saveable + // state — and the code below only wrote the fields that were non-empty, so + // clearing one and pressing Save reported "Identity updated" while leaving + // the old value in ~/.gitconfig, untouched and unmentioned. + if (!name && !email) { + return { ok: false, changed: false, message: "Enter a name and an email to save." }; + } + if (!name || !email) { + return { + ok: false, + changed: false, + message: `Git needs both a name and an email to record a commit. ${ + name ? "Add an email" : "Add a name" + } to save, or leave the card as it is — nothing has been changed.`, + }; + } try { - if (name) { - await ctx.process.run(["config", "--global", "user.name", name]); - } - if (email) { - await ctx.process.run(["config", "--global", "user.email", email]); + const writes: Array<[string, string]> = [ + ["user.name", name], + ["user.email", email], + ]; + for (const [key, value] of writes) { + const r = await ctx.process.run(["config", "--global", key, value]); + // `git config` exits non-zero WITHOUT throwing (run() resolves with the + // code) — e.g. a read-only or locked ~/.gitconfig, or a broken include. + // This used to fall through to "updated ✓" while writing nothing. + if (r.code !== 0) { + return { + ok: false, + changed: false, + message: + r.stderr.trim() || `git config --global ${key} failed (exit ${r.code}).`, + }; + } } return { ok: true, changed: true }; } catch (err) { @@ -1184,6 +1652,93 @@ export class GitBridge { // ── Branch management ─────────────────────────────────────────────────────── + /** + * The repository's default branch, as cheaply as it can be known. + * + * `refs/remotes/origin/HEAD` is a symbolic ref pointing at it, set by clone. + * When it is absent (a repo initialised locally, or a clone whose origin/HEAD + * was never fetched), fall back to the checked-out branch — which is wrong + * only in the case where nothing better exists anyway. + */ + private async defaultBranch(ctx: GitContext): Promise<string | undefined> { + try { + // NOT origin's only. `git clone -o upstream`, a `git remote rename`, or a + // fork clone leaves the default branch's pointer under another remote — + // and this value decides `merged`, which decides what the branch list + // offers to delete. Asking only origin made every ancestor of HEAD read + // as merged (the fallback below is the CURRENT branch), so a bulk delete + // was measured against a ref nobody chose. + const US = "\x1f"; + const r = await ctx.process.run([ + "for-each-ref", + `--format=%(refname:short)${US}%(symref:short)`, + "refs/remotes/*/HEAD", + ]); + if (r.code === 0) { + const rows = r.stdout + .split("\n") + .map((l) => l.split(US).map((x) => x.trim())) + .filter(([n, s]) => n && s); + // Prefer origin when it is there; take whatever exists otherwise. + const [remote, symref] = rows.find(([n]) => n === "origin") ?? rows[0] ?? []; + if (remote && symref) { + const prefix = `${remote}/`; + return symref.startsWith(prefix) ? symref.slice(prefix.length) : symref; + } + } + } catch { + /* fall through */ + } + try { + const h = await ctx.refs.getHead(); + return h.detached ? undefined : h.branch; + } catch { + return undefined; + } + } + + /** + * How far each local branch is ahead of and behind `base`. + * + * Asked for in its own `for-each-ref` because `%(ahead-behind:)` needs git + * >= 2.41: an older git does not recognise the atom and fails the WHOLE read, + * which would take the branch list down with it. Here a failure is just an + * empty map, and the divergence bar does not render. + */ + private async divergenceFrom( + ctx: GitContext, + base: string, + ): Promise<Map<string, { ahead: number; behind: number }>> { + const out = new Map<string, { ahead: number; behind: number }>(); + if (!safeArg(base)) return out; + try { + // Named US (unit separator), not the name the NUL-separated reader one + // screen up uses: this file has both, a format argument interpolating the + // shared name cannot be read as safe at a glance, and a NUL in argv makes + // spawn THROW — a throw this function's catch would swallow whole. The + // repo's scan flags that shape by name, and it is right to. + const US = "\x1f"; + const r = await ctx.process.run([ + "for-each-ref", + `--format=%(refname:short)${US}%(ahead-behind:${base})`, + "refs/heads", + ]); + if (r.code !== 0) return out; + for (const line of r.stdout.split("\n")) { + if (!line.trim()) continue; + const [name, pair] = line.split(US); + // git prints "<ahead> <behind>"; on an unsupported git the atom comes + // back as the literal format string, which parses to NaN and is + // dropped here rather than rendering as a bar of zero. + const [a, b] = (pair ?? "").trim().split(/\s+/).map(Number); + if (Number.isFinite(a) && Number.isFinite(b)) out.set(name, { ahead: a, behind: b }); + } + } catch { + /* an older git, or a bad base — no bar, no error */ + } + return out; + } + /** One `for-each-ref` gives every local branch with upstream + ahead/behind. */ async branchesList(): Promise<BranchInfo[]> { const ctx = this.ctx(); @@ -1191,32 +1746,45 @@ export class GitBridge { return []; } const SEP = "\x1f"; + // Divergence from the DEFAULT branch, not just from the upstream. It + // answers a different and more useful question — "how far is this from + // main" — and `ahead === 0` against the default IS the definition of + // merged, so one field buys the bar, the Merged state and "what is safe to + // delete" at once. + // + // `%(ahead-behind:)` needs git >= 2.41. On an older git the atom is not + // recognised and for-each-ref FAILS the whole read rather than returning a + // blank column — which would take the branch list down with it — so it is + // asked for separately and the result is optional. + const base = await this.defaultBranch(ctx); const fmt = `%(refname:short)${SEP}%(HEAD)${SEP}%(upstream:short)${SEP}` + `%(upstream:track)${SEP}%(committerdate:unix)${SEP}%(contents:subject)`; - let out = ""; - try { - const r = await ctx.process.run([ - "for-each-ref", - `--format=${fmt}`, - "--sort=-committerdate", - "refs/heads", - ]); - out = r.stdout; - } catch { - return []; - } + // No catch-and-return-[]: `for-each-ref` exits 0 with no output in a repo + // that genuinely has no branches, so a non-zero exit means the read FAILED + // and "No branches yet" would be a lie about a repo full of them. + const r = await ctx.process.run([ + "for-each-ref", + `--format=${fmt}`, + "--sort=-committerdate", + "refs/heads", + ]); + const out = mustSucceed(r, "Couldn't list branches"); + const divergence = base ? await this.divergenceFrom(ctx, base) : new Map(); const branches: BranchInfo[] = []; for (const line of out.split("\n")) { if (!line.trim()) continue; const [name, head, upstream, track, date, subject] = line.split(SEP); - const { ahead, behind } = parseTrack(track ?? ""); + const { ahead, behind, gone } = parseTrack(track ?? ""); + const vs = base ? divergence.get(name) : undefined; branches.push({ + ...(vs ? { aheadDefault: vs.ahead, behindDefault: vs.behind, merged: vs.ahead === 0 } : {}), name, current: head === "*", upstream: upstream || undefined, ahead, behind, + ...(gone ? { gone: true } : {}), subject: subject ?? "", date: Number(date) || 0, }); @@ -1224,6 +1792,32 @@ export class GitBridge { return branches; } + /** Recent commits reachable from one ref — the browsable history a peek card + * shows for a branch/remote/tag without loading the whole graph. */ + async refLog(req: { ref: string; maxCount?: number }): Promise<CompareCommit[]> { + const ctx = this.ctx(); + if (!ctx || !safeArg(req.ref)) { + return []; + } + const max = Math.min(Math.max(req.maxCount ?? 25, 1), 100); + const out: CompareCommit[] = []; + try { + for await (const c of ctx.log.streamCommits({ revRange: req.ref, maxCount: max })) { + out.push({ + sha: c.sha, + shortSha: c.sha.slice(0, 7), + subject: c.subject, + author: c.author, + date: c.authorDate, + }); + } + } catch { + // An unknown/unborn ref is a state, not an error — the peek shows empty. + return []; + } + return out; + } + async branchCreate(req: { name: string; checkout?: boolean }): Promise<CommitActionResult> { if (!safeArg(req.name)) return UNSAFE_REF_RESULT; return this.staged((ctx) => @@ -1345,7 +1939,16 @@ export class GitBridge { private async staged( op: ( ctx: GitContext, - ) => Promise<{ ok?: boolean; code?: number; stderr?: string; stdout?: string }>, + ) => Promise<{ + ok?: boolean; + code?: number; + stderr?: string; + stdout?: string; + /** A message the OP composed. It knows more than stderr does. */ + message?: string; + changed?: boolean; + expected?: boolean; + }>, ): Promise<CommitActionResult> { const ctx = this.ctx(); if (!ctx) { @@ -1360,11 +1963,42 @@ export class GitBridge { } const stderr = r.stderr?.trim() ?? ""; const stdout = r.stdout?.trim() ?? ""; + // An op's OWN message wins. stageAll composes one naming the files it + // held back; replacing it with "The operation failed." threw away the + // only part the user could act on. + if (r.message) { + return { + ok: false, + changed: r.changed ?? false, + message: r.message, + ...(r.expected ? { expected: true } : {}), + }; + } + const both = `${stdout}\n${stderr}`; + // git explains some ordinary situations on STDERR, and the IPC wrapper + // crash-reports any ok:false result carrying a message that is not + // marked expected. "The previous cherry-pick is now empty" is the + // commonest of them — picking something already on the branch — and it + // filed a report on every press of a button the app itself had enabled. + // + // Matching on English strings is the wrong mechanism for that, and it + // proved it: `git am --continue` stopping on the next patch of a series + // — the most ordinary outcome there is — matched none of these + // wordings and was reported as a crash. Callers that KNOW their failure + // is always a condition say so with `expected`, and the strings stay + // only as a fallback for the callers that do not. + const ordinary = + r.expected === true || + /is now empty|nothing to commit|no changes .* patch already applied/i.test(both); return { ok: false, changed: false, - message: stderr || stdout || "The operation failed.", - ...(stderr ? {} : { expected: true }), + // BOTH streams, stdout first. git splits one explanation across them + // — `am --continue` puts "error: Failed to merge in the changes." on + // stderr and the file it stopped on, plus what to do next, on stdout + // — and showing only stderr threw away the half that helps. + message: [stdout.trim(), stderr.trim()].filter(Boolean).join("\n") || "The operation failed.", + ...(stderr && !ordinary ? {} : { expected: true }), }; } catch (err) { return { ok: false, changed: false, message: String(err) }; @@ -1461,7 +2095,12 @@ export class GitBridge { rebasing: false, cherryPicking: false, reverting: false, + amApplying: false, conflicts: 0, + nothingToCommit: false, + kind: null, + canContinue: false, + canSkip: false, }; if (!ctx) return empty; const present = async (gitPath: string): Promise<boolean> => { @@ -1487,48 +2126,295 @@ export class GitBridge { } catch { conflicts = 0; } - const [merging, rebaseM, rebaseA, cherryPicking, reverting] = await Promise.all([ + const [merging, rebaseM, rebaseA, amMarker, cherryPicking, reverting] = await Promise.all([ present("MERGE_HEAD"), present("rebase-merge"), present("rebase-apply"), + // `git am` uses the SAME rebase-apply directory. git tells them apart by + // a marker inside it — `applying` for am, `rebasing` for a rebase on the + // apply backend — and they are mutually exclusive. + present("rebase-apply/applying"), present("CHERRY_PICK_HEAD"), present("REVERT_HEAD"), ]); + const rebasing = rebaseM || (rebaseA && !amMarker); + const amApplying = rebaseA && amMarker; + + // ONE name for what is in progress, decided HERE. + // + // The renderer used to re-derive this from five booleans, and got the + // precedence wrong in a way that destroyed work: `rebase --rebase-merges` + // stopping on a `merge` step leaves MERGE_HEAD *and* `rebase-merge/`, and + // "merging first" named it a merge — so Abort ran `git merge --abort`, + // which throws away a hand resolution and leaves the rebase running. A + // rebase that stops inside a merge step is still a rebase, and only git's + // rebase verbs can end it. + const kind: GitOpState["kind"] = rebasing + ? "rebase" + : amApplying + ? "am" + : cherryPicking + ? "cherry-pick" + : reverting + ? "revert" + : merging + ? "merge" + : null; + + // Is there anything left to record? `diff --cached --quiet HEAD` exiting 0 + // means the index matches HEAD. Asked only while something is stopped, so + // the ordinary refresh path pays nothing for it. + const indexMatchesHead = + kind !== null && + conflicts === 0 && + (await ctx.process.run(["diff", "--cached", "--quiet", "HEAD"])).code === 0; + + // What the two forward buttons can actually DO, decided here rather than + // guessed by the renderer from the booleans above. `skipping` was re-derived + // there and came out wrong in BOTH directions in consecutive commits: once + // offering a hard-resetting Skip at a pause the user asked for, then + // removing the only Skip that could finish an apply-backend rebase. + // Both FALSE when nothing is in progress. `conflicts === 0` is true of an + // ordinary clean repo, and defaulting `canContinue` from it made the field + // claim a Continue was possible with no operation to continue — inert + // today, because the banner returns early on a null kind, but a field that + // is wrong in a state nobody reads is a field the next caller will trust. + let canContinue = kind !== null && conflicts === 0; + let canSkip = false; + if (kind === "merge") { + // git allows an EMPTY merge commit, so `commit --no-edit` finishes one + // whose result matches HEAD. There is no `git merge --skip`. + canSkip = false; + } else if (kind === "rebase") { + if (rebaseM) { + // The MERGE backend never offers Skip, for two reasons that point the + // same way. Its `--continue` auto-drops a commit that conflict + // resolution emptied, so Skip is not needed. And a deliberate pause — + // `edit`, `break` — can ONLY happen here: `git rebase -i --apply` is + // refused outright ("apply options and merge options cannot be used + // together") and `-i` writes `rebase-merge/` even under + // `rebase.backend = apply`. At such a pause the index equals HEAD and + // nothing is conflicted, indistinguishable from an empty patch, and + // `rebase --skip` HARD-RESETS the working tree: it discards the amend + // the pause existed to make, and in git's split-a-commit flow the + // commit being split with it. + canSkip = false; + } else { + // The APPLY backend refuses `--continue` on an emptied patch and names + // `--skip` itself. This is the one place a rebase Skip is correct, and + // removing it left the operation with no way to finish at all. + canContinue = conflicts === 0 && !indexMatchesHead; + canSkip = conflicts === 0 && indexMatchesHead; + } + } else if (kind === "cherry-pick" || kind === "revert" || kind === "am") { + // The sequencer refuses to record an empty patch and names `--skip`. + canContinue = conflicts === 0 && !indexMatchesHead; + canSkip = true; + } + return { merging, - rebasing: rebaseM || rebaseA, + rebasing, + amApplying, cherryPicking, reverting, conflicts, + kind, + canContinue, + canSkip, + nothingToCommit: indexMatchesHead, }; } - private runResult(args: string[]): Promise<CommitActionResult> { + /** + * `alwaysExpected` keeps a command's failures OUT OF THE CRASH REPORTS, and + * does nothing else. + * + * The sequencer's continue and skip verbs fail routinely — "stopped on the + * next patch", "nothing to do" — and the classifier's fallback is matching + * git's English on stderr, which matched no `git am` wording at all and filed + * a report on every press. But the flag is per-CALLER, so it cannot tell that + * routine failure from "I could not take the index lock", and it must not be + * read as "this was fine": the banner shows every failure in red regardless, + * because in all of these cases the operation did not finish. + */ + private runResult(args: string[], opts?: { alwaysExpected?: boolean }): Promise<CommitActionResult> { return this.staged(async (ctx) => { const r = await ctx.process.run(args); // stdout matters here: rebase --continue with unresolved conflicts, and // merge-continue's `commit --no-edit`, both explain themselves there. - return { ok: r.code === 0, code: r.code, stderr: r.stderr, stdout: r.stdout }; + return { + ok: r.code === 0, + code: r.code, + stderr: r.stderr, + stdout: r.stdout, + ...(opts?.alwaysExpected ? { expected: true } : {}), + }; }); } + /** Is a `git am` stopped mid-series? The `applying` marker is git's own way + * of telling an am from a rebase inside the shared `rebase-apply/`. */ + private async amInProgress(): Promise<boolean> { + const ctx = this.ctx(); + if (!ctx) return false; + const r = await ctx.process.run(["rev-parse", "--git-path", "rebase-apply/applying"]); + if (r.code !== 0) return false; + try { + // resolve(), not join() — inside a linked worktree git answers with an + // absolute path. Same reasoning as `opState`'s own probe. + await stat(resolve(ctx.root, r.stdout.trim())); + return true; + } catch { + return false; + } + } + /** + * Finishing, and abandoning, a part-applied patch series. + * + * `--continue` takes no `--no-edit`: it reuses the patch's own message and + * author, which is the whole reason a plain commit is the wrong way out. + * + * `--abort` is the destructive one. It rewinds to where the series started, + * discarding patches it already applied — and when HEAD has moved since, git + * declines to rewind, prints "Not rewinding to ORIG_HEAD" and still exits 0. + * Reporting that as "Done." would be a lie about the repository's state, so + * the warning is passed back as the result's message. + */ + /** + * Drop the patch git is stuck on and carry on with the rest of the series. + * + * The banner offered Continue and Abandon and nothing between them, so a + * patch that simply would not apply left "finish it" (which git refuses) and + * "throw the whole series away" as the only choices — while git's own advice + * on that screen is `git am --skip`. + */ + amSkip(): Promise<CommitActionResult> { + return this.runResult(["am", "--skip"], { alwaysExpected: true }); + } + amContinue(): Promise<CommitActionResult> { + return this.runResult(["am", "--continue"], { alwaysExpected: true }); + } + async amAbort(): Promise<CommitActionResult> { + const ctx = this.ctx(); + if (!ctx) return { ok: false, changed: false, message: "No repository open." }; + const r = await ctx.process.run(["am", "--abort"]); + if (r.code !== 0) { + return { ok: false, changed: false, message: r.stderr.trim() || `git am --abort failed (${r.code}).` }; + } + const warned = /not rewinding to orig_head/i.test(`${r.stdout}\n${r.stderr}`); + return { + ok: true, + changed: true, + ...(warned + ? { + message: + "The patch series was abandoned, but HEAD had moved since it started, so git left it " + + "where it is rather than rewinding. Check the log before carrying on.", + } + : {}), + }; + } mergeAbort(): Promise<CommitActionResult> { return this.runResult(["merge", "--abort"]); } mergeContinue(): Promise<CommitActionResult> { return this.runResult(["commit", "--no-edit"]); } + /** + * Cherry-pick and revert abort and continue THEMSELVES. + * + * The banner names four operations and then collapsed them into two + * channels, so a stopped cherry-pick or revert was aborted with + * `git merge --abort` — which fails outright, because MERGE_HEAD does not + * exist. The banner correctly said "cherry-pick in progress" and its only + * way out did nothing. + */ + cherryPickAbort(): Promise<CommitActionResult> { + return this.runResult(["cherry-pick", "--abort"]); + } + cherryPickContinue(): Promise<CommitActionResult> { + return this.runResult(["cherry-pick", "--continue", "--no-edit"], { alwaysExpected: true }); + } + /** + * Skipping the stopped commit. This is git's own answer to "the previous + * cherry-pick is now empty", and it was the one way out the banner never + * offered. + */ + cherryPickSkip(): Promise<CommitActionResult> { + return this.runResult(["cherry-pick", "--skip"], { alwaysExpected: true }); + } + revertSkip(): Promise<CommitActionResult> { + return this.runResult(["revert", "--skip"], { alwaysExpected: true }); + } + revertAbort(): Promise<CommitActionResult> { + return this.runResult(["revert", "--abort"]); + } + revertContinue(): Promise<CommitActionResult> { + return this.runResult(["revert", "--continue", "--no-edit"], { alwaysExpected: true }); + } + /** + * Abort through the RUNNER, which also forgets the reword queue. + * + * `runResult(["rebase","--abort"])` left it in `.git`. Keying by sha makes a + * stale queue inert against a FOREIGN rebase — but an abort restores the + * ORIGINAL shas, so an abandoned draft matched perfectly the next time that + * branch was rebased, and renamed a commit the user never asked to reword. + * Measured: "FINAL log: ABANDONED-DRAFT | m2 | m1". + */ rebaseAbort(): Promise<CommitActionResult> { - return this.runResult(["rebase", "--abort"]); + return this.resumeRebase(async (root, o) => { + // The runner's message form: a failed abort says WHY — a locked index, + // an unmerged path git will not discard — instead of a canned sentence. + return await abortRebase(root, o); + }); } + /** + * Continue / skip through the RUNNER, not `-c core.editor=true`. + * + * A no-op editor discards every reword message queued for the commits AFTER + * the one that stopped — the plan the user composed, applied silently and + * only in part, reported as "Rebase continued." The runner re-installs the + * queue (keyed by sha, so it can only ever apply to this rebase). + */ rebaseContinue(): Promise<CommitActionResult> { - return this.runResult(["-c", "core.editor=true", "rebase", "--continue"]); + return this.resumeRebase((root, o) => continueRebase(root, o)); } rebaseSkip(): Promise<CommitActionResult> { - return this.runResult(["-c", "core.editor=true", "rebase", "--skip"]); + return this.resumeRebase((root, o) => skipRebase(root, o)); + } + + private async resumeRebase( + run: (root: string, opts: ReturnType<RepoStore["runnerOptions"]>) => Promise<RebaseOutcome>, + ): Promise<CommitActionResult> { + const root = this.repos.current()?.root; + if (!root) return { ok: false, changed: false, message: "No repository open." }; + return this.serialize(async () => { + try { + // The runner spawns git itself, so it has to be told which git and + // where to report — otherwise these commands vanish from the Output tab + // and fall back to a bare "git" on PATH. + const out = await run(root, this.repos.runnerOptions()); + if (out.status === "done") return { ok: true, changed: true }; + // A stop is not a failure — the rebase is still live and the view says + // so. `expected` keeps it out of the crash reporter. + return { + ok: false, + changed: true, + expected: out.status === "stopped", + message: out.message ?? (out.status === "stopped" ? "Rebase paused." : "Rebase failed."), + }; + } catch (err) { + return { ok: false, changed: false, message: err instanceof Error ? err.message : String(err) }; + } + }); } - // ── Tag creation (the Branches view's "Create tag here…") ─────────────────── + // ── Tags (the Branches view's create / delete / push) ─────────────────────── + // + // `TagOps` has had `delete` and `push` since it was written; neither had an + // IPC channel, so the app could CREATE a tag it could then never remove or + // publish. A verb you can only do in one direction is not a feature. tagCreate(req: { name: string; ref?: string; message?: string }): Promise<CommitActionResult> { if (!safeArg(req.name)) return Promise.resolve(UNSAFE_REF_RESULT); @@ -1542,6 +2428,43 @@ export class GitBridge { ); } + /** `git tag -d <name>` — local only; the remote copy outlives it. */ + tagDelete(name: string): Promise<CommitActionResult> { + if (!safeArg(name)) return Promise.resolve(UNSAFE_REF_RESULT); + return this.staged((ctx) => ctx.tags.delete(name)); + } + + /** `git push <remote> refs/tags/<name>` — publishing one tag, not `--tags`. + * Pushing every tag at once is a different, much larger action and must be + * asked for explicitly rather than ridden along with a single one. */ + tagPush(req: { name: string; remote?: string }): Promise<CommitActionResult> { + if (!safeArg(req.name)) return Promise.resolve(UNSAFE_REF_RESULT); + if (req.remote && !safeArg(req.remote)) return Promise.resolve(UNSAFE_REF_RESULT); + return this.staged(async (ctx) => { + // Not a hardcoded "origin". A fork clone, or a `git remote rename`, and + // the Push button on every tag could only ever fail — with git's raw + // "'origin' does not appear to be a git repository" in a toast — while + // its own tooltip promised the tag would go to origin. Same rule as + // publishing an unpublished branch: prefer origin, else the only remote, + // and refuse to guess between several. + let remote = req.remote; + if (!remote) { + const names = (await ctx.remotes.list()).map((r) => r.name); + remote = names.find((n) => n === "origin") ?? (names.length === 1 ? names[0] : undefined); + if (!remote) { + return { + ok: false, + stderr: + names.length === 0 + ? `No remote is configured, so '${req.name}' can't be pushed.` + : `Several remotes are configured — name the one to push '${req.name}' to.`, + }; + } + } + return ctx.tags.push(remote, req.name); + }); + } + // ── Hunk / line staging (working ⇄ index) ─────────────────────────────────── async stageLines(req: { path: string; lines: number[]; reverse?: boolean }): Promise<CommitActionResult> { @@ -1558,22 +2481,86 @@ export class GitBridge { const rel = req.path; const ranges = linesToRanges(req.lines); if (!ranges.length) return { ok: false, changed: false, message: "No lines selected." }; + // Before reading anything: this path round-trips the file through a + // string, which destroys a binary and follows a symlink. + const safe = await lineStageable(ctx, rel); + if (!safe.ok) return { ok: false, changed: false, expected: true, message: safe.why }; let original: string; let modified: string; if (req.reverse) { - // Unstage: roll the selected index changes back to HEAD. + // Unstage: roll the selected index changes back to HEAD — under the + // name HEAD actually knows. See headSideName: for a staged rename the + // new path is not in HEAD, and reading "" made the whole file look + // like one insertion that a single-line unstage then wiped. + const headName = await headSideName(ctx, rel); original = await ctx.staging.indexContent(rel); - modified = await ctx.staging.headContent(rel); + modified = headName ? await ctx.staging.headContent(headName) : ""; + if (!headName) { + // HEAD has no such file under any name: this is a newly ADDED file, + // where "roll back to HEAD" means unstage the whole thing. Doing it + // through the file-level op keeps the add intact in the working + // tree instead of writing an empty blob over it. + const un = await ctx.staging.unstageFile(rel); + return un.ok + ? { ok: true, changed: true } + : { ok: false, changed: false, message: un.stderr.trim() || "Couldn't unstage the file." }; + } } else { // Stage: apply the selected working-tree changes onto the index. original = await ctx.staging.indexContent(rel); - modified = await readWorking(ctx, rel); + modified = (await readWorking(ctx, rel)).text; } const hunks = computeHunks(original, modified); - const selected = hunks.filter((h) => ranges.some((r) => rangesOverlap(h.modified, r))); + // WHICH coordinates the selection arrives in. + // + // `fileDiff` builds EVERY working-tree diff as HEAD (left) vs WORKING + // (right), whatever the file's stage state — the index is carried only + // as `indexText`, for the tick glyphs, and is never a pane. And + // `getSelectedLines` reads the RIGHT editor. So the numbers the renderer + // sends are always WORKING-tree line numbers. + // + // The comment that stood here said the opposite ("that pane always shows + // `original` — the index"), and the code followed the comment. For + // staging it did not matter: `modified` IS the working tree there. For + // UNSTAGING, `modified` is HEAD and `original` is the index, and neither + // is numbered like the working tree — so on a file that is staged AND + // further modified (git's `MM`), an unstaged edit above the selection + // shifts every later working line away from its index line, and the + // selection matched a DIFFERENT hunk: a staged change the user never + // clicked was rolled back to HEAD and the app said "Unstaged selected + // lines." Or it matched nothing, and said "Nothing to apply in the + // selection." for a line plainly on screen. + // + // So translate first, through the index→working diff, and match on the + // index side. + let selection = ranges; + if (req.reverse) { + selection = toOriginalRanges(ranges, computeHunks(original, (await readWorking(ctx, rel)).text)); + if (!selection.length) { + return { ok: false, changed: false, message: "Nothing to apply in the selection." }; + } + } + const sideOf = (h: (typeof hunks)[number]): LineRange => (req.reverse ? h.original : h.modified); + const selected = hunks.filter((h) => selection.some((r) => rangesOverlap(sideOf(h), r))); if (!selected.length) return { ok: false, changed: false, message: "Nothing to apply in the selection." }; const content = applySelectedChanges(original, modified, selected.map((h) => h.modified)); - await ctx.staging.stageContent(rel, content); + // …and report what actually happened. This discarded stageContent's + // result and answered ok:true unconditionally, so a write that failed + // was indistinguishable from one that worked. + const wrote = await ctx.staging.stageContent(rel, content); + if (!wrote.ok) { + // `stderr`, not `message`. CommitResult is `{ ok, stderr }` — it has + // never had a `message` — so the cast always read undefined and the + // fallback always won. Every real failure said "Couldn't update the + // index." while git's own text was thrown away, including the one + // that tells you exactly what to do: "Another git process seems to be + // running in this repository… remove the file manually to continue." + return { + ok: false, + changed: false, + message: wrote.stderr.trim() || "Couldn't update the index.", + }; + } return { ok: true, changed: true }; } catch (err) { return { ok: false, changed: false, message: String(err) }; @@ -1596,11 +2583,68 @@ export class GitBridge { async conflictResolve(req: { path: string; content: string }): Promise<CommitActionResult> { const ctx = this.ctx(); if (!ctx) return { ok: false, changed: false, message: "No repository open." }; - if (!safeArg(req.path)) return UNSAFE_REF_RESULT; + if (!safePath(req.path)) return UNSAFE_PATH_RESULT; return this.serialize(async () => { try { const abs = containedPath(ctx.root, req.path); if (!abs) return { ok: false, changed: false, message: "Path escapes the repository." }; + // The merge view's "Mark resolved" hands back a JavaScript string, and + // `writeFile` follows symlinks. So on a conflicted symlink this opened + // the LINK'S TARGET — a file that may be nowhere near the repository — + // and overwrote it, while the link git actually tracks kept its old + // value; and on a conflicted binary it wrote back the U+FFFD wreckage + // of a UTF-8 round trip. Both reported "Resolved and staged." + // + // Take ours / Take theirs is the way through both: `git checkout + // --ours/--theirs` never decodes and never follows. + const safe = await textWriteSafe(abs, req.path, (what) => + what === "symlink" + ? `${req.path} is a symbolic link. Saving text here would overwrite whatever it points at, not the link — use Take ours or Take theirs.` + : `${req.path} isn't UTF-8 text. Saving it as text would rewrite the bytes it can't represent — use Take ours or Take theirs.`, + ); + if (!safe.ok) return { ok: false, changed: false, expected: true, message: safe.why }; + // A BOTH-DELETED (DD) conflict has no side that still has the file, so + // there is nothing to write back: saving text here CREATES it, staged + // as an addition nobody asked for, and Discard afterwards reports + // success having changed nothing. Both sides agree it is gone. + // + // Asked with the same shape of probe `conflictTakeSide` uses — and + // refused only on the POSITIVE signal (listed, stage 1, no 2 or 3). + // "Mark resolved" is the only way to commit a hand-merged result, so a + // blanket refusal on an unreadable listing would take that away, unlike + // take-a-side where refusing is the safe default. + const stages = await ctx.process.run(["ls-files", "-u", "-z"]); + if (stages.code === 0) { + const mine = stages.stdout + .split("\0") + .map((rec) => /^\d{6} [0-9a-f]+ (\d)\t([\s\S]*)$/.exec(rec)) + .filter((m): m is RegExpExecArray => !!m && m[2] === req.path) + .map((m) => m[1]); + if (mine.length && !mine.includes("2") && !mine.includes("3")) { + return { + ok: false, + changed: false, + expected: true, + message: `Both sides deleted ${req.path}. There is nothing to merge — use Discard to accept the deletion.`, + }; + } + } + // A containment check that resolves SYMLINKS, not just "..". The one + // above is purely lexical, so a repo-relative path whose PARENT is a + // symlink pointing outside still lands outside. Both sides are + // realpath'd — on macOS the repo root itself is usually under a + // symlinked /tmp, so realpathing only the child refuses every + // legitimate file. + const realRoot = await realpath(ctx.root).catch(() => ctx.root); + const realDir = await realpath(dirname(abs)).catch(() => undefined); + if (!realDir || (realDir !== realRoot && !realDir.startsWith(realRoot + sep))) { + return { + ok: false, + changed: false, + expected: true, + message: `${req.path} resolves outside the repository — nothing was written.`, + }; + } await writeFile(abs, req.content, "utf8"); const r = await ctx.process.run(["add", "--", req.path]); if (r.code !== 0) return { ok: false, changed: false, message: r.stderr.trim() }; @@ -1614,15 +2658,106 @@ export class GitBridge { async conflictTakeSide(req: { path: string; side: "ours" | "theirs" }): Promise<CommitActionResult> { const ctx = this.ctx(); if (!ctx) return { ok: false, changed: false, message: "No repository open." }; - if (!safeArg(req.path)) return UNSAFE_REF_RESULT; + if (!safePath(req.path)) return UNSAFE_PATH_RESULT; const stage = req.side === "ours" ? "2" : "3"; return this.serialize(async () => { try { - const show = await ctx.process.run(["show", `:${stage}:${req.path}`]); - if (show.code !== 0) return { ok: false, changed: false, message: show.stderr.trim() }; - const abs = containedPath(ctx.root, req.path); - if (!abs) return { ok: false, changed: false, message: "Path escapes the repository." }; - await writeFile(abs, show.stdout, "utf8"); + // A modify/delete conflict has only TWO stages: the base, and whichever + // side kept the file. Asking for the missing one is not an error — that + // side's answer IS "delete it" — but `git show :3:path` exits non-zero + // and the user got a raw `fatal: path ... does not exist` for pressing a + // button the app itself offered. Taking a side that deleted the file + // means removing the file. + // `-z`, ALWAYS. Without it `ls-files` honours `core.quotePath`, which + // defaults to true, so it C-QUOTES every path outside ASCII: + // `"caf\303\251.txt"`, quotes and octal escapes included. The renderer + // sends the RAW path (it comes from `status --porcelain=v2 -z`), so an + // exact comparison against the quoted form never matches — and "no + // stages found" fell into the branch that runs `git rm`. Verified: a + // merge conflicting six files, "Take ours" on each, four of six DELETED + // and the deletions staged, every one reported ok:true. The convention + // is written down forty lines above this, at `fileDiff`'s own listing. + // + // The path is compared HERE rather than passed as a pathspec: this + // answer decides between writing a file and deleting one, and a + // pathspec is glob-capable with environment-steerable precedence + // (`GIT_GLOB_PATHSPECS`, `GIT_LITERAL_PATHSPECS`). `:(literal)` is not + // the escape hatch it looks like — under `GIT_LITERAL_PATHSPECS=1` the + // magic prefix becomes part of the filename and matches nothing. + // + // The list is bounded by the number of conflicts, which is small. + const unmerged = await ctx.process.run(["ls-files", "-u", "-z"]); + if (unmerged.code !== 0) { + return { + ok: false, + changed: false, + expected: true, + message: `Couldn't read the conflict state for ${req.path}. Nothing was changed.`, + }; + } + { + // `[\s\S]` for the path, not `.`: with `-z` a path containing a + // NEWLINE arrives raw, and `.` will not cross it — which would drop + // that file straight back into the delete branch. + const rows = unmerged.stdout + .split("\0") + .map((rec) => /^\d{6} [0-9a-f]+ (\d)\t([\s\S]*)$/.exec(rec)) + .filter((m): m is RegExpExecArray => !!m); + const present = new Set(rows.filter((m) => m[2] === req.path).map((m) => m[1])); + // DELETE only when git says this path really has no such side. A + // modify/delete conflict always lists the path with stage 1 plus one + // of 2 or 3, so "listed, but not the side you asked for" is the only + // safe reading of an absent stage. "Not listed at all" means the + // parse failed or the path moved, and answering that with `git rm` + // makes destruction the default outcome of not understanding the + // input — which is exactly how the C-quoting bug destroyed files. + // The verdict gates BOTH outcomes. This used to sit inside an + // `if (stdout.trim())`, so an empty listing — the file is no longer + // conflicted, because a watcher tick or another window resolved it — + // skipped the probe entirely and fell through to a precondition-free + // `git checkout --ours/--theirs`, which happily overwrites a file + // that has no conflict left and reports "Took your version." + if (!present.size) { + return { + ok: false, + changed: false, + expected: true, + message: `${req.path} is no longer conflicted — nothing was changed.`, + }; + } + if (!present.has(stage)) { + const rm = await ctx.process.run(["rm", "-f", "--", req.path]); + if (rm.code !== 0) { + return { ok: false, changed: false, message: rm.stderr.trim() || "Couldn't delete the file." }; + } + return { ok: true, changed: true }; + } + } + // Let GIT write the bytes. This used to `git show :N:path`, take the + // stdout as a STRING and write it back as UTF-8 — and `GitProcess.run` + // decodes stdout with `Buffer.concat(...).toString("utf8")`, which is + // lossy for anything that is not UTF-8 text. So resolving a conflicted + // PNG, PDF or any binary asset wrote mangled bytes over it and STAGED + // them, then reported success: verified on a real 512×512 PNG, whose + // header came back `efbfbd504e470d0a` instead of `89504e470d0a1a0a`, + // 36,078 bytes in and 67,288 bytes out. Take-ours/take-theirs is the + // only resolution the app offers for a binary conflict, so this was the + // only path available, and it destroyed the file. + // + // `checkout --ours/--theirs` never decodes anything. + const co = await ctx.process.run([ + "checkout", + req.side === "ours" ? "--ours" : "--theirs", + "--", + req.path, + ]); + if (co.code !== 0) { + return { + ok: false, + changed: false, + message: co.stderr.trim() || `Couldn't take the ${req.side === "ours" ? "current" : "incoming"} version.`, + }; + } const r = await ctx.process.run(["add", "--", req.path]); if (r.code !== 0) return { ok: false, changed: false, message: r.stderr.trim() }; return { ok: true, changed: true }; @@ -1649,6 +2784,177 @@ function linesToRanges(lines: number[]): LineRange[] { return ranges; } +/** + * Re-expresses ranges numbered on the MODIFIED side of `hunks` in ORIGINAL-side + * coordinates. + * + * Used to carry a working-tree selection back into index numbering before it is + * matched against the index→HEAD hunks. Lines outside every hunk shift by the + * running length difference of the hunks before them; a line inside a hunk maps + * to that hunk's whole original span. Lines the original does not have at all — + * a working-only insertion — map to nothing, because there is no staged change + * under them to pick up. + */ +function toOriginalRanges(ranges: LineRange[], hunks: Hunk[]): LineRange[] { + const len = (r: LineRange): number => (r.end < r.start ? 0 : r.end - r.start + 1); + const mapped: LineRange[] = []; + // The mapping is monotonic, so folding each result into the previous one keeps + // the output the size of the selection's shape rather than its line count. + const add = (r: LineRange): void => { + const last = mapped[mapped.length - 1]; + if (last && r.start >= last.start && r.start <= last.end + 1) { + last.end = Math.max(last.end, r.end); + return; + } + mapped.push(r); + }; + for (const range of ranges) { + for (let line = range.start; line <= range.end; line++) { + let delta = 0; + let landed = false; + for (const h of hunks) { + if (len(h.modified) > 0 && line >= h.modified.start && line <= h.modified.end) { + if (len(h.original) > 0) add({ ...h.original }); + landed = true; + break; + } + if (h.modified.start > line) break; + delta += len(h.modified) - len(h.original); + } + if (!landed) add({ start: line - delta, end: line - delta }); + } + } + return mapped; +} + +/** + * Can this path be staged CHANGE BY CHANGE without being destroyed? + * + * Line and hunk staging round-trips the file through a JavaScript string: + * `indexContent`/`readWorking` decode it as UTF-8, the selected changes are + * applied to that string, and `stageContent` hashes it back. Every byte that is + * not valid UTF-8 becomes U+FFFD on the way through, so staging one line of a + * PNG wrote a mangled blob into the index — 29 bytes in, 42 out, header + * `efbfbd504e47` instead of `89504e47` — and answered ok:true. + * + * A symlink is worse: `readFile` FOLLOWS it, so the "content" is the pointed-at + * file's text, and staging wrote that text as the link's new target under mode + * 120000 — a permanently dangling link, committed and cloned that way. + * + * Deliberately NOT `isStageableText`, which the sibling tick path uses: its + * invariant is "cheap enough to repaint ticks", so it passes NUL-free Latin-1 + * (still destroyed) and REFUSES a 25,000-line text file that stages correctly + * today. This asks the exact question instead — do the bytes survive the round + * trip this code is about to perform. + */ +/** + * The two kinds of file that a text write-back destroys, asked once. + * + * A symlink, because `writeFile` FOLLOWS it: the app opens the link's target + * and overwrites whatever is there — a file that may be nowhere near the + * repository — while the link itself, which is what git tracks, is untouched. + * And a non-UTF-8 file, because the content has been round-tripped through a + * JavaScript string by the time it gets here, and every byte that is not valid + * UTF-8 came back as U+FFFD. + * + * Shared because it was answered separately in two places and only one of them + * was ever right. `conflictTakeSide` was fixed to let git move the bytes; + * `conflictResolve`, forty lines below it, still wrote a JS string through + * `writeFile` and reported "Resolved and staged." over a corrupted PNG and an + * obliterated file outside the repo. `caller` supplies wording that names a + * control the user can actually see from where they are. + */ +async function textWriteSafe( + abs: string, + rel: string, + advice: (what: "symlink" | "binary") => string, +): Promise<{ ok: true } | { ok: false; why: string }> { + const st = await lstat(abs).catch(() => undefined); + if (st?.isSymbolicLink()) return { ok: false, why: advice("symlink") }; + if (st?.isFile()) { + const bytes = await readFile(abs).catch(() => undefined); + if (bytes && Buffer.compare(Buffer.from(bytes.toString("utf8"), "utf8"), bytes) !== 0) { + return { ok: false, why: advice("binary") }; + } + } + return { ok: true }; +} + +async function lineStageable( + ctx: GitContext, + rel: string, +): Promise<{ ok: true } | { ok: false; why: string }> { + const abs = containedPath(ctx.root, rel); + if (!abs) return { ok: false, why: "That path is outside the repository." }; + const safe = await textWriteSafe(abs, rel, (what) => + what === "symlink" + ? `${rel} is a symbolic link — stage it whole. Staging part of one would write a file's contents into the link.` + : `${rel} isn't UTF-8 text — stage it whole. Staging part of it would rewrite the bytes it can't represent.`, + ); + if (!safe.ok) return safe; + // The side already in the index can be binary even when the working file is + // gone or readable. git answers this itself: `--numstat` prints "-" for a + // binary blob rather than a line count. + const ns = await ctx.process.run(["diff", "--cached", "--numstat", "--", rel]); + if (ns.code === 0 && /^-\t-\t/m.test(ns.stdout)) { + return { + ok: false, + why: `${rel} is staged as a binary file — stage or unstage it whole.`, + }; + } + // A CONFLICTED path is not stageable in parts either, and this is the one + // place both partial-staging routes meet. `git add` on an unmerged path is + // how you declare the conflict RESOLVED — so ticking a single hunk on a + // conflicted file settled the whole thing, with every other hunk's markers + // still in it. The whole-file `stage()` refuses that; these two did not. + const st = await ctx.process.run(["status", "--porcelain=v1", "-z", "--", rel]); + if (st.code === 0 && parsePorcelainStatus(st.stdout).some((f) => f.conflicted)) { + return { + ok: false, + why: `${rel} is still conflicted — resolve it as a whole rather than staging part of it.`, + }; + } + return { ok: true }; +} + +/** + * The name this path had at HEAD. + * + * A staged RENAME means HEAD has only the OLD name, so `git show HEAD:<new>` + * exits non-zero and `headContent` answers "". Everything downstream then reads + * the file as one giant insertion: the diff's left pane is empty, so a rename + * plus a one-line edit renders as a brand-new file — and unstaging a single + * line rolls the WHOLE file back to that empty side, putting the empty blob in + * the index and committing a 0-byte file, reporting ok:true at every step. + * + * `-M` asks git which path it came from. Returns the path unchanged when it is + * not a rename, and undefined when HEAD does not have it under any name (a + * genuinely new file), which is a different case the caller must handle. + */ +async function headSideName(ctx: GitContext, rel: string): Promise<string | undefined> { + // NO pathspec. Limiting the diff to the destination filters the rename's + // SOURCE out of it, and `-M` then has nothing to pair with — git reports + // `A helpers.ts` instead of `R077 util.ts helpers.ts`, which is exactly the + // "brand new file" answer that made a one-line unstage wipe the whole thing. + // Verified both ways against real git. + const r = await ctx.process.run(["diff", "--cached", "--name-status", "-M", "-z"]); + if (r.code === 0) { + const tok = r.stdout.split("\0").filter((t) => t.length > 0); + for (let i = 0; i < tok.length; ) { + const code = tok[i]; + // R/C carry a similarity score and TWO paths: source then destination. + const renamed = code.startsWith("R") || code.startsWith("C"); + const src = tok[i + 1]; + const dst = renamed ? tok[i + 2] : src; + if (renamed && dst === rel && src) return src; + i += renamed ? 3 : 2; + } + } + // Not a rename. Does HEAD have it at all? + const has = await ctx.process.run(["cat-file", "-e", `HEAD:${rel}`]); + return has.code === 0 ? rel : undefined; +} + /** Whether two inclusive line ranges overlap (zero-width spans treated as a point). */ function rangesOverlap(a: LineRange, b: LineRange): boolean { const aEnd = a.end < a.start ? a.start : a.end; @@ -1685,9 +2991,76 @@ function actionArgs(req: CommitActionRequest): string[] | undefined { // ── content helpers ────────────────────────────────────────────────────────── -async function showAt(ctx: GitContext, sha: string, rel: string): Promise<string> { +/** + * Fold two sides' classifications into the flags a FileDiff carries. + * + * A side being ABSENT is not a problem to report — that is just an added or a + * deleted file, and the empty pane beside the full one says it perfectly well. + * Binary and truncated ARE, because there the editor renders nothing (or a wall + * of replacement characters) and the reader blames the app. + */ +function diffKind( + left: { binary?: boolean; truncated?: boolean }, + right: { binary?: boolean; truncated?: boolean }, +): { binary?: boolean; truncated?: boolean } { + const out: { binary?: boolean; truncated?: boolean } = {}; + if (left.binary || right.binary) out.binary = true; + if (left.truncated || right.truncated) out.truncated = true; + return out; +} + +/** + * One side of a diff, read out of a commit — and what KIND of thing it is. + * + * This used to return `r.stdout` bare, which fed the diff editor three lies: + * + * - a binary file (a PNG, a font, an icon) came back as `git show`'s raw bytes + * decoded as UTF-8: a wall of U+FFFD, or nothing at all when it held a NUL. + * The panel mounted two empty editors and the reader saw "the diff doesn't + * show". + * - a 40MB file went to Monaco whole. `showAt`'s sibling one screen up caps at + * FILE_CAP_BYTES; this one never did. + * - `code !== 0` — a bad ref, a missing object, git failing — became `""`, + * which is exactly what a side that legitimately does not exist looks like. + * + * The classification rides on the FileDiff so the renderer can SAY which of + * those happened instead of rendering an editor over nothing. + */ +async function showAt( + ctx: GitContext, + sha: string, + rel: string, +): Promise<{ text: string; binary?: boolean; truncated?: boolean; absent?: boolean }> { const r = await ctx.process.run(["show", `${sha}:${rel}`]); - return r.code === 0 ? r.stdout : ""; + if (r.code !== 0) { + // Absent on THIS side (added or deleted in this commit) is the common case + // and is not an error; either way there is no text to show. + return { text: "", absent: true }; + } + // Binary: a NUL byte, or a high density of U+FFFD — git's stdout is decoded + // utf8, so a non-UTF-8, NUL-free binary surfaces as replacement characters. + if (r.stdout.includes("\0") || replacementRatio(r.stdout) > 0.3) { + return { text: "", binary: true }; + } + // BY BYTES, like every other reader here — the constant is named for them. + // + // `r.stdout` is a JS string, so `.length` counts UTF-16 code units and + // `.slice` cuts by them. The working side of the very same diff is a Buffer + // cut at FILE_CAP_BYTES actual bytes. On any file that is not pure ASCII the + // two sides were therefore cut at DIFFERENT points in the file, and the + // difference between those two points rendered as a change — in a file where + // nothing past the cap had been touched at all. + // `byteLength` MEASURES without allocating; the Buffer copy is paid for only + // by the files that actually need cutting. This runs on every file selection + // in the Changes view, so copying every read would be a megabyte of garbage + // per click on a large repository. + if (Buffer.byteLength(r.stdout, "utf8") > FILE_CAP_BYTES) { + return { + text: Buffer.from(r.stdout, "utf8").subarray(0, FILE_CAP_BYTES).toString("utf8"), + truncated: true, + }; + } + return { text: r.stdout }; } /** @@ -1754,40 +3127,154 @@ async function parentOf(ctx: GitContext, sha: string): Promise<string | undefine * the actual file; if it's gone (a deletion) we fall back to the index, then * HEAD, so the diff still shows the prior content on the left. */ -async function readWorking(ctx: GitContext, rel: string): Promise<string> { +/** + * The working copy of a file, and what KIND of thing it is. + * + * Two lies used to leave here: + * + * - `readFile(abs, "utf8")` on a PNG, a font, or a 40MB generated bundle hands + * the diff editor a wall of U+FFFD or one enormous line. The commit and + * compare producers classify their reads; this one — the Changes view, the + * most-used diff surface in the app — did not. + * - the catch path returned the INDEX, then HEAD. That is the left-hand side + * of the very diff being built, so a file that could not be read came back + * as "identical on both sides": a diff with nothing in it, presented as the + * truth about your working tree. A read that failed must say it failed. + */ +async function readWorking( + ctx: GitContext, + rel: string, +): Promise<{ text: string; binary?: boolean; truncated?: boolean; unreadable?: boolean }> { + const abs = containedPath(ctx.root, rel); + if (!abs) return { text: "", unreadable: true }; try { - const abs = containedPath(ctx.root, rel); - if (!abs) return ""; - return await readFile(abs, "utf8"); + const buf = await readFile(abs); + // CLASSIFY BEFORE CAPPING. The size check used to return `truncated` on its + // own line, above the binary tests — so anything binary and larger than the + // cap never reached them. A 40MB PNG, a video, a compiled bundle: the first + // 512KB were decoded as utf8 and mounted in a text editor, under a note + // reading "showing the first part of it". The pane filled with mojibake and + // the app claimed that was the file. Size and kind are independent + // questions, and the kind is the one that decides whether there is anything + // to show at all. + const head = buf.length > FILE_CAP_BYTES ? buf.subarray(0, FILE_CAP_BYTES) : buf; + // A NUL byte is the same test git itself uses, and it runs on the BYTES — + // decoding first is what turned a binary into replacement characters that + // then looked like text. + if (head.includes(0)) return { text: "", binary: true }; + const text = head.toString("utf8"); + if (replacementRatio(text) > 0.3) return { text: "", binary: true }; + if (buf.length > FILE_CAP_BYTES) return { text, truncated: true }; + return { text }; } catch { - const indexed = await ctx.staging.indexContent(rel).catch(() => ""); - return indexed || (await ctx.staging.headContent(rel).catch(() => "")); + return { text: "", unreadable: true }; } } // ── parse helpers ──────────────────────────────────────────────────────────── -/** Parses git's `%(upstream:track)` field, e.g. "[ahead 2, behind 1]" / "[gone]". */ -export function parseTrack(track: string): { ahead: number; behind: number } { +/** + * Parses git's `%(upstream:track)` field, e.g. "[ahead 2, behind 1]" / "[gone]". + * + * `[gone]` used to be discarded, and a branch whose upstream had been deleted + * came back as `{ ahead: 0, behind: 0 }` — indistinguishable from perfectly in + * sync. That is the most common state in this app's own workflow: GitHub + * deletes the head branch when a pull request merges, and the local copy then + * reads as up to date with a remote that no longer exists. It is also exactly + * the signal that the branch is finished and safe to delete. + */ +/** + * What the two conflict sides ARE, named for the operation in progress. + * + * Git's stage 2 is "ours" and stage 3 is "theirs" — but which of YOUR work each + * one holds depends on the operation, and for a rebase it is inverted: + * + * merge / cherry-pick / revert ours = HEAD, your branch + * theirs = the change being brought in + * rebase / am ours = the UPSTREAM you are replaying onto + * theirs = YOUR commit being replayed + * + * The labels were hardcoded to the merge reading, so in a rebase the button + * offering "your version" handed you the branch you were rebasing onto and + * discarded the commit you were replaying — with the tooltip and the success + * toast both agreeing it had done the opposite. + */ +export function sideLabels(kind: GitOpState["kind"]): { + oursLabel: string; + theirsLabel: string; +} { + if (kind === "rebase") { + return { + oursLabel: "Upstream (what you're rebasing onto)", + theirsLabel: "Your commit (being replayed)", + }; + } + // `am` reads like a MERGE, not like a rebase — verified against real git. + // + // A rebase inverts the sides because it checks the upstream out first and + // replays your commits onto it, so stage 2 is the upstream. `git am` does + // nothing of the kind: it applies a mailbox patch onto the branch you are + // standing on, so stage 2 is YOUR branch and stage 3 is the patch. Lumping + // the two together — which this function did, in the same change that fixed + // the rebase labels — put the identical lie on the identical buttons, one + // operation over: "Take Upstream" handed you your own branch, and "Take Your + // commit (being replayed)" handed you someone else's mailed patch. + // + // `opState` never confuses the two: a rebase on the apply backend uses the + // same `rebase-apply/` directory but writes `rebasing`, not `applying`, and + // is reported as "rebase". + if (kind === "am") { + return { + oursLabel: "Your branch", + theirsLabel: "The patch being applied", + }; + } + return { + oursLabel: "Current change (your branch)", + theirsLabel: "Incoming change", + }; +} + +export function parseTrack(track: string): { ahead: number; behind: number; gone: boolean } { const a = track.match(/ahead (\d+)/); const b = track.match(/behind (\d+)/); - return { ahead: a ? Number(a[1]) : 0, behind: b ? Number(b[1]) : 0 }; + return { + ahead: a ? Number(a[1]) : 0, + behind: b ? Number(b[1]) : 0, + gone: /\bgone\b/.test(track), + }; } /** Parses `git diff --name-status` (tab-separated, newline-delimited). */ +/** + * Parses `git diff/show --name-status -M -z`. + * + * With -z the output is a flat NUL-separated stream, NOT lines: a status record + * followed by its path, and for R/C entries by TWO paths (source then + * destination). Nothing is quoted or escaped, which is the whole point — the + * previous line/tab parse handed the UI git's C-quoted form of any non-ASCII + * name (`"caf\303\251.txt"`), and that same string was then passed back as a + * pathspec, so the diff for it was always empty. + */ export function parseNameStatus(stdout: string): ChangedFile[] { const files: ChangedFile[] = []; - for (const line of stdout.split("\n")) { - if (!line) { - continue; - } - const parts = line.split("\t"); - const code = parts[0] ?? ""; + const tok = stdout.split("\0").filter((t) => t.length > 0); + for (let i = 0; i < tok.length; i++) { + const code = tok[i]; const status = code.charAt(0); - // Renames/copies carry two paths (R100\told\tnew); take the destination. - const path = parts.length >= 3 ? parts[2] : parts[1] ?? ""; + // R/C carry a similarity score and two paths; the destination is the one + // that exists now, so it is the one to show and to diff — but the SOURCE + // has to be kept, because the base side of a rename lives under the old + // name. Dropping it made every rename diff as a brand-new file: the base + // was asked for a path it never had, answered nothing, and a twelve-line + // edit rendered as several hundred added lines with no history. + const renamed = status === "R" || status === "C"; + const paths = renamed ? 2 : 1; + const oldPath = renamed ? tok[i + 1] : undefined; + const path = tok[i + paths]; + i += paths; if (path) { - files.push({ path, status }); + files.push(oldPath ? { path, status, oldPath } : { path, status }); } } return files; @@ -1812,6 +3299,20 @@ export function parsePorcelainStatus(stdout: string): ChangedFile[] { if (!path) { continue; } + // UNMERGED paths first, because for them the two columns do NOT mean + // index-half and worktree-half. Git's own docs list seven unmerged codes — + // DD AU UD UA DU AA UU — and in every one the columns are the two SIDES of + // the merge. Reading them as halves emitted TWO rows for one conflicted + // file: a phantom "staged" copy (carrying an Unstage button that destroys + // the merge stages) and a worktree copy whose letter contradicted git, e.g. + // `D` on a file plainly sitting on disk. One path, one row, marked as what + // it is. + const unmerged = + x === "U" || y === "U" || (x === "A" && y === "A") || (x === "D" && y === "D"); + if (unmerged) { + files.push({ path, status: "U", staged: false, conflicted: true, conflictKind: x + y }); + continue; + } // A record can carry BOTH an index half (x) and a worktree half (y) — // e.g. "MM" = staged edit plus a newer unstaged edit. Emitting only the // index side hides the worktree half from the Changes view, and a commit diff --git a/apps/desktop/src/main/github/actions.ts b/apps/desktop/src/main/github/actions.ts index 57fed50..cb65dae 100644 --- a/apps/desktop/src/main/github/actions.ts +++ b/apps/desktop/src/main/github/actions.ts @@ -17,9 +17,13 @@ import { access, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { GitHubClient, enc, type TokenGetter } from "../githubClient"; +import { mapJob, mapRun, type RawJob, type RawRun } from "./maps"; +import { sliceLogDelta, type LogDelta } from "./logTail"; +import { PAGE_CAPS } from "../githubPaging"; import { ExpectedError } from "../expectedError"; import { errorFields, githubHttpError, networkError } from "../githubErrors"; import type { + ActionsRunsFilter, ArtifactInfo, CommitActionResult, RepoSecretInfo, @@ -35,33 +39,6 @@ const API_BASE = "https://api.github.com"; // ── Raw GitHub REST shapes (only the fields we map) ────────────────────────── -interface RawRun { - id: number; - name?: string; - display_title?: string; - status?: string; - conclusion?: string; - head_branch?: string; - event?: string; - created_at?: string; - html_url?: string; -} -interface RawStep { - name?: string; - status?: string; - conclusion?: string; - number?: number; -} -interface RawJob { - id: number; - name?: string; - status?: string; - conclusion?: string; - html_url?: string; - started_at?: string; - completed_at?: string; - steps?: RawStep[]; -} interface RawWorkflow { id: number; name?: string; @@ -88,35 +65,6 @@ interface RawVariable { // ── Mappers (Raw* → public ipc types) ──────────────────────────────────────── -function mapRun(r: RawRun): WorkflowRun { - return { - id: r.id, - name: r.name ?? r.display_title ?? "(run)", - status: r.status ?? "", - conclusion: r.conclusion ?? "", - branch: r.head_branch ?? "", - event: r.event ?? "", - createdAt: r.created_at ?? "", - htmlUrl: r.html_url ?? "", - }; -} -function mapJob(j: RawJob): WorkflowJob { - return { - id: j.id, - name: j.name ?? "(job)", - status: j.status ?? "", - conclusion: j.conclusion ?? "", - htmlUrl: j.html_url ?? "", - startedAt: j.started_at ?? "", - completedAt: j.completed_at ?? "", - steps: (j.steps ?? []).map((s) => ({ - name: s.name ?? "", - status: s.status ?? "", - conclusion: s.conclusion ?? "", - number: s.number ?? 0, - })), - }; -} function mapWorkflow(w: RawWorkflow): WorkflowInfo { return { id: w.id, @@ -178,10 +126,20 @@ function ghHeaders(token: string): Record<string, string> { * (the signed URL needs none, and GitHub rejects a forwarded Bearer). A direct 2xx * is returned as-is. Throws a clean Error on any non-OK status. */ -async function fetchSignedRedirect(token: string, path: string): Promise<Response> { +async function fetchSignedRedirect( + token: string, + path: string, + timeoutMs = 30_000, +): Promise<Response> { let res: Response; try { - res = await fetch(`${API_BASE}${path}`, { headers: ghHeaders(token), redirect: "manual" }); + res = await fetch(`${API_BASE}${path}`, { + headers: ghHeaders(token), + redirect: "manual", + // This used to be the app's ONLY un-timed network path — a hung blob + // fetch pinned a log pane on its spinner forever. + signal: AbortSignal.timeout(timeoutMs), + }); } catch { throw networkError(); } @@ -190,7 +148,7 @@ async function fetchSignedRedirect(token: string, path: string): Promise<Respons const loc = res.headers.get("location"); if (!loc) throw new Error("GitHub returned a redirect with no location."); try { - res = await fetch(loc); + res = await fetch(loc, { signal: AbortSignal.timeout(timeoutMs) }); } catch { throw networkError( "Couldn't download from GitHub's storage. Check your network connection.", @@ -203,13 +161,29 @@ async function fetchSignedRedirect(token: string, path: string): Promise<Respons // ── Reads (throw on API error) ─────────────────────────────────────────────── -/** Recent workflow runs for the repo (capped at 30, newest first). */ -export async function listRuns(client: GitHubClient, owner: string, repo: string): Promise<WorkflowRun[]> { - const raw = await client.request<{ workflow_runs?: RawRun[] }>( - "GET", - `/repos/${enc(owner)}/${enc(repo)}/actions/runs?per_page=30`, +/** Recent workflow runs for the repo (paged, newest first). `filter` narrows + * SERVER-SIDE: workflowId switches to the per-workflow endpoint; branch / + * actor / event / status ride as query params GitHub filters itself. */ +export async function listRuns( + client: GitHubClient, + owner: string, + repo: string, + filter?: ActionsRunsFilter, +): Promise<WorkflowRun[]> { + const base = filter?.workflowId + ? `/repos/${enc(owner)}/${enc(repo)}/actions/workflows/${filter.workflowId}/runs` + : `/repos/${enc(owner)}/${enc(repo)}/actions/runs`; + const qs = new URLSearchParams({ per_page: "100" }); + if (filter?.branch) qs.set("branch", filter.branch); + if (filter?.actor) qs.set("actor", filter.actor); + if (filter?.event) qs.set("event", filter.event); + if (filter?.status) qs.set("status", filter.status); + const raw = await client.requestPagedKey<RawRun>( + `${base}?${qs.toString()}`, + "workflow_runs", + PAGE_CAPS.runs, ); - return (raw.workflow_runs ?? []).map(mapRun); + return raw.map(mapRun); } /** A single run plus its jobs (GET /actions/runs/{id} + /jobs), for the detail pane. */ @@ -223,11 +197,12 @@ export async function getRunDetail( "GET", `/repos/${enc(owner)}/${enc(repo)}/actions/runs/${id}`, ); - const jobsRaw = await client.request<{ jobs?: RawJob[] }>( - "GET", + const jobsRaw = await client.requestPagedKey<RawJob>( `/repos/${enc(owner)}/${enc(repo)}/actions/runs/${id}/jobs?per_page=100`, + "jobs", + PAGE_CAPS.detail, ); - return { run: mapRun(run), jobs: (jobsRaw.jobs ?? []).map(mapJob) }; + return { run: mapRun(run), jobs: jobsRaw.map(mapJob) }; } /** All workflows declared in this repo (GET /actions/workflows). */ @@ -291,36 +266,36 @@ export async function jobLog( } /** - * Plain-text logs for a WHOLE run, assembled from its jobs. The native - * `…/runs/{id}/logs` endpoint returns a ZIP (heavy + needs unzip in-process); - * instead we fetch the run's jobs and concatenate each job's `jobLog` under a - * `=== job name ===` banner — the same text, streamable straight into the viewer. - * One job's failure is annotated inline rather than failing the whole aggregate. + * Incremental log delta for ONE job — the live-tail workhorse. GitHub's log + * endpoint has no offset support, so we re-fetch the full text and ship only + * what the renderer hasn't seen (see logTail.sliceLogDelta for the reset / + * truncation semantics). */ -export async function runLog( +export async function jobLogChunk( client: GitHubClient, owner: string, repo: string, - req: { runId: number }, -): Promise<string> { - const jobsRaw = await client.request<{ jobs?: RawJob[] }>( - "GET", - `/repos/${enc(owner)}/${enc(repo)}/actions/runs/${req.runId}/jobs?per_page=100`, - ); - const jobs = jobsRaw.jobs ?? []; - if (jobs.length === 0) return "This run reported no jobs."; - const parts: string[] = []; - for (const j of jobs) { - const name = j.name ?? `job ${j.id}`; - parts.push(`=== ${name} ===`); - try { - parts.push((await jobLog(client, owner, repo, { jobId: j.id })).trimEnd()); - } catch (err) { - parts.push(`[logs unavailable: ${err instanceof Error ? err.message : String(err)}]`); - } - parts.push(""); // blank line between jobs + req: { jobId: number; offset: number }, +): Promise<LogDelta> { + const full = await jobLog(client, owner, repo, { jobId: req.jobId }); + return sliceLogDelta(full, Math.max(0, req.offset)); +} + +/** Save one job's full log to ~/Downloads as a .log file. Mutation-shaped. */ +export async function saveLog( + client: GitHubClient, + owner: string, + repo: string, + req: { jobId: number; name: string }, +): Promise<CommitActionResult> { + try { + const text = await jobLog(client, owner, repo, { jobId: req.jobId }); + const dest = await uniqueDownloadPath(safeFileName(req.name || `job-${req.jobId}`) + ".log"); + await writeFile(dest, text, "utf8"); + return { ok: true, changed: false, message: `Saved to ${dest}` }; + } catch (err) { + return { ok: false, changed: false, ...errorFields(err) }; } - return parts.join("\n"); } /** Artifacts produced by a run (GET /actions/runs/{id}/artifacts). */ diff --git a/apps/desktop/src/main/github/gists.ts b/apps/desktop/src/main/github/gists.ts index c1f89b4..0d2daa0 100644 --- a/apps/desktop/src/main/github/gists.ts +++ b/apps/desktop/src/main/github/gists.ts @@ -11,6 +11,7 @@ // can select it after a refresh. import { GitHubClient, enc, mapUser, RawUser } from "../githubClient"; +import { PAGE_CAPS } from "../githubPaging"; import { errorFields } from "../githubErrors"; import type { CommitActionResult, @@ -82,7 +83,7 @@ function mapGist(g: RawGist): GistInfo { * The list payload carries file METADATA only — file `content` is null here, so * the detail view re-fetches the full gist via `getGist`. */ export async function listGists(client: GitHubClient): Promise<GistInfo[]> { - const raw = await client.request<RawGist[]>("GET", "/gists?per_page=100"); + const raw = await client.requestPaged<RawGist>("/gists?per_page=100", PAGE_CAPS.account); return raw.map(mapGist); } diff --git a/apps/desktop/src/main/github/issues.ts b/apps/desktop/src/main/github/issues.ts index bd1d4e3..028eedb 100644 --- a/apps/desktop/src/main/github/issues.ts +++ b/apps/desktop/src/main/github/issues.ts @@ -13,7 +13,16 @@ // Everything here is REST (Issues live under the OAuth `repo` scope, same as // PRs); GraphQL is only needed for Projects v2, which lives elsewhere. -import { GitHubClient, enc, mapUser, type RawUser } from "../githubClient"; +import { GitHubClient, enc } from "../githubClient"; +import { + mapComment, + mapIssue, + mapUser, + type RawIssue, + type RawIssueComment, + type RawUser, +} from "./maps"; +import { PAGE_CAPS } from "../githubPaging"; import { errorFields } from "../githubErrors"; import type { CommitActionResult, @@ -27,30 +36,6 @@ import type { // ── Raw GitHub payloads (only what we read) ────────────────────────────────── -interface RawLabelRef { - name: string; - color: string; -} -interface RawIssue { - number: number; - title: string; - body: string | null; - state: string; - html_url: string; - user: RawUser | null; - created_at: string; - updated_at: string; - comments: number; - labels?: (RawLabelRef | string)[]; - assignees?: RawUser[]; - pull_request?: unknown; -} -interface RawIssueComment { - id: number; - user?: RawUser | null; - body?: string | null; - created_at: string; -} interface RawRepoLabel { name: string; color: string; @@ -67,34 +52,7 @@ interface RawMilestone { // ── Mappers ────────────────────────────────────────────────────────────────── -function mapIssue(i: RawIssue): IssueInfo { - return { - number: i.number, - title: i.title, - body: i.body, - state: i.state, - htmlUrl: i.html_url, - user: mapUser(i.user), - createdAt: i.created_at, - updatedAt: i.updated_at, - comments: i.comments, - labels: (i.labels ?? []).map((l) => - typeof l === "string" ? { name: l, color: "888888" } : { name: l.name, color: l.color }, - ), - assignees: (i.assignees ?? []) - .map(mapUser) - .filter((u): u is GitHubUser => u !== null), - }; -} -function mapComment(c: RawIssueComment): IssueComment { - return { - id: c.id, - author: mapUser(c.user ?? null), - body: c.body ?? "", - createdAt: c.created_at, - }; -} function mapLabel(l: RawRepoLabel): RepoLabel { return { name: l.name, color: l.color, description: l.description ?? null }; @@ -123,9 +81,9 @@ export async function listIssues( repo: string, state: "open" | "closed" | "all" = "open", ): Promise<IssueInfo[]> { - const raw = await client.request<RawIssue[]>( - "GET", - `/repos/${enc(owner)}/${enc(repo)}/issues?state=${state}&sort=updated&direction=desc&per_page=50`, + const raw = await client.requestPaged<RawIssue>( + `/repos/${enc(owner)}/${enc(repo)}/issues?state=${state}&sort=updated&direction=desc&per_page=100`, + PAGE_CAPS.list, ); return raw.filter((i) => !i.pull_request).map(mapIssue); } @@ -145,9 +103,9 @@ export async function getIssueDetail( await client.request<RawIssue>("GET", `/repos/${enc(owner)}/${enc(repo)}/issues/${n}`), ); const comments = await client - .request<RawIssueComment[]>( - "GET", + .requestPaged<RawIssueComment>( `/repos/${enc(owner)}/${enc(repo)}/issues/${n}/comments?per_page=100`, + PAGE_CAPS.detail, ) .then((raw) => raw.map(mapComment)) .catch(() => [] as IssueComment[]); @@ -160,9 +118,9 @@ export async function listLabels( owner: string, repo: string, ): Promise<RepoLabel[]> { - const raw = await client.request<RawRepoLabel[]>( - "GET", + const raw = await client.requestPaged<RawRepoLabel>( `/repos/${enc(owner)}/${enc(repo)}/labels?per_page=100`, + PAGE_CAPS.detail, ); return raw.map(mapLabel); } @@ -245,11 +203,37 @@ export async function milestones( * Open a new issue. Returns the created issue's `number` so the caller can * select it. This is its own result shape (carries `number`) per the channel. */ +/** + * The REST body for a new issue, as one pure function. + * + * Labels, assignees and the milestone are sent WITH the issue rather than + * patched on afterwards: a second request can fail on its own, and an issue + * that exists without the labels its author chose has already been announced to + * everyone watching the repository. Empty selections are OMITTED rather than + * sent as `[]` — GitHub reads an explicit empty array as "clear these", which + * on a create is a different statement from "I did not choose any". + */ +export function newIssueBody(req: { + title: string; + body?: string; + labels?: string[]; + assignees?: string[]; + milestone?: number; +}): Record<string, unknown> { + return { + title: req.title, + body: req.body ?? "", + ...(req.labels?.length ? { labels: req.labels } : {}), + ...(req.assignees?.length ? { assignees: req.assignees } : {}), + ...(req.milestone !== undefined ? { milestone: req.milestone } : {}), + }; +} + export async function createIssue( client: GitHubClient, owner: string, repo: string, - req: { title: string; body?: string }, + req: { title: string; body?: string; labels?: string[]; assignees?: string[]; milestone?: number }, ): Promise<{ ok: boolean; number?: number; message?: string }> { const title = req.title.trim(); if (!title) { @@ -259,7 +243,7 @@ export async function createIssue( const created = await client.request<RawIssue>( "POST", `/repos/${enc(owner)}/${enc(repo)}/issues`, - { title, body: req.body ?? "" }, + newIssueBody({ ...req, title }), ); return { ok: true, number: created.number }; } catch (err) { diff --git a/apps/desktop/src/main/github/logTail.ts b/apps/desktop/src/main/github/logTail.ts new file mode 100644 index 0000000..f0ea420 --- /dev/null +++ b/apps/desktop/src/main/github/logTail.ts @@ -0,0 +1,47 @@ +// Pure incremental-log helpers for the live-tail pipeline — no client or +// Electron imports, unit-tested in isolation. +// +// GitHub's job-log endpoint has no byte-range/offset support: a live tail +// re-fetches the FULL text each poll. `sliceLogDelta` turns that full text +// plus the renderer's last-seen offset into the smallest correct delta to +// ship over IPC — an append in the common case, an explicit reset when the +// log shrank (a re-run attempt replaced it), and a truncated tail window when +// the log outgrew the cap. + +import type { LogDelta } from "../../shared/ipc"; + +export type { LogDelta }; + +/** Hard ceiling on how much log text the renderer holds per job. */ +export const MAX_LOG_BYTES = 8 * 1024 * 1024; + +/** + * Compute the delta between the freshly fetched `full` text and the + * renderer's `offset` (how many chars it already has). `cap` bounds the + * window (defaults to {@link MAX_LOG_BYTES}). + */ +export function sliceLogDelta(full: string, offset: number, cap = MAX_LOG_BYTES): LogDelta { + const total = full.length; + // The log shrank — a re-run attempt replaced it. Start over. + if (offset > total) { + return trimmed(full, total, cap); + } + // The unseen remainder alone exceeds the cap — the append would blow the + // renderer's budget; hand back a fresh tail window instead. + if (total - offset > cap) { + return trimmed(full, total, cap); + } + return { text: full.slice(offset), totalLength: total, reset: false, truncated: false }; +} + +function trimmed(full: string, total: number, cap: number): LogDelta { + if (total <= cap) { + return { text: full, totalLength: total, reset: true, truncated: false }; + } + // Cut at a line boundary inside the tail window so the first rendered line + // isn't a torn fragment. + let start = total - cap; + const nl = full.indexOf("\n", start); + if (nl !== -1 && nl < total - 1) start = nl + 1; + return { text: full.slice(start), totalLength: total, reset: true, truncated: true }; +} diff --git a/apps/desktop/src/main/github/maps.ts b/apps/desktop/src/main/github/maps.ts new file mode 100644 index 0000000..3e1946b --- /dev/null +++ b/apps/desktop/src/main/github/maps.ts @@ -0,0 +1,414 @@ +// The ONE home for GitHub raw-payload shapes and their wire-type mappers. +// +// Pure module — imports nothing but the shared IPC types (like githubPaging), +// so every mapper unit-tests in isolation with canned API JSON. Before this +// module existed, mapRun/mapPull/mapIssue each lived in TWO divergent copies +// (githubClient.ts vs github/*.ts) and quietly disagreed about which fields +// survive; new fields now land HERE, once. +// +// Mapping convention: raw fields are optional (GitHub omits more than its +// docs admit), mapped fields are concrete — absent strings become "", absent +// arrays [], absent users null. The UI never branches on undefined. + +import type { + GitHubUser, + IssueComment, + IssueInfo, + NotificationThread, + PullRequest, + ReactionSummary, + WorkflowJob, + WorkflowRun, + WorkflowStep, +} from "../../shared/ipc"; + +// ── Users ──────────────────────────────────────────────────────────────────── + +export interface RawUser { + login: string; + avatar_url?: string; +} + +export function mapUser(u: RawUser | null | undefined): GitHubUser | null { + return u ? { login: u.login, avatarUrl: u.avatar_url ?? null } : null; +} + +// ── Workflow runs ──────────────────────────────────────────────────────────── + +export interface RawRun { + id: number; + run_number?: number; + run_attempt?: number; + /** The WORKFLOW's name ("Desktop CI"). */ + name?: string; + /** The run's own title (commit subject / PR title). */ + display_title?: string; + status?: string; + conclusion?: string; + head_branch?: string; + head_sha?: string; + event?: string; + created_at?: string; + updated_at?: string; + run_started_at?: string; + html_url?: string; + actor?: RawUser | null; + triggering_actor?: RawUser | null; + workflow_id?: number; + /** The workflow file path (".github/workflows/desktop.yml"). */ + path?: string; + head_commit?: { message?: string; author?: { name?: string } | null } | null; + pull_requests?: { number: number }[]; +} + +export function mapRun(r: RawRun): WorkflowRun { + return { + id: r.id, + runNumber: r.run_number ?? 0, + runAttempt: r.run_attempt ?? 1, + name: r.name ?? r.display_title ?? "(run)", + displayTitle: r.display_title ?? r.name ?? "(run)", + status: r.status ?? "", + conclusion: r.conclusion ?? "", + branch: r.head_branch ?? "", + headSha: r.head_sha ?? "", + event: r.event ?? "", + createdAt: r.created_at ?? "", + updatedAt: r.updated_at ?? "", + runStartedAt: r.run_started_at ?? "", + htmlUrl: r.html_url ?? "", + actor: mapUser(r.actor), + triggeringActor: mapUser(r.triggering_actor), + workflowId: r.workflow_id ?? 0, + workflowPath: r.path ?? "", + headCommitMessage: r.head_commit?.message ?? "", + headCommitAuthor: r.head_commit?.author?.name ?? "", + pullRequests: (r.pull_requests ?? []).map((p) => ({ number: p.number })), + }; +} + +// ── Workflow jobs + steps ──────────────────────────────────────────────────── + +export interface RawStep { + name?: string; + status?: string; + conclusion?: string; + number?: number; + started_at?: string | null; + completed_at?: string | null; +} + +export interface RawJob { + id: number; + run_id?: number; + run_attempt?: number; + name?: string; + status?: string; + conclusion?: string; + html_url?: string; + /** Queued time — `started_at − created_at` is the queue latency. */ + created_at?: string; + started_at?: string; + completed_at?: string; + steps?: RawStep[]; + runner_name?: string | null; + runner_group_name?: string | null; + labels?: string[]; + workflow_name?: string | null; + head_branch?: string | null; +} + +export function mapStep(s: RawStep): WorkflowStep { + return { + name: s.name ?? "", + status: s.status ?? "", + conclusion: s.conclusion ?? "", + number: s.number ?? 0, + startedAt: s.started_at ?? "", + completedAt: s.completed_at ?? "", + }; +} + +export function mapJob(j: RawJob): WorkflowJob { + return { + id: j.id, + runId: j.run_id ?? 0, + runAttempt: j.run_attempt ?? 1, + name: j.name ?? "(job)", + status: j.status ?? "", + conclusion: j.conclusion ?? "", + htmlUrl: j.html_url ?? "", + createdAt: j.created_at ?? "", + startedAt: j.started_at ?? "", + completedAt: j.completed_at ?? "", + steps: (j.steps ?? []).map(mapStep), + runnerName: j.runner_name ?? "", + runnerGroupName: j.runner_group_name ?? "", + labels: j.labels ?? [], + workflowName: j.workflow_name ?? "", + headBranch: j.head_branch ?? "", + }; +} + +// ── Reactions ──────────────────────────────────────────────────────────────── + +export interface RawReactions { + total_count?: number; + "+1"?: number; + "-1"?: number; + laugh?: number; + hooray?: number; + confused?: number; + heart?: number; + rocket?: number; + eyes?: number; +} + +/** Undefined when nobody reacted — the UI renders nothing rather than a row of + * zeroes, which is what GitHub does and what reads honestly. */ +export function mapReactions(r: RawReactions | null | undefined): ReactionSummary | undefined { + if (!r) return undefined; + const total = r.total_count ?? 0; + if (total <= 0) return undefined; + return { + total, + plusOne: r["+1"] ?? 0, + minusOne: r["-1"] ?? 0, + laugh: r.laugh ?? 0, + hooray: r.hooray ?? 0, + confused: r.confused ?? 0, + heart: r.heart ?? 0, + rocket: r.rocket ?? 0, + eyes: r.eyes ?? 0, + }; +} + +// ── Pull requests ──────────────────────────────────────────────────────────── + +export interface RawRef { + ref: string; + sha: string; + repo?: { full_name?: string } | null; +} + +export interface RawPull { + number: number; + title: string; + body: string | null; + state: string; + draft?: boolean; + html_url: string; + user: RawUser | null; + created_at: string; + updated_at: string; + head: RawRef; + base: RawRef; + labels?: { name: string; color: string }[]; + comments?: number; + additions?: number; + deletions?: number; + changed_files?: number; + assignees?: RawUser[]; + merged_at?: string | null; + closed_at?: string | null; + merged_by?: RawUser | null; + review_comments?: number; + commits?: number; + requested_reviewers?: RawUser[]; + milestone?: { number: number; title: string } | null; + author_association?: string; + reactions?: RawReactions | null; +} + +/** Users, with the nulls dropped — a list mapper that keeps `null` holes makes + * every caller re-filter. */ +function userList(raw: RawUser[] | undefined): GitHubUser[] { + return (raw ?? []).map(mapUser).filter((u): u is GitHubUser => u !== null); +} + +export function mapPull(p: RawPull): PullRequest { + return { + number: p.number, + title: p.title, + body: p.body, + state: p.state, + draft: p.draft ?? false, + htmlUrl: p.html_url, + user: mapUser(p.user), + createdAt: p.created_at, + updatedAt: p.updated_at, + head: { ref: p.head.ref, sha: p.head.sha }, + base: { ref: p.base.ref, sha: p.base.sha }, + labels: (p.labels ?? []).map((l) => ({ name: l.name, color: l.color })), + comments: p.comments, + additions: p.additions, + deletions: p.deletions, + changedFiles: p.changed_files, + assignees: userList(p.assignees), + mergedAt: p.merged_at ?? null, + closedAt: p.closed_at ?? null, + mergedBy: mapUser(p.merged_by), + reviewComments: p.review_comments, + commits: p.commits, + requestedReviewers: userList(p.requested_reviewers), + milestone: p.milestone ? { number: p.milestone.number, title: p.milestone.title } : null, + authorAssociation: p.author_association, + // Only meaningful when it DIFFERS from base — a same-repo branch is the + // normal case and shouldn't paint a "fork" pill on every row. + headRepoFullName: + p.head.repo?.full_name && p.head.repo.full_name !== p.base.repo?.full_name + ? p.head.repo.full_name + : null, + reactions: mapReactions(p.reactions), + }; +} + +// ── Issues ─────────────────────────────────────────────────────────────────── + +export interface RawLabelRef { + name: string; + color: string; +} + +export interface RawIssue { + number: number; + title: string; + body: string | null; + state: string; + html_url: string; + user: RawUser | null; + created_at: string; + updated_at: string; + comments: number; + labels?: (RawLabelRef | string)[]; + assignees?: RawUser[]; + milestone?: { number: number; title: string } | null; + pull_request?: unknown; + closed_at?: string | null; + closed_by?: RawUser | null; + state_reason?: string | null; + author_association?: string; + reactions?: RawReactions | null; +} + +export function mapIssue(i: RawIssue): IssueInfo { + return { + number: i.number, + title: i.title, + body: i.body, + state: i.state, + htmlUrl: i.html_url, + user: mapUser(i.user), + createdAt: i.created_at, + updatedAt: i.updated_at, + comments: i.comments, + labels: (i.labels ?? []).map((l) => + typeof l === "string" ? { name: l, color: "888888" } : { name: l.name, color: l.color }, + ), + assignees: userList(i.assignees), + milestone: i.milestone ? { number: i.milestone.number, title: i.milestone.title } : null, + closedAt: i.closed_at ?? null, + closedBy: mapUser(i.closed_by), + stateReason: i.state_reason ?? null, + authorAssociation: i.author_association, + reactions: mapReactions(i.reactions), + }; +} + +export interface RawIssueComment { + id: number; + user?: RawUser | null; + body?: string | null; + created_at: string; + updated_at?: string; + author_association?: string; + reactions?: RawReactions | null; +} + +export function mapComment(c: RawIssueComment): IssueComment { + return { + id: c.id, + author: mapUser(c.user ?? null), + body: c.body ?? "", + createdAt: c.created_at, + updatedAt: c.updated_at, + authorAssociation: c.author_association, + reactions: mapReactions(c.reactions), + }; +} + +// ── Notifications ──────────────────────────────────────────────────────────── + +export interface RawNotification { + id: string; + unread: boolean; + reason: string; + updated_at: string; + last_read_at?: string | null; + subject: { title?: string; type?: string; url?: string | null } | null; + repository: { + full_name: string; + html_url: string; + owner?: { avatar_url?: string } | null; + } | null; +} + +/** What a notification is ABOUT, parsed out of the subject's API url. + * + * This is the whole reason Inbox rows can open in-app: the subject carries no + * html_url and no number, but its API url's tail IS the number (or the sha). + * Pure and exported so every form GitHub emits is pinned by tests. */ +export function subjectRef( + type: string | undefined, + apiUrl: string | null | undefined, +): { kind: NotificationThread["subjectKind"]; number?: number; sha?: string } { + const url = apiUrl ?? ""; + const numbered = /\/repos\/[^/]+\/[^/]+\/(pulls|issues|releases)\/(\d+)(?:$|[?#])/.exec(url); + if (numbered) { + const kind = numbered[1] === "pulls" ? "pull" : numbered[1] === "issues" ? "issue" : "release"; + return { kind, number: Number(numbered[2]) }; + } + const commit = /\/repos\/[^/]+\/[^/]+\/commits\/([0-9a-f]{7,40})(?:$|[?#])/i.exec(url); + if (commit) return { kind: "commit", sha: commit[1] }; + // No usable url (Discussions, and Releases addressed by tag) — fall back to + // the declared subject type so the row can still say what it is. + const t = (type ?? "").toLowerCase(); + if (t === "pullrequest") return { kind: "pull" }; + if (t === "issue") return { kind: "issue" }; + if (t === "release") return { kind: "release" }; + if (t === "commit") return { kind: "commit" }; + if (t === "discussion") return { kind: "discussion" }; + return { kind: "other" }; +} + +/** github.com url for a notification subject — numbered issues/PRs/releases + * resolve exactly; anything else falls back to the repository. */ +export function subjectHtmlUrl(n: RawNotification): string { + const repo = n.repository?.full_name ?? ""; + const ref = subjectRef(n.subject?.type, n.subject?.url); + if (repo && ref.number !== undefined) { + const path = ref.kind === "pull" ? "pull" : ref.kind === "issue" ? "issues" : "releases"; + return `https://github.com/${repo}/${path}/${ref.number}`; + } + if (repo && ref.sha) return `https://github.com/${repo}/commit/${ref.sha}`; + return n.repository?.html_url ?? ""; +} + +export function mapNotification(n: RawNotification): NotificationThread { + const ref = subjectRef(n.subject?.type, n.subject?.url); + return { + id: n.id, + title: n.subject?.title ?? "(untitled)", + type: n.subject?.type ?? "", + reason: n.reason ?? "", + repo: n.repository?.full_name ?? "", + repoAvatarUrl: n.repository?.owner?.avatar_url ?? null, + updatedAt: n.updated_at ?? "", + unread: n.unread ?? false, + htmlUrl: subjectHtmlUrl(n), + lastReadAt: n.last_read_at ?? null, + subjectKind: ref.kind, + subjectNumber: ref.number, + subjectSha: ref.sha, + }; +} diff --git a/apps/desktop/src/main/github/myWork.ts b/apps/desktop/src/main/github/myWork.ts new file mode 100644 index 0000000..56526f9 --- /dev/null +++ b/apps/desktop/src/main/github/myWork.ts @@ -0,0 +1,74 @@ +// "My Work" — everything in the CURRENT repo that involves the signed-in user, +// gathered with the search API's `@me` qualifiers (no login round-trip needed): +// • open PRs where my review is requested +// • open issues/PRs assigned to me +// • open PRs I authored +// • open items that mention me (minus my own) +// +// Four searches run in parallel; each is best-effort (one failing bucket never +// blanks the page). Items are deduped across buckets in priority order — +// review-requested beats assigned beats my-prs beats mentions — so one PR shows +// once, under the most actionable heading. + +import { GitHubClient } from "../githubClient"; +import type { MyWorkItem } from "../../shared/ipc"; + +interface RawSearchIssue { + number: number; + title: string; + state: string; + draft?: boolean; + pull_request?: unknown; + updated_at: string; + comments?: number; + user?: { login?: string } | null; +} + +async function search(client: GitHubClient, q: string): Promise<RawSearchIssue[]> { + const res = await client.request<{ items?: RawSearchIssue[] }>( + "GET", + `/search/issues?q=${encodeURIComponent(q)}&sort=updated&per_page=50`, + ); + return res.items ?? []; +} + +export async function myWork( + client: GitHubClient, + owner: string, + repo: string, +): Promise<MyWorkItem[]> { + const scope = `repo:${owner}/${repo} is:open`; + const [rev, assigned, mine, mentions] = await Promise.all([ + search(client, `${scope} is:pr review-requested:@me`).catch(() => [] as RawSearchIssue[]), + search(client, `${scope} assignee:@me`).catch(() => [] as RawSearchIssue[]), + search(client, `${scope} is:pr author:@me`).catch(() => [] as RawSearchIssue[]), + search(client, `${scope} mentions:@me -author:@me`).catch(() => [] as RawSearchIssue[]), + ]); + + const out: MyWorkItem[] = []; + const seen = new Set<string>(); + const push = (kind: MyWorkItem["kind"], items: RawSearchIssue[]): void => { + for (const it of items) { + const type: MyWorkItem["type"] = it.pull_request ? "pr" : "issue"; + const key = `${type}#${it.number}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ + kind, + type, + number: it.number, + title: it.title, + state: it.state, + draft: !!it.draft, + updatedAt: it.updated_at, + comments: it.comments ?? 0, + author: it.user?.login ?? null, + }); + } + }; + push("review-requested", rev); + push("assigned", assigned); + push("my-prs", mine); + push("mentions", mentions); + return out; +} diff --git a/apps/desktop/src/main/github/notifications.ts b/apps/desktop/src/main/github/notifications.ts index b933ce0..431d046 100644 --- a/apps/desktop/src/main/github/notifications.ts +++ b/apps/desktop/src/main/github/notifications.ts @@ -10,7 +10,9 @@ // scope is needed; the existing `request`/`requestBody` primitives set Bearer. import { GitHubClient, enc } from "../githubClient"; +import { PAGE_CAPS } from "../githubPaging"; import { errorFields } from "../githubErrors"; +import { mapNotification, type RawNotification } from "./maps"; import type { NotificationActionResult, NotificationThread } from "../../shared/ipc"; /** Options for the inbox listing (mirrors the IPC request shape). */ @@ -34,8 +36,11 @@ export async function listNotifications( const qs = new URLSearchParams(); if (opts.all) qs.set("all", "true"); if (opts.participating) qs.set("participating", "true"); - qs.set("per_page", "50"); - const raw = await client.request<RawNotification[]>("GET", `/notifications?${qs.toString()}`); + qs.set("per_page", "50"); // the notifications endpoint caps per_page at 50 + const raw = await client.requestPaged<RawNotification>( + `/notifications?${qs.toString()}`, + PAGE_CAPS.notifications, + ); return raw.map(mapNotification); } @@ -75,57 +80,5 @@ export async function markAllNotificationsRead( // ── Raw API shapes + mappers ───────────────────────────────────────────────── -interface RawNotificationOwner { - avatar_url?: string | null; -} -interface RawNotificationSubject { - title: string; - type: string; - url: string | null; - latest_comment_url: string | null; -} -interface RawNotificationRepository { - full_name: string; - html_url: string; - owner?: RawNotificationOwner | null; -} -interface RawNotification { - id: string; - unread: boolean; - reason: string; - updated_at: string; - subject: RawNotificationSubject; - repository: RawNotificationRepository; -} -function mapNotification(n: RawNotification): NotificationThread { - return { - id: n.id, - title: n.subject?.title ?? "(untitled)", - type: n.subject?.type ?? "", - reason: n.reason ?? "", - repo: n.repository?.full_name ?? "", - repoAvatarUrl: n.repository?.owner?.avatar_url ?? null, - updatedAt: n.updated_at ?? "", - unread: n.unread ?? false, - htmlUrl: subjectHtmlUrl(n), - }; -} -/** - * GitHub's notification subject `url` is an API url - * (api.github.com/repos/o/r/pulls/123) with no `html_url`. Rewrite pulls/issues - * to a github.com web url; Releases / Commits / Discussions lack a clean - * numbered subject url, so fall back to the repository's html_url. - */ -function subjectHtmlUrl(n: RawNotification): string { - const api = n.subject?.url ?? ""; - if (api) { - const m = api.match(/repos\/([^/]+)\/([^/]+)\/(pulls|issues)\/(\d+)/); - if (m) { - const kind = m[3] === "pulls" ? "pull" : "issues"; - return `https://github.com/${m[1]}/${m[2]}/${kind}/${m[4]}`; - } - } - return n.repository?.html_url ?? ""; -} diff --git a/apps/desktop/src/main/github/orgs.ts b/apps/desktop/src/main/github/orgs.ts index c4b85d5..8d8a395 100644 --- a/apps/desktop/src/main/github/orgs.ts +++ b/apps/desktop/src/main/github/orgs.ts @@ -10,7 +10,15 @@ // not used anywhere in this module. import { GitHubClient, enc } from "../githubClient"; -import type { OrgInfo, OrgMember, OrgRepo, OrgTeam } from "../../shared/ipc"; +import { PAGE_CAPS } from "../githubPaging"; +import type { + GhUserInfo, + OrgInfo, + OrgMember, + OrgRepo, + OrgRepoDetail, + OrgTeam, +} from "../../shared/ipc"; // ── Raw GitHub shapes (only the fields we map) ──────────────────────────────── @@ -94,16 +102,16 @@ function mapMember(m: RawMember): OrgMember { /** Orgs the signed-in user has visible membership in. Orgs that hide the user's * membership won't appear — expected GitHub behavior. */ export async function listOrgs(client: GitHubClient): Promise<OrgInfo[]> { - const raw = await client.request<RawOrg[]>("GET", `/user/orgs?per_page=100`); + const raw = await client.requestPaged<RawOrg>(`/user/orgs?per_page=100`, PAGE_CAPS.account); return raw.map(mapOrg); } /** An org's repositories, most-recently-pushed first. Private repos appear when * the OAuth token also carries `repo`; needs no extra scope of its own. */ export async function listOrgRepos(client: GitHubClient, org: string): Promise<OrgRepo[]> { - const raw = await client.request<RawRepo[]>( - "GET", + const raw = await client.requestPaged<RawRepo>( `/orgs/${enc(org)}/repos?sort=pushed&direction=desc&per_page=100`, + PAGE_CAPS.account, ); return raw.map(mapRepo); } @@ -111,13 +119,126 @@ export async function listOrgRepos(client: GitHubClient, org: string): Promise<O /** An org's teams. Requires `read:org` + org membership; non-members get a 403 * that surfaces as the renderer's errorState + Retry. */ export async function listOrgTeams(client: GitHubClient, org: string): Promise<OrgTeam[]> { - const raw = await client.request<RawTeam[]>("GET", `/orgs/${enc(org)}/teams?per_page=100`); + const raw = await client.requestPaged<RawTeam>(`/orgs/${enc(org)}/teams?per_page=100`, PAGE_CAPS.account); return raw.map(mapTeam); } /** An org's members, per the org's visibility. A non-owner may only see public * members → can be empty even for large orgs; a restricted list yields a 403. */ export async function listOrgMembers(client: GitHubClient, org: string): Promise<OrgMember[]> { - const raw = await client.request<RawMember[]>("GET", `/orgs/${enc(org)}/members?per_page=100`); + const raw = await client.requestPaged<RawMember>(`/orgs/${enc(org)}/members?per_page=100`, PAGE_CAPS.account); return raw.map(mapMember); } + +/** The full-repo raw shape (only the extra fields the repo peek renders). */ +interface RawRepoDetail extends RawRepo { + clone_url?: string; + ssh_url?: string; + default_branch?: string; + open_issues_count?: number; + forks_count?: number; + topics?: string[]; + license?: { name?: string } | null; + created_at?: string; + homepage?: string | null; +} + +/** One repository's full record ("owner/repo") — the org-repo peek's body. */ +export async function getOrgRepoDetail( + client: GitHubClient, + fullName: string, +): Promise<OrgRepoDetail> { + const [owner, repo] = fullName.split("/", 2); + const r = await client.request<RawRepoDetail>("GET", `/repos/${enc(owner)}/${enc(repo)}`); + return { + fullName: r.full_name, + description: r.description ?? null, + htmlUrl: r.html_url, + cloneUrl: r.clone_url ?? `${r.html_url}.git`, + sshUrl: r.ssh_url ?? "", + defaultBranch: r.default_branch ?? "main", + openIssuesCount: r.open_issues_count ?? 0, + forksCount: r.forks_count ?? 0, + stargazersCount: r.stargazers_count ?? 0, + topics: r.topics ?? [], + license: r.license?.name ?? null, + language: r.language ?? null, + private: r.private ?? false, + archived: r.archived ?? false, + fork: r.fork ?? false, + pushedAt: r.pushed_at ?? "", + createdAt: r.created_at ?? "", + homepage: r.homepage ?? null, + }; +} + +/** A team's members — the team peek's drill-in list. Same visibility rules as + * listOrgTeams (needs read:org; non-members 403 → errorState in the peek). */ +export async function listTeamMembers( + client: GitHubClient, + org: string, + slug: string, +): Promise<OrgMember[]> { + const raw = await client.requestPaged<RawMember>( + `/orgs/${enc(org)}/teams/${enc(slug)}/members?per_page=100`, + PAGE_CAPS.account, + ); + return raw.map(mapMember); +} + +interface RawUser extends RawMember { + name?: string | null; + bio?: string | null; + company?: string | null; + location?: string | null; + blog?: string | null; + followers?: number; + following?: number; + public_repos?: number; + created_at?: string; + /** "User" or "Organization" — an Explore profile page must know which. */ + type?: string; + twitter_username?: string | null; + email?: string | null; +} + +/** A user's public profile — the member peek's body. */ +export async function getUserInfo(client: GitHubClient, login: string): Promise<GhUserInfo> { + const u = await client.request<RawUser>("GET", `/users/${enc(login)}`); + return { + login: u.login, + name: u.name ?? null, + avatarUrl: u.avatar_url ?? null, + bio: u.bio ?? null, + company: u.company ?? null, + location: u.location ?? null, + blog: u.blog ?? null, + htmlUrl: u.html_url ?? `https://github.com/${u.login}`, + followers: u.followers ?? 0, + following: u.following ?? 0, + publicRepos: u.public_repos ?? 0, + createdAt: u.created_at ?? "", + type: u.type ?? "User", + twitter: u.twitter_username ?? null, + email: u.email ?? null, + }; +} + +/** A user's public repositories — the profile page's list. Sorted by GitHub's + * "updated" so the page opens on what they're actually working on. */ +export async function listUserRepos(client: GitHubClient, login: string): Promise<OrgRepo[]> { + const raw = await client.requestPaged<RawRepo>( + `/users/${enc(login)}/repos?per_page=100&sort=updated&direction=desc`, + PAGE_CAPS.account, + ); + return raw.map(mapRepo); +} + +/** The organizations a user belongs to (public membership only). */ +export async function listUserOrgs(client: GitHubClient, login: string): Promise<OrgInfo[]> { + const raw = await client.requestPaged<RawOrg>( + `/users/${enc(login)}/orgs?per_page=100`, + PAGE_CAPS.account, + ); + return raw.map(mapOrg); +} diff --git a/apps/desktop/src/main/github/prs.ts b/apps/desktop/src/main/github/prs.ts index 1c4137f..4a14f03 100644 --- a/apps/desktop/src/main/github/prs.ts +++ b/apps/desktop/src/main/github/prs.ts @@ -18,7 +18,9 @@ // the client keeps private (RawPull/mapPull) are redefined locally so this // module is self-contained. -import { GitHubClient, enc, mapUser, RawUser } from "../githubClient"; +import { GitHubClient, enc } from "../githubClient"; +import { mapPull, mapUser, type RawPull, type RawUser } from "./maps"; +import { PAGE_CAPS } from "../githubPaging"; import { errorFields } from "../githubErrors"; import type { BranchRef, @@ -36,28 +38,6 @@ import type { // ── Raw API shapes (snake_case, GitHub REST) ────────────────────────────────── -interface RawRef { - ref: string; - sha: string; -} -interface RawPull { - number: number; - title: string; - body: string | null; - state: string; - draft?: boolean; - html_url: string; - user: RawUser | null; - created_at: string; - updated_at: string; - head: RawRef; - base: RawRef; - labels?: { name: string; color: string }[]; - comments?: number; - additions?: number; - deletions?: number; - changed_files?: number; -} interface RawBranch { name: string; } @@ -77,26 +57,6 @@ interface RawContents { size?: number; } -function mapPull(p: RawPull): PullRequest { - return { - number: p.number, - title: p.title, - body: p.body, - state: p.state, - draft: p.draft ?? false, - htmlUrl: p.html_url, - user: mapUser(p.user), - createdAt: p.created_at, - updatedAt: p.updated_at, - head: { ref: p.head.ref, sha: p.head.sha }, - base: { ref: p.base.ref, sha: p.base.sha }, - labels: (p.labels ?? []).map((l) => ({ name: l.name, color: l.color })), - comments: p.comments, - additions: p.additions, - deletions: p.deletions, - changedFiles: p.changed_files, - }; -} function errMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); @@ -129,7 +89,7 @@ async function fileTextAt( repo: string, path: string, ref: string, -): Promise<string> { +): Promise<{ text: string; binary?: boolean; truncated?: boolean }> { // Hard cap: never decode more than ~2MB of base64 into the renderer. const MAX_BYTES = 2 * 1024 * 1024; let raw: RawContents; @@ -141,23 +101,31 @@ async function fileTextAt( } catch (err) { // The Contents API 404s when the path is absent at this ref — that's the // "added on one side / removed on the other" case, so the side is empty. - if (/not found/i.test(errMessage(err))) return ""; + if (/not found/i.test(errMessage(err))) return { text: "" }; throw err; } if (typeof raw.size === "number" && raw.size > MAX_BYTES) { - return `// File too large to display (${Math.round(raw.size / 1024)} KB).`; + return { text: "", truncated: true }; } if (raw.encoding !== "base64" || !raw.content) { // No inlined content (oversized blob or a non-file entry) — degrade cleanly. - return raw.content ? raw.content : "// Diff not available for this file."; + return raw.content ? { text: raw.content } : { text: "", truncated: true }; } try { const text = Buffer.from(raw.content, "base64").toString("utf8"); - // A NUL byte means binary — Monaco would render mojibake, so blank it. - if (text.includes(String.fromCharCode(0))) return "// Binary file not shown."; - return text; + // A NUL byte means binary. + // + // This used to return the STRING "// Binary file not shown." for both + // sides, which is a lie with a specific consequence: two identical texts. + // The unified view builds with `hideUnchangedRegions`, and a diff with no + // changes collapses the entire file to one "N hidden lines" band — so a + // binary in a pull request rendered as a completely empty unified view + // while the side-by-side view showed the placeholder twice. Say what it is + // on the wire and let the panel draw the explanation. + if (text.includes(String.fromCharCode(0))) return { text: "", binary: true }; + return { text }; } catch { - return "// Diff not available for this file."; + return { text: "", binary: true }; } } @@ -303,7 +271,7 @@ export async function prBranches( repo: string, ): Promise<BranchRef[]> { const [branches, def] = await Promise.all([ - client.request<RawBranch[]>("GET", `/repos/${enc(owner)}/${enc(repo)}/branches?per_page=100`), + client.requestPaged<RawBranch>(`/repos/${enc(owner)}/${enc(repo)}/branches?per_page=100`, PAGE_CAPS.account), client .request<RawRepoMeta>("GET", `/repos/${enc(owner)}/${enc(repo)}`) .then((m) => m.default_branch ?? "main") @@ -323,9 +291,9 @@ export async function prReviewers( repo: string, ): Promise<RepoCollaborator[]> { try { - const raw = await client.request<RawUser[]>( - "GET", + const raw = await client.requestPaged<RawUser>( `/repos/${enc(owner)}/${enc(repo)}/collaborators?per_page=100`, + PAGE_CAPS.account, ); return raw.map((u) => ({ login: u.login, avatarUrl: u.avatar_url ?? null })); } catch { @@ -350,7 +318,7 @@ export async function fileDiff( req: { number: number; path: string }, ): Promise<FileDiff | undefined> { const { baseSha, headSha } = await prRefs(client, owner, repo, req.number); - const [leftText, rightText] = await Promise.all([ + const [left, right] = await Promise.all([ fileTextAt(client, owner, repo, req.path, baseSha), fileTextAt(client, owner, repo, req.path, headSha), ]); @@ -358,9 +326,11 @@ export async function fileDiff( path: req.path, leftLabel: "base", rightLabel: "head", - leftText, - rightText, + leftText: left.text, + rightText: right.text, conflicted: false, + ...(left.binary || right.binary ? { binary: true } : {}), + ...(left.truncated || right.truncated ? { truncated: true } : {}), }; } @@ -443,9 +413,9 @@ export async function labels( owner: string, repo: string, ): Promise<RepoLabel[]> { - const raw = await client.request<RawLabel[]>( - "GET", + const raw = await client.requestPaged<RawLabel>( `/repos/${enc(owner)}/${enc(repo)}/labels?per_page=100`, + PAGE_CAPS.detail, ); return raw.map((l) => ({ name: l.name, color: l.color, description: l.description ?? null })); } diff --git a/apps/desktop/src/main/github/releases.ts b/apps/desktop/src/main/github/releases.ts index 96b0111..dec3b78 100644 --- a/apps/desktop/src/main/github/releases.ts +++ b/apps/desktop/src/main/github/releases.ts @@ -11,9 +11,11 @@ // surfaces a 403 from the mutation calls as a normal error message. import { GitHubClient, enc, mapUser, type RawUser } from "../githubClient"; +import { PAGE_CAPS } from "../githubPaging"; import { errorFields } from "../githubErrors"; import type { CommitActionResult, + GeneratedNotes, ReleaseInfo, ReleaseInput, TagInfo, @@ -90,9 +92,9 @@ export async function listReleases( owner: string, repo: string, ): Promise<ReleaseInfo[]> { - const raw = await client.request<RawRelease[]>( - "GET", - `/repos/${enc(owner)}/${enc(repo)}/releases?per_page=50`, + const raw = await client.requestPaged<RawRelease>( + `/repos/${enc(owner)}/${enc(repo)}/releases?per_page=100`, + PAGE_CAPS.list, ); return raw.map(mapRelease); } @@ -126,6 +128,36 @@ export async function listTags( // ── Mutations (return CommitActionResult) ── +/** + * The REST body for a release, as one pure function. + * + * `make_latest` is the reason this is worth extracting: GitHub takes the + * string "true"/"false", omitting it means "you decide" (it picks by date), and + * getting that wrong silently moves the repository's Latest badge onto whatever + * was published most recently — including a back-ported tag. A caller that + * never asked the question must not answer it. + */ +export function releaseBody( + input: ReleaseInput, + o: { forCreate: boolean }, +): Record<string, unknown> { + return { + tag_name: input.tagName, + target_commitish: input.targetCommitish || undefined, + // A NEW release with no title sensibly defaults to the tag; an EDIT sends + // the raw name (including "") so an emptied title clears it rather than + // being silently overwritten with the tag. + name: o.forCreate ? input.name || input.tagName : (input.name ?? ""), + body: input.body ?? "", + draft: input.draft ?? false, + prerelease: input.prerelease ?? false, + ...(input.makeLatest === undefined + ? {} + : { make_latest: input.makeLatest ? "true" : "false" }), + }; +} + + /** * Draft or publish a release. An empty `targetCommitish` is sent as `undefined` * so GitHub uses the repo's default branch rather than erroring on "". If the @@ -136,18 +168,16 @@ export async function createRelease( owner: string, repo: string, input: ReleaseInput, -): Promise<CommitActionResult> { +): Promise<CommitActionResult & { id?: number }> { try { - await client.requestBody("POST", `/repos/${enc(owner)}/${enc(repo)}/releases`, { - tag_name: input.tagName, - target_commitish: input.targetCommitish || undefined, - // A new release with no title sensibly defaults to the tag. - name: input.name || input.tagName, - body: input.body ?? "", - draft: input.draft ?? false, - prerelease: input.prerelease ?? false, - }); - return { ok: true, changed: true }; + // `request`, not `requestBody`: the new release's id is how the composer + // lands on what you just published instead of on a list of everything. + const created = await client.request<{ id?: number }>( + "POST", + `/repos/${enc(owner)}/${enc(repo)}/releases`, + releaseBody(input, { forCreate: true }), + ); + return { ok: true, changed: true, id: created?.id }; } catch (err) { return { ok: false, @@ -171,16 +201,7 @@ export async function updateRelease( await client.requestBody( "PATCH", `/repos/${enc(owner)}/${enc(repo)}/releases/${input.id}`, - { - tag_name: input.tagName, - target_commitish: input.targetCommitish || undefined, - // Send the raw name (incl. "") so an emptied title clears it rather than - // being silently overwritten with the tag. - name: input.name ?? "", - body: input.body ?? "", - draft: input.draft ?? false, - prerelease: input.prerelease ?? false, - }, + releaseBody(input, { forCreate: false }), ); return { ok: true, changed: true }; } catch (err) { @@ -217,3 +238,64 @@ export async function deleteRelease( }; } } + +/** Upload ONE asset's bytes to a release. Mutation-shaped: never throws. */ +export async function uploadAssetData( + client: GitHubClient, + owner: string, + repo: string, + releaseId: number, + name: string, + data: Uint8Array, + contentType: string, +): Promise<CommitActionResult> { + try { + await client.uploadReleaseAsset(owner, repo, releaseId, name, data, contentType); + return { ok: true, changed: false }; + } catch (err) { + return { ok: false, changed: false, ...errorFields(err) }; + } +} + +/** Delete one release asset by id. Mutation-shaped: never throws. */ +export async function deleteAsset( + client: GitHubClient, + owner: string, + repo: string, + assetId: number, +): Promise<CommitActionResult> { + try { + await client.request("DELETE", `/repos/${enc(owner)}/${enc(repo)}/releases/assets/${assetId}`); + return { ok: true, changed: false }; + } catch (err) { + return { ok: false, changed: false, ...errorFields(err) }; + } +} + +/** + * GitHub's own release notes for a tag — the website's "Generate release + * notes" button, which reads the pull requests merged since the previous tag. + * + * `previous_tag_name` is omitted rather than guessed: GitHub picks the last + * release itself, and a wrong guess produces a changelog that silently starts + * in the wrong place. + */ +export async function generateNotes( + client: GitHubClient, + owner: string, + repo: string, + req: { tagName: string; targetCommitish?: string; previousTagName?: string }, +): Promise<GeneratedNotes> { + // `request`, not `requestBody`: the generated notes ARE the response, and + // requestBody throws the body away. + const raw = await client.request<{ name?: string; body?: string }>( + "POST", + `/repos/${enc(owner)}/${enc(repo)}/releases/generate-notes`, + { + tag_name: req.tagName, + target_commitish: req.targetCommitish || undefined, + previous_tag_name: req.previousTagName || undefined, + }, + ); + return { name: raw?.name ?? "", body: raw?.body ?? "" }; +} diff --git a/apps/desktop/src/main/github/repoBrowse.ts b/apps/desktop/src/main/github/repoBrowse.ts new file mode 100644 index 0000000..5c7309d --- /dev/null +++ b/apps/desktop/src/main/github/repoBrowse.ts @@ -0,0 +1,175 @@ +// Browse ANY GitHub repository in-app WITHOUT cloning it — the read side of +// "the native GitHub app". Three read-only REST calls over the contents API: +// a directory listing, a file's text, and the repo README. This is what turns +// an org's repo list from a launcher for github.com into a place you can +// actually LOOK AT code (and then clone, one action later, if it earns it). +// +// All three THROW on API error (clean Error via the client), so the renderer +// paints an errorState + Retry. Notably, a 404 on an ORG repo frequently means +// the org restricts OAuth-app access — the renderer explains that instead of +// showing a bare "Not Found" (the exact "scuffed info" that pushed users back +// to the website). + +import { GitHubClient, enc } from "../githubClient"; +import { PAGE_CAPS } from "../githubPaging"; +import type { GhRepoBranch, GhRepoEntry, GhRepoFile, GhRepoPaths } from "../../shared/ipc"; + +/** Encode a repo-relative path segment-by-segment (slashes must survive). */ +function encPath(path: string): string { + return path.split("/").filter(Boolean).map(encodeURIComponent).join("/"); +} + +/** `?ref=` for a non-default branch/tag/sha, or "" for the default branch. */ +function refParam(ref: string | undefined, sep: "?" | "&" = "?"): string { + return ref ? `${sep}ref=${encodeURIComponent(ref)}` : ""; +} + +/** Split "owner/repo" for the API path. */ +function ownerRepo(fullName: string): string { + const [owner, repo] = fullName.split("/", 2); + return `${enc(owner)}/${enc(repo)}`; +} + +interface RawContent { + name: string; + path: string; + type: "file" | "dir" | "symlink" | "submodule"; + size?: number; + content?: string; + encoding?: string; +} + +/** One directory of a remote repo, dirs first then files, both name-sorted. */ +export async function listRepoDir( + client: GitHubClient, + fullName: string, + path: string, + ref?: string, +): Promise<GhRepoEntry[]> { + const p = encPath(path); + const raw = await client.request<RawContent[] | RawContent>( + "GET", + `/repos/${ownerRepo(fullName)}/contents${p ? `/${p}` : ""}?per_page=1000${refParam(ref, "&")}`, + ); + const list = Array.isArray(raw) ? raw : [raw]; + return list + .map((e): GhRepoEntry => ({ + name: e.name, + path: e.path, + type: e.type === "dir" ? "dir" : "file", + size: e.size, + })) + .sort((a, b) => + a.type !== b.type ? (a.type === "dir" ? -1 : 1) : a.name.localeCompare(b.name), + ); +} + +/** Cap remote file reads — the contents API base64-inlines up to 1MB anyway, + * and a quick look never needs more. */ +const MAX_FILE_BYTES = 1024 * 1024; + +/** One file's text from a remote repo. Binary and oversized files are flagged + * rather than dumped — the viewer shows a notice instead of garbage. */ +export async function readRepoFile( + client: GitHubClient, + fullName: string, + path: string, + ref?: string, +): Promise<GhRepoFile> { + const raw = await client.request<RawContent>( + "GET", + `/repos/${ownerRepo(fullName)}/contents/${encPath(path)}${refParam(ref)}`, + ); + const size = raw.size ?? 0; + if (size > MAX_FILE_BYTES || raw.encoding === "none" || !raw.content) { + return { path, text: "", truncated: true, binary: false, size }; + } + const buf = Buffer.from(raw.content, "base64"); + // The classic binary sniff: a NUL byte in the first 8KB. + if (buf.subarray(0, 8192).includes(0)) { + return { path, text: "", truncated: false, binary: true, size }; + } + return { path, text: buf.toString("utf8"), truncated: false, binary: false, size }; +} + +/** The repo's README markdown (GitHub resolves the preferred one), or + * undefined when the repo simply has none — that's a state, not an error. */ +export async function readRepoReadme( + client: GitHubClient, + fullName: string, + ref?: string, +): Promise<{ name: string; text: string } | undefined> { + let raw: RawContent; + try { + raw = await client.request<RawContent>( + "GET", + `/repos/${ownerRepo(fullName)}/readme${refParam(ref)}`, + ); + } catch { + return undefined; + } + if (!raw.content) { + return undefined; + } + return { name: raw.name, text: Buffer.from(raw.content, "base64").toString("utf8") }; +} + +// ── Refs + the whole-tree path index ───────────────────────────────────────── + +interface RawBranchRef { + name: string; + commit?: { sha?: string }; + protected?: boolean; +} + +/** The repo's branches — the ref switcher's options. Paged, because a busy + * repo has hundreds and a single page would silently show the first 100. */ +export async function listRepoBranches( + client: GitHubClient, + fullName: string, +): Promise<GhRepoBranch[]> { + const raw = await client.requestPaged<RawBranchRef>( + `/repos/${ownerRepo(fullName)}/branches?per_page=100`, + PAGE_CAPS.detail, + ); + return raw.map((b) => ({ + name: b.name, + sha: b.commit?.sha ?? "", + protected: b.protected ?? false, + })); +} + +interface RawTree { + tree?: { path?: string; type?: string; size?: number }[]; + truncated?: boolean; +} + +/** Every blob path in the repo, in one request — what makes "go to file" + * possible without walking directories. + * + * GitHub itself truncates enormous trees, and we cap on top of that: 25k + * paths is far past the point where fuzzy search stays useful, and holding + * more in the renderer buys nothing. BOTH truncations are reported, because + * a file search that silently can't see a file is worse than one that says so. + */ +export async function listRepoPaths( + client: GitHubClient, + fullName: string, + ref?: string, +): Promise<GhRepoPaths> { + const target = ref || "HEAD"; + const raw = await client.request<RawTree>( + "GET", + `/repos/${ownerRepo(fullName)}/git/trees/${encodeURIComponent(target)}?recursive=1`, + ); + const all = (raw.tree ?? []).filter((t) => t.type === "blob" && t.path); + const capped = all.slice(0, MAX_PATHS); + return { + paths: capped.map((t) => t.path as string), + truncated: (raw.truncated ?? false) || all.length > MAX_PATHS, + total: all.length, + }; +} + +/** Past this, fuzzy search is noise and the payload is a liability. */ +const MAX_PATHS = 25_000; diff --git a/apps/desktop/src/main/github/search.ts b/apps/desktop/src/main/github/search.ts new file mode 100644 index 0000000..c2c0963 --- /dev/null +++ b/apps/desktop/src/main/github/search.ts @@ -0,0 +1,212 @@ +// Global GitHub search — the data half of the Explore page. +// +// Unlike every other section here, search is ACCOUNT-scoped and metered on its +// own budget (30/min, 10/min for code). So the model is deliberately one API +// request per invoke: an explicit page of 30, never an automatic Link-follow. +// Pagination is a user action ("Load more"), because each page is real money +// out of a small purse. +// +// Every failure mode the UI must distinguish is expressed in the RESULT, not +// as a throw: `limited` (our own budget says wait), `incomplete` (GitHub gave +// up early), and `hasMore: false` at the hard 1000-result ceiling. + +import { GitHubClient } from "../githubClient"; +import { SearchGuard } from "./searchGuard"; +import { + beyondCeiling, + codeSearchPath, + normalizeQuery, + repoSearchPath, + userSearchPath, + SEARCH_PER_PAGE, + SEARCH_RESULT_CEILING, + type RepoSort, +} from "./searchQuery"; +import { mapUser, type RawUser } from "./maps"; +import type { + SearchCodeItem, + SearchPage, + SearchRepoItem, + SearchUserItem, +} from "../../shared/ipc"; + +/** The process-wide budget. One guard for the app: two windows fighting over + * the same GitHub quota is exactly how you earn a surprise 403. */ +const guard = new SearchGuard(); + +interface RawSearchEnvelope<T> { + total_count?: number; + incomplete_results?: boolean; + items?: T[]; +} + +interface RawSearchRepo { + id: number; + full_name: string; + owner?: RawUser | null; + description?: string | null; + language?: string | null; + stargazers_count?: number; + forks_count?: number; + open_issues_count?: number; + updated_at?: string; + pushed_at?: string; + private?: boolean; + fork?: boolean; + archived?: boolean; + topics?: string[]; + license?: { spdx_id?: string | null; name?: string } | null; + html_url?: string; + default_branch?: string; +} + +interface RawSearchCode { + name?: string; + path?: string; + html_url?: string; + repository?: { full_name?: string } | null; + text_matches?: { + fragment?: string; + /** Where in `fragment` the query matched. Offsets are into the fragment, + * and GitHub gives them in the same request that gives the fragment — the + * row just never carried them, so nothing was highlighted. */ + matches?: { text?: string; indices?: [number, number] }[]; + }[]; +} + +function mapRepo(r: RawSearchRepo): SearchRepoItem { + return { + id: r.id, + fullName: r.full_name, + owner: r.owner?.login ?? r.full_name.split("/")[0] ?? "", + ownerAvatarUrl: r.owner?.avatar_url ?? null, + description: r.description ?? null, + language: r.language ?? null, + stars: r.stargazers_count ?? 0, + forks: r.forks_count ?? 0, + openIssues: r.open_issues_count ?? 0, + updatedAt: r.updated_at ?? "", + pushedAt: r.pushed_at ?? "", + private: r.private ?? false, + fork: r.fork ?? false, + archived: r.archived ?? false, + topics: r.topics ?? [], + license: r.license?.spdx_id && r.license.spdx_id !== "NOASSERTION" ? r.license.spdx_id : null, + htmlUrl: r.html_url ?? `https://github.com/${r.full_name}`, + defaultBranch: r.default_branch ?? "", + }; +} + +function mapCode(c: RawSearchCode): SearchCodeItem { + return { + name: c.name ?? "", + path: c.path ?? "", + repoFullName: c.repository?.full_name ?? "", + htmlUrl: c.html_url ?? "", + fragments: (c.text_matches ?? []) + .filter((m) => (m.fragment ?? "").trim().length > 0) + .map((m) => { + const text = m.fragment ?? ""; + const ranges: Array<[number, number]> = []; + for (const hit of m.matches ?? []) { + const [a, b] = hit.indices ?? []; + // Trust nothing from the wire: an out-of-range pair would slice the + // fragment into gibberish or drop characters silently. + if (typeof a !== "number" || typeof b !== "number") continue; + if (a < 0 || b > text.length || a >= b) continue; + ranges.push([a, b]); + } + ranges.sort((x, y) => x[0] - y[0]); + return { text, ranges }; + }), + }; +} + +/** An empty page — what an empty query returns without spending anything. */ +function emptyPage<T>(): SearchPage<T> { + return { items: [], totalCount: 0, incomplete: false, hasMore: false }; +} + +/** Shared envelope handling: budget check → fetch → page metadata. */ +async function runSearch<Raw, Item>( + client: GitHubClient, + o: { + query: string; + page: number; + category: "core" | "code"; + path: string; + map: (raw: Raw) => Item; + accept?: string; + }, +): Promise<SearchPage<Item>> { + if (!normalizeQuery(o.query)) return emptyPage<Item>(); + // Past the ceiling GitHub answers 422 — refuse locally and keep the budget. + if (beyondCeiling(o.page)) { + return { items: [], totalCount: SEARCH_RESULT_CEILING, incomplete: false, hasMore: false }; + } + const claim = guard.take(o.category); + if (!claim.ok) { + return { ...emptyPage<Item>(), limited: { retryInMs: claim.retryInMs } }; + } + const env = await client.request<RawSearchEnvelope<Raw>>("GET", o.path, undefined, { + accept: o.accept, + }); + const items = (env.items ?? []).map(o.map); + const total = env.total_count ?? items.length; + return { + items, + totalCount: total, + incomplete: env.incomplete_results ?? false, + // More exists only if GitHub has more AND the next page is reachable. + hasMore: items.length === SEARCH_PER_PAGE && !beyondCeiling(o.page + 1) && o.page * SEARCH_PER_PAGE < total, + }; +} + +export function searchRepos( + client: GitHubClient, + req: { query: string; sort?: RepoSort; page?: number }, +): Promise<SearchPage<SearchRepoItem>> { + const page = req.page ?? 1; + return runSearch<RawSearchRepo, SearchRepoItem>(client, { + query: req.query, + page, + category: "core", + path: repoSearchPath(req.query, req.sort ?? "best", page), + map: mapRepo, + }); +} + +export function searchUsers( + client: GitHubClient, + req: { query: string; kind: "users" | "orgs"; page?: number }, +): Promise<SearchPage<SearchUserItem>> { + const page = req.page ?? 1; + return runSearch<RawUser & { html_url?: string; type?: string }, SearchUserItem>(client, { + query: req.query, + page, + category: "core", + path: userSearchPath(req.query, req.kind, page), + map: (u) => ({ + login: mapUser(u)?.login ?? u.login, + avatarUrl: u.avatar_url ?? null, + htmlUrl: u.html_url ?? `https://github.com/${u.login}`, + type: u.type ?? (req.kind === "orgs" ? "Organization" : "User"), + }), + }); +} + +export function searchCode( + client: GitHubClient, + req: { query: string; page?: number }, +): Promise<SearchPage<SearchCodeItem>> { + const page = req.page ?? 1; + return runSearch<RawSearchCode, SearchCodeItem>(client, { + query: req.query, + page, + category: "code", + path: codeSearchPath(req.query, page), + map: mapCode, + // The text-match media type is what turns a file list into readable hits. + accept: "application/vnd.github.text-match+json", + }); +} diff --git a/apps/desktop/src/main/github/searchGuard.ts b/apps/desktop/src/main/github/searchGuard.ts new file mode 100644 index 0000000..cf9d8fc --- /dev/null +++ b/apps/desktop/src/main/github/searchGuard.ts @@ -0,0 +1,60 @@ +// Search rate budget. +// +// GitHub's search API is metered separately from core REST — 30 requests per +// minute for repositories/users, and only 10 for code. Blowing through that +// earns a 403 that looks, to a user, exactly like "the app is broken". +// +// So we spend the budget deliberately: a token bucket per category, checked +// BEFORE the request. When there's nothing left we return a structured +// `limited { retryInMs }` instead of firing and hoping — the UI can then say +// "one moment" honestly, with a countdown, rather than showing an error for a +// condition that resolves itself in seconds. +// +// Pure and clock-injectable, so the whole policy is node-testable. + +export type SearchCategory = "core" | "code"; + +/** Requests per minute GitHub allows an authenticated user, per category. */ +export const SEARCH_LIMITS: Record<SearchCategory, number> = { + core: 30, + code: 10, +}; + +const WINDOW_MS = 60_000; +/** Leave a little headroom — other GitStudio surfaces share the same budget. */ +const RESERVE = 2; + +export class SearchGuard { + /** Timestamps of recent spends, per category (oldest first). */ + private readonly spent: Record<SearchCategory, number[]> = { core: [], code: [] }; + + constructor(private readonly now: () => number = () => Date.now()) {} + + /** Drop spends that have aged out of the window. */ + private prune(cat: SearchCategory): number[] { + const cutoff = this.now() - WINDOW_MS; + const kept = this.spent[cat].filter((t) => t > cutoff); + this.spent[cat] = kept; + return kept; + } + + /** Requests still available in this window. */ + remaining(cat: SearchCategory): number { + return Math.max(0, SEARCH_LIMITS[cat] - RESERVE - this.prune(cat).length); + } + + /** + * Claim one request. Returns `{ ok: true }` when the caller may proceed, or + * `{ ok: false, retryInMs }` — the wait until the oldest spend ages out. + */ + take(cat: SearchCategory): { ok: true } | { ok: false; retryInMs: number } { + const kept = this.prune(cat); + if (kept.length < SEARCH_LIMITS[cat] - RESERVE) { + kept.push(this.now()); + return { ok: true }; + } + const oldest = kept[0] ?? this.now(); + // +1s so a retry scheduled at exactly this time doesn't land a tick early. + return { ok: false, retryInMs: Math.max(0, oldest + WINDOW_MS - this.now()) + 1_000 }; + } +} diff --git a/apps/desktop/src/main/github/searchQuery.ts b/apps/desktop/src/main/github/searchQuery.ts new file mode 100644 index 0000000..6238189 --- /dev/null +++ b/apps/desktop/src/main/github/searchQuery.ts @@ -0,0 +1,84 @@ +// Pure query + path builders for GitHub search. +// +// Split from the fetchers so every URL this app can ask for is node-testable +// without a network — search paths are the easiest place to quietly build a +// wrong query (an unescaped qualifier, a sort GitHub rejects) and the hardest +// place to notice, since a wrong query returns results, just not the right +// ones. + +export type SearchKind = "repos" | "users" | "orgs" | "code"; +export type RepoSort = "best" | "stars" | "updated"; + +/** GitHub caps every search at 1000 results, however many it claims to match. */ +export const SEARCH_RESULT_CEILING = 1000; +export const SEARCH_PER_PAGE = 30; + +/** Trim, collapse whitespace — the form a query is cached and compared by. */ +export function normalizeQuery(raw: string): string { + return raw.trim().replace(/\s+/g, " "); +} + +/** + * The `q` for a user/org search. GitHub has no "orgs" endpoint — orgs are + * users with `type:org`, and the qualifier is what separates the two tabs. + * A user-supplied `type:` is left alone: someone who types it means it. + */ +export function userQuery(query: string, kind: "users" | "orgs"): string { + const q = normalizeQuery(query); + if (/\btype:\s*\S+/i.test(q)) return q; + return `${q} type:${kind === "orgs" ? "org" : "user"}`; +} + +/** `sort`/`order` params for a repo search; "best" means GitHub's own ranking + * (which is expressed by sending NO sort at all). */ +export function repoSortParams(sort: RepoSort): Record<string, string> { + if (sort === "stars") return { sort: "stars", order: "desc" }; + if (sort === "updated") return { sort: "updated", order: "desc" }; + return {}; +} + +function qs(params: Record<string, string | number>): string { + const sp = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) sp.set(k, String(v)); + return sp.toString(); +} + +/** `/search/repositories?…` for one page (1-based). */ +export function repoSearchPath(query: string, sort: RepoSort, page = 1): string { + return `/search/repositories?${qs({ + q: normalizeQuery(query), + per_page: SEARCH_PER_PAGE, + page, + ...repoSortParams(sort), + })}`; +} + +/** `/search/users?…` for one page — `kind` picks the type: qualifier. */ +export function userSearchPath(query: string, kind: "users" | "orgs", page = 1): string { + return `/search/users?${qs({ + q: userQuery(query, kind), + per_page: SEARCH_PER_PAGE, + page, + })}`; +} + +/** `/search/code?…` for one page. */ +export function codeSearchPath(query: string, page = 1): string { + return `/search/code?${qs({ + q: normalizeQuery(query), + per_page: SEARCH_PER_PAGE, + page, + })}`; +} + +/** True when this page would reach past GitHub's hard 1000-result ceiling — + * asking anyway earns a 422, so the UI stops offering "Load more" instead. */ +export function beyondCeiling(page: number): boolean { + return page * SEARCH_PER_PAGE > SEARCH_RESULT_CEILING; +} + +/** How many of `total` are actually reachable. The difference is what the UI + * must be honest about: "1,284 matches, first 1,000 available". */ +export function reachableCount(total: number): number { + return Math.min(total, SEARCH_RESULT_CEILING); +} diff --git a/apps/desktop/src/main/githubBridge.ts b/apps/desktop/src/main/githubBridge.ts index 803d2fe..a0d8ca2 100644 --- a/apps/desktop/src/main/githubBridge.ts +++ b/apps/desktop/src/main/githubBridge.ts @@ -5,8 +5,9 @@ // same `status/connect` surface; the renderer only knows about connect/disconnect // plus the data calls. -import { app, safeStorage } from "electron"; -import { readFile, unlink, access } from "node:fs/promises"; +import { app } from "electron"; +import { githubStatus } from "./githubStatus"; +import { unlink } from "node:fs/promises"; import { join } from "node:path"; import { SecretStore } from "@gitstudio/secret-store/secretStore"; @@ -16,6 +17,10 @@ import { GitHubClient } from "./githubClient"; import { requestDeviceCode, pollForToken } from "./githubAuth"; import type { RepoStore } from "./repoStore"; import { ExpectedError } from "./expectedError"; +import { parseGitHubRemote } from "./githubRemote"; + +// Re-exported so existing importers (and their tests) keep their seam. +export { parseGitHubRemote } from "./githubRemote"; import { errorFields } from "./githubErrors"; import type { CheckRun, @@ -34,6 +39,7 @@ import type { WorkflowRun, } from "../shared/ipc"; + export class GitHubBridge { private token: string | undefined; private login: string | undefined; @@ -63,61 +69,57 @@ export class GitHubBridge { this.loaded = true; this.token = await this.secrets().get(TOKEN_SECRET); if (this.token === undefined) { - this.token = await this.adoptLegacyToken(); + // NO KEYRING, EVER. A pre-1.4 safeStorage blob could only be read + // through the OS keychain, whose ACL is bound to the app's code + // signature -- so every rebuilt/re-signed binary raised the macOS + // password prompt again, and declining it made sign-in look broken. + // That migration is gone: any leftover blob is deleted unread, and the + // user signs in once more through the (now prompt-free) device flow. + // read-failure-reviewed: deleting a legacy token file that may not exist. + // Nothing is READ here and nothing downstream renders the result — a + // failure to remove it is not news. + await unlink(this.legacyTokenPath()).catch(() => {}); } } - /** - * Move a pre-1.4 safeStorage token into the keychain-free store, once. - * - * The only remaining path that can raise an OS password prompt, and it is - * reached only from `ensureLoaded`, which callers now invoke exclusively - * before a real GitHub request — never from `status()`, never at launch. After - * a successful adopt the legacy blob is deleted, so this costs at most one - * prompt, ever. Declining it just leaves GitHub disconnected for the session. - */ - private async adoptLegacyToken(): Promise<string | undefined> { - let buf: Buffer; - try { - buf = await readFile(this.legacyTokenPath()); - } catch { - return undefined; // no stored token — the keyring is never touched - } - // We never persisted a plaintext token, so a blob we can't decrypt is junk. - if (!safeStorage.isEncryptionAvailable()) { - return undefined; - } - let token: string; - try { - token = safeStorage.decryptString(buf); - } catch { - return undefined; // stored by a different machine/user - } - await this.secrets().set(TOKEN_SECRET, token); - await unlink(this.legacyTokenPath()).catch(() => {}); - return token; - } - /** Resolve owner/repo from `git remote get-url origin` (cached per repo root). */ + /** Resolve owner/repo from the repo's remotes (positive result cached per + * root). Tries `origin`, then `upstream`, then every other remote — a fork + * checked out with only an `upstream` remote used to be refused outright. + * A miss is NOT cached: adding a remote after opening the repo starts + * working on the very next call instead of after a repo switch. */ private async resolveOwnerRepo(): Promise<{ owner: string; repo: string } | undefined> { const ctx = this.repos.getContext(); if (!ctx) { return undefined; } - if (this.ownerRepoRoot === ctx.root) { + if (this.ownerRepoRoot === ctx.root && this.cachedOwnerRepo) { return this.cachedOwnerRepo; } - let url = ""; - try { - const r = await ctx.process.run(["remote", "get-url", "origin"]); - url = r.stdout.trim(); - } catch { - url = ""; + const tryRemote = async (name: string): Promise<{ owner: string; repo: string } | undefined> => { + try { + const r = await ctx.process.run(["remote", "get-url", name]); + return r.code === 0 ? parseGitHubRemote(r.stdout.trim()) : undefined; + } catch { + return undefined; + } + }; + let hit = (await tryRemote("origin")) ?? (await tryRemote("upstream")); + if (!hit) { + try { + const r = await ctx.process.run(["remote"]); + for (const name of r.stdout.split("\n").map((s) => s.trim()).filter(Boolean)) { + if (name === "origin" || name === "upstream") continue; + hit = await tryRemote(name); + if (hit) break; + } + } catch { + // no remotes at all + } } - const m = url.match(/github\.com[:/]([^/]+)\/(.+?)(?:\.git)?$/i); this.ownerRepoRoot = ctx.root; - this.cachedOwnerRepo = m ? { owner: m[1], repo: m[2] } : undefined; - return this.cachedOwnerRepo; + this.cachedOwnerRepo = hit; + return hit; } async status(): Promise<GitHubStatus> { @@ -129,31 +131,27 @@ export class GitHubBridge { // bound to the app's code signature). Whether a token FILE exists is enough // to answer "connected"; the token itself is decrypted lazily, the first // time an actual GitHub call needs it. - if (this.token) { - if (!this.login) { - this.login = await this.client.currentLogin(); - } - return { connected: !!this.login, login: this.login, repo }; - } - if (await this.hasStoredToken()) { - // Connected, but not yet unlocked — the login name fills in after the - // first real request. - return { connected: true, login: this.login, repo }; - } - return { connected: false, repo }; + if (this.token && !this.login) { + // Best effort. `currentLogin` swallows its own failures and answers + // undefined, and THAT used to decide `connected` — so one flaky request + // signed the user out of the top bar while Settings, holding the same + // account, went on showing them signed in. + this.login = await this.client.currentLogin(); + } + return githubStatus({ + hasToken: !!this.token, + hasStoredToken: await this.hasStoredToken(), + login: this.login, + repo, + }); } - /** Is there a stored token, WITHOUT decrypting it? */ + /** Is there a stored token, WITHOUT decrypting it? Only the keychain-free + * store counts — a leftover pre-1.4 keyring blob is unreadable by design + * (see ensureLoaded), so treating it as "connected" showed a signed-in UI + * whose every real request then failed. */ private async hasStoredToken(): Promise<boolean> { - if (this.secrets().has(TOKEN_SECRET)) { - return true; - } - try { - await access(this.legacyTokenPath()); - return true; - } catch { - return false; - } + return this.secrets().has(TOKEN_SECRET); } async connect(pat: string): Promise<{ ok: boolean; login?: string; message?: string }> { @@ -174,6 +172,9 @@ export class GitHubBridge { await this.secrets().set(TOKEN_SECRET, token); // A freshly entered token supersedes any pre-1.4 blob; drop it so // `hasStoredToken` can't be satisfied by a file we will never read again. + // read-failure-reviewed: deleting a legacy token file that may not exist. + // Nothing is READ here and nothing downstream renders the result — a + // failure to remove it is not news. await unlink(this.legacyTokenPath()).catch(() => {}); } catch { // best-effort persistence; the in-memory token still works this session @@ -243,7 +244,7 @@ export class GitHubBridge { } } - async prList(): Promise<PullRequest[]> { + async prList(state: "open" | "closed" | "all" = "open"): Promise<PullRequest[]> { await this.ensureLoaded(); const r = await this.resolveOwnerRepo(); if (!r || !this.token) { @@ -251,7 +252,7 @@ export class GitHubBridge { } // Let API errors (rate limit / auth / network) propagate so the renderer can // show a real error state instead of a misleading "no pull requests". - return this.client.listOpenPulls(r.owner, r.repo); + return this.client.listPulls(r.owner, r.repo, state); } /** @@ -309,8 +310,11 @@ export class GitHubBridge { } try { const pr = await this.client.getPull(r.owner, r.repo, n); + // The FILES are the point of this response — losing them silently turns + // "Files (9)" into an empty tab. The combined status is genuinely + // optional decoration, so that one still degrades to blank. const [files, status] = await Promise.all([ - this.client.getPullFiles(r.owner, r.repo, n).catch(() => []), + this.client.getPullFiles(r.owner, r.repo, n), this.client.getCombinedStatus(r.owner, r.repo, pr.head.sha).catch(() => ({ state: "", totalCount: 0 })), ]); return { pr, files, checks: status.state }; @@ -331,6 +335,11 @@ export class GitHubBridge { if (!this.token) return undefined; const { owner, repo, number, kind } = req; try { + // read-failure-reviewed: this is the AI assistant's summary of an item it + // was handed, and the title/body/state below carry the answer. A dropped + // comment list makes the summary thinner, not wrong — and the whole call + // is already inside a try that returns undefined, so letting this reject + // would throw the item away over its least important part. const comments = (await this.client.listConversation(owner, repo, number).catch(() => [])) .filter((c) => c.kind === "comment") .map((c) => ({ author: c.author || null, body: c.body, createdAt: c.createdAt })); @@ -386,15 +395,23 @@ export class GitHubBridge { } } + // A FAILED read is not an empty result, and the difference is the whole + // message. Swallowed, a rate limit or a dropped connection rendered "This PR + // has no commits yet." beside a rail reading 14 — the app stating something + // false with total confidence, and offering no way to retry. The renderer's + // errorState-with-Retry branches were already written and could never run. + // + // "Not signed in" and "no repository" ARE empty, and stay empty: nothing went + // wrong, there is simply nothing to fetch. async prCommits(n: number): Promise<PrCommitInfo[]> { const r = await this.resolveOwnerRepo(); if (!r || !this.token) return []; - return this.client.listPrCommits(r.owner, r.repo, n).catch(() => []); + return this.client.listPrCommits(r.owner, r.repo, n); } async prConversation(n: number): Promise<PrComment[]> { const r = await this.resolveOwnerRepo(); if (!r || !this.token) return []; - return this.client.listConversation(r.owner, r.repo, n).catch(() => []); + return this.client.listConversation(r.owner, r.repo, n); } async prChecks(n: number): Promise<CheckRun[]> { const r = await this.resolveOwnerRepo(); @@ -416,12 +433,6 @@ export class GitHubBridge { return { ok: false, changed: false, ...errorFields(err) }; } } - async actionsRuns(): Promise<WorkflowRun[]> { - const r = await this.resolveOwnerRepo(); - if (!r || !this.token) return []; - return this.client.listWorkflowRuns(r.owner, r.repo); - } - async issueList(): Promise<IssueInfo[]> { const r = await this.resolveOwnerRepo(); if (!r || !this.token) { diff --git a/apps/desktop/src/main/githubClient.ts b/apps/desktop/src/main/githubClient.ts index 53ace53..1288894 100644 --- a/apps/desktop/src/main/githubClient.ts +++ b/apps/desktop/src/main/githubClient.ts @@ -6,6 +6,8 @@ import { ExpectedError } from "./expectedError"; import { githubHttpError, graphqlError, networkError } from "./githubErrors"; +import { nextPagePath, PAGE_CAPS } from "./githubPaging"; +import { mapIssue, mapPull, mapUser, type RawIssue, type RawPull, type RawUser } from "./github/maps"; import type { CheckRun, GitHubUser, @@ -31,17 +33,24 @@ export type TokenGetter = () => string | undefined; export class GitHubClient { constructor(private readonly getToken: TokenGetter) {} - /** REST call returning the parsed JSON body. `body` (POST/PATCH/PUT) is sent as - * JSON. Throws a clean Error on non-2xx or network failure. Public so the - * per-section modules under ./github can call it. */ - async request<T>(method: string, path: string, body?: unknown): Promise<T> { + /** The shared fetch under `request`/`requestPaged`: auth headers, timeout, + * network-error wrapping, non-2xx → githubHttpError. Returns the RESPONSE so + * paged callers can read the `Link` header. */ + private async fetchRes( + method: string, + path: string, + body?: unknown, + opts?: { accept?: string }, + ): Promise<Response> { const token = this.getToken(); if (!token) { throw new ExpectedError("Not connected to GitHub."); } const headers: Record<string, string> = { Authorization: `Bearer ${token}`, - Accept: "application/vnd.github+json", + // Some endpoints need a different media type to return the good stuff — + // code search only includes match fragments under text-match+json. + Accept: opts?.accept ?? "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", "User-Agent": "GitStudio", }; @@ -54,16 +63,123 @@ export class GitHubClient { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, + // A hung request used to hang every gated section on its skeleton + // forever — the API must fail fast enough for the UI to say so. + signal: AbortSignal.timeout(20_000), }); } catch { throw networkError(); } - if (res.ok) { - if (res.status === 204) return undefined as T; + if (!res.ok) { + throw await githubHttpError(res); + } + return res; + } + + /** REST call returning the parsed JSON body. `body` (POST/PATCH/PUT) is sent as + * JSON. Throws a clean Error on non-2xx or network failure. Public so the + * per-section modules under ./github can call it. */ + async request<T>( + method: string, + path: string, + body?: unknown, + opts?: { accept?: string }, + ): Promise<T> { + const res = await this.fetchRes(method, path, body, opts); + if (res.status === 204) return undefined as T; + const text = await res.text(); + return (text.length > 0 ? JSON.parse(text) : undefined) as T; + } + + /** + * GET an ARRAY-bodied list endpoint, following the `Link: rel="next"` chain + * up to `maxPages` pages and concatenating the results. The first page's + * failure throws; a FOLLOW-UP page's failure returns what was gathered so far + * (a partial long list beats an error state the user already had data for). + */ + async requestPaged<T>(path: string, maxPages: number): Promise<T[]> { + const out: T[] = []; + let next: string | undefined = path; + for (let page = 0; next && page < maxPages; page++) { + let res: Response; + try { + res = await this.fetchRes("GET", next); + } catch (e) { + if (page === 0) throw e; + break; + } const text = await res.text(); - return (text.length > 0 ? JSON.parse(text) : undefined) as T; + const chunk = (text.length > 0 ? JSON.parse(text) : []) as T[]; + out.push(...chunk); + next = nextPagePath(res.headers.get("link"), API_BASE); + } + return out; + } + + /** + * As {@link requestPaged}, for OBJECT-bodied list endpoints (`{ total_count, + * workflow_runs: [...] }` and friends) — `key` names the array to gather. + */ + async requestPagedKey<T>(path: string, key: string, maxPages: number): Promise<T[]> { + const out: T[] = []; + let next: string | undefined = path; + for (let page = 0; next && page < maxPages; page++) { + let res: Response; + try { + res = await this.fetchRes("GET", next); + } catch (e) { + if (page === 0) throw e; + break; + } + const text = await res.text(); + const body = (text.length > 0 ? JSON.parse(text) : {}) as Record<string, unknown>; + const chunk = body[key]; + if (Array.isArray(chunk)) out.push(...(chunk as T[])); + next = nextPagePath(res.headers.get("link"), API_BASE); + } + return out; + } + + /** + * Upload one release asset (binary) to uploads.github.com — a different host + * than the API base, hence its own fetch. Generous timeout: installers are + * hundreds of megabytes. + */ + async uploadReleaseAsset( + owner: string, + repo: string, + releaseId: number, + name: string, + data: Uint8Array, + contentType: string, + ): Promise<void> { + const token = this.getToken(); + if (!token) { + throw new ExpectedError("Not connected to GitHub."); + } + let res: Response; + try { + res = await fetch( + `https://uploads.github.com/repos/${enc(owner)}/${enc(repo)}/releases/${releaseId}/assets?name=${encodeURIComponent(name)}`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "GitStudio", + "Content-Type": contentType, + }, + body: data as unknown as RequestInit["body"], + signal: AbortSignal.timeout(300_000), + }, + ); + } catch { + throw networkError(); + } + if (!res.ok) { + throw await githubHttpError(res); } - throw await githubHttpError(res); } /** REST call that ignores the response body (fire-and-forget mutations). */ @@ -79,6 +195,7 @@ export class GitHubClient { try { res = await fetch(`${API_BASE}${path}`, { method, + signal: AbortSignal.timeout(30_000), headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", @@ -142,10 +259,13 @@ export class GitHubClient { } // ── Pull requests ── - async listOpenPulls(owner: string, repo: string): Promise<PullRequest[]> { - const raw = await this.request<RawPull[]>( - "GET", - `/repos/${enc(owner)}/${enc(repo)}/pulls?state=open&sort=updated&direction=desc&per_page=50`, + /** `state` is GitHub's: open | closed | all. "merged" is not a state upstream + * — a merged PR is closed with `merged_at` set — so the renderer asks for + * `closed` and narrows locally. */ + async listPulls(owner: string, repo: string, state: "open" | "closed" | "all" = "open"): Promise<PullRequest[]> { + const raw = await this.requestPaged<RawPull>( + `/repos/${enc(owner)}/${enc(repo)}/pulls?state=${state}&sort=updated&direction=desc&per_page=100`, + PAGE_CAPS.list, ); return raw.map(mapPull); } @@ -153,15 +273,18 @@ export class GitHubClient { return mapPull(await this.request<RawPull>("GET", `/repos/${enc(owner)}/${enc(repo)}/pulls/${n}`)); } async getPullFiles(owner: string, repo: string, n: number): Promise<PrFile[]> { - const raw = await this.request<RawFile[]>( - "GET", + const raw = await this.requestPaged<RawFile>( `/repos/${enc(owner)}/${enc(repo)}/pulls/${n}/files?per_page=100`, + PAGE_CAPS.detail, ); return raw.map((f) => ({ filename: f.filename, status: f.status, additions: f.additions, deletions: f.deletions, + // In the response already — dropping it left every rename unable to say + // what it was renamed from. + ...(f.previous_filename ? { previousFilename: f.previous_filename } : {}), })); } async mergePull(owner: string, repo: string, n: number, method: "merge" | "squash" | "rebase"): Promise<void> { @@ -171,23 +294,39 @@ export class GitHubClient { await this.requestBody("POST", `/repos/${enc(owner)}/${enc(repo)}/pulls/${n}/reviews`, { event: "APPROVE" }); } async listPrCommits(owner: string, repo: string, n: number): Promise<PrCommitInfo[]> { - const raw = await this.request<RawPrCommit[]>( - "GET", + const raw = await this.requestPaged<RawPrCommit>( `/repos/${enc(owner)}/${enc(repo)}/pulls/${n}/commits?per_page=100`, + PAGE_CAPS.detail, ); - return raw.map((c) => ({ - sha: c.sha, - shortSha: c.sha.slice(0, 7), - message: (c.commit?.message ?? "").split("\n", 1)[0], - author: c.commit?.author?.name ?? c.author?.login ?? "unknown", - date: c.commit?.author?.date ?? "", - })); + return raw.map((c) => { + const full = c.commit?.message ?? ""; + const nl = full.indexOf("\n"); + return { + sha: c.sha, + shortSha: c.sha.slice(0, 7), + message: nl < 0 ? full : full.slice(0, nl), + // The rest of the message, if there is any. GitHub's own commits list + // hangs a "…" on rows that have one and expands it in place; we threw + // the body away here and could not have offered it. + body: nl < 0 ? "" : full.slice(nl + 1).trim(), + author: c.commit?.author?.name ?? c.author?.login ?? "unknown", + login: c.author?.login, + avatarUrl: c.author?.avatar_url, + date: c.commit?.author?.date ?? "", + // GitHub shows a "Verified" badge on a signed commit; the flag was in + // this very response and dropped. + verified: c.commit?.verification?.verified === true, + // A merge has more than one parent, and reads completely differently + // from an ordinary commit in a list of them. + isMerge: (c.parents?.length ?? 0) > 1, + }; + }); } /** The conversation = issue comments + reviews, merged chronologically. */ async listConversation(owner: string, repo: string, n: number): Promise<PrComment[]> { const [comments, reviews] = await Promise.all([ - this.request<RawComment[]>("GET", `/repos/${enc(owner)}/${enc(repo)}/issues/${n}/comments?per_page=100`).catch(() => []), - this.request<RawReview[]>("GET", `/repos/${enc(owner)}/${enc(repo)}/pulls/${n}/reviews?per_page=100`).catch(() => []), + this.requestPaged<RawComment>(`/repos/${enc(owner)}/${enc(repo)}/issues/${n}/comments?per_page=100`, PAGE_CAPS.detail).catch(() => []), + this.requestPaged<RawReview>(`/repos/${enc(owner)}/${enc(repo)}/pulls/${n}/reviews?per_page=100`, PAGE_CAPS.detail).catch(() => []), ]); const out: PrComment[] = []; for (const c of comments) { @@ -216,26 +355,6 @@ export class GitHubClient { return []; } } - async listWorkflowRuns(owner: string, repo: string): Promise<WorkflowRun[]> { - try { - const raw = await this.request<{ workflow_runs?: RawRun[] }>( - "GET", - `/repos/${enc(owner)}/${enc(repo)}/actions/runs?per_page=30`, - ); - return (raw.workflow_runs ?? []).map((r) => ({ - id: r.id, - name: r.name ?? r.display_title ?? "(run)", - status: r.status ?? "", - conclusion: r.conclusion ?? "", - branch: r.head_branch ?? "", - event: r.event ?? "", - createdAt: r.created_at ?? "", - htmlUrl: r.html_url ?? "", - })); - } catch { - return []; - } - } async getCombinedStatus(owner: string, repo: string, ref: string): Promise<CombinedStatus> { try { const raw = await this.request<{ state?: string; total_count?: number }>( @@ -250,9 +369,9 @@ export class GitHubClient { // ── Issues (the issues endpoint also returns PRs — filter them out) ── async listOpenIssues(owner: string, repo: string): Promise<IssueInfo[]> { - const raw = await this.request<RawIssue[]>( - "GET", - `/repos/${enc(owner)}/${enc(repo)}/issues?state=open&sort=updated&direction=desc&per_page=50`, + const raw = await this.requestPaged<RawIssue>( + `/repos/${enc(owner)}/${enc(repo)}/issues?state=open&sort=updated&direction=desc&per_page=100`, + PAGE_CAPS.list, ); return raw.filter((i) => !i.pull_request).map(mapIssue); } @@ -288,56 +407,36 @@ export function enc(part: string): string { return encodeURIComponent(part); } -export interface RawUser { - login: string; - avatar_url?: string; -} +// RawUser + mapUser live in github/maps.ts now (imported above) — re-exported +// here so the per-section modules' existing `import { mapUser } from +// "../githubClient"` keeps working. +export { mapUser, type RawUser }; interface RawRef { ref: string; sha: string; } -interface RawPull { - number: number; - title: string; - body: string | null; - state: string; - draft?: boolean; - html_url: string; - user: RawUser | null; - created_at: string; - updated_at: string; - head: RawRef; - base: RawRef; - labels?: { name: string; color: string }[]; - comments?: number; - additions?: number; - deletions?: number; - changed_files?: number; -} interface RawFile { filename: string; status: string; additions: number; deletions: number; -} -interface RawIssue { - number: number; - title: string; - body: string | null; - state: string; - html_url: string; - user: RawUser | null; - created_at: string; - updated_at: string; - comments: number; - labels?: ({ name: string; color: string } | string)[]; - assignees?: RawUser[]; - pull_request?: unknown; + /** GitHub's own field name, present on renames and copies. */ + previous_filename?: string; } interface RawPrCommit { sha: string; - commit?: { message?: string; author?: { name?: string; date?: string } }; - author?: { login?: string } | null; + commit?: { + message?: string; + author?: { name?: string; email?: string; date?: string }; + committer?: { name?: string; date?: string }; + verification?: { verified?: boolean; reason?: string }; + }; + // The GitHub ACCOUNT behind the commit, when it matched one — this is where + // the avatar comes from. It was declared as `{login}` and everything else in + // the same object thrown away, so the rows had no faces on them. + author?: RawUser | null; + parents?: Array<{ sha: string }>; + html_url?: string; } interface RawComment { user?: RawUser | null; @@ -356,17 +455,6 @@ interface RawCheck { conclusion?: string; details_url?: string; } -interface RawRun { - id: number; - name?: string; - display_title?: string; - status?: string; - conclusion?: string; - head_branch?: string; - event?: string; - created_at?: string; - html_url?: string; -} interface RawProjectsData { repository?: { projectsV2?: { @@ -384,45 +472,3 @@ interface RawProjectsData { }; } -export function mapUser(u: RawUser | null | undefined): GitHubUser | null { - return u ? { login: u.login, avatarUrl: u.avatar_url ?? null } : null; -} -function mapPull(p: RawPull): PullRequest { - return { - number: p.number, - title: p.title, - body: p.body, - state: p.state, - draft: p.draft ?? false, - htmlUrl: p.html_url, - user: mapUser(p.user), - createdAt: p.created_at, - updatedAt: p.updated_at, - head: { ref: p.head.ref, sha: p.head.sha }, - base: { ref: p.base.ref, sha: p.base.sha }, - labels: (p.labels ?? []).map((l) => ({ name: l.name, color: l.color })), - comments: p.comments, - additions: p.additions, - deletions: p.deletions, - changedFiles: p.changed_files, - }; -} -function mapIssue(i: RawIssue): IssueInfo { - return { - number: i.number, - title: i.title, - body: i.body, - state: i.state, - htmlUrl: i.html_url, - user: mapUser(i.user), - createdAt: i.created_at, - updatedAt: i.updated_at, - comments: i.comments, - labels: (i.labels ?? []).map((l) => - typeof l === "string" ? { name: l, color: "888888" } : { name: l.name, color: l.color }, - ), - assignees: (i.assignees ?? []) - .map(mapUser) - .filter((u): u is GitHubUser => u !== null), - }; -} diff --git a/apps/desktop/src/main/githubPaging.ts b/apps/desktop/src/main/githubPaging.ts new file mode 100644 index 0000000..d4423b7 --- /dev/null +++ b/apps/desktop/src/main/githubPaging.ts @@ -0,0 +1,50 @@ +// Pure pagination helpers for the GitHub REST client — kept in their own +// module (no client / Electron imports) so they unit-test in isolation. +// +// GitHub paginates list endpoints with an RFC-5988 `Link` response header: +// <https://api.github.com/repos/o/r/issues?page=2>; rel="next", +// <https://api.github.com/repos/o/r/issues?page=9>; rel="last" +// Following rel="next" until it disappears (or a page cap) is the ONLY correct +// way to read a full list — `per_page=100` alone silently truncates busy repos. + +/** + * The rel="next" target from a `Link` header, as a PATH relative to `apiBase` + * (the form `GitHubClient.request` takes), or undefined on the last page. + * A rel="next" pointing at a different host is ignored — we never follow a + * redirect off api.github.com. + */ +export function nextPagePath( + linkHeader: string | null | undefined, + apiBase: string, +): string | undefined { + if (!linkHeader) return undefined; + for (const part of linkHeader.split(",")) { + const m = /<([^>]+)>\s*;\s*(?:[^,]*;\s*)?rel="next"/.exec(part.trim()); + if (!m) continue; + const url = m[1]; + if (url.startsWith(apiBase)) return url.slice(apiBase.length); + // A relative path is fine too (not what GitHub sends, but harmless). + if (url.startsWith("/")) return url; + return undefined; // absolute URL on some other host — refuse to follow + } + return undefined; +} + +/** + * Page caps per surface: how many pages `requestPaged` follows before stopping. + * Deliberate ceilings — a repo with 4,000 open issues should not stall the + * section behind 40 sequential requests. When a list comes back at exactly + * cap × per_page items, the UI says "showing the first N" instead of lying. + */ +export const PAGE_CAPS = { + /** Issues / PRs: 3 × 100 = 300 items. */ + list: 3, + /** Workflow runs (heavy payloads): 2 × 100 = 200 runs. */ + runs: 2, + /** Timeline comments / reviews / files / commits on one item: 3 × 100. */ + detail: 3, + /** Org repos / members, branches, gists: 3 × 100. */ + account: 3, + /** Notifications (endpoint max per_page=50): 3 × 50 = 150 threads. */ + notifications: 3, +} as const; diff --git a/apps/desktop/src/main/githubRemote.ts b/apps/desktop/src/main/githubRemote.ts new file mode 100644 index 0000000..f8df950 --- /dev/null +++ b/apps/desktop/src/main/githubRemote.ts @@ -0,0 +1,37 @@ +// The `origin` remote parser — pure, dependency-free, and deliberately in its +// OWN module: githubBridge imports electron, and the local-repo scanner (plus +// its node tests) must not drag that in just to read a remote URL. + +/** + * Parse a git remote URL into github.com owner/repo — or undefined when it + * isn't github.com. Handles what real machines actually have: + * https://github.com/o/r(.git)(/) — plus http + * git@github.com:o/r.git — scp-like + * git@github.com-work:o/r.git — SSH host ALIASES (multi-account) + * ssh://git@github.com:22/o/r.git — ssh with a port (the old regex + * parsed owner="22" and 404'd forever) + * git://github.com/o/r + * Host-anchored: "evilnotgithub.com" never matches. + */ +export function parseGitHubRemote(url: string): { owner: string; repo: string } | undefined { + const u = url.trim(); + if (!u) return undefined; + let path: string | undefined; + // scp-like: [user@]HOST:path — HOST must be github.com or a github.com-* alias. + let m = /^(?:[\w.-]+@)?github\.com(?:-[\w.-]+)?:([^/].*)$/i.exec(u); + if (m) path = m[1]; + if (!path) { + // URL forms: scheme://[user@]github.com[:port]/path + m = /^(?:https?|ssh|git|git\+ssh):\/\/(?:[\w.-]+@)?github\.com(?:-[\w.-]+)?(?::\d+)?\/(.+)$/i.exec(u); + if (m) path = m[1]; + } + if (!path) return undefined; + const parts = path + .replace(/\.git\/?$/i, "") + .replace(/\/+$/, "") + .split("/") + .filter(Boolean); + // Exactly owner/repo — github.com has no deeper namespaces. + if (parts.length !== 2) return undefined; + return { owner: parts[0], repo: parts[1] }; +} diff --git a/apps/desktop/src/main/githubStatus.ts b/apps/desktop/src/main/githubStatus.ts new file mode 100644 index 0000000..fa4ca20 --- /dev/null +++ b/apps/desktop/src/main/githubStatus.ts @@ -0,0 +1,40 @@ +// What "connected to GitHub" means — as one pure function, so the rule can be +// tested. `githubBridge.ts` imports electron and cannot be loaded in a test. + +import type { GitHubStatus } from "../shared/ipc"; + +/** + * Decide the connection status from what is actually known. + * + * The rule this exists to hold: **having a token IS being connected.** Whether + * we have managed to ASK GitHub for the account's login name is a different and + * much smaller question, and a failed ask must never present as signed out. + * + * It used to be `connected: !!login`, computed right after a `currentLogin()` + * call that swallows its own failures and returns undefined. So one flaky + * request — a rate limit, a captive portal, a proxy, GitHub being slow for a + * second — turned a signed-in user's top bar into a "Sign in" button while the + * Settings page, which had the account in hand, went on showing them signed in. + * Two parts of one window disagreeing about who you are. + * + * Three states, and they are genuinely different: + * - a token in memory → connected; `login` when we know it + * - a token on disk, locked → connected; the name fills in after the first + * real request (asking here would raise the OS + * keychain prompt on every launch) + * - no token anywhere → not connected + */ +export function githubStatus(o: { + /** A decrypted token is loaded in this process. */ + hasToken: boolean; + /** A token exists in the store, whether or not it has been decrypted. */ + hasStoredToken: boolean; + /** The account name, once some request has told us. */ + login?: string; + repo?: GitHubStatus["repo"]; +}): GitHubStatus { + if (o.hasToken || o.hasStoredToken) { + return { connected: true, login: o.login, repo: o.repo }; + } + return { connected: false, repo: o.repo }; +} diff --git a/apps/desktop/src/main/localRepos.ts b/apps/desktop/src/main/localRepos.ts new file mode 100644 index 0000000..cc1ac30 --- /dev/null +++ b/apps/desktop/src/main/localRepos.ts @@ -0,0 +1,243 @@ +// The local-copies scanner behind Settings → Repositories. +// +// GitStudio clones into a folder the user controls (appSettings.cloneDir), and +// it remembers repos opened from anywhere else. Neither list alone answers +// "what do I actually have on this machine?" — so this module unions them: +// +// top-level dirs of the clone folder ∪ the recents list +// +// Each candidate is probed for its `origin` remote (so a row can say WHICH +// GitHub repo it is, not just which folder), deduped by real path, and cached +// for 30s — the settings card re-renders freely without re-shelling out to git +// dozens of times. +// +// Electron-free by design (every path and the clock are injected) so it runs +// under plain node in tests. + +import { execFile } from "node:child_process"; +import { readdir, realpath, stat } from "node:fs/promises"; +import { basename, dirname, join, resolve, sep } from "node:path"; +import { parseGitHubRemote } from "./githubRemote"; +import type { LocalCopy } from "../shared/ipc"; + +/** Don't shell out to git hundreds of times for a huge folder. */ +const MAX_ENTRIES = 300; +/** Parallel `git remote get-url` probes. */ +const PROBE_CONCURRENCY = 8; +const PROBE_TIMEOUT_MS = 5_000; +export const SCAN_TTL_MS = 30_000; + +export interface ScanInput { + /** The configured clone folder (its top-level dirs are candidates). */ + cloneDir: string; + /** Recently-opened repo roots (candidates from anywhere on disk). */ + recents: string[]; + /** The repo the app currently has open, if any. */ + current?: string; +} + +/** `git -C root remote get-url origin`, parsed to "owner/repo" — or undefined. */ +function originOf(root: string): Promise<string | undefined> { + return new Promise((res) => { + execFile( + "git", + ["-C", root, "remote", "get-url", "origin"], + { timeout: PROBE_TIMEOUT_MS }, + (err, stdout) => { + if (err) return res(undefined); + const parsed = parseGitHubRemote(stdout.trim()); + res(parsed ? `${parsed.owner}/${parsed.repo}` : undefined); + }, + ); + }); +} + +/** Two paths that name the same place, compared the way the rest of this + * module does (resolve only — realpath needs I/O and a missing path has none). */ +export function samePath(a: string, b: string): boolean { + return resolve(a) === resolve(b); +} + +/** True when `child` is `parent` itself or sits underneath it. */ +export function isInside(parent: string, child: string): boolean { + const p = resolve(parent); + const c = resolve(child); + return c === p || c.startsWith(p.endsWith(sep) ? p : p + sep); +} + +/** realpath, falling back to a plain resolve when the path doesn't exist. + * Load-bearing on macOS, where /var is a symlink to /private/var: comparing a + * resolved repo path against an UNresolved clone dir marks every managed + * clone as unmanaged (and so undeletable). */ +async function realOrResolve(p: string): Promise<string> { + try { + return await realpath(p); + } catch { + // The path itself is gone — resolve its PARENT instead, so a missing entry + // is still judged against the same real prefix as everything else. Without + // this, a deleted clone inside the clone folder reads as "outside your + // clone folder", which is a true refusal for a false reason. + try { + return join(await realpath(dirname(p)), basename(p)); + } catch { + return resolve(p); + } + } +} + +/** A directory that is (or contains) a git repo — `.git` may be a dir or a file + * (worktrees/submodules use a gitfile), so a plain existence check is right. */ +async function isRepoDir(root: string): Promise<boolean> { + try { + await stat(join(root, ".git")); + return true; + } catch { + return false; + } +} + +/** Run `fn` over `items` at most `limit` at a time, preserving order. */ +async function mapLimit<T, R>(items: T[], limit: number, fn: (t: T) => Promise<R>): Promise<R[]> { + const out = new Array<R>(items.length); + let next = 0; + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { + for (;;) { + const i = next++; + if (i >= items.length) return; + out[i] = await fn(items[i]); + } + }); + await Promise.all(workers); + return out; +} + +export class LocalRepoScanner { + private cache: { at: number; key: string; value: LocalCopy[] } | undefined; + + /** `now` is injectable so the 30s cache is testable without real waiting. */ + constructor(private readonly now: () => number = () => Date.now()) {} + + /** Drop the cache — call after anything that changes what's on disk. */ + invalidate(): void { + this.cache = undefined; + } + + async scan(input: ScanInput): Promise<LocalCopy[]> { + const key = JSON.stringify([input.cloneDir, input.recents, input.current ?? ""]); + const c = this.cache; + if (c && c.key === key && this.now() - c.at < SCAN_TTL_MS) return c.value; + const value = await scanLocalCopies(input); + this.cache = { at: this.now(), key, value }; + return value; + } +} + +/** The uncached scan. Missing paths are REPORTED (dimmed in the UI), never + * silently dropped — a recent whose folder was deleted elsewhere is exactly + * the thing the manager exists to show. */ +export async function scanLocalCopies(input: ScanInput): Promise<LocalCopy[]> { + const realCloneDir = await realOrResolve(input.cloneDir); + const realCurrent = input.current ? await realOrResolve(input.current) : undefined; + const managedRoots: string[] = []; + try { + const names = await readdir(input.cloneDir, { withFileTypes: true }); + for (const d of names) { + if (!d.isDirectory() || d.name.startsWith(".")) continue; + const root = join(input.cloneDir, d.name); + if (await isRepoDir(root)) managedRoots.push(root); + if (managedRoots.length >= MAX_ENTRIES) break; + } + } catch { + /* clone dir missing / unreadable — recents still list */ + } + managedRoots.sort((a, b) => basename(a).localeCompare(basename(b))); + + // Recents first (they carry the app's own ordering), then managed folders. + const candidates = [...input.recents, ...managedRoots].slice(0, MAX_ENTRIES); + + // Dedupe by REAL path: a recent entry and a managed folder can be the same + // repo reached through a symlink, and showing it twice with two different + // action sets would be a lie about what's on disk. + const seen = new Map<string, LocalCopy>(); + const resolved = await mapLimit(candidates, PROBE_CONCURRENCY, async (root) => { + try { + return { root, real: await realpath(root), missing: false }; + } catch { + return { root, real: resolve(root), missing: true }; + } + }); + + for (const { root, real, missing } of resolved) { + const existing = seen.get(real); + const managed = isInside(realCloneDir, real); + const recent = input.recents.some((r) => resolve(r) === resolve(root)); + if (existing) { + existing.managed ||= managed; + existing.recent ||= recent; + continue; + } + seen.set(real, { + root: missing ? resolve(root) : real, + name: basename(root) || real, + managed, + recent, + missing, + current: !!realCurrent && realCurrent === real, + }); + } + + const list = [...seen.values()]; + const origins = await mapLimit(list, PROBE_CONCURRENCY, (c) => + c.missing ? Promise.resolve(undefined) : originOf(c.root), + ); + list.forEach((c, i) => { + if (origins[i]) c.origin = origins[i]; + }); + + // Present ones first, then by name — a deleted clone shouldn't head the list. + return list.sort((a, b) => { + if (a.missing !== b.missing) return a.missing ? 1 : -1; + return a.name.localeCompare(b.name); + }); +} + +/** The rule, applied to REAL paths — the form main.ts must use, since a + * configured clone dir and a scanned repo root can reach the same place + * through different symlinks. */ +export async function trashRefusalResolved( + root: string, + o: { cloneDir: string; current?: string }, +): Promise<string | null> { + const real = await realOrResolve(root); + const refusal = trashRefusal(real, { + cloneDir: await realOrResolve(o.cloneDir), + current: o.current ? await realOrResolve(o.current) : undefined, + }); + if (refusal) return refusal; + // The UI only ever offers rows that came from a scan, so this can't be hit + // from the app — but the channel is reachable, and "inside the clone folder" + // must never be enough on its own to delete a folder. + if (!(await isRepoDir(real))) { + return "That folder isn't a git repository — GitStudio won't delete it."; + } + return null; +} + +/** Why this root must not be trashed, or null when it's safe. + * Pure so the rule is testable and stated in exactly one place. */ +export function trashRefusal( + root: string, + o: { cloneDir: string; current?: string }, +): string | null { + const r = resolve(root); + if (!r || r === resolve(o.cloneDir)) { + return "That's the clone folder itself, not a repository inside it."; + } + if (o.current && resolve(o.current) === r) { + return "That repository is open right now — switch to another one first."; + } + if (!isInside(o.cloneDir, r)) { + return "GitStudio only deletes clones inside your clone folder. Remove this one from Finder if you meant to."; + } + return null; +} diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 346565f..ee87809 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -9,6 +9,7 @@ import { app, BrowserWindow, + clipboard, dialog, ipcMain, Menu, @@ -17,8 +18,9 @@ import { } from "electron"; import type { IpcMainInvokeEvent, MenuItemConstructorOptions, WebContents } from "electron"; import { AsyncLocalStorage } from "node:async_hooks"; -import { join } from "node:path"; +import { join, basename, extname } from "node:path"; import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { redactCredentials } from "@gitstudio/host-bridge/scrub"; import { RepoStore } from "./repoStore"; import { GitBridge } from "./gitBridge"; import { GitHubBridge } from "./githubBridge"; @@ -26,21 +28,29 @@ import { RebaseBridge } from "./rebaseBridge"; import { AiBridge } from "./aiBridge"; import { TerminalBridge } from "./terminalBridge"; import { pickCloneDir, startClone, listGhRepos, killActiveClones } from "./cloneBridge"; +import { AppSettings } from "./appSettings"; +import { LocalRepoScanner, samePath, trashRefusalResolved } from "./localRepos"; +import { openGitHubRepo, managedReposDir } from "./ghRepoOpen"; import { initAutoUpdate } from "./autoUpdate"; +import type { UpdateManager } from "./autoUpdate"; import { ErrorReporter } from "./errorReporter"; import { isExpectedError } from "./expectedError"; import { RepoWatcher } from "./repoWatcher"; import * as issuesApi from "./github/issues"; +import * as myWorkApi from "./github/myWork"; import * as prsApi from "./github/prs"; import * as actionsApi from "./github/actions"; import * as releasesApi from "./github/releases"; import * as notificationsApi from "./github/notifications"; import * as orgsApi from "./github/orgs"; +import * as repoBrowseApi from "./github/repoBrowse"; +import * as searchApi from "./github/search"; import * as projectsApi from "./github/projects"; import * as gistsApi from "./github/gists"; import type { CommitActionResult, IpcChannel, + LocalCopy, IpcEvents, IpcRequest, IpcResponse, @@ -53,7 +63,11 @@ import type { app.setName("GitStudio"); let mainWindow: BrowserWindow | undefined; +/** The poll→confirm→pull update manager; set once at startup. */ +let updates: UpdateManager | undefined; let repos: RepoStore; +let appSettings: AppSettings; +const localRepos = new LocalRepoScanner(); let bridge: GitBridge; let github: GitHubBridge; let rebase: RebaseBridge; @@ -129,8 +143,12 @@ function hardenWebContents(contents: WebContents): void { } }); contents.on("will-attach-webview", (event) => event.preventDefault()); - contents.session.setPermissionRequestHandler((_wc, _permission, callback) => - callback(false), + // Deny every permission request EXCEPT plain clipboard writes — the blanket + // deny made navigator.clipboard.writeText reject, so every Copy button + // (including the GitHub device-flow confirmation code) errored instead of + // copying. Writes only; clipboard READS stay denied. + contents.session.setPermissionRequestHandler((_wc, permission, callback) => + callback(permission === "clipboard-sanitized-write"), ); } @@ -218,7 +236,7 @@ function buildMenu(): void { click: () => void openRepoPath(r.root), })); if (recentSubmenu.length === 0) { - recentSubmenu.push({ label: "No Recent Repositories", enabled: false }); + recentSubmenu.push({ label: "No recent repositories", enabled: false }); } const template: MenuItemConstructorOptions[] = [ @@ -248,14 +266,35 @@ function buildMenu(): void { }, { label: "Open Recent", submenu: recentSubmenu }, { type: "separator" }, + { + label: "Clone repository…", + accelerator: "CmdOrCtrl+Shift+O", + click: () => send("menu:command", { command: "cloneRepo" }), + }, + { type: "separator" }, { label: "Refresh", + // ⌘R, the chord everyone's hands already know. It used to be ⌘⇧R to + // dodge the `reload` role, which claims ⌘R by default — but that meant + // the most reflexive refresh gesture there is HARD-RELOADED the + // renderer: every view rebuilt from nothing, every cache dropped, the + // repo re-opened, and whatever you had typed gone. It looked like the + // app had crashed and recovered. + // + // And ⌘⇧R was no safer: that is `forceReload`'s default, so Refresh + // and Force Reload were bound to the SAME chord and which one fired + // came down to menu order. Both dev roles are explicitly re-bound + // below, so neither can reclaim a chord by default ever again. accelerator: "CmdOrCtrl+R", click: () => send("menu:command", { command: "refresh" }), }, { - label: "Close Repository", - accelerator: "CmdOrCtrl+W", + label: "Close repository", + // NOT CmdOrCtrl+W. On macOS that is the most reflexive shortcut + // there is and it means "close this window"; here it threw you back + // to the welcome screen with the window still open. The Window menu + // owns ⌘W now, and closing the repo is a deliberate act. + accelerator: "CmdOrCtrl+Shift+W", click: () => closeRepo(), }, ...(isMac @@ -279,17 +318,45 @@ function buildMenu(): void { ], }, { + // The menu named "View" could not reach a single one of the app's + // eighteen views: it was Electron's stock template verbatim, so the only + // things a user could "view" were the zoom level and the dev tools. label: "View", submenu: [ - { role: "reload" }, - { role: "forceReload" }, - { role: "toggleDevTools" }, + { + label: "Toggle Sidebar", + accelerator: "CmdOrCtrl+B", + click: () => send("menu:command", { command: "toggleSidebar" }), + }, + { + label: "Toggle Terminal", + accelerator: "CmdOrCtrl+`", + click: () => send("menu:command", { command: "toggleTerminal" }), + }, + { + label: "Command Palette…", + accelerator: "CmdOrCtrl+K", + click: () => send("menu:command", { command: "palette" }), + }, { type: "separator" }, { role: "resetZoom" }, { role: "zoomIn" }, { role: "zoomOut" }, { type: "separator" }, { role: "togglefullscreen" }, + { type: "separator" }, + // Kept, but where they belong: developer tools, not "views". + { + label: "Developer", + // Accelerators stated, not inherited. A `role` carries its default + // chord even nested three levels down a submenu, which is how ⌘R came + // to restart the app and ⌘⇧R came to mean two different things. + submenu: [ + { role: "reload" as const, accelerator: "Alt+CmdOrCtrl+R" }, + { role: "forceReload" as const, accelerator: "Alt+CmdOrCtrl+Shift+R" }, + { role: "toggleDevTools" as const }, + ], + }, ], }, { @@ -412,15 +479,23 @@ function actionLabel(channel: string): string { "sync:push": "Push", "branch:push": "Push branch", "branches:list": "List branches", + "ref:log": "Read ref history", "branch:create": "Create branch", "branch:delete": "Delete branch", "branch:pullFf": "Pull branch", + "tag:create": "Create tag", + "tag:delete": "Delete tag", + "tag:push": "Push tag", "compare:refs": "Compare", "compare:fileDiff": "Compare file", "repo:tree": "Read tree", "repo:file": "Read file", "repo:open": "Open repository", "repo:openPath": "Open repository", + "repos:local": "List local repositories", + "repos:reveal": "Reveal repository", + "repos:removeRecent": "Forget repository", + "repos:trash": "Delete clone", "clone:start": "Clone", "pr:checkout": "Checkout PR", "git:identity": "Read identity", @@ -473,6 +548,53 @@ function registerIpc(): void { handle("repo:open", () => openRepoDialog()); handle("repo:openPath", (path) => openRepoPath(path)); handle("repo:recent", async () => repos.recentRepos()); + handle("search:repos", (req) => github.withClient((c) => searchApi.searchRepos(c, req))); + handle("search:users", (req) => github.withClient((c) => searchApi.searchUsers(c, req))); + handle("search:code", (req) => github.withClient((c) => searchApi.searchCode(c, req))); + handle("repos:local", () => scanLocalCopies()); + handle("repos:reveal", async (root) => { + // Only reveal something the app already lists. `showItemInFolder` on an + // arbitrary renderer-supplied string is the one shell call here with no + // natural bound, and every real caller passes a row from this same scan. + const known = (await scanLocalCopies()).some((c) => samePath(c.root, root)); + if (!known) return false; + shell.showItemInFolder(root); + return true; + }); + handle("repos:removeRecent", async (root) => { + if (repos.removeRecent(root)) { + void saveState(); + localRepos.invalidate(); + buildMenu(); // the Recent Repositories submenu is built from this list + send("repo:recentChanged", repos.recentRepos()); + } + return scanLocalCopies(); + }); + handle("repos:trash", async (root) => { + // Deleting someone's working copy is the most destructive thing this app + // can do, so the rule lives in ONE pure function and is enforced HERE — + // never in the renderer, which can't be trusted to be the only caller. + const refusal = await trashRefusalResolved(root, { + cloneDir: appSettings.effectiveCloneDir(), + current: repos.current()?.root, + }); + if (refusal) return { ok: false, changed: false, expected: true, message: refusal }; + try { + await shell.trashItem(root); + } catch (e) { + return { + ok: false, + changed: false, + expected: true, + message: e instanceof Error ? e.message : "Couldn't move that folder to the trash.", + }; + } + if (repos.removeRecent(root)) void saveState(); + localRepos.invalidate(); + buildMenu(); + send("repo:recentChanged", repos.recentRepos()); + return { ok: true, changed: true }; + }); handle("repo:current", async () => repos.current()); handle("repo:close", async () => { closeRepo(); @@ -530,6 +652,7 @@ function registerIpc(): void { // Branch management. handle("branches:list", () => bridge.branchesList()); + handle("ref:log", (req) => bridge.refLog(req)); handle("branch:create", (req) => bridge.branchCreate(req)); handle("branch:delete", (req) => bridge.branchDelete(req)); handle("branch:pullFf", (req) => bridge.branchPullFf(req.name)); @@ -552,7 +675,7 @@ function registerIpc(): void { handle("terminal:kill", async (req) => terminal.kill(req.id)); // Clone / browse repos. - handle("clone:pickDir", () => pickCloneDir()); + handle("clone:pickDir", (req) => pickCloneDir(req?.defaultPath ?? appSettings.effectiveCloneDir())); handle("clone:start", (req) => startClone(req, (p) => send("clone:progress", p))); handle("github:repos", (req) => github.withClient((c) => listGhRepos(c, req?.search)), @@ -568,8 +691,36 @@ function registerIpc(): void { // Settings: git identity + local SSH keys. handle("git:identity", () => bridge.gitIdentity()); handle("git:setIdentity", (req) => bridge.setGitIdentity(req)); + handle("clipboard:write", async (text) => { + clipboard.writeText(typeof text === "string" ? text : String(text ?? "")); + }); + + // App info + updates (poll → confirm → pull → apply). + handle("app:info", async () => ({ version: app.getVersion(), platform: process.platform })); + handle("settings:get", () => Promise.resolve(appSettings.view())); + handle("settings:update", (patch) => appSettings.update(patch)); + handle("settings:pickCloneDir", async () => { + const r = await dialog.showOpenDialog({ + properties: ["openDirectory", "createDirectory"], + title: "Choose the default clone folder", + defaultPath: appSettings.effectiveCloneDir(), + }); + if (r.canceled || !r.filePaths[0]) return undefined; + return appSettings.update({ cloneDir: r.filePaths[0] }); + }); + handle("update:check", async () => + updates + ? updates.check(true) + : { status: "disabled" as const, current: app.getVersion(), message: "Updater not ready." }, + ); + handle("update:download", async () => + updates ? updates.download() : { ok: false, message: "Updater not ready." }, + ); + handle("update:install", async () => + updates ? updates.install() : { ok: false, message: "Updater not ready." }, + ); handle("ssh:keys", () => bridge.sshKeys()); - handle("pr:list", () => github.prList()); + handle("pr:list", (req) => github.prList(req?.state ?? "open")); handle("pr:detail", (n) => github.prDetail(n)); handle("pr:checkout", (n) => github.prCheckout(n)); handle("pr:merge", (req) => github.prMerge(req)); @@ -577,7 +728,7 @@ function registerIpc(): void { handle("pr:conversation", (n) => github.prConversation(n)); handle("pr:checks", (n) => github.prChecks(n)); handle("pr:approve", (n) => github.prApprove(n)); - handle("actions:runs", () => github.actionsRuns()); + handle("actions:runs", (req) => github.withRepo((c, o, r) => actionsApi.listRuns(c, o, r, req))); handle("issue:list", (req) => github.withRepo((c, o, r) => issuesApi.listIssues(c, o, r, req?.state ?? "open"))); // ── Section modules: full CRUD for issues / PRs / actions / releases / @@ -586,6 +737,7 @@ function registerIpc(): void { handle("issue:detail", (n) => github.withRepo((c, o, r) => issuesApi.getIssueDetail(c, o, r, n))); // Cross-repo read-only item view (notifications for OTHER repos open in-app). handle("github:externalItem", (req) => github.externalItem(req)); + handle("github:myWork", () => github.withRepo((c, o, r) => myWorkApi.myWork(c, o, r))); handle("issue:create", (req) => github.withRepo((c, o, r) => issuesApi.createIssue(c, o, r, req))); handle("issue:comment", (req) => github.withRepo((c, o, r) => issuesApi.commentIssue(c, o, r, req))); handle("issue:setState", (req) => github.withRepo((c, o, r) => issuesApi.setIssueState(c, o, r, req))); @@ -616,7 +768,34 @@ function registerIpc(): void { handle("release:tags", () => github.withRepo((c, o, r) => releasesApi.listTags(c, o, r))); handle("release:create", (input) => github.withRepo((c, o, r) => releasesApi.createRelease(c, o, r, input))); handle("release:update", (input) => github.withRepo((c, o, r) => releasesApi.updateRelease(c, o, r, input))); + handle("release:generateNotes", (req) => + github.withRepo((c, o, r) => releasesApi.generateNotes(c, o, r, req)), + ); handle("release:delete", (id) => github.withRepo((c, o, r) => releasesApi.deleteRelease(c, o, r, id))); + handle("release:uploadAssets", async (req) => { + // The file dialog lives HERE (main) — the renderer has no filesystem. + const picked = await dialog.showOpenDialog({ + title: "Attach assets to the release", + buttonLabel: "Upload", + properties: ["openFile", "multiSelections"], + }); + if (picked.canceled || picked.filePaths.length === 0) { + return { ok: false, changed: false, message: "No files selected.", expected: true }; + } + return github.withRepo(async (c, o, r) => { + for (const fp of picked.filePaths) { + const name = basename(fp); + const data = await readFile(fp); + const res = await releasesApi.uploadAssetData(c, o, r, req.id, name, data, assetContentType(name)); + if (!res.ok) { + return { ...res, message: `${name}: ${res.message ?? "upload failed"}` }; + } + } + const n = picked.filePaths.length; + return { ok: true, changed: false, message: `Uploaded ${n} asset${n === 1 ? "" : "s"}.` }; + }); + }); + handle("release:deleteAsset", (id) => github.withRepo((c, o, r) => releasesApi.deleteAsset(c, o, r, id))); // Notifications (user-level). handle("notifications:list", (opts) => github.withClient((c) => notificationsApi.listNotifications(c, opts))); // Ambient: polls on launch, so it must NOT unlock the token (that prompted for @@ -634,6 +813,40 @@ function registerIpc(): void { handle("orgs:repos", (org) => github.withClient((c) => orgsApi.listOrgRepos(c, org))); handle("orgs:teams", (org) => github.withClient((c) => orgsApi.listOrgTeams(c, org))); handle("orgs:members", (org) => github.withClient((c) => orgsApi.listOrgMembers(c, org))); + handle("orgs:repoDetail", (fullName) => github.withClient((c) => orgsApi.getOrgRepoDetail(c, fullName))); + handle("ghrepo:open", (req) => + openGitHubRepo( + req.fullName, + repos, + (p) => send("clone:progress", p), + appSettings.effectiveCloneDir(), + req.dest, + req.name, + ), + ); + handle("ghrepo:tree", (req) => + github.withClient((c) => repoBrowseApi.listRepoDir(c, req.fullName, req.path, req.ref)), + ); + handle("ghrepo:file", (req) => + github.withClient((c) => repoBrowseApi.readRepoFile(c, req.fullName, req.path, req.ref)), + ); + handle("ghrepo:readme", (req) => + github.withClient((c) => + typeof req === "string" + ? repoBrowseApi.readRepoReadme(c, req) + : repoBrowseApi.readRepoReadme(c, req.fullName, req.ref), + ), + ); + handle("ghrepo:branches", (fullName) => + github.withClient((c) => repoBrowseApi.listRepoBranches(c, fullName)), + ); + handle("ghrepo:paths", (req) => + github.withClient((c) => repoBrowseApi.listRepoPaths(c, req.fullName, req.ref)), + ); + handle("orgs:teamMembers", (req) => github.withClient((c) => orgsApi.listTeamMembers(c, req.org, req.slug))); + handle("github:userInfo", (login) => github.withClient((c) => orgsApi.getUserInfo(c, login))); + handle("users:repos", (login) => github.withClient((c) => orgsApi.listUserRepos(c, login))); + handle("users:orgs", (login) => github.withClient((c) => orgsApi.listUserOrgs(c, login))); // Projects v2. handle("project:list", () => github.withRepo((c, o, r) => projectsApi.listProjects(c, o, r))); handle("project:board", (id) => github.withRepo((c, o, r) => projectsApi.getProjectBoard(c, o, r, id))); @@ -691,13 +904,25 @@ function registerIpc(): void { handle("branch:rename", (req) => bridge.branchRename(req)); handle("branch:setUpstream", (req) => bridge.branchSetUpstream(req)); handle("branch:deleteRemote", (req) => bridge.branchDeleteRemote(req)); + handle("commit:branches", (sha) => bridge.commitBranches(sha)); handle("git:opState", () => bridge.opState()); handle("merge:abort", () => bridge.mergeAbort()); handle("merge:continue", () => bridge.mergeContinue()); + handle("cherryPick:abort", () => bridge.cherryPickAbort()); + handle("cherryPick:continue", () => bridge.cherryPickContinue()); + handle("revert:abort", () => bridge.revertAbort()); + handle("revert:continue", () => bridge.revertContinue()); + handle("cherryPick:skip", () => bridge.cherryPickSkip()); + handle("revert:skip", () => bridge.revertSkip()); + handle("am:abort", () => bridge.amAbort()); + handle("am:skip", () => bridge.amSkip()); + handle("am:continue", () => bridge.amContinue()); handle("rebase:abort", () => bridge.rebaseAbort()); handle("rebase:continue", () => bridge.rebaseContinue()); handle("rebase:skip", () => bridge.rebaseSkip()); handle("tag:create", (req) => bridge.tagCreate(req)); + handle("tag:delete", (name) => bridge.tagDelete(name)); + handle("tag:push", (req) => bridge.tagPush(req)); // ── GitHub depth (PR review / issues / actions / search / repo admin) ── handle("pr:fileDiff", (req) => github.withRepo((c, o, r) => prsApi.fileDiff(c, o, r, req))); @@ -718,7 +943,8 @@ function registerIpc(): void { handle("label:update", (req) => github.withRepo((c, o, r) => issuesApi.updateLabel(c, o, r, req))); handle("label:delete", (name) => github.withRepo((c, o, r) => issuesApi.deleteLabel(c, o, r, name))); handle("actions:jobLog", (req) => github.withRepo((c, o, r) => actionsApi.jobLog(c, o, r, req))); - handle("actions:runLog", (req) => github.withRepo((c, o, r) => actionsApi.runLog(c, o, r, req))); + handle("actions:jobLogChunk", (req) => github.withRepo((c, o, r) => actionsApi.jobLogChunk(c, o, r, req))); + handle("actions:saveLog", (req) => github.withRepo((c, o, r) => actionsApi.saveLog(c, o, r, req))); handle("actions:artifacts", (id) => github.withRepo((c, o, r) => actionsApi.artifacts(c, o, r, id))); handle("actions:downloadArtifact", (req) => github.withRepo((c, o, r) => actionsApi.downloadArtifact(c, o, r, req))); handle("actions:secrets", () => github.withRepo((c, o, r) => actionsApi.secrets(c, o, r))); @@ -768,6 +994,10 @@ async function boot(): Promise<void> { const state = await loadState(); repos = new RepoStore(state.recent); + appSettings = await AppSettings.load(app.getPath("userData"), { + defaultCloneDir: managedReposDir(), + home: app.getPath("home"), + }); bridge = new GitBridge(repos); github = new GitHubBridge(repos); rebase = new RebaseBridge(repos); @@ -789,14 +1019,19 @@ async function boot(): Promise<void> { let gitLogId = 0; repos.onGitRun = (e) => { const action = actionCtx.getStore(); + // The Output tab is a surface the user reads, copies and pastes into bug + // reports, and `git remote add origin https://user:ghp_…@github.com/org/repo` + // puts a token straight into argv. Only the credential is removed — the + // command has to stay legible to be worth showing at all. + const args = e.args.map(redactCredentials); send("git:log", { id: ++gitLogId, - args: e.args, - command: `git ${e.args.join(" ")}`, + args, + command: `git ${args.join(" ")}`, durationMs: e.durationMs, exitCode: e.exitCode, failed: e.failed, - ...(e.stderr ? { stderr: e.stderr } : {}), + ...(e.stderr ? { stderr: redactCredentials(e.stderr) } : {}), ...(action ? { actionId: action.id, action: action.label } : {}), at: Date.now(), }); @@ -809,7 +1044,7 @@ async function boot(): Promise<void> { // tile before the renderer reports its (possibly overridden) theme. setDockIcon(nativeTheme.shouldUseDarkColors ? "dark" : "light"); await createWindow(); - initAutoUpdate({ isDev: !app.isPackaged }); + updates = initAutoUpdate({ isDev: !app.isPackaged, send }); // Re-open the last repo, if any, so the window lands on real history. if (state.current) { @@ -854,3 +1089,25 @@ app.on("before-quit", () => { ai?.dispose(); repos?.dispose(); }); + +/** MIME type for a release-asset upload, from the file extension. GitHub only + * uses it for the download response's Content-Type — octet-stream is fine. */ +function assetContentType(name: string): string { + switch (extname(name).toLowerCase()) { + case ".dmg": return "application/x-apple-diskimage"; + case ".zip": case ".vsix": case ".nupkg": return "application/zip"; + case ".gz": case ".tgz": return "application/gzip"; + case ".txt": case ".md": return "text/plain"; + case ".json": return "application/json"; + default: return "application/octet-stream"; + } +} + +/** The Settings → Repositories manager's list: the clone folder ∪ recents. */ +function scanLocalCopies(): Promise<LocalCopy[]> { + return localRepos.scan({ + cloneDir: appSettings.effectiveCloneDir(), + recents: repos.recentRepos().map((r) => r.root), + current: repos.current()?.root, + }); +} diff --git a/apps/desktop/src/main/rebaseBridge.ts b/apps/desktop/src/main/rebaseBridge.ts index cc3bc8a..c9ff95b 100644 --- a/apps/desktop/src/main/rebaseBridge.ts +++ b/apps/desktop/src/main/rebaseBridge.ts @@ -3,6 +3,7 @@ import { buildRebasePlan } from "@gitstudio/git-service/rebasePlan"; import type { RepoStore } from "./repoStore"; import type { RebaseApplyRequest, + RebaseApplyRow, RebaseCommitInfo, RebaseOutcomeWire, RebasePlanState, @@ -86,15 +87,77 @@ export class RebaseBridge { const b = branchRes.stdout.trim(); const branch = b && b !== "HEAD" ? b : "detached HEAD"; - const commits = await this.loadCommits(base); - const baseCommit = base === "--root" ? undefined : await this.loadBaseCommit(base); + // Concurrently: the walk for the plan and the walk for the merge count are + // independent, and on a large repo with no commit-graph each is ~115ms. + // Run in series that is a quarter-second before the view paints; run + // together it is the cost of one. + // HEAD is read BEFORE the walk, strictly serially, and that one value is + // what the plan records. Sampled after — which is where the object literal + // below used to evaluate it — a commit landing during the load is baked in + // as "the tip this plan describes", so `apply()` compares the new tip to + // the new tip and passes, and the commit nobody saw is replayed as the + // OLDEST on the branch. Folding it into the Promise.all fixes nothing: the + // rev-parse would still race the log. A tip read first can only be equal or + // older than the rows, which makes the staleness check fail safe. + const head = await this.headSha(); + const [commits, baseCommit, merges, applied] = await Promise.all([ + this.loadCommits(base), + base === "--root" ? Promise.resolve(undefined) : this.loadBaseCommit(base), + this.countMerges(base), + this.countAlreadyApplied(base), + ]); const notes: string[] = []; + // The plan now deliberately omits commits that ARE in the range. Say so — + // silently dropping rows is how the previous version of this got away with + // being wrong. + if (merges > 0) { + notes.push( + merges === 1 + ? "A merge commit in this range isn't listed — a rebase replays the merged-in commits one by one and the merge itself disappears." + : `${merges} merge commits in this range aren't listed — a rebase replays the merged-in commits one by one and the merges themselves disappear.`, + ); + } + // `--cherry-pick` drops commits whose patch is already on the base. That is + // the right todo — git's own sequencer drops them too — but silently it + // reads as data loss, and when it empties the range the fallback sentence + // "No commits between X and Y" is simply false: the commits are there, they + // have just already landed upstream. A branch fully merged by a squash or a + // rebase-merge on the server is the ordinary way to hit this. + if (applied > 0) { + notes.push( + applied === 1 + ? "One commit in this range isn't listed — its change is already on the base, so a rebase would skip it." + : `${applied} commits in this range aren't listed — their changes are already on the base, so a rebase would skip them.`, + ); + } if (fellBack) { - notes.push(`“${req.base ?? "that base"}” doesn't resolve here — showing the whole branch instead.`); + // Only quote a base the CALLER supplied. With none, the fallback quoted + // the app's own default and told the user that "that base" — a string + // they had never typed, chosen, or seen — does not resolve, which reads + // as a warning about something they did wrong. In a repo with fewer + // commits than the default range, that is what every first visit said. + notes.push( + req.base + ? `“${req.base}” doesn't resolve here — showing the whole branch instead.` + : "No base was set and the default range doesn't reach here, so this is the whole branch, starting at the root commit.", + ); } if (commits.length >= MAX_PLAN_COMMITS) { - notes.push(`Showing the first ${MAX_PLAN_COMMITS} commits; pick a nearer base to narrow it.`); + // Say what happens to the rest, not just that they are not shown. They + // ride along as plain picks (see apply → commitsBelowCap); before that + // they were silently deleted, so this note was worse than incomplete. + // "Kept as-is" is not true and the distinction matters: they are REPLAYED + // as plain picks, which is what stops them being deleted — but a replay + // gives every one of them a new sha, so a rebase of a 205-commit range + // rewrites all 205, not the 200 on screen. Saying "kept" invited the + // reading that the older commits are untouched, which is exactly what + // someone weighing a large rebase needs to get right. + notes.push( + `Showing the newest ${MAX_PLAN_COMMITS} commits. The older ones in this range are replayed ` + + `unchanged — you can't edit them here, but they are still rewritten and get new IDs. ` + + `Pick a nearer base to narrow the range.`, + ); } return { ok: true, @@ -103,6 +166,13 @@ export class RebaseBridge { commits, baseCommit, inProgress, + headSha: head, + // The number apply() acts on: the whole selection, cap or no cap. The + // walk is `--cherry-pick --right-only`'s, NOT a plain `base..HEAD` count + // — the commits that walk drops are exactly the ones apply() does not + // replay, so counting them would swap an undercount for an overcount. + replayCount: await this.selectionCount(base), + updateRefs: await this.repoUpdateRefs(), message: notes.join(" ") || undefined, }; } @@ -112,30 +182,262 @@ export class RebaseBridge { if (!ctx) { return []; } - const range = base === "--root" ? "HEAD" : `${base}..HEAD`; + // THREE dots, and --cherry-pick --right-only, for anything but --root. + // + // git's sequencer selects the todo with `--cherry-mark --right-only` over + // `upstream...HEAD`, which DROPS commits whose patch is already on the base + // — a backport, a cherry-pick that went both ways, a commit merged upstream + // by someone else. `<base>..HEAD` keeps them, so the plan listed a commit + // git's own todo omits; running it made git skip that commit and PAUSE: + // + // warning: skipped previously applied commit 4b20fb3 + // + // leaving the repo mid-rebase with a clean tree and an in-progress card + // telling the user to resolve conflicts that do not exist — the same wedge + // a merge commit in the range used to produce, for the same reason. + const threeDot = base !== "--root"; + const range = threeDot ? `${base}...HEAD` : "HEAD"; const sep = "\x1f"; const r = await ctx.process.run([ "log", - // NEWEST FIRST, matching the Commits list (issue #18). git's todo file is - // the other way round; apply() does that reversal in exactly one place. + ...(threeDot ? ["--cherry-pick", "--right-only"] : []), + // A rebase FLATTENS merges: it replays the merged-in commits one by one + // and the merge itself disappears. `git log` lists merges; `git rebase -i` + // does not — its sequencer builds the todo from + // `rev-list --reverse --topo-order --no-merges`, and its parser REFUSES + // `pick <merge>` outright ("error: 'pick' does not accept merge commits"). + // So a feature branch with main merged into it — the ordinary shape — + // produced a plan git would never accept, and running it left the repo + // detached at the base, mid-rebase, with a clean tree and no conflict to + // resolve. Continue re-ran the same failing todo; only Abort escaped. + "--no-merges", + // The other half, and load-bearing beyond merges: reversed, this + // reproduces git's own todo, and the default ordering does not. Measured + // on a range whose two lines interleave by date — base, then + // B(Jan 9) → C(Jan 2) on one line and A(Jan 3) on the other: + // + // git log --no-merges A, C, B → todo: pick B, pick C, pick A + // git log --no-merges --topo C, B, A → todo: pick A, pick B, pick C + // git rebase -i's OWN todo → pick A, pick B, pick C + // + // Both todos are legal; only one replays the branch the way git would, + // and the plan is a promise about what running it will do. + // + // (It does mean the plan can order differently from the Commits list, + // which is --date-order. The plan has to match the todo git executes.) + "--topo-order", + // Newest first on screen (issue #18); git's todo is the other way round, + // and apply() does that reversal in exactly one place. // Hard cap: rebasing onto --root in a large repo would otherwise try to // render thousands of rows (and be a terrible idea to execute). `--max-count=${MAX_PLAN_COMMITS}`, - `--format=%H${sep}%h${sep}%an${sep}%at${sep}%s`, + // NUL-separated RECORDS, with the full message LAST. + // + // %B contains newlines, so it cannot be another \x1f field in a + // newline-delimited stream — the body's own lines would parse as extra + // commits whose "shas" then fail the plan's validation and break the + // whole view. `-z` ends each record with NUL instead, which %B cannot + // contain. + "-z", + `--format=%H${sep}%h${sep}%an${sep}%at${sep}%s${sep}%B`, range, ]); if (r.code !== 0) { return []; } + const tips = await this.branchTips(); + const out: RebaseCommitInfo[] = []; + for (const line of r.stdout.split("\0")) { + if (!line.trim()) continue; + const [sha, shortSha, author, at, subject, body] = line.split(sep); + const branches = tips.get(sha); + out.push({ + sha, + shortSha, + author, + subject: subject ?? "", + rel: relTime(Number(at) || 0), + // Trailing newlines are git's, not the author's. + ...(body?.trim() ? { body: body.replace(/\n+$/, "") } : {}), + ...(branches?.length ? { branches } : {}), + }); + } + return out; + } + + + /** + * Local branches by the commit they point at, for the todo's `update-ref` + * lines. + * + * Its own method because BOTH halves of the plan need it. It used to be + * inline in `loadCommits`, so only the commits shown on screen carried their + * branches: a branch pointing below the 200-commit display cap got no + * `update-ref`, and the rebase left it on a parallel line nothing references + * — the precise orphaning this feature exists to prevent, on the commits the + * user was least able to notice. + */ + private async branchTips(): Promise<Map<string, string[]>> { + const ctx = this.repos.getContext(); + const tips = new Map<string, string[]>(); + if (!ctx) return tips; + // A rewrite gives every commit a new sha, so a branch pointing at an old + // one is stranded on a line nothing references — `update-ref` in the todo + // moves it across. + // + // FULL refnames, not `%(refname:short)`. The short form is the shortest + // UNAMBIGUOUS name, so a branch that collides with a tag — v1.2, release, + // stable, all routine — comes back as `heads/stacked-a`. That string went + // straight into `update-ref refs/heads/heads/stacked-a`: the rebase created + // a junk branch under that name, reported success, and left the user's real + // branch on a parallel line no longer in the rebased history — exactly the + // orphaning this feature exists to prevent. Verified on a real repo. + // `RefProvider.ts` already says this in a comment; this code did not follow it. + // + // `%(worktreepath)` too: git's own --update-refs REFUSES to move a branch + // checked out in another worktree, writing a comment into the todo instead + // ("# Ref refs/heads/x checked out at ..."). Moving it anyway leaves that + // worktree's HEAD on a rewritten commit while its index and working tree + // stay behind — `git status` there then shows staged changes nobody made. + // The app ships a Worktrees feature, so this is a normal setup for its users. + const headRef = ( + await ctx.process.run(["symbolic-ref", "--quiet", "HEAD"]) + ).stdout.trim(); + const SEP = "\x1e"; + const refs = await ctx.process.run([ + "for-each-ref", + `--format=%(objectname)${SEP}%(refname)${SEP}%(worktreepath)`, + "refs/heads", + ]); + if (refs.code === 0) { + for (const line of refs.stdout.split("\n")) { + if (!line.trim()) continue; + const [sha, ref, worktree] = line.split(SEP); + if (!sha || !ref?.startsWith("refs/heads/")) continue; + // Never the branch being rebased: git moves that one itself, and + // naming it in an update-ref would fight the rebase for it. + if (ref === headRef) continue; + // Nor one checked out anywhere else — see above. + if (worktree?.trim()) continue; + const name = ref.slice("refs/heads/".length); + tips.set(sha, [...(tips.get(sha) ?? []), name]); + } + } + + return tips; + } + + /** + * Commits in `base..HEAD` that the plan does not mention — the tail the + * display cap hid. Returned as plain picks so applying the plan cannot + * delete history the user never saw. `null` means we could not read the + * range, which must block the apply rather than silently truncate it. + */ + private async commitsBelowCap( + base: string, + rows: RebaseApplyRow[], + ): Promise<RebaseApplyRow[] | null> { + const ctx = this.repos.getContext(); + if (!ctx) return null; + // The SAME selection as loadCommits, for the same reasons — a merge, or a + // patch already on the base, re-injected as a `pick` below the display cap + // wedges the repo just as surely — and so this tail is the same + // linearization the shown page came from, which is what makes appending it + // correct. + const threeDot = base !== "--root"; + const range = threeDot ? `${base}...HEAD` : "HEAD"; + const sep = "\x1f"; + const r = await ctx.process.run([ + "log", + ...(threeDot ? ["--cherry-pick", "--right-only"] : []), + "--no-merges", + "--topo-order", + `--format=%H${sep}%s`, + range, + ]); + if (r.code !== 0) return null; + const known = new Set(rows.map((x) => x.sha)); + // Carried commits need their branches as much as the shown ones do — more, + // in fact, because nothing on screen would have hinted at the loss. Without + // this the tail rode along as bare picks, and a branch pointing below the + // display cap was left on a parallel line no longer in the rebased history. + const tips = await this.branchTips(); + const out: RebaseApplyRow[] = []; for (const line of r.stdout.split("\n")) { if (!line.trim()) continue; - const [sha, shortSha, author, at, subject] = line.split(sep); - out.push({ sha, shortSha, author, subject: subject ?? "", rel: relTime(Number(at) || 0) }); + const [sha, subject] = line.split(sep); + if (!sha || known.has(sha)) continue; + const branches = tips.get(sha); + out.push({ + action: "pick", + sha, + subject: subject ?? "", + ...(branches?.length ? { branches } : {}), + }); } + // git lists newest-first, and so does the plan; appending keeps that order. return out; } + /** How many merge commits the range contains. They are NOT in the plan — a + * rebase flattens them away — so the view has to say they were left out. */ + private async countMerges(base: string): Promise<number> { + const ctx = this.repos.getContext(); + if (!ctx) return 0; + const range = base === "--root" ? "HEAD" : `${base}..HEAD`; + const r = await ctx.process.run(["rev-list", "--count", "--merges", range]); + return r.code === 0 ? Number(r.stdout.trim()) || 0 : 0; + } + + /** + * How many commits `--cherry-pick` dropped: everything in `base..HEAD` that + * is NOT in the plan's `base...HEAD --cherry-pick --right-only` selection. + * + * Both walks exclude merges, which are counted and explained separately, so + * the two notes never describe the same commit twice. + */ + private async countAlreadyApplied(base: string): Promise<number> { + const ctx = this.repos.getContext(); + if (!ctx || base === "--root") return 0; + const [all, kept] = await Promise.all([ + ctx.process.run(["rev-list", "--count", "--no-merges", `${base}..HEAD`]), + ctx.process.run(["rev-list", "--count", "--no-merges", "--cherry-pick", "--right-only", `${base}...HEAD`]), + ]); + if (all.code !== 0 || kept.code !== 0) return 0; + return Math.max(0, (Number(all.stdout.trim()) || 0) - (Number(kept.stdout.trim()) || 0)); + } + + /** How many commits the plan's selection contains, ignoring the display cap. */ + private async selectionCount(base: string): Promise<number | undefined> { + const ctx = this.repos.getContext(); + if (!ctx) return undefined; + const args = + base === "--root" + ? ["rev-list", "--count", "--no-merges", "HEAD"] + : ["rev-list", "--count", "--no-merges", "--cherry-pick", "--right-only", `${base}...HEAD`]; + const r = await ctx.process.run(args); + return r.code === 0 ? Number(r.stdout.trim()) || 0 : undefined; + } + + /** HEAD's sha, or undefined on an unborn branch or an unreadable repo. */ + private async headSha(): Promise<string | undefined> { + const ctx = this.repos.getContext(); + if (!ctx) return undefined; + const r = await ctx.process.run(["rev-parse", "HEAD"]); + return r.code === 0 && r.stdout.trim() ? r.stdout.trim() : undefined; + } + + /** The repo's own `rebase.updateRefs`. Following it means the app does what + * the user's git already does; ignoring it was how branches got orphaned by + * a rebase that their own config said should carry them. */ + private async repoUpdateRefs(): Promise<boolean> { + const ctx = this.repos.getContext(); + if (!ctx) return false; + const r = await ctx.process.run(["config", "--get", "--type=bool", "rebase.updateRefs"]); + return r.code === 0 && r.stdout.trim() === "true"; + } + private async loadBaseCommit(base: string): Promise<{ shortSha: string; subject: string } | undefined> { const ctx = this.repos.getContext(); if (!ctx) { @@ -160,16 +462,63 @@ export class RebaseBridge { if (!rows.length) { return { status: "failed", message: "Nothing to rebase." }; } + // The plan describes a branch tip. If the tip has moved since — a commit + // made in a terminal, a pull, an amend — the rows no longer cover the + // range, and `commitsBelowCap` cannot tell "the user never saw this" from + // "this is below the display cap": it appends the new commit, and the + // reversal into git's todo makes it the FIRST pick. Measured: plan C,B,A, + // commit D, apply → the branch reads D, A, B, C oldest-first, and nothing + // on screen ever mentioned D. + // + // Compared against HEAD ITSELF, not against the plan's top row: the + // selection drops merges and already-applied commits, so on a perfectly + // current plan the top row is legitimately not HEAD. + if (req.headSha) { + const now = await this.headSha(); + if (now && now !== req.headSha) { + return { + status: "failed", + message: + "The branch has moved since this plan was built — something committed, pulled or amended " + + "while it was open. Reload the plan and try again.", + }; + } + } + + // The todo IS the plan: a commit in the range but NOT in the todo is + // dropped. `loadCommits` caps the list at MAX_PLAN_COMMITS so a huge range + // does not render thousands of rows — a DISPLAY limit — and the note said + // exactly that ("Showing the first 200 commits"). But apply() then built + // the todo from those 200 rows and ran it over the whole range, so + // rebasing 205 commits DELETED the 5 oldest and reported "done". + // Measured, on a real repo, before this: BEFORE=205 AFTER=200 LOST=5. + // + // The commits below the cap are ones the user was never shown and never + // made a decision about, so they ride along untouched: appended in display + // order (which is newest-first, so appending puts them oldest-last), which + // `buildRebasePlan`'s reversal turns into the first picks of the todo. + const carried = await this.commitsBelowCap(req.base, rows); + if (carried === null) { + return { + status: "failed", + message: + "Couldn't read the full commit range, so the plan can't be applied safely. Pick a nearer base and try again.", + }; + } + const fullRows = [...rows, ...carried]; // Display order (newest first) becomes git's todo order in buildRebasePlan, // shared with the extension because every way to get this wrong is silent. - const built = buildRebasePlan(rows); + const updateRefs = req.updateRefs ?? (await this.repoUpdateRefs()); + const built = buildRebasePlan(fullRows, { updateRefs }); if (!built.ok) { return { status: "failed", message: built.message }; } - const { todo, rewordMessages } = built; + const { todo, rewords } = built; try { - return await runRebasePlan(root, { base: req.base, todo, rewordMessages }); + // Same options as every other git command here: the app's git, and the + // observer that feeds the Output tab. + return await runRebasePlan(root, { base: req.base, todo, rewords }, this.repos.runnerOptions()); } catch (err) { return { status: "failed", message: err instanceof Error ? err.message : String(err) }; } diff --git a/apps/desktop/src/main/repoStore.ts b/apps/desktop/src/main/repoStore.ts index cdffe38..08f5233 100644 --- a/apps/desktop/src/main/repoStore.ts +++ b/apps/desktop/src/main/repoStore.ts @@ -4,13 +4,43 @@ // Electron-specific beyond the persistence path, so the data layer stays the // same one the extension uses. -import { basename } from "node:path"; +import { basename, resolve } from "node:path"; import { GitContext, NodeGitAdapter } from "@gitstudio/git-service/index"; import type { GitRunHook } from "@gitstudio/git-service/index"; import type { RepoInfo } from "../shared/ipc"; const MAX_RECENT = 12; +/** + * How two repo roots are compared, everywhere. + * + * `removeRecent` already established that raw string equality is the wrong + * rule — the repo manager hands back realpath'd roots, so a recent stored + * through a symlink would never match and "Forget" would silently do nothing. + * The same argument applies to ADDING: the recents file is plain JSON that + * survives upgrades and can be hand-edited, so a stored "/x/repo/" and a + * freshly discovered "/x/repo" are the same repo listed twice. + */ +export function sameRoot(a: string, b: string): boolean { + return resolve(a) === resolve(b); +} + +/** + * The recents list after opening `root`: most recent first, no duplicates + * (compared by {@link sameRoot}), capped. Pure, so the ordering rules are + * testable without a git repo on disk. + * + * The freshly opened spelling of the path wins, so a list that accumulated an + * odd variant heals the next time you open that repo. + */ +export function promoteRecentList( + recent: readonly string[], + root: string, + max = MAX_RECENT, +): string[] { + return [root, ...recent.filter((r) => !sameRoot(r, root))].slice(0, max); +} + export class RepoStore { private readonly adapter = new NodeGitAdapter(); private context: GitContext | undefined; @@ -22,11 +52,30 @@ export class RepoStore { * so the renderer's Output tab can show a live git-command log. */ onGitRun?: GitRunHook; + /** + * The options a shared runner needs to look like any other git command here: + * the same executable, and the same observer feeding the Output tab. + * + * `RebaseRunner` spawns git directly rather than through `GitContext`, so + * without this its invocations were invisible — a `rebase --continue` that + * failed left no row anywhere in the app. + */ + runnerOptions(): { gitPath: string; onRun: GitRunHook } { + return { gitPath: this.adapter.gitPath(), onRun: (e) => this.onGitRun?.(e) }; + } + /** Listeners fired when the active repo changes (the main process re-emits). */ private readonly listeners = new Set<(info: RepoInfo | undefined) => void>(); constructor(recent: string[] = []) { - this.recent = recent.slice(0, MAX_RECENT); + // The persisted list is plain JSON that outlives upgrades, so de-duplicate + // on the way in rather than trusting it. Order is preserved; the first + // spelling of each root wins. + const seen: string[] = []; + for (const r of recent) { + if (r && !seen.some((k) => sameRoot(k, r))) seen.push(r); + } + this.recent = seen.slice(0, MAX_RECENT); } onChange(fn: (info: RepoInfo | undefined) => void): void { @@ -62,8 +111,16 @@ export class RepoStore { // A newer open() began while we were discovering the root — let it win, and // touch no shared state here (otherwise we'd leave the UI on one repo and the // active context on another). + // + // What we RETURN matters too. Handing back the root we discovered would tell + // our caller "you opened A" while the active context is B, so the window + // would render A's branches against B's repo. Report whatever is actually + // open instead; if nothing is yet (the winner is still discovering), fall + // back to our root so the caller doesn't raise a false "not a Git + // repository" — the winner's change event corrects the view a moment later. if (seq !== this.openSeq) { - return root ? toInfo(root) : undefined; + if (!root) return undefined; + return this.current() ?? toInfo(root); } if (!root) { return undefined; @@ -102,11 +159,20 @@ export class RepoStore { this.listeners.clear(); } + /** Forget a root (Settings → Repositories). Returns true when it was there — + * main.ts persists only on a real change. Never touches disk or the open + * repo: forgetting a repo you're standing in is a list edit, nothing more. */ + removeRecent(root: string): boolean { + const before = this.recent.length; + // Compare RESOLVED paths: the manager hands back realpath'd roots, and a + // recent stored through a symlink would otherwise never match — the click + // would report success and change nothing. + this.recent = this.recent.filter((r) => !sameRoot(r, root)); + return this.recent.length !== before; + } + private promoteRecent(root: string): void { - this.recent = [root, ...this.recent.filter((r) => r !== root)].slice( - 0, - MAX_RECENT, - ); + this.recent = promoteRecentList(this.recent, root); } private emit(info: RepoInfo | undefined): void { diff --git a/apps/desktop/src/renderer/aiAssist.ts b/apps/desktop/src/renderer/aiAssist.ts index f9ae206..a78a376 100644 --- a/apps/desktop/src/renderer/aiAssist.ts +++ b/apps/desktop/src/renderer/aiAssist.ts @@ -17,7 +17,12 @@ let enabledCache: boolean | undefined; export async function aiEnabled(): Promise<boolean> { if (enabledCache !== undefined) return enabledCache; try { - enabledCache = (await host.invoke("ai:settings", undefined)).enabled; + // `?? false`, not a bare read: an answer that arrives without `enabled` + // assigned `undefined` to the cache, which is the ONE value that means "not + // cached yet" — so the memo never took and every view that gates on AI + // re-asked over IPC on every single route. A memo you can poison with a + // malformed answer is not a memo. + enabledCache = (await host.invoke("ai:settings", undefined))?.enabled ?? false; } catch { enabledCache = false; } @@ -98,26 +103,57 @@ export async function streamInto( btn.replaceChildren(glyph("loading"), span("Writing…")); } const prev = textarea.value; - textarea.value = ""; + // Through an `input` event, always. Every control that watches this box — + // both Commit buttons, the character counter — learns about it that way, and + // a bare assignment is invisible to all of them. So emptying the composer to + // stream a generated message into it left Commit fully enabled over a box + // with nothing in it, for as long as the model took to produce a first token. + const setComposer = (v: string): void => { + textarea.value = v; + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }; + setComposer(""); let got = false; + /** + * Stop the moment the box we are writing into leaves the document. + * + * A stream outlives a repo switch, a route change and a view rebuild — and it + * went on appending to a detached textarea, then dispatching `input` on it. + * For the commit composer that meant repo A's generated message being written + * into the draft while repo B was open, silently replacing whatever was + * typed there. What is on screen is the only thing worth writing to. + */ + const gone = (): boolean => !textarea.isConnected; const res = await streamTask(task, input, (d) => { + if (gone()) return; got = true; - textarea.value += d; - textarea.dispatchEvent(new Event("input", { bubbles: true })); + setComposer(textarea.value + d); }); + if (gone()) { + if (btn) { + btn.disabled = false; + if (original) btn.innerHTML = original; + } + return; + } if (btn) { btn.disabled = false; if (original) btn.innerHTML = original; } if (!res.ok || (!got && !res.text)) { - textarea.value = prev; // restore on failure + // …and through the same helper. This one was a bare assignment, so putting + // the reader's message BACK after a failure left both Commit buttons + // disabled over it — the state the empty box had put them in. + setComposer(prev); // restore on failure toast(res.message ?? "Couldn't generate that.", "error"); return; } if (!got && res.text) { - textarea.value = res.text; - textarea.dispatchEvent(new Event("input", { bubbles: true })); + setComposer(res.text); } - textarea.value = textarea.value.trim(); + // The trim is a programmatic write like any other: if the model returned + // only whitespace this empties the box, and without the event the commit + // buttons would stay enabled over an empty message. + setComposer(textarea.value.trim()); textarea.focus(); } diff --git a/apps/desktop/src/renderer/aiSettings.ts b/apps/desktop/src/renderer/aiSettings.ts index 7851972..c946781 100644 --- a/apps/desktop/src/renderer/aiSettings.ts +++ b/apps/desktop/src/renderer/aiSettings.ts @@ -14,8 +14,20 @@ import { providerLogo } from "./providerLogos"; import { invalidateAiEnabled } from "./aiAssist"; import { toast, confirmDialog, promptInline } from "./dialogs"; import { trapTab } from "./views/common"; +import { registerLayer, holdBackground } from "./overlays"; import type { AiConnectionView, AiPresetView, AiSettingsView, McpInfo } from "../shared/ipc"; +/** Say that the set of usable models may have changed. + * + * The Assistant gates itself ONCE, on mount, and it is a keep-alive view — so + * its cached DOM was re-attached unchanged on every later visit. Connecting a + * model in Settings therefore never lifted the "Connect a model" panel: the + * Assistant stayed gated for the rest of the session, with a working + * connection sitting behind it. */ +export function announceAiChanged(): void { + window.dispatchEvent(new CustomEvent("gs:ai-changed")); +} + /** The "AI Models" card: manage model connections. */ export function aiModelsCard(): HTMLElement { const { card, body } = settingsCard("AI Models", "sparkle"); @@ -30,11 +42,31 @@ export function aiModelsCard(): HTMLElement { "Connect any model to power the ✨ helpers and the Assistant — bring your own key, or run a local model (Ollama / LM Studio) that never leaves your machine. AI is optional and never blocks Git."; body.append(sub); + /** The card's one action, built where BOTH exits can use it. */ + const addButton = (): HTMLElement => { + const add = el("button", "btn btn-primary ai-add-btn"); + add.append(glyph("add"), span("Connect a model")); + add.addEventListener("click", () => openGallery(body, render)); + return add; + }; + let settings: AiSettingsView; try { settings = await host.invoke("ai:settings", undefined); } catch (e) { + // A FAILED READ IS NOT AN EMPTY CARD. This returned before the button + // below was built, so one transient error — the settings file locked, a + // slow disk — left the card with an error line and nothing else: no + // models, no way to connect one, and no way to try again short of + // restarting the app. The card's entire purpose, gone, for a read that + // would have succeeded a second later. body.append(errorLine(cleanErr(e))); + const retry = el("button", "mini-btn"); + retry.append(glyph("refresh"), span("Try again")); + retry.addEventListener("click", () => void render()); + const row = el("div", "settings-actions"); + row.append(retry, addButton()); + body.append(row); return; } @@ -50,10 +82,7 @@ export function aiModelsCard(): HTMLElement { body.append(list); } - const add = el("button", "btn btn-primary ai-add-btn"); - add.append(glyph("add"), span("Connect a model")); - add.addEventListener("click", () => openGallery(body, render)); - body.append(add); + body.append(addButton()); }; void render(); @@ -89,6 +118,7 @@ function connectionRow(c: AiConnectionView, isDefault: boolean, refresh: () => P star.addEventListener("click", () => void runBusy(star, async () => { await host.invoke("ai:setDefault", { id: c.id }); + announceAiChanged(); void refresh(); }), ); @@ -114,12 +144,19 @@ function connectionRow(c: AiConnectionView, isDefault: boolean, refresh: () => P remove.addEventListener("click", async () => { const ok = await confirmDialog({ title: "Remove model", - message: `Remove “${c.label}”? Its stored API key will be deleted from this machine.`, + // Only claim the key when there IS one. A local connection that needs no + // key, or one you have not given a key to yet, was told its key would be + // deleted — a confirm that describes a consequence that cannot happen + // teaches people to stop reading confirms. + message: c.hasKey + ? `Remove “${c.label}”? Its stored API key will be deleted from this machine.` + : `Remove “${c.label}”?`, confirmLabel: "Remove", danger: true, }); if (!ok) return; await host.invoke("ai:removeConnection", { id: c.id }); + announceAiChanged(); toast("Model removed.", "info"); void refresh(); }); @@ -147,16 +184,25 @@ function buildEditor(editor: HTMLElement, c: AiConnectionView, refresh: () => Pr editor.append(urlF.row, fastF.row, midF.row, deepF.row); } + /** The key field, when this connection has one — read by the primary Save. */ + let keyField: HTMLInputElement | undefined; if (c.needsKey) { const keyRow = el("div", "settings-field"); - const kl = el("label", "settings-field-label"); + const kl = el("label", "settings-field-label") as HTMLLabelElement; kl.textContent = "API key"; const keyInput = document.createElement("input"); keyInput.type = "password"; keyInput.className = "settings-input"; + // UNIQUE per connection. Two open editors both carried id="gs-ai-key", so + // clicking one card's "API key" label focused the OTHER card's field — and + // the document had two elements with the same id, which is invalid and + // makes every label ambiguous to a screen reader. + keyInput.id = `gs-ai-key-${c.id}`; + kl.htmlFor = keyInput.id; keyInput.placeholder = c.hasKey ? "•••••••• (stored — leave blank to keep)" : "Paste your API key"; keyRow.append(kl, keyInput); editor.append(keyRow); + keyField = keyInput; const saveKey = el("button", "mini-btn"); saveKey.append(glyph("key"), span(c.hasKey ? "Update key" : "Save key")); @@ -167,6 +213,7 @@ function buildEditor(editor: HTMLElement, c: AiConnectionView, refresh: () => Pr } void runBusy(saveKey, async () => { await host.invoke("ai:setKey", { id: c.id, key: keyInput.value.trim() }); + announceAiChanged(); keyInput.value = ""; toast("Key stored securely.", "success"); void refresh(); @@ -186,7 +233,19 @@ function buildEditor(editor: HTMLElement, c: AiConnectionView, refresh: () => Pr baseUrl: urlF.input.value.trim(), models: { fast: fastF.input.value.trim(), mid: midF.input.value.trim(), deep: deepF.input.value.trim() }, }); - toast("Saved.", "success"); + // The key too. It has its own button because it goes to a different + // channel and a different store — but the PRIMARY button is the one + // labelled Save, and it used to send everything on the card EXCEPT the + // field you had just typed into, then say "Saved." The key was gone, and + // the only sign was that the placeholder still read "Paste your API key" + // the next time you opened the card. + const typedKey = keyField?.value.trim(); + if (typedKey) { + await host.invoke("ai:setKey", { id: c.id, key: typedKey }); + announceAiChanged(); + if (keyField) keyField.value = ""; + } + toast(typedKey ? "Saved, and the key stored securely." : "Saved.", "success"); void refresh(); }), ); @@ -231,13 +290,36 @@ async function openGallery(body: HTMLElement, refresh: () => Promise<void>): Pro // its controls aren't clickable. overlay.style.setProperty("-webkit-app-region", "no-drag"); const prevFocus = document.activeElement as HTMLElement | null; + // A layer like every other floating surface. It set `aria-modal="true"` and + // then did none of the three things that makes true: it was not in the + // registry (so a route change left it hanging over the next view), it did not + // hold the page behind it back (so Tab walked straight out into a board the + // user could not see), and it did not stand down for anything above it. Every + // sibling surface in the app had each of those fixed in turn; this one was + // never in the list. + let releaseBg: (() => void) | undefined; const closeOverlay = (): void => { + layer.release(); + releaseBg?.(); overlay.remove(); document.removeEventListener("keydown", onKey, true); prevFocus?.focus?.(); }; + const layer = registerLayer(() => closeOverlay(), "modal"); const onKey = (e: KeyboardEvent): void => { if (e.key === "Escape") { + // `isTop()`, NOT `ownsEscape()`. The gallery registers itself as a + // "modal", and `ownsEscape()` asks "is any modal open?" — which its own + // registration makes true, so Escape could never close it at all. The + // registry answers the question each surface is actually asking: is + // anything still open that opened after me? + // `isTop()` ALONE. This asked `isTop() || ownsEscape()`'s conjunction + // and kept the exact term that broke it: the gallery registers itself as + // a "modal", `ownsEscape()` asks "is any modal open?", and its own + // registration makes that true — so Escape could never close it at all. + // Every layer that can sit above this one is IN the registry (the + // palette and menus included), so being top is the whole question. + if (!layer.isTop()) return; e.preventDefault(); closeOverlay(); return; @@ -248,6 +330,12 @@ async function openGallery(body: HTMLElement, refresh: () => Promise<void>): Pro const choose = async (p: AiPresetView): Promise<void> => { closeOverlay(); await host.invoke("ai:addConnection", { preset: p.id }); + // A KEYLESS preset (a local model, a CLI agent) is usable the moment it is + // added — no key dialog follows, so nothing else on this path announces it. + // The four `setKey` / `removeConnection` / `setDefault` sites all do; this + // one did not, and it is the only way to connect the providers that need no + // key at all. Their users could never lift the Assistant's gate. + announceAiChanged(); const ready = !p.needsKey; toast(`Added ${p.label}. ${ready ? "Ready to use." : "Add your API key to finish."}`, "success"); await refresh(); @@ -315,6 +403,9 @@ async function openGallery(body: HTMLElement, refresh: () => Promise<void>): Pro }); overlay.append(panel); document.body.append(overlay); + // AFTER the mount, so the overlay itself is not one of the elements held + // back. `aria-modal` is a claim; this is the mechanism behind it. + releaseBg = holdBackground(overlay); document.addEventListener("keydown", onKey, true); // Focus the first card (or the close button) so keyboard users land inside. (panel.querySelector<HTMLElement>(".ai-prov-card") ?? close).focus(); @@ -355,7 +446,13 @@ export function agentAccessCard(): HTMLElement { // Permission selector. const permWrap = el("div", "mcp-perm"); const permLabel = el("div", "settings-field-label"); - permLabel.textContent = "What the agent may do"; + // What the next Add or Update will GRANT — not what any client currently + // has. This control is an argument to `ai:mcpInstall`, and it resets to + // Read-only every time the card is built; labelled as a state ("what the + // agent may do") it therefore announced Read-only over a client installed + // with write, and its description asserted "the agent cannot change your + // repository" about an agent that could. + permLabel.textContent = "Grant to the next client you add or update"; const seg = el("div", "settings-seg"); const perms: Array<{ id: typeof permission; label: string }> = [ { id: "read", label: "Read-only" }, @@ -371,12 +468,14 @@ export function agentAccessCard(): HTMLElement { }); seg.append(b); } - // Explain what the CURRENT level grants, so the choice is never a guess. + // Explain what the SELECTED level will grant, so the choice is never a + // guess — in the future tense, because it applies to the next install and + // not to what is already there. const permDesc = el("div", "mcp-perm-desc"); const permDescs: Record<typeof permission, string> = { - read: "Inspect only — history, diffs, branches and file contents. The agent cannot change your repository.", - write: "Everything in Read-only, plus stage, commit and create or switch branches. It can't discard or rewrite existing work.", - destructive: "Everything above, plus discard, reset and force operations that can lose uncommitted work or rewrite history.", + read: "Adds inspect-only access — history, diffs, branches and file contents. A client added this way cannot change your repository.", + write: "Adds everything in Read-only, plus stage, commit and create or switch branches. It won't be able to discard or rewrite existing work.", + destructive: "Adds everything above, plus discard, reset and force operations that can lose uncommitted work or rewrite history.", }; permDesc.textContent = permDescs[permission]; permWrap.append(permLabel, seg, permDesc); diff --git a/apps/desktop/src/renderer/assistant.ts b/apps/desktop/src/renderer/assistant.ts index ab210bc..c3df2c5 100644 --- a/apps/desktop/src/renderer/assistant.ts +++ b/apps/desktop/src/renderer/assistant.ts @@ -10,10 +10,71 @@ import { host } from "./bridge"; import { el, span, glyph, openMenu, relTimeISO } from "./ui"; import type { MenuItem } from "./ui"; +import { confirmDialog, toast } from "./dialogs"; import { runAgentTurn, addBubble, markdownBlock, errorBlock, connectPrompt, elText, setBusy, scrollDown } from "./chatRender"; import type { SectionRender } from "./views/common"; import type { AiModelOption, AiSettingsView, ChatView } from "../shared/ipc"; +/** A goal handed in from elsewhere (✨ actions in PR/issue views) — consumed + * by the next render. The ✨ flow used to open a CHAT TAB in the bottom dock, + * which split the screen in half; now it lands here, in the one AI surface. */ +let pendingGoal: string | null = null; +/** What the USER BUBBLE should say for that goal. + * + * A ✨ action's goal is a whole prompt — "Analyze this issue:" plus the title, + * the body and every comment on it — and it was posted verbatim as the user's + * chat message. Opening ✨ Analyze on a busy issue put several screens of + * quoted text into the transcript as if the reader had typed it, burying the + * answer below the fold. The dock's chat tab has carried a short label for + * exactly this since it was written (`seedLabel` → `runAgentTurn`'s + * `displayText`); the section path dropped it on the floor. */ +let pendingLabel: string | undefined; + +/** The Assistant currently on screen, if there is one — so a ✨ action can be + * handed to it instead of rebuilding the view around it. Cleared when the view + * is torn down. */ +let live: + | { run: (goal: string, label?: string) => void; busy: () => boolean; el: HTMLElement } + | undefined; + +/** + * Seed a goal for the Assistant, and say whether the caller still needs to + * route there. + * + * `false` means it has already been handed to the Assistant on screen. The + * caller used to route unconditionally with `force: true`, which drops the view + * from the cache and rebuilds it — so firing a second ✨ action while the agent + * was answering the first destroyed the transcript and the Stop button and + * orphaned the run, with no confirm and no cancel. Exactly the defect the + * refreshAll exemption fixes, reached through a different door. + */ +export function seedAssistantGoal(goal: string, label?: string): boolean { + // The BUSY test asks about identity, not attachment. + // + // Every ✨ action fires from another view — an issue, a PR, the compare page + // — and "assistant" is keep-alive, so by the time this runs the wrap is + // PARKED: detached, and very much alive with a turn streaming into it. An + // `isConnected` guard therefore made this branch unreachable in every real + // case, which is the identical mistake `onAiChanged` twenty lines below + // carries a comment about. The toast below had never once been shown. + if (live?.busy()) { + toast("The agent is still working — stop it first, or wait for it to finish.", "info"); + return false; + } + // The IDLE branch keeps `isConnected`, deliberately. A parked view can be + // evicted from the cache (leaving during the initial gate drops it), and + // `live` is never cleared — so running a goal into an evicted node would + // write it into a true orphan and the route below would then build a fresh, + // empty Assistant with the goal lost. + if (live?.el.isConnected) { + live.run(goal, label); + return false; + } + pendingGoal = goal; + pendingLabel = label; + return true; +} + /** Agent write permission, remembered across navigations within a session. */ let permission: "read" | "write" | "destructive" = "read"; /** The explicit model id the user picked (from the provider's models). */ @@ -53,14 +114,19 @@ export const renderAssistant: SectionRender = (wrap, nav) => { title.append(glyph("sparkle"), span("Assistant")); const connTag = el("span", "assistant-model"); // New-chat + chat-history controls — sessions persist across refresh/restart. - const newBtn = el("button", "assistant-iconbtn"); + const newBtn = el("button", "assistant-iconbtn") as HTMLButtonElement; + newBtn.dataset.baseTitle = "New chat"; newBtn.title = "New chat"; newBtn.append(glyph("add")); newBtn.addEventListener("click", () => void newChat()); - const histBtn = el("button", "assistant-iconbtn"); + const histBtn = el("button", "assistant-iconbtn") as HTMLButtonElement; + histBtn.dataset.baseTitle = "Chat history"; histBtn.title = "Chat history"; histBtn.append(glyph("history")); histBtn.addEventListener("click", () => void openHistory()); + /** The two chat-management controls — off while the gate is closed, since + * there are no chats to manage and their handlers return on their own. */ + const chatBtns: HTMLButtonElement[] = [newBtn, histBtn]; header.append(title, connTag, newBtn, histBtn); // Three compact dropdown "chips" — the agent's options shown directly here and @@ -69,8 +135,11 @@ export const renderAssistant: SectionRender = (wrap, nav) => { const controls = el("div", "assistant-controls"); /** Build a chip whose menu items are produced fresh each open. */ + /** The three run-setting chips, so one rule can say when they take effect. */ + const settingChips: HTMLElement[] = []; const makeChip = (icon: string, initial: string, items: () => MenuItem[]): { el: HTMLElement; set: (t: string) => void } => { const b = el("button", "assistant-chip-ctl"); + settingChips.push(b); const ic = glyph(icon); const lab = span(initial, "assistant-chip-label"); const car = glyph("chevron-down"); @@ -119,28 +188,111 @@ export const renderAssistant: SectionRender = (wrap, nav) => { header.append(controls); const transcript = el("div", "assistant-transcript"); + // Reachable by keyboard. A scrollable region that cannot take focus cannot be + // scrolled by anything but a pointer — PageUp, Home and the arrows all need a + // focused scroller to act on, and this one had no tabindex at all. It is the + // longest-lived scroller in the app: a chat you have been working in all day. + transcript.tabIndex = 0; + transcript.setAttribute("role", "log"); + transcript.setAttribute("aria-label", "Conversation"); const composer = el("div", "assistant-composer"); const quick = el("div", "assistant-quick"); + const chips: HTMLButtonElement[] = []; for (const qa of QUICK_ACTIONS) { - const chip = el("button", "assistant-chip"); + const chip = el("button", "assistant-chip") as HTMLButtonElement; chip.append(glyph(qa.icon), span(qa.label)); - chip.addEventListener("click", () => void runGoal(qa.goal)); + // `fromInput: false` — a chip carries its OWN goal, so clearing the + // composer would throw away a message the user had typed and not yet sent, + // in exchange for running something else entirely. + chip.addEventListener("click", () => void runGoal(qa.goal, false)); quick.append(chip); + chips.push(chip); } const inputRow = el("div", "assistant-input-row"); const input = document.createElement("textarea"); input.className = "assistant-input"; input.rows = 2; input.placeholder = "Ask the agent to do something in this repo…"; - const send = el("button", "btn btn-primary assistant-send"); + const send = el("button", "btn btn-primary assistant-send") as HTMLButtonElement; send.append(glyph("send")); send.title = "Send"; inputRow.append(input, send); composer.append(quick, inputRow); - wrap.append(header, transcript, composer); + // replaceChildren, not append: mountSection puts a loading skeleton in this + // container first, and the Assistant renders synchronously — so appending + // left a six-row shimmer pinned above the header, 278px of the pane, + // pretending to load something forever. + wrap.replaceChildren(header, transcript, composer); let running = false; + /** Stops the turn currently streaming — the same abort the Stop button uses, + * reachable from the places that would otherwise leave a run going with its + * transcript detached. */ + let cancelRun: (() => void) | undefined; + /** The connection gate refused — every control stays off until Settings + * changes, and nothing transient (a finished run, a re-render) may quietly + * turn them back on. */ + let gated = false; + + /** Send is off with nothing to send. It used to be lit over an empty + * composer, and clicking it called `runGoal("")`, which returns on its own + * first line — a primary button that did nothing, with no way to tell that + * from a broken one. */ + /** Everything the composer owns, in one place. + * + * The chips and the two "New chat" entry points were enabled in states where + * pressing them did nothing at all: a chip during a run hit `runGoal`'s + * `if (running) return`, and New chat while gated hit its own `if (gated) + * return`. Both guards are correct and neither is visible, so the control + * looked live and answered with silence. */ + const syncControls = (): void => { + const off = gated || running; + for (const c of chips) { + c.disabled = off; + c.title = gated + ? "Connect a model to use the Assistant" + : running + ? "The agent is still working" + : ""; + } + for (const b of chatBtns) { + b.disabled = gated; + b.title = gated ? "Connect a model to use the Assistant" : b.dataset.baseTitle || ""; + } + // The model, thinking level and access are read when a turn STARTS and + // travel with it. Changing one mid-run relabels the chip and leaves the + // running turn on the old value — so the chip states, in the present tense, + // something the agent working below it is not doing. It stays usable (you + // are usually setting up the next message) and says when it applies. + for (const b of settingChips) { + b.title = running ? "Applies to your next message — this turn keeps what it started with" : ""; + } + }; + + const syncSend = (): void => { + syncControls(); + // HANDS OFF while a turn is running. During a run this button is not Send — + // `swapToCancel` has turned it into Stop, and it owns its own enabled + // state. Including `running` in this expression meant that typing your next + // message while the agent worked disabled the Stop button on the first + // keystroke: the only way to stop a running agent, taken away by using the + // composer it sits next to. + if (running) return; + send.disabled = gated || !input.value.trim(); + }; + + /** Grow with the text, up to the height the stylesheet already budgets. + * Locked at two rows, a pasted commit message or a paragraph-long task was + * read through a 40px slot while 180px of empty composer sat under it. */ + const autoGrow = (): void => { + input.style.height = "auto"; + input.style.height = `${Math.min(input.scrollHeight, 180)}px`; + }; + input.addEventListener("input", () => { + syncSend(); + autoGrow(); + }); const empty = el("div", "assistant-empty"); empty.append( @@ -155,7 +307,13 @@ export const renderAssistant: SectionRender = (wrap, nav) => { transcript.append(empty); // Gate on a usable connection. - void (async () => { + // + // Held as a PROMISE: a ✨ goal seeded from another view starts at the bottom + // of this function, synchronously, while this is still in flight — so it ran + // before `permission`, `thinkLevel` and `selectedModelId` had been read out + // of settings, and then had its transcript wiped by the `restoreChat` below + // landing a quarter-second later. Both callers await it now. + const runGate = async (): Promise<void> => { let settings: AiSettingsView | undefined; try { settings = await host.invoke("ai:settings", undefined); @@ -163,9 +321,13 @@ export const renderAssistant: SectionRender = (wrap, nav) => { settings = undefined; } if (!settings || !settings.enabled) { + gated = true; transcript.replaceChildren(connectPrompt(nav)); input.disabled = true; - (send as HTMLButtonElement).disabled = true; + // The chips and the two chat controls too. They sat live in front of the + // "Connect a model" panel, and pressing one hit a guard that returns + // silently — a control that looks live and answers with nothing. + syncSend(); // owns send, the chips and the chat buttons controls.classList.add("is-disabled"); } else { const def = settings.connections.find((c) => c.id === settings!.defaultId) ?? settings.connections.find((c) => c.usable); @@ -191,25 +353,126 @@ export const renderAssistant: SectionRender = (wrap, nav) => { const cur = await host.invoke("ai:chatCurrent", undefined); if (cur) { currentChatId = cur.id; + // `restoreChat` replaces the transcript wholesale, so this used to + // delete a ✨ turn's answer and its Stop button mid-stream. The + // ordering is settled now — `runGoal` awaits this gate before writing + // its first bubble — so the guard that skipped the restore is no + // longer needed, and skipping it was its own bug: the seeded turn + // then ran into a chat whose HISTORY was never drawn, so the answer + // arrived with no sign of the conversation it was continuing. if (cur.turns.length > 0) restoreChat(cur); } } catch { /* no prior chat */ } } - })(); + }; + const ready = runGate(); + + // Connecting a model in Settings must LIFT the gate. This view is kept alive, + // so its gated DOM was re-attached unchanged on every later visit — the + // Assistant stayed behind "Connect a model" for the rest of the session with + // a working connection sitting behind it, and the only way out was to restart + // the app. + const onAiChanged = (): void => { + // NOT gated on `wrap.isConnected`. + // + // Connecting a model means going to Settings, which PARKS this view in the + // keep-alive cache — detached, but very much alive and about to be shown + // again. An `isConnected` guard here therefore fired on the one path that + // matters and, worse, unsubscribed: the gate could then never lift, which + // is the whole defect this listener exists to fix, restored by the guard + // added to stop it leaking. + // + // The leak is answered by identity instead. Each build registers itself as + // `live`; only the newest one acts, and the older listeners fall out with + // their closures when nothing references them. + if (live?.el !== wrap) { + window.removeEventListener("gs:ai-changed", onAiChanged); + return; + } + // BOTH directions. This returned early when the Assistant was ungated, so + // it only ever opened the gate and never closed it: removing the last + // model, or the last usable key, left the composer live and the header + // still advertising a connection that no longer exists — and the first + // message went to a provider the app had just been told about. + void (async () => { + const s = await host.invoke("ai:settings", undefined).catch(() => undefined); + if (live?.el !== wrap) return; + const enabled = !!s?.enabled; + if (gated === !enabled) return; // nothing changed for this view + if (enabled) { + gated = false; + transcript.replaceChildren(empty); + input.disabled = false; + controls.classList.remove("is-disabled"); + syncSend(); // …and back on again, through the same rule + await runGate(); // re-seed the model, permission and thinking controls + } else { + gated = true; + connTag.textContent = ""; + transcript.replaceChildren(connectPrompt(nav)); + input.disabled = true; + controls.classList.add("is-disabled"); + syncSend(); // owns send, the chips and the chat buttons + } + })(); + }; + window.addEventListener("gs:ai-changed", onAiChanged); + + // Publish this Assistant so a ✨ action fired while it is on screen is handed + // to it, rather than routed to with `force` — which rebuilds the view and + // takes a running turn down with it. + live = { + el: wrap, + busy: () => running, + run: (goal, label) => void runGoal(goal, false, label), + }; function restoreChat(chat: ChatView): void { empty.remove(); transcript.replaceChildren(); for (const t of chat.turns) { if (t.role === "user") addBubble(transcript, "user", t.text); - else transcript.append(markdownBlock(t.text)); + else { + // Inside a `.assistant-turn`, exactly as the live path builds it. Only + // the turn carries the measure (`max-width: min(760px, 94%)`), so a + // restored answer ran the full 820px of the pane while the identical + // message, live, had been 760 — the same text at two widths depending + // on whether you had left the chat and come back. + const turn = el("div", "assistant-turn"); + turn.append(markdownBlock(t.text)); + transcript.append(turn); + } } - scrollDown(transcript); + scrollDown(transcript, true); // opening a chat lands on its latest turn + } + + /** A chat cannot be left while a turn is streaming into it: both routes here + * replace the transcript, so the answer being written vanished mid-sentence + * and the run kept going invisibly — writing into a detached node, with the + * Stop button gone and no way to reach it. Stop first, then switch. */ + async function leavingLiveTurn(): Promise<boolean> { + if (!running) return false; + const stop = await confirmDialog({ + title: "The agent is still working", + message: + "Leaving this chat stops the run. Anything it has already done to your repository stays done.", + confirmLabel: "Stop and leave", + danger: true, + }); + if (!stop) return true; + cancelRun?.(); + return false; } async function newChat(): Promise<void> { + // A gated Assistant has no chats. This replaced the "Connect a model" panel + // with an empty-state that invites you to type into a composer that cannot + // be typed into — the one explanation of why nothing works, deleted by a + // menu item that was never disabled. + if (gated) return; + if (await leavingLiveTurn()) return; try { const chat = await host.invoke("ai:chatNew", undefined); currentChatId = chat?.id; @@ -240,6 +503,8 @@ export const renderAssistant: SectionRender = (wrap, nav) => { } async function switchChat(id: string): Promise<void> { + if (gated) return; + if (await leavingLiveTurn()) return; try { const chat = await host.invoke("ai:chatGet", { id }); if (!chat) return; @@ -252,10 +517,33 @@ export const renderAssistant: SectionRender = (wrap, nav) => { } } - async function runGoal(goal: string): Promise<void> { - if (running || !goal.trim()) return; + async function runGoal(goal: string, fromInput = true, display?: string): Promise<void> { + if (running || gated || !goal.trim()) return; + // CLAIMED BEFORE THE FIRST await. `running` is the only thing stopping a + // second turn, and an await hands control back to the event loop: with the + // flag set after it, two quick presses of Send both read `running === false`, + // both suspended, and both went on to start a turn into the same chat. + // CLAIMED BEFORE THE FIRST await. `running` is the only thing stopping a + // second turn, and an await hands control back to the event loop: with the + // flag set after it, two quick presses of Send both read `running === false`, + // both suspended, and both went on to start a turn into the same chat. running = true; - input.value = ""; + syncSend(); + // Settings decide the permission, the model and the thinking level this + // turn runs with. A ✨ goal reaches here before the gate has read them. + await ready; + if (gated) { + running = false; + syncSend(); + return; + } + // Only the text this send is ACTUALLY sending. A quick-action chip supplies + // its own goal, so clearing here threw away a draft the user was writing. + if (fromInput) { + input.value = ""; + autoGrow(); + } + syncSend(); empty.remove(); setBusy(send, true); @@ -269,25 +557,43 @@ export const renderAssistant: SectionRender = (wrap, nav) => { } } if (!currentChatId) { - addBubble(transcript, "user", goal); + addBubble(transcript, "user", display ?? goal); transcript.append(errorBlock("Couldn't start a chat — open a repository and connect a model.")); running = false; - setBusy(send, false); + // Through the one rule. A bare `setBusy(send, false)` left Send fully lit + // over a composer this path has just emptied, so the button invited a + // click that `runGoal`'s own empty-goal guard then swallowed in silence. + syncSend(); return; } + const ac = new AbortController(); + cancelRun = () => ac.abort(); try { - await runAgentTurn(transcript, send, currentChatId, goal, { - allowWrite: permission !== "read", - allowDestructive: permission === "destructive", - modelId: selectedModelId, - thinking: thinkLevel, - }); + await runAgentTurn( + transcript, + send, + currentChatId, + goal, + { + allowWrite: permission !== "read", + allowDestructive: permission === "destructive", + modelId: selectedModelId, + thinking: thinkLevel, + }, + ac.signal, + display, + ); } finally { running = false; + cancelRun = undefined; + // Through the one rule — never a bare `disabled = false`, which is what + // let a finished run hand a gated composer a working-looking Send. + syncSend(); } } + syncSend(); send.addEventListener("click", () => void runGoal(input.value)); input.addEventListener("keydown", (e) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { @@ -295,4 +601,16 @@ export const renderAssistant: SectionRender = (wrap, nav) => { void runGoal(input.value); } }); + + // A goal seeded from another view (✨ Explain / Review / …) starts running + // the moment this surface is up. + if (pendingGoal) { + const goal = pendingGoal; + const label = pendingLabel; + pendingGoal = null; + pendingLabel = undefined; + // `runGoal` awaits `ready` itself, so this runs with the real permission, + // model and thinking level rather than whatever the defaults happened to be. + void runGoal(goal, false, label); + } }; diff --git a/apps/desktop/src/renderer/benignErrors.ts b/apps/desktop/src/renderer/benignErrors.ts new file mode 100644 index 0000000..029fd02 --- /dev/null +++ b/apps/desktop/src/renderer/benignErrors.ts @@ -0,0 +1,37 @@ +// What the crash reporter is allowed to swallow. +// +// Kept in its own module, free of browser globals, so the rule is testable — +// see test/benignErrors.test.ts. It is imported through `ui.ts` as before. + +/** + * True for noise the global error boundary should swallow: Monaco's language + * worker rejecting unimplemented TS/JS service methods (we bundle only the base + * editor worker, not the language workers) + ResizeObserver loop warnings. + */ +export function isBenignError(message: string, source?: string): boolean { + const m = message || ""; + // Errors from Monaco's blob-wrapped worker reach window.onerror MASKED by + // cross-origin rules as a bare "Script error." — zero information, nothing + // actionable, yet it toasted "Something went wrong" over every diff open. + // The unmasked originals are the known-benign worker noise matched below. + if (/^Script error\.?$/i.test(m.trim())) return true; + if (/Missing requestHandler or method/i.test(m)) return true; + if (/ResizeObserver loop/i.test(m)) return true; + if (/Canceled|Canceled: Canceled/i.test(m)) return true; + // NOT everything from the worker. The diff itself is computed there, so + // blanket-suppressing that file is what made a dead worker present as "the + // diff just doesn't show" with nothing anywhere to say otherwise. Anything + // naming the diff computation is a real failure of a real feature and must + // reach the reporter; the rest of the worker's chatter (inlay hints, link + // detection, tokenisation) stays suppressed. + // + // This cannot catch everything, and the limit is worth stating: a + // cross-origin blob worker's error reaches window.onerror MASKED as a bare + // "Script error." with no message and no stack, and that case is matched + // above. There is nothing in it to attribute or to report. The real safety + // net for a silent worker is the diff panel's own watchdog, which falls back + // to the in-process view — see diffPanel.ts INLINE_WORKER_GRACE_MS. + if (/computeDiff|diff computation|DiffComputer/i.test(m)) return false; + if (source && /editor\.worker(\.[a-z0-9]+)?\.js/i.test(source)) return true; + return false; +} diff --git a/apps/desktop/src/renderer/bottomDock.ts b/apps/desktop/src/renderer/bottomDock.ts index 97d477b..7c10d58 100644 --- a/apps/desktop/src/renderer/bottomDock.ts +++ b/apps/desktop/src/renderer/bottomDock.ts @@ -71,13 +71,20 @@ export class BottomDock { this.resizer.addEventListener("pointerdown", (e) => this.startResize(e)); wireResizerKeys(this.resizer, { orientation: "horizontal", - label: opts.label ? `Resize ${opts.label}` : "Resize panel", + // Sentence case, like every other control in the app ("Resize sidebar", + // "Resize file list"). `label` is a region name — "Panel", "Terminal" — + // and interpolating it verbatim produced the app's only Title Case + // accessible name, "Resize Panel". + label: opts.label + ? `Resize ${opts.label.charAt(0).toLowerCase()}${opts.label.slice(1)}` + : "Resize panel", min: opts.minHeight ?? 120, max: () => BottomDock.clampHeight(Number.MAX_SAFE_INTEGER, opts.minHeight ?? 120), get: () => this.heightPx, set: (h) => { this.heightPx = h; this.bodyEl.style.height = `${h}px`; + this.publishReserve(); this.opts.onResize?.(); }, onCommit: () => this.opts.onHeightChange?.(this.heightPx), @@ -93,7 +100,6 @@ export class BottomDock { this.tabsEl = el("div", "dock-tabs"); this.actionsEl = el("div", "dock-actions"); this.chevron = el("button", "dock-chevron"); - this.chevron.setAttribute("aria-label", "Toggle panel"); this.syncChevron(); this.chevron.addEventListener("click", () => this.toggle()); footer.append(this.tabsEl, el("div", "dock-spacer"), this.actionsEl, this.chevron); @@ -115,6 +121,7 @@ export class BottomDock { this.root = el("div", "dock-mount" + (this.collapsed ? " collapsed" : "")); this.root.append(this.panel); host.appendChild(this.root); + this.publishReserve(); } /** Collapse to just the footer bar, or expand back to footer + body. */ @@ -128,6 +135,7 @@ export class BottomDock { this.collapsed = collapsed; this.root.classList.toggle("collapsed", collapsed); this.syncChevron(); + this.publishReserve(); this.opts.onResize?.(); } @@ -135,6 +143,24 @@ export class BottomDock { return this.collapsed; } + /** + * Publish how much vertical space the dock is taking, as `--dock-reserve` on + * its host. + * + * The dock is an overlay footer: it does not shrink the scrollers above it, + * so with it open the last card of a long page sat behind it with nowhere + * left to scroll — Settings' final section was simply unreachable. Long + * scrollers add this to their bottom padding, so the end of the content + * always clears the dock. + */ + private publishReserve(): void { + const host = this.root.parentElement ?? this.root; + host.style.setProperty( + "--dock-reserve", + this.collapsed ? "0px" : `${this.heightPx}px`, + ); + } + get height(): number { return this.heightPx; } @@ -142,7 +168,23 @@ export class BottomDock { /** Point the chevron the right way (up = expand, down = collapse). */ private syncChevron(): void { this.chevron.replaceChildren(glyph(this.collapsed ? "chevron-up" : "chevron-down")); - this.chevron.title = this.collapsed ? "Expand panel" : "Collapse panel"; + const what = this.collapsed ? "Expand panel" : "Collapse panel"; + this.chevron.title = what; + // The label moves with the state, and the state is announced. + // + // It was a fixed "Toggle panel" while the tooltip changed underneath it, so + // a screen reader heard the same three words whether the dock was open or + // shut — and was never told which. `aria-expanded` is the part that makes + // the answer available without operating the control to find out. + this.chevron.setAttribute("aria-label", what); + this.chevron.setAttribute("aria-expanded", String(!this.collapsed)); + // A collapsed dock has nothing to resize, and `wireResizerKeys` already + // refuses the keys (`disabled: () => this.collapsed`). Say so, and stop + // being a tab stop: a focusable separator that answers no key at all is a + // dead stop in the tab order and a value a screen reader reads out as + // adjustable when it is not. + this.resizer.setAttribute("aria-disabled", String(this.collapsed)); + this.resizer.tabIndex = this.collapsed ? -1 : 0; } /** @@ -163,6 +205,7 @@ export class BottomDock { setHeight(h: number): void { this.heightPx = BottomDock.clampHeight(h, this.opts.minHeight ?? 120); this.bodyEl.style.height = `${this.heightPx}px`; + this.publishReserve(); this.opts.onResize?.(); this.opts.onHeightChange?.(this.heightPx); } @@ -172,6 +215,7 @@ export class BottomDock { if (next !== this.heightPx) { this.heightPx = next; this.bodyEl.style.height = `${next}px`; + this.publishReserve(); this.opts.onResize?.(); } } @@ -189,6 +233,12 @@ export class BottomDock { const h = Math.max(min, Math.min(max, startH + (startY - ev.clientY))); this.heightPx = h; this.bodyEl.style.height = `${h}px`; + // The DRAG path was the one place the reserve was not republished — the + // keyboard resizer, collapse, setHeight and reclamp all did. So dragging + // the dock taller left `--dock-reserve` at its old value and put the end + // of every long list back behind the dock, which is the whole thing the + // reserve exists to prevent. + this.publishReserve(); this.opts.onResize?.(); }; const up = (): void => { diff --git a/apps/desktop/src/renderer/cache.ts b/apps/desktop/src/renderer/cache.ts index bc0f0f4..adc1dba 100644 --- a/apps/desktop/src/renderer/cache.ts +++ b/apps/desktop/src/renderer/cache.ts @@ -56,6 +56,15 @@ export function setCacheScope(repoRoot: string | undefined): void { } } +/** + * The repo the cache is currently pointed at, for the few things that must be + * keyed by repo but are not cache entries — an unsent comment draft, say. + * Empty string when no repo is open. + */ +export function cacheScope(): string { + return scope; +} + function keyFor(channel: string, payload: unknown): string { return scope + " " + channel + "|" + (payload === undefined ? "" : JSON.stringify(payload)); } @@ -81,6 +90,18 @@ export async function gget<C extends IpcChannel>( channel: C, payload: IpcRequest<C>, ttl = DEFAULT_TTL, + opts?: { + /** + * Is this answer worth remembering? Some channels resolve SUCCESSFULLY with + * a refusal — search returns `{ limited: { retryInMs } }` when GitHub's + * per-minute budget is spent. Caching that pins the refusal for the whole + * TTL, so every retry, timed or manual, is answered from the cache without + * a request ever being made: the countdown sits still and "Retry now" is + * dead until the entry expires. Returning false leaves the cache untouched, + * exactly as a rejection would. + */ + cacheable?: (value: IpcResponse<C>) => boolean; + }, ): Promise<IpcResponse<C>> { const key = keyFor(channel, payload); const e = store.get(key); @@ -92,25 +113,49 @@ export async function gget<C extends IpcChannel>( const startedScope = scope; /** Was the cache invalidated (or the repo switched) while we were waiting? */ const superseded = (): boolean => epoch !== startedEpoch || scope !== startedScope; - const pending = host.invoke(channel, payload).then( + + let pending!: Promise<unknown>; + /** + * Retire OUR in-flight marker when the request settles. + * + * Clearing the marker and PUBLISHING the answer are two different decisions, + * and conflating them was a real bug. `gget` short-circuits on `e.pending` + * before it ever looks at the TTL, so an entry left holding a settled promise + * is pinned to that one answer for the rest of the session. That is what + * happened whenever an unrelated prefix bust — staging a file fires + * `bust("status")` and `bust("diff")` — landed while a GitHub list was + * loading: the list froze on whatever it had, and Refresh did nothing. + * If the request had FAILED, the entry served that rejection forever instead. + * + * So: always clear the marker; only publish the value when nothing has + * invalidated the cache meanwhile. + * + * The identity check matters too. If a bust cleared our entry and a newer + * request took its place — or `prime()` seeded a value from an event that is + * newer than the read we started earlier — that entry is not ours to touch. + */ + const settle = (next?: Entry): void => { + const cur = store.get(key); + if (!cur || cur.pending !== pending) return; + if (next) { + store.set(key, next); + } else if (cur.value !== undefined) { + // Keep the last-known-good readable via `peek`, with its ORIGINAL + // timestamp so the next `gget` still treats it as stale and refetches. + store.set(key, { value: cur.value, at: cur.at }); + } else { + store.delete(key); + } + }; + + pending = host.invoke(channel, payload).then( (value) => { - // Only publish if nothing invalidated the cache meanwhile — otherwise this - // answer predates the mutation that busted it. - if (!superseded()) { - store.set(key, { value, at: Date.now() }); - } + const keep = !superseded() && (opts?.cacheable?.(value as IpcResponse<C>) ?? true); + settle(keep ? { value, at: Date.now() } : undefined); return value; }, (err) => { - // Drop the failed in-flight marker so a retry can re-fetch; keep any prior - // good value in place (callers can still `peek` the last-known-good). - if (!superseded()) { - const prev = store.get(key); - if (prev && prev.pending) { - if (prev.value !== undefined) store.set(key, { value: prev.value, at: prev.at }); - else store.delete(key); - } - } + settle(undefined); throw err; }, ); @@ -141,3 +186,97 @@ export function prime<C extends IpcChannel>( ): void { store.set(keyFor(channel, payload), { value, at: Date.now() }); } + +/** + * Stable stringify — key order must not decide whether two payloads "differ". + * + * `JSON.stringify` preserves insertion order, and an IPC response rebuilt from a + * different code path can carry the same facts with its keys in another order. + * Comparing those raw would report a change on every single revalidation, which + * is exactly the repaint this module exists to avoid. + */ +function stable(v: unknown): string { + const seen = new WeakSet<object>(); + const walk = (x: unknown): unknown => { + if (x === null || typeof x !== "object") return x; + if (seen.has(x as object)) return "[circular]"; + seen.add(x as object); + if (Array.isArray(x)) return x.map(walk); + const o = x as Record<string, unknown>; + const out: Record<string, unknown> = {}; + for (const k of Object.keys(o).sort()) out[k] = walk(o[k]); + return out; + }; + try { + return JSON.stringify(walk(v)); + } catch { + return String(v); + } +} + +/** Do these two IPC answers carry the same facts? Key order is not a fact. */ +export function sameData(a: unknown, b: unknown): boolean { + return stable(a) === stable(b); +} + +/** + * Render what we already know, then quietly check whether it is still true. + * + * The complaint this answers: "clicking around causes slow screen loading and + * reloading". Every view fetched its data on every route and painted a skeleton + * while it waited — so returning to a screen you had just left cost a round trip + * and a flash of nothing, even when the answer could not possibly have changed. + * + * Three properties, and the third is the one that matters: + * + * 1. A cached value is handed back SYNCHRONOUSLY, before this function + * returns. The caller renders it in the same frame; there is no skeleton + * and no await for data we already hold. + * 2. The request is still made, so the screen cannot go stale. + * 3. If the fresh answer is IDENTICAL to what was rendered, `onData` is not + * called again. Nothing repaints, nothing scrolls, nothing flickers, and + * whatever the user had selected or typed survives. A view only rebuilds + * when the data behind it actually changed — which is what "refresh" should + * have meant all along. + * + * `alive()` lets a caller drop a response that arrived after its view was + * replaced; without it a slow answer repaints a screen the user has left. + */ +export function swr<C extends IpcChannel>( + channel: C, + payload: IpcRequest<C>, + opts: { + onData: (value: IpcResponse<C>, from: "cache" | "network") => void; + onError?: (err: unknown) => void; + /** Skip the revalidation entirely while the cached value is younger. */ + ttl?: number; + /** False once the caller's view is gone — a late answer is then dropped. */ + alive?: () => boolean; + }, +): void { + const cached = peek(channel, payload); + let rendered: string | undefined; + if (cached !== undefined) { + rendered = stable(cached); + opts.onData(cached, "cache"); + } + // A fresh-enough cached value needs no round trip at all. + if (cached !== undefined && opts.ttl !== undefined) { + const e = store.get(keyFor(channel, payload)); + if (e && Date.now() - e.at <= opts.ttl) return; + } + void gget(channel, payload, 0) + .then((fresh) => { + if (opts.alive && !opts.alive()) return; + if (rendered !== undefined && stable(fresh) === rendered) return; // nothing changed + opts.onData(fresh, "network"); + }) + .catch((err) => { + if (opts.alive && !opts.alive()) return; + // A failed revalidation must not blank a screen that is already showing + // the last good answer — that turns a transient network blip into a + // regression the user can see. + if (cached !== undefined) return; + opts.onError?.(err); + }); +} diff --git a/apps/desktop/src/renderer/chatPanel.ts b/apps/desktop/src/renderer/chatPanel.ts index c89d0c4..b39ddaf 100644 --- a/apps/desktop/src/renderer/chatPanel.ts +++ b/apps/desktop/src/renderer/chatPanel.ts @@ -138,7 +138,7 @@ export class ChatPanel { /** Called by the dock when this tab becomes active — land focus in the input. */ reveal(): void { - scrollDown(this.transcript); + scrollDown(this.transcript, true); // switching to this tab shows its latest if (!this.input.disabled) this.input.focus(); } diff --git a/apps/desktop/src/renderer/chatRender.ts b/apps/desktop/src/renderer/chatRender.ts index bfe6e4c..dfc1ac6 100644 --- a/apps/desktop/src/renderer/chatRender.ts +++ b/apps/desktop/src/renderer/chatRender.ts @@ -10,6 +10,7 @@ import { host } from "./bridge"; import { el, span, glyph } from "./ui"; import { renderMarkdown } from "./markdown"; +import { highlightProse } from "./highlight"; import { confirmDialog, toast } from "./dialogs"; import type { AgentConfirmRequest, AgentEventWire } from "../shared/ipc"; @@ -66,7 +67,7 @@ export async function runAgentTurn( thinking.append(dots, thinkLabel, thinkMeta); turn.append(thinking); transcript.append(turn); - scrollDown(transcript); + scrollDown(transcript, true); // they just pressed Send — show them their turn const state: TurnState = { turn, thinking, stream: null, raw: "", pending: false, status: "Thinking" }; const t0 = Date.now(); @@ -83,14 +84,22 @@ export async function runAgentTurn( const offEvent = host.on("ai:agentEvent", (e) => { if (e.requestId === requestId) onEvent(state, e); }); + // THIS turn's life, so anything waiting on it can be ended with it. The + // caller's `signal` cancels from outside; Stop and the `finally` below fire + // it too, so an approval dialog is never left behind by any of the three. + const turn$ = new AbortController(); + signal?.addEventListener("abort", () => turn$.abort(), { once: true }); const offConfirm = host.on("ai:confirmRequest", (c) => { - if (c.requestId === requestId) void onConfirm(requestId, c); + if (c.requestId === requestId) void onConfirm(requestId, c, turn$.signal); }); const onAbort = (): void => void host.invoke("ai:cancel", { requestId }); signal?.addEventListener("abort", onAbort, { once: true }); // A cancel affordance replaces the send button while running. - const cancel = swapToCancel(send, () => void host.invoke("ai:cancel", { requestId })); + const cancel = swapToCancel(send, () => { + turn$.abort(); // close a pending approval before the run goes away + void host.invoke("ai:cancel", { requestId }); + }); try { const done = await host.invoke("ai:chatSend", { @@ -102,6 +111,12 @@ export async function runAgentTurn( modelId: cfg.modelId, thinking: cfg.thinking, }); + // Was the reader at the tail BEFORE the turn's last block goes in? Asked + // after, the block it just appended is exactly what puts them "away from + // the bottom", so a turn whose whole answer arrives at the end — no + // streaming, which is every non-streaming provider — landed below the fold + // and the settle at the end of `finally` politely declined to move. + const stickAtEnd = atBottom(transcript); finalizeStream(state); thinking.remove(); if (!done.ok && done.message) { @@ -109,7 +124,14 @@ export async function runAgentTurn( } else if (done.text && !turn.querySelector(".assistant-msg")) { turn.append(markdownBlock(done.text)); } + if (stickAtEnd) scrollDown(transcript, true); } catch (e) { + // SETTLE the half-written answer first. `is-streaming` draws a blinking + // caret after the last line, and this path did not remove it — so a turn + // that failed mid-sentence left its partial reply apparently still being + // typed, for as long as the chat stayed open, with an error underneath it. + // The `!done.ok` path above already goes through `finalizeStream`. + finalizeStream(state); thinking.remove(); turn.append(errorBlock(e instanceof Error ? e.message : String(e))); } finally { @@ -118,6 +140,9 @@ export async function runAgentTurn( offEvent(); offConfirm(); signal?.removeEventListener("abort", onAbort); + // The turn is over however it ended — a dialog still waiting on it is + // waiting for something that cannot answer. + turn$.abort(); cancel.restore(); scrollDown(transcript); } @@ -127,14 +152,16 @@ export async function runAgentTurn( /** Append a streamed text delta and re-render the block as Markdown (live). */ export function onDelta(state: TurnState, delta: string): void { + const wrap = state.turn.parentElement as HTMLElement; + const stick = atBottom(wrap); if (!state.stream) { - state.stream = el("div", "assistant-msg is-streaming"); + state.stream = el("div", "assistant-msg gh-body-md is-streaming"); state.turn.insertBefore(state.stream, state.thinking); state.raw = ""; } state.raw += delta; scheduleStreamRender(state); - scrollDown(state.turn.parentElement as HTMLElement); + if (stick) scrollDown(wrap, true); } /** Re-render the live block as Markdown, at most once per animation frame. */ @@ -151,7 +178,21 @@ function scheduleStreamRender(state: TurnState): void { export function finalizeStream(state: TurnState): void { if (state.stream) { state.stream.classList.remove("is-streaming"); - if (state.raw.trim()) state.stream.innerHTML = renderMarkdown(state.raw); + if (state.raw.trim()) { + const block = state.stream; + block.innerHTML = renderMarkdown(state.raw); + // The block is FINISHED, so highlight it — `markdownBlock` does this for + // every other rendered answer, and a streamed one is the same content. + // Without it a reply's code fences stayed monochrome until you left the + // chat and came back, at which point the restore path rendered the same + // text through `markdownBlock` and it gained colour: the same message, + // two different appearances, for no reason the reader can see. + // + // NOT in `scheduleStreamRender` — that runs per animation frame on a + // block whose fences are still arriving, so it would tokenize a fragment + // dozens of times and paint half-finished syntax. + highlightProse(block); + } state.stream = null; state.raw = ""; } @@ -160,6 +201,8 @@ export function finalizeStream(state: TurnState): void { /** Apply one structured agent event to the active turn. */ export function onEvent(state: TurnState, e: AgentEventWire): void { const { turn, thinking } = state; + const wrap = turn.parentElement as HTMLElement; + const stick = atBottom(wrap); switch (e.kind) { case "status": // A pre-token status (e.g. "Loading the agent…" on a cold start). @@ -169,8 +212,10 @@ export function onEvent(state: TurnState, e: AgentEventWire): void { // The step's text finished — render the final Markdown. if (state.stream) { const text = e.text && e.text.trim() ? e.text : state.raw; - state.stream.innerHTML = renderMarkdown(text); - state.stream.classList.remove("is-streaming"); + const block = state.stream; + block.innerHTML = renderMarkdown(text); + block.classList.remove("is-streaming"); + highlightProse(block); // settled — see finalizeStream state.stream = null; state.raw = ""; } else if (e.text && e.text.trim()) { @@ -198,17 +243,28 @@ export function onEvent(state: TurnState, e: AgentEventWire): void { default: break; } - scrollDown(turn.parentElement as HTMLElement); + if (stick) scrollDown(wrap, true); } /** Render the confirm dialog for a write/destructive tool and answer the agent. */ -export async function onConfirm(requestId: string, c: AgentConfirmRequest): Promise<void> { +export async function onConfirm( + requestId: string, + c: AgentConfirmRequest, + /** Ends with the TURN. Without it, pressing Stop finished the run in the main + * process and left this dialog on screen — and its Approve button then + * posted an approval for a run that no longer existed. */ + signal?: AbortSignal, +): Promise<void> { const approved = await confirmDialog({ title: c.mode === "destructive" ? "Approve destructive action" : "Approve action", message: c.summary, confirmLabel: c.mode === "destructive" ? "Yes, do it" : "Approve", danger: c.mode === "destructive", + signal, }); + // A turn that has been stopped has nothing to answer. Posting `false` here + // would be harmless but pointless; posting `true` after a Stop is the bug. + if (signal?.aborted) return; await host.invoke("ai:agentConfirm", { requestId, callId: c.callId, approved }); if (!approved) toast("Action declined.", "info"); } @@ -222,8 +278,9 @@ export function addBubble(transcript: HTMLElement, who: "user", text: string): v } export function markdownBlock(md: string): HTMLElement { - const block = el("div", "assistant-msg"); + const block = el("div", "assistant-msg gh-body-md"); block.innerHTML = renderMarkdown(md); + highlightProse(block); return block; } @@ -250,6 +307,23 @@ function toolStep(e: AgentEventWire): HTMLElement { function finishToolStep(step: HTMLElement, e: AgentEventWire): void { step.querySelector(".assistant-tool-spin")?.remove(); + + // A DECLINED action is not an error, and its result text is not for you. + // + // `tool_denied` lands first and marks the step; the agent then emits a + // tool_result carrying the sentence it feeds back to the MODEL — "The user + // declined to run this action. Do not retry it; adapt or stop and explain." + // That was rendered like any other failure: a red step with a warning glyph, + // whose body instructed the person who had just made the decision not to + // retry it. + if (step.classList.contains("is-denied")) { + const said = span("Declined", "assistant-tool-verdict"); + const status = glyph("circle-slash"); + status.classList.add("assistant-tool-status"); + step.querySelector(".assistant-tool-head")?.append(said, status); + return; + } + step.classList.toggle("is-error", e.isError === true); const status = glyph(e.isError ? "error" : "check"); status.classList.add("assistant-tool-status"); @@ -336,6 +410,32 @@ export function swapToCancel(send: HTMLElement, onCancel: () => void): { restore }; } -export function scrollDown(container: HTMLElement | null): void { - if (container) container.scrollTop = container.scrollHeight; +/** Is the reader at the bottom RIGHT NOW? + * + * Must be asked BEFORE the new content goes in. Asking afterwards compares + * the old scrollTop against a scrollHeight that has already grown by exactly + * the block just inserted, so a reader sitting at the tail measures as one + * block behind it and the autoscroll that should carry them along declines to. + * The log pane takes its anchor before appending for the same reason. */ +export function atBottom(container: HTMLElement | null): boolean { + if (!container) return false; + return container.scrollHeight - container.scrollTop - container.clientHeight <= 24; +} + +/** Keep the newest content in view — but ONLY for a reader who is already at + * the bottom. + * + * This was unconditional, and it is called on every streamed token. Scrolling + * up to re-read what the agent said thirty seconds ago lasted until the next + * delta arrived, which is to say a fraction of a second: the transcript + * snapped back to the tail, every time, for the whole length of a run. The + * job log had the identical defect and the same complaint about it — nothing + * in the app may move the viewport while the reader is reading something. + * + * `force` is for the moments the reader DID ask: sending a message, opening a + * chat, and switching to a tab. */ +export function scrollDown(container: HTMLElement | null, force = false): void { + if (!container) return; + if (!force && !atBottom(container)) return; + container.scrollTop = container.scrollHeight; } diff --git a/apps/desktop/src/renderer/cloneDialog.ts b/apps/desktop/src/renderer/cloneDialog.ts index dc0188e..29ac5d6 100644 --- a/apps/desktop/src/renderer/cloneDialog.ts +++ b/apps/desktop/src/renderer/cloneDialog.ts @@ -13,7 +13,7 @@ // `modal()` in ./dialogs (which isn't exported), so this self-contained module // matches that a11y behaviour exactly. -import { toast } from "./dialogs"; +import { toast, openModal } from "./dialogs"; import { host } from "./bridge"; import { el, @@ -25,56 +25,22 @@ import { cleanErr, } from "./ui"; import type { GhRepoBrief } from "../shared/ipc"; +import { deriveNameFromUrl, validateTargetName } from "../shared/cloneName"; type Tab = "url" | "github"; type Scheme = "https" | "ssh"; -/** Open the clone modal. On a successful clone, `onCloned(root)` is called. */ -export function openCloneDialog(onCloned: (root: string) => void): void { - // ── modal scaffold (mirrors ./dialogs modal(): focus-trap, Esc, backdrop) ── - const prevFocus = document.activeElement as HTMLElement | null; - const overlay = el("div", "modal-overlay"); - overlay.setAttribute("role", "dialog"); - overlay.setAttribute("aria-modal", "true"); - overlay.setAttribute("aria-label", "Clone a repository"); - +/** Open the clone modal. On a successful clone, `onCloned(root)` is called. + * `opts.url` prefills the URL tab (e.g. cloning straight from an org's repo + * peek) — the user still picks the destination folder. */ +export function openCloneDialog( + onCloned: (root: string) => void, + opts: { url?: string } = {}, +): void { + // ── modal scaffold: the shared openModal (focus-trap, Esc, backdrop) ────── const card = el("div", "modal-card clone-card"); - - let closed = false; - const close = (): void => { - if (closed) return; - closed = true; - if (offProgress) offProgress(); - overlay.remove(); - document.removeEventListener("keydown", onKey, true); - prevFocus?.focus?.(); - }; - const onKey = (e: KeyboardEvent): void => { - if (e.key === "Escape") { - // While a clone is in flight, dismissing would orphan the clone and still - // fire onCloned() on completion — match the busy-guarded backdrop click. - if (busy) return; - e.preventDefault(); - close(); - return; - } - if (e.key !== "Tab") return; - const f = Array.from( - card.querySelectorAll<HTMLElement>( - "button, input, [tabindex]:not([tabindex='-1'])", - ), - ).filter((n) => !n.hasAttribute("disabled") && n.offsetParent !== null); - if (!f.length) return; - const first = f[0]; - const last = f[f.length - 1]; - if (e.shiftKey && document.activeElement === first) { - e.preventDefault(); - last.focus(); - } else if (!e.shiftKey && document.activeElement === last) { - e.preventDefault(); - first.focus(); - } - }; + /** Bound by openModal at mount; wrapped so earlier listeners see the real one. */ + let close = (): void => {}; // ── state ──────────────────────────────────────────────────────────────── let tab: Tab = "url"; @@ -88,7 +54,7 @@ export function openCloneDialog(onCloned: (root: string) => void): void { // ── header: title + segmented tab switch ─────────────────────────────────── const h = el("div", "modal-title"); - h.textContent = "Clone a repository"; + h.textContent = "Clone repository"; const tabs = el("div", "gh-seg clone-tabs"); tabs.setAttribute("role", "tablist"); @@ -117,7 +83,12 @@ export function openCloneDialog(onCloned: (root: string) => void): void { void runClone(); } }); - urlPanel.append(urlInput); + // One field shape for the whole form. These three rows used to have three + // different structures — a bare placeholder-only input, a label-value-button + // row inside a bordered card, and a caption above an input inside a SECOND + // bordered card — so three consecutive fields had three left edges and three + // ideas of where a label goes. + urlPanel.append(cloneField("Repository URL", urlInput, "clone-url-field")); // ── GitHub panel ─────────────────────────────────────────────────────────── const ghPanel = el("div", "clone-panel clone-gh"); @@ -146,20 +117,30 @@ export function openCloneDialog(onCloned: (root: string) => void): void { schemeSeg.append(httpsBtn, sshBtn); schemeSeg.hidden = true; - ghPanel.append(ghSearch, ghList, schemeSeg); + ghPanel.append(cloneField("Your repositories", ghSearch), ghList, schemeSeg); // ── footer: destination + progress + actions ─────────────────────────────── - const destRow = el("div", "clone-dest"); - const destLabel = el("div", "clone-dest-label"); - destLabel.textContent = "Destination"; const destValue = el("div", "clone-dest-path"); destValue.textContent = "No folder chosen"; const chooseBtn = el("button", "mini-btn clone-choose"); chooseBtn.append(glyph("folder-opened"), span("Choose…")); chooseBtn.addEventListener("click", () => void pickDir()); - const destText = el("div", "clone-dest-text"); - destText.append(destLabel, destValue); - destRow.append(destText, chooseBtn); + const destControl = el("div", "clone-dest-control"); + destControl.append(destValue, chooseBtn); + const destRow = cloneField("Destination", destControl); + + // Folder-name override — blank means "use the name derived from the URL" + // (shown as the placeholder, so what will happen is never a mystery). + const nameInput = document.createElement("input"); + nameInput.className = "modal-input clone-name-input"; + nameInput.placeholder = "Derived from the URL"; + nameInput.spellcheck = false; + nameInput.autocapitalize = "off"; + nameInput.setAttribute("aria-label", "Folder name (optional)"); + nameInput.addEventListener("input", refreshClone); + const nameRow = cloneField("Folder name", nameInput); + const nameError = el("div", "dest-name-error clone-name-error"); + nameError.hidden = true; const progress = el("div", "clone-progress"); progress.hidden = true; @@ -174,7 +155,7 @@ export function openCloneDialog(onCloned: (root: string) => void): void { const actions = el("div", "modal-actions clone-actions"); const cancel = el("button", "mini-btn"); cancel.textContent = "Cancel"; - cancel.addEventListener("click", close); + cancel.addEventListener("click", () => close()); const primary = el("button", "btn btn-primary modal-ok clone-go"); const primaryLabel = span("Clone"); primary.append(primaryLabel); @@ -182,8 +163,7 @@ export function openCloneDialog(onCloned: (root: string) => void): void { primary.addEventListener("click", () => void runClone()); actions.append(cancel, primary); - card.append(h, tabs, urlPanel, ghPanel, destRow, progress, actions); - overlay.appendChild(card); + card.append(h, tabs, urlPanel, ghPanel, destRow, nameRow, nameError, progress, actions); // ── tab switching ────────────────────────────────────────────────────────── function setTab(next: Tab): void { @@ -227,11 +207,26 @@ export function openCloneDialog(onCloned: (root: string) => void): void { const status = await host.invoke("github:status", undefined); if (seq !== searchSeq) return; if (!status.connected) { + // This used to say "sign in from the account button at the top of the + // window". From the welcome screen — where most people meet this + // dialog, because there is no repo open yet — there IS no account + // button and no Settings: the whole window is a hero, two buttons and + // a recent list. So the one instruction on the screen pointed at a + // control that did not exist, and the dialog dead-ended. + // + // Cloning by URL needs no account at all, and is right here. ghList.replaceChildren( emptyState( - "Connect GitHub", - "Sign in from the account button at the top of the window to browse and clone your repositories.", - { icon: "github" }, + "Not signed in to GitHub", + "Browsing your repositories needs a GitHub account, which you can connect from Settings once a repository is open. Any repository URL works right now without one.", + { + icon: "github", + action: { + label: "Clone by URL", + icon: "link", + onClick: () => setTab("url"), + }, + }, ), ); return; @@ -334,16 +329,15 @@ export function openCloneDialog(onCloned: (root: string) => void): void { return scheme === "ssh" ? selectedRepo.sshUrl : selectedRepo.cloneUrl; } - /** Derive the target folder name from the URL (so it's stable + predictable). */ + /** The folder name a clone would use: the override, else derived. */ function targetName(url: string): string | undefined { - const m = url.match(/([^/:]+?)(?:\.git)?\/?\s*$/); - return m ? m[1] : undefined; + return nameInput.value.trim() || deriveNameFromUrl(url); } async function pickDir(): Promise<void> { if (busy) return; try { - const dir = await host.invoke("clone:pickDir", undefined); + const dir = await host.invoke("clone:pickDir", parentDir ? { defaultPath: parentDir } : undefined); if (dir) { parentDir = dir; destValue.textContent = dir; @@ -356,7 +350,17 @@ export function openCloneDialog(onCloned: (root: string) => void): void { } function refreshClone(): void { - const ready = !busy && !!chosenUrl() && !!parentDir; + const url = chosenUrl(); + // On the GitHub tab there is no URL field, so "Derived from the URL" named + // a control the user cannot see. + nameInput.placeholder = + (url && deriveNameFromUrl(url)) || + (tab === "github" ? "Same as the repository" : "Derived from the URL"); + const problem = validateTargetName(nameInput.value); + nameError.textContent = problem ?? ""; + nameError.hidden = !problem; + nameInput.classList.toggle("is-invalid", !!problem); + const ready = !busy && !!url && !!parentDir && !problem; if (ready) primary.removeAttribute("disabled"); else primary.setAttribute("disabled", "true"); } @@ -385,11 +389,25 @@ export function openCloneDialog(onCloned: (root: string) => void): void { httpsBtn, sshBtn, chooseBtn, - cancel, + nameInput, ]) { if (on) ctl.setAttribute("disabled", "true"); else ctl.removeAttribute("disabled"); } + // NOT disabled with the rest. A clone that hangs — a slow remote, a + // credential prompt that never arrives — left every control dead, Escape + // and the backdrop blocked by `canDismiss: () => !busy`, and no way out of + // the app short of quitting it. + // + // git is already running and there is no channel to stop it, so this does + // not pretend to cancel: it puts the dialog away and lets the clone finish + // in the background, which it does perfectly well — the success path opens + // the repository and toasts either way, whether this card is on screen or + // not. + cancel.textContent = on ? "Hide" : "Cancel"; + cancel.title = on + ? "Close this and let the clone finish — you'll be told when it's done" + : ""; ghList.classList.toggle("is-disabled", on); if (on) { primary.setAttribute("disabled", "true"); @@ -437,19 +455,85 @@ export function openCloneDialog(onCloned: (root: string) => void): void { toast(res.message || "Clone failed.", "error"); progress.hidden = true; setBusy(false); + // A destination collision is fixed right here: point at the name field. + if (res.code === "dest-exists" || res.code === "bad-name") { + nameInput.focus(); + nameInput.select(); + } } } // ── mount ────────────────────────────────────────────────────────────────── - document.body.appendChild(overlay); - overlay.addEventListener("mousedown", (e) => { - if (e.target === overlay && !busy) close(); + openModal((c) => { + close = c; + return { + card, + focusEl: urlInput, + label: "Clone repository", + // While a clone is in flight, dismissing (Esc/backdrop) would orphan the + // clone and still fire onCloned() on completion — keep the modal up. + // Always. See setBusy: the clone continues without this card, so being + // unable to dismiss it bought nothing and cost the only way out. + canDismiss: () => true, + // A background REBUILD is not a dismissal the user asked for. Any file + // saved anywhere in the open repository fires the watcher, and the + // overlay sweep that follows takes every layer down — so a clone URL + // half-typed, a repository picked from the list, or a clone in flight + // vanished because a build touched a file. `hasUnsavedWork` is the veto + // for exactly that, and this spec never declared one. + // + // Compared against what the form OPENED with, not against truthiness: + // this form prefills its own URL field, so "non-empty" would veto every + // dismissal from the first frame. + hasUnsavedWork: () => + busy || + !!selectedRepo || + urlInput.value.trim() !== (opts.url ?? "").trim() || + nameInput.value.trim() !== "", + onClose: () => { + if (offProgress) offProgress(); + }, + }; }); - document.addEventListener("keydown", onKey, true); + if (opts.url) urlInput.value = opts.url; setTab("url"); + + // Prefill the configured default clone folder so Clone is one paste away — + // Choose… still overrides per-clone. + void host + .invoke("settings:get", undefined) + .then((v) => { + if (!parentDir) { + parentDir = v.cloneDir; + destValue.textContent = v.cloneDirDisplay; + destValue.title = v.cloneDir; + refreshClone(); + } + }) + .catch(() => {}); } /** A tiny inline badge appended to a repo's name (Private / Fork). */ +/** + * One field: a caption above its control, on the form's single left edge. The + * caption is a real <label> when the control can take focus, so clicking the + * word lands the caret in the box. + */ +function cloneField(label: string, control: HTMLElement, cls?: string): HTMLElement { + const wrap = el("div", "clone-field" + (cls ? ` ${cls}` : "")); + const isInput = control instanceof HTMLInputElement; + const cap = document.createElement(isInput ? "label" : "div"); + cap.className = "clone-field-label"; + cap.textContent = label; + if (isInput) { + if (!control.id) control.id = `clone-f-${++fieldSeq}`; + (cap as HTMLLabelElement).htmlFor = control.id; + } + wrap.append(cap, control); + return wrap; +} +let fieldSeq = 0; + function badge(text: string): HTMLElement { const b = span(text, "clone-repo-badge"); return b; diff --git a/apps/desktop/src/renderer/commandPalette.ts b/apps/desktop/src/renderer/commandPalette.ts new file mode 100644 index 0000000..466b23d --- /dev/null +++ b/apps/desktop/src/renderer/commandPalette.ts @@ -0,0 +1,360 @@ +// The ⌘K command palette — the Linear signature, and the piece that makes +// every corner of the app one keystroke away: sections, branches/tags, +// recent repositories, open PRs and issues, and every headline action +// (new branch, stash, fetch/pull/push, clone, theme…) live in ONE fuzzy +// search. Local groups render instantly; GitHub groups stream in as they +// resolve, so the palette never waits on the network to be useful. +// +// Self-contained overlay (same contract as peeks/modals: Esc, backdrop, +// focus). The App supplies data + actions through PaletteProviders. + +import { el, span, glyph, closeMenu } from "./ui"; +import { createSearchScheduler } from "./searchDebounce"; +import { registerLayer, holdBackground } from "./overlays"; + +export interface PaletteItem { + /** Codicon for the row. */ + icon: string; + label: string; + /** Muted right-side hint ("view", a branch's subject, "#42 · open"…). */ + hint?: string; + /** Extra text the fuzzy matcher may hit (number, author, sha…). */ + keywords?: string; + run: () => void; +} + +export interface PaletteGroup { + title: string; + items: PaletteItem[]; + /** Skip fuzzy filtering for this group. A search group's items ARE the + * answer to the query — re-filtering them by the same query throws away + * results GitHub already ranked (and drops the "Search GitHub for…" row + * the moment the query stops matching its own label). */ + pinned?: boolean; +} + +export interface PaletteProviders { + /** Instant, local groups (views, actions, branches, recent repos). */ + local: () => PaletteGroup[]; + /** Slow groups (PRs, issues) — appended when they resolve. */ + remote: () => Array<Promise<PaletteGroup | undefined>>; + /** QUERY-driven groups (global GitHub search). Unlike `remote`, this fires + * as the user types — debounced, minimum-length-gated, and generation- + * checked by the palette so a slow answer to an old query is dropped. */ + search?: (query: string) => Array<Promise<PaletteGroup | undefined>>; +} + +let live: { overlay: HTMLElement; dispose: () => void } | null = null; + +export function paletteIsOpen(): boolean { + return live !== null; +} + +export function closeCommandPalette(): void { + live?.dispose(); +} + +/** + * Subsequence fuzzy score: every query char must appear in order. Earlier, + * denser, word-start matches score higher; 0 = no match. + * + * Exported because Explore's go-to-file wants EXACTLY this ranking — a second + * fuzzy matcher would drift from the palette's feel for no reason. The "/" + * word-boundary bonus already suits paths. + */ +export function fuzzyScore(query: string, text: string): number { + if (!query) return 1; + const q = query.toLowerCase(); + const t = text.toLowerCase(); + let qi = 0; + let score = 0; + let streak = 0; + for (let ti = 0; ti < t.length && qi < q.length; ti++) { + if (t[ti] === q[qi]) { + qi++; + streak++; + score += 2 + streak; // consecutive hits compound + if (ti === 0 || t[ti - 1] === " " || t[ti - 1] === "/" || t[ti - 1] === "-") score += 4; + } else { + streak = 0; + } + } + if (qi < q.length) return 0; + return score + Math.max(0, 24 - t.length / 4); // shorter targets edge ahead +} + +export function openCommandPalette(providers: PaletteProviders): void { + closeCommandPalette(); + // A dropdown open underneath would be STRANDED: openMenu's own Escape + // handler and the palette's both listen on `document`, so one Escape closed + // both layers at once and dropped focus on <body> — and until then a menu + // hung over the palette's scrim, belonging to nothing on screen. The palette + // is a new top layer; the menu the user was in is finished. + // Where to put the keyboard back when the palette closes. If a menu is open, + // `document.activeElement` is one of its rows — which is about to be + // destroyed — so remember the control that OPENED it instead. Without this, + // ⌘K over a dropdown ended with focus on <body> and the next Tab restarting + // at the top of the window. + const openAnchor = document.querySelector<HTMLElement>('[aria-expanded="true"]'); + closeMenu(); + const prevFocus = openAnchor ?? (document.activeElement as HTMLElement | null); + + const overlay = el("div", "cmdk-overlay"); + overlay.setAttribute("role", "dialog"); + overlay.setAttribute("aria-modal", "true"); + overlay.setAttribute("aria-label", "Command palette"); + // The claim, made true. Tab from the palette's single input walked 34 controls + // sitting under its own opaque scrim — the flagship keyboard surface was the + // easiest place in the app to get lost. + const releaseBackground = holdBackground(overlay); + const card = el("div", "cmdk-card"); + const inputRow = el("div", "cmdk-input-row"); + const icon = glyph("search"); + const input = document.createElement("input"); + input.className = "cmdk-input"; + input.placeholder = "Jump to a section, branch, PR, repo — or run an action…"; + input.setAttribute("aria-label", "Search commands"); + input.setAttribute("role", "combobox"); + input.setAttribute("aria-expanded", "true"); + input.setAttribute("aria-autocomplete", "list"); + const kbd = el("span", "cmdk-esc"); + kbd.textContent = "esc"; + inputRow.append(icon, input, kbd); + const list = el("div", "cmdk-list"); + list.id = "cmdk-list"; + input.setAttribute("aria-controls", "cmdk-list"); + list.setAttribute("role", "listbox"); + card.append(inputRow, list); + overlay.appendChild(card); + + let groups: PaletteGroup[] = providers.local(); + /** Query-driven groups, replaced wholesale per search generation. */ + let searchGroups: PaletteGroup[] = []; + let flat: Array<{ item: PaletteItem; el: HTMLElement }> = []; + let selected = 0; + + const dispose = (): void => { + // Release BEFORE anything else: an `inert` that outlives its dialog freezes + // the whole app behind a palette that is no longer there. + releaseBackground(); + if (live?.overlay !== overlay) return; + live = null; + layer.release(); + scheduler?.cancel(); + document.body.classList.remove("cmdk-open"); + overlay.remove(); + document.removeEventListener("keydown", onKey, true); + prevFocus?.focus?.(); + }; + + const select = (i: number): void => { + if (!flat.length) return; + selected = Math.max(0, Math.min(i, flat.length - 1)); + flat.forEach(({ el: row }, idx) => { + const on = idx === selected; + row.classList.toggle("is-selected", on); + // The markup was almost right — role=dialog, aria-modal, a role=listbox + // of role=option rows — but the selection lived in a CSS class alone. + // Focus never leaves the input (correctly, so you can keep typing), so + // without aria-selected and aria-activedescendant a screen reader hears + // nothing at all as you arrow through 28 results. + row.setAttribute("aria-selected", on ? "true" : "false"); + if (!row.id) row.id = `cmdk-row-${idx}`; + }); + const cur = flat[selected]?.el; + if (cur) input.setAttribute("aria-activedescendant", cur.id); + cur?.scrollIntoView({ block: "nearest" }); + }; + + const activate = (i: number): void => { + const hit = flat[i]; + if (!hit) return; + dispose(); + hit.item.run(); + }; + + /** Set when query-driven groups change shape, so the highlight resets to the + * top instead of tracking an item that just got pushed down the list. */ + let resetSelection = false; + /** + * The reader has moved the highlight with the keyboard since the query last + * changed. This is the whole question a late-arriving search group has to + * answer, and it has two opposite right answers: + * + * · NOT moved — the highlight is still on row 0 by default, and search + * groups are PREPENDED, so preserving it by identity slides it down the + * list with every result that lands above it. It must stay at the top. + * · MOVED — the reader chose that row. A group arriving ~300ms after they + * stopped typing, i.e. exactly while they are arrowing, must not throw + * that choice away and send Enter somewhere they never looked. + * + * Resetting unconditionally got the first right and the second wrong; + * preserving unconditionally does the reverse. + */ + let userMoved = false; + + const render = (): void => { + const q = input.value.trim(); + // A streamed group (PRs/issues) landing mid-navigation must not snap the + // highlight back to the top — re-select the same ITEM after rebuilding. + // + // But SEARCH groups are PREPENDED, so keeping the item meant the selection + // slid downward with every result that arrived above it, ending on the + // bottom row — which is what Enter then fired. Identity is preserved only + // for groups appended below (remote()); a change to the search groups + // returns the highlight to the first row. + const keep = resetSelection ? undefined : flat[selected]?.item; + resetSelection = false; + list.replaceChildren(); + flat = []; + for (const group of [...searchGroups, ...groups]) { + // A pinned group is already the answer to this query — render it as-is. + const scored = group.pinned + ? group.items.map((item) => ({ item, score: 1 })) + : group.items + .map((item) => ({ + item, + score: Math.max( + fuzzyScore(q, item.label), + item.keywords ? fuzzyScore(q, item.keywords) * 0.9 : 0, + ), + })) + .filter((s) => s.score > 0) + .sort((a, b) => b.score - a.score) + // With no query the cap used to be 6 — and since groups are built + // in rail order, those were exactly the six destinations that + // already have ⌘1–⌘6. The palette's idle state showed you only + // what you could already reach without it, and hid the twelve + // things you actually need it for. Idle now shows a group whole; + // a query still narrows to the best 8. + .slice(0, q ? 8 : undefined); + if (!scored.length) continue; + const head = el("div", "cmdk-group"); + head.textContent = group.title; + list.appendChild(head); + for (const { item } of scored) { + const row = el("div", "cmdk-row"); + row.setAttribute("role", "option"); + row.append(glyph(item.icon), span(item.label, "cmdk-label")); + if (item.hint) row.appendChild(span(item.hint, "cmdk-hint")); + const idx = flat.length; + row.addEventListener("mousemove", () => select(idx)); + row.addEventListener("click", () => activate(idx)); + list.appendChild(row); + flat.push({ item, el: row }); + } + } + if (!flat.length) { + const none = el("div", "cmdk-empty"); + none.textContent = q ? `Nothing matches “${q}”.` : "Nothing here yet."; + list.appendChild(none); + } + const kept = keep ? flat.findIndex((f) => f.item === keep) : -1; + select(kept >= 0 ? kept : 0); + // The bottom fade means "there is more below"; a list that fits must not + // wear it, or its own last row looks cut off. + requestAnimationFrame(() => { + list.classList.toggle("is-short", list.scrollHeight <= list.clientHeight + 1); + }); + }; + + const onKey = (e: KeyboardEvent): void => { + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + dispose(); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + userMoved = true; + select(selected + 1); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + userMoved = true; + select(selected - 1); + } else if (e.key === "Enter") { + e.preventDefault(); + activate(selected); + } + }; + + // ── query-driven search mode ── + const scheduler = providers.search + ? createSearchScheduler((query, generation) => { + const provider = providers.search; + if (!provider) return; + // A new generation replaces the previous answers immediately, so the + // list never mixes results from two different queries. + searchGroups = []; + resetSelection = true; + // NOT `userMoved = false` here. This fires when the DEBOUNCE elapses, + // ~300ms after the last keystroke — by which time the reader may well + // have arrowed, and their choice was made against a query that has not + // changed since. Only a change of query clears the flag, in the input + // handler below where the query actually changes. + for (const p of provider(query)) { + void p + .then((group) => { + // Three guards, all load-bearing: the palette is still open, + // this is still the newest query, and the group has content. + if (!group || live?.overlay !== overlay) return; + if (!scheduler?.isCurrent(generation)) return; + searchGroups = [...searchGroups, group]; + resetSelection = !userMoved; + // Reset only while the reader has not moved. A new QUERY resets (above) — that is a different + // list, and starting at the top is right. A group merely ARRIVING + // is the same list growing. + // + // Measured, typing "git" and pressing ↓↓ before the debounced + // search lands: with the reset, the highlight jumped to row 0 + // ("Search GitHub for “git”") — the two key presses discarded, + // and Enter would have fired a row the reader never chose. Without + // it the highlight stays down the list where they put it. + render(); + }) + .catch(() => { + /* a failed search group simply doesn't appear */ + }); + } + render(); + }) + : undefined; + + input.addEventListener("input", () => { + if (scheduler) { + const q = input.value.trim(); + scheduler.queue(q); + // Clear stale results the moment the query changes — showing the last + // query's hits under a different query is worse than showing none. + if (q !== scheduler.lastQuery()) { + searchGroups = []; + resetSelection = true; + userMoved = false; + } + } + render(); + }); + overlay.addEventListener("mousedown", (e) => { + if (e.target === overlay) dispose(); + }); + const layer = registerLayer(dispose); + document.addEventListener("keydown", onKey, true); + document.body.appendChild(overlay); + // Marks the palette open for OTHER document-level Esc handlers (peeks, + // modals): they stand down so one Esc never closes two layers. + document.body.classList.add("cmdk-open"); + live = { overlay, dispose }; + render(); + input.focus(); + + // Stream the slow groups in (PRs, issues) — appended once, re-rendered + // through the same filter so an in-progress query applies to them too. + for (const p of providers.remote()) { + void p + .then((group) => { + if (!group || live?.overlay !== overlay) return; + groups = [...groups, group]; + render(); + }) + .catch(() => {}); + } +} diff --git a/apps/desktop/src/renderer/compareDiff.ts b/apps/desktop/src/renderer/compareDiff.ts deleted file mode 100644 index 2b6e75e..0000000 --- a/apps/desktop/src/renderer/compareDiff.ts +++ /dev/null @@ -1,96 +0,0 @@ -// A GitHub-style diff surface for the Compare view: Monaco's NATIVE diff editor, -// which renders side-by-side when there's room and auto-folds to a single inline -// view when the pane gets narrow (`useInlineViewWhenSpaceIsLimited`). That's the -// behaviour the shared 2-pane DiffView can't give us — it's always split — so the -// master/detail compare pane uses this instead. - -import * as monaco from "monaco-editor"; -import { ensureNativeTheme, nativeFontOptions } from "@gitstudio/webview-ui/theme"; -import { languageForFile } from "@gitstudio/webview-ui/language"; -import type { FileDiff } from "../shared/ipc"; -import { bootMonaco } from "./monacoBoot"; - -/** Below this container width Monaco collapses the diff to a single inline view. */ -const INLINE_BREAKPOINT = 720; - -export class CompareDiff { - private editor?: monaco.editor.IStandaloneDiffEditor; - private models: monaco.editor.ITextModel[] = []; - - constructor(private readonly container: HTMLElement) { - bootMonaco(); - } - - /** Render `file` as original (left/base) → modified (right/compare). */ - show(file: FileDiff): void { - this.teardown(); - const host = document.createElement("div"); - host.className = "cmp-diff-editor"; - this.container.replaceChildren(host); - - const language = languageForFile(file.path); - const original = monaco.editor.createModel(file.leftText, language); - const modified = monaco.editor.createModel(file.rightText, language); - this.models = [original, modified]; - - this.editor = monaco.editor.createDiffEditor(host, { - theme: ensureNativeTheme(), - readOnly: true, - originalEditable: false, - automaticLayout: true, - renderSideBySide: true, - // GitHub-like: split when wide, inline when cramped. - useInlineViewWhenSpaceIsLimited: true, - renderSideBySideInlineBreakpoint: INLINE_BREAKPOINT, - minimap: { enabled: false }, - scrollBeyondLastLine: false, - renderOverviewRuler: false, - overviewRulerLanes: 0, - hideCursorInOverviewRuler: true, - scrollbar: { useShadows: false }, - folding: false, - glyphMargin: false, - lineNumbersMinChars: 3, - ignoreTrimWhitespace: false, - inlayHints: { enabled: "off" }, - codeLens: false, - occurrencesHighlight: "off", - quickSuggestions: false, - ...nativeFontOptions(), - }); - this.editor.setModel({ original, modified }); - } - - /** Composed placeholder (icon badge + text) when no file is selected. */ - showEmpty(text: string): void { - this.teardown(); - const empty = document.createElement("div"); - empty.className = "diff-empty list-empty"; - const badge = document.createElement("div"); - badge.className = "list-empty-badge"; - badge.innerHTML = '<span class="glyph codicon codicon-git-compare"></span>'; - const t = document.createElement("div"); - t.className = "list-empty-desc"; - t.textContent = text; - empty.append(badge, t); - this.container.replaceChildren(empty); - } - - layout(): void { - this.editor?.layout(); - } - - dispose(): void { - this.teardown(); - } - - private teardown(): void { - this.editor?.dispose(); - this.editor = undefined; - for (const m of this.models) { - m.dispose(); - } - this.models = []; - this.container.replaceChildren(); - } -} diff --git a/apps/desktop/src/renderer/contextMenu.ts b/apps/desktop/src/renderer/contextMenu.ts index e958412..7e2bcc4 100644 --- a/apps/desktop/src/renderer/contextMenu.ts +++ b/apps/desktop/src/renderer/contextMenu.ts @@ -6,6 +6,7 @@ import type { CommitActionRequest } from "../shared/ipc"; import { confirmDialog, promptInline } from "./dialogs"; import { refMenuItems, type RowRef } from "./refMenuItems"; +import { registerLayer } from "./overlays"; interface MenuItem { label: string; @@ -23,8 +24,8 @@ interface MenuItem { const ITEMS: MenuItem[] = [ { label: "Checkout", action: "checkout", confirm: "Checkout this commit (detached HEAD)?" }, - { label: "Create Branch Here…", action: "branch", prompt: "feature/my-branch" }, - { label: "Create Tag Here…", action: "tag", prompt: "v1.0.0" }, + { label: "Create branch here…", action: "branch", prompt: "feature/my-branch" }, + { label: "Create tag here…", action: "tag", prompt: "v1.0.0" }, { label: "Cherry-pick", action: "cherry-pick" }, { label: "Revert", action: "revert", confirm: "Create a revert commit for this commit?" }, { label: "Reset (soft)", action: "reset-soft", confirm: "Move HEAD here, keep index & working tree?" }, @@ -38,6 +39,8 @@ export class CommitContextMenu { private prevFocus?: HTMLElement | null; private rows: HTMLElement[] = []; private readonly onDocClick = (): void => this.close(); + /** Registry handle, so a route change closes this menu with everything else. */ + private layer?: { release: () => void }; private readonly onKey = (e: KeyboardEvent): void => this.handleKey(e); constructor( @@ -59,13 +62,17 @@ export class CommitContextMenu { refs: readonly RowRef[] = [], ): void { this.close(); + this.layer = registerLayer(() => this.close(false), "menu"); this.prevFocus = document.activeElement as HTMLElement | null; const menu = document.createElement("div"); menu.className = "ctx-menu"; menu.setAttribute("role", "menu"); const header = document.createElement("div"); header.className = "ctx-menu-header"; - header.textContent = sha.slice(0, 10); + // 7, like every other short SHA in the app. Ten characters here meant the + // menu's heading and the row it was opened on named the same commit two + // different ways, one directly above the other. + header.textContent = sha.slice(0, 7); menu.appendChild(header); this.rows = []; @@ -118,6 +125,8 @@ export class CommitContextMenu { switch (e.key) { case "Escape": e.preventDefault(); + // This menu owns the keystroke — see the same note in ui.ts's openMenu. + e.stopPropagation(); this.close(); break; case "ArrowDown": @@ -165,10 +174,26 @@ export class CommitContextMenu { }); if (!ok) return; } + // A checkout-ref item carries the ref it is ABOUT, and the request is the + // only place it can travel. Left off, `name` arrived undefined and the main + // process — which requires it — refused every branch checkout from the + // graph with "unsafe ref", the exact detached-HEAD complaint of issues + // #12/#19 wearing a different error message. + if (item.ref) { + this.resolve({ + action: item.action, + sha, + name: item.ref.name, + refKind: item.ref.kind, + }); + return; + } this.resolve({ action: item.action, sha, name }); } private close(restoreFocus = true): void { + this.layer?.release(); + this.layer = undefined; document.removeEventListener("keydown", this.onKey, true); document.removeEventListener("click", this.onDocClick); this.menu?.remove(); diff --git a/apps/desktop/src/renderer/destinationSheet.ts b/apps/desktop/src/renderer/destinationSheet.ts new file mode 100644 index 0000000..08ec466 --- /dev/null +++ b/apps/desktop/src/renderer/destinationSheet.ts @@ -0,0 +1,156 @@ +// The "Choose location…" sheet — a small modal that asks WHERE a one-click +// clone should land before it starts. Used by every "open in GitStudio" +// surface when the user asks for control (or always, when the +// ask-where-every-time setting is on), and offered as the retry path after a +// destination collision. Confirms with `{dest, name}`; never clones itself — +// the caller owns the actual ghrepo:open. + +import { host } from "./bridge"; +import { openModal, toast } from "./dialogs"; +import { el, span, glyph, cleanErr } from "./ui"; +import { validateTargetName } from "../shared/cloneName"; + +export function openDestinationSheet( + fullName: string, + onConfirm: (choice: { dest: string; name?: string }) => void, + opts: { + /** Prefill the folder-name field (e.g. retrying after a collision). */ + name?: string; + /** Prefill the DESTINATION too. + * + * A collision retry reopened this sheet with a new name and no + * destination, so the sheet fell back to the configured default folder — + * and confirming put the clone somewhere the user had not chosen, quietly, + * on the one path whose whole purpose is that they had chosen elsewhere. */ + dest?: string; + /** One-line context above the fields (e.g. why the sheet appeared). */ + note?: string; + } = {}, +): void { + const repo = fullName.split("/")[1] ?? fullName; + const card = el("div", "modal-card dest-card"); + let close = (): void => {}; + let dest = opts.dest ?? ""; + + const title = el("div", "modal-title"); + title.textContent = `Where should ${fullName} go?`; + + const sub = el("div", "modal-message"); + sub.textContent = opts.note ?? "Pick the folder this repository is cloned into."; + + // Destination row — the same look as the clone dialog's, which means the same + // CLASSES. `.clone-dest` and `.clone-dest-text` have no rules anywhere in the + // stylesheet, so this row was an unstyled stack: the label and path piled on + // top of each other and "Choose…" sat under them instead of beside them. The + // styled control is `.clone-dest-control`. + const destLabel = el("div", "clone-dest-label"); + destLabel.textContent = "Destination"; + const destValue = el("div", "clone-dest-path"); + // A seeded destination is shown as ITSELF, not as "Loading…" — the settings + // read below must not overwrite one the caller supplied. + destValue.textContent = opts.dest || "Loading…"; + if (opts.dest) destValue.title = opts.dest; + const chooseBtn = el("button", "mini-btn"); + chooseBtn.append(glyph("folder-opened"), span("Choose…")); + const destControl = el("div", "clone-dest-control"); + destControl.append(destValue, chooseBtn); + const destRow = el("div", "clone-field"); + destRow.append(destLabel, destControl); + + // Folder-name override. + const nameLabel = el("div", "clone-dest-label dest-name-label"); + nameLabel.textContent = "Folder name"; + const nameInput = document.createElement("input"); + nameInput.className = "modal-input dest-name-input"; + nameInput.placeholder = repo; + nameInput.value = opts.name ?? ""; + nameInput.spellcheck = false; + nameInput.autocapitalize = "off"; + nameInput.setAttribute("aria-label", "Folder name"); + const nameError = el("div", "dest-name-error"); + nameError.hidden = true; + + const actions = el("div", "modal-actions"); + const cancel = el("button", "mini-btn"); + cancel.textContent = "Cancel"; + cancel.addEventListener("click", () => close()); + const go = el("button", "btn btn-primary modal-ok"); + go.textContent = "Clone here"; + go.setAttribute("disabled", "true"); + actions.append(cancel, go); + + card.append(title, sub, destRow, nameLabel, nameInput, nameError, actions); + + function refresh(): void { + const problem = validateTargetName(nameInput.value); + nameError.textContent = problem ?? ""; + nameError.hidden = !problem; + nameInput.classList.toggle("is-invalid", !!problem); + if (dest && !problem) go.removeAttribute("disabled"); + else go.setAttribute("disabled", "true"); + } + nameInput.addEventListener("input", refresh); + + chooseBtn.addEventListener("click", () => { + void (async () => { + try { + const dir = await host.invoke("clone:pickDir", dest ? { defaultPath: dest } : undefined); + if (dir) { + dest = dir; + destValue.textContent = dir; + destValue.title = dir; + refresh(); + } + } catch (e) { + toast(cleanErr(e) || "Couldn't choose a folder.", "error"); + } + })(); + }); + + const confirm = (): void => { + if (go.hasAttribute("disabled")) return; + const name = nameInput.value.trim(); + close(); + onConfirm({ dest, name: name || undefined }); + }; + go.addEventListener("click", confirm); + nameInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + confirm(); + } + }); + + openModal((c) => { + close = c; + return { + card, + focusEl: nameInput, + label: `Choose where ${fullName} goes`, + // Same veto as the clone dialog it follows: any file saved in the open + // repository fires the watcher, and the overlay sweep that follows was + // taking this sheet down with a destination chosen and a folder name + // typed into it. Compared against what it OPENED with, since it prefills. + hasUnsavedWork: () => !!dest || nameInput.value.trim() !== (opts.name ?? "").trim(), + onClose: () => {}, + }; + }); + + // Prefill the configured default folder; the sheet is usable before this + // lands (Choose… works regardless), it just can't confirm without a dest. + void host + .invoke("settings:get", undefined) + .then((v) => { + if (!dest) { + dest = v.cloneDir; + destValue.textContent = v.cloneDirDisplay; + destValue.title = v.cloneDir; + refresh(); + } + }) + .catch(() => { + // …and not over a destination the caller gave us. The `if (!dest)` guard + // above protects the success path; this one protects the failure path. + if (!dest) destValue.textContent = "Choose a folder…"; + }); +} diff --git a/apps/desktop/src/renderer/dialogs.ts b/apps/desktop/src/renderer/dialogs.ts index 530938f..ec456d4 100644 --- a/apps/desktop/src/renderer/dialogs.ts +++ b/apps/desktop/src/renderer/dialogs.ts @@ -4,6 +4,10 @@ // These replace the native alert()/confirm()/prompt(), which are jarring (and, // for prompt(), unsupported) in an Electron renderer. +import { registerLayer, isMenuOpen, holdBackground } from "./overlays"; +import { mdEditor } from "./mdEditor"; +import { wireDraft } from "./draftStore"; + function mk(tag: string, cls = ""): HTMLElement { const n = document.createElement(tag); if (cls) n.className = cls; @@ -51,41 +55,103 @@ export function toast(message: string, kind: ToastKind = "info", timeoutMs?: num timer = window.setTimeout(dismiss, timeoutMs ?? (kind === "error" ? 7000 : 4000)); } -interface ModalSpec { +export interface ModalSpec { card: HTMLElement; focusEl: HTMLElement; /** Accessible name for the dialog (announced by screen readers). */ label?: string; /** Called on ANY close (button or dismiss) — resolve a default if needed. */ onClose: () => void; + /** Veto Esc/backdrop dismissal (return false to keep the modal up — e.g. a + * clone mid-flight). The explicit close() handed to build() always works. */ + canDismiss?: () => boolean; + /** + * Is there unsaved work in here right now? + * + * A route change tears every floating layer down, which is right for a menu or + * a peek and wrong for a form someone is typing into. The window-focus refresh + * routes, so saving a file in your editor while a half-written issue sat in + * this dialog destroyed it — the user did nothing, and their text was gone. + * + * Returning true makes a BACKGROUND teardown skip this modal. Esc, the + * backdrop and the modal's own close() are unaffected: those are the user + * asking, and the user is allowed to throw their own work away. + */ + hasUnsavedWork?: () => boolean; } -/** Focus-trapping modal scaffold shared by confirmDialog + promptInline. */ -function modal(build: (close: () => void) => ModalSpec): void { +/** Open-modal stack — Esc must dismiss only the TOPMOST modal, not every one + * listening on document (a prompt over the secrets manager used to take the + * manager down with it). */ +const modalStack: symbol[] = []; + +/** + * THE focus-trapping modal scaffold — overlay, Esc, Tab trap, backdrop + * dismissal, previous-focus restore. Every modal in the app builds on this + * (confirm/prompt/edit here; the section views' form modals via the export) so + * the dismissal contract can never drift between surfaces. + */ +export function openModal(build: (close: () => void) => ModalSpec): void { const prevFocus = document.activeElement as HTMLElement | null; + const token = Symbol("modal"); const overlay = mk("div", "modal-overlay"); overlay.setAttribute("role", "dialog"); overlay.setAttribute("aria-modal", "true"); let spec: ModalSpec; let closed = false; + /** Set once the overlay is in the DOM — see the holdBackground call below. */ + let releaseBackground: (() => void) | undefined; const close = (): void => { if (closed) return; closed = true; + layer.release(); + const i = modalStack.indexOf(token); + if (i >= 0) modalStack.splice(i, 1); spec.onClose(); overlay.remove(); document.removeEventListener("keydown", onKey, true); + // BEFORE restoring focus: focus cannot land inside an inert subtree, so + // releasing after would silently drop the keyboard on <body>. + releaseBackground?.(); prevFocus?.focus?.(); }; + // A route change dismisses the modal like Esc would — but through `close`, + // not `dismiss`, so a modal that refuses dismissal mid-clone still tears down + // rather than being orphaned above a view it no longer belongs to. + // + // …unless it holds work the user has not saved. A background refresh routing + // underneath a form is not a reason to throw that form away. + const layer = registerLayer( + () => { + if (spec?.hasUnsavedWork?.()) return; + close(); + }, + "modal", + // A veto leaves the dialog on screen, and the registry has to say so: + // dropped from it, `isTop()` was false for a dialog that IS the top layer + // (its Escape dead), and `openLayerCount()` was zero with it open (the + // page's own ← navigating out from under it). + () => overlay.isConnected, + ); + const dismiss = (): void => { + if (spec.canDismiss && !spec.canDismiss()) return; + close(); + }; const onKey = (e: KeyboardEvent): void => { if (e.key === "Escape") { + if (modalStack[modalStack.length - 1] !== token) return; // a newer modal owns Esc + if (document.body.classList.contains("cmdk-open")) return; // the palette owns Esc + if (isMenuOpen()) return; // …and so does a menu opened from inside this dialog e.preventDefault(); - close(); + dismiss(); return; } if (e.key !== "Tab") return; const f = Array.from( - spec.card.querySelectorAll<HTMLElement>("button, input, [tabindex]:not([tabindex='-1'])"), - ).filter((n) => !n.hasAttribute("disabled")); + spec.card.querySelectorAll<HTMLElement>( + "button, input, select, textarea, a[href], [tabindex]:not([tabindex='-1'])", + ), + ).filter((n) => !n.hasAttribute("disabled") && n.offsetParent !== null); if (!f.length) return; const first = f[0]; const last = f[f.length - 1]; @@ -101,23 +167,66 @@ function modal(build: (close: () => void) => ModalSpec): void { if (spec.label) overlay.setAttribute("aria-label", spec.label); overlay.appendChild(spec.card); document.body.appendChild(overlay); + /** + * Hold the page behind the dialog. + * + * `aria-modal="true"` is a CLAIM, not a mechanism. The Tab wrap below acts + * only when focus is exactly on the first or last focusable in the card — so + * an in-dialog re-render that destroys the focused control, or a click on the + * dialog's own heading, put focus on <body>, and the next Tab walked into the + * dozens of controls behind the scrim: reachable, focusable and clickable + * while invisible. A screen reader read the whole background as though no + * dialog were open. Two of the app's four modal surfaces already did this; + * the other two claimed it. + * + * The wrap stays as belt and braces — `inert` fixes the escape, the wrap + * keeps the cycle tight. + */ + releaseBackground = holdBackground(overlay); + modalStack.push(token); overlay.addEventListener("mousedown", (e) => { - if (e.target === overlay) close(); + if (e.target === overlay) dismiss(); }); document.addEventListener("keydown", onKey, true); setTimeout(() => spec.focusEl.focus(), 0); } +/** Internal alias — the pre-export name the local wrappers were written against. */ +const modal = openModal; + /** A styled confirmation dialog (replaces native confirm()); resolves true/false. */ export function confirmDialog(opts: { title: string; message: string; confirmLabel?: string; danger?: boolean; + /** Demand this exact text before enabling Confirm — for irreversible, + * disk-destroying actions where a mis-aimed click must not be enough. */ + requireTyped?: string; + /** Close the dialog and answer `false` when this aborts. + * + * A confirm can outlive the thing it is asking about. The agent's + * tool-approval dialog is the case: pressing Stop ends the turn in the main + * process, but the modal stayed on screen — and its Approve button then + * posted an approval for a run that no longer existed. A dialog tied to + * something cancellable should be cancelled with it. */ + signal?: AbortSignal; }): Promise<boolean> { return new Promise((resolve) => { let settled = false; modal((close) => { + // Cancelled from OUTSIDE — see `opts.signal`. Reuses `settled` so the + // close hook cannot resolve a second time. + opts.signal?.addEventListener( + "abort", + () => { + if (settled) return; + settled = true; + resolve(false); + close(); + }, + { once: true }, + ); const card = mk("div", "modal-card"); const h = mk("div", "modal-title"); h.textContent = opts.title; @@ -131,20 +240,54 @@ export function confirmDialog(opts: { okLabel.textContent = opts.confirmLabel ?? "Confirm"; ok.appendChild(okLabel); actions.append(cancel, ok); - card.append(h, body, actions); + card.append(h, body); + let typedInput: HTMLInputElement | undefined; + if (opts.requireTyped) { + const hint = mk("div", "modal-message confirm-typed-hint"); + hint.textContent = `Type ${opts.requireTyped} to confirm.`; + typedInput = document.createElement("input"); + typedInput.className = "modal-input confirm-typed-input"; + // NOT the required text: using it as the placeholder showed the answer + // inside the box you had to type it into, which teaches you to copy + // what is already on screen and defeats the point of the safeguard. + typedInput.placeholder = "Type the name to confirm"; + typedInput.spellcheck = false; + typedInput.autocapitalize = "off"; + typedInput.setAttribute("aria-label", `Type ${opts.requireTyped} to confirm`); + const sync = (): void => { + const match = typedInput!.value.trim() === opts.requireTyped; + if (match) ok.removeAttribute("disabled"); + else ok.setAttribute("disabled", "true"); + }; + typedInput.addEventListener("input", sync); + typedInput.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !ok.hasAttribute("disabled")) { + e.preventDefault(); + ok.click(); + } + }); + sync(); + card.append(hint, typedInput); + } + card.append(actions); cancel.addEventListener("click", () => { settled = true; resolve(false); close(); }); ok.addEventListener("click", () => { + if (ok.hasAttribute("disabled")) return; settled = true; resolve(true); close(); }); return { card, - focusEl: ok, + // A destructive confirm used to open with the DESTROY button focused, + // so the Return key that dismisses most dialogs deleted the branch + // instead. Danger dialogs start on Cancel; a typed-confirmation dialog + // starts in the field you have to fill in either way. + focusEl: typedInput ?? (opts.danger ? cancel : ok), label: opts.title, onClose: () => { if (!settled) resolve(false); @@ -155,82 +298,12 @@ export function confirmDialog(opts: { } /** A modal text prompt (Electron's renderer has no window.prompt). */ -/** A proper single-step form modal: a required title input + a body textarea → - * `{title, body}` or null. Replaces clumsy sequential prompts for issue/PR-style - * edits, so editing feels like GitHub, not a chain of one-line dialogs. */ -export function editForm(opts: { - title: string; - okLabel?: string; - titleValue?: string; - titlePlaceholder?: string; - bodyValue?: string; - bodyPlaceholder?: string; -}): Promise<{ title: string; body: string } | null> { - return new Promise((resolve) => { - let settled = false; - modal((close) => { - const card = mk("div", "modal-card modal-card-form"); - const h = mk("div", "modal-title"); - h.textContent = opts.title; - const titleInput = document.createElement("input"); - titleInput.className = "modal-input"; - titleInput.placeholder = opts.titlePlaceholder ?? "Title"; - titleInput.value = opts.titleValue ?? ""; - const bodyInput = document.createElement("textarea"); - bodyInput.className = "modal-input modal-textarea"; - bodyInput.placeholder = opts.bodyPlaceholder ?? "Description…"; - bodyInput.value = opts.bodyValue ?? ""; - bodyInput.rows = 7; - const actions = mk("div", "modal-actions"); - const cancel = mk("button", "mini-btn"); - cancel.textContent = "Cancel"; - const ok = mk("button", "btn btn-primary modal-ok"); - const okSpan = mk("span"); - okSpan.textContent = opts.okLabel ?? "Save"; - ok.appendChild(okSpan); - actions.append(cancel, ok); - card.append(h, titleInput, bodyInput, actions); - const done = (v: { title: string; body: string } | null): void => { - settled = true; - resolve(v); - close(); - }; - const submit = (): void => { - const t = titleInput.value.trim(); - if (!t) { - titleInput.focus(); - return; // title is required - } - done({ title: t, body: bodyInput.value.trim() }); - }; - cancel.addEventListener("click", () => done(null)); - ok.addEventListener("click", submit); - // Enter in the title moves to the body; ⌘/Ctrl+Enter anywhere submits. - titleInput.addEventListener("keydown", (e) => { - if (e.key === "Enter") { - e.preventDefault(); - bodyInput.focus(); - } - }); - const metaSubmit = (e: KeyboardEvent): void => { - if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - submit(); - } - }; - titleInput.addEventListener("keydown", metaSubmit); - bodyInput.addEventListener("keydown", metaSubmit); - return { - card, - focusEl: titleInput, - label: opts.title, - onClose: () => { - if (!settled) resolve(null); - }, - }; - }); - }); -} +/* + * `editForm` — a title input plus a body textarea in a modal — used to live + * here. Both of its callers are routed PAGES now (`views/issueCompose.ts`), + * because a modal has nowhere to put the third thing an issue is filed WITH: + * its labels, its assignees and its milestone. + */ export function promptInline( title: string, @@ -285,3 +358,35 @@ export function promptInline( }); }); } + +/** + * Open a form, submit it, and give it BACK if the submit fails. + * + * Every create/edit flow in the app had the same shape: collect the text, close + * the dialog, then send it. When the send failed — offline, a permissions error, + * a validation the API rejects — the user got a toast and an empty screen, and + * everything they had written was gone. For a release body or an issue + * description that can be several minutes of work destroyed by one bad request. + * + * `submit` returns an error message to keep the loop going, or `undefined` when + * it succeeded. On failure the form re-opens carrying exactly what was typed, + * with the reason shown, so the fix is one edit away instead of a retype. + */ +export async function formWithRetry<V>( + open: (seed: V | undefined, error: string | undefined) => Promise<V | null>, + submit: (value: V) => Promise<string | undefined>, +): Promise<V | undefined> { + let seed: V | undefined; + let error: string | undefined; + // Bounded: a submit that fails forever must not trap the user in a loop they + // cannot leave. Cancelling (a null from `open`) exits immediately. + for (let attempt = 0; attempt < 20; attempt++) { + const value = await open(seed, error); + if (value === null) return undefined; // the user chose to abandon it + const failure = await submit(value); + if (failure === undefined) return value; + seed = value; + error = failure; + } + return undefined; +} diff --git a/apps/desktop/src/renderer/diffPanel.ts b/apps/desktop/src/renderer/diffPanel.ts index 557b029..43fd0c9 100644 --- a/apps/desktop/src/renderer/diffPanel.ts +++ b/apps/desktop/src/renderer/diffPanel.ts @@ -10,6 +10,7 @@ import type { TickRow } from "@gitstudio/webview-ui/stageTicks"; import { selectedLineNumbers } from "./selectionLines"; import { MergeView } from "@gitstudio/webview-ui/mergeView"; import { languageForFile } from "@gitstudio/webview-ui/language"; +import { ignoreTrimWhitespaceFor } from "@gitstudio/engine/lineDiff"; import { ensureNativeTheme, nativeFontOptions } from "@gitstudio/webview-ui/theme"; import type { DiffInitPayload, MergeInitPayload } from "@gitstudio/host-bridge/protocol"; import type { ConflictModel, FileDiff } from "../shared/ipc"; @@ -23,20 +24,77 @@ type DiffMode = "inline" | "split"; const LS_DIFF_MODE = "gitstudio.diffMode"; /** Below this surface width, an unset preference defaults to inline. */ const INLINE_DEFAULT_BELOW = 1000; +/** + * How long the unified view waits for Monaco's worker to compute its diff + * before falling back to the in-process split view. + * + * Long enough that a cold worker (the bundle is ~576KB and starts on first use) + * wins the race on any normal machine; short enough that a dead one does not + * leave someone staring at an unmarked file wondering what changed. + */ +const INLINE_WORKER_GRACE_MS = 2500; +/** + * The diff worker failed to answer once this session. + * + * Once is enough to stop asking: a worker that did not load will not load for + * the next file either, and making every single file wait out the grace period + * before falling back turns one broken dependency into a permanently slow app. + * Module-level, so it resets when the window does — which is also when a + * genuinely transient failure gets its second chance. + */ +let workerDiffBroken = false; /** * A single reusable diff/merge surface. Swaps between the 2-pane DiffView and * the 3-pane MergeView depending on whether the opened file is conflicted, * disposing the previous view so Monaco editors never leak. */ +/** Wire one conflict-bar button: disable while it runs, toast the outcome, and + * tell the caller to repaint when it worked. */ +function mergeRun( + btn: HTMLButtonElement, + op: () => Promise<{ ok: boolean; message?: string }>, + okMsg: string, + onResolved?: () => void, +): void { + btn.addEventListener("click", () => { + void (async () => { + btn.disabled = true; + try { + const r = await op(); + if (r.ok) { + toast(okMsg, "success"); + onResolved?.(); + } else { + toast(r.message || "Could not resolve the conflict.", "error"); + } + } catch (err) { + toast(String(err), "error"); + } finally { + btn.disabled = false; + } + })(); + }); +} + export class DiffPanel { private diff?: DiffView; private merge?: MergeView; /** Inline (unified) mode: Monaco's native diff editor + its two models. */ private inline?: monaco.editor.IStandaloneDiffEditor; private inlineModels: monaco.editor.ITextModel[] = []; + /** Pending "did the diff worker answer?" timer — see `renderInline`. */ + private inlineWatchdog?: number; + /** The mode segment, so a fallback can say which view is actually on screen. */ + private seg?: HTMLElement; + /** Whitespace / granularity, so a newly built editor starts where the last + * one left off — and so BOTH modes answer to the same setting. */ + private renderOpts: { whitespace: "none" | "trailing"; showInner?: boolean } = { whitespace: "none" }; /** The last-shown file, so the mode toggle can re-render it. */ private lastFile?: FileDiff; + /** The mode actually on screen. Diverges from the stored preference whenever + * the inline worker misses its grace period and we fall back to Split. */ + private renderedMode?: DiffMode; /** Fired after a tick changes the index, so the Changes list can refresh. */ public onStagingChanged?: () => void; @@ -60,6 +118,123 @@ export class DiffPanel { /** Renders a file diff — unified or 2-pane per the mode toggle. */ showDiff(file: FileDiff): void { this.teardown(); + // A BINARY file has no text diff, and mounting an editor over two empty + // strings is how "the diff doesn't show" happened: two blank panes, no + // explanation, and the app looking broken over a PNG behaving normally. + if (file.binary) { + // WHAT happened to it, not just that it is binary. "Its contents changed" + // is a specific claim, and it is wrong for the two cases where the file + // only exists on one side: an image that was ADDED has no previous + // contents to have changed, and one that was DELETED has no current + // ones. Both are ordinary, and both read as a puzzling non-answer. + const what = + file.onlySide === "added" + ? "It was added." + : file.onlySide === "deleted" || file.deleted + ? "It was deleted." + : "Its contents changed."; + this.showEmpty( + `${file.path} is a binary file, so there is nothing to diff line by line. ${what}`, + { title: "Binary file", kind: "none" }, + ); + return; + } + // NOTHING CAME BACK, because the file was too big to send. + // + // A producer that hits its size limit and has nothing to show returns two + // EMPTY sides with `truncated` set — GitHub's Contents API does this for + // any blob over its inline cap, for both the base and the head ref. The + // panel mounted an editor over two empty strings and then wrote "showing + // the first part of it" underneath: two blank panes under a note claiming + // they held something. + if (file.truncated && !file.leftText && !file.rightText) { + this.showEmpty( + `${file.path} is too large to fetch a diff for, so none of it could be read. Nothing here is a ` + + `statement about what changed in it.`, + { title: "Too large to diff", kind: "none" }, + ); + return; + } + // EMPTY ON BOTH SIDES, genuinely. Two empty strings mount an editor over + // nothing: a blank field, no note, no hint that the file simply has no + // contents. It fell through every guard here because the identical-sides + // test below requires a non-zero length, and it is a real state — + // `touch`ing a file and staging it, or emptying one without deleting it. + if (!file.leftText && !file.rightText) { + // NOT NECESSARILY AN EMPTY FILE. A path added to the index and then + // deleted from the working tree — git's `AD` — has nothing in HEAD and + // nothing on disk, and reads exactly like a file with no contents. "This + // file is empty" and "you deleted this file" are different claims, and + // only the producer can tell them apart. + if (file.deleted) { + // WHICH deletion. `deleted` says only that the path is not on disk, and + // two different things reach here that way: a file added to the index + // and then removed (git's `AD`), and a tracked file that was EMPTY in + // HEAD and has now been deleted. Both have two empty sides; only the + // first is "staged as a new file", and saying that about the second + // tells the reader their committed file was never committed. + const wasCommitted = file.onlySide === "deleted"; + this.showEmpty( + wasCommitted + ? `${file.path} was empty, and has been deleted. There are no lines to show — the change ` + + `is the deletion itself.` + : `${file.path} is staged as a new file but is no longer on disk. There is nothing to ` + + `show — committing it as it stands would add nothing.`, + { + title: wasCommitted ? "Deleted" : "Deleted before it was committed", + kind: "none", + }, + ); + return; + } + this.showEmpty(`${file.path} is empty on both sides — there are no lines to compare.`, { + title: "Empty file", + kind: "none", + }); + return; + } + // IDENTICAL SIDES. A rename with no edit, or a mode-only change, has two + // equal texts — and the inline editor is built with + // `hideUnchangedRegions`, which then collapses the entire file and renders + // as an empty box, while Split shows two identical panes. That is exactly + // "sometimes it doesn't show the diff on just one of the two views". Say + // what happened instead of drawing nothing. + // + // NOT when the file was truncated. Then these are not the file's contents, + // they are the first N bytes of each side — and two large files whose + // openings match are the ordinary case, not a rename. This branch returned + // before the truncation note below could be added, so a half-read file was + // confidently declared "renamed, or only its file mode changed" with + // nothing on screen saying the rest had not been looked at. + if (file.leftText === file.rightText && file.leftText.length > 0) { + if (file.truncated) { + this.showEmpty( + `${file.path} is too large to diff in full. The part that could be read is identical on ` + + `both sides, so whatever changed is further into the file.`, + { title: "Too large to diff", kind: "none" }, + ); + return; + } + // …and not a rename either, if the INDEX holds something different from + // both. That is git's `MM` with the working copy edited back to HEAD: a + // staged change that the working tree has since undone. HEAD and the + // working tree match, so the two panes are identical — but there is a + // real staged change sitting between them, and calling it a rename hides + // the one thing about this state worth knowing. + if (file.indexText !== undefined && file.indexText !== file.leftText) { + this.showEmpty( + `${file.path} is back to its committed contents, but a different version of it is STAGED. ` + + `Committing now would commit the staged version, not what is on disk.`, + { title: "Staged, then undone on disk", kind: "none" }, + ); + return; + } + this.showEmpty( + `${file.path} has the same contents on both sides — it was renamed, or only its file mode changed.`, + { title: "No line changes", kind: "none" }, + ); + return; + } this.lastFile = file; const mode = this.resolveMode(); @@ -71,23 +246,78 @@ export class DiffPanel { b.append(glyph(icon), span(label)); b.title = m === "inline" ? "Unified diff (one column)" : "Side-by-side diff"; b.setAttribute("aria-pressed", String(mode === m)); + b.dataset.mode = m; b.addEventListener("click", () => { - if (this.resolveMode() === m && localStorage.getItem(LS_DIFF_MODE)) return; + // A no-op only when there is nothing to change on EITHER side. + // + // It used to compare the click to the stored preference alone. After a + // worker fallback that preference is still "inline" while the segment + // correctly shows Split, so pressing Inline matched and returned — + // leaving the button inert for the rest of the session with no way to + // ask for the unified view again. Comparing only to what is rendered + // has the mirror problem: pressing Split while Split is showing + // BECAUSE of a fallback is a real choice, and it has to record the + // preference and clear the note explaining a fallback you have now + // accepted. + if (this.renderedMode === m && localStorage.getItem(LS_DIFF_MODE) === m) return; try { localStorage.setItem(LS_DIFF_MODE, m); } catch { /* non-fatal */ } - if (this.lastFile) this.showDiff(this.lastFile); + // Swap the EDITOR, not the toolbar. This used to re-run showDiff, which + // replaced the whole panel — so the button you had just pressed was + // destroyed under your finger, taking hover, focus and the pressed + // state with it, and the panel's Monaco instance was thrown away and + // rebuilt even though the file had not changed. + this.swapMode(body, m); }); return b; }; seg.append(mkBtn("inline", "list-flat", "Inline"), mkBtn("split", "split-horizontal", "Split")); - bar.append(span(file.path, "diffmode-path"), seg); + this.seg = seg; + // The path is truncated from the LEFT (the filename is the part that + // identifies it), which the stylesheet does with `direction: rtl`. That + // reorders NEUTRAL characters at the edges of the string, and a leading dot + // is neutral: ".github/workflows/ci.yml" rendered as + // "github/workflows/ci.yml." — every dotfile path in the app naming a file + // that does not exist. An inner LTR isolate keeps the characters in the + // order they were written while the outer box still ellipsises on the left. + const path = span("", "diffmode-path"); + path.appendChild(span(file.path, "diffmode-path-text")); + path.title = file.path; + bar.append(path, seg); const body = el("div", "diffmode-body"); wrap.append(bar, body); this.container.replaceChildren(wrap); + // Say it BEFORE the editor, not after: a diff that silently stops halfway + // through a large file reads as a diff, and the reader draws conclusions + // from the half they can see. + if (file.truncated) { + const note = el("div", "diff-truncated-note"); + note.append( + glyph("warning"), + span("This file is too large to diff in full — showing the first part of it."), + ); + wrap.insertBefore(note, body); + } + + this.renderMode(body, file, mode); + } + + /** Paint one mode's editor into the panel body. Owns nothing above it. */ + private renderMode(body: HTMLElement, file: FileDiff, mode: DiffMode): void { + // A worker that already failed this session will fail again; skip the wait. + if (mode === "inline" && workerDiffBroken) { + this.markSegment("split"); + this.noteFallback(body); + this.renderMode(body, file, "split"); + return; + } + // The single funnel every render passes through, so the toggle's guard can + // ask what is on screen rather than what was once preferred. + this.renderedMode = mode; if (mode === "split") { const payload: DiffInitPayload = { leftLabel: file.leftLabel, @@ -101,6 +331,10 @@ export class DiffPanel { this.diff.onToggleTick = (row, staged) => { void this.toggleTick(file.path, row, staged); }; + // Start where the last editor left off — a rebuild (a file switch, a + // mode toggle) used to silently reset the whitespace setting to the + // default, so the toggle appeared to un-toggle itself. + this.diff.setRenderOptions(this.renderOpts); this.diff.render(payload); // Staging ticks only where staging means something: a working-tree diff // (HEAD on the left) that is not conflicted. A commit diff carries no @@ -114,6 +348,41 @@ export class DiffPanel { } } + /** + * Change diff mode in place: dispose only the editor, repaint only the body, + * and re-mark the segment. The bar — and the button under the pointer — + * survives. + */ + private swapMode(body: HTMLElement, mode: DiffMode): void { + const file = this.lastFile; + if (!file) return; + this.disposeEditors(); + body.replaceChildren(); + body.parentElement?.querySelector(".diff-staging-hint")?.remove(); + // A note left by an earlier fallback describes a render that no longer + // exists — asking for a mode explicitly clears it. + // + // The FALLBACK note only. This used to remove `.diff-truncated-note`, which + // was the class for both notes, so on any file over FILE_CAP_BYTES one + // press of the toggle permanently deleted "this file is too large to diff + // in full" — a warning about the CONTENT, still true in either mode, and + // the only thing telling the reader the diff they are drawing conclusions + // from stops halfway. + body.parentElement?.querySelector(".diff-fallback-note")?.remove(); + this.markSegment(mode); + this.renderMode(body, file, mode); + } + + /** Paint the segment to match the view that is actually rendered. */ + private markSegment(mode: DiffMode): void { + this.renderedMode = mode; + for (const b of this.seg?.querySelectorAll<HTMLElement>(".cmp-mode-btn") ?? []) { + const on = b.dataset.mode === mode; + b.classList.toggle("active", on); + b.setAttribute("aria-pressed", String(on)); + } + } + /** * Inline mode carries no ticks, and says so rather than looking broken. * @@ -156,7 +425,21 @@ export class DiffPanel { } } - /** Unified diff via Monaco's native diff editor (renderSideBySide: false). */ + /** + * Unified diff via Monaco's native diff editor (renderSideBySide: false). + * + * This mode has a dependency the Split mode does not: Monaco computes its + * diff in the EDITOR WEB WORKER, asynchronously. The editor mounts and paints + * the modified text immediately, and if the worker is missing, cold, crashed, + * or answering for a model that has since been disposed, the diff never + * arrives and you are left looking at a plain file with no changes marked — + * or, for a deleted file, at nothing at all. Every error that path produces + * is swallowed as worker noise, so the surface simply looks broken. + * + * Split has no such failure mode: it computes in-process. So inline waits a + * moment for the worker, and if the diff has not been computed by then it + * falls back to Split, which cannot fail this way, and says why. + */ private renderInline(body: HTMLElement, file: FileDiff): void { const language = languageForFile(file.path); const original = monaco.editor.createModel(file.leftText, language); @@ -166,6 +449,19 @@ export class DiffPanel { theme: ensureNativeTheme(), ...nativeFontOptions(), renderSideBySide: false, + // The SAME whitespace rule the split view uses. Monaco defaults + // `ignoreTrimWhitespace` to TRUE; the engine's `buildDiffModel` maps our + // default `whitespace: "none"` to FALSE. So a trailing-whitespace-only + // change showed in Split and vanished in Inline — the same file, the same + // click, one view showing a diff and the other showing none. + // + // The toggle sends "trailing", never "all", for the same reason. Under + // "all" the engine normalizes internal whitespace runs before diffing, + // and Monaco has no equivalent — its only whitespace option is this flag. + // So "all" made the two views disagree again, the other way round: Split + // called `a b` → `a b` unchanged while Inline drew it as a change. Both + // sides now run the same vscode-diff computer with the same flag set. + ignoreTrimWhitespace: ignoreTrimWhitespaceFor(this.renderOpts.whitespace), readOnly: true, automaticLayout: true, minimap: { enabled: false }, @@ -179,6 +475,59 @@ export class DiffPanel { lineNumbersMinChars: 3, }); this.inline.setModel({ original, modified }); + + // Did the worker actually answer? `onDidUpdateDiff` fires once the + // computation lands; `getLineChanges()` is null until it does. + const editor = this.inline; + let answered = false; + const sub = editor.onDidUpdateDiff(() => { + answered = true; + sub.dispose(); + window.clearTimeout(this.inlineWatchdog); + }); + this.inlineWatchdog = window.setTimeout(() => { + sub.dispose(); + // Identical texts legitimately produce no changes — and `showDiff` + // already refused that case above, so reaching here with a diff still + // uncomputed means the worker did not answer. + if (answered || this.inline !== editor) return; + if (editor.getLineChanges()) return; + this.fallBackToSplit(body, file); + }, INLINE_WORKER_GRACE_MS); + } + + /** + * The inline editor never got its diff. Render the Split view instead, which + * computes in-process, and say so — quietly, once, above the diff. + */ + private fallBackToSplit(body: HTMLElement, file: FileDiff): void { + this.disposeEditors(); + body.replaceChildren(); + // "Switch to Split to stage individual changes" — which is what is about to + // be rendered. swapMode has always cleared this; the fallback did not, so + // the surface flipped to Split and went on telling you to switch to Split. + body.parentElement?.querySelector(".diff-staging-hint")?.remove(); + workerDiffBroken = true; + this.noteFallback(body); + // Mark the segment to match what is ON SCREEN. The stored preference is + // deliberately left alone: it is still what you asked for, and it comes + // back the next time the window starts with a working worker. + this.markSegment("split"); + this.renderMode(body, file, "split"); + } + + /** One line above the diff saying which view this actually is, and why. */ + private noteFallback(body: HTMLElement): void { + if (body.parentElement?.querySelector(".diff-fallback-note")) return; + // Its OWN class. Sharing one with the truncation note also meant that on a + // truncated file this early return fired against the wrong note and the + // fallback said nothing at all. + const note = el("div", "diff-truncated-note diff-fallback-note"); + note.append( + glyph("warning"), + span("Showing this diff side by side — the unified view didn't come back."), + ); + body.parentElement?.insertBefore(note, body); } /** @@ -188,7 +537,15 @@ export class DiffPanel { * merge editor was previously display-only; this is the write-back path. * `onResolved` fires after a successful resolve so the caller can refresh. */ - showMerge(model: ConflictModel, onResolved?: () => void): void { + showMerge( + model: ConflictModel, + onResolved?: () => void, + /** This file has no text to merge — a binary, or a side that does not + * exist. The take-side buttons still apply; the three-pane editor does + * not, and mounting it over decoded bytes is how a conflicted PNG offered + * you a line-by-line merge of two walls of U+FFFD. */ + opts: { noText?: "binary" | "modify-delete" | "too-large" | "both-deleted" } = {}, + ): void { this.teardown(); const wrap = el("div", "merge-wrap"); @@ -196,15 +553,31 @@ export class DiffPanel { const title = el("div", "merge-bar-title"); title.append(glyph("git-merge"), span(model.path, "merge-bar-path")); const actions = el("div", "merge-bar-actions"); - const ours = el("button", "mini-btn") as HTMLButtonElement; - ours.append(glyph("arrow-left"), span("Take ours")); - ours.title = "Replace the file with your version (current change) and stage it"; - const theirs = el("button", "mini-btn") as HTMLButtonElement; - theirs.append(glyph("arrow-right"), span("Take theirs")); - theirs.title = "Replace the file with the incoming version and stage it"; + // NAMED FOR THE OPERATION. "ours" and "theirs" are git's index stages, and + // which of YOUR work each holds is inverted during a rebase — so a button + // reading "Take ours" handed you the branch you were rebasing ONTO and + // discarded the commit being replayed. The model carries labels the main + // process derives from the operation actually in progress; use them. + // A MODIFY/DELETE conflict has a side with no file at all, and taking that + // side does not replace the file — it removes it. The button read "Take + // <side>" with the tooltip "Replace the file with …", which is the wrong + // verb for the only irreversible thing on this bar. + const deletes = (side: "ours" | "theirs"): boolean => model.missingSide === side; + const sideBtn = (side: "ours" | "theirs", icon: string, label: string): HTMLButtonElement => { + const b = el("button", "mini-btn" + (deletes(side) ? " is-danger" : "")) as HTMLButtonElement; + b.append(glyph(deletes(side) ? "trash" : icon), span(deletes(side) ? "Delete the file" : `Take ${label}`)); + b.title = deletes(side) + ? `“${label}” has no version of this file — taking that side removes it and stages the deletion` + : `Replace the file with “${label}” and stage it`; + return b; + }; + const ours = sideBtn("ours", "arrow-left", model.oursLabel); + const theirs = sideBtn("theirs", "arrow-right", model.theirsLabel); const resolve = el("button", "btn btn-primary mini-btn merge-resolve") as HTMLButtonElement; resolve.append(glyph("check"), span("Mark resolved")); - resolve.title = "Save your merged result and stage the file as resolved"; + // Armed only once the merge has actually been made — see syncResolve below. + resolve.disabled = true; + resolve.title = "Work through the conflicts first"; actions.append(ours, theirs, resolve); bar.append(title, actions); @@ -212,7 +585,104 @@ export class DiffPanel { wrap.append(bar, surface); this.container.replaceChildren(wrap); + if (opts.noText) { + // No merge editor at all — an explanation, and the two side buttons in + // the bar above it, which are the only moves that make sense here. + resolve.remove(); + const note = el("div", "merge-notext list-empty is-none"); + const badge = el("div", "list-empty-badge"); + badge.appendChild( + glyph( + opts.noText === "binary" + ? "file-binary" + : opts.noText === "too-large" + ? "warning" + : "diff-removed", + ), + ); + const h = el("div", "list-empty-title"); + const d = el("div", "list-empty-desc"); + if (opts.noText === "binary") { + h.textContent = "Conflicted binary file"; + d.textContent = + `${model.path} is binary, so there is no line-by-line merge to make. Take one side, or ` + + `replace the file yourself and stage it.`; + } else if (opts.noText === "too-large") { + h.textContent = "Too large to merge here"; + d.textContent = + `${model.path} is larger than this app reads in one go, so only part of it is available — ` + + `and saving a merge built from part of a file would delete the rest. Take one side, or ` + + `resolve it in an editor and stage it.`; + } else if (opts.noText === "both-deleted") { + // Git's DD. Neither side has the file, so neither "Take" button has + // anything to take — `conflictTakeSide` refuses both, and the panel + // used to offer them anyway by drawing this as a modify/delete. + ours.remove(); + theirs.remove(); + h.textContent = "Deleted on both sides"; + d.textContent = + `${model.path} was deleted in “${model.oursLabel}” and in “${model.theirsLabel}”. There is ` + + `nothing to choose between — the file is going either way. Discard it to accept the ` + + `deletion and settle the conflict.`; + } else if (!model.hasBase) { + // ADDED on one side, with no common ancestor — git's UA / AU. Nothing + // was DELETED here: there is no base, so the file simply does not exist + // on the other side and never did. Telling the modify/delete story + // ("deleted in X") describes a deletion that never happened, about a + // file that has no history to have been deleted from. + const addedIn = model.missingSide === "ours" ? model.theirsLabel : model.oursLabel; + const absent = model.missingSide === "ours" ? model.oursLabel : model.theirsLabel; + h.textContent = "Added on one side only"; + d.textContent = + `${model.path} is new in “${addedIn}” and does not exist in “${absent}” — there is no ` + + `earlier version behind either. Keep the new file, or leave it out.`; + } else { + // `missingSide` says WHICH side has no file. The note used to print + // both readings and then "— or the other way round", which is the app + // declining to answer the only question the reader has, about a state + // where one of the two buttons below DELETES their file. + const goneSide = model.missingSide === "ours" ? model.oursLabel : model.theirsLabel; + const keptSide = model.missingSide === "ours" ? model.theirsLabel : model.oursLabel; + h.textContent = "Changed on one side, deleted on the other"; + d.textContent = + `${model.path} was edited in “${keptSide}” and deleted in “${goneSide}”. There is nothing ` + + `to merge line by line: keep the edited file, or accept the deletion.`; + } + note.append(badge, h, d); + surface.appendChild(note); + this.wireSides(ours, theirs, model, onResolved); + return; + } + this.merge = new MergeView(surface); + // How much of the merge is still undone. The result pane is deliberately + // SEEDED WITH THE BASE — the block trackers are anchored in base + // coordinates and you build the answer by accepting sides, as IntelliJ does + // — which means "Mark resolved" pressed before accepting anything writes + // the BASE over the file and stages it: both sides' work discarded, under a + // toast reading "Resolved and staged." Nothing recovers that. + let pending = Number.POSITIVE_INFINITY; + const syncResolve = (): void => { + const blocked = pending > 0; + resolve.disabled = blocked; + resolve.title = blocked + ? pending === Number.POSITIVE_INFINITY + ? "Work through the conflicts first" + : `${pending} conflict${pending === 1 ? "" : "s"} still to settle — the result would not be your merge` + : "Save your merged result and stage the file as resolved"; + }; + this.merge.onCountsChanged = (counts) => { + // EVERY pending block, not only the conflicting ones. + // + // The result pane is seeded with the BASE, so a block nobody has accepted + // still holds the base's version of those lines — conflicting or not. + // Gating on `conflictsPending` therefore unlocked the button while + // auto-mergeable hunks were still sitting at base, and saving wrote the + // pre-merge original over both sides' work in every one of them. The + // gate that was added to stop exactly this counted the wrong thing. + pending = counts.pending; + syncResolve(); + }; this.merge.render({ fileName: model.path, conflictType: "content", @@ -225,48 +695,54 @@ export class DiffPanel { theirs: model.theirs, result: model.result, }); + // Start from git's own auto-merge, the way the worktree already has. + // + // Without this the correctness fix above is unusable: a file with one true + // conflict and forty hunks git merged cleanly would need forty gestures to + // redo work git had already done. Seeding them makes `pending` equal + // `conflictsPending` by construction, so the stricter gate is invisible in + // the ordinary case and only bites when a block really is unresolved. + this.merge.applyAllNonConflicting(); + // Paint the button's initial state. `onCountsChanged` fires on the first + // model build, but a merge view that fails to mount at all (a cold or + // broken Monaco worker) never emits it — and the button must stay closed in + // that case, not open by default. + syncResolve(); // The surface starts at 0 height until Monaco lays out — nudge it. requestAnimationFrame(() => (this.merge as { layout?: () => void } | undefined)?.layout?.()); - const run = async ( - btn: HTMLButtonElement, - op: () => Promise<{ ok: boolean; message?: string }>, - okMsg: string, - ): Promise<void> => { - const prev = btn.textContent; - btn.disabled = true; - try { - const r = await op(); - if (r.ok) { - toast(okMsg, "success"); - onResolved?.(); - } else { - toast(r.message || "Could not resolve the conflict.", "error"); - } - } catch (err) { - toast(String(err), "error"); - } finally { - btn.disabled = false; - void prev; - } - }; - - ours.addEventListener("click", () => - run(ours, () => host.invoke("conflict:takeSide", { path: model.path, side: "ours" }), "Took your version."), + this.wireSides(ours, theirs, model, onResolved); + mergeRun( + resolve, + () => + host.invoke("conflict:resolve", { + path: model.path, + content: this.merge?.getResultText() ?? model.result, + }), + "Resolved and staged.", + onResolved, ); - theirs.addEventListener("click", () => - run(theirs, () => host.invoke("conflict:takeSide", { path: model.path, side: "theirs" }), "Took the incoming version."), + } + + /** "Take <side>" on both merge layouts — the three-pane editor and the + * no-text one, which has the same two moves available and nothing else. */ + private wireSides( + ours: HTMLButtonElement, + theirs: HTMLButtonElement, + model: ConflictModel, + onResolved?: () => void, + ): void { + mergeRun( + ours, + () => host.invoke("conflict:takeSide", { path: model.path, side: "ours" }), + `Took “${model.oursLabel}”.`, + onResolved, ); - resolve.addEventListener("click", () => - run( - resolve, - () => - host.invoke("conflict:resolve", { - path: model.path, - content: this.merge?.getResultText() ?? model.result, - }), - "Resolved and staged.", - ), + mergeRun( + theirs, + () => host.invoke("conflict:takeSide", { path: model.path, side: "theirs" }), + `Took “${model.theirsLabel}”.`, + onResolved, ); } @@ -275,6 +751,19 @@ export class DiffPanel { * * ALL selections, not just the primary one: Monaco supports multi-cursor, and * reading getSelection() staged the first range and dropped the rest. */ + /** Is a line-by-line diff editor actually mounted? + * + * The same condition `getSelectedLines` tests, exposed so the toolbar can be + * set from it. "Stage lines" and the whitespace toggle were enabled by + * SELECTING A ROW, before the diff had even been asked for — so over a + * binary, a conflict, a truncated file or a failed read they stayed lit over + * a pane with no editor in it, and answered a click with "select some lines + * first": advice you cannot follow, about a control that could never work + * here. */ + hasLineEditor(): boolean { + return !!(this.diff?.right ?? this.inline?.getModifiedEditor()); + } + getSelectedLines(): number[] | null { // Both modes, not just split. `this.diff` is undefined in inline mode, and // inline is the DEFAULT below 1000px of surface width — so on a narrow @@ -285,39 +774,87 @@ export class DiffPanel { return selectedLineNumbers(ed.getSelections()); } - /** Re-run the 2-pane diff with new whitespace / granularity options. */ - setRenderOptions(opts: { whitespace?: "none" | "all"; showInner?: boolean }): void { + /** + * Re-run the diff with new whitespace / granularity options. + * + * BOTH surfaces. This reached only the split view, so the app's own + * whitespace toggle silently did nothing in unified mode — and the two modes + * then disagreed about what counted as a change. + */ + setRenderOptions(opts: { whitespace?: "none" | "trailing"; showInner?: boolean }): void { + this.renderOpts = { ...this.renderOpts, ...opts }; this.diff?.setRenderOptions(opts); + if (this.inline && opts.whitespace !== undefined) { + this.inline.updateOptions({ ignoreTrimWhitespace: ignoreTrimWhitespaceFor(opts.whitespace) }); + } } - /** Shows a composed placeholder (icon badge + text) when nothing is selected. */ - showEmpty(text: string): void { + /** + * The placeholder this panel shows when there is no diff on screen. + * + * It used to be one line of grey text under the same compare icon whatever + * the reason was — "select a file", "there is no diff", and "the request + * failed" all looked identical, so a failure read as an instruction. It now + * takes a title and picks its icon from the KIND of nothing it is showing. + */ + showEmpty(text: string, opts: { title?: string; kind?: "waiting" | "none" | "error" } = {}): void { this.teardown(); + const kind = opts.kind ?? "waiting"; + const icon = kind === "error" ? "warning" : kind === "none" ? "check-all" : "git-compare"; + const title = + opts.title ?? + (kind === "error" ? "Couldn't load this diff" : kind === "none" ? "No changes" : "Nothing selected"); const empty = document.createElement("div"); - empty.className = "diff-empty list-empty"; + empty.className = `diff-empty list-empty is-${kind}`; const badge = document.createElement("div"); badge.className = "list-empty-badge"; - badge.innerHTML = '<span class="glyph codicon codicon-git-compare"></span>'; + badge.innerHTML = `<span class="glyph codicon codicon-${icon}"></span>`; + const h = document.createElement("div"); + h.className = "list-empty-title"; + h.textContent = title; const t = document.createElement("div"); t.className = "list-empty-desc"; t.textContent = text; - empty.append(badge, t); + empty.append(badge, h, t); this.container.replaceChildren(empty); } + /** + * Re-measure the editors after the container changes size for a reason no + * observer will see (a resizer drag, a pane collapsing). + * + * Both surfaces do keep themselves laid out — DiffView watches its container, + * the inline editor uses automaticLayout — but a host that resizes on a + * pointer drag wants the new width THIS frame, not on the observer's. + */ + layout(): void { + this.diff?.layout?.(); + this.inline?.layout(); + (this.merge as { layout?: () => void } | undefined)?.layout?.(); + } + dispose(): void { this.teardown(); } - private teardown(): void { + /** Dispose every editor and model this panel owns, keeping the DOM. */ + private disposeEditors(): void { + window.clearTimeout(this.inlineWatchdog); + this.inlineWatchdog = undefined; this.diff?.dispose(); this.diff = undefined; this.merge?.dispose(); this.merge = undefined; this.inline?.dispose(); this.inline = undefined; + // Monaco models are not owned by the editor that used them: leaving these + // behind on every rebuild leaked one pair of models per diff shown. for (const m of this.inlineModels) m.dispose(); this.inlineModels = []; + } + + private teardown(): void { + this.disposeEditors(); this.container.replaceChildren(); } } diff --git a/apps/desktop/src/renderer/draftStore.ts b/apps/desktop/src/renderer/draftStore.ts new file mode 100644 index 0000000..08f899d --- /dev/null +++ b/apps/desktop/src/renderer/draftStore.ts @@ -0,0 +1,135 @@ +// Unsent text, kept. +// +// Every composer in the app threw away what you had typed if you pressed +// Escape: the release form, the issue form, the issue body edit. No warning, no +// undo, and Escape is the key people press to dismiss a menu they opened by +// accident — so the gesture that loses a paragraph of release notes is the same +// gesture that means "never mind" everywhere else. +// +// A confirm dialog is the obvious answer and the wrong one: it makes leaving +// expensive rather than making the text safe, and it still loses everything if +// the window closes, the app updates, or the view is routed away by a +// background refresh. Keeping the draft is the fix; a confirm is at best a +// second line. +// +// Keyed by REPO as well as by item, because issue #31 in one repository is not +// issue #31 in another — the app has already shipped that exact bug once, with +// comment drafts keyed by number alone being handed to the wrong repository's +// issue, pre-filled and ready to send to strangers. + +import { cacheScope } from "./cache"; + +const PREFIX = "gitstudio.draft."; +/** Drop anything untouched for this long, so the store cannot grow forever. */ +const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; + +interface Stored { + text: string; + at: number; +} + +/** + * A draft's identity. `kind` names the surface ("release", "issue", "comment"), + * `id` the thing being edited — a number, a tag, or "new". + */ +export function draftKey(kind: string, id: string | number): string { + return `${PREFIX}${cacheScope()}|${kind}|${id}`; +} + +function read(key: string): Stored | undefined { + try { + const raw = localStorage.getItem(key); + if (!raw) return undefined; + const v = JSON.parse(raw) as Stored; + return typeof v?.text === "string" ? v : undefined; + } catch { + // A private window, cleared site data, a browser refusing storage. A draft + // that cannot be read is not an error worth surfacing — the field simply + // starts empty, which is what would have happened anyway. + return undefined; + } +} + +/** The saved text for this draft, or undefined. */ +export function loadDraft(kind: string, id: string | number): string | undefined { + const v = read(draftKey(kind, id)); + if (!v) return undefined; + if (Date.now() - v.at > MAX_AGE_MS) { + clearDraft(kind, id); + return undefined; + } + return v.text || undefined; +} + +/** + * Save, debounced by the caller's typing. Empty text CLEARS rather than storing + * an empty draft: otherwise deleting your text and leaving would restore an + * empty box over whatever the server actually has, which reads as data loss the + * next time the form opens. + */ +export function saveDraft(kind: string, id: string | number, text: string): void { + const key = draftKey(kind, id); + try { + if (!text.trim()) { + localStorage.removeItem(key); + return; + } + localStorage.setItem(key, JSON.stringify({ text, at: Date.now() } satisfies Stored)); + } catch { + /* storage full or refused — the draft is a convenience, never a promise */ + } +} + +export function clearDraft(kind: string, id: string | number): void { + try { + localStorage.removeItem(draftKey(kind, id)); + } catch { + /* nothing to do */ + } +} + +/** + * Wire a draft to an editor: restore on open, save as you type, clear on a + * successful submit. Returns the restored text so the caller can decide whether + * to prefer it over the server's value. + * + * The debounce is deliberate rather than a write per keystroke — `localStorage` + * is synchronous and on the main thread, and a 400ms window costs at most a few + * words if the app dies outright. + */ +export function wireDraft( + kind: string, + id: string | number, + onRestore: (text: string) => void, +): { save: (text: string) => void; clear: () => void; restored: boolean } { + const existing = loadDraft(kind, id); + if (existing) onRestore(existing); + let timer: number | undefined; + return { + save: (text: string) => { + window.clearTimeout(timer); + timer = window.setTimeout(() => saveDraft(kind, id, text), 400); + }, + clear: () => { + window.clearTimeout(timer); + clearDraft(kind, id); + }, + restored: existing !== undefined, + }; +} + +/** Housekeeping: forget drafts nobody came back to. Called once at startup. */ +export function pruneDrafts(): void { + try { + const dead: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (!k || !k.startsWith(PREFIX)) continue; + const v = read(k); + if (!v || Date.now() - v.at > MAX_AGE_MS) dead.push(k); + } + for (const k of dead) localStorage.removeItem(k); + } catch { + /* storage unavailable */ + } +} diff --git a/apps/desktop/src/renderer/exploreRoutes.ts b/apps/desktop/src/renderer/exploreRoutes.ts new file mode 100644 index 0000000..d1ac7b8 --- /dev/null +++ b/apps/desktop/src/renderer/exploreRoutes.ts @@ -0,0 +1,77 @@ +// Explore's routing vocabulary — pure, DOM-free, node-tested. +// +// Explore states ride in `target.id` as micro-paths rather than new +// SectionTarget fields, so ⌘[ / Esc walk search → repo → folder → file with no +// change to the shared navigation contract: +// +// q/<tab>/<query> +// repo/<owner>/<name> +// repo/<owner>/<name>/(tree|blob)/<ref>/<path…> +// user/<login> org/<login> +// +// That makes these parsers load-bearing for navigation: a wrong parse doesn't +// throw, it silently strands someone on the wrong page. Hence the tests. + +export type ExploreTab = "repos" | "users" | "orgs" | "code"; + +/** A parsed `repo/…` target. */ +export interface RepoRoute { + fullName: string; + /** "" for the repo root. */ + path: string; + /** Undefined = the repo's default branch. */ + ref?: string; + kind: "tree" | "blob"; +} + +/** `q/<tab>/<query>` — the routed form of a search. */ +export function searchTargetId(tab: ExploreTab, query: string): string { + return `q/${tab}/${query}`; +} + +/** Parse a routed search target. Unknown shapes are ignored rather than + * throwing — a stale history entry must never break the view. */ +export function parseExploreTarget( + id: string | undefined, +): { tab: ExploreTab; query: string } | undefined { + if (!id) return undefined; + const m = /^q\/(repos|users|orgs|code)\/([\s\S]*)$/.exec(id); + if (!m) return undefined; + return { tab: m[1] as ExploreTab, query: m[2] }; +} + +/** `repo/<owner>/<name>[/tree|blob/<ref>/<path>]` → a route, or undefined. */ +export function parseRepoRoute(id: string | undefined): RepoRoute | undefined { + if (!id) return undefined; + const m = /^repo\/([^/]+)\/([^/]+)(?:\/(tree|blob)\/([^/]+)(?:\/([\s\S]*))?)?$/.exec(id); + if (!m) return undefined; + return { + fullName: `${m[1]}/${m[2]}`, + kind: (m[3] as "tree" | "blob") ?? "tree", + // "HEAD" is the sentinel a path-carrying route uses when no explicit ref + // was chosen — it must parse back to "the default branch", or walking into + // a file would silently pin the ref and relabel the switcher. + ref: m[4] && m[4] !== "HEAD" ? decodeURIComponent(m[4]) : undefined, + path: m[5] ?? "", + }; +} + +/** The inverse — build the routed id for a location in a repo. */ +export function repoRouteId(o: { + fullName: string; + path?: string; + ref?: string; + kind?: "tree" | "blob"; +}): string { + const base = `repo/${o.fullName}`; + if (!o.path && !o.ref) return base; + const ref = encodeURIComponent(o.ref ?? "HEAD"); + return `${base}/${o.kind ?? "tree"}/${ref}${o.path ? `/${o.path}` : ""}`; +} + +/** `user/<login>` or `org/<login>` → the login, or undefined. */ +export function parseAccountTarget(id: string | undefined): { login: string } | undefined { + if (!id) return undefined; + const m = /^(?:user|org)\/([^/]+)$/.exec(id); + return m ? { login: m[1] } : undefined; +} diff --git a/apps/desktop/src/renderer/facetModel.ts b/apps/desktop/src/renderer/facetModel.ts new file mode 100644 index 0000000..605d80f --- /dev/null +++ b/apps/desktop/src/renderer/facetModel.ts @@ -0,0 +1,103 @@ +// The pure half of the facet system: what a facet IS, which items survive it, +// and which values are the server's problem rather than ours. +// +// DOM-free on purpose (like logModel.ts next to logView.ts) so the filtering +// rules unit-test under plain node. `views/common.ts` builds the buttons and +// menus on top of this and re-exports these names, so views import one place. + +/** One option in a facet menu. `value` is what lands in the state. */ +export interface FacetOption { + value: string; + label?: string; + /** Optional leading glyph (e.g. a state icon). */ + icon?: string; + /** A pre-built leading element — a label swatch, an avatar. Wins over `icon`, + * and is a FACTORY because a DOM node can only live in one menu at a time. */ + iconEl?: () => HTMLElement; +} + +/** + * One facet — a named dimension a list can be narrowed by. + * + * `options` is either fixed, harvested from the items on screen (so a Label + * facet offers exactly the labels present), or loaded asynchronously + * (workflows, which the rows don't name). + * + * `predicate` is what makes a facet CLIENT-side. Omit it and the facet is + * declared server-side: its value surfaces through `serverValues()` and the + * view passes it to the API instead of filtering on screen. That distinction + * is the whole design — a server facet that ALSO filtered locally would + * silently hide rows the server already excluded. + */ +export interface FacetSpec<T> { + /** Stable key — also the state key and the API parameter name. */ + key: string; + /** Human label, shown on the button when nothing is selected. */ + label: string; + icon: string; + options?: FacetOption[]; + /** Derive options from the items currently loaded. */ + harvest?: (items: T[]) => FacetOption[]; + /** Load options on first open (cached for the bar's lifetime). */ + load?: () => Promise<FacetOption[]>; + /** Client-side test. OMIT for a server-side facet. */ + predicate?: (item: T, value: string) => boolean; + /** Label for the "no filter" menu entry (default "Any <label>"). */ + anyLabel?: string; +} + +export type FacetState = Record<string, string | undefined>; + +/** True when `item` survives every ACTIVE client-side facet. Server-side + * facets always pass here — their filtering already happened upstream. */ +export function facetPasses<T>(specs: FacetSpec<T>[], state: FacetState, item: T): boolean { + return specs.every((spec) => { + const v = state[spec.key]; + if (v == null || !spec.predicate) return true; + return spec.predicate(item, v); + }); +} + +/** Active values for server-side facets (those declaring no predicate). */ +export function facetServerValues<T>( + specs: FacetSpec<T>[], + state: FacetState, +): Record<string, string> { + const out: Record<string, string> = {}; + for (const spec of specs) { + const v = state[spec.key]; + if (v != null && !spec.predicate) out[spec.key] = v; + } + return out; +} + +/** How many facets are currently narrowing the list. */ +export function facetActiveCount<T>(specs: FacetSpec<T>[], state: FacetState): number { + return specs.reduce((n, s) => n + (state[s.key] != null ? 1 : 0), 0); +} + +/** Distinct values off the loaded items, sorted — the usual `harvest`. + * Empty and nullish values are dropped: an option nothing matches is a dead + * row in the menu. + * + * `label` is what the menu SHOWS. Without it a facet offers the raw API value + * — "subscribed", "PullRequest" — beside rows that render the humanized form + * ("watching", "PR"), so the filter never matches what you are reading. Pass + * the same mapper the rows use. Sorting follows the label, since that is the + * order the reader perceives. */ +export function harvestValues<T>( + pick: (item: T) => string | string[] | null | undefined, + label?: (value: string) => string, +) { + return (items: T[]): FacetOption[] => { + const seen = new Set<string>(); + for (const it of items) { + const v = pick(it); + if (!v) continue; + for (const one of Array.isArray(v) ? v : [v]) if (one) seen.add(one); + } + return [...seen] + .map((value) => ({ value, label: label ? label(value) : undefined })) + .sort((a, b) => (a.label ?? a.value).localeCompare(b.label ?? b.value)); + }; +} diff --git a/apps/desktop/src/renderer/focusReturn.ts b/apps/desktop/src/renderer/focusReturn.ts new file mode 100644 index 0000000..8aa3620 --- /dev/null +++ b/apps/desktop/src/renderer/focusReturn.ts @@ -0,0 +1,230 @@ +// Where the keyboard goes when a page changes. +// +// The app's primary gesture is: arrow to a row, press Enter, read the thing, +// press Escape. Both halves used to drop `document.activeElement` on <body>. +// After Enter your next Tab started at the top of the window — past the whole +// nav rail — instead of in the page you had just opened. After Escape the list +// came back with nothing focused at all, so arrowing had to begin again from +// the first row rather than the one you were reading. +// +// Two rules, and they are symmetric: +// +// leaving a list → remember which row you were on +// arriving back → put focus on that row again +// +// This is deliberately a listener rather than a call at every navigation site. +// Rows are opened from a dozen places (click, Enter, the palette, a deep link, +// a peek's "open full page"), and a mechanism that only works when a caller +// remembers to invoke it is a mechanism that works most of the time. A focusin +// listener sees them all. +// +// The restore is armed rather than immediate because a list's rows arrive +// asynchronously: the view is built, then its data resolves, then rows render. +// So arriving at a view with a remembered row starts a short watch that focuses +// the row the moment it exists, and gives up quietly if it never does (the item +// was deleted, the filter changed, the list is empty now). + +/** How long to wait for an asynchronous list to produce the row we want. */ +const ARM_MS = 2500; + +/** Rows carry `data-num`; that is the identity we remember. */ +const ROW_SELECTOR = "[data-num]"; + +/** view id → the `data-num` of the row focus was last on in that view. */ +const lastRow = new Map<string, string>(); + +let scope = ""; +let armed: { view: string; num: string; until: number } | undefined; +let timer = 0; + +/** + * Polling uses a TIMER, not requestAnimationFrame. + * + * rAF only runs when the page produces a frame, which is not guaranteed when + * the window is occluded or minimised — and not guaranteed at all under the + * headless harness, where a run of short timers can be serviced without a + * single frame in between. Focus that lands "only when the compositor feels + * like it" is exactly the kind of intermittent hiccup this module exists to + * remove. + */ +const POLL_MS = 32; + +/** + * Record focus as it moves. Only rows in the CURRENT view are remembered, so a + * row focused inside a peek or a modal never becomes the thing we return to. + */ +function onFocusIn(e: FocusEvent): void { + if (!scope) return; + const t = e.target as HTMLElement | null; + const row = t?.closest?.(ROW_SELECTOR) as HTMLElement | null; + const num = row?.dataset.num; + if (num) lastRow.set(scope, num); +} + +let wired = false; +function wire(): void { + if (wired) return; + wired = true; + document.addEventListener("focusin", onFocusIn, true); + document.addEventListener("focusout", onFocusOut, true); +} + +/** Poll for the remembered row until it appears or the arm window expires. */ +function tick(): void { + timer = 0; + if (!armed) return; + if (armed.view !== scope) { + armed = undefined; + return; + } + const row = document.querySelector<HTMLElement>( + `${ROW_SELECTOR}[data-num="${CSS.escape(armed.num)}"]`, + ); + if (row && row.offsetParent !== null) { + armed = undefined; + row.focus({ preventScroll: true }); + row.scrollIntoView({ block: "nearest" }); + return; + } + if (Date.now() > armed.until) { + armed = undefined; + return; + } + timer = window.setTimeout(tick, POLL_MS); +} + +/** + * Tell the module which view is on screen. Called from `routeView` on every + * navigation — including back/forward and a re-entry into the same section. + * + * Arriving at a view we have a remembered row for arms the restore. Arriving + * anywhere else simply changes the scope, so the next row focused is recorded + * against the right view. + */ +export function setFocusScope(view: string): void { + wire(); + scope = view; + if (timer) { + clearTimeout(timer); + timer = 0; + } + const num = lastRow.get(view); + armed = num ? { view, num, until: Date.now() + ARM_MS } : undefined; + if (armed) timer = window.setTimeout(tick, 0); +} + +/** + * Move focus into a page that has just replaced another one. + * + * Prefers the page's own heading — a screen reader then announces what you + * arrived at, which "the back button" does not — and falls back to the first + * control. `tabindex="-1"` makes a heading programmatically focusable without + * adding it to the Tab order. + * + * Deferred by a frame because a detail page's title is appended by its caller + * after the shell is built. + */ +export function focusNewPage(view: HTMLElement, fallback?: HTMLElement | null): void { + // Wait for the page to be BOTH attached and titled before moving focus. + // + // A single frame is not enough. `detailPage()` returns its shell to a caller + // that may await data before attaching it, and the caller appends the <h1> + // afterwards — so one frame later the view can be unconnected, or connected + // but headless. The first version bailed out in exactly those cases and left + // focus on <body>, which is the bug this function exists to fix: it worked + // for issues and silently did nothing for pull requests. + let frames = 0; + const attempt = (): void => { + // The view was replaced again (a fast second navigation) — let that one win. + if (frames > 60) return; + const active = document.activeElement as HTMLElement | null; + // Never steal focus from something the user is already using: a page that + // finishes loading while you type in its comment box must not yank the + // caret away. + if (active && active !== document.body && view.contains(active)) return; + const heading = view.isConnected ? view.querySelector<HTMLElement>(".det-title, h1") : null; + const target = heading ?? (view.isConnected ? fallback ?? null : null); + if (!target) { + if (frames++ < 60) window.setTimeout(attempt, POLL_MS); + return; + } + if (!target.hasAttribute("tabindex") && !/^(A|BUTTON|INPUT|SELECT|TEXTAREA)$/.test(target.tagName)) { + target.setAttribute("tabindex", "-1"); + } + target.focus({ preventScroll: true }); + }; + window.setTimeout(attempt, 0); +} + +/** + * When the app destroys the control you were using, put focus on its + * replacement. + * + * Most surfaces here rebuild a whole subtree in response to a click — staging a + * file, refreshing a list, flipping a sub-tab, changing a rebase action, hiding + * a column. The node you clicked is detached in the process, focus falls to + * <body>, and the next Tab starts at the top of the window. That is a different + * bug from "the page changed" (which `focusNewPage` handles): here you have not + * gone anywhere, and the thing you were operating still exists — as a new + * element with the same identity. + * + * One listener catches all of it. The rule is deliberately narrow, so it can + * never steal focus from a person: it acts only when focus landed on <body> + * AND the element that lost it is no longer in the document. Clicking blank + * space, closing a menu, or moving focus anywhere real all fail that test. + */ +function sameThing(a: HTMLElement, b: Element): boolean { + if (a.tagName !== b.tagName) return false; + const bh = b as HTMLElement; + const num = a.dataset.num; + if (num) return bh.dataset?.num === num; + if (a.title) return bh.title === a.title; + const label = a.getAttribute("aria-label"); + if (label) return bh.getAttribute("aria-label") === label; + const t = (a.textContent ?? "").trim(); + if (!t) return false; + // The BASE class only. A rebuilt control usually differs by exactly the + // state class the click just changed — flipping Releases to Tags rebuilds + // the segment and moves `active` onto the button you pressed, so comparing + // the whole className would fail on precisely the elements this exists for. + const base = (n: Element): string => (n.className || "").split(" ")[0] ?? ""; + return base(a) === base(bh) && (bh.textContent ?? "").trim() === t; +} + +function restoreEquivalent(lost: HTMLElement): boolean { + // Search only among things that can actually take focus. + const candidates = document.querySelectorAll<HTMLElement>( + 'button, [role="button"], [role="option"], [role="tab"], a[href], input, select, textarea, [tabindex]', + ); + for (const el of candidates) { + if (el.offsetParent === null && el.tagName !== "INPUT") continue; + if (sameThing(lost, el)) { + el.focus({ preventScroll: true }); + return true; + } + } + return false; +} + +function onFocusOut(e: FocusEvent): void { + const lost = e.target as HTMLElement | null; + if (!lost || !lost.tagName) return; + // Poll briefly rather than checking once. A rebuild is usually asynchronous — + // the view is torn down, data is awaited, rows arrive — so a single timeout + // lands in the gap where the old control is gone and the new one does not + // exist yet, and the rescue quietly finds nothing. + let tries = 0; + const attempt = (): void => { + if (document.activeElement !== document.body) return; // something took it + if (lost.isConnected) return; // still there — the user simply clicked away + if (restoreEquivalent(lost)) return; + if (++tries < 25) window.setTimeout(attempt, POLL_MS); + }; + window.setTimeout(attempt, POLL_MS); +} + +/** Forget everything — a repo switch makes every remembered row meaningless. */ +export function clearFocusReturn(): void { + lastRow.clear(); + armed = undefined; +} diff --git a/apps/desktop/src/renderer/ghOpen.ts b/apps/desktop/src/renderer/ghOpen.ts new file mode 100644 index 0000000..144b9cc --- /dev/null +++ b/apps/desktop/src/renderer/ghOpen.ts @@ -0,0 +1,207 @@ +// One-click "open owner/repo as a NORMAL repo" — the renderer end of +// ghrepo:open. Existing clones open instantly; first opens clone themselves +// into the configured clone folder behind a slim progress card and then flip +// the whole app to the repo (Code, Commits, Branches, PRs — everything), +// exactly like opening a local folder. The progress card only appears if the +// open takes longer than a beat, so the instant path never flashes UI. +// +// Destination control (Settings → Repositories): `openGhRepoChooseLocation` +// asks first via the destination sheet, and the ask-where-every-time setting +// turns EVERY one-click open into that flow. Failures come back with a +// structured `code` — a collision reopens the sheet prefilled, so "it's +// already there" is a two-click fix, not a dead end. + +import { host } from "./bridge"; +import { openModal, toast } from "./dialogs"; +import { el, cleanErr } from "./ui"; +import { openDestinationSheet } from "./destinationSheet"; +import { closePeek } from "./peek"; + +/** + * The open in flight, if any. + * + * This was a bare `let opening = false` and three `if (opening) return;` + * guards. Esc and the backdrop only HIDE the progress card — the clone keeps + * running, by design — so dismissing it left the flag set with nothing on + * screen, and from then on every click on every repo in the app did nothing at + * all: no card, no toast, no error. It looked like the buttons had stopped + * working. Holding the request instead of a bare boolean lets the same repo + * bring its card BACK, a different one say why not, and a hung open time out + * rather than disabling the feature for the rest of the session. + */ +let current: { fullName: string; reopen: () => void } | null = null; + +/** How long an open may run before we stop believing in it. A clone of a large + * repo is slow; a wedged IPC is forever, and the two have to be told apart. */ +const OPEN_TIMEOUT_MS = 10 * 60_000; + +/** Answer a click that arrives while another open is running. Never silent. */ +function busy(fullName: string): boolean { + if (!current) return false; + if (current.fullName === fullName) current.reopen(); + else toast(`Still opening ${current.fullName} — one at a time.`, "info"); + return true; +} + +/** One-click open. Respects ask-where-every-time; otherwise clones straight + * into the default folder (or `opts.dest`/`opts.name` when given). */ +export function openGhRepoInApp(fullName: string, opts: { dest?: string; name?: string } = {}): void { + if (busy(fullName)) return; // one at a time — a second click mid-clone is a misfire + if (opts.dest) { + run(fullName, opts); + return; + } + void host + .invoke("settings:get", undefined) + .then((v) => { + if (v.askWhereEveryTime) openGhRepoChooseLocation(fullName); + else run(fullName, opts); + }) + .catch(() => run(fullName, opts)); +} + +/** The "Choose location…" path: ask where first, then open. */ +export function openGhRepoChooseLocation(fullName: string, opts: { name?: string; note?: string } = {}): void { + if (busy(fullName)) return; + openDestinationSheet( + fullName, + (choice) => run(fullName, { dest: choice.dest, name: choice.name }), + { name: opts.name, note: opts.note }, + ); +} + +function run(fullName: string, opts: { dest?: string; name?: string }): void { + if (busy(fullName)) return; + closePeek(); + + let done = false; + let close = (): void => {}; + let phaseEl: HTMLElement | undefined; + let fillEl: HTMLElement | undefined; + let destDisplay = opts.dest ?? ""; + + // The card's copy names the real destination; fetch the pretty form of the + // default when no override was given (fire-and-forget — the card may not + // even appear). + if (!opts.dest) { + void host + .invoke("settings:get", undefined) + .then((v) => { + destDisplay = v.cloneDirDisplay; + if (subEl) subEl.textContent = cloneCopy(); + }) + .catch(() => {}); + } + let subEl: HTMLElement | undefined; + const cloneCopy = (): string => + destDisplay + ? `First open clones it into ${destDisplay} — after that it's instant.` + : "First open clones it — after that it's instant."; + + let cardOpen = false; + const showCard = (): void => { + if (done || cardOpen) return; + cardOpen = true; + openModal((c) => { + close = c; + const card = el("div", "modal-card ghopen-card"); + card.tabIndex = -1; + const title = el("div", "modal-title"); + title.textContent = `Opening ${fullName}…`; + const sub = el("div", "modal-message"); + sub.textContent = cloneCopy(); + subEl = sub; + const phase = el("div", "clone-progress-phase"); + phase.textContent = "Preparing…"; + const bar = el("div", "clone-progress-bar"); + const fill = el("div", "clone-progress-fill"); + bar.appendChild(fill); + phaseEl = phase; + fillEl = fill; + card.append(title, sub, phase, bar); + // Esc/backdrop just hides the card — the clone keeps going and the app + // flips to the repo the moment it lands (repo:changed). Clicking the same + // repo again brings this card back, so dismissing it is undo-able. + return { + card, + focusEl: card, + label: `Opening ${fullName}`, + onClose: () => { + cardOpen = false; + phaseEl = undefined; + fillEl = undefined; + subEl = undefined; + }, + }; + }); + }; + const timer = window.setTimeout(showCard, 250); + current = { fullName, reopen: showCard }; + + // A never-settling `ghrepo:open` used to disable one-click open for the rest + // of the session. Let go after a bound, and say so. + const giveUp = window.setTimeout(() => { + if (done) return; + finish(); + toast(`Gave up waiting for ${fullName}. It may still be cloning — check your clone folder.`, "error"); + }, OPEN_TIMEOUT_MS); + + const offProgress = host.on("clone:progress", (p) => { + if (phaseEl && (p.phase || p.raw)) phaseEl.textContent = p.phase || p.raw || ""; + if (fillEl && typeof p.percent === "number") fillEl.style.width = `${p.percent}%`; + }); + + const finish = (): void => { + if (done) return; + done = true; + current = null; + window.clearTimeout(timer); + window.clearTimeout(giveUp); + offProgress(); + close(); + }; + + host + .invoke("ghrepo:open", { fullName, dest: opts.dest, name: opts.name }) + .then((r) => { + finish(); + if (r.ok) { + toast( + r.cloned + ? `Cloned ${fullName} into ${destDisplay || "your clone folder"} and opened it.` + : `Opened ${fullName}.`, + "success", + ); + return; + } + // A folder COLLISION is fixable in place: reopen the sheet prefilled + // with an alternative name. Everything else is just the error. + if (r.code === "collision") { + openDestinationSheet( + fullName, + (choice) => run(fullName, { dest: choice.dest, name: choice.name }), + { + name: suggestAltName(fullName), + // …and the folder they CHOSE, not the configured default. Reopening + // with only a name let the sheet fall back to the default, so + // confirming a collision retry quietly relocated the clone — on the + // one path whose whole premise is that they picked somewhere else. + dest: opts.dest, + note: r.message || "That folder already exists — pick another spot or name.", + }, + ); + return; + } + toast(r.message || `Couldn't open ${fullName}.`, "error"); + }) + .catch((e) => { + finish(); + toast(cleanErr(e) || `Couldn't open ${fullName}.`, "error"); + }); +} + +/** "owner-repo" — the collision-retry suggestion (distinct from the default). */ +function suggestAltName(fullName: string): string { + const [owner, repo] = fullName.split("/", 2); + return owner && repo ? `${owner}-${repo}` : fullName.replace(/\//g, "-"); +} diff --git a/apps/desktop/src/renderer/graphMount.ts b/apps/desktop/src/renderer/graphMount.ts index 4cbf6d1..6c0c590 100644 --- a/apps/desktop/src/renderer/graphMount.ts +++ b/apps/desktop/src/renderer/graphMount.ts @@ -15,6 +15,11 @@ export interface GraphCallbacks { onContext(sha: string, x: number, y: number): void; /** The already-selected row was clicked again — reveal the details pane. */ onShowDetails(sha: string): void; + /** A branch/remote/tag chip on a row was clicked — navigate to that ref. */ + onRefClick(name: string, kind: string): void; + /** The history is empty (or stopped being). Lets the shell stand the details + * pane down instead of asking you to select a commit that doesn't exist. */ + onEmpty?(empty: boolean): void; } export class GraphMount { @@ -40,6 +45,9 @@ export class GraphMount { case "context": cb.onContext(action.sha, action.x, action.y); break; + case "refClick": + cb.onRefClick(action.name, action.kind); + break; case "loadMore": this.adapter.loadMore().catch(() => { /* a paging failure is non-fatal; keep what's already shown */ @@ -51,8 +59,18 @@ export class GraphMount { case "requestStats": void host .invoke("commit:rowStats", action.shas) - .then((stats) => this.element.setRowStats(stats)) - .catch(() => {}); + // A short answer is as bad as no answer: anything the host did not + // return stays pending, and a pending sha renders no CHANGES cell. + // Release the whole batch, keep what came back. + .then((stats) => { + this.element.setRowStats(stats); + // Anything the host did not return is an ANSWER of "no stats for + // this commit" — recorded, not re-asked. + this.element.failRowStats(action.shas, true); + }) + // An ERROR is worth retrying, but not immediately: `false` releases + // the shas without recording them, and the next repaint asks. + .catch(() => this.element.failRowStats(action.shas, false)); break; } }; @@ -71,8 +89,10 @@ export class GraphMount { // A genuinely empty history gets the crafted tile, not the shared // element's bare "No commits yet" — consistent with every other view. this.renderEmpty(); + cb.onEmpty?.(true); } else { this.element.status = message.rows.length === 0 ? "empty" : "ready"; + cb.onEmpty?.(false); } break; case "graphAppend": @@ -94,8 +114,13 @@ export class GraphMount { * On failure, render an in-view error + Retry instead of spinning forever. */ async reload(): Promise<void> { this.container.replaceChildren(this.element); + // Keep the rows you are looking at. Blanking them turned Refresh into a + // re-mount: the history vanished, a spinner took the whole pane, your scroll + // position and selection went with it — for an operation that usually + // returns the same list plus one commit. The graph's own placeholder only + // appears when there is nothing to show, so `status = "loading"` over a + // populated list marks the toolbar busy and leaves the list alone. this.element.status = "loading"; - this.element.rows = []; try { await this.adapter.loadInitial(); } catch (err) { @@ -128,7 +153,16 @@ export class GraphMount { if (action) { const btn = document.createElement("button"); btn.className = `${action.primary ? "btn btn-primary" : "mini-btn"} list-empty-action`; - btn.innerHTML = `<span class="glyph codicon codicon-${action.icon}"></span><span>${action.label}</span>`; + // Built as nodes, not as an HTML string. Both callers pass literals today, + // so this is not a live hole — but a template that interpolates a label + // straight into innerHTML becomes one the moment somebody passes a branch + // name or an error string through it, and the label right above this is + // already `desc`, which IS remote text. + const gi = document.createElement("span"); + gi.className = `glyph codicon codicon-${action.icon}`; + const gl = document.createElement("span"); + gl.textContent = action.label; + btn.append(gi, gl); btn.addEventListener("click", action.onClick); wrap.appendChild(btn); } @@ -168,9 +202,11 @@ export class GraphMount { this.element.status = "empty"; } - /** Select + scroll a commit (e.g. a branch tip) into view. */ - reveal(sha: string): void { - this.element.reveal(sha); + /** Select + scroll a commit (e.g. a branch tip) into view. + * Returns whether the row was found — the graph holds only its loaded + * pages, and a commit further back cannot be revealed at all. */ + reveal(sha: string): boolean { + return this.element.reveal(sha); } /** Detach the Lit element so its disconnectedCallback tears down listeners. */ diff --git a/apps/desktop/src/renderer/highlight.ts b/apps/desktop/src/renderer/highlight.ts new file mode 100644 index 0000000..a389989 --- /dev/null +++ b/apps/desktop/src/renderer/highlight.ts @@ -0,0 +1,124 @@ +// Syntax highlighting for prose code fences and the remote file quick-look. +// +// github.com highlights every fenced block in every README, PR body, and +// release note; we rendered them all monochrome — a constant, glaring +// readability gap. Monaco is already in this bundle for the diff views, and +// `monaco.editor.colorize` reuses its tokenizers + the app-native theme, so +// highlighting costs nothing new. Output is Monaco-generated token spans over +// escaped text — safe to inject. + +import * as monaco from "monaco-editor"; +import { languageForFile } from "@gitstudio/webview-ui/language"; +import { ensureNativeTheme } from "@gitstudio/webview-ui/theme"; +import { bootMonaco } from "./monacoBoot"; + +/** Fence-info → Monaco language id, for the aliases people actually type. */ +const FENCE_LANG: Record<string, string> = { + js: "javascript", jsx: "javascript", javascript: "javascript", node: "javascript", + ts: "typescript", tsx: "typescript", typescript: "typescript", + py: "python", python: "python", + rb: "ruby", ruby: "ruby", + sh: "shell", bash: "shell", zsh: "shell", shell: "shell", console: "shell", + json: "json", jsonc: "json", json5: "json", + yaml: "yaml", yml: "yaml", + toml: "ini", ini: "ini", + css: "css", scss: "scss", less: "less", + html: "html", xml: "xml", svg: "xml", vue: "html", + md: "markdown", markdown: "markdown", + sql: "sql", + go: "go", golang: "go", + rust: "rust", rs: "rust", + java: "java", + kotlin: "kotlin", kt: "kotlin", + swift: "swift", + c: "c", h: "c", + cpp: "cpp", "c++": "cpp", cc: "cpp", hpp: "cpp", + cs: "csharp", csharp: "csharp", + php: "php", + diff: "diff", patch: "diff", + dockerfile: "dockerfile", docker: "dockerfile", + graphql: "graphql", gql: "graphql", + lua: "lua", perl: "perl", r: "r", scala: "scala", dart: "dart", + powershell: "powershell", ps1: "powershell", + txt: "", text: "", plain: "", plaintext: "", +}; + +function monacoLangFor(hint: string): string | undefined { + const h = hint.toLowerCase(); + const mapped = FENCE_LANG[h]; + if (mapped !== undefined) return mapped || undefined; + // Try the alias as a file extension via the shared mapping. + const viaExt = languageForFile(`x.${h}`); + if (viaExt && viaExt !== "plaintext") return viaExt; + // Monaco may know the id directly (e.g. "objective-c"). + return monaco.languages.getLanguages().some((l) => l.id === h) ? h : undefined; +} + +let themed = false; +/** Re-derive the Monaco token theme from the live CSS variables — call after a + * theme switch so already-highlighted blocks re-color (the token classes are + * global; redefining the theme restyles every existing span in place). */ +export function refreshHighlightTheme(): void { + try { + monaco.editor.setTheme(ensureNativeTheme()); + themed = true; + } catch { + /* highlighting is decoration — never let it throw into a render */ + } +} + +async function colorize(text: string, lang: string): Promise<string | undefined> { + bootMonaco(); + if (!themed) refreshHighlightTheme(); + try { + const html = await monaco.editor.colorize(text, lang, { tabSize: 2 }); + // REAL SPACES. Monaco writes indentation as non-breaking spaces, which + // render identically inside a `<pre>` (both containers that use this are + // whitespace-preserving) but copy as U+00A0 — so a snippet pasted out of + // this app into a terminal, a file or a chat carried non-breaking spaces + // where its indentation used to be. Python and YAML break outright; a diff + // of the pasted text is unreadable. + // + // It emits the CHARACTER, not the entity: `sb.appendCharCode(0xA0)` in + // viewLineRenderer.js, three times. Replacing ` ` — which is what the + // comment here used to claim it emitted — matched nothing at all, and the + // paste stayed broken while this line looked like it had fixed it. + return html?.replace(/\u00a0/g, " "); + } catch { + return undefined; + } +} + +/** Don't tokenize monsters; a fence this big is a data dump, not prose. */ +const MAX_HIGHLIGHT_CHARS = 100_000; + +/** Highlight every ```lang fence inside a rendered-markdown container. + * Fire-and-forget per block; monochrome is the graceful fallback. */ +export function highlightProse(container: HTMLElement): void { + const blocks = container.querySelectorAll<HTMLElement>('pre > code[class*="language-"]'); + for (const code of blocks) { + if (code.dataset.hl) continue; // already done (observer re-entry) + const m = /language-([\w+.-]+)/.exec(code.className); + if (!m) continue; + const lang = monacoLangFor(m[1]); + if (!lang) continue; + const text = code.textContent ?? ""; + if (!text.trim() || text.length > MAX_HIGHLIGHT_CHARS) continue; + code.dataset.hl = "1"; + void colorize(text, lang).then((html) => { + if (html && code.isConnected) code.innerHTML = html; + }); + } +} + +/** Highlight one code element in place from its file name (remote file view). */ +export async function highlightCode( + codeEl: HTMLElement, + text: string, + fileName: string, +): Promise<void> { + const lang = languageForFile(fileName); + if (!lang || lang === "plaintext" || text.length > MAX_HIGHLIGHT_CHARS) return; + const html = await colorize(text, lang); + if (html && codeEl.isConnected) codeEl.innerHTML = html; +} \ No newline at end of file diff --git a/apps/desktop/src/renderer/logModel.ts b/apps/desktop/src/renderer/logModel.ts new file mode 100644 index 0000000..5df5ee1 --- /dev/null +++ b/apps/desktop/src/renderer/logModel.ts @@ -0,0 +1,270 @@ +// The pure model behind the log pane — parsing, incremental append, ANSI. +// DOM-free by design so every rule here unit-tests under node (test/logModel). +// +// GitHub Actions job logs are plain text where each line is prefixed with an +// ISO timestamp, and workflow commands ride inline: +// 2026-08-25T10:00:42.1234567Z ##[group]Run npm ci +// 2026-08-25T10:00:43.0000000Z npm WARN deprecated … +// 2026-08-25T10:01:02.0000000Z ##[endgroup] +// 2026-08-25T10:01:02.5000000Z ##[error]Process completed with exit code 1. +// ANSI SGR sequences appear inside line text (colors from the tools). + +export type LineKind = + | "plain" + | "error" + | "warning" + | "notice" + | "command" + | "debug" + | "group" + | "endgroup" + | "section"; + +export interface LogLine { + /** The line's text with timestamp + ##[…] marker stripped (ANSI kept). */ + text: string; + /** The leading ISO timestamp, "" when the line has none. */ + ts: string; + kind: LineKind; +} + +export interface LogGroup { + /** Line index of the ##[group] header. */ + start: number; + /** Line index of the matching ##[endgroup], or -1 while still open. */ + end: number; +} + +export interface LogDoc { + lines: LogLine[]; + groups: LogGroup[]; + /** A trailing partial line (no newline yet) — re-parsed on the next append. */ + danglingTail: string; +} + +const TS_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)\s?/; +const CMD_RE = /^##\[(group|endgroup|error|warning|notice|command|debug|section)\](.*)$/; + +export function emptyLogDoc(): LogDoc { + return { lines: [], groups: [], danglingTail: "" }; +} + +/** + * Apply carriage returns the way a terminal does: `\r` returns the cursor to + * column 0 and what follows OVERWRITES what was there. + * + * Every CI tool that draws a progress bar — npm, pip, docker, gradle, cargo — + * rewrites one logical line in place and terminates it with a single newline. + * Keeping the raw text meant the pane rendered + * "Downloading 0%\rDownloading 25%\rDownloading 60%\rDownloading 100%" + * as one line, and since a `\r` paints as nothing in HTML the reader saw all + * four states run together. This leaves the final state, which is what the same + * output looks like in a terminal. + * + * It is a real overwrite, not "take the last segment": a short redraw over a + * long line leaves the tail of the long one, exactly as a terminal would + * ("abcdef" then "\rxy" is "xycdef"). It also drops the stray trailing `\r` + * that CRLF logs leave on every single line. + */ +function applyCarriageReturns(raw: string): string { + if (!raw.includes("\r")) { + return raw; + } + let out = ""; + for (const seg of raw.split("\r")) { + out = seg + out.slice(seg.length); + } + return out; +} + +function classify(raw: string): LogLine { + let text = raw; + let ts = ""; + const tm = TS_RE.exec(text); + if (tm) { + ts = tm[1]; + text = text.slice(tm[0].length); + } + // AFTER the timestamp is taken off: the log service stamps once per newline, + // so a redraw segment must not be allowed to overwrite the stamp. + text = applyCarriageReturns(text); + const cm = CMD_RE.exec(text); + if (cm) { + return { text: cm[2], ts, kind: cm[1] as LineKind }; + } + return { text, ts, kind: "plain" }; +} + +/** + * Append a delta of raw text to the doc IN PLACE (the pane owns the doc; a + * fresh copy per 4s tick would churn hundreds of thousands of line objects). + * Returns the doc for chaining. A trailing partial line is held in + * `danglingTail` and re-parsed once the rest of it arrives. + */ +export function appendLog(doc: LogDoc, delta: string): LogDoc { + const text = doc.danglingTail + delta; + const parts = text.split("\n"); + doc.danglingTail = parts.pop() ?? ""; + for (const raw of parts) { + const line = classify(raw); + if (line.kind === "endgroup") { + // `##[endgroup]` carries no payload, so pushing it emitted a blank + // numbered row for every group — the log looked peppered with empty + // lines that the raw output does not contain. Close the group at the + // last real line instead of giving the marker a row of its own. + for (let g = doc.groups.length - 1; g >= 0; g--) { + if (doc.groups[g].end === -1) { + doc.groups[g].end = Math.max(doc.groups[g].start, doc.lines.length - 1); + break; + } + } + continue; + } + const idx = doc.lines.length; + doc.lines.push(line); + if (line.kind === "group") { + doc.groups.push({ start: idx, end: -1 }); + } + } + return doc; +} + +/** Parse a complete text from scratch. */ +export function parseLog(text: string): LogDoc { + return appendLog(emptyLogDoc(), text); +} + +/** Flush the dangling tail as a final line (call when the log is complete). */ +export function finishLog(doc: LogDoc): LogDoc { + if (doc.danglingTail) { + appendLog(doc, "\n"); + } + return doc; +} + +// ── ANSI (SGR) → styled spans ──────────────────────────────────────────────── + +export interface AnsiSpan { + text: string; + /** Space-joined class names ("log-fg-9 log-b"), "" for plain. */ + cls: string; +} + +interface SgrState { + fg: number; // -1 = default, 0..15 = palette index + bg: number; + bold: boolean; + dim: boolean; + italic: boolean; + underline: boolean; +} + +const SGR_DEFAULT: SgrState = { fg: -1, bg: -1, bold: false, dim: false, italic: false, underline: false }; + +/** Nearest 16-color palette index for a 256-color code. */ +function xterm256To16(n: number): number { + if (n < 16) return n; + if (n >= 232) { + // Grayscale ramp: dark half → black/bright-black, light half → white-ish. + return n < 244 ? 8 : n < 250 ? 7 : 15; + } + // 6×6×6 cube → pick by dominant channels. + const c = n - 16; + const r = Math.floor(c / 36); + const g = Math.floor((c % 36) / 6); + const b = c % 6; + const bright = r + g + b >= 9 ? 8 : 0; + if (r >= g && r >= b) return g >= 3 && r >= 3 ? 3 + bright : 1 + bright; // yellow-ish vs red + if (g >= r && g >= b) return b >= 3 && g >= 3 ? 6 + bright : 2 + bright; // cyan-ish vs green + return r >= 3 && b >= 3 ? 5 + bright : 4 + bright; // magenta-ish vs blue +} + +/** Nearest 16-color index for a truecolor RGB. */ +function rgbTo16(r: number, g: number, b: number): number { + // A saturated channel near full brightness reads as the BRIGHT variant — + // vivid (255,40,40) is bright red, muddy (128,0,0) is plain red. + const bright = Math.max(r, g, b) >= 224 ? 8 : 0; + if (Math.max(r, g, b) - Math.min(r, g, b) < 32) return r > 160 ? 15 : r > 64 ? 7 : 0; + if (r >= g && r >= b) return g > r * 0.6 ? 3 + bright : 1 + bright; + if (g >= r && g >= b) return b > g * 0.6 ? 6 + bright : 2 + bright; + return r > b * 0.6 ? 5 + bright : 4 + bright; +} + +function clsOf(st: SgrState): string { + const parts: string[] = []; + if (st.fg >= 0) parts.push(`log-fg-${st.fg}`); + if (st.bg >= 0) parts.push(`log-bg-${st.bg}`); + if (st.bold) parts.push("log-b"); + if (st.dim) parts.push("log-dim"); + if (st.italic) parts.push("log-i"); + if (st.underline) parts.push("log-u"); + return parts.join(" "); +} + +// Any ESC-initiated sequence; SGR (ending in "m") is interpreted, the rest +// (cursor moves, erase, OSC titles…) are stripped. +// eslint-disable-next-line no-control-regex +const ESC_RE = /\x1b(?:\[([0-9;]*)m|\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_])/g; + +/** Split one line of raw text into styled spans, interpreting SGR sequences. */ +export function parseAnsi(line: string): AnsiSpan[] { + const spans: AnsiSpan[] = []; + let st: SgrState = { ...SGR_DEFAULT }; + let last = 0; + const push = (end: number): void => { + if (end > last) { + const text = line.slice(last, end); + const cls = clsOf(st); + const prev = spans[spans.length - 1]; + if (prev && prev.cls === cls) prev.text += text; + else spans.push({ text, cls }); + } + }; + ESC_RE.lastIndex = 0; + for (let m = ESC_RE.exec(line); m; m = ESC_RE.exec(line)) { + push(m.index); + last = m.index + m[0].length; + if (m[1] === undefined) continue; // non-SGR escape — stripped + const codes = m[1] === "" ? [0] : m[1].split(";").map((c) => Number(c || "0")); + for (let i = 0; i < codes.length; i++) { + const c = codes[i]; + if (c === 0) st = { ...SGR_DEFAULT }; + else if (c === 1) st.bold = true; + else if (c === 2) st.dim = true; + else if (c === 3) st.italic = true; + else if (c === 4) st.underline = true; + else if (c === 22) { st.bold = false; st.dim = false; } + else if (c === 23) st.italic = false; + else if (c === 24) st.underline = false; + else if (c >= 30 && c <= 37) st.fg = c - 30; + else if (c === 39) st.fg = -1; + else if (c >= 90 && c <= 97) st.fg = c - 90 + 8; + else if (c >= 40 && c <= 47) st.bg = c - 40; + else if (c === 49) st.bg = -1; + else if (c >= 100 && c <= 107) st.bg = c - 100 + 8; + else if (c === 38 || c === 48) { + const isFg = c === 38; + const mode = codes[i + 1]; + if (mode === 5 && codes.length > i + 2) { + const idx = xterm256To16(codes[i + 2]); + if (isFg) st.fg = idx; else st.bg = idx; + i += 2; + } else if (mode === 2 && codes.length > i + 4) { + const idx = rgbTo16(codes[i + 2], codes[i + 3], codes[i + 4]); + if (isFg) st.fg = idx; else st.bg = idx; + i += 4; + } + } + // Everything else: ignored (rare in CI logs). + } + } + push(line.length); + if (spans.length === 0) spans.push({ text: "", cls: "" }); + return spans; +} + +/** Strip every escape sequence — the searchable/copyable plain text. */ +export function stripAnsi(line: string): string { + ESC_RE.lastIndex = 0; + return line.replace(ESC_RE, ""); +} diff --git a/apps/desktop/src/renderer/logView.ts b/apps/desktop/src/renderer/logView.ts new file mode 100644 index 0000000..17dde28 --- /dev/null +++ b/apps/desktop/src/renderer/logView.ts @@ -0,0 +1,963 @@ +// The log pane — a virtualized, ANSI-aware, foldable, searchable, live-tail +// log surface (docs/desktop-redesign.md "Depth guarantees"). One pane per job; +// The log is a PAGE now (views/jobLog.ts), not a pane re-slotted into a run +// page's job cards on every repaint — so there is no save/restoreViewport +// either; nothing re-slots a pane any more. +// +// Rendering model: every line is a fixed --log-line-h row; a `visible` array +// maps render positions → doc line indices (lines inside collapsed ##[group] +// ranges drop out). Only the scrolled-into-view window (± overscan) exists in +// the DOM — top/bottom spacer divs carry the rest of the height, so a 200k-line +// log costs ~120 nodes. + +import { + appendLog, + emptyLogDoc, + finishLog, + parseAnsi, + stripAnsi, + type LogDoc, + type LogGroup, + type LogLine, +} from "./logModel"; +import { el, glyph, span } from "./ui"; +import { searchField } from "./views/common"; + +const LINE_H = 20; +const OVERSCAN = 30; +/** Render-window guardrail — beyond this we keep the newest lines + a banner. */ +export const MAX_RENDER_LINES = 200_000; + +export interface LogPane { + el: HTMLElement; + /** Replace the whole content (initial load, or a live-tail reset). */ + reset(text: string, o?: { truncated?: boolean }): void; + /** Append a live-tail delta. */ + append(delta: string): void; + /** The log's producer finished — flush the last partial line. */ + finish(): void; + /** + * The producer STARTED after the pane was built. + * + * A job opened while queued is created with `live: false`, so it has nothing + * to follow and Follow is correctly dead. When the runner picks it up the + * tail starts — but the pane was never told, so Follow stayed disabled and + * "Jump to latest" never appeared for the whole rest of the run. Liveness is + * not decided once; it is a state the job moves through. + */ + setProducing(on: boolean): void; + setFollow(on: boolean): void; + /** Put the keyboard in the log itself, without moving the viewport. */ + focusReader(): void; + destroy(): void; +} + +export function createLogPane(o: { + ariaLabel: string; + onCopy: () => string | Promise<string>; + onDownload?: () => void; + /** + * The pane IS the page: it fills its container instead of capping itself, and + * its expand control widens it over the job list rather than growing a box. + * + * A log read inside a pane on a scrolling page gets whatever height is left + * over — measured at 523px of a 913px window — and that is the "the log + * window is too small" report. On its own route there is nothing to leave + * over. + */ + fill?: boolean; + /** + * The producer is still running, so start following the tail. + * + * "following active logging is one thing, but scrolling super fast or instead + * of me is pure ragebait." Following a FINISHED log is not following, it is + * just jumping you to the end of a document you have not read yet. + */ + live?: boolean; + /** + * The job exists but no runner has picked it up. Distinct from `live: false`, + * which on its own cannot tell "finished" from "not started". + */ + queued?: boolean; +}): LogPane { + let doc: LogDoc = emptyLogDoc(); + const collapsed = new Set<number>(); // group START line indices + let visible: number[] = []; + // Following is a MODE, and it belongs to the caller: a job that will never + // produce another byte has nothing to follow, and arming it there is what + // slammed every log you opened straight to its last line. + let follow = !!o.live; + /** The producer is still running, so "the tail moved on without you" is a + * thing that can happen. On a finished log it cannot, and a pill offering to + * jump to a latest that is not moving is just an unlabelled End key. */ + let producing = !!o.live; + /** + * The job has not been picked up by a runner yet. + * + * "Not producing" covers two OPPOSITE situations — finished, and not started + * — and they want opposite words. The pane cannot tell them apart on its own + * (both are simply `live: false`), and inferring it from "has this pane ever + * produced" gets a job that was already finished when opened exactly + * backwards. So the caller, which knows the job's status, says. + */ + let notStarted = !!o.queued; + let showTs = false; + let capped = false; // over MAX_RENDER_LINES — oldest dropped + /** + * How many lines have been spliced off the FRONT by the cap. + * + * The gutter numbers a row by its index in `doc.lines`, which restarts at 0 + * every time the cap drops lines — so a capped log numbered its first visible + * row "1" when it was really line 50,001, and every error tick's "Error on + * line N" named a line 50,000 rows from the one it pointed at. The number in + * the gutter has to be the line's number in the JOB'S output, not its offset + * into the window we happen to be holding. + */ + let droppedLines = 0; + let truncatedTail = false; // main sent only the 8MB tail window + let query = ""; + let matches: number[] = []; // doc line indices + let matchIdx = -1; + let raf = 0; + let rafTimer = 0; + let destroyed = false; + + const root = el("div", "log-pane" + (o.fill ? " log-fill" : "")); + root.setAttribute("role", "region"); + root.setAttribute("aria-label", o.ariaLabel); + + // ── toolbar ── + const bar = el("div", "log-toolbar"); + const errChip = el("button", "log-chip log-chip-err"); + errChip.title = "Jump between errors"; + errChip.hidden = true; + const search = searchField({ + placeholder: "Search log…", + onInput: (q) => { + query = q; + rebuildMatches(); + // HIGHLIGHT, do not travel. This used to jump the viewport to the first + // match on every keystroke, so typing "err" hard-scrolled to three + // different places before you had finished the word — the other half of + // "scrolling instead of me". Enter (and the two step buttons) go; typing + // only paints and counts. + matchIdx = -1; + matchCounter.textContent = matches.length + ? `${matches.length} match${matches.length === 1 ? "" : "es"}` + : q.trim() + ? "no matches" + : ""; + render(); + }, + }); + search.classList.add("log-search"); + const matchCounter = span("", "log-match-count"); + let matchStepSync: (() => void) | undefined; + // These were five identical unlabelled squares, and their titles never + // changed with their state — "Show timestamps" still read "Show timestamps" + // while timestamps were showing. The clock-with-arrow icon also universally + // means "history", not "timestamps". + // + // The two STATE toggles now carry their names, because a toggle you can't + // read is a toggle you can't trust — "is this log showing timestamps?" has + // to be answerable without hovering. The three transient verbs (copy, save, + // expand) stay icons: they're momentary, universally drawn, and grouped + // behind a hairline so the bar reads as [state] | [actions]. + const tsBtn = toolBtn("watch", "Show timestamps", () => { + showTs = !showTs; + tsBtn.classList.toggle("is-on", showTs); + tsBtn.title = showTs ? "Hide timestamps" : "Show timestamps"; + tsBtn.setAttribute("aria-label", tsBtn.title); + tsBtn.setAttribute("aria-pressed", String(showTs)); + render(); + }, "Timestamps"); + tsBtn.setAttribute("aria-pressed", "false"); + const followBtn = toolBtn( + "fold-down", + "Follow the newest output", + () => setFollow(!follow), + "Follow", + ) as HTMLButtonElement; + // NOT a hand-stamped "false": `follow` starts ON, so a literal here made the + // button open lit while announcing itself off. setFollow is the only writer; + // it is called once below, after it is defined, to paint the initial state. + const copyBtn = toolBtn("copy", "Copy the full log", () => { + void Promise.resolve(o.onCopy()).then((t) => navigator.clipboard.writeText(t).catch(() => {})); + }); + const dlBtn = o.onDownload ? toolBtn("cloud-download", "Save the full log to Downloads", o.onDownload) : null; + const expandTitles = o.fill + ? { on: "Show the job list", off: "Use the full width" } + : { on: "Shrink the pane", off: "Expand the pane" }; + const expandBtn = toolBtn("screen-full", expandTitles.off, () => { + // Resizing the pane changes its scroll height, which the scroll listener + // reads as "the user scrolled away from the bottom" and silently turns + // follow OFF, dumping you into the middle of the log. Resizing is not + // scrolling: remember the mode and restore it. + const wasFollowing = follow; + const max = root.classList.toggle("log-max"); + expandBtn.title = max ? expandTitles.on : expandTitles.off; + expandBtn.setAttribute("aria-label", expandBtn.title); + render(); + // Expanding to 78vh while the pane sits ~320px down the page pushed its + // tail — the error line, the toolbar's own controls — below the fold, so + // "expand" made the thing you wanted LESS visible. Bring it into view. + // On a filled page there is nothing to scroll to: the pane is the page. + if (max && !o.fill) root.scrollIntoView({ block: "start", behavior: "smooth" }); + if (wasFollowing) setFollow(true); + }); + // Stepping through matches was Enter-only and unadvertised, so a search that + // found 40 hits gave you the first one and no way to reach the other 39 + // unless you guessed. Two buttons, disabled until there is something to step. + const prevMatch = toolBtn("chevron-up", "Previous match (Shift+Enter)", () => + jumpToMatch(matchIdx < 0 ? matches.length - 1 : matchIdx - 1), + ); + const nextMatch = toolBtn("chevron-down", "Next match (Enter)", () => + jumpToMatch(matchIdx < 0 ? 0 : matchIdx + 1), + ); + prevMatch.classList.add("log-match-step"); + nextMatch.classList.add("log-match-step"); + const syncMatchSteps = (): void => { + // Enabled from ONE match, not two: typing no longer travels to the first + // hit, so with a single match the step button is the only way to reach it. + for (const b of [prevMatch, nextMatch]) (b as HTMLButtonElement).disabled = matches.length < 1; + }; + syncMatchSteps(); + matchStepSync = syncMatchSteps; + + bar.append( + errChip, + search, + matchCounter, + prevMatch, + nextMatch, + span("", "log-toolbar-spring"), + tsBtn, + followBtn, + el("span", "log-toolbar-div"), + copyBtn, + ); + if (dlBtn) bar.appendChild(dlBtn); + bar.appendChild(expandBtn); + root.appendChild(bar); + + // ── banners + scroller ── + const banner = el("div", "log-banner"); + banner.hidden = true; + root.appendChild(banner); + const scroll = el("div", "log-scroll"); + // A log is a document you READ, so it has to be able to take the keyboard. + // Without a tabindex the scroller was unreachable by Tab and answered no key + // at all: the only way through 50,000 lines was a trackpad, against a + // sixteen-line port. `role="log"` tells assistive tech what it is, and + // `aria-label` names which job's output this is. + scroll.tabIndex = 0; + scroll.setAttribute("role", "log"); + scroll.setAttribute("aria-label", "Job log"); + const top = el("div", "log-spacer"); + const win = el("div", "log-window"); + const bottom = el("div", "log-spacer"); + scroll.append(top, win, bottom); + // The scroller and the two things that FLOAT over it share a positioned + // wrapper. Both were briefly children of the scroller itself, where a sticky + // element that is the last child sticks only once its own place scrolls into + // view — i.e. at the very end of a 20,000-line log, which is nowhere. + const body = el("div", "log-body"); + body.appendChild(scroll); + // Which ##[group] the top of the port is inside. A CI log is mostly group + // CONTENTS, and scrolling past the header that named them leaves you reading + // 400 lines of output with no idea which step produced it — the other half of + // "not easy to use and practical at all". Click it to jump back to its header. + const groupBar = el("button", "log-groupbar"); + groupBar.hidden = true; + groupBar.title = "Jump to the start of this step"; + body.appendChild(groupBar); + // Where the errors ARE, over the whole log rather than the screenful you can + // see. A 20,000-line log has no shape without it: you scroll and hope. Each + // tick is a click that lands on that failure. + const errMap = el("div", "log-errmap"); + errMap.setAttribute("aria-hidden", "true"); // the error chip + `n` are the accessible path + body.appendChild(errMap); + root.appendChild(body); + const jumpPill = el("button", "log-jump"); + jumpPill.append(glyph("arrow-down"), span("Jump to latest")); + jumpPill.hidden = true; + jumpPill.addEventListener("click", () => setFollow(true)); + root.appendChild(jumpPill); + + /** An icon button; pass `label` to spell the control out beside its glyph. */ + function toolBtn( + icon: string, + title: string, + onClick: () => void, + label?: string, + ): HTMLElement { + const b = el("button", "icon-btn log-tool" + (label ? " has-label" : "")); + b.title = title; + b.setAttribute("aria-label", title); + b.appendChild(glyph(icon)); + if (label) b.appendChild(span(label, "log-tool-label")); + b.addEventListener("click", onClick); + return b; + } + + /** Is the viewport already at the tail? */ + function atTail(): boolean { + return scroll.scrollTop + scroll.clientHeight >= scroll.scrollHeight - LINE_H; + } + + /** + * The Follow button's own state, and the pill's. + * + * A finished job has nothing to follow, so the button is DISABLED and says + * why. It used to stay enabled, reporting `aria-pressed="false"` before and + * after — while a press jumped you to the last line. A control that performs + * End under a label reading Follow, and then denies having done anything, is + * worse than one that refuses. + */ + function syncFollowBtn(): void { + followBtn.disabled = !producing; + followBtn.classList.toggle("is-on", follow); + followBtn.title = !producing + ? notStarted + ? "This job hasn't started yet — there is nothing to follow" + : "This job has finished — there is nothing left to follow" + : follow + ? "Following the newest output" + : "Follow the newest output"; + followBtn.setAttribute("aria-label", followBtn.title); + followBtn.setAttribute("aria-pressed", String(follow)); + // The pill offers to take you to a tail that has moved on WITHOUT you. It + // needs a moving tail (producing), the reader to be away from it, and + // follow to be off — the third alone put a "Jump to latest" over a reader + // sitting on the last line, pointing at the row under their cursor. + jumpPill.hidden = follow || !producing || visible.length === 0 || atTail(); + } + + function setFollow(on: boolean): void { + // You cannot follow a producer that has stopped. The button is disabled + // there, so this only guards the keyboard and programmatic callers. + if (on && !producing) { + syncFollowBtn(); + return; + } + follow = on; + syncFollowBtn(); + if (on) { + scroll.scrollTop = scroll.scrollHeight; + render(); + syncFollowBtn(); + } + } + + // Paint the initial state through the ONE writer, so what the button looks + // like and what it announces can never start out disagreeing. + followBtn.classList.toggle("is-on", follow); + followBtn.title = follow ? "Following the newest output" : "Follow the newest output"; + followBtn.setAttribute("aria-label", followBtn.title); + followBtn.setAttribute("aria-pressed", String(follow)); + + // Scrolling AWAY from the bottom stops following — that is the reader saying + // "stop moving". Scrolling BACK to the bottom does NOT start it again: it + // used to, so reading to the end of a live log silently re-armed the tail and + // the next 4-second poll yanked you away from the line you were on. Following + // resumes only when the reader asks: the Follow button, or the pill. + scroll.addEventListener("scroll", () => scheduleScrollFrame()); + + /** + * Damped wheel scrolling. + * + * "scrolling super fast ... is pure ragebait." A 20px line against a trackpad + * flick — which delivers 2,000-4,000px of momentum — is a hundred-plus lines + * of monospace going past with nothing readable on the way. Native speed is + * tuned for prose and images, not for a wall of fixed-width text you are + * SCANNING. Halving it is the difference between skimming and teleporting, + * and a single event can never move more than one screenful however large a + * delta the OS synthesises. + * + * Pixel-mode, vertical-dominant events only: line/page mode (some mice), + * horizontal intent, and zoom gestures are left entirely alone. + */ + const WHEEL_SCALE = 0.45; + scroll.addEventListener( + "wheel", + (e) => { + if (e.ctrlKey || e.metaKey || e.altKey) return; // zoom / OS gestures + if (e.deltaMode !== 0) return; // not pixels — leave it native + if (Math.abs(e.deltaX) >= Math.abs(e.deltaY)) return; // horizontal intent + if (!e.deltaY) return; + e.preventDefault(); + const step = Math.sign(e.deltaY) * Math.min(Math.abs(e.deltaY) * WHEEL_SCALE, scroll.clientHeight); + scroll.scrollTop += step; + // SCROLLING UP IS AN ANSWER. The dead band that decides "still at the + // bottom" is two lines deep, and the wheel is damped to 0.45 — so one + // notch on a trackpad moves less than that and the paint below leaves + // `follow` armed. The next tail poll then pulls the reader straight back + // down: they scrolled away, and four seconds later they were at the + // bottom again with nothing to say why. + // + // The keyboard already disarms on intent rather than on distance. This + // makes the wheel say the same thing. + if (e.deltaY < 0 && follow) setFollow(false); + scheduleScrollFrame(); + }, + { passive: false }, + ); + + /** + * Repaint the window after a scroll — on the next frame, or on a short timer + * if no frame comes. + * + * This was rAF alone. A window that is occluded, minimised, or otherwise not + * being composited is served NO frames, and a virtualized log whose repaint + * only ever runs inside rAF then shows the lines from wherever it last + * painted while the scrollbar says something else. The frame is the fast + * path; it must not be the only one. + */ + function scheduleScrollFrame(): void { + if (raf || rafTimer) return; + const paint = (): void => { + if (raf) cancelAnimationFrame(raf); + if (rafTimer) window.clearTimeout(rafTimer); + raf = 0; + rafTimer = 0; + if (destroyed) return; + const atBottom = scroll.scrollTop + scroll.clientHeight >= scroll.scrollHeight - LINE_H * 2; + // Leaving the bottom stops the tail. Returning to it does NOT restart the + // tail — it only takes the pill away, because there is nothing left to + // jump to. Route the disarm through setFollow: flipping the class by hand + // here is how the button came to render as ON while its own tooltip and + // aria-pressed still said OFF. + if (follow && !atBottom) setFollow(false); + if (!follow) syncFollowBtn(); + render(); + }; + raf = requestAnimationFrame(paint); + rafTimer = window.setTimeout(paint, 80); + } + + function groupOf(startIdx: number): { start: number; end: number } | undefined { + for (const g of doc.groups) if (g.start === startIdx) return { start: g.start, end: g.end === -1 ? doc.lines.length - 1 : g.end }; + return undefined; + } + + function rebuildVisible(): void { + visible = []; + let skipUntil = -1; + for (let i = 0; i < doc.lines.length; i++) { + if (i <= skipUntil) continue; + visible.push(i); + if (doc.lines[i].kind === "group" && collapsed.has(i)) { + const g = groupOf(i); + if (g) skipUntil = g.end; + } + } + } + + /** + * `keep` is the line the reader had walked to, held as the LINE OBJECT rather + * than its index: a live delta re-scans the whole doc, and enforceCap may have + * dropped lines off the front, so the old index means nothing afterwards. + * Identity survives both (appendLog only pushes, the cap only splices). + * + * Without this, every 4s tick reset "2 of 11" to "11 matches" and the next + * Enter — pressed meaning "next match" — took the viewport back to match 1. + * Only a change of QUERY may throw the reader's place away. + */ + function rebuildMatches(keep?: LogLine): void { + matches = []; + matchIdx = -1; + const q = query.trim().toLowerCase(); + if (!q) { + matchCounter.textContent = ""; + matchStepSync?.(); + return; + } + for (let i = 0; i < doc.lines.length; i++) { + if (stripAnsi(doc.lines[i].text).toLowerCase().includes(q)) matches.push(i); + } + if (keep) matchIdx = matches.findIndex((i) => doc.lines[i] === keep); + matchCounter.textContent = matches.length + ? matchIdx >= 0 + ? `${matchIdx + 1} of ${matches.length}` + : `${matches.length} match${matches.length === 1 ? "" : "es"}` + : "no matches"; + matchStepSync?.(); + } + + /** The line a jump landed on, flashed until the next one. */ + let hitLine = -1; + + /** + * `align` decides where the target lands. + * + * "center" is right for a search or error hit: you want to see what is + * AROUND it. "top" is right for "go to the start of this step" — centring + * that put the group header in the middle of the port with half a screen of + * the PREVIOUS step above it, so the strip immediately relabelled itself to + * the step you had just left and the header you asked for was not at the top + * of anything. + */ + function jumpToLine(docIdx: number, align: "center" | "top" = "center"): void { + // Un-collapse any group hiding the target, then place it. + for (const g of doc.groups) { + const end = g.end === -1 ? doc.lines.length - 1 : g.end; + if (docIdx > g.start && docIdx <= end && collapsed.has(g.start)) collapsed.delete(g.start); + } + rebuildVisible(); + const pos = visible.indexOf(docIdx); + if (pos < 0) return; + // A search or error jump turns following off — so the way BACK to the tail + // has to appear, or you are stranded mid-log with no affordance. + // + // setFollow already decides this correctly, `!producing` included. The line + // that used to sit here re-derived it from `visible.length` alone and threw + // that away, so a FINISHED job grew a "Jump to latest" pill the moment you + // clicked an error tick or a search hit — offering to follow a tail that + // stopped moving before you opened the page. + setFollow(false); + scroll.scrollTop = Math.max( + 0, + align === "top" ? pos * LINE_H : pos * LINE_H - scroll.clientHeight / 2, + ); + // Centring is not enough to FIND it. A CI log is a wall of monospace, and + // an error line looks like every other line in it once it is on screen — + // which is most of what "not practical" means here. + hitLine = docIdx; + render(); + } + + function jumpToMatch(i: number): void { + if (!matches.length) return; + matchIdx = ((i % matches.length) + matches.length) % matches.length; + matchCounter.textContent = `${matchIdx + 1} of ${matches.length}`; + jumpToLine(matches[matchIdx]); + } + + // Enter / Shift+Enter walk matches from the search box. From "no match + // selected" (which is where typing now leaves you), Enter goes to the FIRST + // one rather than the second. + search.addEventListener("keydown", (e) => { + if (e.key !== "Enter") return; + e.preventDefault(); + e.stopPropagation(); + if (matchIdx < 0) jumpToMatch(e.shiftKey ? matches.length - 1 : 0); + else jumpToMatch(e.shiftKey ? matchIdx - 1 : matchIdx + 1); + }); + + function errorLines(): number[] { + const out: number[] = []; + for (let i = 0; i < doc.lines.length; i++) if (doc.lines[i].kind === "error") out.push(i); + return out; + } + /** + * The keys a person expects in a document, and two this log needs. + * + * PageUp/PageDown move by a SCREENFUL rather than a fixed number of lines, so + * the step matches whatever height the pane happens to have. `n`/`N` walk the + * failures, which is the actual question being asked of a CI log — the error + * chip could already do it, but only by mouse, and only forwards. + */ + scroll.addEventListener("keydown", (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey || e.altKey) return; + const page = Math.max(1, Math.floor(scroll.clientHeight / LINE_H) - 1) * LINE_H; + const by = (dy: number): void => { + e.preventDefault(); + // ANY deliberate move means the reader has taken over; follow-tail must + // stand down or it will yank them back. This used to disarm only on the + // way UP, so paging DOWN through a live log kept the tail armed and every + // poll snapped you past whatever you were reading. Direction is not the + // question — who is driving is. + setFollow(false); + scroll.scrollTop += dy; + }; + switch (e.key) { + case "ArrowDown": return by(LINE_H); + case "ArrowUp": return by(-LINE_H); + case "PageDown": return by(page); + case "PageUp": return by(-page); + case "Home": + e.preventDefault(); + setFollow(false); + scroll.scrollTop = 0; + return; + case "End": + e.preventDefault(); + // End is the one key that ARMS: "take me to the newest" is the whole + // meaning of it on a log. setFollow already writes the scroll. + setFollow(true); + scroll.scrollTop = scroll.scrollHeight; + return; + case "n": + case "N": { + const errs = errorLines(); + if (!errs.length) return; + e.preventDefault(); + setFollow(false); + // Shift-N walks backwards, wrapping at both ends. + errJump = e.shiftKey + ? (errJump - 1 + errs.length) % errs.length + : (errJump + 1) % errs.length; + jumpToLine(errs[errJump]); + return; + } + default: + return; + } + }); + + let errJump = -1; + errChip.addEventListener("click", () => { + const errs = errorLines(); + if (!errs.length) return; + errJump = (errJump + 1) % errs.length; + jumpToLine(errs[errJump]); + }); + + function syncBanner(): void { + const bits: string[] = []; + if (truncatedTail) bits.push("This log is larger than 8 MB — showing the most recent output. Download for the full text."); + if (capped) bits.push(`Very long log — showing the most recent ${MAX_RENDER_LINES.toLocaleString()} lines.`); + banner.hidden = bits.length === 0; + banner.textContent = bits.join(" "); + } + + function lineRow(docIdx: number): HTMLElement { + const line = doc.lines[docIdx]; + const row = el("div", `log-line log-k-${line.kind}`); + const num = el("span", "log-num"); + num.textContent = String(docIdx + 1 + droppedLines); + row.appendChild(num); + if (line.kind === "group") { + const isCollapsed = collapsed.has(docIdx); + const chev = glyph(isCollapsed ? "chevron-right" : "chevron-down"); + chev.classList.add("log-chev"); + row.appendChild(chev); + row.classList.add("log-groupline"); + // These fold whole sections of a build log and were mouse-only: a div + // with a click handler, no role, no tab stop, and no expanded state to + // read. Enter/Space now fold them like every other disclosure. + row.setAttribute("role", "button"); + row.tabIndex = 0; + row.setAttribute("aria-expanded", String(!isCollapsed)); + row.setAttribute("aria-label", `${isCollapsed ? "Expand" : "Collapse"} group: ${line.text}`); + // Which doc line this row IS, so the rebuild below can find it again. + // Deliberately `data-doc-idx` and not `data-num`: focusReturn keys its + // remembered-row identity off `[data-num]`, and a log's line numbers + // would poison that map for every list in the app. + row.dataset.docIdx = String(docIdx); + const toggle = (): void => { + // `render()` replaces the whole window of rows, so the row this + // keypress came from is DESTROYED by its own handler — focus fell to + // <body> and the next Enter went nowhere. Folding a section of a build + // log by keyboard therefore ended the keyboard's involvement. + const hadFocus = document.activeElement === row; + if (collapsed.has(docIdx)) collapsed.delete(docIdx); + else collapsed.add(docIdx); + rebuildVisible(); + render(); + if (hadFocus) { + win.querySelector<HTMLElement>(`[data-doc-idx="${docIdx}"]`)?.focus(); + } + }; + row.addEventListener("click", toggle); + row.addEventListener("keydown", (e) => { + if (e.key !== "Enter" && e.key !== " ") return; + e.preventDefault(); + toggle(); + }); + } + if (showTs && line.ts) { + const ts = el("span", "log-ts"); + ts.textContent = line.ts.replace(/^\d{4}-\d{2}-\d{2}T/, "").replace(/\.\d+Z$/, ""); + row.appendChild(ts); + } + const content = el("span", "log-text"); + const q = query.trim().toLowerCase(); + for (const sp of parseAnsi(line.text)) { + if (q && sp.text.toLowerCase().includes(q)) { + // Paint search hits inside this span. + let rest = sp.text; + while (rest.length) { + const at = rest.toLowerCase().indexOf(q); + if (at < 0) { + content.appendChild(span(rest, sp.cls)); + break; + } + if (at > 0) content.appendChild(span(rest.slice(0, at), sp.cls)); + content.appendChild(span(rest.slice(at, at + q.length), `${sp.cls} log-hit`.trim())); + rest = rest.slice(at + q.length); + } + } else { + content.appendChild(span(sp.text, sp.cls)); + } + } + row.appendChild(content); + if (docIdx === hitLine) row.classList.add("is-hit"); + return row; + } + + function render(): void { + if (destroyed) return; + const h = scroll.clientHeight || 1; + const first = Math.max(0, Math.floor(scroll.scrollTop / LINE_H) - OVERSCAN); + const last = Math.min(visible.length, Math.ceil((scroll.scrollTop + h) / LINE_H) + OVERSCAN); + top.style.height = `${first * LINE_H}px`; + bottom.style.height = `${Math.max(0, (visible.length - last) * LINE_H)}px`; + win.replaceChildren(); + // A log with nothing in it used to be a full-height black rectangle — no + // rows, no message, no banner — while the toolbar went on offering Copy, + // Save and a search box. A queued job, a job that died before printing, a + // step that produced nothing: the reader could not tell an empty log from + // one that had failed to load. + if (!visible.length) { + const note = el("div", "log-empty"); + note.textContent = producing + ? "Waiting for the first line of output…" + : notStarted + ? "This job hasn't started yet." + : "This job produced no output."; + win.appendChild(note); + } + for (let i = first; i < last; i++) win.appendChild(lineRow(visible[i])); + const errs = errorLines(); + errChip.hidden = errs.length === 0; + if (errs.length) errChip.textContent = `${errs.length} error${errs.length === 1 ? "" : "s"}`; + // The first line actually IN the port, not the first RENDERED one: `first` + // carries 30 lines of overscan above the fold, so the strip named the group + // you had already scrolled past. + // The first line actually IN the port, not the first RENDERED one: `first` + // carries 30 lines of overscan above the fold, so the strip named the step + // you had already scrolled past for the first 30 lines of every new one. + syncGroupBar(Math.min(visible.length - 1, Math.floor(scroll.scrollTop / LINE_H))); + syncErrMap(errs); + syncBanner(); + } + + /** Name the group the top of the port sits inside, or hide the strip. */ + function syncGroupBar(firstVisible: number): void { + const docIdx = visible[firstVisible]; + if (docIdx === undefined) { + groupBar.hidden = true; + return; + } + // The innermost group whose header is above us and whose end is below. + let found: LogGroup | undefined; + for (const g of doc.groups) { + if (g.start > docIdx) break; + const end = g.end === -1 ? doc.lines.length - 1 : g.end; + if (end >= docIdx) found = g; + } + // Standing ON the header needs no reminder of it. + if (!found || found.start === docIdx) { + groupBar.hidden = true; + return; + } + const label = stripAnsi(doc.lines[found.start]?.text ?? "").trim(); + if (!label) { + groupBar.hidden = true; + return; + } + groupBar.hidden = false; + groupBar.replaceChildren(glyph("chevron-up"), span(label, "log-groupbar-name")); + groupBar.onclick = () => jumpToLine(found.start, "top"); + } + + /** One tick per error, positioned by its place in the whole log. */ + function syncErrMap(errs: number[]): void { + if (errs.length === 0 || visible.length === 0) { + errMap.replaceChildren(); + errMap.hidden = true; + return; + } + errMap.hidden = false; + const pos = new Map<number, number>(); + for (let i = 0; i < visible.length; i++) pos.set(visible[i], i); + const ticks: HTMLElement[] = []; + const seen = new Set<number>(); + for (const docIdx of errs) { + const at = pos.get(docIdx); + if (at === undefined) continue; // inside a collapsed group + const pct = Math.round((at / Math.max(1, visible.length - 1)) * 1000) / 10; + const key = Math.round(pct * 2); // don't stack 40 ticks on one pixel + if (seen.has(key)) continue; + seen.add(key); + const tick = el("button", "log-errtick") as HTMLButtonElement; + // Scaled by the track MINUS the tick's own height, so 100% puts the + // tick's bottom on the map's bottom rather than its top — a `top: 100%` + // on a 3px box sits entirely outside the map, flush on the pane's border, + // which is exactly where an error on the log's last line landed. + tick.style.top = `calc(${pct / 100} * (100% - 3px))`; + tick.title = `Error on line ${docIdx + 1 + droppedLines}`; + tick.tabIndex = -1; + tick.addEventListener("click", () => jumpToLine(docIdx)); + ticks.push(tick); + } + errMap.replaceChildren(...ticks); + } + + /** Returns how many lines were dropped off the FRONT, so the caller can put + * the reader back where they were. */ + function enforceCap(): number { + if (doc.lines.length <= MAX_RENDER_LINES) return 0; + const drop = doc.lines.length - MAX_RENDER_LINES; + doc.lines.splice(0, drop); + doc.groups = doc.groups + .map((g) => ({ start: g.start - drop, end: g.end === -1 ? -1 : g.end - drop })) + .filter((g) => (g.end === -1 ? g.start >= 0 : g.end >= 0)) + .map((g) => ({ start: Math.max(0, g.start), end: g.end })); + const shifted = new Set<number>(); + for (const c of collapsed) if (c - drop >= 0) shifted.add(c - drop); + collapsed.clear(); + for (const c of shifted) collapsed.add(c); + capped = true; + droppedLines += drop; + return drop; + } + + const pane: LogPane = { + el: root, + reset(text, opts = {}) { + doc = emptyLogDoc(); + collapsed.clear(); + capped = false; + droppedLines = 0; + truncatedTail = !!opts.truncated; + appendLog(doc, text); + enforceCap(); + rebuildVisible(); + rebuildMatches(); + render(); + // Only when FOLLOWING. On an already-finished log this used to jump you + // to the last line the moment it loaded, before you had read a word. + if (follow) scroll.scrollTop = scroll.scrollHeight; + else syncFollowBtn(); + }, + append(delta) { + if (!delta) return; + // Grabbed BEFORE the doc changes underneath it. + const held = matchIdx >= 0 ? doc.lines[matches[matchIdx]] : undefined; + // And so is the line under the top of the viewport, as the LINE OBJECT. + // + // At the 200,000-line cap `enforceCap` splices lines off the FRONT. Every + // remaining line then sits `drop` rows higher while `scrollTop` stays + // where it was, so a reader who has deliberately scrolled away — follow + // off, reading something — is carried forward by exactly that many rows + // on every 4s tick. On the biggest logs, which are the ones that reach the + // cap, that is the "it scrolls instead of me" complaint in its purest + // form: nothing in the app is scrolling, the document is sliding out from + // under a fixed offset. + // + // Anchored on identity, not on the count: `visible` is doc indices and a + // collapsed group means the rows dropped and the ROWS SHOWN differ. + // Taken whenever the reader is not following — NOT only once the doc has + // already reached the cap. That pre-check read `doc.lines.length` BEFORE + // the delta was appended, so on the single tick that CROSSES the cap the + // length was still under it, the anchor was undefined, and the correction + // below was skipped for exactly the drop that matters: everything the job + // emitted in that poll window, minus the headroom, in one jerk. Every + // later tick was anchored, which is what made it look fixed. + // + // The cheap part is this lookup; the O(n) `indexOf` below is already + // gated on `dropped`, so an unconditional anchor costs a modulo per poll. + const anchor = + !follow && visible.length + ? doc.lines[visible[Math.min(visible.length - 1, Math.floor(scroll.scrollTop / LINE_H))]] + : undefined; + const anchorOffset = anchor ? scroll.scrollTop % LINE_H : 0; + appendLog(doc, delta); + const dropped = enforceCap(); + rebuildVisible(); + if (query) rebuildMatches(held); + render(); + if (follow) { + scroll.scrollTop = scroll.scrollHeight; + } else if (anchor && dropped) { + const row = visible.indexOf(doc.lines.indexOf(anchor)); + // -1 means the reader's own line was one of the ones dropped. There is + // nowhere honest to put them then; the top of what survives is the + // closest thing to where they were. + scroll.scrollTop = row >= 0 ? row * LINE_H + anchorOffset : 0; + } + // The tail just moved, so whether there is anything to jump TO has + // changed — and nothing else will say so. `atTail()` is only re-read on + // scroll, and a reader parked at the bottom with follow off does not + // scroll: the log grew past them in silence, the pill stayed hidden, and + // the one control that would have caught them up was never offered. + syncFollowBtn(); + }, + setProducing(on) { + if (producing === on) return; + producing = on; + // Arm the tail the way opening a live job would have. Not `setFollow` on + // the way DOWN — finish() owns that, and it also flushes the last line. + if (on) { + follow = true; + notStarted = false; + } + syncFollowBtn(); + // `notStarted` is read by the empty-log note, which is on screen right + // now saying "This job hasn't started yet." Without a re-render it keeps + // saying it for as long as the job runs — until the first chunk happens + // to arrive, which on a slow step is minutes of a running job insisting + // it has not begun. + render(); + }, + finish() { + finishLog(doc); + rebuildVisible(); + // Nothing will ever arrive again, so there is nothing to follow — and + // nothing to be behind. Leaving the mode armed left a finished log + // claiming to be tailing, with a lit button that could only ever do one + // more thing: jump you to the end. + producing = false; + follow = false; + // Through the one rule: it disables the button and hides the pill, both + // of which are now permanently meaningless for this pane. + syncFollowBtn(); + render(); + }, + setFollow, + focusReader() { + // `preventScroll` is load-bearing. A plain focus() scrolls the port to + // the focused element, and on a live job the reader would read that as + // the tail jumping — the one thing this pane must never do. + scroll.focus({ preventScroll: true }); + }, + destroy() { + destroyed = true; + if (raf) cancelAnimationFrame(raf); + if (rafTimer) window.clearTimeout(rafTimer); + sizeWatch?.disconnect(); + window.removeEventListener("resize", onResize); + root.remove(); + }, + }; + + // The virtual window is sized from `scroll.clientHeight`, and the only things + // that called render() were scroll events, the keyboard, the toolbar and the + // tail. A height change that produces neither — resizing the Electron window + // taller, entering fullscreen, dragging the terminal dock down — left the + // window the size it was, so the log ended mid-pane with a blank band below + // it until you happened to scroll. + // + // A resize is not a scroll, so following must survive it (`expandBtn` learned + // this the hard way); render() alone touches no scroll position. + const onResize = (): void => { + if (!destroyed) render(); + }; + const sizeWatch = + typeof ResizeObserver === "function" ? new ResizeObserver(onResize) : undefined; + sizeWatch?.observe(scroll); + // BOTH. The observer catches a pane that changes size without the window + // doing so (the dock being dragged, the job rail folding); the window event + // catches the case a reader actually hits — resizing the app, or going + // fullscreen — and is the one that can be driven in a test, since a + // ResizeObserver callback is delivered with the rendering steps and those do + // not run on an idle headless page. + window.addEventListener("resize", onResize); + + // Paint the button's initial state through the one rule that owns it, rather + // than stamping a class — a finished pane must open with Follow disabled. + syncFollowBtn(); + return pane; +} diff --git a/apps/desktop/src/renderer/markdown.ts b/apps/desktop/src/renderer/markdown.ts index 9ba0c91..b4ddc9c 100644 --- a/apps/desktop/src/renderer/markdown.ts +++ b/apps/desktop/src/renderer/markdown.ts @@ -19,6 +19,9 @@ /** Private-use sentinel for parking finished HTML during inline parsing; cannot * occur in escaped text and is stripped from input anyway. */ const SENT = "\uE000"; +/** Sentinel for allowlisted tags parked during sanitizeHtml's escape pass. + * Distinct from SENT so an inline hold can never collide with a tag hold. */ +const TAG_SENT = "\uE001"; /** Hard caps so a crafted document can't blow the stack / DOM. */ const MAX_QUOTE_DEPTH = 16; const MAX_LIST_DEPTH = 10; @@ -175,7 +178,9 @@ function filterAttrs(tag: string, rawAttrs: string): string { * permitted is removed. This is the security boundary for all rendered markdown. */ export function sanitizeHtml(html: string): string { - let s = html; + // The sentinels below are private-use codepoints; strip any the input carries + // so a crafted body can never forge one and smuggle markup past the escape. + let s = html.split(TAG_SENT).join(""); // 1. Drop dangerous elements together with their contents (closed or not). for (const tag of DROP_WITH_CONTENT) { @@ -190,6 +195,11 @@ export function sanitizeHtml(html: string): string { // 3. Rewrite every remaining tag through the allowlist. The attribute-aware // pattern tolerates `>` inside quoted attribute values. + // + // Each surviving tag is parked behind a sentinel rather than written back + // directly, so that step 4 can escape everything the pattern did NOT + // match without also re-escaping our own output. + const kept: string[] = []; s = s.replace( /<(\/?)([a-zA-Z][a-zA-Z0-9-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g, (_m, slash: string, rawTag: string, attrs: string) => { @@ -197,17 +207,36 @@ export function sanitizeHtml(html: string): string { if (!ALLOWED_TAGS.has(tag)) { return ""; } + let html: string; if (slash) { - return VOID_TAGS.has(tag) ? "" : `</${tag}>`; + if (VOID_TAGS.has(tag)) return ""; + html = `</${tag}>`; + } else { + const filtered = filterAttrs(tag, attrs); + html = VOID_TAGS.has(tag) ? `<${tag}${filtered} />` : `<${tag}${filtered}>`; } - const filtered = filterAttrs(tag, attrs); - return VOID_TAGS.has(tag) ? `<${tag}${filtered} />` : `<${tag}${filtered}>`; + kept.push(html); + return TAG_SENT + (kept.length - 1) + TAG_SENT; }, ); - // 4. Any lone `<` that survived (e.g. `a < b`) is inert text. - s = s.replace(/<(?![a-zA-Z/])/g, "<"); - return s; + // 4. FAIL CLOSED. Every `<` still standing is markup the pattern above could + // not parse, and passing it through verbatim was a real, proven XSS: an + // unterminated attribute quote — `<img src="x` — does not match, so the + // tag was emitted untouched and never attribute-filtered. The browser then + // ran that quote on until the NEXT `"` in the document, which the sanitizer + // itself supplies from a later tag's `title="…"`, and everything after it + // landed in attribute position on the unfiltered tag. A body carrying + // `<img src="x` and, further down, `<b title="onerror=alert(1) x">` gave + // the img a live onerror. Escaping instead of trusting closes the whole + // class, lone `a < b` included. + s = s.replace(/</g, "<"); + + // 5. Restore the tags that DID pass the allowlist. + return s.replace( + new RegExp(`${TAG_SENT}(\\d+)${TAG_SENT}`, "g"), + (_m, i: string) => kept[Number(i)] ?? "", + ); } // ── inline parsing ─────────────────────────────────────────────────────────── diff --git a/apps/desktop/src/renderer/mdEditor.ts b/apps/desktop/src/renderer/mdEditor.ts new file mode 100644 index 0000000..beeea61 --- /dev/null +++ b/apps/desktop/src/renderer/mdEditor.ts @@ -0,0 +1,264 @@ +// The markdown editor, shared. +// +// Release notes, issue bodies, issue comments, PR descriptions and gist +// descriptions were each a bare `<textarea class="modal-input modal-textarea">` +// — no preview, no toolbar, no paste handling, no keyboard beyond what a +// textarea gives you for free. The owner's words about two of them: "editing a +// release is complete garbage compared to github ui ux" and "Same goes for +// issues creating and editing". +// +// One component, five callers. Two things in it are deliberately better than +// the reference: +// +// · PREVIEW RENDERS THROUGH THE REAL RENDERER. `renderMarkdown` is the same +// function that draws a published issue body, so preview and published +// output cannot drift. GitHub runs a separate preview implementation and +// they differ in practice. +// · ⌘Enter SUBMITS from inside the field. Every one of these forms had its +// primary button somewhere the text had already scrolled past. + +import { el, span, glyph } from "./ui"; +import { renderMarkdown } from "./markdown"; + +export interface MdEditorOpts { + value?: string; + placeholder?: string; + /** Visible rows before it grows. */ + rows?: number; + /** Called on every keystroke — for a draft store, or to enable a Save. */ + onInput?: (value: string) => void; + /** ⌘/Ctrl+Enter. Without one, the key does nothing. */ + onSubmit?: () => void; + /** Accessible name for the text area. */ + label?: string; + /** + * Fill the height the container gives it instead of growing with the text. + * + * Auto-grow is right inside a modal, where the editor is one field among + * several. On a composer PAGE the body is the page: growing to half the + * window and then scrolling a box inside a box is the same "the window is + * too small" complaint in a different surface. + */ + fill?: boolean; +} + +export interface MdEditor { + root: HTMLElement; + textarea: HTMLTextAreaElement; + get(): string; + set(v: string): void; + focus(): void; +} + +/** A toolbar button: what it does to the selection. */ +interface Tool { + icon: string; + title: string; + /** Wrap the selection, e.g. `**` for bold. */ + wrap?: string; + /** Prefix each selected LINE, e.g. "> " for quote. */ + linePrefix?: string; + key?: string; +} + +const TOOLS: Tool[] = [ + { icon: "bold", title: "Bold ⌘B", wrap: "**", key: "b" }, + { icon: "italic", title: "Italic ⌘I", wrap: "_", key: "i" }, + { icon: "code", title: "Code", wrap: "`" }, + { icon: "link", title: "Link ⌘K", wrap: "[](url)", key: "k" }, + { icon: "quote", title: "Quote", linePrefix: "> " }, + { icon: "list-unordered", title: "Bulleted list", linePrefix: "- " }, + { icon: "list-ordered", title: "Numbered list", linePrefix: "1. " }, + { icon: "tasklist", title: "Task list", linePrefix: "- [ ] " }, +]; + +/** Apply a tool to the current selection, keeping the caret sensible. */ +function applyTool(ta: HTMLTextAreaElement, t: Tool): void { + const start = ta.selectionStart; + const end = ta.selectionEnd; + const selected = ta.value.slice(start, end); + + if (t.linePrefix) { + // Whole lines, so a prefix applied to a selection spanning three lines + // marks three lines rather than gluing itself to the middle of the first. + const from = ta.value.lastIndexOf("\n", start - 1) + 1; + const to = ta.value.indexOf("\n", end); + const stop = to === -1 ? ta.value.length : to; + const block = ta.value.slice(from, stop); + const already = block.split("\n").every((l) => l.startsWith(t.linePrefix!)); + const next = block + .split("\n") + .map((l) => (already ? l.slice(t.linePrefix!.length) : t.linePrefix! + l)) + .join("\n"); + ta.setRangeText(next, from, stop, "select"); + return; + } + + const w = t.wrap ?? ""; + if (w === "[](url)") { + // A link keeps the selection as the TEXT and puts the caret on the url, + // which is the part that still needs typing. + const text = selected || "text"; + ta.setRangeText(`[${text}](url)`, start, end, "end"); + const urlAt = start + text.length + 3; + ta.setSelectionRange(urlAt, urlAt + 3); + return; + } + // Toggling off, when the selection is already wrapped. + if (selected.startsWith(w) && selected.endsWith(w) && selected.length >= w.length * 2) { + ta.setRangeText(selected.slice(w.length, -w.length), start, end, "select"); + return; + } + ta.setRangeText(`${w}${selected}${w}`, start, end, selected ? "select" : "end"); + if (!selected) { + const caret = start + w.length; + ta.setSelectionRange(caret, caret); + } +} + +/** The list marker a line starts with, if any — for continuation on Enter. */ +function listMarker(line: string): string | undefined { + const m = /^(\s*)(-\s\[[ xX]\]\s|[-*+]\s|\d+\.\s)/.exec(line); + if (!m) return undefined; + // A finished task box continues as an empty one, not a ticked one. + return `${m[1]}${m[2].replace(/\[[xX]\]/, "[ ]")}`; +} + +export function mdEditor(opts: MdEditorOpts = {}): MdEditor { + const root = el("div", "md-editor" + (opts.fill ? " md-fill" : "")); + + // ── Write | Preview ─────────────────────────────────────────────────────── + const tabs = el("div", "md-tabs"); + const writeTab = el("button", "md-tab is-active") as HTMLButtonElement; + writeTab.textContent = "Write"; + writeTab.setAttribute("role", "tab"); + const previewTab = el("button", "md-tab") as HTMLButtonElement; + previewTab.textContent = "Preview"; + previewTab.setAttribute("role", "tab"); + tabs.append(writeTab, previewTab); + + const bar = el("div", "md-toolbar"); + const ta = document.createElement("textarea"); + ta.className = "md-text"; + ta.rows = opts.rows ?? 10; + ta.placeholder = opts.placeholder ?? "Write something…"; + ta.value = opts.value ?? ""; + if (opts.label) ta.setAttribute("aria-label", opts.label); + + const preview = el("div", "gh-body-md md-preview"); + preview.hidden = true; + + for (const t of TOOLS) { + const b = el("button", "md-tool") as HTMLButtonElement; + b.append(glyph(t.icon)); + b.title = t.title; + b.setAttribute("aria-label", t.title); + b.tabIndex = -1; // the toolbar is a shortcut, not a tab stop before the text + b.addEventListener("mousedown", (e) => e.preventDefault()); // keep the caret + b.addEventListener("click", () => { + applyTool(ta, t); + ta.focus(); + opts.onInput?.(ta.value); + }); + bar.appendChild(b); + } + + const head = el("div", "md-head"); + head.append(tabs, bar); + + const show = (mode: "write" | "preview"): void => { + const writing = mode === "write"; + writeTab.classList.toggle("is-active", writing); + previewTab.classList.toggle("is-active", !writing); + writeTab.setAttribute("aria-selected", String(writing)); + previewTab.setAttribute("aria-selected", String(!writing)); + ta.hidden = !writing; + preview.hidden = writing; + bar.hidden = !writing; + if (!writing) { + // The REAL renderer, so preview cannot disagree with what gets published. + const text = ta.value.trim(); + preview.innerHTML = text + ? renderMarkdown(text) + : `<p class="md-preview-empty">Nothing to preview yet.</p>`; + // Match the text area's height so switching tabs does not jump the form. + preview.style.minHeight = `${ta.offsetHeight}px`; + } else { + ta.focus(); + } + }; + writeTab.addEventListener("click", () => show("write")); + previewTab.addEventListener("click", () => show("preview")); + + // Grow with the text, to a point — a release note is not a tweet, and a + // fixed six rows meant scrolling a box inside a box. + const autoGrow = (): void => { + if (opts.fill) return; // the container decides the height; see `fill` + ta.style.height = "auto"; + ta.style.height = `${Math.min(ta.scrollHeight + 2, Math.round(window.innerHeight * 0.5))}px`; + }; + + ta.addEventListener("input", () => { + autoGrow(); + opts.onInput?.(ta.value); + }); + + ta.addEventListener("keydown", (e) => { + const mod = e.metaKey || e.ctrlKey; + if (mod && e.key === "Enter") { + e.preventDefault(); + opts.onSubmit?.(); + return; + } + if (mod && !e.shiftKey) { + const tool = TOOLS.find((t) => t.key && t.key === e.key.toLowerCase()); + if (tool) { + e.preventDefault(); + applyTool(ta, tool); + opts.onInput?.(ta.value); + return; + } + } + if (e.key === "Enter" && !mod && !e.shiftKey) { + // Continue a list. Typing "- a" then Enter should give "- ", not a bare + // line you have to re-mark — and an empty marker ENDS the list, which is + // how every editor that does this behaves. + const upto = ta.value.slice(0, ta.selectionStart); + const line = upto.slice(upto.lastIndexOf("\n") + 1); + const marker = listMarker(line); + if (marker) { + e.preventDefault(); + if (line.trim() === marker.trim()) { + // An empty item: clear it and break out of the list. + ta.setRangeText("", ta.selectionStart - line.length, ta.selectionStart, "end"); + } else { + ta.setRangeText(`\n${marker}`, ta.selectionStart, ta.selectionEnd, "end"); + } + autoGrow(); + opts.onInput?.(ta.value); + } + return; + } + if (e.key === "Tab" && !e.shiftKey) { + // Indent inside the field rather than leaving it — a markdown list needs + // indentation, and Tab was the only way to lose the field mid-thought. + e.preventDefault(); + ta.setRangeText(" ", ta.selectionStart, ta.selectionEnd, "end"); + opts.onInput?.(ta.value); + } + }); + + root.append(head, ta, preview); + queueMicrotask(autoGrow); + + return { + root, + textarea: ta, + get: () => ta.value, + set: (v: string) => { + ta.value = v; + autoGrow(); + }, + focus: () => ta.focus(), + }; +} diff --git a/apps/desktop/src/renderer/navStack.ts b/apps/desktop/src/renderer/navStack.ts new file mode 100644 index 0000000..784cf5d --- /dev/null +++ b/apps/desktop/src/renderer/navStack.ts @@ -0,0 +1,120 @@ +// The in-app navigation history, reachable from a view. +// +// The history itself lives on `App` (renderer.ts: navHistory/navPos), because +// only `routeView` knows when a navigation happens. But a detail page needs two +// things from it that it had no way to ask for: +// +// · "take me back" — a POP, stepping over an entry +// · "what is behind me" — so the button can say where it goes +// +// Without those, every `.det-back` did `nav(view, {list:true})`, which is a +// PUSH. Measured on the shipping build: after pressing back, FORWARD is +// disabled — proof that the press appended an entry instead of stepping over +// one. So the control that should restore your place was the one destroying it, +// on every detail page in the app. And because `SectionTarget.from` was +// `{view, label}`, it could only ever name a LIST: the mechanism structurally +// could not say "return to Pull Request #106", which is why leaving a PR for a +// pipeline and pressing back landed in the Actions list. +// +// This module is the bridge, not a second history. `App` installs itself once; +// views ask through here. + +import type { SectionTarget } from "./views/common"; + +export interface NavEntry { + view: string; + target?: SectionTarget; + /** What to call this place in a back button: "Pull Request #106". */ + label?: string; +} + +interface NavStackImpl { + /** Step back one entry. False when there is nothing behind. */ + back: () => boolean; + /** The entry behind the current one, if any. */ + prev: () => NavEntry | undefined; + /** Name the CURRENT entry, once the page knows its own identity. */ + label: (label: string) => void; + /** Amend the CURRENT entry's target, for a page whose identity moves within + * itself. Does NOT navigate. */ + retarget: (patch: Record<string, unknown>) => void; +} + +let impl: NavStackImpl | undefined; + +/** Called once by App. */ +export function installNavStack(next: NavStackImpl): void { + impl = next; +} + +/** + * Where Back goes, or undefined when this page was the first thing on screen + * (a deep link, a fresh launch, a restored session). + */ +export function navPrev(): NavEntry | undefined { + return impl?.prev(); +} + +/** Step back. Returns false when there is nothing to step back to, and the + * caller should use its own fallback — see `detailPage`'s `homeLabel`. */ +export function navPop(): boolean { + return impl?.back() ?? false; +} + +/** + * Name the page currently on screen, so the NEXT page's back button can say + * where it leads. Pages call this once they know their identity — a PR knows it + * is "#106" only after its data arrives. + */ +export function setPageLabel(label: string): void { + impl?.label(label); +} + +/** + * Record where WITHIN this page the reader now is, without navigating. + * + * Some pages hold several things and let you move between them — the job log + * has one route for a whole run and a rail of jobs inside it. The history entry + * kept whichever job the page was ENTERED with, and `refreshAll` re-routes to + * `navHistory[navPos]`, so any refresh — a rail poll, the file watcher, a + * window focus — silently swapped the reader onto a different job's output, + * mid-read, with only the crumb changing to say so. Back would return them + * there too. + */ +export function setPageTarget(patch: Record<string, unknown>): void { + impl?.retarget(patch); +} + +/** Human name for a view id, for a back button with nothing better to say. */ +const VIEW_LABELS: Record<string, string> = { + changes: "Changes", + graph: "Commits", + branches: "Branches", + code: "Code", + compare: "Compare", + rebase: "Rebase", + issues: "Issues", + prs: "Pull requests", + actions: "Actions", + releases: "Releases", + orgs: "Organizations", + projects: "Projects", + gists: "Gists", + notifications: "Inbox", + mywork: "My Work", + explore: "Explore", + settings: "Settings", + assistant: "Assistant", + commit: "Commit", + joblog: "Job log", + releasenew: "Release composer", + issuenew: "Issue composer", + refdetail: "Ref", + predit: "Pull request composer", +}; + +/** The label a back button should show for an entry. */ +export function entryLabel(e: NavEntry | undefined, fallback: string): string { + if (!e) return fallback; + return e.label || VIEW_LABELS[e.view] || fallback; +} diff --git a/apps/desktop/src/renderer/outputsPanel.ts b/apps/desktop/src/renderer/outputsPanel.ts index 97f3c9d..e24a3c1 100644 --- a/apps/desktop/src/renderer/outputsPanel.ts +++ b/apps/desktop/src/renderer/outputsPanel.ts @@ -45,8 +45,17 @@ interface ActionCtx { } export class OutputsPanel { - /** The tab surface: sticky toolbar + scrolling list. */ + /** The tab surface — the scrolling list ALONE. */ readonly el: HTMLElement; + /** + * The panel's controls, for the dock to mount in its footer's action slot. + * + * These used to be a sticky bar on top of `el`, which gave Output a 40px + * chrome row that Terminal did not have — so the dock's content origin + * jumped as you switched tabs. The dock already owns one horizontal control + * row (its footer); this is that row's Output half. + */ + readonly bar: HTMLElement; private readonly scroller: HTMLElement; private readonly list: HTMLElement; private readonly empty: HTMLElement; @@ -56,6 +65,12 @@ export class OutputsPanel { private total = 0; private failed = 0; private failuresOnly = false; + /** Set in the constructor; the one place the filter's three surfaces + * (the flag, the button, the wrapper class) are changed together. */ + private setFailuresOnly!: (on: boolean) => void; + private filteredEmpty!: HTMLElement; + /** Filter + clear — hidden while there is nothing to filter or clear. */ + private readonly controls: HTMLButtonElement[]; private ctx: ActionCtx | null = null; /** The last CLOSED single-command action, for cross-action ×N coalescing. */ private lastSingle: { @@ -68,25 +83,33 @@ export class OutputsPanel { constructor() { this.el = el("div", "outputs-wrap"); - // ── Sticky toolbar: totals · failures filter · clear ────────────────── + // ── Controls: totals · failures filter · clear ──────────────────────── const bar = el("div", "outputs-bar"); + this.bar = bar; this.countEl = el("span", "outputs-count"); const failBtn = el("button", "mini-btn outputs-failbtn") as HTMLButtonElement; failBtn.append(glyph("error"), span("Errors only")); failBtn.title = "Show only failed commands"; failBtn.setAttribute("aria-pressed", "false"); + this.setFailuresOnly = (on: boolean): void => { + this.failuresOnly = on; + failBtn.classList.toggle("is-on", on); + failBtn.setAttribute("aria-pressed", String(on)); + this.el.classList.toggle("failures-only", on); + this.renderCount(); + }; failBtn.addEventListener("click", () => { - this.failuresOnly = !this.failuresOnly; - failBtn.classList.toggle("is-on", this.failuresOnly); - failBtn.setAttribute("aria-pressed", String(this.failuresOnly)); - this.el.classList.toggle("failures-only", this.failuresOnly); + this.setFailuresOnly(!this.failuresOnly); if (this.stick) this.scroller.scrollTop = this.scroller.scrollHeight; }); const clearBtn = el("button", "mini-btn") as HTMLButtonElement; clearBtn.append(glyph("clear-all"), span("Clear")); clearBtn.title = "Clear the log"; clearBtn.addEventListener("click", () => this.clear()); - bar.append(this.countEl, el("span", "outputs-bar-spacer"), failBtn, clearBtn); + bar.append(this.countEl, failBtn, clearBtn); + // Nothing logged yet means nothing to filter and nothing to clear. The + // controls used to sit there enabled beside a defiant "0 commands". + this.controls = [failBtn, clearBtn]; // ── Scrolling log ────────────────────────────────────────────────────── this.scroller = el("div", "outputs-panel"); @@ -95,9 +118,18 @@ export class OutputsPanel { span("Git command log", "outputs-empty-title"), span("Every git command GitStudio runs will appear here.", "outputs-empty-sub"), ); + // "Errors only" is a CSS filter over the rows — with nothing failed it hid + // every one of them and left a blank panel beside a count still reading + // "40 commands". A filter that empties a list has to say it was the filter. + this.filteredEmpty = el("div", "outputs-empty"); + this.filteredEmpty.append( + span("No failures", "outputs-empty-title"), + span("Nothing has failed. Turn off “Errors only” to see every command.", "outputs-empty-sub"), + ); + this.filteredEmpty.hidden = true; this.list = el("div", "outputs-list"); - this.scroller.append(this.empty, this.list); - this.el.append(bar, this.scroller); + this.scroller.append(this.empty, this.filteredEmpty, this.list); + this.el.append(this.scroller); this.renderCount(); // Stick to the bottom only when the user is already near it. @@ -110,6 +142,14 @@ export class OutputsPanel { } private renderCount(): void { + // An empty log says so once, in the empty state below — not twice, and not + // as a count of nothing beside two controls that would do nothing. + for (const b of this.controls) b.hidden = this.total === 0; + this.filteredEmpty.hidden = !(this.total > 0 && this.failuresOnly && this.failed === 0); + if (this.total === 0) { + this.countEl.replaceChildren(); + return; + } this.countEl.replaceChildren( span(`${this.total} command${this.total === 1 ? "" : "s"}`, "outputs-count-n"), ); @@ -318,12 +358,32 @@ export class OutputsPanel { } /** Clear the log. */ + /** Called when the Output tab becomes visible: land on the NEWEST command. + * + * The panel is hidden with `display: none` while another tab is up, so its + * scroller has no height and `scrollTop` cannot be set — every log entry + * that arrived meanwhile left it pinned at 0. Opening Output therefore + * showed the OLDEST command in the session, and the next git command to + * finish hit `if (this.stick)` and jerked the pane a thousand pixels to the + * bottom, unasked. + * + * A log is read from its tail; `stick` already says the reader has not + * moved away from it. */ + reveal(): void { + if (this.stick) this.scroller.scrollTop = this.scroller.scrollHeight; + } + clear(): void { this.list.replaceChildren(); this.ctx = null; this.lastSingle = null; this.total = 0; this.failed = 0; + this.setFailuresOnly(false); + // Turn the filter OFF with it. `renderCount` hides both controls at zero, + // so clearing while "Errors only" was on left the toggle switched on and + // out of reach — every command logged afterwards was filtered away by a + // control the reader could no longer see, and the panel looked dead. this.renderCount(); if (!this.empty.isConnected) this.scroller.insertBefore(this.empty, this.list); } diff --git a/apps/desktop/src/renderer/overlays.ts b/apps/desktop/src/renderer/overlays.ts new file mode 100644 index 0000000..88d6f73 --- /dev/null +++ b/apps/desktop/src/renderer/overlays.ts @@ -0,0 +1,263 @@ +// The registry of open floating layers — menus, modals, peeks, the palette, +// the notifications popover, context menus, drawers. +// +// Every one of these appends to `document.body`, which is what makes them float +// above a view — and also what stops a view swap from taking them with it. The +// symptom was an Inbox facet menu still hovering over the Releases page after +// navigating, filtering a list that was no longer on screen. +// +// Rather than have `routeView` know about eight different closers (several of +// which were module-private and never exported), each opener registers how to +// dispose itself and gets a token back. One `dismissLayers()` at the route +// change closes whatever happens to be open. +// +// Deliberately tiny and dependency-free: layers register from modules that this +// one must never import back. + +/** What kind of layer this is. Only "menu" is distinguished, and only because + * Escape precedence needs it — see `isMenuOpen`. */ +export type LayerKind = "menu" | "modal" | "surface"; + +/** A live floating layer. `dispose` must be idempotent. */ +interface Layer { + id: number; + kind: LayerKind; + dispose: () => void; + /** Optional: "am I still on screen?", asked after a dispose that may have + * been declined. A layer that cannot answer is assumed to have closed. */ + stillOpen?: () => boolean; +} + +let nextId = 1; +let layers: Layer[] = []; + +/** + * Register an open layer. Returns a handle whose `release()` the opener calls + * from its OWN close path, so a layer that closes normally doesn't linger in + * the registry (and can't be disposed twice). + */ +export function registerLayer( + dispose: () => void, + kind: LayerKind = "surface", + stillOpen?: () => boolean, +): LayerHandle { + const id = nextId++; + layers.push({ id, kind, dispose, stillOpen }); + return { + release: () => { + layers = layers.filter((l) => l.id !== id); + }, + // "Is anything still open that opened AFTER me?" — the question every + // surface with a keyboard handler is actually asking, and the registry is + // the only thing that can answer it. See `ownsEscape` for what happened + // while they each asked something else. + isTop: () => layers.length > 0 && layers[layers.length - 1].id === id, + }; +} + +/** What `registerLayer` hands back. */ +export interface LayerHandle { + release: () => void; + /** True while no layer registered after this one is still open. */ + isTop: () => boolean; +} + +/** + * Close every open layer. Called on route changes; safe to call when nothing + * is open. + * + * Iterates a COPY and clears first: a dispose() will call its own release(), + * which mutates `layers` — walking the live array would skip entries. + * + * A layer that DECLINES to close is put back. Not every dispose closes: a + * dialog with unsaved work vetoes (`hasUnsavedWork`), and one that throws has + * not closed either. Clearing the array regardless left those surfaces on + * screen and absent from the registry, and from that point every predicate + * built on the registry lied about them — `isTop()` false forever for a dialog + * that IS the top layer, so its Escape was dead; `openLayerCount()` zero with a + * dialog open, so the page's own ← navigated out from under it. The registry + * has to describe what is on screen, not what this function intended. + */ +export function dismissLayers(): void { + const open = layers; + layers = []; + const survived: Layer[] = []; + // Newest first, so a modal opened from a menu closes before the menu. + for (const l of [...open].reverse()) { + try { + l.dispose(); + } catch { + /* one broken layer must not strand the rest open */ + } + // A dispose that closed calls its own `release()`, which is a no-op now + // that `layers` is cleared — so "still here" is decided by asking the layer + // itself, through the same predicate everything else uses. + if (l.stillOpen?.()) survived.push(l); + } + // In ORIGINAL order, ahead of anything registered DURING the sweep (a + // dispose can open something). Plain assignment would drop those; reversed + // order would make `isTop()` name the wrong survivor. + if (survived.length) { + survived.sort((a, b) => a.id - b.id); + layers = [...survived, ...layers]; + } +} + +/** How many layers are open — for tests and for handlers that stand down. */ +export function openLayerCount(): number { + return layers.length; +} + +/** + * Hold the page behind a modal surface: everything outside `keep` becomes + * `inert`, so Tab, the pointer, and assistive tech all stop at the surface. + * + * `aria-modal="true"` is a CLAIM, not a mechanism — the Projects drawer set it + * and every card on the board behind it stayed in the tab order, so Tab walked + * straight out of the dialog and into a board the user could not see. + * Returns the release function; call it once, when the surface goes away. + */ +export function holdBackground(keep: HTMLElement): () => void { + const held: HTMLElement[] = []; + for (const node of Array.from(document.body.children)) { + const el = node as HTMLElement; + if (el === keep || el.contains(keep)) continue; + // Never a LIVE REGION. Toasts live in a persistent `#toast-stack` on the + // body, and inerting it made a toast raised over an open palette or dialog + // unclickable — aiming at its Dismiss ✕ dismissed the layer instead and + // threw away what had been typed — and, worse, silent: an `aria-live` host + // inside an inert subtree announces nothing, so an error toast raised while + // a dialog was open was never read out at all. A live region is not part of + // the page being held back; it is how the app speaks. + if (isLiveRegion(el)) continue; + hold(el); + held.push(el); + } + let released = false; + return () => { + if (released) return; + released = true; + for (const el of held) drop(el); + }; +} + +/** + * How many open surfaces are holding each element back. + * + * A plain boolean attribute cannot answer that, and two surfaces holding the + * same shell is the ordinary case: a peek or a drawer opens a dialog, so both + * are up at once. `holdBackground` used to SKIP anything already `inert`, which + * is right only when the inner surface closes first. It does not always: a + * route change disposes layers newest-first, the dialog vetoes (that is what + * `hasUnsavedWork` is for), the drawer beneath it does not — and the drawer's + * release then stripped `inert` off the shell the surviving dialog still + * needed. The result was a dialog claiming `aria-modal="true"` over an app that + * was fully tab-reachable, and activating anything back there routed the whole + * window behind a dialog the user could still see. + * + * A WeakMap, not a Map: the counted elements include a scrim that is removed + * from the DOM when its surface closes, and a strong Map would pin every one of + * them for the life of the session. + * + * The invariant this rests on: `inert` on a body child is set ONLY here. Verify + * with `rg 'setAttribute\("inert"' src/renderer/` before adding another. + */ +const holds = new WeakMap<HTMLElement, number>(); + +function hold(el: HTMLElement): void { + const n = (holds.get(el) ?? 0) + 1; + holds.set(el, n); + if (n === 1) el.setAttribute("inert", ""); +} + +function drop(el: HTMLElement): void { + const n = (holds.get(el) ?? 1) - 1; + if (n <= 0) { + holds.delete(el); + el.removeAttribute("inert"); + } else { + holds.set(el, n); + } +} + +/** A host whose whole job is to announce things. Inerting one silences it. */ +function isLiveRegion(el: HTMLElement): boolean { + return ( + el.id === "toast-stack" || + el.matches('[aria-live], [role="status"], [role="alert"], [role="log"]') + ); +} + +/** + * Is a dropdown or context menu open right now? + * + * Escape precedence is the problem this answers. Every floating layer attaches + * its own capture-phase `keydown` to `document`, so which one hears the key + * first is REGISTRATION ORDER, not stacking order — and `stopPropagation` is no + * help, because listeners on the same node still all run (only + * `stopImmediatePropagation` would, and that makes precedence depend on + * registration order too, which is exactly the thing that is wrong). + * + * So one Escape closed both a menu and the peek or dialog it was opened from: + * you dismissed a menu on top of a surface, and the surface went with it, + * taking whatever you had typed into it. The palette had a hand-carved + * exception for this (`body.cmdk-open`); menus never did. + * + * A menu is always the topmost thing when it is open — it is opened FROM the + * surface beneath it — so surfaces stand down while one is up. This is a + * predicate rather than routing Escape through the registry on purpose: a peek + * registers a full `dispose` while its Escape means `back()` (one step up its + * own history), and a dialog deliberately registers `close` rather than + * `dismiss` so an in-flight clone can veto being dismissed. Those differences + * are load-bearing. + */ +export function isMenuOpen(): boolean { + return layers.some((l) => l.kind === "menu"); +} + +/** + * Is a modal dialog on screen? + * + * A peek and a dialog opened from inside it both listen for Escape on + * `document`, in the capture phase. `stopPropagation()` does not stop a sibling + * listener on the SAME node — only `stopImmediatePropagation()` would — and the + * peek registered first, so it ran first: one Escape dismissed the dialog AND + * the card that opened it, discarding whatever was behind it. The peek already + * stands down for the palette and for a menu; a dialog is the third layer that + * can sit above it. + */ +export function isModalOpen(): boolean { + return layers.some((l) => l.kind === "modal"); +} + +/** + * Does a surface own the Escape key right now, or is something ABOVE it up? + * + * The command palette, a menu, and a dialog all sit over whatever opened them, + * and all of them listen on `document`. `stopPropagation()` does not stop a + * sibling listener on the same node, and the surface underneath usually + * registered FIRST, so it runs first: one Escape dismissed the thing you aimed + * at AND the thing underneath, along with whatever you had typed into it. + * + * One helper because three surfaces have now independently forgotten some of + * these checks — the peek, the Projects drawer, and the notifications popover — + * each in its own copy of the same three lines. A fourth copy is not the answer. + */ +export function ownsEscape(): boolean { + return !document.body.classList.contains("cmdk-open") && !isMenuOpen() && !isModalOpen(); +} + +/** + * The rule for a PAGE-LEVEL key handler — one that belongs to the view itself + * rather than to a floating surface. It sits underneath every layer, so ANY + * open layer outranks it, not only the ones that outrank a peek. + * + * `wireDetailEsc` (which answers ← as well as Escape) used a whitelist of four + * CSS selectors and was moved to `ownsEscape()`, which cannot see a peek — a + * peek registers as a "surface". So ← started routing the page out from under + * an open peek and throwing the peek away with it. A page-level handler should + * never have been asking the peek's question. + */ +export function pageOwnsKeys(): boolean { + return openLayerCount() === 0 && ownsEscape(); +} diff --git a/apps/desktop/src/renderer/peek.ts b/apps/desktop/src/renderer/peek.ts new file mode 100644 index 0000000..e27d485 --- /dev/null +++ b/apps/desktop/src/renderer/peek.ts @@ -0,0 +1,355 @@ +// The "peek" — a Linear-style drill-in popup used across every browsable list. +// One overlay hosts a STACK of cards: clicking a branch opens its card, clicking +// a commit inside pushes the commit's card on top, and the header's ← walks +// back — so inspecting never loses your place in the list behind it. +// +// Self-contained like dialogs.ts (no App/renderer imports) so any view module +// can open a peek. Cards render lazily and may be async; the body shows a +// skeleton until the renderer resolves. + +import { registerLayer, holdBackground } from "./overlays"; + +function mk(tag: string, cls = ""): HTMLElement { + const n = document.createElement(tag); + if (cls) n.className = cls; + return n; +} + +function gl(name: string): HTMLElement { + const s = mk("span", `glyph codicon codicon-${name}`); + s.setAttribute("aria-hidden", "true"); + return s; +} + +/** A header action button on a peek card. */ +export interface PeekAction { + label: string; + icon?: string; + /** Accent-filled (at most one per card reads well). */ + primary?: boolean; + danger?: boolean; + /** Tooltip; defaults to the label. */ + title?: string; + onClick: (ctx: PeekContext, buttonEl: HTMLElement) => void; +} + +/** One drillable card in the peek stack. */ +export interface PeekCard { + /** Codicon shown in the header badge. */ + icon: string; + /** A concrete element for the badge instead of the codicon (e.g. an avatar). */ + iconEl?: HTMLElement; + title: string; + /** Small chips rendered after the title (e.g. "current", "↑2"). */ + chips?: HTMLElement[]; + /** Muted line under the title (upstream, author · date, …). */ + subtitle?: string; + /** Content-heavy cards (repo browsing, file views) get the wide shell. */ + wide?: boolean; + actions?: PeekAction[]; + /** Fill `body`; async renderers get a skeleton until they resolve. */ + render: (body: HTMLElement, ctx: PeekContext) => void | Promise<void>; +} + +/** Handed to card renderers + actions: drives the stack it lives in. */ +export interface PeekContext { + /** Drill into a deeper card (the header grows a ← back button). */ + push(card: PeekCard): void; + /** Pop back one card; closes the peek when at the root. */ + back(): void; + /** Close the whole peek (all cards). */ + close(): void; + /** Re-run the current card's renderer (after a mutation). */ + refresh(): void; + /** Update the current card's header in place — for cards whose real title + * only exists once their async body resolves (e.g. a commit's subject). */ + retitle(title: string, subtitle?: string): void; +} + +/** The singleton open peek, so a second openPeek replaces the first. */ +let live: { overlay: HTMLElement; dispose: () => void } | null = null; + +export function closePeek(): void { + live?.dispose(); +} + +/** True while a peek is open — lets global key handlers stand down. */ +export function peekIsOpen(): boolean { + return live !== null; +} + +/** + * Open a peek rooted at `card`. Esc pops one level (closing at the root), + * clicking the dim backdrop closes outright, and focus is trapped inside — + * the same contract as the modal dialogs, plus the drill-in stack. + */ +export function openPeek(card: PeekCard): void { + closePeek(); + const prevFocus = document.activeElement as HTMLElement | null; + + const overlay = mk("div", "peek-overlay"); + overlay.setAttribute("role", "dialog"); + overlay.setAttribute("aria-modal", "true"); + const shell = mk("div", "peek-card"); + overlay.appendChild(shell); + + const stack: PeekCard[] = []; + /** Bumped per render; an async renderer landing late writes into a detached body. */ + let renderGen = 0; + + /** Set once the overlay is mounted — see the holdBackground call below. */ + let releaseBackground: (() => void) | undefined; + const dispose = (): void => { + if (live?.overlay !== overlay) return; + live = null; + layer.release(); + renderGen++; + overlay.remove(); + document.removeEventListener("keydown", onKey, true); + // BEFORE restoring focus: focus cannot land inside an inert subtree. + releaseBackground?.(); + prevFocus?.focus?.(); + }; + const layer = registerLayer(dispose); + + const ctx: PeekContext = { + push(next) { + stack.push(next); + renderTop(); + }, + back() { + if (stack.length <= 1) { + dispose(); + return; + } + stack.pop(); + renderTop(); + }, + close: dispose, + refresh: () => renderTop(), + retitle(title, subtitle) { + const top = stack[stack.length - 1]; + if (!top) return; + top.title = title; + if (subtitle !== undefined) top.subtitle = subtitle; + const t = shell.querySelector<HTMLElement>(".peek-title"); + if (t) { + t.textContent = title; + t.title = title; + } + if (subtitle !== undefined) { + const s = shell.querySelector<HTMLElement>(".peek-subtitle"); + if (s) { + s.textContent = subtitle; + s.title = subtitle; + } + } + overlay.setAttribute("aria-label", title); + }, + }; + + const onKey = (e: KeyboardEvent): void => { + if (e.key === "Escape") { + // Whatever opened AFTER this peek owns Esc — a menu, a dialog, the + // palette, or another surface. Without standing down, one Esc closed both + // layers: you dismissed the thing you aimed at and the card under it went + // too, taking anything you had typed into its filter. + // + // Asked as "am I the top layer?", not "is a menu or a modal open?". The + // latter is a list of the kinds that happen to outrank a peek, and three + // surfaces have now each got a different subset of that list wrong. The + // registry knows the order; nothing else does. + // `isTop()` is the whole question: every layer that can sit above a + // peek — a menu, a dialog, the command palette — registers, so "nothing + // opened after me" answers it without a list of kinds to keep current. + if (!layer.isTop()) return; + e.preventDefault(); + e.stopPropagation(); + ctx.back(); + return; + } + if (e.key !== "Tab") return; + const f = Array.from( + shell.querySelectorAll<HTMLElement>("button, input, a[href], [tabindex]:not([tabindex='-1'])"), + ).filter((n) => !n.hasAttribute("disabled") && n.offsetParent !== null); + if (!f.length) return; + const first = f[0]; + const last = f[f.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + }; + + const renderTop = (): void => { + const top = stack[stack.length - 1]; + if (!top) return; + const gen = ++renderGen; + shell.replaceChildren(); + shell.classList.toggle("peek-card-wide", !!top.wide); + + // ── header ── + const head = mk("div", "peek-head"); + if (stack.length > 1) { + const back = mk("button", "peek-nav-btn"); + back.setAttribute("aria-label", "Back"); + back.title = "Back (Esc)"; + back.appendChild(gl("arrow-left")); + back.addEventListener("click", () => ctx.back()); + head.appendChild(back); + } + const badge = mk("div", "peek-badge"); + badge.appendChild(top.iconEl ?? gl(top.icon)); + head.appendChild(badge); + + const titleWrap = mk("div", "peek-titlewrap"); + const titleRow = mk("div", "peek-titlerow"); + const t = mk("div", "peek-title"); + t.textContent = top.title; + t.title = top.title; + titleRow.appendChild(t); + for (const chip of top.chips ?? []) titleRow.appendChild(chip); + titleWrap.appendChild(titleRow); + if (top.subtitle) { + const sub = mk("div", "peek-subtitle"); + sub.textContent = top.subtitle; + sub.title = top.subtitle; + titleWrap.appendChild(sub); + } + head.appendChild(titleWrap); + + const acts = mk("div", "peek-actions"); + for (const a of top.actions ?? []) { + const b = mk( + "button", + a.primary ? "btn btn-primary peek-act" : a.danger ? "mini-btn peek-act peek-act-danger" : "mini-btn peek-act", + ); + if (a.icon) b.appendChild(gl(a.icon)); + const lbl = mk("span"); + lbl.textContent = a.label; + b.appendChild(lbl); + b.title = a.title ?? a.label; + b.addEventListener("click", () => a.onClick(ctx, b)); + acts.appendChild(b); + } + const closeBtn = mk("button", "peek-nav-btn peek-close"); + closeBtn.setAttribute("aria-label", "Close"); + closeBtn.title = "Close"; + closeBtn.appendChild(gl("close")); + closeBtn.addEventListener("click", dispose); + acts.appendChild(closeBtn); + head.appendChild(acts); + shell.appendChild(head); + + // ── body ── + const body = mk("div", "peek-body"); + shell.appendChild(body); + overlay.setAttribute("aria-label", top.title); + + const out = top.render(body, ctx); + if (out instanceof Promise) { + if (!body.childElementCount) body.appendChild(peekSkeleton()); + out + .then(() => { + if (gen !== renderGen) return; + body.querySelector(".peek-skel")?.remove(); + }) + .catch(() => { + if (gen !== renderGen) return; + body.replaceChildren(peekError()); + }); + } + // Focus lands on the card so Esc/Tab work immediately without stealing + // focus into the first action (which reads as an accidental highlight). + shell.tabIndex = -1; + setTimeout(() => { + if (gen === renderGen && overlay.isConnected) shell.focus(); + }, 0); + }; + + overlay.addEventListener("mousedown", (e) => { + if (e.target === overlay) dispose(); + }); + document.addEventListener("keydown", onKey, true); + document.body.appendChild(overlay); + /** + * Hold the page behind the card. `aria-modal="true"` above is a claim; this + * is the mechanism. + * + * A peek was the worse case of the two: its card is focused on open with + * `tabindex="-1"`, and the Tab wrap's own selector excludes `[tabindex='-1']` + * — so the very first Tab, from the state the peek opens in, walked straight + * out into the view behind the scrim. + */ + releaseBackground = holdBackground(overlay); + live = { overlay, dispose }; + stack.push(card); + renderTop(); +} + +/** The shimmering placeholder shown while an async card body loads — built + * from the same .sk-* classes as the list-view skeletons so loading looks + * identical everywhere. */ +function peekSkeleton(): HTMLElement { + const wrap = mk("div", "peek-skel"); + for (let i = 0; i < 4; i++) { + const row = mk("div", "sk-row"); + row.appendChild(mk("div", "sk sk-dot")); + const lines = mk("div", "sk-lines"); + lines.append(mk("div", "sk sk-line mid"), mk("div", "sk sk-line short")); + row.appendChild(lines); + wrap.appendChild(row); + } + return wrap; +} + +function peekError(): HTMLElement { + const wrap = mk("div", "peek-empty"); + wrap.appendChild(gl("warning")); + const t = mk("div"); + t.textContent = "Couldn't load this — try again."; + wrap.appendChild(t); + return wrap; +} + +/** A small titled section inside a peek body (e.g. "Commits", "Changed files"). */ +export function peekSection(label: string, count?: number): { root: HTMLElement; body: HTMLElement } { + const root = mk("div", "peek-section"); + const head = mk("div", "peek-section-head"); + const l = mk("span", "peek-section-label"); + l.textContent = label; + head.appendChild(l); + if (typeof count === "number") { + const c = mk("span", "peek-section-count"); + c.textContent = String(count); + head.appendChild(c); + } + const body = mk("div", "peek-section-body"); + root.append(head, body); + return { root, body }; +} + +/** A key→value metadata grid row block (sha, author, dates …). */ +export function peekMetaGrid(rows: Array<[string, string | HTMLElement]>): HTMLElement { + const grid = mk("div", "peek-meta"); + for (const [k, v] of rows) { + if (typeof v === "string" && !v) continue; + const key = mk("div", "peek-meta-key"); + key.textContent = k; + const val = mk("div", "peek-meta-val"); + if (typeof v === "string") val.textContent = v; + else val.appendChild(v); + grid.append(key, val); + } + return grid; +} + +/** A small chip for the peek title row ("current", "↑ 2", "detached"…). */ +export function peekChip(text: string, kind: "accent" | "ok" | "warn" | "muted" = "muted"): HTMLElement { + const c = mk("span", `peek-chip peek-chip-${kind}`); + c.textContent = text; + return c; +} diff --git a/apps/desktop/src/renderer/peeks.ts b/apps/desktop/src/renderer/peeks.ts new file mode 100644 index 0000000..c687eef --- /dev/null +++ b/apps/desktop/src/renderer/peeks.ts @@ -0,0 +1,246 @@ +// The concrete peek cards for git objects — branch, remote branch, tag, commit, +// stash. This is what makes every row in the native sections BROWSABLE: click a +// branch and you're reading its history; click a commit inside and you've +// drilled into its files; ← walks back out. Actions that change the repo are +// delegated to the App through GitPeekHost so refresh/toast behavior stays in +// one place; read-only data loads straight over IPC here. + +import { host } from "./bridge"; +import { + openPeek, + peekChip, + peekMetaGrid, + peekSection, + type PeekCard, + type PeekContext, +} from "./peek"; +import { el, span, glyph, relTime, absTime, avatarHue, initials, copyText } from "./ui"; +import { toast, confirmDialog } from "./dialogs"; +import type { + BranchInfo, + CompareCommit, + RefInfo, + StashInfo, + CommitDetailsPayload, +} from "../shared/ipc"; +import type { CommitFileChange } from "@gitstudio/host-bridge/commitDetailsProtocol"; + +/** The App-owned operations a peek card can trigger. Every mutation funnels + * through here so cache-busting, toasts, and view refreshes stay centralized. */ +export interface GitPeekHost { + /** Check out a local branch / remote branch / tag (App resolves semantics). */ + checkout(ref: string): void; + /** The existing per-branch ⋯ actions menu, anchored to a peek button. */ + branchMenu(b: BranchInfo, anchor: HTMLElement): void; + /** Open the Compare view with the current branch as base and `head` as head. */ + compareWith(head: string): void; + /** Jump to a commit in the Commits view (switching views if needed). */ + revealInGraph(sha: string): void; + /** Jump to a ref's row in the Branches view (scroll + flash). */ + openBranch(ref: string): void; + /** Open one commit file's diff in the bottom dock (after a graph reveal). */ + openCommitFile(file: { path: string; status: string }, sha: string): void; + /** A stash was applied/popped/dropped — refresh whatever shows stashes. */ + stashesChanged(): void; +} + +// ── shared row builders ────────────────────────────────────────────────────── + +/** A tiny deterministic author dot (same hue rules as the graph avatars). */ +function authorDot(name: string): HTMLElement { + const dot = el("span", "peek-avatar"); + dot.style.background = avatarHue(name); + dot.textContent = initials(name); + return dot; +} + +/** One commit row inside a peek section; clicking drills into the commit. */ +function commitRow(c: CompareCommit): HTMLElement { + const row = el("button", "peek-row"); + row.append(authorDot(c.author)); + const main = el("div", "peek-row-main"); + const title = el("div", "peek-row-title"); + title.textContent = c.subject || "(no subject)"; + title.title = c.subject; + const sub = el("div", "peek-row-sub"); + sub.textContent = `${c.author} · ${relTime(c.date)}`; + sub.title = absTime(c.date); + main.append(title, sub); + row.appendChild(main); + const side = el("div", "peek-row-side"); + side.append(span(c.shortSha), glyph("chevron-right")); + side.lastElementChild?.classList.add("peek-row-chev"); + row.appendChild(side); + return row; +} + +/** Render a list of commits into a section, wiring each row to drill in. */ +function commitsSection( + gp: GitPeekHost, + ctx: PeekContext, + commits: CompareCommit[], + label = "Recent commits", +): HTMLElement { + const { root, body } = peekSection(label, commits.length); + for (const c of commits) { + const row = commitRow(c); + row.addEventListener("click", () => ctx.push(commitCard(gp, c.sha, c))); + body.appendChild(row); + } + if (!commits.length) { + const none = el("div", "peek-row"); + none.appendChild(span("No commits to show.", "peek-row-sub")); + body.appendChild(none); + } + return root; +} + +/** One changed-file row. For real commits, clicking lands in Commits with this + * diff open; `nav: false` renders a read-only row (stashes aren't graph rows, + * so there is nowhere to navigate to). */ +function fileRow( + gp: GitPeekHost, + ctx: PeekContext, + f: CommitFileChange, + sha: string, + nav = true, +): HTMLElement { + const row = el(nav ? "button" : "div", "peek-row"); + const st = (f.status || "M").toUpperCase().charAt(0); + const cls = st === "A" ? "add" : st === "D" ? "del" : st === "R" || st === "C" ? "ren" : "mod"; + const letter = el("span", `peek-fstat ${cls}`); + letter.textContent = st; + row.appendChild(letter); + const main = el("div", "peek-row-main"); + const title = el("div", "peek-row-title"); + title.textContent = f.oldPath ? `${f.oldPath} → ${f.path}` : f.path; + title.title = title.textContent; + main.appendChild(title); + row.appendChild(main); + const side = el("div", "peek-row-side"); + if (f.additions >= 0 || f.deletions >= 0) { + const stat = el("span", "peek-diffstat"); + if (f.additions > 0) stat.appendChild(span(`+${f.additions}`, "plus")); + if (f.deletions > 0) stat.appendChild(span(`−${f.deletions}`, "minus")); + if (stat.childElementCount) side.appendChild(stat); + } + if (nav) { + const chev = glyph("chevron-right"); + chev.classList.add("peek-row-chev"); + side.appendChild(chev); + row.title = `Open ${f.path} in Commits`; + row.addEventListener("click", () => { + ctx.close(); + gp.revealInGraph(sha); + gp.openCommitFile({ path: f.path, status: f.status }, sha); + }); + } + row.appendChild(side); + return row; +} + +// ── the commit card ────────────────────────────────────────────────────────── + +/** A commit's drill-in card. `brief` (when the opener already had the row data) + * gives the header its real title synchronously; otherwise the sha stands in + * until the details load and retitle the card. */ +export function commitCard(gp: GitPeekHost, sha: string, brief?: CompareCommit): PeekCard { + return { + icon: "git-commit", + title: brief?.subject || `Commit ${sha.slice(0, 7)}`, + subtitle: brief ? `${brief.author} · ${relTime(brief.date)}` : sha.slice(0, 7), + actions: [ + { + label: "Copy SHA", + icon: "copy", + onClick: () => void copyText(sha, "Commit SHA copied."), + }, + { + label: "View in Commits", + icon: "git-commit", + primary: true, + title: "Reveal this commit in the Commits view", + onClick: (ctx) => { + ctx.close(); + gp.revealInGraph(sha); + }, + }, + ], + async render(body, ctx) { + const d: CommitDetailsPayload | undefined = await host.invoke("commit:details", sha); + body.replaceChildren(); + if (!d) { + const none = el("div", "peek-empty"); + none.append(glyph("git-commit"), span("This commit couldn't be loaded.")); + body.appendChild(none); + return; + } + ctx.retitle(d.subject || `Commit ${d.shortSha}`, `${d.author} · ${relTime(d.authorDate)}`); + + const shaVal = el("span", "peek-mono"); + shaVal.textContent = d.shortSha; + shaVal.title = d.sha; + const parents = el("span"); + for (const p of d.parents) { + const chip = el("button", "peek-parent"); + chip.textContent = p.slice(0, 7); + chip.title = `Peek parent ${p.slice(0, 7)}`; + chip.addEventListener("click", () => ctx.push(commitCard(gp, p))); + parents.appendChild(chip); + } + const meta: Array<[string, string | HTMLElement]> = [ + ["Commit", shaVal], + ["Author", `${d.author} <${d.authorEmail}>`], + ["Date", absTime(d.authorDate)], + ]; + if (d.committer && d.committer !== d.author) { + meta.push(["Committer", `${d.committer} <${d.committerEmail}>`]); + } + if (d.parents.length) meta.push([d.parents.length > 1 ? "Parents" : "Parent", parents]); + if (d.refs.length) { + const refs = el("span"); + for (const r of d.refs) { + // Ref chips NAVIGATE: clicking one lands on that ref's row in the + // Branches view (scrolled + flashed) — every label is a link. + const chip = el("button", "peek-refchip"); + chip.appendChild( + peekChip(r.name, r.kind === "tag" ? "warn" : r.kind === "currentHead" ? "accent" : "muted"), + ); + chip.title = `Show ${r.name} in Branches`; + chip.addEventListener("click", () => { + ctx.close(); + gp.openBranch(r.name); + }); + refs.appendChild(chip); + } + meta.push(["Refs", refs]); + } + body.appendChild(peekMetaGrid(meta)); + + const msg = el("pre", "peek-msg"); + msg.textContent = d.body ? `${d.subject}\n\n${d.body}` : d.subject; + body.appendChild(msg); + + const { root, body: fbody } = peekSection("Changed files", d.files.length); + for (const f of d.files) fbody.appendChild(fileRow(gp, ctx, f, d.sha)); + if (!d.files.length) { + const none = el("div", "peek-row"); + none.appendChild(span("No files changed.", "peek-row-sub")); + fbody.appendChild(none); + } + body.appendChild(root); + }, + }; +} + +// ── branch / remote / tag cards ────────────────────────────────────────────── + +/* + * `openBranchPeek`, `openRefPeek` and `openStashPeek` lived here. + * + * A ref's history was a modal: no route, no entry in the back stack, no + * ⌘[ / ⌘], and gone on Escape. Worse, for a remote branch, a tag and a stash + * that modal was the ONLY door to every action those rows had, because none of + * them carried any. Each kind has a PAGE now — `views/refDetail.ts`, routed as + * "refdetail" — and the rows carry their verbs at rest. + */ diff --git a/apps/desktop/src/renderer/prefs.ts b/apps/desktop/src/renderer/prefs.ts new file mode 100644 index 0000000..a7bae96 --- /dev/null +++ b/apps/desktop/src/renderer/prefs.ts @@ -0,0 +1,29 @@ +/** + * The handful of user preferences a VIEW needs to honour. + * + * `App` owns the preferences blob and parses it into fields, which is fine for + * the app shell — but a view module cannot reach those fields, so it passed + * `undefined` and silently ignored the setting. That is how Settings → + * "Prune on fetch" came to be honoured by two of the app's four fetch buttons. + * + * Read from the same localStorage key `App` persists to, so there is one source + * of truth and no wiring to forget. + */ +const PREFS_KEY = "gitstudio.ui.prefs"; + +function read(): Record<string, unknown> { + try { + const raw = localStorage.getItem(PREFS_KEY); + const v: unknown = raw ? JSON.parse(raw) : undefined; + return v && typeof v === "object" ? (v as Record<string, unknown>) : {}; + } catch { + return {}; + } +} + +/** Fetch with `--prune`, so branches deleted on the remote drop out (issue #23). + * Defaults to true, matching `App`'s own default. */ +export function pruneOnFetch(): boolean { + const v = read().pruneOnFetch; + return typeof v === "boolean" ? v : true; +} diff --git a/apps/desktop/src/renderer/proseNav.ts b/apps/desktop/src/renderer/proseNav.ts new file mode 100644 index 0000000..4f7dc6b --- /dev/null +++ b/apps/desktop/src/renderer/proseNav.ts @@ -0,0 +1,200 @@ +// Make rendered markdown NAVIGATE instead of eject. Every prose surface +// (README cards, PR/issue bodies, comments, release notes) is full of GitHub +// links and bare #123 references; they all used to bounce to the browser — +// the single biggest "features acting separated" seam. Wired through here: +// +// • a link to an issue/PR in the OPEN repo → the in-app section, deep-linked +// • a link to an issue/PR in ANY OTHER repo → the in-app read-only viewer +// • bare `#123` text → clickable, same rules +// • anything else → the browser, as before +// +// One delegated listener per container; re-rendered bodies need no re-wiring. + +import { host } from "./bridge"; +import { highlightProse } from "./highlight"; +import { parseGitHubItemUrl } from "./ui"; +import { openExternalItem } from "./views/notifications"; +import type { SectionNav } from "./views/common"; + +/** Wire a rendered-markdown container — ONCE, on a stable pane. A + * MutationObserver keeps re-rendered bodies linkified, so callers never + * re-wire. `nav` routes same-repo items to their section; without it + * (surfaces outside the section system) everything opens in the read-only + * viewer. `refRepo` overrides which repo a bare `#123` belongs to (a browsed + * remote repo's README means ITS issues, not the open repo's). */ +export function wireProseNav( + container: HTMLElement, + nav?: SectionNav, + refRepo?: { owner: string; repo: string }, + /** Handle RELATIVE links (README "./docs/x.md") — without this they were + * silently dead (blocked navigation, no browser, nothing). The handler + * receives the raw relative path; resolve it with resolveRelative(). */ + onRelative?: (path: string) => void, +): void { + // Bare-#N linkification only when there's a repo to resolve them against — + // otherwise the "link" was a styled dead click. Absolute-URL handling below + // never needs this gate. + let refsEnabled = !!refRepo; + if (refsEnabled) linkifyIssueRefs(container); + else { + void host + .invoke("github:status", undefined) + .then((s) => { + if (s.repo && container.isConnected) { + refsEnabled = true; + linkifyIssueRefs(container); + } + }) + .catch(() => {}); + } + highlightProse(container); + // Coalesce mutation work: N async colorize completions used to trigger N + // full TreeWalker passes; one rAF batches them all. + let scheduled = false; + let mutating = false; + const obs = new MutationObserver(() => { + if (mutating || scheduled) return; + scheduled = true; + requestAnimationFrame(() => { + scheduled = false; + mutating = true; + try { + if (refsEnabled) linkifyIssueRefs(container); + highlightProse(container); + } finally { + mutating = false; + } + }); + }); + obs.observe(container, { childList: true, subtree: true }); + container.addEventListener("click", (e) => { + const a = (e.target as HTMLElement).closest?.("a[href]"); + if (!(a instanceof HTMLAnchorElement) || !container.contains(a)) return; + const href = a.getAttribute("href") ?? ""; + + // Relative links first: they belong to the document's own repo. + if ( + onRelative && + href && + !href.startsWith("#") && + !/^[a-z][a-z0-9+.-]*:/i.test(href) && + !href.startsWith("//") + ) { + e.preventDefault(); + e.stopPropagation(); + onRelative(href.split("#")[0].split("?")[0]); + return; + } + + // Bare #123 spans linkified below carry just the number. + const refNum = a.dataset.ghref ? Number(a.dataset.ghref) : undefined; + const hit = refNum ? undefined : parseGitHubItemUrl(href); + // Commit links land in the Commits view when they're the open repo's. + const commit = hit || refNum + ? undefined + : /github\.com\/([^/]+\/[^/]+?)\/commit\/([0-9a-f]{7,40})\b/i.exec(href); + if (!hit && !refNum && !commit) return; // a normal link — default flow (browser) + + e.preventDefault(); + e.stopPropagation(); + void (async () => { + let status: { connected: boolean; repo?: { owner: string; repo: string } }; + try { + status = await host.invoke("github:status", undefined); + } catch { + status = { connected: false }; + } + const open = status.repo ? `${status.repo.owner}/${status.repo.repo}`.toLowerCase() : ""; + + if (refNum) { + // #123 means "the repo this prose belongs to" — the browsed remote + // repo when refRepo is set, otherwise the open repo. GitHub's issues + // namespace covers PRs too, so Issues is always a safe landing. + const home = refRepo ?? status.repo; + if (!refRepo && nav && open) { + nav("issues", { number: refNum }); + } else if (home && status.connected) { + openExternalItem({ + owner: home.owner, + repo: home.repo, + number: refNum, + kind: "issue", + htmlUrl: `https://github.com/${home.owner}/${home.repo}/issues/${refNum}`, + }); + } + return; + } + if (commit) { + if (nav && commit[1].toLowerCase() === open) nav("commit", { sha: commit[2] }); + else window.open(href, "_blank"); + return; + } + if (!hit) return; + if (nav && hit.repo.toLowerCase() === open) { + nav(hit.kind, { number: hit.number }); + return; + } + if (status.connected) { + const [owner, repo] = hit.repo.split("/"); + openExternalItem({ + owner, + repo, + number: hit.number, + kind: hit.kind === "prs" ? "pull" : "issue", + htmlUrl: href, + }); + return; + } + window.open(href, "_blank"); // signed out — the browser is all we have + })(); + }); +} + +/** Resolve "./x", "../y", "docs/z" against a base DIRECTORY ("" = root). */ +export function resolveRelative(baseDir: string, rel: string): string { + const parts = baseDir ? baseDir.split("/").filter(Boolean) : []; + for (const seg of rel.split("/")) { + if (!seg || seg === ".") continue; + if (seg === "..") parts.pop(); + else parts.push(seg); + } + return parts.join("/"); +} + +/** Turn bare `#123` text into clickable references — outside code, pre, and + * existing links. GitHub does this server-side; we do it at wire time. */ +function linkifyIssueRefs(container: HTMLElement): void { + const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + if (!/(^|\s)#\d+/.test(node.textContent ?? "")) return NodeFilter.FILTER_REJECT; + for (let p = node.parentElement; p && p !== container; p = p.parentElement) { + const tag = p.tagName; + if (tag === "A" || tag === "CODE" || tag === "PRE" || tag === "KBD") { + return NodeFilter.FILTER_REJECT; + } + } + return NodeFilter.FILTER_ACCEPT; + }, + }); + const targets: Text[] = []; + for (let n = walker.nextNode(); n; n = walker.nextNode()) targets.push(n as Text); + for (const text of targets) { + const parts = (text.textContent ?? "").split(/(^#\d+\b|(?<=\s)#\d+\b)/); + if (parts.length < 2) continue; + const frag = document.createDocumentFragment(); + for (const part of parts) { + const m = /^#(\d+)$/.exec(part); + if (m) { + const a = document.createElement("a"); + a.href = "#"; + a.dataset.ghref = m[1]; + a.textContent = part; + a.title = `Open ${part} in GitStudio`; + frag.appendChild(a); + } else if (part) { + frag.appendChild(document.createTextNode(part)); + } + } + text.replaceWith(frag); + } +} diff --git a/apps/desktop/src/renderer/renderer.ts b/apps/desktop/src/renderer/renderer.ts index 771e67d..dc4d641 100644 --- a/apps/desktop/src/renderer/renderer.ts +++ b/apps/desktop/src/renderer/renderer.ts @@ -11,7 +11,13 @@ // palette + gutter chrome, and the graph host-page frame. The renderer carries // the same look as the extension because it ships the same CSS. import "@gitstudio/webview-ui/styles/diff.css"; -import { clickIntent, rangeBetween, reconcile, rowKey, selectionEntries, selectionPaths } from "./selection"; +import { clickIntent, parseRowKey, rangeBetween, reconcile, rowKey, selectionEntries, selectionPaths } from "./selection"; +import { installNavStack } from "./navStack"; +import { renderCommit } from "./views/commit"; +import { renderJobLog } from "./views/jobLog"; +import { renderReleaseCompose } from "./views/releaseCompose"; +import { renderIssueCompose } from "./views/issueCompose"; +import { renderRefDetail } from "./views/refDetail"; import "@gitstudio/webview-ui/styles/graph.css"; import "@gitstudio/webview-ui/commit-details"; import "./styles/app.css"; @@ -23,16 +29,15 @@ import { applyTheme, followSystemTheme, resolveTheme } from "./desktopTheme"; import type { AppTheme, ThemeMode, LogoMode } from "./desktopTheme"; import { GraphMount } from "./graphMount"; import { DiffPanel } from "./diffPanel"; -import { CompareDiff } from "./compareDiff"; import { ReadonlyFileView } from "./readonlyFileView"; import { renderMarkdown } from "./markdown"; -import { renderAssistant } from "./assistant"; +import { renderAssistant, seedAssistantGoal } from "./assistant"; import { aiModelsCard, agentAccessCard } from "./aiSettings"; import { aiChip, openAssistantTab, registerAssistantTab, streamInto, aiEnabled } from "./aiAssist"; -import { toast, confirmDialog, promptInline } from "./dialogs"; +import { toast, confirmDialog, promptInline, openModal } from "./dialogs"; import { TerminalDock } from "./terminalDock"; import { openCloneDialog } from "./cloneDialog"; -import { gget, peek, bust, setCacheScope } from "./cache"; +import { gget, peek, bust, setCacheScope, swr, sameData} from "./cache"; import { el, span, @@ -42,6 +47,7 @@ import { relTimeISO, initials, avatarHue, + avatarInk, avatar, fileIcon, formatBytes, @@ -60,20 +66,38 @@ import { brandMark, openMenu, wireResizerKeys, + middleTruncate, + markSegment, + statusWord, } from "./ui"; import type { MenuItem } from "./ui"; +import { plural } from "./textFit"; +import { dismissLayers, pageOwnsKeys } from "./overlays"; +import { setFocusScope, clearFocusReturn } from "./focusReturn"; +import { closePeek } from "./peek"; +import type { GitPeekHost } from "./peeks"; import { CommitContextMenu } from "./contextMenu"; +import { wireListNav, commitList, ghHeader, searchField, segmented, secRow, facetBar } from "./views/common"; +import { resolveRelative, wireProseNav } from "./proseNav"; +import { refreshHighlightTheme } from "./highlight"; +import { openCommandPalette, paletteIsOpen } from "./commandPalette"; +import type { PaletteGroup, PaletteItem } from "./commandPalette"; import type { SectionRender, SectionTarget } from "./views/common"; -import { renderIssues } from "./views/issues"; +import type { FacetSpec, FacetState } from "./facetModel"; +import { renderIssues, openNewIssue } from "./views/issues"; +import { renderMyWork } from "./views/mywork"; import { renderPrs, openCreatePr } from "./views/prs"; import { renderActions } from "./views/actions"; import { renderReleases } from "./views/releases"; -import { openNotificationsPanel, fetchUnreadCount } from "./views/notifications"; -import { renderOrgs } from "./views/orgs"; +import { openNotificationsPanel, fetchUnreadCount, renderNotifications } from "./views/notifications"; +import { renderExplore } from "./views/explore"; +import { repoRouteId, searchTargetId } from "./exploreRoutes"; +import { renderOrgs, setPeekNav } from "./views/orgs"; import { renderProjects } from "./views/projects"; import { renderGists } from "./views/gists"; import { renderRebase } from "./views/rebase"; import type { CommitDetails as CommitDetailsEl } from "@gitstudio/webview-ui/commit-details"; +import { COLUMN_DROP_TAIL_AT } from "@gitstudio/webview-ui/limits"; import type { BranchInfo, ChangedFile, @@ -83,6 +107,9 @@ import type { HeadCommit, IssueInfo, MergeMethod, + AppSettingsView, + HeadInfo, + LocalCopy, PrDetail, ProjectInfo, PullRequest, @@ -90,6 +117,8 @@ import type { GitHubStatus, RepoInfo, SshKey, + StashInfo, + WorktreeInfo, SyncStatus, } from "../shared/ipc"; @@ -101,16 +130,56 @@ class App { private detailsEl?: HTMLElement; /** The commit-details column beside the graph (commits view). */ private graphDetailsPane?: HTMLElement; + /** The kept-alive Commits view DOM — re-attached on return, never rebuilt. */ + private graphViewWrap?: HTMLElement; + /** Re-clamps the graph/details split when the pane's box changes. */ + private graphSplitRO?: ResizeObserver; + /** The top-bar account chip's re-reader, so signing in or out can refresh it. */ + private syncAccountChip?: () => Promise<void>; + /** The live composer's label writer, so HEAD resolving can refresh it. */ + private syncCommitLabel?: () => void; + /** + * What the Changes view had open and where it was scrolled. + * + * Every per-row Stage / Unstage / Discard, Stage all, Stash and Refresh ends + * in `showChangesView()`, which replaces the whole subtree — so the diff you + * were reading closed, the row you had selected deselected, and the list + * jumped back to the top. Staging one file in a list of forty meant finding + * your place again, every single time. + * + * Remembered as a row KEY (`kind:path`), never a bare path: a partially-staged + * file — git's `MM`, a staged edit plus a newer unstaged one — is deliberately + * TWO rows sharing one path, and only the kind says which half is open. + */ + private changesOpenKey?: string; + private changesScroll = 0; + /** The repo changed while the graph was parked — reload in place on return. */ + private graphDirty = false; private diffSurfaceEl?: HTMLElement; private repoSwitchName?: HTMLElement; private branchSwitchName?: HTMLElement; private notifBellBadge?: HTMLElement; + /** The resolved HEAD from `head:get` — the authoritative answer to "which + * branch am I on?", and the one the top bar already uses. */ + private headInfo?: HeadInfo; private selectedSha?: string; private currentRepo?: RepoInfo; private refs: RefInfo[] = []; private viewHost!: HTMLElement; private navButtons: HTMLElement[] = []; - private currentView = "code"; + /** + * The view the app opens on before any preference is restored. + * + * Not "code". A desktop Git client's first screen should answer a question + * you actually have when you open it — what have I changed, what is staged, + * what am I about to commit — and Changes is the surface that does. The file + * tree answered none of them, and the files are already open in the editor + * the user just came from. + * + * A returning user does not see this at all: `prefs.currentView` puts you + * back on the surface you were last working in. + */ + private currentView = "changes"; /** Guards re-entrant disk-triggered refreshes (see refreshFromDisk). */ private refreshingFromDisk = false; /** "split" (staged/unstaged groups) or "checkboxes" (one ticked list) — issue #16. */ @@ -143,23 +212,44 @@ class App { * typing a message and then staging one more file discarded the message — * along with the amend / sign-off toggles and any co-authors. */ + /** The repo root `composerDraft` was typed in. See showRepoScreen. */ + private composerDraftRoot?: string; private composerDraft: { message: string; amend: boolean; signoff: boolean; coAuthors: string[]; + /** + * The text Amend PUT in the box, so un-ticking can tell "the previous + * commit's message, untouched" from "something the user wrote". + * + * This lived as a render-local `let` while every sibling piece of composer + * state lived here — so the withdrawal only worked inside a single render. + * Every stage, unstage, discard, stash, Refresh, route change and + * filesystem-watcher tick rebuilds the composer, which means the guard was + * almost never in force: tick Amend to look at the last message, change your + * mind, untick, and the box kept the previous commit's message while the + * toggle, the button label and the branch line all returned to the + * new-commit shape. Committing then duplicated someone else's subject. + */ + prefilled?: string; + /** Where the caret was, so a rebuild can put it back. */ + caret?: { start: number; end: number }; } = { message: "", amend: false, signoff: false, coAuthors: [] }; /** A pending deep-link target for the next section mount (e.g. an issue number * to open from the project board). Consumed + cleared by mountSection. */ private sectionTarget?: SectionTarget; + /** In-app navigation history — every routed view (with its deep-link target) + * lands here so ⌘[/⌘] and the top-bar chevrons walk back/forward like a real + * app. Reset on repo switch (entries would point into the previous repo). */ + private navHistory: Array<{ view: string; target?: SectionTarget; label?: string }> = []; + private navPos = -1; + /** True while back/forward drives routeView, so the travel isn't re-recorded. */ + private navTravel = false; + private navBackBtn?: HTMLButtonElement; + private navFwdBtn?: HTMLButtonElement; /** Current directory inside the Code (repo browser) view; "" = repo root. */ private codePath = ""; - /** Branches view: per-category collapse memory (label → collapsed), persisted - * across re-renders so checkout/new/delete/filter don't reset expand state. */ - private branchCatsCollapsed: Record<string, boolean> = Object.create(null) as Record< - string, - boolean - >; private compareBase?: string; private compareHead?: string; private compareMode: CompareMode = "three-dot"; @@ -187,9 +277,44 @@ class App { * back to a view restores it instantly instead of rebuilding from scratch. * Cleared on a repo switch; busted per-view on an explicit refresh. */ private viewCache = new Map<string, HTMLElement>(); + /** Where each kept-alive view was scrolled when it was parked. Keyed by the + * node itself, so a rebuilt view never inherits the old one's position. */ + private viewScroll = new WeakMap<HTMLElement, [HTMLElement, number, number, boolean][]>(); /** Views safe to keep alive (no Monaco surface / dispose lifecycle of their own). */ + /** A search that came back rate-limited is a REFUSAL, not an answer — caching + * it makes every retry a cache hit for the whole TTL. See cache.gget. */ + private static readonly SEARCH_KEEP = { cacheable: (r: { limited?: unknown }) => !r.limited }; + + /** Every scrolled element inside a view, with where it was. + * + * Whole-subtree, not just the outermost scroller: these views nest them — + * a list pane beside a detail pane, a rail beside a log — and restoring only + * the outer one puts you back at the top of the part you were reading. */ + private static scrollSnapshot(root: HTMLElement): [HTMLElement, number, number, boolean][] { + const out: [HTMLElement, number, number, boolean][] = []; + const walk = (n: HTMLElement): void => { + // NOT INTO MONACO. It manages its own viewport — partly by transform, + // partly by scrollTop on nodes it recreates — and restores its position + // from the model when it is re-attached. Snapshotting those nodes and + // writing them back afterwards can only fight it, and this harness cannot + // catch that: with the animation frame starved, Monaco never lays out, so + // its internal scrollers all read 0 here and the walk looks harmless. + if (n.classList.contains("monaco-editor")) return; + if (n.scrollTop > 0 || n.scrollLeft > 0) { + // …and whether that offset WAS the bottom, which is a different + // intention from "this many pixels down" for anything still growing. + const atTail = n.scrollHeight - n.scrollTop - n.clientHeight <= 24; + out.push([n, n.scrollTop, n.scrollLeft, atTail]); + } + for (const kid of n.children) walk(kid as HTMLElement); + }; + walk(root); + return out; + } + private static readonly KEEPALIVE = new Set([ "branches", + "explore", "settings", "assistant", "prs", @@ -199,7 +324,14 @@ class App { "orgs", "projects", "gists", + "notifications", + "mywork", ]); + /** Whether the working tree has anything to commit. Read by the composer's + * enable rule, which used to gate on the message text alone and offered a + * live Commit button over a clean tree. */ + private changesHaveWork = false; + private syncCommitEnabled: (() => void) | undefined; /** True while a fetch/pull/push is in flight — locks the sync trigger. */ private syncing = false; /** Theme preference: follow the OS, or pin light/dark. */ @@ -222,6 +354,9 @@ class App { private terminalHeight = 280; async start(): Promise<void> { + // Views can pop the history from here on. Before this the only way back + // from a detail page was a forward navigation dressed as a back button. + this.installNav(); // Catch-all error boundary: a rejected promise or thrown render should never // leave the app silently broken — surface it as a toast. BUT skip the benign // Monaco worker noise (it asks the base worker for TS language-service methods @@ -254,6 +389,55 @@ class App { } else if (e.key === "`") { e.preventDefault(); this.toggleTerminal(); + } else if (e.key === "[") { + e.preventDefault(); + this.navBack(); + } else if (e.key === "]") { + e.preventDefault(); + this.navForward(); + } else if (e.key === "k" || e.key === "p") { + // The Linear move: everything — sections, branches, PRs, repos, + // actions — one keystroke away, from anywhere. One carve-out: on + // macOS, Ctrl+K/Ctrl+P are kill-line / previous-line inside text + // fields — leave those to the field (⌘K still opens the palette). + const t = e.target as HTMLElement | null; + const editable = + !!t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable); + if (editable && e.ctrlKey && !e.metaKey && navigator.platform.toLowerCase().includes("mac")) { + return; + } + e.preventDefault(); + if (!paletteIsOpen()) this.openPalette(); + } + }); + + // "?" opens the keyboard cheat sheet — the j/k/e/Esc layer is worthless + // if nobody can discover it. + window.addEventListener("keydown", (e) => { + if (e.key !== "?" || e.metaKey || e.ctrlKey || e.altKey) return; + if (!this.currentRepo) return; + const t = e.target as HTMLElement | null; + if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return; + // A PAGE-LEVEL key, so any open layer outranks it — including the sheet + // itself. Skipping the text-field check alone let "?" open a second + // identical sheet over the first (its own first focusable is a button, + // not a field), and a third, and a fourth — each needing its own Escape. + // It also fired straight through an open dropdown or dialog. + if (!pageOwnsKeys()) return; + e.preventDefault(); + openShortcutsHelp(); + }); + + // Mouse back/forward buttons (buttons 3/4) walk the same history — the + // muscle memory every browser user brings to a mouse with side buttons. + window.addEventListener("mouseup", (e) => { + if (!this.currentRepo) return; + if (e.button === 3) { + e.preventDefault(); + this.navBack(); + } else if (e.button === 4) { + e.preventDefault(); + this.navForward(); } }); @@ -268,8 +452,18 @@ class App { if (prefs.compareView === "commits" || prefs.compareView === "files") { this.compareView = prefs.compareView; } - if (prefs.branchCatsCollapsed && typeof prefs.branchCatsCollapsed === "object") { - this.branchCatsCollapsed = prefs.branchCatsCollapsed as Record<string, boolean>; + // `branchCatsCollapsed` is gone with the four collapsible groups it + // remembered — the Branches view shows ONE kind at a time now. An old + // stored value is simply ignored rather than migrated; it described a + // shape that no longer exists. + if ( + prefs.branchTab === "local" || + prefs.branchTab === "remote" || + prefs.branchTab === "tags" || + prefs.branchTab === "stashes" || + prefs.branchTab === "worktrees" + ) { + this.branchTab = prefs.branchTab; } if (prefs.stagingModel === "checkboxes" || prefs.stagingModel === "split") { this.stagingModelPref = prefs.stagingModel; @@ -303,9 +497,14 @@ class App { if (this.themeMode === "system") { applyTheme(osTheme); this.rerenderForTheme(); + // Monaco's token classes are global — without this, every highlighted + // code block keeps the OLD theme's colors after an OS light/dark flip. + refreshHighlightTheme(); this.terminalDock?.applyTheme(); - // An "auto" dock icon must follow the OS flip too. + // An "auto" dock icon must follow the OS flip too — and so must the + // Appearance card's preview OF that icon, which is built once and kept. this.syncDockIcon(); + this.invalidateAppearanceCard(); } }); this.wireHostEvents(); @@ -363,10 +562,10 @@ class App { const actions = el("div", "welcome-actions"); const open = el("button", "btn btn-primary welcome-open"); - open.append(glyph("folder-opened"), span("Open Repository…")); + open.append(glyph("folder-opened"), span("Open repository…")); open.addEventListener("click", () => void this.openRepo()); const clone = el("button", "btn btn-soft welcome-clone"); - clone.append(glyph("cloud-download"), span("Clone…")); + clone.append(glyph("cloud-download"), span("Clone repository…")); clone.addEventListener("click", () => openCloneDialog((root) => void this.openPath(root)), ); @@ -385,6 +584,12 @@ class App { list.appendChild(empty); } else { for (const r of recent) { + // A ROW holding two controls, not one control containing another: a + // recent whose folder has been deleted or moved looked exactly like a + // live one, and there was no way to get rid of it from this screen — + // the only screen you see when no repository is open. Opening it toasts + // "not inside a Git repository" and the row stays, forever. + const rowWrap = el("div", "recent-card-row"); const row = el("button", "recent-card"); const meta = el("div", "recent-card-meta"); const name = el("div", "recent-card-name"); @@ -394,7 +599,24 @@ class App { meta.append(name, path); row.append(glyph("folder"), meta); row.addEventListener("click", () => void this.openPath(r.root)); - list.appendChild(row); + + const forget = el("button", "recent-card-forget") as HTMLButtonElement; + forget.appendChild(glyph("close")); + forget.title = `Forget ${r.name} — the folder itself is not touched`; + forget.setAttribute("aria-label", forget.title); + forget.addEventListener("click", (e) => { + e.stopPropagation(); + void (async () => { + try { + await host.invoke("repos:removeRecent", r.root); + } catch { + /* the list is rebuilt either way */ + } + void this.showWelcome(); + })(); + }); + rowWrap.append(row, forget); + list.appendChild(rowWrap); } } recentWrap.append(title, list); @@ -423,15 +645,34 @@ class App { this.routeGen++; // a repo switch supersedes the previous repo's in-flight work // A new repo invalidates every kept-alive view (they hold the old repo's DOM). this.viewCache.clear(); + // …and the navigation history: its entries (and deep-link targets) belong + // to the previous repo's sections. + this.navHistory = []; + this.navPos = -1; // Namespace (and wipe) the SWR cache so the previous repo's branches/status/ // graph can never bleed into this one. setCacheScope(info.root); - // A half-written commit message belongs to the repo it was typed in. - this.composerDraft = { message: "", amend: false, signoff: false, coAuthors: [] }; + // A different repo makes every remembered row meaningless — issue #31 in + // one repo is not issue #31 in another. + clearFocusReturn(); + // Baseline the on-disk state for this repo, so the FIRST window focus can + // tell "nothing changed" from "no idea" and skip a full refresh it does not + // need. Fire-and-forget: it only has to land before the user alt-tabs. + void this.recordDiskFingerprint(); + // A half-written commit message belongs to the repo it was typed in — and + // that is the rule this line USED to break. It reset unconditionally, so + // "Back to main menu" and straight back into the SAME repo (the recent-repo + // list is right there, one click away) destroyed the message, and so did + // every re-open of the repo you were already in. Ask which repo first. + if (this.composerDraftRoot !== info.root) { + this.composerDraft = { message: "", amend: false, signoff: false, coAuthors: [], prefilled: undefined, caret: undefined }; + this.composerDraftRoot = undefined; + } // Drop the previous repo's graph mount so a refresh from a non-graph view // never reloads stale history. this.graph?.dispose(); this.graph = undefined; + this.graphViewWrap = undefined; // Tear down the previous repo's terminal sessions — a new repo means a new // working directory, so its shells start fresh. this.terminalDock?.dispose(); @@ -448,6 +689,12 @@ class App { const stack = el("div", "main-stack"); this.mainStackEl = stack; const viewHost = el("div", "view-host"); + // Programmatically focusable, not tab-reachable. Closing the terminal dock + // has to put the keyboard back somewhere in the view, and "the first + // focusable thing in it" is a lottery — a toolbar button, whatever happens + // to be first in the DOM. The container itself is the honest answer: Tab + // then continues from the view rather than from the top of the window. + viewHost.tabIndex = -1; this.viewHost = viewHost; stack.append(viewHost); main.append(this.buildNav(), this.buildRailResizer(), stack); @@ -474,17 +721,41 @@ class App { /** The label shown on the divider before this item (defaults to "GitHub"). */ dividerLabel?: string; }> = [ - { id: "code", label: "Code", icon: "code" }, - // `source-control` (not `request-changes`, a PR-review verdict icon) — this - // is the working tree. - { id: "changes", label: "Changes", icon: "source-control" }, + // ORDER IS DAILY USE, and the first entry is the one the app opens on when + // it has no memory of where you were. + // + // Code — a read-only file tree of HEAD — held that slot, and it is the one + // view in this list nothing else in the app ever navigates to: the only two + // callers of routeView("code") are its own folder hop and the unknown-view + // fallback. It is also the view a user least needs from a Git client, since + // the files are already open in their editor. Landing there answered none + // of the questions you open this app with. It keeps its route and its seat; + // it just stops being the front door. + // + // Five rail entries — Changes, Branches, Rebase, Compare, Pull Requests — + // used to be five variations on the same fork-with-two-nodes motif, which + // at 16px in a single column is no icon at all. `git-branch`, `git-compare` + // and `git-pull-request` have the strongest claim on that shape and keep + // it (and are separated in the rail); the other two take glyphs that say + // what those screens actually are. + // + // Changes is a set of pending file diffs, not the SCM view's fork. + { id: "changes", label: "Changes", icon: "diff-multiple" }, { id: "graph", label: "Commits", icon: "git-commit" }, { id: "branches", label: "Branches", icon: "git-branch" }, - // `git-merge` keeps Rebase in the same visual family as the other git tabs - // (commit / branch / compare) instead of a generic list glyph. - { id: "rebase", label: "Rebase", icon: "git-merge" }, { id: "compare", label: "Compare", icon: "git-compare" }, - { id: "prs", label: "Pull Requests", icon: "git-pull-request", divider: true }, + // Rebase here IS an ordered list of commits you reorder and replay — a + // truer picture than `git-merge`, which is a different operation besides. + { id: "rebase", label: "Rebase", icon: "list-ordered" }, + { id: "code", label: "Code", icon: "code" }, + // Inbox first in the GitHub group — the "what needs me" surface (Linear's + // Inbox translated): review requests, mentions, assignments, CI failures. + // The top-bar bell stays for a quick glance; this is the full triage page. + { id: "notifications", label: "Inbox", icon: "inbox", divider: true }, + // "What needs me?" answered in one page: review requests, assignments, + // your own PRs, mentions — each row one click from acting on it. + { id: "mywork", label: "My Work", icon: "person" }, + { id: "prs", label: "Pull Requests", icon: "git-pull-request" }, // `issues` (the list glyph) rather than `issue-opened`, which reads as a // single issue's OPEN state and clashed with per-issue status icons. { id: "issues", label: "Issues", icon: "issues" }, @@ -492,6 +763,9 @@ class App { { id: "actions", label: "Actions", icon: "play-circle" }, { id: "releases", label: "Releases", icon: "tag" }, { id: "projects", label: "Projects", icon: "project" }, + // Account-scoped (not repo-scoped) surfaces get their own quiet group. + // Explore leads it: discovery comes before the things you already have. + { id: "explore", label: "Explore", icon: "telescope", divider: true, dividerLabel: "Account" }, { id: "orgs", label: "Organizations", icon: "organization" }, // `gist` — was `code`, a duplicate of the Code tab's glyph. { id: "gists", label: "Gists", icon: "gist" }, @@ -538,6 +812,11 @@ class App { App.TABS.forEach((it, i) => { if (it.divider) { const sep = el("div", "nav-divider"); + // Collapsed to icons the label is hidden, so the group's name lives on + // the rule itself. + sep.title = it.dividerLabel ?? "GitHub"; + sep.setAttribute("role", "separator"); + sep.setAttribute("aria-label", sep.title); sep.setAttribute("aria-hidden", "true"); sep.append(span(it.dividerLabel ?? "GitHub", "nav-divider-label")); nav.appendChild(sep); @@ -656,7 +935,9 @@ class App { if (!this.mainStackEl || this.terminalDock) return this.terminalDock; this.terminalDock = new TerminalDock(this.mainStackEl, { expanded: this.terminalExpanded, - height: this.terminalHeight, + // Never let a restored dock eat the window — the screenshot that + // triggered this fix had it at ~60% height, drowning the actual app. + height: Math.min(this.terminalHeight, Math.round(window.innerHeight * 0.4)), onStateChange: ({ expanded, height }) => { this.terminalExpanded = expanded; this.terminalHeight = height; @@ -665,8 +946,22 @@ class App { }); // Shrinking the window must not leave the dock covering the whole view. window.addEventListener("resize", () => this.terminalDock?.handleWindowResize()); - // The ✨ inline AI actions open their chat tabs in this dock. - registerAssistantTab((req) => this.terminalDock?.openChat(req)); + // The ✨ inline AI actions land in the ASSISTANT SECTION — one AI surface, + // full height, instead of a chat tab splitting the window in half from + // the bottom dock. + registerAssistantTab((req) => { + // The TITLE is what the user bubble says — "Analyze #42", not the whole + // prompt the action builds around the issue body and its comments. + // Only route if the goal was not taken by an Assistant already on screen. + // `force: true` drops the view from the cache and rebuilds it, so firing + // a second ✨ action while the agent was answering the first destroyed + // the transcript and the Stop button and orphaned the run. + // NEVER with force when the goal was taken. `force` is what drops the + // kept-alive mount and rebuilds it — the unforced route re-attaches the + // same node, with its transcript and its live Stop button intact. + if (seedAssistantGoal(req.goal, req.title)) this.routeView("assistant", true); + else this.routeView("assistant"); + }); return this.terminalDock; } @@ -678,7 +973,7 @@ class App { pruneOnFetch: this.pruneOnFetchPref, compareFileListW: this.compareFileListW, compareView: this.compareView, - branchCatsCollapsed: this.branchCatsCollapsed, + branchTab: this.branchTab, themeMode: this.themeMode, logoMode: this.logoMode, railWidth: this.railWidth, @@ -694,9 +989,12 @@ class App { this.themeMode = mode; applyTheme(resolveTheme(mode)); this.rerenderForTheme(); + // Recolor every highlighted code block for the new palette. + refreshHighlightTheme(); this.terminalDock?.applyTheme(); // An "auto" dock icon follows the new theme. this.syncDockIcon(); + this.invalidateAppearanceCard(); this.persist(); } @@ -704,9 +1002,33 @@ class App { private setLogoMode(mode: LogoMode): void { this.logoMode = mode; this.syncDockIcon(); + this.invalidateAppearanceCard(); this.persist(); } + /** + * Settings holds a kept-alive DOM built when it was last rendered, so its + * Appearance card kept showing whichever theme was selected THEN. Changing + * the theme from anywhere else — ⌘K, the menu, an OS light/dark flip — left + * the segment highlighting the old mode and the App-icon preview painting + * the old variant, both stating as fact something that had already changed. + */ + private invalidateAppearanceCard(): void { + // Update the card IN PLACE. It used to rebuild the whole Settings view — + // which throws away every other card's in-progress state, so changing the + // theme with ⌘K while half-way through typing a Git identity, an SSH + // passphrase or a clone folder destroyed what was typed. That is the same + // rule this codebase already enforces everywhere else ("a form is not the + // app's to throw away"), broken by the fix for the card NEXT to it. + this.syncAppearanceCard?.(); + // The cached DOM is still correct, because the card just updated itself — + // but a card built LATER must start from the current values, and that is + // what showSettingsView does on a fresh build. + } + + /** Re-sync the Appearance card's own controls, set when that card is built. */ + private syncAppearanceCard?: () => void; + /** The dock icon variant to show: pinned light/dark, or (auto) the resolved theme. */ private dockVariant(): AppTheme { return this.logoMode === "auto" ? resolveTheme(this.themeMode) : this.logoMode; @@ -717,24 +1039,193 @@ class App { void host.invoke("appearance:dockIcon", { variant: this.dockVariant() }).catch(() => {}); } + /** Step back in the in-app navigation history (⌘[ / topbar chevron). */ + private navBack(): boolean { + if (this.navPos <= 0) return false; + this.navPos--; + this.navTravelTo(this.navHistory[this.navPos]); + return true; + } + + /** + * Hand the history to the views, so a detail page's own Back can POP. + * + * It used to PUSH — every `.det-back` called `nav(view, {list:true})`, which + * appends. Measured: pressing back left FORWARD disabled, which only happens + * if nothing was stepped over. The one control that should restore your place + * was the one destroying it. + */ + private installNav(): void { + installNavStack({ + back: () => this.navBack(), + prev: () => (this.navPos > 0 ? this.navHistory[this.navPos - 1] : undefined), + label: (label: string) => { + const cur = this.navHistory[this.navPos]; + if (cur) cur.label = label; + }, + retarget: (patch) => { + const cur = this.navHistory[this.navPos]; + if (cur) cur.target = { ...(cur.target ?? {}), ...patch } as SectionTarget; + }, + }); + } + + /** Step forward in the in-app navigation history (⌘] / topbar chevron). */ + private navForward(): void { + if (this.navPos >= this.navHistory.length - 1) return; + this.navPos++; + this.navTravelTo(this.navHistory[this.navPos]); + } + + private navTravelTo(entry: { view: string; target?: SectionTarget }): void { + this.navTravel = true; + try { + // Same-view travel must FORCE: without it routeView's "already showing" + // early-return swallowed the hop (navPos moved, nothing on screen + // changed — Back looked dead). Cross-view travel stays unforced so + // kept-alive views restore from cache. + this.routeView(entry.view, entry.view === this.currentView, entry.target); + } finally { + this.navTravel = false; + } + this.updateNavButtons(); + } + + /** Enable/disable the top-bar back/forward chevrons to match the stack. */ + private updateNavButtons(): void { + if (this.navBackBtn) this.navBackBtn.disabled = this.navPos <= 0; + if (this.navFwdBtn) this.navFwdBtn.disabled = this.navPos >= this.navHistory.length - 1; + } + /** Swap the main area to the chosen view's surface. `target` deep-links a * specific item in a section view (e.g. opening an issue from the project * board) — keeping navigation inside the app instead of bouncing to GitHub. */ private routeView(id: string, force = false, target?: SectionTarget): void { + // Where the app actually navigated, for the harness to assert against. + // Several defects are "it went somewhere else" — a commit reference + // ejecting you into the graph, a back button landing on a list — and none + // of them were checkable, because a check can see the DOM that resulted + // but not the route that produced it. Costs one array push in a build the + // harness page is the only consumer of; `__GS_ROUTES` is absent in + // production because nothing ever creates it. + const spy = (window as unknown as { __GS_ROUTES?: Array<{ view: string; target?: SectionTarget }> }) + .__GS_ROUTES; + if (spy) spy.push({ view: id, target }); + // Any route change dismisses every floating layer — a peek, a menu, a + // modal, the palette, the notifications popover. They all mount on + // document.body, so a view swap cannot take them with it: an Inbox facet + // menu used to survive navigation and hover over the next view, filtering + // a list that was no longer on screen. + dismissLayers(); + // Which list a row belongs to, so Escaping out of a detail can put the + // keyboard back on the row you opened instead of on <body>. + setFocusScope(id); + // The Assistant has no rail item to light up; its launcher is its tab. + queueMicrotask(() => this.syncAssistantChip?.()); // Deep-linking an item must rebuild the section so it can select that item — - // never restore a stale cached view (which wouldn't have it open). - if (target) force = true; + // never restore a stale cached view (which wouldn't have it open). The ONE + // exception: a sha-only graph reveal, which works against the live + // kept-alive mount — forcing would tear it down and refetch history for a + // scroll that needs nothing rebuilt. + const shaOnlyGraphReveal = + id === "graph" && + !!target?.sha && + target.number === undefined && + target.ref === undefined && + target.path === undefined; + if (target && !shaOnlyGraphReveal) force = true; this.sectionTarget = target; // Re-clicking the section you're already on (or navigating to it) should do // nothing — the view is already there. Only an explicit refresh rebuilds. if (!force && id === this.currentView && this.viewHost.firstChild) { + if (shaOnlyGraphReveal && target?.sha) this.revealWhenReady(target.sha); return; } + // The Code browser's identity includes its folder: a plain "code" route is + // normalized to carry the CURRENT folder, so its history entry restores the + // exact place on back/forward instead of whatever codePath happens to be. + if (id === "code" && !target) target = { path: this.codePath }; + // Record real navigation (not back/forward travel) in the history stack. + // A forward-truncate on push gives browser semantics: navigating after + // going back discards the abandoned forward entries. + if (!this.navTravel) { + const last = this.navHistory[this.navPos]; + const same = (a?: SectionTarget, b?: SectionTarget): boolean => + a?.number === b?.number && + a?.jobId === b?.jobId && + a?.id === b?.id && + a?.sha === b?.sha && + a?.path === b?.path && + // The FILE too. Without it, opening a file from the listing it lives in + // compares equal to the listing (same `path`), so no history entry was + // pushed: Back skipped past the folder entirely and Forward could never + // return to the file. That is half of what SectionTarget.file was added + // for, and it was silently doing nothing. + a?.file === b?.file && + a?.ref === b?.ref && + (a?.list ?? false) === (b?.list ?? false); + if (!last || last.view !== id || (target && !same(last.target, target))) { + // The forward-truncate belongs HERE, with the push it accompanies — + // browser semantics are "navigating after going back discards the + // abandoned forward entries", and a re-route to the place you are + // already standing is not navigating. + // + // Above the check, it ran on every route that reached this point, + // including the one `refreshAll` performs — which is fired by the file + // watcher on every save, by a window focus whose fingerprint moved, and + // by every git action the app runs. So Forward died constantly, seconds + // after going Back, for reasons the reader could not see. + // The forward-truncate belongs HERE, with the push it accompanies — + // browser semantics are "navigating after going back discards the + // abandoned forward entries", and a re-route to the place you are + // already standing is not navigating. + // + // Above the check, it ran on every route that reached this point, + // including the one `refreshAll` performs — which the file watcher + // fires on every save, a window focus fires whenever the fingerprint + // moved, and every git action the app runs fires too. So Forward died + // constantly, seconds after going Back, for reasons nobody could see. + this.navHistory.splice(this.navPos + 1); + this.navHistory.push({ view: id, target }); + this.navPos = this.navHistory.length - 1; + } + this.updateNavButtons(); + } // Stash the OUTGOING view if it's keep-alive-able, so returning to it later // restores the rendered DOM (scroll, expanded state) instead of refetching. + // + // But only if it actually FINISHED. A section you clicked into and left + // before its data arrived was cached mid-load — skeleton and all — and + // restored from that cache on every later visit, so Issues came back + // permanently empty for the rest of the session and nothing but the header + // refresh button could recover it. The trigger is ordinary: click a section, + // get impatient, click something else. A view that never painted is not a + // view worth keeping. const prev = this.currentView; - if (!force && App.KEEPALIVE.has(prev) && this.viewHost.firstElementChild) { - this.viewCache.set(prev, this.viewHost.firstElementChild as HTMLElement); + const outgoing = this.viewHost.firstElementChild as HTMLElement | null; + const stillLoading = + !!outgoing?.querySelector(".skeleton, .sk-row, .list-loading, .loading-state, .spinner"); + // NOT gated on `force`. `force` means "rebuild the view I am going TO with + // fresh data" — and it is set by every navigation that carries a target, + // which is every navigation INTO a detail page. Letting it also throw away + // the view being left meant opening a branch's page discarded the Branches + // list, so Back rebuilt it from scratch: the filter you had typed to find + // that branch was gone and you were back at the top of ninety rows. The + // incoming view's own cache is still dropped below, which is what force is + // actually for. + if (App.KEEPALIVE.has(prev) && outgoing && !stillLoading) { + // Take the scroll positions BEFORE the node is detached. Detaching zeroes + // every `scrollTop` inside it, so by the time it is re-attached there is + // nothing left to restore — which is why keeping the DOM alive returned + // you to a 90-row branch list, or a long settings page, at the top of it + // every time. The comment above has promised otherwise since it was + // written. + this.viewScroll.set(outgoing, App.scrollSnapshot(outgoing)); + this.viewCache.set(prev, outgoing); + } else if (stillLoading) { + // …and drop any older good copy, so the next visit rebuilds rather than + // restoring something staler than what we just abandoned. + this.viewCache.delete(prev); } if (force) { this.viewCache.delete(id); // a refresh must rebuild with fresh data @@ -751,33 +1242,112 @@ class App { // Done AFTER disposing the Monaco diff (which lives in its surface) so we // never remove a surface with a live editor in it. if (id !== "graph") { - this.closeDiffTab(); + this.closeGraphDiff(); this.detailsEl = undefined; } + // Roving tabindex: exactly ONE rail item is in the Tab order. + // + // "The active one" is not enough, because some routes are not rail items at + // all — the Assistant is reached from the top bar, and a detail page is a + // route with no rail entry. On those, nothing matched, every one of the 17 + // destinations got tabIndex -1, and the entire navigation rail dropped out + // of the keyboard's reach until you happened to press ⌘1-8. A roving tab + // stop needs a fallback, or it is not a tab stop. + let anyActive = false; for (const btn of this.navButtons) { const active = btn.dataset.view === id; + if (active) anyActive = true; btn.classList.toggle("active", active); btn.setAttribute("aria-selected", active ? "true" : "false"); - // Roving tabindex: only the active tab is in the Tab order. btn.tabIndex = active ? 0 : -1; } + if (!anyActive && this.navButtons.length) this.navButtons[0].tabIndex = 0; // Restore a kept-alive view instantly, skipping the rebuild + refetch. - const cached = App.KEEPALIVE.has(id) ? this.viewCache.get(id) : undefined; - if (cached) { - this.viewHost.replaceChildren(cached); - return; - } // Any soft-reload hook belongs to the view being replaced, and its captured // routeGen can never match again (routeGen bumps on every route). Left in // place it made refreshBranchesSoft await a function that returns // immediately — so a fetch or pull silently refreshed nothing. + // + // This has to run BEFORE the keep-alive restore below, which returns early. + // It did not, so a route change into a CACHED view skipped it and left the + // dead hook armed: Branches then stopped refreshing for the rest of the + // session. A dropped stash stayed on the list, and dropping it a second + // time ran `stash drop` against an index that now names a DIFFERENT stash — + // destroying work the user never chose. this.reloadBranchRows = null; - if (id === "code") { - void this.showCodeView(); + const cached = App.KEEPALIVE.has(id) ? this.viewCache.get(id) : undefined; + if (cached) { + this.viewHost.replaceChildren(cached); + // …and put them back, on the frame after the attach so layout has run. + const shot = this.viewScroll.get(cached); + if (shot) { + const apply = (): void => { + for (const [node, top, left, atTail] of shot) { + if (!node.isConnected) continue; + // A node that GREW while parked is a different problem from one + // that did not. The Assistant's transcript keeps taking a live + // turn's output behind your back, so restoring the pixel offset put + // you permanently behind the answer — pinned to a fixed point while + // it wrote past you. If the reader was at the TAIL when they left, + // the tail is where they meant to be, wherever that now is. + node.scrollTop = atTail ? node.scrollHeight : top; + node.scrollLeft = left; + } + }; + apply(); + requestAnimationFrame(apply); + } + return; + } + if (id === "refdetail") { + // A ref is a PLACE. A branch's history used to be a modal peek: no route, + // no back-stack entry, no ⌘[/⌘], gone on Escape — and for a remote + // branch, a tag or a stash that modal was the ONLY door to every action + // they had. + void renderRefDetail(this.viewHost, (v, t) => this.routeView(v, false, t), target); + } else if (id === "predit") { + // A pull request's title and body are the same two fields, and editing + // one was the last surface still doing it in a modal — one with no draft + // at all, so Escape took everything you had written. + void renderIssueCompose(this.viewHost, (v, t) => this.routeView(v, false, t), target, "pr"); + } else if (id === "issuenew") { + // Writing an issue is a PAGE. As a modal it had a title box, a body box + // and nowhere to say who it is for — so labels, assignees and milestone + // were a second trip through the issue's own page, after GitHub had + // already announced it. + void renderIssueCompose(this.viewHost, (v, t) => this.routeView(v, false, t), target); + } else if (id === "releasenew") { + // Writing a release is a PAGE. As a modal it gave the notes — the only + // part anyone spends time on — about 180px of a 560px card. + void renderReleaseCompose(this.viewHost, (v, t) => this.routeView(v, false, t), target); + } else if (id === "joblog") { + // The log is a PAGE, not a pane inside one. It used to get 523px of a + // 913px window, on a run page that itself scrolled — two nested scroll + // contexts and whatever height was left over. + void renderJobLog(this.viewHost, (v, t) => this.routeView(v, false, t), target); + } else if (id === "commit") { + // A commit is a PLACE, not a row to reveal in the graph. Everything that + // referenced one used to route to "graph" and call reveal(sha), which + // shows no files, returns silently when the sha is off the loaded page, + // and abandons wherever you were. + void renderCommit(this.viewHost, (v, t) => this.routeView(v, false, t), target, (req) => + this.runAction(req), + ); + } else if (id === "code") { + // A path target deep-links a folder — that's how the Code browser's own + // folder hops travel, so ⌘[/⌘] walk the folder trail like a browser. + if (target?.path !== undefined) this.codePath = target.path; + // …and a `file` target is the open FILE, which is a place in the app just + // as much as a folder is. See SectionTarget.file. + if (target?.file) void this.openCodeFile(target.file); + else void this.showCodeView(); } else if (id === "graph") { - this.showGraphView(); + this.showGraphView(force); + // A sha target deep-links a commit: scroll to + select it once the rows + // stream in (e.g. "View in Commits" from a peek, or a tag detail). + if (target?.sha) this.revealWhenReady(target.sha); } else if (id === "branches") { - void this.showBranchesView(); + void this.showBranchesView(target?.ref); } else if (id === "changes") { void this.showChangesView(); } else if (id === "compare") { @@ -790,10 +1360,16 @@ class App { this.mountSection(renderPrs); } else if (id === "issues") { this.mountSection(renderIssues); + } else if (id === "notifications") { + this.mountSection(renderNotifications); + } else if (id === "mywork") { + this.mountSection(renderMyWork); } else if (id === "actions") { this.mountSection(renderActions); } else if (id === "releases") { this.mountSection(renderReleases); + } else if (id === "explore") { + this.mountSection(renderExplore); } else if (id === "orgs") { this.mountSection(renderOrgs); } else if (id === "projects") { @@ -812,218 +1388,1570 @@ class App { * DOM (harmless) once the user navigates on. */ private mountSection(render: SectionRender): void { const wrap = el("div", "view-host-inner"); + // Paint a content-shaped skeleton NOW — sections gate on github:status + // before their first render, and that await used to leave a blank pane + // (blank → skeleton → content, three stages on every visit). + wrap.appendChild(skeletonList(6)); this.viewHost.replaceChildren(wrap); const target = this.sectionTarget; this.sectionTarget = undefined; - render(wrap, (v, t) => this.routeView(v, false, t), target); + const nav = (v: string, t?: SectionTarget): void => this.routeView(v, false, t); + // The person peek's router, set from the one place every section mounts — + // it is opened by chips on views that have nothing to do with orgs, and + // used to be wired only by the Organizations view itself. + setPeekNav(nav); + render(wrap, nav, target); } /** A real branch manager: local branches with upstream + ahead/behind + last - * commit, plus remotes and tags — checkout, new, delete. */ - private async showBranchesView(): Promise<void> { - const wrap = el("div", "list-view"); - const headRow = el("div", "list-head list-head-row"); - const filterInput = document.createElement("input"); - filterInput.className = "list-filter"; - filterInput.type = "text"; - filterInput.placeholder = "Filter branches & tags…"; - filterInput.setAttribute("aria-label", "Filter branches and tags"); - const newBtn = el("button", "mini-btn"); - newBtn.append(glyph("add"), span("New branch")); - newBtn.addEventListener("click", () => void this.newBranch()); - headRow.append(filterInput, newBtn); + * commit, plus remotes, tags and stashes. `highlightRef` deep-links one row: + * its group builds expanded and the row scrolls into view with a flash. */ + /** + * The ref manager: local branches, remote branches, tags and stashes. + * + * It was the last view in the app still hand-rolling its own chrome — a bare + * filter input, a "New branch" button, and four collapsible groups of four + * incompatible row shapes. No title, no count, no Refresh, no facets, no + * routed detail; the most-used repo-wide verb on the screen (Fetch) was a + * menu item inside ONE local branch's hover-revealed kebab, and remote + * branches, tags and stashes had no row actions at all — their entire action + * set required opening a modal first. + * + * Now: one KIND per screen behind a segmented switch, one row anatomy, and + * every verb visible at rest. + */ + private async showBranchesView(highlightRef?: string): Promise<void> { + // A deep link must SHOW the ref it names (a graph ref chip lands here), so + // it drops the sticky filter below — otherwise the row it asks to scroll to + // and flash is filtered out and the arrival looks like an empty list. + if (highlightRef) this.branchQuery = ""; + const wrap = el("div", "list-view branches-view"); const body = el("div", "list-body"); - // Content-shaped skeleton paints immediately; replaced once data lands. body.appendChild(skeletonList(8)); - wrap.append(headRow, body); + + const header = ghHeader("Branches", undefined, () => this.refreshBranchesSoft()); + // The search field the rest of the app uses: 110ms debounce, a clear ✕, and + // Escape to empty it — none of which a raw `input.list-filter` had. It also + // searches more than the name now (upstream, tip subject, short sha, a + // stash's message), because a name-only filter cannot find "the branch with + // the log-stream fix in it". + // Seeded from instance state, like branchTab/branchFacets beside it. Opening + // a ref carries a target, which forces a rebuild AND skips caching the + // outgoing list — so this view is reconstructed on the way back, and a + // closure-local query meant the filter you typed to find the branch was + // gone the moment you looked at it. Every other section keeps its query + // outside the build for exactly this reason (views/issues.ts). + let query = this.branchQuery; + const search = searchField({ + placeholder: "Search refs…", + initial: query, + onInput: (q) => { + query = q; + this.branchQuery = q; + render(); + }, + }); + header.querySelector(".gh-head-titlewrap")?.appendChild(search); + + const tools = el("div", "gh-head-tools"); + // FETCH, at the surface. It was reachable only from inside one local + // branch's ⋯ menu — the action that makes every ahead/behind number on this + // screen true, two hover levels deep. Refresh (in .gh-acct) re-reads what + // git already has; Fetch goes to the network. Different promises, so + // different buttons, and the titles say which is which. + const fetchBtn = el("button", "mini-btn") as HTMLButtonElement; + fetchBtn.append(glyph("sync"), span("Fetch")); + fetchBtn.title = "Fetch from every remote — updates what ahead and behind mean here"; + fetchBtn.addEventListener("click", () => void this.fetchAllLive(fetchBtn)); + const ctaSlot = el("div", "gh-head-cta"); + tools.append(fetchBtn, ctaSlot); + header.querySelector(".gh-acct")?.before(tools); + + const segSlot = el("div", "branches-segbar"); + const facetSlot = el("div", "branches-facets"); + wrap.append(header, segSlot, facetSlot, body); + wireListNav(body, ".sec-row"); this.viewHost.replaceChildren(wrap); const gen = this.routeGen; await this.refreshRefs(); - let locals = await gget("branches:list", undefined); + let locals: BranchInfo[]; + try { + locals = await gget("branches:list", undefined); + } catch (e) { + // A failed read is not an empty repository. This used to be impossible to + // reach — the bridge turned every git failure into `[]` — so a repo with a + // corrupt packed-refs or a held index.lock rendered as "No branches yet", + // which is a confident lie about a repo full of them. + if (gen !== this.routeGen) return; + body.replaceChildren( + errorState("Couldn't list branches", cleanErr(e) || "Git could not read this repository's refs.", () => + void this.showBranchesView(highlightRef), + ), + ); + return; + } + if (gen !== this.routeGen) return; + // Stashes join the ref manager: they're refs too, and this is the only + // browsable surface they have. + let stashes: StashInfo[] = []; + let stashFailed = false; + try { + stashes = await host.invoke("stash:list", undefined); + } catch { + // Swallowing this used to render "no stashes" over a repo that has some. + stashFailed = true; + } if (gen !== this.routeGen) return; // Recomputed on every render so a live reload (fetch from the branch menu) // picks up new remote branches/tags without rebuilding the whole view. - let remotes = this.refs.filter((r) => r.type === "remote" && !r.name.endsWith("/HEAD")); + // + // `refs/remotes/origin/HEAD` shortens to the bare remote NAME ("origin"), + // not "origin/HEAD" — so the old `endsWith("/HEAD")` guard never fired and + // the list carried a phantom row called "origin" offering to check out a + // branch that does not exist. Its symref names the DEFAULT branch, which is + // worth keeping; the row is not. + const isRemoteHead = (r: RefInfo): boolean => !!r.symref || !r.name.includes("/"); + let remotes = this.refs.filter((r) => r.type === "remote" && !isRemoteHead(r)); let tags = this.refs.filter((r) => r.type === "tag"); + let defaultBranch = this.defaultBranchName(locals); + // Worktrees: four channels that have existed since the IPC contract was + // written with NO caller in any view. The segment appears only when there + // is more than one — a single worktree is just "the repository". + let worktrees: WorktreeInfo[] = []; + // A failed read is not an empty list — the same rule the stash list already + // follows. Swallowing it let the view assert "No other worktrees" from a + // git call that never answered. + let worktreeFailed = false; + try { + worktrees = await host.invoke("worktree:list", undefined); + } catch { + worktrees = []; + worktreeFailed = true; + } + if (gen !== this.routeGen) return; - // A collapsible category: a clickable header (chevron + label + count) over a - // body div holding its rows. Collapse state lives on the App instance so it - // survives re-renders; while filtering we force-expand so matches stay visible. - const group = (label: string, count: number, build: (host: HTMLElement) => void): void => { - if (!count) return; - const filtering = !!filterInput.value.trim(); - const collapsed = !filtering && !!this.branchCatsCollapsed[label]; - const head = el("button", "list-group-head" + (collapsed ? " collapsed" : "")); - head.append( - glyph("chevron-down"), - span(label, "list-group-label"), - span(String(count), "list-group-count"), - ); - const groupBody = el("div", "list-group-body"); - if (collapsed) groupBody.style.display = "none"; - build(groupBody); - // Toggle from the DISPLAYED state (seeded per-render), so the first click - // always matches what the user sees — even when filtering force-expanded it. - let cur = collapsed; - head.addEventListener("click", () => { - cur = !cur; - this.branchCatsCollapsed[label] = cur; - head.classList.toggle("collapsed", cur); - groupBody.style.display = cur ? "none" : ""; - this.persist(); - }); - body.append(head, groupBody); + // Which KIND is on screen. One homogeneous kind per screen is what makes a + // shared row and a facet bar possible at all — and it stops 300 tags + // burying six branches, which is what the four-groups-in-one-scroller shape + // did every time a repo had any history. + type Kind = "local" | "remote" | "tags" | "stashes" | "worktrees"; + if (highlightRef) { + this.branchTab = locals.some((b) => b.name === highlightRef) + ? "local" + : remotes.some((r) => r.name === highlightRef) + ? "remote" + : tags.some((r) => r.name === highlightRef) + ? "tags" + : stashes.some((st) => st.ref === highlightRef) + ? "stashes" + : this.branchTab; + // A deep link must SHOW the row it names, so it clears EVERY narrowing + // that could hide it — not just the search box. The age cut alone was + // enough to swallow the arrival silently: `branchAge` defaults to + // "active", so a link to any branch untouched for three months landed on + // a list that did not contain it, with nothing saying why. The facets can + // do the same, and they persist per segment across launches. + this.branchFacets[this.branchTab] = Object.create(null) as FacetState; + this.branchAge = "all"; + } + + const counts = (): Record<Kind, number> => ({ + local: locals.length, + remote: remotes.length, + tags: tags.length, + stashes: stashes.length, + worktrees: worktrees.length, + }); + + /** + * How recently a branch moved, as GitHub cuts it: Active / Stale / All. + * + * Three months is github.com's own line, and it is the difference between + * "the branches I am working on" and "everything this clone has ever + * touched" — the distinction that makes a list of ninety branches usable. + */ + const STALE_AFTER = 90 * 24 * 3600; + const isStale = (date?: number): boolean => + !!date && Date.now() / 1000 - date > STALE_AFTER; + + /** The one word that describes where a branch stands. First match wins, and + * the order is the order a person cares about them in. */ + const standing = (b: BranchInfo): string => { + if (b.current) return "current"; + if (b.gone) return "gone"; + // `merged` means "ahead === 0 against the default branch", so the default + // branch satisfies it trivially — and reading "Merged" beside main, in a + // facet grouping it with the branches whose work is done, says something + // false about the branch everything else is measured from. It is judged + // on its own upstream instead, like any other branch with one. + if (b.merged && b.name !== defaultBranch) return "merged"; + if (!b.upstream) return "unpublished"; + if (b.ahead && b.behind) return "diverged"; + if (b.ahead) return "ahead"; + if (b.behind) return "behind"; + return "insync"; + }; + const STANDING_LABELS: Record<string, string> = { + current: "Current", + gone: "Upstream gone", + merged: "Merged", + unpublished: "Unpublished", + diverged: "Diverged", + ahead: "Ahead", + behind: "Behind", + insync: "In sync", }; const render = (): void => { - const q = filterInput.value.trim().toLowerCase(); - const match = (n: string): boolean => !q || n.toLowerCase().includes(q); - body.replaceChildren(); + const n = counts(); + segSlot.replaceChildren( + segmented<Kind>({ + ariaLabel: "Which refs to show", + value: this.branchTab, + options: [ + { value: "local", label: `Local (${n.local})`, icon: "git-branch" }, + { value: "remote", label: `Remotes (${n.remote})`, icon: "cloud" }, + { value: "tags", label: `Tags (${n.tags})`, icon: "tag" }, + { value: "stashes", label: `Stashes (${n.stashes})`, icon: "archive" }, + // Only when there is more than one: a single worktree is just "the + // repository", and a segment reading "Worktrees (1)" is a tab that + // tells you nothing. + // + // …unless you are STANDING on it. Removing the second-to-last + // worktree dropped the option out from under the reader, leaving + // them on a segment with no button — the bar showed four, none + // active, while the body still rendered worktrees. A tab may not + // disappear while it is the one you are looking at. + ...(n.worktrees > 1 || this.branchTab === "worktrees" + ? [{ value: "worktrees" as Kind, label: `Worktrees (${n.worktrees})`, icon: "window" }] + : []), + ], + onChange: (v) => { + this.branchTab = v; + this.persist(); + render(); + }, + }), + ); - const localRows = locals.filter((b) => match(b.name)); - group("Local", localRows.length, (host) => { - for (const b of localRows) host.appendChild(this.localBranchRow(b)); + // "What is safe to delete" — the question a branch list is opened to + // answer at least as often as "what do I switch to", and one this view + // could never answer at all. A branch is finished when every commit on it + // is already in the default branch (merged), or when the upstream it + // tracked has been deleted (gone) — which is what a merged pull request + // leaves behind. + // + // The default branch is NEVER finished, and excluding it is not a nicety: + // `merged` is `ahead === 0` measured against the default branch, and the + // default branch is zero commits ahead of itself. So `main` qualified, + // and any moment you were standing on a feature branch the sweep offered + // — in a confirm listing it by name, among five others — to delete the + // one branch the repository is organised around. + const sweep = el("button", "mini-btn branches-sweep") as HTMLButtonElement; + segSlot.appendChild(sweep); + + // ── the filter bar ──────────────────────────────────────────────── + // + // All client-side: `branches:list` and `refs:list` are whole-set reads, + // so every spec carries a predicate and changing one is a re-render, not + // a refetch. State is per KIND — a Standing filter means nothing on the + // tags screen. + const specs: FacetSpec<unknown>[] = + this.branchTab === "local" + ? [ + { + key: "standing", + label: "Standing", + icon: "git-branch", + options: [...new Set(locals.map(standing))].map((v) => ({ + value: v, + label: STANDING_LABELS[v] ?? v, + })), + predicate: (item: unknown, v: string) => standing(item as BranchInfo) === v, + }, + { + key: "remote", + label: "Remote", + icon: "cloud", + options: [...new Set(locals.map((b) => b.upstream?.split("/")[0]).filter(Boolean))].map( + (v) => ({ value: v as string, label: v as string }), + ), + predicate: (item: unknown, v: string) => (item as BranchInfo).upstream?.split("/")[0] === v, + }, + ] + : this.branchTab === "remote" + ? [ + { + key: "remote", + label: "Remote", + icon: "cloud", + options: [...new Set(remotes.map((r) => r.name.split("/")[0]))].map((v) => ({ + value: v, + label: v, + })), + predicate: (item: unknown, v: string) => (item as RefInfo).name.split("/")[0] === v, + }, + { + key: "local", + label: "Local copy", + icon: "git-branch", + options: [ + { value: "yes", label: "Have one" }, + { value: "no", label: "None" }, + ], + predicate: (item: unknown, v: string) => { + const short = (item as RefInfo).name.split("/").slice(1).join("/"); + const have = locals.some((b) => b.name === short); + return v === "yes" ? have : !have; + }, + }, + ] + : this.branchTab === "tags" + ? [ + { + key: "kind", + label: "Kind", + icon: "tag", + options: [ + { value: "annotated", label: "Annotated" }, + { value: "lightweight", label: "Lightweight" }, + ], + predicate: (item: unknown, v: string) => + ((item as RefInfo).objectType === "tag" ? "annotated" : "lightweight") === v, + }, + ] + : []; + + const state = (this.branchFacets[this.branchTab] ??= {}); + const bar = facetBar<unknown>({ + specs, + state, + items: [], + onChange: () => render(), }); + facetSlot.replaceChildren(); + if (specs.length) facetSlot.appendChild(bar.el); + + const q = query.trim().toLowerCase(); + // Beyond the name: the upstream, the tip subject and the short sha, so + // "the branch with the log-stream fix" is findable by what it did. + const hit = (...parts: Array<string | undefined>): boolean => + !q || parts.some((x) => (x ?? "").toLowerCase().includes(q)); + + // "What is safe to delete" — the question a branch list is opened to + // answer at least as often as "what do I switch to", and one this view + // could never answer at all. A branch is finished when every commit on it + // is already in the default branch (merged), or when the upstream it + // tracked has been deleted (gone) — which is what a merged pull request + // leaves behind. + // + // The default branch is NEVER finished, and excluding it is not a nicety: + // `merged` is `ahead === 0` measured against the default branch, and the + // default branch is zero commits ahead of itself. So `main` qualified, + // and any moment you were standing on a feature branch the sweep offered + // — in a confirm listing it by name, among five others — to delete the + // one branch the repository is organised around. + // + // Downstream of the SEARCH and the FACETS, like the age counts beside it: + // computed above them, the button read "Delete 6 finished…" beside a list + // you had narrowed to one, offering to delete five branches that were not + // on screen. Not downstream of the age cut, which is a browsing lens + // rather than a narrowing — a finished branch is usually a stale one, and + // filtering by it would empty the sweep from the segment it opens on. + const finished = locals + .filter((b) => hit(b.name, b.upstream, b.subject)) + .filter((b) => bar.passes(b)) + .filter((b) => !b.current && b.name !== defaultBranch && (b.merged || b.gone)); + sweep.replaceChildren(glyph("trash"), span(`Delete ${finished.length} finished…`)); + sweep.title = q + ? `Of the branches matching “${q}”: those already in ${defaultBranch ?? "the default branch"}, or whose upstream is gone` + : `Branches whose work is already in ${defaultBranch ?? "the default branch"}, or whose upstream is gone`; + sweep.hidden = this.branchTab !== "local" || finished.length === 0; + sweep.onclick = () => void this.sweepFinishedBranches(finished, defaultBranch); + + // Active / Stale / All — github.com's own cut, and the difference between + // "what I am working on" and "everything this clone has touched". + if (this.branchTab === "local") { + // The counts answer "how many if I press this", so they sit downstream + // of the search and the facets: a segment reading (12) while the list + // it would produce holds three is worse than no count at all. + const inScope = locals.filter((b) => hit(b.name, b.upstream, b.subject)).filter((b) => bar.passes(b)); + const stale = inScope.filter((b) => !b.current && isStale(b.date)).length; + facetSlot.appendChild( + segmented<"active" | "stale" | "all">({ + ariaLabel: "How recently these branches moved", + value: this.branchAge, + options: [ + { value: "active", label: `Active (${inScope.length - stale})` }, + { value: "stale", label: `Stale (${stale})` }, + { value: "all", label: `All (${inScope.length})` }, + ], + onChange: (v) => { + this.branchAge = v; + render(); + }, + }), + ); + } + const sortBtn = this.branchSortBtn(() => render()); + if (sortBtn) facetSlot.appendChild(sortBtn); - const refSection = (label: string, refs: RefInfo[], icon: string, pick: (r: RefInfo) => void): void => { - const rows = refs.filter((r) => match(r.name)); - group(label, rows.length, (host) => { - for (const r of rows) { - const row = el("button", "list-row ref-row"); - row.setAttribute("aria-label", `Check out ${label.toLowerCase()} ${r.name}`); - row.append(glyph(icon)); - const nm = el("span", "list-row-name"); - nm.textContent = r.name; - row.appendChild(nm); - // Parity with local rows: show the commit each ref points at, so a - // remote/tag row isn't a bare name floating in the list. - if (r.sha) { - const sha = el("span", "ref-sha"); - sha.textContent = r.sha.slice(0, 7); - sha.title = r.sha; - row.appendChild(sha); - } - row.addEventListener("click", () => pick(r)); - host.appendChild(row); - } - }); - }; - refSection("Remote", remotes, "cloud", (r) => - void this.checkoutRef(r.name.split("/").slice(1).join("/") || r.name), + body.replaceChildren(); + ctaSlot.replaceChildren(this.branchesCta(this.branchTab)); + + // The order the segment on screen can actually carry out — not whatever + // was last picked on a segment that had more choices. + const order = this.effectiveSort(); + const byName = (a: string, b: string): number => a.localeCompare(b, undefined, { numeric: true }); + const byDate = (a?: number, b?: number): number => (b ?? 0) - (a ?? 0); + + let shown = 0; + let total = 0; + // Did the Active/Stale lens actually remove anything? It defaults to + // Active, so "is a lens set" is true before the reader has touched a + // control — and the empty state used that to blame "the filters you have + // set" for every search that matched nothing, in a repo where the lens + // may well be hiding nothing at all. Only a cut that HID something is a + // reason the list is empty. + let ageHid = 0; + if (this.branchTab === "local") { + total = locals.length; + const searched = locals + .filter((b) => hit(b.name, b.upstream, b.subject)) + .filter((b) => bar.passes(b)); + const rows = searched + // The current branch is never "stale" — it is where you are standing. + // Which means it belongs in Active whatever its date says, and NOT in + // Stale: a bare `|| b.current` put it in both, so a repo left alone + // for a year showed its own checked-out branch under Stale while the + // segment's count, which excludes it, said one fewer. + .filter((b) => + this.branchAge === "all" + ? true + : this.branchAge === "stale" + ? !b.current && isStale(b.date) + : b.current || !isStale(b.date), + ) + .sort((a, b) => + order === "name" + ? byName(a.name, b.name) + : order === "ahead" + ? (b.ahead ?? 0) - (a.ahead ?? 0) || byDate(a.date, b.date) + : order === "stale" + ? (a.date ?? 0) - (b.date ?? 0) + : byDate(a.date, b.date), + ); + shown = rows.length; + ageHid = searched.length - rows.length; + // The sweep is a repo-level cleanup and is deliberately NOT cut by the + // age lens — a finished branch is usually a stale one, so binding it + // would empty the button from the segment it opens on. But then its + // number can exceed the rows beneath it, and a destructive control + // whose count contradicts the list is one nobody should press. Say so. + const visible = new Set(rows.map((b) => b.name)); + const unseen = finished.filter((b) => !visible.has(b.name)).length; + if (unseen) { + sweep.title += + `\n${unseen} of them ${unseen === 1 ? "is" : "are"} not shown by the current view — ` + + "the confirm lists every one by name."; + } + // Scaled to what is ON SCREEN, so the bars stay comparable down the + // list rather than against a branch the filter has removed. + const maxAb = Math.max(1, ...rows.map((b) => Math.max(b.aheadDefault ?? 0, b.behindDefault ?? 0))); + for (const b of rows) body.appendChild(this.localBranchRow(b, defaultBranch, maxAb)); + } else if (this.branchTab === "remote") { + total = remotes.length; + const rows = remotes + .filter((r) => hit(r.name, r.subject, r.sha.slice(0, 7))) + .filter((r) => bar.passes(r)) + .sort((a, b) => + order === "name" + ? byName(a.name, b.name) + : order === "stale" + ? (a.date ?? 0) - (b.date ?? 0) + : byDate(a.date, b.date), + ); + shown = rows.length; + const haveLocal = new Set(locals.map((b) => b.name)); + for (const r of rows) body.appendChild(this.remoteRefRow(r, haveLocal)); + } else if (this.branchTab === "tags") { + total = tags.length; + const rows = tags + .filter((r) => hit(r.name, r.subject, r.sha.slice(0, 7))) + .filter((r) => bar.passes(r)) + // By DATE by default, not by name: alphabetical puts v1.10.0 before + // v1.9.0, which is wrong about every version scheme anyone uses. + .sort((a, b) => + order === "name" + ? byName(a.name, b.name) + : order === "stale" + ? (a.date ?? 0) - (b.date ?? 0) + : byDate(a.date, b.date), + ); + shown = rows.length; + for (const r of rows) body.appendChild(this.tagRefRow(r)); + } else if (this.branchTab === "stashes") { + total = stashes.length; + const rows = stashes.filter((st) => hit(st.message, st.ref)); + shown = rows.length; + for (const st of rows) body.appendChild(this.stashRow(st)); + } else { + total = worktrees.length; + const rows = worktrees.filter((w) => hit(w.branch, w.path, w.head.slice(0, 7))); + shown = rows.length; + for (const w of rows) body.appendChild(this.worktreeRow(w)); + } + + header.setCount?.(shown, total); + if (!shown) { + // Which control emptied it. The search box speaks for itself; a facet + // or the age cut does not, and without this the view announced that the + // REPOSITORY had no branches over a repo with ninety. + const narrowed = bar.activeCount() > 0 || ageHid > 0; + body.appendChild( + this.branchesEmpty(this.branchTab, q, stashFailed, narrowed, worktreeFailed, () => { + bar.clear(); + this.branchAge = "all"; + render(); + }), + ); + } + }; + + this.reloadBranchRows = async (): Promise<void> => { + if (gen !== this.routeGen) return; + await this.refreshRefs(); + locals = await gget("branches:list", undefined); + try { + stashes = await host.invoke("stash:list", undefined); + stashFailed = false; + } catch { + // The rows already on screen are kept — they are the last thing git + // actually said — but the failure has to REACH the reader, or a stash + // list that stopped updating looks like one that stopped changing. + // `branchesEmpty` can only speak when the list is EMPTY, so with stale + // rows showing this was the one path with no way to say anything. + stashFailed = true; + if (stashes.length) toast("Couldn't re-read the stashes — showing the last list git gave.", "info"); + } + // Worktrees too. This re-read everything EXCEPT them, so the list was + // fetched exactly once when the view was first built — and every soft + // refresh in the view goes through here, including `removeWorktreeLive`. + // "Worktree removed." left the row and its count on screen, and pressing + // Remove again ran git against a path that no longer existed. + try { + worktrees = await host.invoke("worktree:list", undefined); + worktreeFailed = false; + } catch { + // KEEP the rows we have. Replacing them with [] on a failed refresh + // deleted a list git never said was gone. + worktreeFailed = true; + if (worktrees.length) toast("Couldn't re-read the worktrees — showing the last list git gave.", "info"); + } + if (gen !== this.routeGen) return; + remotes = this.refs.filter((r) => r.type === "remote" && !isRemoteHead(r)); + tags = this.refs.filter((r) => r.type === "tag"); + defaultBranch = this.defaultBranchName(locals); + render(); + }; + // ── the keyboard ────────────────────────────────────────────────────── + // + // Nothing in this view had a shortcut: not the filter, not Fetch, not New + // branch, not a row's own verb. `sectionList`'s ↑↓/j/k/Home/End already + // move between rows; these are the two that make it operable without a + // mouse at all. + wrap.addEventListener("keydown", (e) => { + const t = e.target as HTMLElement | null; + const typing = !!t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable); + + // "/" focuses the search, the way it does in every list people already + // know. Not while typing — a slash is a character in a branch name. + if (e.key === "/" && !typing && !e.metaKey && !e.ctrlKey) { + e.preventDefault(); + search.querySelector("input")?.focus(); + return; + } + // ⌘Enter runs the focused row's PRIMARY verb — Checkout, Pull, Publish, + // Push, Apply — without reaching for the pointer. Plain Enter still opens + // the row, which is what every other list in the app does. + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + const row = t?.closest?.(".sec-row") as HTMLElement | null; + const verb = row?.querySelector<HTMLButtonElement>(".sec-row-actions .row-btn:not(.lv-menu-btn)"); + if (verb) { + e.preventDefault(); + verb.click(); + } + return; + } + // Shift+F fetches. The action the whole screen depends on deserves one. + if ((e.key === "f" || e.key === "F") && e.shiftKey && !typing && !e.metaKey && !e.ctrlKey) { + e.preventDefault(); + fetchBtn.click(); + } + }); + + render(); + if (highlightRef) { + const row = body.querySelector<HTMLElement>(`[data-ref="${CSS.escape(highlightRef)}"]`); + row?.scrollIntoView({ block: "nearest" }); + row?.classList.add("is-flash"); + } + } + + /** Which KIND of ref the Branches view is showing. Survives re-renders and + * is persisted, the way every other section remembers its sub-tab. */ + private branchTab: "local" | "remote" | "tags" | "stashes" | "worktrees" = "local"; + /** Facet state per KIND — a Standing filter means nothing on the tags screen, + * so each segment keeps its own and switching back finds it as you left it. */ + private branchFacets: Record<string, FacetState> = Object.create(null) as Record<string, FacetState>; + /** The live text filter. Outside the build like the facets, so opening a ref + * and pressing Back doesn't throw away the search that found it. */ + private branchQuery = ""; + /** Active / Stale / All — github.com's own cut at three months. */ + private branchAge: "active" | "stale" | "all" = "active"; + /** How the list is ordered. Recency is the default because it answers "what + * was I just doing", which is why this view is opened most often. */ + private branchSort: "recent" | "name" | "ahead" | "stale" = "recent"; + + /** Set while the Branches view is live — see showBranchesView. */ + private reloadBranchRows: (() => Promise<void>) | null = null; + + /** The App-side operations handed to peek cards (peeks.ts). Every mutation a + * peek can trigger routes through the same helpers the views use, so toasts, + * cache busting, and refreshes behave identically everywhere. */ + private peekHost(): GitPeekHost { + return { + checkout: (ref) => void this.checkoutRef(ref), + branchMenu: (b, anchor) => this.openBranchActions(b, anchor), + compareWith: (head) => { + const current = this.refs.find((r) => r.type === "head" && r.isCurrent)?.name; + this.compareBase = current ?? "HEAD"; + this.compareHead = head; + this.routeView("compare", true); + }, + revealInGraph: (sha) => this.revealInGraph(sha), + openBranch: (ref) => this.routeView("branches", false, { ref }), + openCommitFile: (file, sha) => void this.openFile({ path: file.path, status: file.status }, sha), + stashesChanged: () => { + // Applying/popping a stash changes the working tree; dropping changes + // the list. Bust the SWR cache and refresh whatever's showing. + bust(); + void this.refreshBranchesSoft(); + void this.updateSync(); + }, + }; + } + + /** + * Run a refresh with a visible busy state, and put the keyboard back on the + * Refresh button afterwards. + * + * These buttons rebuild their whole view, so the button you pressed is + * destroyed and replaced mid-click: nothing spun, nothing said "working", and + * the focus you had went to <body>. The replacement occupies the same seat, so + * it is found by class and re-focused only if the keyboard was here to start. + */ + private async refreshInPlace(btn: HTMLElement, run: () => void | Promise<void>): Promise<void> { + if ((btn as HTMLButtonElement).disabled) return; + const hadFocus = document.activeElement === btn; + const host_ = btn.parentElement; + const nth = host_ ? [...host_.children].indexOf(btn) : -1; + (btn as HTMLButtonElement).disabled = true; + btn.classList.add("is-busy"); + btn.querySelector(".codicon")?.classList.add("spin"); + try { + await run(); + } finally { + if (btn.isConnected) { + (btn as HTMLButtonElement).disabled = false; + btn.classList.remove("is-busy"); + btn.querySelector(".codicon")?.classList.remove("spin"); + } else if (hadFocus && host_?.isConnected && nth >= 0) { + (host_.children[nth] as HTMLElement | undefined)?.focus?.(); + } + } + } + + /** + * A remote branch. + * + * It used to be a single-line button showing a name and a short sha, with NO + * actions whatsoever — its entire verb set required opening a modal first. + * It now carries what it points at, when, whether you already have it + * locally, and the two things you actually do with one. + */ + private remoteRefRow(r: RefInfo, haveLocal: Set<string>): HTMLElement { + // "origin/feat/x" reads as "feat/x on origin" — the remote is a column, not + // a prefix repeated down every title. + const short = r.name.split("/").slice(1).join("/") || r.name; + const remote = r.name.split("/")[0]; + const mine = haveLocal.has(short); + + const actions: HTMLElement[] = []; + const primary = el("button", "row-btn") as HTMLButtonElement; + primary.textContent = mine ? "Checkout" : "Check out here"; + primary.title = mine + ? `Check out your local ${short}` + : `Create ${short} from ${r.name} and check it out`; + primary.setAttribute("aria-label", primary.title); + primary.addEventListener("click", () => + void this.checkoutRef(mine ? short : r.name, primary, mine ? "head" : "remote"), + ); + actions.push(primary); + const more = el("button", "row-btn lv-menu-btn") as HTMLButtonElement; + more.setAttribute("aria-label", `More actions for ${r.name}`); + more.setAttribute("aria-haspopup", "menu"); + more.appendChild(glyph("ellipsis")); + const menu = (): void => + openMenu(more, [ + { label: `Compare with ${short}`, icon: "git-compare", onClick: () => this.compareWithRef(r.name) }, + { label: "Show in the graph", icon: "git-commit", onClick: () => this.routeView("graph", false, { sha: r.sha }) }, + { separator: true }, + { label: "Copy name", icon: "copy", onClick: () => void copyText(r.name, `Copied “${r.name}”.`) }, + ]); + more.addEventListener("click", menu); + actions.push(more); + + const row = secRow({ + lead: glyph("cloud"), + title: short, + titleSuffix: mine ? [] : [span("no local copy", "ab-pill unpublished")], + chips: r.subject ? [span(r.subject, "br-subject")] : [], + meta: [span(remote, "br-remote"), span(r.sha.slice(0, 7), "br-sha sec-mono")], + time: r.date ? relTime(r.date) : "", + timeTitle: r.date ? absTime(r.date) : undefined, + actions, + onOpen: () => this.routeView("refdetail", false, { ref: r.name, id: "remote" }), + ariaLabel: `${short} on ${remote}${mine ? "" : ", no local copy"}${r.date ? `, updated ${relTime(r.date)}` : ""}`, + }); + row.classList.add("ref-row"); + row.dataset.ref = r.name; + row.title = [r.name, r.subject].filter(Boolean).join("\n"); + row.addEventListener("contextmenu", (e) => { + e.preventDefault(); + menu(); + }); + return row; + } + + /** + * A tag. + * + * Annotated vs lightweight is the one fact that distinguishes the two kinds + * and NOTHING has ever carried it — `%(objecttype)` was there all along. + * Delete and Push are new: `tag:create` existed and the app could not remove + * or publish what it made. + */ + private tagRefRow(r: RefInfo): HTMLElement { + const annotated = r.objectType === "tag"; + const actions: HTMLElement[] = []; + const push = el("button", "row-btn") as HTMLButtonElement; + push.textContent = "Push"; + push.setAttribute("aria-label", `Push tag ${r.name} to the remote`); + push.title = `Publish ${r.name} to the remote`; + push.addEventListener("click", () => void this.pushTagLive(r.name, push)); + actions.push(push); + const more = el("button", "row-btn lv-menu-btn") as HTMLButtonElement; + more.setAttribute("aria-label", `More actions for ${r.name}`); + more.setAttribute("aria-haspopup", "menu"); + more.appendChild(glyph("ellipsis")); + const menu = (): void => + openMenu(more, [ + { label: "Show in the graph", icon: "git-commit", onClick: () => this.routeView("graph", false, { sha: r.sha }) }, + { label: `Compare with ${r.name}`, icon: "git-compare", onClick: () => this.compareWithRef(r.name) }, + { separator: true }, + { label: "Copy name", icon: "copy", onClick: () => void copyText(r.name, `Copied “${r.name}”.`) }, + { separator: true }, + { + label: "Delete tag…", + icon: "trash", + danger: true, + onClick: () => void this.deleteTagLive(r.name), + }, + ]); + more.addEventListener("click", menu); + actions.push(more); + + const row = secRow({ + lead: glyph("tag"), + title: r.name, + titleSuffix: [span(annotated ? "annotated" : "lightweight", `ab-pill ${annotated ? "annotated" : "lightweight"}`)], + chips: r.subject ? [span(r.subject, "br-subject")] : [], + meta: [span(r.sha.slice(0, 7), "br-sha sec-mono")], + time: r.date ? relTime(r.date) : "", + timeTitle: r.date ? absTime(r.date) : undefined, + actions, + onOpen: () => this.routeView("refdetail", false, { ref: r.name, id: "tag" }), + ariaLabel: `${r.name}, ${annotated ? "annotated" : "lightweight"} tag${r.date ? `, ${relTime(r.date)}` : ""}`, + }); + row.classList.add("ref-row"); + row.dataset.ref = r.name; + row.title = [r.name, r.subject].filter(Boolean).join("\n"); + row.addEventListener("contextmenu", (e) => { + e.preventDefault(); + menu(); + }); + return row; + } + + /** + * A stash. + * + * `stash@{n}` is POSITIONAL: dropping one renumbers every stash below it, so + * a row built from a stale list can act on a DIFFERENT stash than the one it + * names. Every mutation here re-reads the list first and refuses if the + * selector no longer points at the same commit. + */ + private stashRow(st: StashInfo): HTMLElement { + const actions: HTMLElement[] = []; + const apply = el("button", "row-btn") as HTMLButtonElement; + apply.textContent = "Apply"; + apply.setAttribute("aria-label", `Apply ${st.ref}`); + apply.title = `Apply ${st.ref} and keep it in the stash list`; + apply.addEventListener("click", () => void this.stashActLive("apply", st, apply)); + actions.push(apply); + const more = el("button", "row-btn lv-menu-btn") as HTMLButtonElement; + more.setAttribute("aria-label", `More actions for ${st.ref}`); + more.setAttribute("aria-haspopup", "menu"); + more.appendChild(glyph("ellipsis")); + const menu = (): void => + openMenu(more, [ + { label: "Pop — apply and remove", icon: "arrow-up", onClick: () => void this.stashActLive("pop", st, more) }, + { separator: true }, + { + label: "Drop this stash…", + icon: "trash", + danger: true, + onClick: () => void this.stashActLive("drop", st, more), + }, + ]); + more.addEventListener("click", menu); + actions.push(more); + + const row = secRow({ + lead: glyph("archive"), + title: st.message || st.ref, + meta: [span(st.ref, "stash-sel sec-mono")], + time: st.time ? relTime(st.time) : "", + timeTitle: st.time ? absTime(st.time) : undefined, + actions, + onOpen: () => this.routeView("refdetail", false, { ref: st.ref, id: "stash" }), + ariaLabel: `${st.message || st.ref}, ${st.ref}${st.time ? `, ${relTime(st.time)}` : ""}`, + }); + row.classList.add("ref-row", "stash-row"); + row.dataset.ref = st.ref; + row.addEventListener("contextmenu", (e) => { + e.preventDefault(); + menu(); + }); + return row; + } + + /** Compare the current branch against a ref, in the Compare view. */ + private compareWithRef(head: string): void { + const current = this.refs.find((r) => r.type === "head" && r.isCurrent)?.name; + this.compareBase = current ?? "HEAD"; + this.compareHead = head; + this.routeView("compare", true); + } + + /** Publish one tag. */ + private async pushTagLive(name: string, btn: HTMLButtonElement): Promise<void> { + await this.refreshInPlace(btn, async () => { + const r = await host.invoke("tag:push", { name }); + if (!r.ok) { + toast(r.message ?? `Couldn't push ${name}.`, r.expected ? "info" : "error"); + return; + } + toast(`Pushed ${name}.`, "success"); + }); + } + + /** Delete a tag LOCALLY — and say that the pushed copy outlives it, because + * "delete" on a tag that has been published is only half true. */ + private async deleteTagLive(name: string): Promise<void> { + const ok = await confirmDialog({ + title: `Delete tag ${name}?`, + message: + `This removes the tag from this clone only. If it has already been pushed, ` + + `the copy on the remote is untouched and a fetch brings it straight back.`, + confirmLabel: "Delete locally", + danger: true, + }); + if (!ok) return; + const r = await host.invoke("tag:delete", name); + if (!r.ok) { + toast(r.message ?? `Couldn't delete ${name}.`, r.expected ? "info" : "error"); + return; + } + toast(`Deleted tag ${name} locally.`, "success"); + await this.refreshBranchesSoft(); + } + + /** + * Apply / pop / drop a stash, safely. + * + * `stash@{n}` is a POSITION, not an identity: dropping one renumbers every + * stash below it. A row built from a list that has since changed therefore + * names one stash and acts on another — and for `drop` that is unrecoverable. + * So: re-read the list first and refuse unless the selector still points at + * the same commit. + */ + private async stashActLive( + action: "apply" | "pop" | "drop", + st: StashInfo, + btn: HTMLElement, + ): Promise<void> { + if (action === "drop") { + const ok = await confirmDialog({ + title: `Drop ${st.ref}?`, + message: `“${st.message || st.ref}” is deleted permanently. This cannot be undone.`, + confirmLabel: "Drop", + danger: true, + }); + if (!ok) return; + } + await this.refreshInPlace(btn, async () => { + let fresh: StashInfo[]; + try { + fresh = await host.invoke("stash:list", undefined); + } catch { + toast("Couldn't re-read the stash list — nothing was changed.", "error"); + return; + } + const still = fresh.find((x) => x.ref === st.ref); + if (!still || (st.sha && still.sha && still.sha !== st.sha)) { + toast( + `${st.ref} is not the stash it was — the list changed underneath. Refreshed instead.`, + "info", + ); + await this.refreshBranchesSoft(); + return; + } + const r = await host.invoke( + action === "apply" ? "stash:apply" : action === "pop" ? "stash:pop" : "stash:drop", + st.ref, + ); + if (!r.ok) { + toast(r.message ?? `Couldn't ${action} ${st.ref}.`, r.expected ? "info" : "error"); + return; + } + toast( + action === "apply" + ? `Applied ${st.ref}.` + : action === "pop" + ? `Popped ${st.ref}.` + : `Dropped ${st.ref}.`, + "success", ); - refSection("Tags", tags, "tag", (r) => void this.checkoutRef(r.name)); + await this.refreshBranchesSoft(); + }); + } + + /** + * Delete the branches whose work is done. + * + * The confirm NAMES every one of them, and says which ref merged-ness was + * measured against — because "merged" is a claim about a specific branch, a + * squash-merged branch reads as unmerged, and a branch merged into a release + * line but not into the default reads as unmerged too. A bulk delete that + * does not show its list is a bulk delete nobody should press. + * + * Sequential, stopping at the first failure, and it reports what actually + * happened rather than assuming: there is no transaction here, and claiming + * six deletions when the third one failed would be a lie about the repo. + */ + private async sweepFinishedBranches(finished: BranchInfo[], defaultBranch?: string): Promise<void> { + // Belt and braces on the one destructive action here that takes a LIST: the + // caller already excludes the default branch and the current one, and this + // refuses to delete them anyway. A bulk delete is the wrong place to trust + // that a filter upstream still says what it said when it was written. + finished = finished.filter((b) => !b.current && b.name !== defaultBranch); + if (!finished.length) return; + const names = finished.map((b) => b.name); + const ok = await confirmDialog({ + title: `Delete ${finished.length} finished ${finished.length === 1 ? "branch" : "branches"}?`, + message: + `${names.join("\n")}\n\n` + + `“Finished” means every commit is already in ${defaultBranch ?? "the default branch"}, ` + + `or the upstream it tracked no longer exists. A squash-merged branch does NOT look ` + + `merged to git, and a branch merged somewhere other than ${defaultBranch ?? "the default branch"} ` + + `will not be listed here. Only the local copies are deleted.`, + confirmLabel: `Delete ${finished.length}`, + danger: true, + }); + if (!ok) return; + + const done: string[] = []; + for (const b of finished) { + let r; + try { + r = await host.invoke("branch:delete", { name: b.name, force: false }); + } catch (e) { + toast( + `Deleted ${done.length} of ${finished.length}, then ${b.name} failed: ${cleanErr(e) || "git error"}.`, + "error", + ); + break; + } + if (!r?.ok) { + toast( + done.length + ? `Deleted ${done.join(", ")}. Stopped at ${b.name}: ${r?.message ?? "git refused."}` + : `${b.name} was not deleted: ${r?.message ?? "git refused."}`, + "error", + ); + break; + } + done.push(b.name); + } + if (done.length === finished.length) { + toast(`Deleted ${done.length} finished ${done.length === 1 ? "branch" : "branches"}.`, "success"); + } + bust("branches"); + await this.refreshBranchesSoft(); + } + + /** + * The sort control. Recency is the default because "what was I just doing" + * is the question this view is opened for most often. + * + * It offers only the orders the CURRENT segment can actually carry out. It + * used to offer all four everywhere and render on every segment, so: + * "Most ahead" and "Stalest first" reordered nothing on Remotes and Tags + * (a RefInfo has no divergence from the default branch) yet the button + * relabelled itself, standing there naming an order the list was not in; and + * on Stashes and Worktrees, which apply no sort at all, every one of the four + * was inert. A control that states a false fact about the list beneath it is + * worse than no control. + */ + private branchSortBtn(rerender: () => void): HTMLElement | undefined { + const LABELS: Record<string, string> = { + recent: "Recently committed", + name: "Name", + ahead: "Most ahead", + stale: "Stalest first", + }; + // Stashes are a STACK — stash@{0} is the newest and the numbering is the + // order — and worktrees are a handful of paths. Neither has an order to + // choose, so neither gets a control. + const keys = this.branchSortKeys(); + if (!keys.length) return undefined; + // A segment can drop the order that is currently selected (switching from + // Local to Tags with "Most ahead" active). Show the one it will really use. + const shown = keys.includes(this.branchSort) ? this.branchSort : "recent"; + const b = el("button", "mini-btn branches-sort") as HTMLButtonElement; + b.append(glyph("list-ordered"), span(LABELS[shown])); + b.title = "How this list is ordered"; + b.setAttribute("aria-haspopup", "menu"); + b.addEventListener("click", () => + openMenu( + b, + keys.map((k) => ({ + label: LABELS[k], + current: shown === k, + onClick: () => { + this.branchSort = k; + rerender(); + }, + })), + ), + ); + return b; + } + + /** + * The repository's default branch, as a LOCAL branch name. + * + * `refs/remotes/<remote>/HEAD` has a symref of "<remote>/<branch>", and the + * remote is not always called origin: `git clone -o upstream`, a + * `git remote rename`, or simply a second remote whose name sorts first — + * `refs:list` is refname-ordered, so the first symref found may be anyone's. + * Stripping the literal "origin/" left "upstream/main", which no local branch + * is ever named, and every check written against this value silently stopped + * firing: no row got the "default" pill, the divergence bar rendered for the + * default branch against itself, and the sweep's guard let `main` through + * into a bulk delete. + * + * Strip the remote the ref actually names — on a remote HEAD `name` IS the + * bare remote — which also keeps `origin/release/2.x` → `release/2.x` right. + */ + private defaultBranchName(locals: BranchInfo[]): string | undefined { + const head = this.refs.find((r) => r.type === "remote" && r.symref); + const symref = head?.symref; + if (symref) { + const prefix = `${head!.name}/`; + return symref.startsWith(prefix) ? symref.slice(prefix.length) : symref; + } + return locals.find((b) => b.current)?.name; + } + + /** Which orders the segment on screen can honour. */ + private branchSortKeys(): Array<"recent" | "name" | "ahead" | "stale"> { + if (this.branchTab === "local") return ["recent", "name", "ahead", "stale"]; + // A remote branch or a tag carries a date and a name, and nothing that + // could answer "most ahead". + if (this.branchTab === "remote" || this.branchTab === "tags") return ["recent", "name", "stale"]; + return []; + } + + /** The order actually applied, once the segment has had its say. */ + private effectiveSort(): "recent" | "name" | "ahead" | "stale" { + const keys = this.branchSortKeys(); + return keys.includes(this.branchSort) ? this.branchSort : "recent"; + } + + /** A worktree row. `worktree:list/add/remove/open` have existed in the IPC + * contract with no caller in any view — the cheapest capability in the app. */ + private worktreeRow(w: WorktreeInfo): HTMLElement { + const actions: HTMLElement[] = []; + if (!w.current) { + const open = el("button", "row-btn") as HTMLButtonElement; + open.textContent = "Open"; + open.setAttribute("aria-label", `Open the worktree at ${w.path}`); + open.title = `Switch this window to ${w.path}`; + open.addEventListener("click", () => void this.openWorktreeLive(w, open)); + actions.push(open); + } + const more = el("button", "row-btn lv-menu-btn") as HTMLButtonElement; + more.setAttribute("aria-label", `More actions for ${w.path}`); + more.setAttribute("aria-haspopup", "menu"); + more.appendChild(glyph("ellipsis")); + const menu = (): void => + openMenu(more, [ + { label: "Copy path", icon: "copy", onClick: () => void copyText(w.path, "Copied the path.") }, + { separator: true }, + { + label: "Remove this worktree…", + icon: "trash", + danger: true, + disabled: w.current, + title: w.current ? "This is the worktree you are in" : undefined, + onClick: () => void this.removeWorktreeLive(w), + }, + ]); + more.addEventListener("click", menu); + actions.push(more); + + const pills: HTMLElement[] = []; + if (w.current) { + const p = span("this window", "ab-pill current"); + p.title = "The worktree this window has open"; + pills.push(p); + } + if (w.locked) pills.push(span("locked", "ab-pill unpublished")); + if (w.prunable) { + const p = span("prunable", "ab-pill gone"); + p.title = "Its directory is gone — git would prune this entry"; + pills.push(p); + } + + const row = secRow({ + lead: glyph("window"), + title: w.branch ?? (w.bare ? "(bare)" : w.head.slice(0, 7)), + titleSuffix: pills, + chips: [span(w.path, "br-subject")], + meta: [span(w.head.slice(0, 7), "br-sha sec-mono")], + time: "", + actions, + // A detached or bare worktree went to `copyText` here — so activating the + // row performed a side effect on data the user owns (whatever was on + // their clipboard), navigated nowhere, and disagreed with the row's own + // "Open" button, while every other row in this view routes somewhere. It + // has a HEAD, and a commit is a page. + onOpen: () => + w.branch + ? this.routeView("refdetail", false, { ref: w.branch, id: "head" }) + : this.routeView("commit", false, { sha: w.head }), + ariaLabel: `${w.branch ?? w.head.slice(0, 7)} at ${w.path}${w.current ? ", this window" : ""}`, + }); + row.classList.add("ref-row"); + row.dataset.ref = w.path; + row.title = w.path; + row.addEventListener("contextmenu", (e) => { + e.preventDefault(); + menu(); + }); + return row; + } + + /** Point this window at another worktree. */ + private async openWorktreeLive(w: WorktreeInfo, btn: HTMLButtonElement): Promise<void> { + await this.refreshInPlace(btn, async () => { + const repo = await host.invoke("worktree:open", w.path); + if (!repo) { + toast(`Couldn't open ${w.path}.`, "error"); + return; + } + toast(`Opened ${w.branch ?? w.path}.`, "success"); + }); + } + + /** Remove a worktree — the directory goes with it, so say so. */ + private async removeWorktreeLive(w: WorktreeInfo): Promise<void> { + const ok = await confirmDialog({ + title: `Remove the worktree at ${w.path}?`, + message: + `git removes the directory as well as the entry. Any uncommitted work inside ` + + `${w.path} goes with it. The branch ${w.branch ?? "it holds"} is not deleted.`, + confirmLabel: "Remove", + danger: true, + }); + if (!ok) return; + const r = await host.invoke("worktree:remove", { path: w.path, force: false }); + if (!r.ok) { + toast(r.message ?? "Couldn't remove the worktree.", r.expected ? "info" : "error"); + return; + } + toast("Worktree removed.", "success"); + await this.refreshBranchesSoft(); + } + + private async refreshBranchesSoft(): Promise<void> { + if (this.currentView === "branches" && this.reloadBranchRows) { + await this.reloadBranchRows(); + } else if (this.currentView === "branches") { + void this.showBranchesView(); + } + } + + /** + * Fetch every remote, from the header. + * + * This existed only as a menu item inside ONE local branch's hover-revealed + * ⋯ — the action that makes every ahead/behind number on the screen true, + * two hover levels deep and attached to a row it has nothing to do with. + */ + private async fetchAllLive(btn: HTMLButtonElement): Promise<void> { + await this.refreshInPlace(btn, async () => { + // Honour the preference, as the other two fetch call sites do. Passing + // `undefined` here meant Settings → "Prune on fetch" was silently ignored + // by the Fetch button on the view whose whole job is showing which + // branches still exist — so a branch deleted on the remote stayed in the + // list after exactly the action that should have removed it. + const r = await host.invoke("sync:fetch", { prune: this.pruneOnFetchPref }); + if (!r.ok) { + toast(r.message ?? "Fetch failed.", r.expected ? "info" : "error"); + return; + } + bust("branches"); + await this.refreshBranchesSoft(); + toast("Fetched from every remote.", "success"); + }); + } + + /** Push a branch that has never been pushed, and set its upstream. */ + private async publishBranchLive(b: BranchInfo, btn: HTMLButtonElement): Promise<void> { + await this.refreshInPlace(btn, async () => { + const r = await host.invoke("branch:push", { name: b.name }); + if (!r.ok) { + toast(r.message ?? `Couldn't publish ${b.name}.`, r.expected ? "info" : "error"); + return; + } + bust("branches"); + await this.refreshBranchesSoft(); + toast(`Published ${b.name}.`, "success"); + }); + } + + /** The per-kind primary action in the header. Each segment has exactly one + * thing you come here to MAKE; Remotes has none, because Fetch is it. */ + private branchesCta(tab: "local" | "remote" | "tags" | "stashes" | "worktrees"): HTMLElement { + const mk = (label: string, icon: string, title: string, run: () => void): HTMLElement => { + const b = el("button", "mini-btn") as HTMLButtonElement; + b.append(glyph(icon), span(label)); + b.title = title; + b.addEventListener("click", run); + return b; + }; + if (tab === "local") { + return mk("New branch", "add", "Create a branch from the current HEAD", () => void this.newBranch()); + } + if (tab === "tags") { + return mk("New tag", "tag", "Tag the current HEAD", () => void this.newTagHere()); + } + if (tab === "stashes") { + // The one screen that LISTS stashes could not make one. + return mk("Stash changes", "archive", "Stash the working tree", () => void this.stashHere()); + } + return el("span", "gh-head-cta-blank"); + } + + /** Tag the current HEAD, from the Tags segment's own CTA. */ + private async newTagHere(): Promise<void> { + const name = await promptInline("Tag name", "v1.0.0"); + if (!name?.trim()) return; + const msg = await promptInline( + `Message for ${name.trim()}`, + "Leave empty for a lightweight tag", + "", + "Create tag", + true, + ); + if (msg === null) return; + const r = await host.invoke("tag:create", { + name: name.trim(), + message: msg.trim() || undefined, + }); + if (!r.ok) { + toast(r.message ?? "Couldn't create the tag.", r.expected ? "info" : "error"); + return; + } + toast(`Created tag ${name.trim()}.`, "success"); + await this.refreshBranchesSoft(); + } - if (!body.children.length) { - body.appendChild( - emptyState(q ? "No matches" : "No branches yet", q ? "Try a different filter." : "", { - icon: "git-branch", - }), - ); - } - }; - filterInput.addEventListener("input", render); - render(); + /** Stash the working tree. The one screen that LISTS stashes could not make + * one — the verb lived only in the Changes view. */ + private async stashHere(): Promise<void> { + const msg = await promptInline("Stash message", "What is this work?", "", "Stash", true); + if (msg === null) return; + const r = await host.invoke("stash:save", { message: msg.trim() || undefined }); + if (!r.ok) { + toast(r.message ?? "Couldn't stash.", r.expected ? "info" : "error"); + return; + } + toast("Stashed your working changes.", "success"); + await this.refreshBranchesSoft(); + } - // Live row reload — refreshes counts/refs IN PLACE (no skeleton, and an - // open branch-actions menu survives) after fetch/pull. Stale-guarded by - // the route generation; cleared implicitly when another view renders. - this.reloadBranchRows = async (): Promise<void> => { - if (gen !== this.routeGen) return; - await this.refreshRefs(); - locals = await gget("branches:list", undefined); - if (gen !== this.routeGen) return; - remotes = this.refs.filter((r) => r.type === "remote" && !r.name.endsWith("/HEAD")); - tags = this.refs.filter((r) => r.type === "tag"); - render(); + /** Empty and error states per kind, each with the verb that fills it. */ + private branchesEmpty( + tab: "local" | "remote" | "tags" | "stashes" | "worktrees", + query: string, + stashFailed: boolean, + /** A facet or the Active/Stale cut is narrowing the list, and it is not the + * search box. Without this the view claimed the REPOSITORY was empty. */ + filtered = false, + /** `worktree:list` threw. A failed read is not an empty list. */ + worktreeFailed = false, + onClear?: () => void, + ): HTMLElement { + // What the reader calls this list, not the internal key. `tab` is + // "local" / "remote" / "tags", and printing it produced "Nothing in local + // matches …" and "No tag here matches …". + const NOUN: Record<string, { one: string; many: string }> = { + local: { one: "branch", many: "branches" }, + remote: { one: "remote branch", many: "remote branches" }, + tags: { one: "tag", many: "tags" }, + stashes: { one: "stash", many: "stashes" }, + worktrees: { one: "worktree", many: "worktrees" }, + }; + const noun = NOUN[tab] ?? { one: "ref", many: "refs" }; + + // A NARROWING emptied it, not the repository. Both narrowings are named + // when both are active: the search branch used to return first, so a search + // that matched something and a facet that matched nothing blamed the search + // box alone — and offered no way to clear the filter actually responsible. + if (query || filtered) { + const why = + query && filtered + ? `No ${noun.many} match “${query}” and the filters in effect.` + : query + ? `No ${noun.many} match “${query}”.` + : `No ${noun.many} match the filters in effect.`; + return emptyState("No matches", why, { + icon: filtered ? "filter" : "search", + anchor: "inline", + // Offered whenever there is a filter to clear — a search you can see in + // the box you typed it into needs no button, but a facet or an age cut + // three controls away does. + ...(filtered && onClear ? { action: { label: "Clear filters", onClick: onClear } } : {}), + }); + } + if (tab === "worktrees" && worktreeFailed) { + return errorState( + "Couldn't read the worktrees", + "Git did not answer. Whether this repository has others is unknown, not settled.", + () => void this.refreshBranchesSoft(), + ); + } + if (tab === "stashes" && stashFailed) { + // A failed read is not an empty list — the old view swallowed the error + // and rendered "no stashes" over a repo that has some. + return errorState( + "Couldn't read the stashes", + "Git did not answer. The stash list is unknown, not empty.", + () => void this.refreshBranchesSoft(), + ); + } + // Every segment, INCLUDING worktrees. `branchTab` is persisted across + // launches, and the Worktrees segment only renders when there is more than + // one — so a repo that loses its extra worktree, or whose `worktree:list` + // fails (the catch turns that into `[]`), reopens on a segment with no + // entry here. Destructuring undefined threw, and the whole Branches view + // rendered blank with a console error nobody sees. + const copy: Record<string, [string, string]> = { + local: ["No branches yet", "Every repository has at least one — this read found none."], + remote: ["No remote branches", "Nothing has been fetched yet. Fetch brings them in."], + tags: ["No tags", "Tag a commit to mark a release or a milestone."], + stashes: ["No stashes", "Stashing puts your working changes aside without committing them."], + worktrees: [ + "No other worktrees", + "A worktree checks out a second branch into its own directory, so you can work on two at once.", + ], }; + const [title, desc] = copy[tab] ?? ["Nothing here", "This list is empty."]; + return emptyState(title, desc, { icon: tab === "stashes" ? "archive" : "git-branch" }); } - /** Set while the Branches view is live — see showBranchesView. */ - private reloadBranchRows: (() => Promise<void>) | null = null; + /** + * One local branch, on the shared `secRow` anatomy. + * + * The old row was a bespoke two-line `div[role=button]` whose entire action + * cluster was `opacity: 0` until hover — which is why every deeper verb had + * to be exiled into a ⋯ menu, and why none of them could be reached by + * keyboard or touch at all. One primary verb and the menu render at rest. + */ + private localBranchRow(b: BranchInfo, defaultBranch?: string, maxAb = 1): HTMLElement { + const pills: HTMLElement[] = []; + const pill = (text: string, cls: string, title: string): HTMLElement => { + const p = span(text, `ab-pill ${cls}`); + p.title = title; + return p; + }; + if (b.current) pills.push(pill("current", "current", "This is the checked-out branch")); + else if (b.name === defaultBranch) pills.push(pill("default", "default", "The repository's default branch")); + if (b.gone) { + // Without this the row reads "0 ahead, 0 behind" — the same shape as + // perfectly in sync — about a remote that no longer exists, which is what + // every merged pull request leaves behind. + pills.push( + pill( + "upstream gone", + "gone", + `${b.upstream ?? "Its upstream"} no longer exists — this branch is probably finished with.`, + ), + ); + } else if (b.merged && !b.current && b.name !== defaultBranch) { + pills.push( + pill( + "merged", + "merged", + `Every commit here is already in ${defaultBranch ?? "the default branch"} — safe to delete.`, + ), + ); + } else if (!b.upstream) { + pills.push(pill("unpublished", "unpublished", "This branch has never been pushed")); + } - /** Refresh branch rows in place when the Branches view is up, else fully. */ - private async refreshBranchesSoft(): Promise<void> { - if (this.currentView === "branches" && this.reloadBranchRows) { - await this.reloadBranchRows(); - } else if (this.currentView === "branches") { - void this.showBranchesView(); + const chips: HTMLElement[] = []; + // How far from the DEFAULT branch, as a bar — the question "how far is this + // from main" that a pair of upstream counts cannot answer. Scaled to the + // widest divergence CURRENTLY ON SCREEN, which is what makes the column + // comparable down the list. Absent on git < 2.41, where it renders nothing + // rather than a bar of zeroes. + if (b.aheadDefault !== undefined && b.behindDefault !== undefined && b.name !== defaultBranch) { + const bar = el("span", "br-ab"); + bar.setAttribute("role", "img"); + bar.setAttribute( + "aria-label", + `${b.aheadDefault} ahead of and ${b.behindDefault} behind ${defaultBranch ?? "the default branch"}`, + ); + bar.title = bar.getAttribute("aria-label")!; + const half = (n: number, cls: string): HTMLElement => { + const h = el("span", `br-ab-half ${cls}`); + const fill = el("span", "br-ab-fill"); + fill.style.width = n ? `${Math.max(3, Math.round(32 * Math.min(1, n / maxAb)))}px` : "0"; + h.appendChild(fill); + return h; + }; + bar.append( + span(String(b.behindDefault), "br-ab-n"), + half(b.behindDefault, "is-behind"), + half(b.aheadDefault, "is-ahead"), + span(String(b.aheadDefault), "br-ab-n"), + ); + chips.push(bar); } - } + if (b.subject) chips.push(span(b.subject, "br-subject")); - private localBranchRow(b: BranchInfo): HTMLElement { - const row = el("div", "list-row branch-row" + (b.current ? " is-current" : "")); - row.appendChild(glyph(b.current ? "check" : "git-branch")); - const meta = el("div", "row-meta"); - const top = el("div", "row-meta-title branch-title"); - const nm = el("span", "branch-name-txt"); - nm.textContent = b.name; - top.appendChild(nm); + const meta: HTMLElement[] = []; + const track = el("span", "br-track"); + // The upstream pair answers a DIFFERENT question from the bar: not "how far + // from main" but "what will Push and Pull do". if (b.ahead) { - const p = el("span", "ab-pill ahead"); - p.textContent = `↑${b.ahead}`; - p.title = `${b.ahead} commit(s) to push to ${b.upstream ?? "upstream"}`; - top.appendChild(p); + const p = span(`↑ ${b.ahead}`, "ab-pill ahead"); + p.title = `${plural(b.ahead, "commit")} to push to ${b.upstream ?? "upstream"}`; + track.appendChild(p); } if (b.behind) { - // The behind count IS the pull button: click pulls those commits live - // (fast-forwarding the branch in place when it isn't checked out). - const p = el("button", "ab-pill behind ab-btn") as HTMLButtonElement; - p.append(glyph("arrow-down"), span(`Pull ${b.behind}`, "ab-lbl")); - p.title = b.current - ? `Pull ${b.behind} commit(s) from ${b.upstream ?? "upstream"}` - : `Pull ${b.behind} commit(s) into ${b.name} — fast-forward, no checkout`; - p.setAttribute("aria-label", p.title); - p.addEventListener("click", (e) => { - e.stopPropagation(); - void this.pullBranchLive(b, p); - }); - top.appendChild(p); - } - meta.appendChild(top); - const bits: string[] = []; - if (b.upstream) bits.push(b.upstream); - if (b.date) bits.push(relTime(b.date)); - if (b.subject) bits.push(b.subject); - const sub = el("div", "row-meta-sub"); - sub.textContent = bits.join(" · "); - if (b.date) sub.title = absTime(b.date); - meta.appendChild(sub); - row.appendChild(meta); - const actions = el("div", "row-actions"); - if (!b.current) { - actions.append( - textBtn("Checkout", "Check out this branch", () => void this.checkoutRef(b.name)), - textBtn("Delete", "Delete this branch", () => void this.deleteBranch(b.name), true), - ); + const p = span(`↓ ${b.behind}`, "ab-pill behind"); + p.title = `${plural(b.behind, "commit")} to pull from ${b.upstream ?? "upstream"}`; + track.appendChild(p); + } + meta.push(track); + meta.push(span(b.upstream ?? "", "br-upstream sec-mono")); + + // ONE contextual primary verb, plus the menu. Delete deliberately does NOT + // live on the row: it is one stray click away from a name you are scanning. + const actions: HTMLElement[] = []; + if (b.behind) { + const pull = el("button", "row-btn") as HTMLButtonElement; + pull.textContent = "Pull"; + pull.setAttribute("aria-label", `Pull ${b.name}`); + pull.title = b.current + ? `Pull ${plural(b.behind, "commit")} from ${b.upstream ?? "upstream"}` + : `Pull ${plural(b.behind, "commit")} into ${b.name} — fast-forward, no checkout`; + pull.addEventListener("click", () => void this.pullBranchLive(b, pull)); + actions.push(pull); + } else if (!b.upstream) { + const pub = el("button", "row-btn") as HTMLButtonElement; + pub.textContent = "Publish"; + pub.setAttribute("aria-label", `Publish ${b.name}`); + pub.title = `Push ${b.name} and set its upstream`; + pub.addEventListener("click", () => void this.publishBranchLive(b, pub)); + actions.push(pub); + } else if (!b.current) { + const co = el("button", "row-btn") as HTMLButtonElement; + co.textContent = "Checkout"; + co.setAttribute("aria-label", `Check out ${b.name}`); + co.title = `Check out ${b.name}`; + co.addEventListener("click", () => void this.checkoutRef(b.name, co)); + actions.push(co); } - // Clicking a row opens the branch-actions dialog (same one as the ⋯ - // button) — deliberate actions live there; a stray click can no longer - // check out a branch. A double-click's second click hits the same handler, - // so single and double click land on the SAME dialog. The row contains - // buttons, so it can't BE a <button> — role + keyboard contract instead. - row.setAttribute("role", "button"); - row.tabIndex = 0; - row.setAttribute("aria-label", `Branch actions for ${b.name}`); - row.setAttribute("aria-haspopup", "menu"); - row.classList.add("is-clickable"); - row.addEventListener("click", () => this.openBranchActions(b, row)); - row.addEventListener("keydown", (e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - this.openBranchActions(b, row); - } - }); const moreBtn = el("button", "row-btn lv-menu-btn") as HTMLButtonElement; moreBtn.setAttribute("aria-label", `More actions for ${b.name}`); moreBtn.setAttribute("aria-haspopup", "menu"); moreBtn.appendChild(glyph("ellipsis")); - moreBtn.addEventListener("click", (e) => { - e.stopPropagation(); + moreBtn.addEventListener("click", () => this.openBranchActions(b, moreBtn)); + actions.push(moreBtn); + + const row = secRow({ + lead: glyph(b.current ? "check" : b.name === defaultBranch ? "home" : "git-branch"), + title: b.name, + titleSuffix: pills, + chips, + meta, + time: b.date ? relTime(b.date) : "", + timeTitle: b.date ? absTime(b.date) : undefined, + actions, + // The row is a PLACE now, not a modal: it routes to the branch's own page. + onOpen: () => this.routeView("refdetail", false, { ref: b.name, id: "head" }), + // What the row says out loud, rather than "Inspect branch main". + ariaLabel: [ + b.name, + b.current ? "current branch" : "", + b.gone ? "upstream gone" : b.merged ? "merged" : "", + // The divergence as a FACT, not as the verbs the row's own buttons + // carry — "3 to push, 5 to pull" beside a Pull button makes a screen + // reader recite the actions back before it reaches them. + b.ahead ? `${b.ahead} ahead` : "", + b.behind ? `${b.behind} behind` : "", + b.date ? `updated ${relTime(b.date)}` : "", + ] + .filter(Boolean) + .join(", "), + }); + row.classList.add("branch-row"); + row.dataset.ref = b.name; + row.title = [b.name, b.subject, b.date ? absTime(b.date) : ""].filter(Boolean).join("\n"); + // Right-click MIRRORS the menu — a shortcut, never a verb's only door. + row.addEventListener("contextmenu", (e) => { + e.preventDefault(); this.openBranchActions(b, moreBtn); }); - actions.appendChild(moreBtn); - row.appendChild(actions); return row; } @@ -1067,7 +2995,7 @@ class App { label: b.upstream ? "Push" : "Publish branch", sub: b.upstream ? b.ahead - ? `${b.ahead} commit(s) to ${b.upstream}` + ? `${plural(b.ahead, "commit")} to ${b.upstream}` : `to ${b.upstream}` : "create it on the remote and track it", icon: b.upstream ? "arrow-up" : "cloud-upload", @@ -1120,19 +3048,14 @@ class App { items.push({ label: "Copy branch name", icon: "copy", - onClick: () => { - void navigator.clipboard.writeText(b.name).then( - () => toast(`Copied “${b.name}”.`, "success"), - () => toast("Couldn't copy to the clipboard.", "error"), - ); - }, + onClick: () => void copyText(b.name, `Copied “${b.name}”.`), }); items.push({ label: "Rename…", icon: "edit", onClick: () => { void (async (): Promise<void> => { - const to = await promptInline("Rename branch", "new-name", b.name); + const to = await promptInline("Rename branch", "new-name", b.name, "Rename"); if (to && to.trim() && to.trim() !== b.name) await run("rename branch", host.invoke("branch:rename", { from: b.name, to: to.trim() })); })(); @@ -1143,7 +3066,7 @@ class App { icon: "cloud", onClick: () => { void (async (): Promise<void> => { - const up = await promptInline("Set upstream", "origin/" + b.name, b.upstream ?? ""); + const up = await promptInline("Set upstream", "origin/" + b.name, b.upstream ?? "", "Set upstream"); if (up && up.trim()) await run("set upstream", host.invoke("branch:setUpstream", { name: b.name, upstream: up.trim() })); })(); @@ -1156,8 +3079,20 @@ class App { void (async (): Promise<void> => { const name = await promptInline("Tag name", "v1.0.0"); if (!name || !name.trim()) return; - const msg = await promptInline("Tag message (optional — blank = lightweight)", "Release 1.0.0"); - await run("create tag", host.invoke("tag:create", { name: name.trim(), ref: b.name, message: msg?.trim() || undefined })); + // `allowEmpty` so Cancel is distinguishable from a deliberate blank. + // Without it both answered `null`, the flow could not tell them + // apart, and cancelling the OPTIONAL second prompt created the tag + // anyway — a Cancel that performs the action, on an object nothing in + // the app can delete afterwards. + const msg = await promptInline( + "Tag message (optional — blank = lightweight)", + "Release 1.0.0", + "", + "Create tag", + true, + ); + if (msg === null) return; + await run("create tag", host.invoke("tag:create", { name: name.trim(), ref: b.name, message: msg.trim() || undefined })); })(); }, }); @@ -1182,6 +3117,20 @@ class App { }, }); } + // Deleting the LOCAL branch belongs here too. The row behind this menu + // offered it as a plain "Delete" button while the menu — reached from the + // branch's own peek, where you have just read its history and decided — did + // not, so the peek was a dead end for the one decision it prepares you for. + if (!b.current) { + items.push({ separator: true }); + items.push({ + label: `Delete ${b.name}`, + icon: "trash", + danger: true, + title: `Delete the local branch ${b.name}`, + onClick: () => void this.deleteBranch(b.name), + }); + } openMenu(anchor, items); } @@ -1313,21 +3262,75 @@ class App { return; // branch still exists — don't refresh as if it were gone } toast(`Deleted ${name}.`, "success"); + // The peek this was very likely launched from is ABOUT the branch that no + // longer exists. Leaving it open left a card offering Checkout, Merge, + // Rename and Push on a ref git would refuse — and a second Delete on + // nothing. A mutation that invalidates a card's subject closes the card; + // the stash peek already works this way. + closePeek(); bust(); await this.refreshRefs(); if (this.currentView === "branches") void this.showBranchesView(); } - /** Check out a branch/tag by name, then refresh refs + the view. */ - private async checkoutRef(ref: string): Promise<void> { - const result = await host.invoke("commit:action", { - action: "checkout", - sha: ref, - } as Parameters<App["runAction"]>[0]); + /** + * Check out a ref by name, then refresh refs + the view. + * + * `kind` is not decoration: it decides what checking out MEANS. A local head + * attaches by name; a remote branch has to create a local tracking branch + * (issues #12/#19); a tag genuinely detaches. Sending a name down the plain + * `checkout` action instead runs `git checkout origin/foo`, which detaches + * HEAD onto the remote-tracking ref — no branch, no upstream, and the next + * commit lands where nothing points at it, under a toast saying "Checked out + * foo." + */ + private async checkoutRef( + ref: string, + btn?: HTMLElement, + kind: "head" | "remote" | "tag" = "head", + ): Promise<void> { + // Checking out is the slowest thing this list does — it rewrites the working + // tree — and it used to show nothing at all while it ran, so the row looked + // like it had ignored the click. + const b = btn as HTMLButtonElement | undefined; + if (b?.disabled) return; + const label = b?.textContent ?? ""; + if (b) { + b.disabled = true; + b.classList.add("is-busy"); + b.textContent = "Checking out…"; + } + const restore = (): void => { + if (!b || !b.isConnected) return; + b.disabled = false; + b.classList.remove("is-busy"); + b.textContent = label; + }; + let result; + try { + result = await host.invoke("commit:action", { + action: "checkout-ref", + // `sha` is required by the request shape but unused on this path; the + // ref travels in `name`, where the kind can be applied to it. + sha: ref, + name: ref, + refKind: kind, + } as Parameters<App["runAction"]>[0]); + } catch (e) { + restore(); + toast(cleanErr(e) || "Couldn't check out.", "error"); + return; + } + restore(); // On failure (e.g. uncommitted changes block the switch) HEAD didn't move — // surface the error and DON'T refresh as if it succeeded (which made the UI // look like the branch was checked out when it wasn't). - if (!result.ok) { + // + // `result` is typed non-nullable but arrives over IPC: a channel that + // failed to register, or a main-process throw, hands back undefined, and + // reading `.ok` off it threw inside an async handler — no toast, no error, + // the click simply did nothing. + if (!result?.ok) { toast(result.message || "Couldn't check out — you may have uncommitted changes.", "error"); return; } @@ -1345,12 +3348,23 @@ class App { await this.refreshRefs(); const current = this.refs.find((r) => r.type === "head" && r.isCurrent)?.name; this.compareHead = this.compareHead ?? current ?? "HEAD"; + // The base must never default to the ref we're already comparing FROM. + // It used to fall back to "main" unconditionally, so standing on main — + // the common case — opened this view on main…main and rendered an error + // the user could do nothing about. + const head = this.compareHead; + const heads = this.refs.filter((r) => r.type === "head"); this.compareBase = this.compareBase ?? - this.refs.find((r) => r.type === "head" && r.name === "main")?.name ?? - this.refs.find((r) => r.type === "head" && !r.isCurrent)?.name ?? - current ?? - "HEAD"; + heads.find((r) => (r.name === "main" || r.name === "master") && r.name !== head)?.name ?? + heads.find((r) => !r.isCurrent && r.name !== head)?.name ?? + // …and past the local branches. A fresh clone has ONE local head, and + // stopping here left the view announcing that the repository has nothing + // to compare against while the picker eight pixels above it listed every + // remote-tracking branch and tag in the repo. The upstream is the base + // anyone actually wants there. + this.refs.find((r) => r.type === "remote" && r.name.endsWith(`/${head}`))?.name ?? + this.refs.find((r) => r.type === "remote")?.name; const wrap = el("div", "compare-view"); @@ -1361,7 +3375,9 @@ class App { const setLabel = (btn: HTMLElement, ref: string): void => { btn.replaceChildren(glyph("git-branch"), span(ref), glyph("chevron-down")); }; - setLabel(baseBtn, this.compareBase); + // With no second ref in the repo the picker says so rather than naming a + // ref that would compare against itself. + setLabel(baseBtn, this.compareBase ?? "Choose a base…"); setLabel(headBtn, this.compareHead); baseBtn.addEventListener("click", () => this.pickRef(baseBtn, (r) => { @@ -1381,16 +3397,32 @@ class App { baseLbl.textContent = "base"; const headLbl = el("span", "compare-lbl"); headLbl.textContent = "compare"; - const swap = el("button", "topbar-icon"); + // Was a bare `topbar-icon` glyph with no border or fill, wedged between two + // bordered ref pickers — it read as a decorative separator, like the tiny + // BASE/COMPARE labels around it. And `git-compare` is the view's own icon, + // not "swap"; the two-way arrow says what the button does. + const swap = el("button", "mini-btn gh-icon-btn cmp-swap"); swap.title = "Swap base and compare"; swap.setAttribute("aria-label", "Swap base and compare"); - swap.appendChild(glyph("git-compare")); + swap.appendChild(glyph("arrow-swap")); swap.addEventListener("click", () => { + // Both sides, or neither. With only one local branch there is no base to + // start with, and this exchanged `undefined` into the HEAD slot: the + // picker rendered an icon, a chevron and an EMPTY label, and the + // comparison ran against nothing. + if (!this.compareBase || !this.compareHead) return; [this.compareBase, this.compareHead] = [this.compareHead, this.compareBase]; - setLabel(baseBtn, this.compareBase!); - setLabel(headBtn, this.compareHead!); + setLabel(baseBtn, this.compareBase); + setLabel(headBtn, this.compareHead); void runCompare(); }); + /** Swap needs two sides to exchange. */ + const syncSwap = (): void => { + const ok = !!this.compareBase && !!this.compareHead; + (swap as HTMLButtonElement).disabled = !ok; + swap.title = ok ? "Swap base and compare" : "Pick a base first"; + swap.setAttribute("aria-label", swap.title); + }; const modeWrap = el("div", "cmp-mode"); const dot3 = el("button", "cmp-mode-btn"); dot3.textContent = "What this branch adds"; @@ -1460,10 +3492,53 @@ class App { const filesCount = el("span", "cmp-seg-count"); filesTab.appendChild(filesCount); seg.append(commitsTab, filesTab); + markSegment(seg, "Comparison view", ".cmp-seg-btn"); const summary = el("div", "cmp-summary"); + // The one action GitHub makes PRIMARY on a comparison was missing entirely: + // you could line up base…head, read every commit and file — and then had + // to rebuild the same comparison on github.com to open the PR. The button + // carries this exact base/head into the create form. + const prBtn = el("button", "mini-btn cmp-pr-btn") as HTMLButtonElement; + prBtn.append(glyph("git-pull-request"), span("Create pull request")); + prBtn.title = "Open a pull request from this comparison"; + prBtn.hidden = true; + /** Whether GitHub could take a pull request at all — the swr answer, kept. + * + * It used to be applied straight to `prBtn.hidden`, and the base===head + * path then hid the button on its own. Nothing ever un-hid it: the swr + * callback had already delivered its cached answer and never fires again, + * and the success path never touched the button. So picking your own + * current branch as the base once removed "Create pull request" for the + * rest of the session — the view's whole purpose, gone, with no way back + * short of a reload. */ + let canPr = false; + /** The two conditions, kept apart and re-asserted on every exit. */ + const syncPrBtn = (): void => { + prBtn.hidden = !(canPr && !!this.compareBase && this.compareBase !== this.compareHead); + }; + prBtn.addEventListener("click", () => + void openCreatePr(() => this.routeView("prs", true), { + base: this.compareBase, + head: this.compareHead, + }), + ); + // Through the cache, not a fresh round trip on every route. Whether you are + // signed in to GitHub cannot change between two clicks in the same app, and + // asking again each time was one of two calls that fired on EVERY entry to + // Compare and Changes — latency spent to re-learn something we already knew. + swr("github:status", undefined, { + // Signing in or out busts the cache explicitly, so a minute of staleness + // costs nothing and saves a round trip on every single route. + ttl: 60_000, + alive: () => prBtn.isConnected, + onData: (s) => { + canPr = s.connected && !!s.repo; + syncPrBtn(); + }, + }); // The Explain / Review actions live on the right of the results row — they act // on the comparison's diff, so they belong with the results, not the pickers. - viewBar.append(seg, summary, aiWrap); + viewBar.append(seg, summary, prBtn, aiWrap); const body = el("div", "cmp-body"); wrap.append(bar, viewBar, body); @@ -1491,12 +3566,64 @@ class App { }); const runCompare = async (): Promise<void> => { - body.replaceChildren(loadingState(`Comparing ${this.compareBase} … ${this.compareHead}`)); - const res = await host.invoke("compare:refs", { - base: this.compareBase!, - head: this.compareHead!, - mode: this.compareMode, - }); + // "Comparing main … feature" belongs on a comparison you have not seen. + // Re-entering Compare on the SAME two refs used to blank the result and + // re-run the whole comparison, so returning to a screen you had just left + // cost a git round trip and a flash of a loading card — for an answer that + // was already on the page a second earlier. + const cmpKey = + this.compareBase && this.compareHead + ? { base: this.compareBase, head: this.compareHead, mode: this.compareMode } + : undefined; + if (!cmpKey || peek("compare:refs", cmpKey) === undefined) { + body.replaceChildren(loadingState(`Comparing ${this.compareBase} … ${this.compareHead}`)); + } + // The previous comparison's answer is no longer an answer to anything. + // `last` was only reassigned on the success path, so the early return + // below left it holding the PRIOR result — and the Commits/Changed-files + // panes went on rendering those commits and files as though they were the + // comparison now on screen, for refs that were never compared. + last = undefined; + // …and so are the numbers derived from it. `last` was nulled because the + // previous comparison's answer is no longer an answer to anything — but + // only the body honoured that. The summary and both tab badges went on + // showing the previous comparison's counts while a new one loaded, so + // "Comparing A … B" sat directly under "12 commits · 9 files" describing + // an entirely different pair of refs. + summary.textContent = ""; + commitsCount.textContent = ""; + filesCount.textContent = ""; + syncPrBtn(); + syncSwap(); + // Nothing to compare yet (a single-branch repo, or base === head): + // prompt for a second ref instead of running a doomed comparison. + if (!this.compareBase || this.compareBase === this.compareHead) { + summary.textContent = ""; + commitsCount.textContent = ""; + filesCount.textContent = ""; + // …and you cannot open a pull request from a branch to itself. Through + // `syncPrBtn`, which owns BOTH conditions — a bare `hidden = true` here + // is what made this state permanent. + syncPrBtn(); + body.replaceChildren( + emptyState( + "Pick two refs to compare", + this.compareBase + ? `Base and compare are both ${this.compareHead}. Choose a different ref on either side.` + : "This repository has only one branch. Compare needs a second ref — create or fetch one first.", + { icon: "git-compare" }, + ), + ); + return; + } + // Through the cache: comparing the same two refs twice should not re-run + // the comparison. Any mutation that could change the answer already calls + // bust(), so this cannot go stale behind the user's back. + const res = await gget( + "compare:refs", + { base: this.compareBase, head: this.compareHead!, mode: this.compareMode }, + 15_000, + ); last = res ?? undefined; if (!res) { summary.textContent = ""; @@ -1511,7 +3638,10 @@ class App { ); return; } - const n = res.commits.length; + // The COUNT is the real one; the LIST may be the first page of it. Showing + // `commits.length` made a 400-commit cap read as a fact, printed beside a + // `behind` that genuinely was one. + const n = res.ahead ?? res.commits.length; const m = res.files.length; commitsCount.textContent = String(n); filesCount.textContent = String(m); @@ -1519,7 +3649,11 @@ class App { n === 0 && m === 0 ? `${this.compareHead} is up to date with ${this.compareBase}.` : `${n} commit${n === 1 ? "" : "s"} · ${m} file${m === 1 ? "" : "s"} changed` + - (res.behind > 0 ? ` · ${this.compareBase} is ${res.behind} ahead` : ""); + // "redesign/issues-detail is 2 ahead" made the reader work out + // whose commits those were; say it straight. + (res.behind > 0 + ? ` · ${res.behind} commit${res.behind === 1 ? "" : "s"} only on ${this.compareBase}` + : ""); renderBody(); }; void runCompare(); @@ -1528,6 +3662,15 @@ class App { /** Commits-only view: the commits `compare` adds over `base`. */ private renderCompareCommits(body: HTMLElement, res: CompareResult | undefined): void { body.replaceChildren(); + // A capped list has to say it is capped, or the rows read as the whole set. + const capNote = (): void => { + if (!res?.commitsTruncated) return; + const note = el("div", "list-cap-note"); + // NOT "the first": the list reads oldest-first, but the cap keeps the + // NEWEST N — so "first" named the wrong end of the range it dropped. + note.textContent = `Showing ${res.commits.length} of ${res.ahead} commits — the most recent.`; + body.appendChild(note); + }; if (!res || !res.commits.length) { body.appendChild( emptyState("No commits", "These refs share the same history in this direction.", { @@ -1536,23 +3679,28 @@ class App { ); return; } - const list = el("div", "cmp-commits"); - for (const c of res.commits) { - // A real button — keyboard-focusable + clickable to reveal the commit in the - // graph (the hover affordance now actually does something). - const row = el("button", "compare-commit"); - row.setAttribute("aria-label", `Commit ${c.shortSha}: ${c.subject} — reveal in the graph`); - row.title = "Reveal in the commit graph"; - const subj = el("div", "cc-subject"); - subj.textContent = c.subject; - const meta = el("div", "cc-meta"); - meta.textContent = `${c.author} · ${c.shortSha} · ${relTime(c.date)}`; - if (c.date) meta.title = absTime(c.date); - row.append(subj, meta); - row.addEventListener("click", () => this.revealInGraph(c.sha)); - list.appendChild(row); - } - body.appendChild(list); + // The SAME list the pull request's Commits tab draws. Both surfaces built + // their own rows out of the same five fields and had drifted apart: this + // one still announced "reveal in the graph" to assistive tech long after + // the click had been changed to open the commit page. + body.appendChild( + commitList( + res.commits.map((c) => ({ + sha: c.sha, + shortSha: c.shortSha, + subject: c.subject, + body: c.body, + author: c.author, + date: c.date, + isMerge: c.isMerge, + })), + { + onOpen: (sha) => this.routeView("commit", false, { sha }), + onCopy: (sha) => void copyText(sha, "Copied the full SHA."), + }, + ), + ); + capNote(); } /** Changed-files view: a GitHub-style master/detail — file list (left, @@ -1595,28 +3743,49 @@ class App { split.append(left, divider, right, restore); body.appendChild(split); - const diff = new CompareDiff(right); + // Release the previous surface BEFORE taking its handle. Overwriting the + // handle orphaned a live Monaco editor — its models, its DOM and its + // listeners all still attached, with nothing left holding a reference to + // dispose them. Every rebuild of this pane leaked one. Every other + // assignment site already does this. + this.activeMonacoView?.dispose(); + // The SAME panel every other diff surface uses — which is where the + // Inline/Split toggle lives. Compare had its own class (`CompareDiff`), so + // it had no toggle at all: "on the compare its missing the switch to toggle + // inline vs split view". Monaco's own width-driven + // `useInlineViewWhenSpaceIsLimited` was deciding for you, invisibly, and + // the segmented control already in Compare's header is the two-dot / + // three-dot RANGE toggle — so the switch looked present and was the wrong + // one. + const diff = new DiffPanel(right); this.activeMonacoView = diff; diff.showEmpty("Select a changed file to view its diff."); let activeRow: HTMLElement | undefined; - const open = (path: string, row: HTMLElement): void => { + const open = (path: string, row: HTMLElement, oldPath?: string): void => { if (activeRow) activeRow.classList.remove("active"); activeRow = row; row.classList.add("active"); - void this.openCompareFile(diff, path); + void this.openCompareFile(diff, path, oldPath); }; res.files.forEach((f, i) => { - const row = el("button", `file-row status-${f.status}`); + // Same two-line treatment the Changes list uses: the FILE NAME, then its + // directory. One path printed whole in a 370px column truncated from the + // right, which ate the only part that tells two files apart + // ("apps/desktop/src/renderer/diffPan…"). + const row = el("button", `file-row dc-file status-${f.status}`); const st = el("span", "file-status"); st.textContent = f.status; - const path = el("span", "file-path"); - path.textContent = f.path; - row.append(st, path); - row.addEventListener("click", () => open(f.path, row)); + const cut = f.path.lastIndexOf("/"); + const meta = el("div", "dc-file-meta"); + meta.appendChild(span(cut < 0 ? f.path : f.path.slice(cut + 1), "dc-file-name")); + if (cut > 0) meta.appendChild(span(f.path.slice(0, cut), "dc-file-dir")); + row.append(st, meta); + row.title = f.path; + row.addEventListener("click", () => open(f.path, row, f.oldPath)); fileScroll.appendChild(row); - if (i === 0) open(f.path, row); // auto-open the first file + if (i === 0) open(f.path, row, f.oldPath); // auto-open the first file }); const setCollapsed = (c: boolean): void => { @@ -1631,7 +3800,7 @@ class App { } /** Drag the vertical divider to resize the file list; relayout the diff live. */ - private wireCompareResizer(divider: HTMLElement, left: HTMLElement, diff: CompareDiff): void { + private wireCompareResizer(divider: HTMLElement, left: HTMLElement, diff: DiffPanel): void { wireResizerKeys(divider, { orientation: "vertical", label: "Resize file list", @@ -1668,18 +3837,41 @@ class App { }); } - private async openCompareFile(diff: CompareDiff, path: string): Promise<void> { + private async openCompareFile(diff: DiffPanel, path: string, oldPath?: string): Promise<void> { + // The same staleness guard openFile and openWorkingFile already use, and + // the only diff surface that was missing it. Click a big file then a small + // one and the big one's response lands last and paints over your actual + // selection — after which the file list and the pane disagree, and nothing + // short of picking a third file resolves it. + const gen = ++this.diffGen; const fileDiff = await host.invoke("compare:fileDiff", { base: this.compareBase!, head: this.compareHead!, path, + // A rename's base side lives under the OLD name. + leftPath: oldPath, mode: this.compareMode, }); + if (gen !== this.diffGen) return; if (fileDiff) { - diff.show(fileDiff); - } else { - diff.showEmpty("No diff available."); + // A file CAN legitimately have identical text on both sides — a mode + // change, or a rename with no edit — and `diff.show` says so itself. + diff.showDiff(fileDiff); + return; } + // No answer is not the same as "no difference". + // + // `compareFileDiff` returns undefined only when there is no repository open + // or a ref failed its safety check — never because the two sides matched. + // Printing "These two refs have identical content for this file." asserted + // equality the app had no basis for, about a file that is in the changed + // list PRECISELY BECAUSE it differs. The same laundering of an absent + // answer into a reassuring one as "working tree clean" over uncommitted + // work, on a smaller surface. + diff.showEmpty( + `${path} is listed as changed between these refs, so this is a failure to read it — not two sides that match.`, + { title: "Couldn't load this file's diff", kind: "error" }, + ); } /** Open a branch/tag picker anchored to `anchor`; calls back with the ref name. */ @@ -1711,6 +3903,7 @@ class App { scroll.append( this.settingsAppearanceCard(), this.settingsAccountCard(), + this.settingsRepositoriesCard(), aiModelsCard(), agentAccessCard(), this.settingsIdentityCard(), @@ -1745,6 +3938,7 @@ class App { btns.push(b); seg.appendChild(b); } + markSegment(seg, "Theme"); // App icon: sits right next to the theme control, same card. "Auto" matches // the theme; the others pin the dock mark regardless of the in-app theme. @@ -1756,9 +3950,11 @@ class App { const logoSeg = el("div", "settings-seg"); // A small live preview of the mark that will actually be shown on the dock. const preview = el("img", "settings-logo-preview") as HTMLImageElement; - preview.alt = ""; const syncLogoPreview = (): void => { - preview.src = this.dockVariant() === "light" ? "./icon-light.png" : "./icon.png"; + const light = this.dockVariant() === "light"; + preview.src = light ? "./icon-light.png" : "./icon.png"; + preview.alt = `Dock icon preview — the ${light ? "light" : "dark"} mark`; + preview.title = preview.alt; }; const logoModes: Array<{ id: LogoMode; label: string }> = [ { id: "auto", label: "Auto" }, @@ -1777,7 +3973,37 @@ class App { logoBtns.push(b); logoSeg.appendChild(b); } + markSegment(logoSeg, logoLabel); syncLogoPreview(); + // Let a theme change from anywhere else — ⌘K, the menu, an OS flip — bring + // these two controls up to date without rebuilding the page around them. + this.syncAppearanceCard = (): void => { + // NOT gated on `seg.isConnected`. Settings is a keep-alive view, so + // leaving it PARKS this card — detached, and re-attached verbatim on + // return. The guard that used to sit here unsubscribed the hook on the + // way out, so a theme changed from anywhere else while you were away + // left the card showing the old one for the rest of the session, with + // its highlight and its `aria-pressed` both stale. + // + // Nothing accumulates: this is a single slot, overwritten by the next + // build, so at most one closure is ever held. Same lesson the Assistant's + // `gs:ai-changed` listener carries — an `isConnected` guard on a + // keep-alive view fires on precisely the path that matters. + btns.forEach((b, i) => b.classList.toggle("active", modes[i].id === this.themeMode)); + logoBtns.forEach((b, i) => b.classList.toggle("active", logoModes[i].id === this.logoMode)); + // `aria-pressed` too, not just the class. `markSegment` keeps it in step + // from a delegated CLICK listener, so a theme changed from anywhere else + // — ⌘K, the menu, an OS flip — moved the highlight while leaving the + // announced state on the button that is no longer chosen. + for (const b of [...btns, ...logoBtns]) { + b.setAttribute("aria-pressed", String(b.classList.contains("active"))); + } + syncLogoPreview(); + }; + // The preview trails the segment so the card's two segmented controls keep + // one left edge. What made it read as a fourth segment was its BORDER — + // a bordered, rounded box a hair from three bordered, rounded buttons — + // so it lost the border, gained a plinth, and stands off by --sp-4. logoRow.append(logoSeg, preview); body.append(sub, seg, logoLabel, logoSub, logoRow); @@ -1814,14 +4040,309 @@ class App { btns.push(b); seg.appendChild(b); } + markSegment(seg, label); body.append(label, sub, seg); return card; } + private settingsRepositoriesCard(): HTMLElement { + const { card, body } = settingsCard("Repositories", "repo"); + const sub = el("div", "settings-sub"); + sub.textContent = "Where one-click opens and clones from GitHub land on disk."; + + const row = el("div", "settings-clonedir-row"); + const rowText = el("div", "settings-clonedir-text"); + const rowLabel = el("div", "settings-field-label"); + rowLabel.textContent = "Default clone folder"; + const rowValue = el("div", "settings-clonedir-path"); + rowValue.textContent = "Loading…"; + rowText.append(rowLabel, rowValue); + const rowBtns = el("div", "settings-clonedir-btns"); + const changeBtn = el("button", "mini-btn"); + changeBtn.append(glyph("folder-opened"), span("Change…")); + const resetBtn = el("button", "mini-btn"); + resetBtn.textContent = "Reset"; + resetBtn.hidden = true; + rowBtns.append(changeBtn, resetBtn); + row.append(rowText, rowBtns); + + const askRow = el("label", "settings-check settings-ask-row"); + const askBox = document.createElement("input"); + askBox.type = "checkbox"; + askBox.setAttribute("aria-label", "Ask where to put each clone"); + const askText = el("div", "settings-check-text"); + // A checkbox's own text, not a group heading — micro-caps would shout a + // whole sentence at you. + const askTitle = el("div", "settings-check-title"); + askTitle.textContent = "Ask where to put each clone"; + const askSub = el("div", "settings-sub"); + askSub.textContent = "Every one-click open shows the destination sheet first."; + askText.append(askTitle, askSub); + askRow.append(askBox, askText); + + const apply = (v: AppSettingsView): void => { + rowValue.textContent = v.cloneDirDisplay; + rowValue.title = v.cloneDir; + resetBtn.hidden = v.cloneDirIsDefault; + askBox.checked = v.askWhereEveryTime; + }; + changeBtn.addEventListener("click", () => { + void host + .invoke("settings:pickCloneDir", undefined) + .then((v) => v && apply(v)) + .catch((e) => toast(cleanErr(e) || "Couldn't choose a folder.", "error")); + }); + resetBtn.addEventListener("click", () => { + void host + .invoke("settings:update", { cloneDir: null }) + .then(apply) + .catch((e) => toast(cleanErr(e) || "Couldn't reset the folder.", "error")); + }); + askBox.addEventListener("change", () => { + void host + .invoke("settings:update", { askWhereEveryTime: askBox.checked }) + .then(apply) + .catch((e) => { + askBox.checked = !askBox.checked; + toast(cleanErr(e) || "Couldn't save the setting.", "error"); + }); + }); + void host + .invoke("settings:get", undefined) + .then(apply) + .catch(() => { + rowValue.textContent = "Unavailable"; + }); + + // The list of every clone on this machine used to live here, 480px down a + // preferences page — with Open, Reveal in Finder and Delete from disk on + // each row. Choosing which repository to work on is the most frequent thing + // anyone does in a Git client and the one thing that must happen before + // anything else works; burying it under "Settings" put it behind the least + // likely door. And nothing on a preferences page should be able to move + // 2GB of someone's work to the Trash: Settings is where reversible knobs + // live. It has its own surface now, reachable from the repository chip in + // the top bar (and from ⌘K). What stays here is the actual preference — + // where clones land — plus a way in. + const manageRow = el("div", "settings-clonedir-row"); + const manageText = el("div", "settings-clonedir-text"); + const manageLabel = el("div", "settings-field-label"); + manageLabel.textContent = "On this machine"; + const manageSub = el("div", "settings-sub"); + manageSub.textContent = "Open, reveal or remove any clone GitStudio knows about."; + manageText.append(manageLabel, manageSub); + const manageBtn = el("button", "mini-btn") as HTMLButtonElement; + manageBtn.append(glyph("repo"), span("Manage repositories…")); + manageBtn.addEventListener("click", () => this.openRepoManager()); + manageRow.append(manageText, manageBtn); + + body.append(sub, row, askRow, manageRow); + return card; + } + + /** + * Every clone on this machine, as a surface of its own. + * + * Moved out of Settings wholesale — the same rows, the same actions — so that + * picking a repository is one gesture from the repository chip instead of a + * scroll through preferences, and so that "Delete from disk" sits in a file + * management context rather than beside the theme switcher. + */ + private openRepoManager(): void { + const body = el("div", "repo-manager"); + const sub = el("div", "settings-sub"); + sub.textContent = + "Every clone GitStudio knows about — the ones in your clone folder plus anything you've opened."; + const list = el("div", "settings-copies"); + list.appendChild(loadingState("Looking for local copies…")); + + const renderCopies = (copies: LocalCopy[]): void => { + list.replaceChildren(); + if (!copies.length) { + list.appendChild( + emptyState( + "No local copies yet", + "Open or clone a repository and it will show up here.", + { + icon: "repo", + action: { + label: "Clone repository…", + icon: "cloud-download", + onClick: () => openCloneDialog((root) => void this.openPath(root)), + }, + }, + ), + ); + return; + } + for (const c of copies) list.appendChild(this.localCopyRow(c, renderCopies)); + }; + void host + .invoke("repos:local", undefined) + .then(renderCopies) + .catch((e) => { + list.replaceChildren( + emptyState("Couldn't list local copies", cleanErr(e) || "Try again in a moment."), + ); + }); + + openModal((close) => { + const card = el("div", "modal-card repo-manager-card"); + const h = el("div", "modal-title"); + h.textContent = "Repositories"; + card.append(h, sub, list); + + const actions = el("div", "modal-actions"); + const openBtn = el("button", "mini-btn") as HTMLButtonElement; + openBtn.append(glyph("folder-opened"), span("Open repository…")); + openBtn.addEventListener("click", () => { + close(); + void this.openRepo(); + }); + const cloneBtn = el("button", "btn btn-primary") as HTMLButtonElement; + cloneBtn.append(glyph("cloud-download"), span("Clone repository…")); + cloneBtn.addEventListener("click", () => { + close(); + openCloneDialog((root) => void this.openPath(root)); + }); + const doneBtn = el("button", "mini-btn") as HTMLButtonElement; + doneBtn.textContent = "Done"; + doneBtn.addEventListener("click", close); + actions.append(openBtn, cloneBtn, doneBtn); + card.appendChild(actions); + + return { card, focusEl: cloneBtn, label: "Repositories", onClose: () => {} }; + }); + } + + /** One row in the local-copies manager: what it is, where it lives, and the + * actions that only make sense for THAT copy (a missing folder can't be + * opened; an unmanaged one can't be deleted from here). */ + private localCopyRow(c: LocalCopy, refresh: (copies: LocalCopy[]) => void): HTMLElement { + const row = el("div", "settings-copy" + (c.missing ? " is-missing" : "") + (c.current ? " is-current" : "")); + row.appendChild(glyph(c.missing ? "warning" : "repo")); + + const meta = el("div", "settings-copy-meta"); + const top = el("div", "settings-copy-name"); + top.textContent = c.name; + if (c.origin) { + const chip = span(c.origin, "settings-copy-origin"); + chip.title = `origin → github.com/${c.origin}`; + top.appendChild(chip); + } + for (const [label, on] of [ + ["Open", c.current], + ["Managed", c.managed && !c.current], + ["Recent", c.recent && !c.managed && !c.current], + ["Missing", c.missing], + ] as Array<[string, boolean]>) { + if (on) top.appendChild(span(label, "settings-copy-badge")); + } + const bottom = el("div", "settings-copy-path"); + bottom.textContent = c.root; + bottom.title = c.root; + meta.append(top, bottom); + row.appendChild(meta); + + // ONE shape for every row: the thing you'd actually do, spelled out, plus + // an overflow menu for the rest. The cluster used to be two to five + // unlabelled icons whose set changed with an invisible flag — two rows + // both badged MANAGED offered different buttons because one happened to + // also be in recents. A toolbar that changes shape per row can't be + // scanned; a menu whose ITEMS vary by what's possible can. + const acts = el("div", "settings-copy-acts"); + if (!c.missing && !c.current) { + acts.appendChild( + textBtn("Open", `Open ${c.name} in GitStudio`, () => void this.openPath(c.root), false, c.name), + ); + } + + const items: MenuItem[] = []; + if (!c.missing) { + items.push({ + label: "Reveal in Finder", + icon: "link-external", + onClick: () => { + void host + .invoke("repos:reveal", c.root) + .catch(() => toast("Couldn't reveal that folder.", "error")); + }, + }); + } + items.push({ + label: "Copy path", + icon: "copy", + onClick: () => void copyText(c.root, "Path copied."), + }); + if (c.recent) { + items.push({ + label: "Remove from recents", + sub: "Keeps the folder on disk", + icon: "close", + onClick: () => { + void host + .invoke("repos:removeRecent", c.root) + .then(refresh) + .catch((e) => toast(cleanErr(e) || "Couldn't update the list.", "error")); + }, + }); + } + if (c.managed && !c.current && !c.missing) { + items.push({ separator: true }); + items.push({ + label: "Delete from disk", + sub: `Moves ${c.name} to the Trash`, + icon: "trash", + danger: true, + onClick: () => { + void (async () => { + const ok = await confirmDialog({ + title: `Delete ${c.name}?`, + message: `${c.root} moves to the Trash. Anything not pushed to ${c.origin ?? "a remote"} is gone with it.`, + confirmLabel: "Move to Trash", + danger: true, + requireTyped: c.name, + }); + if (!ok) return; + try { + const r = await host.invoke("repos:trash", c.root); + if (!r.ok) { + toast(r.message || "Couldn't delete that clone.", "error"); + return; + } + toast(`Moved ${c.name} to the Trash.`, "success"); + refresh(await host.invoke("repos:local", undefined)); + } catch (e) { + toast(cleanErr(e) || "Couldn't delete that clone.", "error"); + } + })(); + }, + }); + } + const more = el("button", "icon-btn settings-copy-more"); + more.title = `More actions for ${c.name}`; + more.setAttribute("aria-label", more.title); + more.appendChild(glyph("kebab-horizontal")); + more.addEventListener("click", () => openMenu(more, items)); + acts.appendChild(more); + + row.appendChild(acts); + return row; + } + + /** Resolves once the account card's async body has painted — see below. */ + private accountCardReady: Promise<unknown> = Promise.resolve(); + private settingsAccountCard(): HTMLElement { const { card, body } = settingsCard("GitHub Account", "github"); body.appendChild(loadingState()); - void (async () => { + // AWAITABLE. `showSettingsView` returns as soon as the card's shell is in + // the DOM, and everything the card actually shows arrives in this async + // body — so "Switch account", which awaits `showSettingsView()` and then + // looks for the new Sign-in button, was searching a card that still held a + // loading spinner. It found nothing, started nothing, and left you signed + // out: a quieter Sign out under a label promising the opposite. + this.accountCardReady = (async () => { let status: { connected: boolean; login?: string } = { connected: false }; try { status = await host.invoke("github:status", undefined); @@ -1846,14 +4367,32 @@ class App { const actions = el("div", "settings-actions"); const switchBtn = el("button", "mini-btn"); switchBtn.append(glyph("sign-in"), span("Switch account")); + // Switching means signing in as SOMEONE ELSE. This ran the sign-out + // code and stopped there — not even the toast — so the button labelled + // "Switch account" was a quieter Sign out that left you on a + // signed-out card with nothing started and no account to switch to. switchBtn.addEventListener("click", async () => { await host.invoke("github:disconnect", undefined); - void this.showSettingsView(); + await this.authChanged(); + // AWAITED, so the card really has been rebuilt before the new + // sign-in is opened against it. (Not a rAF: the callback would fire + // before the async rebuild had replaced the card.) + await this.showSettingsView(); + // …and for the card INSIDE it, which paints on its own promise. + await this.accountCardReady; + const fresh = document.querySelector<HTMLElement>(".settings-view"); + const btn = fresh + ? [...fresh.querySelectorAll<HTMLButtonElement>("button")].find((b) => + /sign in with github/i.test(b.textContent ?? ""), + ) + : undefined; + btn?.click(); }); const signOut = el("button", "mini-btn danger"); signOut.append(span("Sign out")); signOut.addEventListener("click", async () => { await host.invoke("github:disconnect", undefined); + await this.authChanged(); toast("Signed out of GitHub.", "info"); void this.showSettingsView(); }); @@ -1870,7 +4409,9 @@ class App { ); body.append(sub, signIn, flow); } - })(); + })().catch(() => { + /* the card shows its own error; the promise exists only to be awaited */ + }); return card; } @@ -1932,7 +4473,12 @@ class App { } body.replaceChildren(); const sub = el("div", "settings-sub"); - sub.textContent = "Public keys found in ~/.ssh on this machine."; + // The card used to state "Public keys found in ~/.ssh" and then, on the + // very next line, "No SSH keys found in ~/.ssh" — contradicting itself. + // Describe the card, and let the body report what was actually found. + sub.textContent = keys.length + ? `Public keys in ~/.ssh on this machine.` + : "GitStudio looks for public keys in ~/.ssh on this machine."; body.appendChild(sub); if (failed) { const none = el("div", "settings-empty"); @@ -1964,7 +4510,7 @@ class App { } body.appendChild(list); } - const manage = el("button", "gh-link"); + const manage = el("button", "mini-btn") as HTMLButtonElement; manage.append(glyph("link-external"), span("Manage SSH keys on GitHub")); manage.addEventListener("click", () => window.open("https://github.com/settings/keys", "_blank")); body.appendChild(manage); @@ -1976,17 +4522,73 @@ class App { const { card, body } = settingsCard("About", "info"); const sub = el("div", "settings-sub"); sub.textContent = "GitStudio — an open-source, JetBrains-grade Git client."; - const repo = el("button", "gh-link"); - repo.append(glyph("github"), span("View the project on GitHub")); + const versionRow = el("div", "settings-sub"); + versionRow.textContent = "…"; + void host + .invoke("app:info", undefined) + .then((i) => { + versionRow.textContent = `Version ${i.version}`; + }) + .catch(() => { + versionRow.textContent = ""; + }); + + // Check for updates — the manual end of the same poll→confirm→pull flow + // the background check drives. The status line doubles as the live + // download-progress label while a pull is running. + const updRow = el("div", "settings-update-row"); + const checkBtn = el("button", "mini-btn") as HTMLButtonElement; + checkBtn.append(glyph("sync"), span("Check for updates")); + const status = el("span", "settings-sub settings-update-status"); + this.updateProgressEl = status; + updRow.append(checkBtn, status); + checkBtn.addEventListener("click", async () => { + checkBtn.disabled = true; + status.textContent = "Checking…"; + try { + const r = await host.invoke("update:check", undefined); + if (r.status === "uptodate") { + status.textContent = `You're on the latest version (${r.current}).`; + } else if (r.status === "available" && r.version) { + status.textContent = `GitStudio ${r.version} is available.`; + void this.promptUpdateAvailable({ version: r.version, current: r.current }, true); + } else if (r.status === "downloading") { + status.textContent = "An update is downloading…"; + } else if (r.status === "ready" && r.version) { + status.textContent = `GitStudio ${r.version} is ready to install.`; + void host.invoke("update:download", undefined); // re-announces update:ready + } else { + status.textContent = r.message || "Couldn't check for updates."; + } + } catch (e) { + status.textContent = cleanErr(e) || "Couldn't check for updates."; + } finally { + checkBtn.disabled = false; + } + }); + + // One action language per card: a bordered button beside a bare purple + // text link made two peers look like a control and a footnote. Leaving the + // app is a mini-btn with an external glyph everywhere else in the product. + const repo = el("button", "mini-btn") as HTMLButtonElement; + repo.append(glyph("link-external"), span("View the project on GitHub")); repo.addEventListener("click", () => window.open("https://github.com/GitStudioHQ/gitstudio", "_blank"), ); - body.append(sub, repo); + updRow.insertBefore(repo, status); + body.append(sub, versionRow, updRow); return card; } // ── Code view (GitHub-style repo browser: breadcrumb + listing + README) ───── + /** Every folder hop routes through here (not a bare codePath mutation), so + * each one is a navigation-history entry — back/forward walk the folder + * trail exactly like a browser. */ + private goCodePath(path: string): void { + this.routeView("code", false, { path }); + } + private async showCodeView(): Promise<void> { // Returning to the browser (Back / folder nav) bypasses routeView, so drop // any open-file Monaco viewer here too. @@ -2000,20 +4602,23 @@ class App { const btn = el("button", "code-crumb" + (isLast ? " is-current" : "")); btn.append(glyph(path === "" ? "repo" : "folder"), span(label)); if (!isLast) { - btn.addEventListener("click", () => { - this.codePath = path; - void this.showCodeView(); - }); + btn.addEventListener("click", () => this.goCodePath(path)); } crumbs.appendChild(btn); if (!isLast) crumbs.appendChild(span("/", "code-crumb-sep")); }; + // At the ROOT the repo crumb carries nothing the top-bar switcher does not + // already show 45px above it, both with a folder-ish icon. Deeper in, it + // earns its place as the way back to the root. const repoName = this.currentRepo?.name ?? "repo"; const parts = this.codePath ? this.codePath.split("/") : []; - seg(repoName, "", parts.length === 0); + if (parts.length > 0) seg(repoName, "", false); parts.forEach((p, i) => { seg(p, parts.slice(0, i + 1).join("/"), i === parts.length - 1); }); + // At the root there is no trail to draw; the folder listing below already + // says where you are. + if (parts.length === 0) crumbs.hidden = true; const countChip = el("span", "code-count"); countChip.hidden = true; @@ -2029,7 +4634,17 @@ class App { refreshBtn.title = "Refresh"; refreshBtn.setAttribute("aria-label", "Refresh"); refreshBtn.appendChild(glyph("refresh")); - refreshBtn.addEventListener("click", () => void this.showCodeView()); + refreshBtn.addEventListener("click", () => + // BUST first. The listing is read through `gget("repo:tree", …)`, so a + // Refresh that only re-ran the view was answered from the cache — the + // commit bar and the README (which fetch separately) updated while the + // file list beneath them did not, which is the one thing Refresh is + // pressed for. + void this.refreshInPlace(refreshBtn, () => { + bust("repo:tree"); + return this.showCodeView(); + }), + ); const head = el("div", "code-head"); head.append(crumbs, countChip, el("div", "topbar-spacer"), filterInput, refreshBtn); @@ -2048,6 +4663,16 @@ class App { listing.appendChild(skeletonList(8, false)); wrap.append(head, scroll); this.viewHost.replaceChildren(wrap); + // The view's own keys — "/" to jump to the filter, Backspace to go up a + // folder — are bound on `wrap`, so they only fire for keys pressed INSIDE + // it. Nothing here had focus after a render, so both were dead until you + // happened to click a row first, while the filter placeholder went on + // advertising "(/)". Making the view itself the focus target fixes that and + // the more general "nothing is focused after a folder hop". + wrap.tabIndex = -1; + if (document.activeElement === document.body || document.activeElement === null) { + wrap.focus({ preventScroll: true }); + } const gen = this.routeGen; let entries; @@ -2093,16 +4718,26 @@ class App { if (this.codePath) { const up = el("button", "file-row code-row is-dir code-up"); up.append(glyph("arrow-up"), span("..", "file-path"), el("span", "code-row-size")); - up.addEventListener("click", () => { - this.codePath = this.codePath.split("/").slice(0, -1).join("/"); - void this.showCodeView(); - }); + up.addEventListener("click", () => + this.goCodePath(this.codePath.split("/").slice(0, -1).join("/")), + ); listing.appendChild(up); } - if (!sorted.length && !this.codePath) { + if (!sorted.length) { colhead.hidden = true; - listing.appendChild(emptyState("Empty repository", "No tracked files at HEAD yet.")); + // A subfolder can be empty too — and used to render as a bare card with + // nothing in it and nothing said, which reads as a failed load rather + // than as an answer. The repository-level copy is only right at the root. + listing.appendChild( + this.codePath + ? emptyState( + "This folder is empty", + `${this.codePath} has no tracked files at HEAD.`, + { icon: "folder" }, + ) + : emptyState("Empty repository", "No tracked files at HEAD yet."), + ); } /** Rows in display order, so the filter and keyboard nav can drive them. */ @@ -2115,12 +4750,8 @@ class App { const label = span(e.name, "file-path"); row.append(glyph(fileIcon(e.name, isDir)), label, size); row.addEventListener("click", () => { - if (isDir) { - this.codePath = e.path; - void this.showCodeView(); - } else { - void this.openCodeFile(e.path); - } + if (isDir) this.goCodePath(e.path); + else this.goCodeFile(e.path); }); listing.appendChild(row); rows.push({ el: row, name: e.name, label }); @@ -2202,8 +4833,7 @@ class App { this.codePath ) { ev.preventDefault(); - this.codePath = this.codePath.split("/").slice(0, -1).join("/"); - void this.showCodeView(); + this.goCodePath(this.codePath.split("/").slice(0, -1).join("/")); } }); @@ -2240,6 +4870,19 @@ class App { // README can never abort the surrounding Code-view render. try { bodyEl.innerHTML = renderMarkdown(text); + // README links to this repo's issues/PRs/commits stay IN the app — + // and RELATIVE links ("./docs/x.md") open in the Code browser. + const baseDir = this.codePath; + wireProseNav( + bodyEl, + (v, t) => this.routeView(v, false, t), + undefined, + (rel) => { + const p = resolveRelative(baseDir, rel); + if (/\.[A-Za-z0-9]{1,8}$/.test(p.split("/").pop() ?? "")) this.goCodeFile(p); + else this.goCodePath(p); + }, + ); } catch { bodyEl.classList.add("code-md-plain"); bodyEl.textContent = text; @@ -2258,7 +4901,9 @@ class App { const av = el("span", "code-latest-av"); av.textContent = initials(hc.author); - av.style.setProperty("--av", avatarHue(hc.authorEmail || hc.author)); + const seed = hc.authorEmail || hc.author; + av.style.setProperty("--av", avatarHue(seed)); + av.style.setProperty("--av-ink", avatarInk(seed)); const meta = el("div", "code-latest-meta"); const who = el("span", "code-latest-author"); @@ -2287,16 +4932,67 @@ class App { } /** Opens a tracked file read-only over the listing (Back restores the browser). */ + /** Open a file AS A NAVIGATION — the sibling of `goCodePath` for blobs. The + * folder rides along so Back returns to the listing the file came from. */ + private goCodeFile(path: string): void { + const dir = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : ""; + this.routeView("code", true, { path: dir, file: path }); + } + private async openCodeFile(path: string): Promise<void> { const wrap = el("div", "code-view code-file-view"); const back = el("button", "mini-btn"); back.append(glyph("arrow-left"), span("Back")); - back.addEventListener("click", () => void this.showCodeView()); - const name = el("span", "code-file-name"); - name.textContent = path; + // Through routeView, like every other hop in this view — a bare + // `showCodeView()` repainted the listing without telling the navigation + // history anything, so the top-bar Back chevron (and ⌘[) still pointed at + // whatever you were doing before you opened the file: pressing it from the + // listing jumped out of Code entirely, and Forward came back to the FILE, + // a page you had already left. + back.addEventListener("click", () => + this.goCodePath(path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : ""), + ); + // The tree header carries a clickable trail, a count and a filter; opening a + // file used to replace all of it with "Back" and a raw path string, so the + // routine things — go up a folder, copy this path, see it on GitHub, reload + // it — all became "Back, then find the file again". + const crumbs = el("div", "code-crumbs code-file-crumbs"); + const parts = path.split("/"); + const seg = (label: string, dir: string, isLast: boolean): void => { + const btn = el("button", "code-crumb" + (isLast ? " is-current" : "")); + btn.append(glyph(isLast ? "file" : dir === "" ? "repo" : "folder"), span(label)); + if (!isLast) btn.addEventListener("click", () => this.goCodePath(dir)); + crumbs.appendChild(btn); + if (!isLast) crumbs.appendChild(span("/", "code-crumb-sep")); + }; + seg(this.currentRepo?.name ?? "repo", "", false); + parts.forEach((p, i) => { + seg(p, parts.slice(0, i + 1).join("/"), i === parts.length - 1); + }); + + const copyBtn = el("button", "topbar-icon"); + copyBtn.title = "Copy this file's path"; + copyBtn.setAttribute("aria-label", copyBtn.title); + copyBtn.appendChild(glyph("copy")); + copyBtn.addEventListener("click", () => void copyText(path, "Path copied.")); + + const reloadBtn = el("button", "topbar-icon"); + reloadBtn.title = "Reload this file"; + reloadBtn.setAttribute("aria-label", reloadBtn.title); + reloadBtn.appendChild(glyph("refresh")); + reloadBtn.addEventListener("click", () => + void this.refreshInPlace(reloadBtn, () => this.openCodeFile(path)), + ); + const bar = el("div", "code-head"); - bar.append(back, name); + bar.append(back, crumbs, el("div", "topbar-spacer"), copyBtn, reloadBtn); const surface = el("div", "diff-surface code-file-surface"); + // Something to look at while the read lands. The surface was mounted empty + // and filled only once the file came back, so opening or reloading a file + // showed a blank pane under a full toolbar — indistinguishable from a file + // that is genuinely empty, or from a load that failed. Every other surface + // in the app paints a skeleton first. + surface.appendChild(skeletonList(8, false)); wrap.append(bar, surface); this.viewHost.replaceChildren(wrap); @@ -2304,7 +5000,18 @@ class App { this.activeMonacoView?.dispose(); const viewer = new ReadonlyFileView(surface); this.activeMonacoView = viewer; + // Which route this read belongs to. `routeView` disposes `activeMonacoView` + // and clears it the moment you navigate, so leaving while this fetch is in + // flight meant the editor was built AFTER its owner had let go of it: a + // Monaco instance in a detached node, with nothing holding a reference that + // could ever dispose it. `showCodeView` already guards its own read this + // way; this one did not. + const gen = this.routeGen; const file = await host.invoke("repo:file", { path }); + if (gen !== this.routeGen || !surface.isConnected) { + viewer.dispose(); + return; + } if (!file) { viewer.showMessage("Couldn't read this file."); } else if (file.binary) { @@ -2322,12 +5029,22 @@ class App { const wrap = el("div", "changes-view"); const composer = el("div", "dc-composer"); - const curBranch = this.refs.find((r) => r.type === "head" && r.isCurrent)?.name; + // Built from the awaited `head:get` (what the top bar uses), not from + // `refs`, which is filled by a fire-and-forget refreshRefs() and is empty + // on first paint — the old fallback told you that you were on a detached + // HEAD while the top bar said "main" one row above. When HEAD is not known + // yet the label is a placeholder that syncComposerBranch() fills in. + const head = this.headInfo; + const refsKnown = !!head; + const curBranch = + head && !head.detached + ? head.branch + : this.refs.find((r) => r.type === "head" && r.isCurrent)?.name; const branchLine = el("div", "dc-branch"); const branchSummary = span("", "dc-branch-sum"); branchLine.append( glyph("git-branch"), - span(curBranch ?? "detached HEAD", "dc-branch-name"), + span(curBranch ?? (refsKnown ? "detached HEAD" : "…"), "dc-branch-name"), branchSummary, ); const msgWrap = el("div", "dc-message-wrap"); @@ -2339,8 +5056,59 @@ class App { // this whole subtree. Without a surviving draft, typing a commit message and // then staging one more file silently threw the message away. textarea.value = this.composerDraft.message; + // The repo this composer BELONGS to, captured now — not read at event time. + // An `input` fires for a streaming AI message too, and that stream outlives + // a repo switch: repo A's generated message landed in repo B's box, stamped + // with B, so the guard above then PROTECTED it and it survived every later + // re-open of B. Stamping the repo the composer was built for makes the + // guard drop it instead, which is what it is for. + const composerRepo = this.currentRepo?.root; + // The caret, not just the text. `showChangesView()` rebuilds this whole + // subtree on every stage, unstage, discard and filesystem-watcher tick, and + // a rebuilt textarea is a NEW element: focus fell to <body> and the caret + // went to 0. Typing a paragraph of commit message while a build tool + // touched a file meant the next keystroke landed at the START of it. + const rememberCaret = (): void => { + this.composerDraft.caret = { start: textarea.selectionStart, end: textarea.selectionEnd }; + }; + for (const ev of ["keyup", "click", "select", "input"] as const) { + textarea.addEventListener(ev, rememberCaret); + } textarea.addEventListener("input", () => { this.composerDraft.message = textarea.value; + // Whose draft this is. Without it the reset above cannot tell a repo + // SWITCH (drop it) from a re-open of the same repo (keep it). + this.composerDraftRoot = composerRepo; + }); + /** + * Put text in the composer the way a keystroke would. + * + * Assigning `.value` fires no `input` event, so everything hanging off that + * event goes stale: the surviving draft, and — worse — the commit buttons' + * enabled state. Ticking "Amend last commit" prefilled the previous + * message and then left BOTH Commit and Commit & Push greyed out, insisting + * you "write a commit message first" while it sat in front of you. + */ + /** The exact text the amend prefill put in the box, while it is untouched. */ + let prefilled: string | undefined = this.composerDraft.prefilled; + const setMessage = (text: string): void => { + textarea.value = text; + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }; + textarea.addEventListener("input", () => { + if (prefilled !== undefined && textarea.value !== prefilled) { + prefilled = undefined; + this.composerDraft.prefilled = undefined; + this.composerDraft.prefilled = undefined; + } + }); + // ⌘/Ctrl+Enter commits. Every commit box in every tool does this, and here + // it did nothing at all — the only way to commit was to leave the keyboard. + textarea.addEventListener("keydown", (e) => { + if (e.key !== "Enter" || !(e.metaKey || e.ctrlKey)) return; + e.preventDefault(); + if (commitBtn.hasAttribute("disabled")) return; + commitBtn.click(); }); msgWrap.append(textarea); // ✨ Write the message from the staged diff — sits up in the branch header row @@ -2411,7 +5179,10 @@ class App { this.composerDraft.amend = amend; amendToggle.classList.toggle("is-on", amend); amendToggle.setAttribute("aria-checked", amend ? "true" : "false"); - commitLabel.textContent = amend ? "Amend commit" : curBranch ? `Commit to ${curBranch}` : "Commit"; + syncCommitLabel(); + // Amending needs no changes — rewording the last commit is a commit with + // nothing staged — so the enable rule has to be re-evaluated here too. + syncCommitEnabled(); // Prefill the last commit message when amending an empty composer. if (amend && !textarea.value.trim()) { void host.invoke("repo:headCommit", undefined).then((hc) => { @@ -2419,8 +5190,20 @@ class App { // Amend and pressing commit silently deleted the body and every // trailer — the box looked like the commit, so nothing warned you. const prefill = hc?.message || hc?.subject; - if (amend && prefill && !textarea.value.trim()) textarea.value = prefill; + if (amend && prefill && !textarea.value.trim()) { + setMessage(prefill); + prefilled = prefill; + this.composerDraft.prefilled = prefill; + } }); + } else if (!amend && prefilled !== undefined && textarea.value === prefilled) { + // Un-ticking takes the prefill back. It is the LAST COMMIT'S text, and + // leaving it in the box with amend off armed the composer to create a + // brand-new commit carrying the previous one's exact message — with + // nothing on screen to distinguish it from something you wrote. Only + // withdrawn when untouched: the moment you edit it, it is yours. + setMessage(""); + prefilled = undefined; } }); signoffToggle.addEventListener("click", () => { @@ -2430,7 +5213,7 @@ class App { signoffToggle.setAttribute("aria-checked", signoff ? "true" : "false"); }); coAuthorBtn.addEventListener("click", async () => { - const v = await promptInline("Add co-author", "Name <email@example.com>"); + const v = await promptInline("Add co-author", "Name <email@example.com>", "", "Add"); if (v && v.trim()) { coAuthors.push(v.trim()); renderChips(); @@ -2450,13 +5233,59 @@ class App { const commitRow = el("div", "dc-commit-row"); const commitBtn = el("button", "btn btn-primary dc-commit"); - const commitLabel = span(curBranch ? `Commit to ${curBranch}` : "Commit"); + const commitLabel = span(curBranch ? `Commit to ${curBranch}` : "Commit", "dc-commit-label"); commitBtn.append(glyph("git-commit"), commitLabel); + /** + * The primary button's label, from the one state that decides it. + * + * It used to be written in two places, and the second one — the repaint + * after status loads — did not consult `amend`. So staging a file with + * Amend on left the toggle lit, the label reading "Commit to main", and a + * click still sending `amend: true`. The button promised a new commit and + * rewrote the last one. + */ + const syncCommitLabel = (): void => { + // Reads `this.headInfo` LIVE rather than the `curBranch` const captured + // when the composer was built. HEAD is often still resolving at that + // moment, so the const is undefined and stays undefined — which meant + // ticking Amend and un-ticking it turned "Commit to main" into a bare + // "Commit" and shrank the button by 51px, with the branch line right + // beside it still reading "main". syncComposerBranch used to paper over + // it by writing this element's text directly; now it calls this. + const head = this.headInfo; + const branch = head && !head.detached ? head.branch : undefined; + const name = branch ?? curBranch; + commitLabel.textContent = amend ? "Amend commit" : name ? `Commit to ${name}` : "Commit"; + }; + this.syncCommitLabel = syncCommitLabel; commitBtn.addEventListener("click", () => void this.doDesktopCommit(textarea, commitBtn, false, getOpts())); const pushBtn = el("button", "btn dc-commit dc-push"); pushBtn.append(glyph("arrow-up"), span("Commit & Push")); pushBtn.addEventListener("click", () => void this.doDesktopCommit(textarea, pushBtn, true, getOpts())); commitRow.append(commitBtn, pushBtn); + // A commit needs a message, so the buttons must LOOK unavailable until + // there is one. They used to sit in full accent and swallow the click in + // silence — the app's most important action, dead on arrival. + const syncCommitEnabled = (): void => { + const written = textarea.value.trim().length > 0; + // A message is not enough. On a CLEAN working tree the button was a live + // accent control that could only ever produce git's "nothing to commit", + // because it gated on the text alone. Amend is the exception and a real + // one: rewording the last commit needs no changes at all. + const somethingToCommit = amend || this.changesHaveWork; + const ready = written && somethingToCommit; + for (const b of [commitBtn, pushBtn]) { + b.toggleAttribute("disabled", !ready); + b.title = !written + ? "Write a commit message first" + : somethingToCommit + ? "" + : "Nothing to commit — the working tree is clean"; + } + }; + this.syncCommitEnabled = syncCommitEnabled; + textarea.addEventListener("input", syncCommitEnabled); + syncCommitEnabled(); // Commit options on the left, the commit buttons up on the right — one row. const actionsRow = el("div", "dc-actions"); actionsRow.append(optsRow, commitRow); @@ -2499,20 +5328,22 @@ class App { createPrBtn.addEventListener("click", () => void openCreatePr(() => this.routeView("prs", true), { head: curBranch }), ); - void host - .invoke("github:status", undefined) - .then((s) => { + swr("github:status", undefined, { + ttl: 60_000, + alive: () => createPrBtn.isConnected, + onData: (s) => { createPrBtn.hidden = !(s.connected && !!s.repo); - }) - .catch(() => { - /* offline / not connected — leave hidden */ - }); + }, + }); // Hunk / line staging: stage (or unstage) exactly the lines selected in the // open file's diff. Hidden until a file is open; relabelled by stage state. let openFile: { path: string; staged: boolean } | null = null; let whitespaceIgnored = false; const stageLinesBtn = el("button", "mini-btn dc-stagelines") as HTMLButtonElement; - stageLinesBtn.hidden = true; + // Disabled, not hidden: hiding these two made every button to their left + // slide ~160px sideways the instant you clicked a file — the control you + // were aiming at moved out from under the cursor. + stageLinesBtn.disabled = true; const stageLinesLabel = span("Stage lines"); stageLinesBtn.append(glyph("list-selection"), stageLinesLabel); stageLinesBtn.title = "Stage (or unstage) the lines selected in the diff"; @@ -2533,33 +5364,57 @@ class App { else toast(openFile.staged ? "Unstaged selected lines." : "Staged selected lines.", "success"); } catch (e) { toast(cleanErr(e) || "Couldn't apply the selected lines.", "error"); - } - bust("status"); - bust("diff"); - if (this.currentView === "changes") void this.showChangesView(); + } + void this.repaintChanges(); }); const wsBtn = el("button", "topbar-icon dc-ws") as HTMLButtonElement; - wsBtn.hidden = true; - wsBtn.title = "Ignore whitespace in the diff"; - wsBtn.setAttribute("aria-label", "Ignore whitespace"); + wsBtn.disabled = true; + wsBtn.title = "Ignore leading and trailing whitespace"; + wsBtn.setAttribute("aria-label", "Ignore leading and trailing whitespace"); wsBtn.appendChild(glyph("whitespace")); + wsBtn.setAttribute("aria-pressed", "false"); + // A toggle you cannot read is a toggle you cannot trust: this one was an + // unlabelled glyph whose title said the same thing whichever way it was + // set, and which announced no state at all. + const syncWs = (): void => { + wsBtn.classList.toggle("is-on", whitespaceIgnored); + wsBtn.setAttribute("aria-pressed", String(whitespaceIgnored)); + wsBtn.title = whitespaceIgnored + ? "Leading and trailing whitespace is ignored — click to show it" + : "Ignore leading and trailing whitespace"; + wsBtn.setAttribute("aria-label", wsBtn.title); + }; + syncWs(); wsBtn.addEventListener("click", () => { whitespaceIgnored = !whitespaceIgnored; - wsBtn.classList.toggle("is-on", whitespaceIgnored); - diffPanel.setRenderOptions({ whitespace: whitespaceIgnored ? "all" : "none" }); + syncWs(); + diffPanel.setRenderOptions({ whitespace: whitespaceIgnored ? "trailing" : "none" }); }); const refreshBtn = el("button", "topbar-icon"); refreshBtn.title = "Refresh"; refreshBtn.setAttribute("aria-label", "Refresh"); refreshBtn.appendChild(glyph("refresh")); - refreshBtn.addEventListener("click", () => void this.showChangesView()); + refreshBtn.addEventListener("click", () => void this.refreshInPlace(refreshBtn, () => this.showChangesView())); // A stash button that follows the selection and relabels itself, matching the // extension. Without one, the toolbar could stage everything but never stash // anything, and the only stash route was a right-click most people never try. - const stashBtn = el("button", "topbar-icon") as HTMLButtonElement; - stashBtn.appendChild(glyph("archive")); + // Stashing moves your working tree; its only affordance used to be an + // unlabelled archive glyph sitting between two text buttons. Label it. + const stashBtn = el("button", "mini-btn") as HTMLButtonElement; + stashBtn.append(glyph("archive"), span("Stash")); + // What the button will actually do, captured when its label is written. + // + // The label used to be computed from `selectionPaths()` at toolbar-build + // time, when `this.rowOrder` still held the PREVIOUS render's keys — and + // the click then called `selectionPaths()` AGAIN, against the rebuilt + // order, where those keys no longer matched. So the button could read + // "Stash 1 selected file…" and hand `[]` to `stashPaths`, which means the + // whole working tree. A control must do what it says, even when the state + // underneath it has moved. + let stashScope: string[] = []; const syncStashBtn = (): void => { - const n = this.selectionPaths().length; + stashScope = this.selectionPaths(); + const n = stashScope.length; const label = n === 0 ? "Stash all changes\u2026" @@ -2574,17 +5429,23 @@ class App { syncStashBtn(); stashBtn.addEventListener("click", () => { // With a selection live it follows it; otherwise it means the whole tree, - // and the title has already said which. - const paths = this.selectionPaths(); - void this.stashPaths(paths).then(() => this.clearSelection(lists, selBar)); + // and the title has already said which. `stashScope` is what the title + // was written from, so the two can never disagree. + void this.stashPaths(stashScope).then(() => this.clearSelection(lists, selBar)); }); toolbar.append(tTitle, tSpacer, modelBtn, reviewBtn, createPrBtn, stageLinesBtn, wsBtn, stashBtn, stageAllBtn, refreshBtn); const body = el("div", "dc-body"); const lists = el("div", "dc-lists"); + // ↑/↓ and j/k, the same as every other list in the app — and the same as the + // app's own cheat sheet has been promising. Five lists wired this; the + // LANDING view was not one of them, so the first list most people ever touch + // was the one where the documented keys did nothing. + wireListNav(lists, ".dc-file"); lists.style.flex = `0 0 ${this.changesListW}px`; - lists.appendChild(skeletonList(6)); + // Only when there is nothing cached to draw. See the status load below. + if (peek("status", undefined) === undefined) lists.appendChild(skeletonList(6)); // Selection bar — present only while a selection exists, so the view is // unchanged for anyone who never selects. @@ -2634,7 +5495,7 @@ class App { get: () => this.changesListW, set: (w) => { this.changesListW = w; - lists.style.flex = `0 0 ${w}px`; + listCol.style.flex = `0 1 ${w}px`; }, onCommit: () => this.persist(), }); @@ -2660,60 +5521,136 @@ class App { // The list column carries its own selection bar and drop target beneath it, // so both stay put while `lists` itself is cleared and refilled on repaint. const listCol = el("div", "dc-listcol"); - listCol.style.flex = `0 0 ${this.changesListW}px`; + // `0 1` — the basis is the width the user chose, but the column SHRINKS + // before the diff does. Pinned at `0 0` it held that width at every window + // size and the diff paid for all of it: 529px of file names beside a 254px + // diff pane at 1000px, which cannot show a diff at all. + listCol.style.flex = `0 1 ${this.changesListW}px`; lists.style.flex = "1 1 auto"; listCol.append(lists, selBar, dropZone); body.append(listCol, divider, surface); wrap.append(composer, toolbar, body); + // Whether the composer had the keyboard is read HERE, immediately before + // the swap — not at the top of this method, which is several awaits away + // and could have been true about a textarea the user has since left. + const composerHadFocus = document.activeElement?.classList.contains("dc-message") === true; this.viewHost.replaceChildren(wrap); + if (composerHadFocus) { + textarea.focus({ preventScroll: true }); + const caret = this.composerDraft.caret; + const end = textarea.value.length; + textarea.setSelectionRange(Math.min(caret?.start ?? end, end), Math.min(caret?.end ?? end, end)); + } + this.activeMonacoView?.dispose(); const diffPanel = new DiffPanel(surface); this.activeMonacoView = diffPanel; diffPanel.showEmpty("Select a file to view its diff."); + // Paint from what we already know, and only rebuild if the tree ACTUALLY + // moved. Before this, the file list was a 6-row skeleton on every entry and + // the answer was re-fetched past a 3s TTL — so clicking away for four + // seconds and coming back cost a git round trip and a flash of nothing, on + // the app's landing view, for a working tree that had not changed. The + // skeleton now appears only when there is genuinely nothing to show yet. let files: ChangedFile[]; - try { - files = await gget("status", undefined, 3000); - } catch (e) { - // A failed status load must not leave the skeleton spinning forever. - lists.replaceChildren( - errorState("Couldn't read the working tree", cleanErr(e) || "Git status failed.", () => - void this.showChangesView(), - ), - ); - return; + const known = peek("status", undefined); + if (known !== undefined) { + files = known; + void gget("status", undefined, 0) + .then((fresh) => { + // Drop an answer for a view the user has already left, and repaint + // only on a real difference — a rebuild here would otherwise throw + // away the open file, the scroll position and the selection every + // few seconds for no reason. + this.staleTreeWarned = false; + if (this.currentView !== "changes" || !lists.isConnected) return; + if (sameData(fresh, known)) return; + void this.showChangesView(); + }) + .catch((e) => { + // Keep the last good tree on screen — blanking it would turn a + // transient blip into a visible regression — but SAY that we could not + // confirm it. Silence here means a genuinely broken repo goes on + // showing a stale working tree that the user believes is current. + if (this.currentView !== "changes" || !lists.isConnected) return; + if (this.staleTreeWarned) return; + this.staleTreeWarned = true; + toast( + cleanErr(e) || "Couldn't re-read the working tree — showing the last known state.", + "error", + ); + }); + } else { + try { + files = await gget("status", undefined, 3000); + } catch (e) { + // A failed status load must not leave the skeleton spinning forever. + lists.replaceChildren( + errorState("Couldn't read the working tree", cleanErr(e) || "Git status failed.", () => + void this.showChangesView(), + ), + ); + return; + } } const staged = files.filter((f) => f.staged); const unstaged = files.filter((f) => !f.staged); - commitLabel.textContent = curBranch ? `Commit to ${curBranch}` : "Commit"; + syncCommitLabel(); // A quiet context line: how many changes are staged vs. still to stage. + // + // In the same UNITS as the rows underneath it. `staged` and `unstaged` are + // status RECORDS, and a partially staged file (git's `MM`) appears in both + // — which is correct for the two-list model, where it really does have a + // row in each. The checkbox model deliberately collapses it to ONE row with + // an indeterminate tick, so counting records there put "5 staged · 6 to + // stage" directly above a header reading "Changes (10)": two numbers about + // the same list that cannot both be right. const sumBits: string[] = []; - sumBits.push(staged.length ? `${staged.length} staged` : "nothing staged"); - if (unstaged.length) sumBits.push(`${unstaged.length} to stage`); + if (this.stagingModel() === "checkboxes") { + const paths = new Set([...staged, ...unstaged].map((f) => f.path)); + const stagedPaths = new Set(staged.map((f) => f.path)); + const unstagedPaths = new Set(unstaged.map((f) => f.path)); + const partial = [...stagedPaths].filter((p) => unstagedPaths.has(p)).length; + const fully = stagedPaths.size - partial; + sumBits.push(fully ? `${fully} staged` : "nothing staged"); + if (partial) sumBits.push(`${partial} partly staged`); + const todo = paths.size - fully - partial; + if (todo) sumBits.push(`${todo} to stage`); + } else { + sumBits.push(staged.length ? `${staged.length} staged` : "nothing staged"); + if (unstaged.length) sumBits.push(`${unstaged.length} to stage`); + } branchSummary.textContent = `· ${sumBits.join(" · ")}`; // Mid-operation banner: a merge/rebase/cherry-pick/revert in progress gets an // Abort / Continue affordance (Continue is gated on zero remaining conflicts). void host.invoke("git:opState", undefined).then((op) => { if (this.currentView !== "changes") return; - const kind = op.merging - ? "merge" - : op.rebasing - ? "rebase" - : op.cherryPicking - ? "cherry-pick" - : op.reverting - ? "revert" - : null; + // The host decides WHAT is in progress and WHAT its buttons can do. This + // used to re-derive both from five booleans, and got each wrong in turn: + // "merging first" named a rebase stopped on a merge step a merge (whose + // Abort discards the resolution), and the Skip/Continue choice came out + // wrong in both directions in consecutive commits. + const kind = op.kind; if (!kind) return; + const label = kind === "am" ? "patch series (git am)" : kind; const banner = el("div", "dc-opbanner"); const txt = el("div", "dc-opbanner-text"); txt.append( glyph("warning"), span( op.conflicts > 0 - ? `${kind} in progress — ${op.conflicts} file${op.conflicts === 1 ? "" : "s"} still conflicted` - : `${kind} in progress — resolve and continue`, + ? `${label} in progress — ${op.conflicts} file${op.conflicts === 1 ? "" : "s"} still conflicted` + : op.canSkip && !op.canContinue + // Zero conflicts does not mean "ready to continue": an empty + // patch, or one that would not apply, leaves nothing to record + // and git refuses. Saying "resolve and continue" there sent the + // user at a button that could never work. + ? kind === "am" + ? `${label} in progress — git couldn't apply this patch` + : `${label} in progress — nothing left to commit, this one is already on the branch` + : `${label} in progress — resolve and continue`, "dc-opbanner-strong", ), ); @@ -2722,12 +5659,64 @@ class App { abort.append(glyph("discard"), span("Abort")); const cont = el("button", "btn btn-primary mini-btn") as HTMLButtonElement; cont.append(glyph("check"), span("Continue")); - cont.disabled = op.conflicts > 0; - const runOp = async (ch: "merge:abort" | "merge:continue" | "rebase:abort" | "rebase:continue"): Promise<void> => { + cont.disabled = !op.canContinue; + type OpChannel = + | "merge:abort" | "merge:continue" + | "rebase:abort" | "rebase:continue" | "rebase:skip" + | "cherryPick:abort" | "cherryPick:continue" | "cherryPick:skip" + | "revert:abort" | "revert:continue" | "revert:skip" + | "am:abort" | "am:continue" | "am:skip"; + const family = + kind === "rebase" ? "rebase" + : kind === "cherry-pick" ? "cherryPick" + : kind === "revert" ? "revert" + : kind === "am" ? "am" + : "merge"; + const buttons: HTMLButtonElement[] = []; + /** Ask, with the trigger held down for the whole dialog. + * + * A confirm that leaves its own button live stacks one dialog per click: + * three impatient presses of Abort opened three modals, and dismissing + * them one at a time then fired the command once per Yes. `runOp` + * already locks the banner, but only once it starts — the window + * between the click and the answer belonged to nobody. */ + const askThen = ( + btn: HTMLButtonElement, + opts: Parameters<typeof confirmDialog>[0], + go: () => void, + ): void => { + if (btn.disabled) return; + btn.disabled = true; + void confirmDialog(opts).then((yes) => { + btn.disabled = false; + if (yes) go(); + }); + }; + const runOp = async (ch: OpChannel): Promise<void> => { + // Disabled for the whole round trip, and deliberately NOT restored: + // the repaint below rebuilds the banner with fresh buttons. Restoring + // in a `finally` is not enough — the invoke takes ~10ms and the repaint + // lands a fresh enabled button within ~15ms, so the guard would be + // narrower than a double-click. These controls discard patches one + // press at a time, and `serialize()` QUEUES a second call rather than + // dropping it, so two clicks really did throw away two patches. + for (const b of buttons) b.disabled = true; try { const r = await host.invoke(ch, undefined); - if (!r.ok) toast(r.message || "Operation failed.", r.expected ? "info" : "error"); - else toast("Done.", "success"); + // A failure here is ALWAYS shown as a failure, whatever `expected` + // says. That flag has one job — keep an ordinary condition out of the + // crash reports — and it was doing a second one badly: the sequencer + // verbs are marked expected wholesale, so "I could not take the index + // lock" arrived in the same calm blue as "stopped on the next patch", + // and those are not the same news. Every failure of one of these + // buttons means the operation did not finish, which is worth red even + // when the reason is routine. + // + // A message on SUCCESS is the opposite case — a caveat, not a + // failure. `git am --abort` exits 0 while declining to rewind a HEAD + // that has moved. + if (!r.ok) toast(r.message || "Operation failed.", "error"); + else toast(r.message || "Done.", r.message ? "info" : "success"); } catch (e) { toast(cleanErr(e) || "Operation failed.", "error"); } @@ -2736,19 +5725,153 @@ class App { await this.updateSync(); if (this.currentView === "changes") void this.showChangesView(); }; - const isRebase = kind === "rebase"; - abort.addEventListener("click", () => void runOp(isRebase ? "rebase:abort" : "merge:abort")); - cont.addEventListener("click", () => void runOp(isRebase ? "rebase:continue" : "merge:continue")); - acts.append(abort, cont); + abort.addEventListener("click", () => { + // EVERY abort asks now, not only `am`. + // + // The old reasoning was that the other aborts "return you to a commit + // still in the reflog", so nothing is lost. That is true of the + // COMMITS and false of the thing that actually costs time: the conflict + // resolutions. Working through eight conflicted files by hand and then + // pressing Abort — one click, no confirm, right beside Continue — + // throws all of that away, and none of it was ever committed, so the + // reflog has no copy of it. It is the most expensive irreversible click + // in the app and was the only one that did not ask. + const ASK: Record<string, { title: string; message: string; confirmLabel: string }> = { + am: { + title: "Abandon this patch series?", + message: + "git has applied part of the series already. Abandoning it discards those patches, and " + + "the patch files themselves are usually not something the app can replay.", + confirmLabel: "Abandon series", + }, + merge: { + title: "Abandon this merge?", + message: + "Your branch goes back to where it was before the merge. Any conflicts you have already " + + "resolved are discarded with it — those were never committed, so nothing can bring them back.", + confirmLabel: "Abandon merge", + }, + rebase: { + title: "Abandon this rebase?", + message: + "Your branch goes back to where it was before the rebase. Any conflicts you have already " + + "resolved are discarded with it — those were never committed, so nothing can bring them back.", + confirmLabel: "Abandon rebase", + }, + "cherry-pick": { + title: "Abandon this cherry-pick?", + message: + "The commit is not applied, and any conflicts you have already resolved are discarded — " + + "those were never committed, so nothing can bring them back.", + confirmLabel: "Abandon cherry-pick", + }, + revert: { + title: "Abandon this revert?", + message: + "The revert is not applied, and any conflicts you have already resolved are discarded — " + + "those were never committed, so nothing can bring them back.", + confirmLabel: "Abandon revert", + }, + }; + const ask = ASK[kind] ?? { + title: "Abandon this operation?", + message: + "Any conflicts you have already resolved are discarded. Those were never committed, so " + + "nothing can bring them back.", + confirmLabel: "Abandon", + }; + askThen(abort, { ...ask, danger: true }, () => + runOp(`${kind === "am" ? "am" : family}:abort` as OpChannel), + ); + }); + cont.addEventListener("click", () => void runOp(`${family}:continue` as OpChannel)); + if (op.canSkip) { + const skip = el("button", "mini-btn") as HTMLButtonElement; + skip.append(glyph("arrow-right"), span(kind === "am" ? "Skip this patch" : "Skip this commit")); + skip.title = + kind === "am" + ? "Drop the patch git is stuck on and carry on with the rest of the series" + : "Drop this commit and carry on with the rest"; + // Skipping discards work — a patch, or a commit — and cannot be undone + // from inside the app. It asks, and it is never the primary button. + skip.addEventListener("click", () => { + askThen( + skip, + { + title: kind === "am" ? "Skip this patch?" : "Skip this commit?", + message: + kind === "am" + ? "The patch git is stuck on is dropped and the rest of the series carries on. The app cannot replay it." + : "This commit is dropped from the rebase and the rest carries on.", + confirmLabel: kind === "am" ? "Skip patch" : "Skip commit", + danger: true, + }, + () => runOp(`${family}:skip` as OpChannel), + ); + }); + acts.append(abort, skip, cont); + buttons.push(abort, skip, cont); + } else { + acts.append(abort, cont); + buttons.push(abort, cont); + } banner.append(txt, acts); wrap.insertBefore(banner, wrap.firstChild); }); + /** Select a row and open its diff — the one path a click and a restore share. */ + const selectRow = (row: HTMLElement, f: ChangedFile): void => { + lists.querySelectorAll(".file-row.active").forEach((n) => n.classList.remove("active")); + row.classList.add("active"); + openFile = { path: f.path, staged: !!f.staged }; + this.changesOpenKey = rowKey(f.staged ? "staged" : "unstaged", f.path); + stageLinesLabel.textContent = f.staged ? "Unstage lines" : "Stage lines"; + // Held CLOSED until the diff actually arrives and turns out to have a + // line editor in it. These used to be opened by the click that selected + // the row, before the diff had even been asked for — so over a binary, a + // conflict, a truncated file or a failed read they sat lit above a pane + // with no editor, and answered a press with "select some lines first": + // advice that cannot be followed, about a control that could never work + // on this file. + stageLinesBtn.disabled = true; + wsBtn.disabled = true; + // `finally`, not `then`: these buttons start CLOSED, so a rejection here + // would leave them shut over a file whose diff is on screen, and the only + // way out would be to pick a different file. A read that failed has no + // line editor either, which is the state this settles them into. + void this.openWorkingFile(diffPanel, f.path).finally(() => { + if (this.changesOpenKey !== rowKey(f.staged ? "staged" : "unstaged", f.path)) return; + const live = diffPanel.hasLineEditor(); + stageLinesBtn.disabled = !live; + wsBtn.disabled = !live; + const why = "This file has no line-by-line diff to work with."; + stageLinesBtn.title = live ? "" : why; + // `syncWs` OWNS this title — it depends on whether whitespace is + // currently ignored, not only on whether there is a diff to ignore it + // in. Writing the "turn it on" text here unconditionally relabelled a + // toggle that was already ON as though it were off, on every file you + // opened: the exact "titles never change with their state" defect the + // log toolbar was fixed for, reintroduced one toolbar over. + if (live) syncWs(); + else wsBtn.title = why; + }); + }; + const fileRow = (f: ChangedFile, kind: "staged" | "unstaged"): HTMLElement => { const row = el("button", `file-row dc-file status-${f.status}`); const slash = f.path.lastIndexOf("/"); const base = slash >= 0 ? f.path.slice(slash + 1) : f.path; const dir = slash >= 0 ? f.path.slice(0, slash) : ""; + // NAME the row explicitly, because it is a <button> that CONTAINS + // buttons. Without a name of its own it derives one from its contents, + // so giving the row actions labels that name their file — the right fix + // for the actions — folded that path into the row's announcement three + // times over: "app.css Stage app.css Discard app.css". Carrying the + // status letter and the staged side says what the path alone cannot. + row.setAttribute( + "aria-label", + `${kind === "staged" ? "Staged" : "Unstaged"} ${statusWord(f.status)} ${f.path}`, + ); row.appendChild(glyph(fileIcon(base))); const meta = el("div", "dc-file-meta"); meta.appendChild(span(base, "dc-file-name")); @@ -2761,23 +5884,18 @@ class App { const actions = el("div", "row-actions"); if (kind === "staged") { actions.appendChild( - textBtn("Unstage", "Unstage this file", () => void this.changesAction("unstage", f.path)), + textBtn("Unstage", "Unstage this file", () => void this.changesAction("unstage", f.path), false, f.path), ); } else { actions.appendChild( - textBtn("Stage", "Stage this file", () => void this.changesAction("stage", f.path)), + textBtn("Stage", "Stage this file", () => void this.changesAction("stage", f.path), false, f.path), ); actions.appendChild( textBtn("Discard", "Discard changes to this file", () => { - void confirmDialog({ - title: "Discard changes?", - message: `Discard your changes to ${f.path}? This can't be undone.`, - confirmLabel: "Discard", - danger: true, - }).then((ok) => { + void confirmDialog(this.discardConfirm([f.path])).then((ok) => { if (ok) void this.changesAction("discard", f.path); }); - }, true), + }, true, f.path), ); } row.appendChild(actions); @@ -2795,13 +5913,7 @@ class App { row.addEventListener("click", (ev) => { // A modifier click selects; a plain one opens the file, as before. if (this.handleSelectionClick(ev, key, lists, selBar)) return; - lists.querySelectorAll(".file-row.active").forEach((n) => n.classList.remove("active")); - row.classList.add("active"); - openFile = { path: f.path, staged: !!f.staged }; - stageLinesLabel.textContent = f.staged ? "Unstage lines" : "Stage lines"; - stageLinesBtn.hidden = false; - wsBtn.hidden = false; - void this.openWorkingFile(diffPanel, f.path); + selectRow(row, f); }); row.addEventListener("contextmenu", (ev) => { @@ -2841,6 +5953,10 @@ class App { }; lists.replaceChildren(); + // What the composer's enable rule needs to know: is there anything here to + // commit at all. Set on every render, before the empty-tree early return. + this.changesHaveWork = files.length > 0; + this.syncCommitEnabled?.(); // Rebuilt with the rows below, so a shift-range always covers what is on // screen rather than what was there before the last stage. this.rowOrder = []; @@ -2848,6 +5964,11 @@ class App { lists.appendChild( emptyState("Working tree clean", "No changes to commit.", { icon: "check-all" }), ); + // Reconcile before leaving, exactly as the populated path does. Returning + // early left `selectedRows` holding keys for files that no longer exist, + // so the selection bar and the stash button went on describing a + // selection over a clean tree. + this.reconcileSelection(lists, selBar); return; } if (this.stagingModel() === "checkboxes") { @@ -2855,10 +5976,22 @@ class App { // stages, unticking unstages, and the checked state is read back from what // git reports — so there is no shadow selection able to drift away from the // repository, and an external `git add` keeps agreeing with the UI. - const all = [ - ...staged.map((f) => ({ f, staged: true })), - ...unstaged.map((f) => ({ f, staged: false })), - ].sort((a, b) => a.f.path.localeCompare(b.f.path)); + // ONE row per FILE. Concatenating the two lists gave a partially-staged + // file (git's `MM`: a staged edit plus a newer unstaged one) two rows — + // the same path listed twice, once ticked and once not, contradicting + // itself, and counted twice in "Changes (N)". In a model whose entire + // promise is "the tick is the index", one file cannot be both. + // + // Partial is a real third state, and a checkbox has one: indeterminate. + const byPath = new Map<string, { f: ChangedFile; staged: boolean; partial: boolean }>(); + for (const f of staged) byPath.set(f.path, { f, staged: true, partial: false }); + for (const f of unstaged) { + const prior = byPath.get(f.path); + // The UNSTAGED record wins the row: it is the one with unstaged hunks + // to open, which is what a partial file needs its twisty for. + byPath.set(f.path, { f, staged: false, partial: !!prior }); + } + const all = [...byPath.values()].sort((a, b) => a.f.path.localeCompare(b.f.path)); // Selecting a "section" here means the CHECKED rows or the UNCHECKED ones: // this model deliberately has no Staged/Unstaged split to click on. @@ -2878,20 +6011,39 @@ class App { head.insertBefore(master, head.firstChild); lists.appendChild(head); - for (const { f, staged: isStaged } of all) { + for (const { f, staged: isStaged, partial } of all) { const row = fileRow(f, isStaged ? "staged" : "unstaged"); const ck = document.createElement("input"); ck.type = "checkbox"; ck.className = "dc-ck"; ck.checked = isStaged; - ck.title = isStaged ? "Included in the commit" : "Not included"; + // Partly in, partly out — the state the two-row version could not say. + ck.indeterminate = partial; + // NAMED FOR THE FILE, not just the state. + // + // Every tick in the list said "Not included" or "Included in the + // commit", so three of them shared one name — which is useless to a + // screen reader ("not included" — WHAT isn't?) and actively harmful to + // the focus rescue: `sameThing` matches on `title`, so after the + // rebuild a tick took focus from the FIRST checkbox with that state, + // and ticking the fourth file moved the keyboard to the first. + const stateWord = partial + ? "Partly included — some changes to this file are staged" + : isStaged + ? "Included in the commit" + : "Not included"; + ck.title = `${stateWord} — ${f.path}`; + ck.setAttribute("aria-label", ck.title); ck.addEventListener("click", (ev) => { // The row opens the diff; the tick must not. ev.stopPropagation(); // Ticking the whole file supersedes any hunk view of it: those indexes // describe a state that is about to stop existing. this.expandedHunks.delete(f.path); - void this.changesAction(isStaged ? "unstage" : "stage", f.path); + // From partial, one click means "include the whole file" — matching + // the master tick above, and the only reading that leaves the file in + // a state the checkbox can then describe. + void this.changesAction(partial ? "stage" : isStaged ? "unstage" : "stage", f.path); }); row.insertBefore(ck, row.firstChild); @@ -2913,6 +6065,13 @@ class App { void this.showChangesView(); }); row.insertBefore(tw, row.firstChild); + } else { + // Staged rows have no twisty, so without a spacer their content + // started ~29px left of the unstaged rows and the list read as two + // ragged columns. The cell is always there; only its ink isn't. + const spacer = el("span", "dc-hunk-twisty is-spacer"); + spacer.setAttribute("aria-hidden", "true"); + row.insertBefore(spacer, row.firstChild); } lists.appendChild(row); @@ -2925,22 +6084,68 @@ class App { void this.fillHunks(holder, f.path); } } - return; + // NO `return` here: the tail below is model-agnostic — it keys off the + // `.dc-file` rows this branch emits too — and skipping it threw the open + // diff away on every tick, in the one model whose whole interaction is + // "tick boxes while reading the diff". + } else { + if (staged.length) { + lists.appendChild( + this.sectionHeader(`Staged (${staged.length})`, "staged", staged, lists, selBar), + ); + staged.forEach((f) => lists.appendChild(fileRow(f, "staged"))); + } + if (unstaged.length) { + lists.appendChild( + // "Changes" already names the view and the pane; this group is the + // UNSTAGED half, and calling it "Changes" beside "Staged" made the two + // read as unrelated rather than as a pair. + this.sectionHeader(`Unstaged (${unstaged.length})`, "unstaged", unstaged, lists, selBar), + ); + unstaged.forEach((f) => lists.appendChild(fileRow(f, "unstaged"))); + } } + this.reconcileSelection(lists, selBar); - if (staged.length) { - lists.appendChild( - this.sectionHeader(`Staged (${staged.length})`, "staged", staged, lists, selBar), - ); - staged.forEach((f) => lists.appendChild(fileRow(f, "staged"))); - } - if (unstaged.length) { - lists.appendChild( - this.sectionHeader(`Changes (${unstaged.length})`, "unstaged", unstaged, lists, selBar), - ); - unstaged.forEach((f) => lists.appendChild(fileRow(f, "unstaged"))); + // Put the view back where the user left it. Everything above rebuilt the + // list from scratch — which is what closed the diff you were reading, + // deselected the row you had picked and scrolled you back to the top on + // every single stage, unstage, discard or refresh. + lists.addEventListener("scroll", () => { + this.changesScroll = lists.scrollTop; + }); + const reopen = this.changesOpenKey; + if (reopen) { + const rows = [...lists.querySelectorAll<HTMLElement>(".dc-file")]; + // Reopen the exact HALF that was open, matched by row KEY. A partially- + // staged file (git's `MM`) is two rows sharing one path in the split + // model, and the STAGED one renders first — so matching on the path alone + // always landed on it. Having the unstaged half open and touching + // anything at all (stage, unstage, discard, Refresh, a watcher tick) + // moved you to the staged half, which relabels this toolbar's + // "Stage lines" to "Unstage lines" and flips the `reverse` its click + // sends. `file:diff` is HEAD↔working tree either way, so the pane looked + // identical and the next click unstaged what you meant to stage. + // + // The half can legitimately be gone — staged in full, or discarded — and + // the file's other half is then the right place to land; only when no row + // is left for the path at all is the open diff actually dropped. + const row = + rows.find((r) => r.dataset.key === reopen) ?? + rows.find((r) => r.dataset.path === parseRowKey(reopen).path); + // The ROW decides which record to reopen with, not the other way round. + // A partially-staged file has a record on both sides, and the checkbox + // model renders only the UNSTAGED one — reading `staged` first there + // reopened it labelled "Unstage lines" against an unstaged row. + const f = row + ? (row.dataset.kind === "staged" ? staged : unstaged).find( + (x) => x.path === row.dataset.path, + ) + : undefined; + if (f && row) selectRow(row, f); + else this.changesOpenKey = undefined; } - this.reconcileSelection(lists, selBar); + if (this.changesScroll > 0) lists.scrollTop = this.changesScroll; } /** @@ -2976,9 +6181,7 @@ class App { if (!r.ok) { toast(r.message || "Couldn't stage that change.", r.expected ? "info" : "error"); } - bust("status"); - bust("diff"); - void this.showChangesView(); + void this.repaintChanges(); })(); }); // 1-based, matching what an editor's gutter shows. @@ -3003,18 +6206,42 @@ class App { const diff = await host.invoke("file:diff", { path }); if (gen !== this.diffGen) return; if (!diff) { - diffPanel.showEmpty("No diff available."); + // NOT "no changes". `fileDiff` returns undefined when there is no + // repository open or the path failed its containment check — never + // because the two sides matched. This file is in the changed list + // PRECISELY BECAUSE it differs, so claiming equality here asserts + // something the app has no basis for. The same laundering of an absent + // answer into a reassuring one that "working tree clean" over + // uncommitted work would be. + diffPanel.showEmpty( + `${path} is listed as changed, so this is a failure to read it — not a file that matches HEAD.`, + { title: "Couldn't read this file", kind: "error" }, + ); return; } if (diff.conflicted) { const model = await host.invoke("conflict:model", path); if (gen !== this.diffGen) return; if (model) { - diffPanel.showMerge(model, () => { - bust("status"); - bust("diff"); - if (this.currentView === "changes") void this.showChangesView(); - }); + // A conflicted BINARY, or a modify/delete, has no line-by-line merge to + // make — the three-pane editor was mounted over decoded bytes, or over + // one deliberately blank pane that never said the file had been deleted + // on that side. + diffPanel.showMerge( + model, + () => { + void this.repaintChanges(); + }, + { noText: model.binary + ? "binary" + : model.truncated + ? "too-large" + : model.bothDeleted + ? "both-deleted" + : model.missingSide + ? "modify-delete" + : undefined }, + ); return; } } @@ -3022,6 +6249,120 @@ class App { } /** Selection helpers — see selectedRows for why the key is kind:path. */ + /** + * The confirmation for a Discard, told truthfully for these exact files. + * + * Discard means two different things and the dialog only ever described one. + * For a TRACKED file it reverts edits and the file stays. For an UNTRACKED one + * the bridge runs `git clean`, which deletes the file from disk — and git has + * no copy of it, so there is nothing to restore it from, ever. Both cases said + * "Discard your changes to <path>? This can't be undone", which someone with a + * brand-new file reasonably reads as "revert my edits". They lose the file. + */ + private discardConfirm(paths: string[]): { + title: string; + message: string; + confirmLabel: string; + danger: true; + } { + const files = peek("status", undefined) ?? []; + const untrackedPaths = new Set( + files.filter((f) => f.status === "?" && !f.staged).map((f) => f.path), + ); + const conflictedPaths = new Set(files.filter((f) => f.conflicted).map((f) => f.path)); + const gone = paths.filter((p) => untrackedPaths.has(p)); + const reverted = paths.filter((p) => !untrackedPaths.has(p)); + const one = paths.length === 1; + + // A CONFLICTED path is a third thing, and the dialog described neither of + // the two it knew about. Discard here does not delete the file and does not + // revert it to HEAD — it recreates the conflict from the index, throwing + // away the resolution work and nothing else. Said as "permanently discard", + // it read as if the file were about to be destroyed. + const stuck = paths.filter((p) => conflictedPaths.has(p)); + if (stuck.length) { + const onlyStuck = stuck.length === paths.length; + // Every OTHER kind in the selection still has to be described. This + // branch used to return the moment it saw one conflicted path, so a + // selection holding a conflicted file AND an untracked one lost the + // sentence saying the untracked file would be DELETED from disk with + // nothing to restore it from — the single most important sentence this + // dialog can say, dropped because something else in the list was + // conflicted. + const alsoGone = gone.filter((p) => !conflictedPaths.has(p)); + const alsoReverted = reverted.filter((p) => !conflictedPaths.has(p)); + const parts: string[] = [ + stuck.length === 1 + ? `${stuck[0]} is still conflicted: discarding puts its conflict back exactly as git left ` + + `it, and whatever you have resolved in it is lost. The file itself stays.` + : `${stuck.length} of these files are still conflicted: their conflicts come back as git ` + + `left them, and the resolution work in them is lost. The files themselves stay.`, + ]; + if (alsoGone.length) { + parts.push( + alsoGone.length === 1 + ? `${alsoGone[0]} isn't tracked by git, so discarding it DELETES the file from disk. ` + + `Git has no copy of it — there is nothing to restore it from.` + : `${alsoGone.length} of them aren't tracked by git, so discarding them DELETES those ` + + `files from disk. Git has no copy of them — there is nothing to restore them from.`, + ); + } + if (alsoReverted.length) { + parts.push( + `The other ${alsoReverted.length === 1 ? "file has its" : `${alsoReverted.length} have their`} ` + + `changes reverted.`, + ); + } + if (!onlyStuck) parts.push("None of it can be undone."); + return { + title: alsoGone.length + ? "Discard changes and delete files?" + : stuck.length === 1 + ? "Start this conflict again?" + : "Start these conflicts again?", + message: parts.join(" "), + confirmLabel: alsoGone.length + ? "Discard and delete" + : stuck.length === 1 + ? "Restore the conflict" + : "Restore the conflicts", + danger: true, + }; + } + + if (gone.length === 0) { + return { + title: "Discard changes?", + message: one + ? `Discard your changes to ${paths[0]}? This can't be undone.` + : `Discard your changes to ${paths.length} files? This can't be undone.`, + confirmLabel: "Discard", + danger: true, + }; + } + if (reverted.length === 0) { + return { + title: one ? "Delete this file?" : `Delete ${gone.length} files?`, + message: one + ? `${gone[0]} isn't tracked by git, so discarding it DELETES the file from disk. ` + + `Git has no copy of it — there is nothing to restore it from.` + : `${gone.length} of these files aren't tracked by git, so discarding them DELETES ` + + `them from disk. Git has no copy of them — there is nothing to restore them from.`, + confirmLabel: one ? "Delete file" : `Delete ${gone.length} files`, + danger: true, + }; + } + return { + title: "Discard changes and delete files?", + message: + `${gone.length} of these ${paths.length} files aren't tracked by git and will be ` + + `DELETED from disk with no way to restore them. The other ${reverted.length} will have ` + + `their changes reverted. Neither can be undone.`, + confirmLabel: "Discard and delete", + danger: true, + }; + } + private selectionEntries(): Array<{ kind: string; path: string }> { return selectionEntries(this.rowOrder, this.selectedRows); } @@ -3240,12 +6581,7 @@ class App { items.push({ label: "Discard Changes", icon: "discard", onClick: () => { - void confirmDialog({ - title: "Discard changes?", - message: `Discard your changes to ${f.path}? This can't be undone.`, - confirmLabel: "Discard", - danger: true, - }).then((ok) => { + void confirmDialog(this.discardConfirm([f.path])).then((ok) => { if (ok) void this.changesAction("discard", f.path); }); }, @@ -3287,15 +6623,7 @@ class App { items.push({ label: `Discard ${noun(discardable.length)}`, icon: "discard", onClick: () => { - void confirmDialog({ - title: "Discard changes?", - message: - discardable.length === 1 - ? `Discard your changes to ${discardable[0]}? This can't be undone.` - : `Discard your changes to ${discardable.length} files? This can't be undone.`, - confirmLabel: "Discard", - danger: true, - }).then((ok) => { + void confirmDialog(this.discardConfirm(discardable)).then((ok) => { if (ok) void this.bulkAction("discard", discardable, lists, selBar); }); }, @@ -3325,9 +6653,7 @@ class App { toast(failed === paths.length ? "Nothing could be applied." : `${failed} of ${paths.length} failed.`, "error"); } this.clearSelection(lists, selBar); - bust("status"); - bust("diff"); - void this.showChangesView(); + void this.repaintChanges(); } /** Stash the given paths, then refresh. Empty means the whole tree. */ @@ -3338,9 +6664,32 @@ class App { return; } toast(paths.length === 1 ? "Stashed 1 file." : `Stashed ${paths.length} files.`); - bust("status"); - bust("diff"); - void this.showChangesView(); + void this.repaintChanges(); + } + + /** + * Re-read the working tree IN PLACE, then repaint Changes. + * + * `bust("status")` DELETES the cached tree, so `showChangesView`'s + * paint-from-what-we-know path found nothing and fell back to a 6-row + * skeleton — on every stage, unstage, discard, stash and hunk apply. The + * list you were working in blanked and the open diff went back to "Select a + * file to view its diff.", several times a minute, for an operation that + * usually moves one row. + * + * Re-reading into the same cache entry keeps a real tree on screen the whole + * time, and the existing change-diff gate then swaps in only what moved. The + * status read must land BEFORE `bust("diff")`, because a bust supersedes + * every request already in flight — including this one. + */ + private async repaintChanges(): Promise<void> { + try { + await gget("status", undefined, 0); + } catch { + // Leave the last-known tree up; showChangesView reports the failure. + } + bust("diff"); // a staged/unstaged file's diff genuinely changed + if (this.currentView === "changes") void this.showChangesView(); } private async changesAction( @@ -3364,9 +6713,7 @@ class App { } catch (e) { toast(cleanErr(e) || "The operation failed.", "error"); } - bust("status"); - bust("diff"); - if (this.currentView === "changes") void this.showChangesView(); + void this.repaintChanges(); } private async doDesktopCommit( @@ -3394,7 +6741,16 @@ class App { } } if (trailers.length) message = `${message}\n\n${trailers.join("\n")}`; - (btn as HTMLButtonElement).disabled = true; + // Both commit buttons go out together. Disabling only the one you pressed + // left "Commit & Push" fully clickable while a commit was already in + // flight, so an impatient second click started a second commit of the same + // staged tree. + const row = btn.closest(".dc-commit-row"); + const pair = row + ? [...row.querySelectorAll<HTMLButtonElement>("button")] + : [btn as HTMLButtonElement]; + for (const b of pair) b.disabled = true; + btn.classList.add("is-busy"); try { // Nothing staged, but there IS work? Offer to commit all of it rather than // refusing (issue #16) — VS Code and JetBrains both do this. The @@ -3413,7 +6769,8 @@ class App { confirmLabel: `Commit all ${n}`, }); if (!yes) { - (btn as HTMLButtonElement).disabled = false; + for (const b of pair) b.disabled = false; + btn.classList.remove("is-busy"); return; } // Stage for real rather than using commit -a: -a skips untracked files @@ -3421,7 +6778,8 @@ class App { const staged = await host.invoke("stageAll", undefined); if (!staged.ok) { toast(staged.message || "Couldn't stage the changes.", "error"); - (btn as HTMLButtonElement).disabled = false; + for (const b of pair) b.disabled = false; + btn.classList.remove("is-busy"); return; } } @@ -3475,7 +6833,8 @@ class App { } textarea.value = ""; // The draft has been spent — do not carry it into the next commit. - this.composerDraft = { message: "", amend: false, signoff: false, coAuthors: [] }; + this.composerDraft = { message: "", amend: false, signoff: false, coAuthors: [], prefilled: undefined, caret: undefined }; + this.composerDraftRoot = undefined; bust(); // a commit (± push) touches refs/branches/status/sync/graph await this.refreshRefs(); await this.updateSync(); @@ -3483,7 +6842,8 @@ class App { } catch (e) { toast(cleanErr(e) || "Commit failed.", "error"); } finally { - (btn as HTMLButtonElement).disabled = false; + for (const b of pair) b.disabled = false; + btn.classList.remove("is-busy"); } } @@ -3525,9 +6885,18 @@ class App { copyBtn.appendChild(glyph("copy")); copyBtn.addEventListener("click", () => void copyText(dc.userCode!, "Code copied.")); codeRow.append(code, copyBtn); + // ONE explicit action, nothing automatic. Auto-copying + auto-opening the + // browser yanked users to GitHub before they'd even read the screen — most + // didn't know the code was "already on the clipboard". Now the user reads + // the code, then clicks: the click copies (a real user gesture, so the + // clipboard write always lands) and opens GitHub. The code stays on screen + // the whole time for retyping if the clipboard is lost. const openBtn = el("button", "btn btn-primary gh-device-open"); - openBtn.append(glyph("link-external"), span("Open GitHub to authorize")); - openBtn.addEventListener("click", () => window.open(openUrl, "_blank")); + openBtn.append(glyph("link-external"), span("Copy code & open GitHub")); + openBtn.addEventListener("click", () => { + void copyText(dc.userCode!, "Code copied — paste it on GitHub."); + window.open(openUrl, "_blank"); + }); const status = el("div", "gh-device-status"); status.setAttribute("role", "status"); status.setAttribute("aria-live", "polite"); @@ -3535,42 +6904,83 @@ class App { card.append(step, codeRow, openBtn, status); flow.replaceChildren(card); - // Smooth the path: copy the code and open GitHub automatically. - void copyText(dc.userCode, "Code copied — paste it on GitHub."); - window.open(openUrl, "_blank"); - - this.pollDeviceFlow(wrap, dc.deviceCode, dc.interval ?? 5, dc.expiresIn ?? 900, status, onConnected); + this.pollDeviceFlow(wrap, dc.deviceCode, dc.interval ?? 5, dc.expiresIn ?? 900, status, signIn, onConnected); } - /** Poll the device-flow token endpoint until authorized / expired / dismissed. */ + /** Poll the device-flow token endpoint until authorized / expired / dismissed. + * Also polls IMMEDIATELY when the window regains focus: the user authorizes + * in the browser and switches back, and the old fixed 5-second cadence made + * that moment feel laggy — now success lands the instant they return. */ private pollDeviceFlow( wrap: HTMLElement, deviceCode: string, interval: number, expiresIn: number, status: HTMLElement, + signIn: HTMLElement, onConnected: () => void, ): void { const deadline = Date.now() + expiresIn * 1000; let intervalSec = interval; + let timer = 0; + let inFlight = false; + let done = false; + const cleanup = (): void => { + done = true; + window.clearTimeout(timer); + window.removeEventListener("focus", onFocus); + }; const fail = (msg: string): void => { + cleanup(); status.replaceChildren(span(msg)); status.classList.add("gh-device-failed"); + // Every failure message says "try again" — the button must be pressable. + (signIn as HTMLButtonElement).disabled = false; + }; + const interrupted = (): void => { + // The settings DOM was detached (view switch) — polling must stop, but + // the card may be RESTORED from the keep-alive cache later. Leave an + // actionable message instead of an eternal spinner. + fail("Sign-in was interrupted — click “Sign in with GitHub” to try again."); }; const tick = async (): Promise<void> => { - if (!wrap.isConnected) return; // panel was replaced — stop polling + if (done) return; + if (!wrap.isConnected) { + interrupted(); + return; + } if (Date.now() > deadline) { fail("The code expired. Click “Sign in with GitHub” to try again."); return; } + if (inFlight) return; // a focus-poll raced the timer — one at a time + inFlight = true; let r; try { r = await host.invoke("github:devicePoll", { deviceCode }); } catch { r = { state: "pending" as const }; + } finally { + inFlight = false; + } + if (done) return; + if (!wrap.isConnected) { + interrupted(); + return; } - if (!wrap.isConnected) return; if (r.state === "authorized") { + cleanup(); + // Every cached GitHub answer was computed while signed OUT — the empty + // issue lists, the hidden PR buttons, the connect prompts. None of it is + // true any more. + // + // Dropped WHOLESALE, because the prefixes this replaced matched almost + // nothing: the channels are `issue:list`, `pr:list`, + // `notifications:list`, `actions:runs`, `release:list`, `orgs:list`, + // `gist:list`, `project:list` — only `github:status` and + // `github:myWork` ever began with "github:", and no channel at all + // begins with "gh". + void this.authChanged(); toast(`Signed in as @${r.login}.`, "success"); onConnected(); return; @@ -3580,14 +6990,36 @@ class App { return; } if (r.state === "slow_down") intervalSec += 5; - window.setTimeout(() => void tick(), intervalSec * 1000); + timer = window.setTimeout(() => void tick(), intervalSec * 1000); }; - window.setTimeout(() => void tick(), intervalSec * 1000); + const onFocus = (): void => { + if (done || inFlight) return; + window.clearTimeout(timer); + void tick(); + }; + window.addEventListener("focus", onFocus); + timer = window.setTimeout(() => void tick(), intervalSec * 1000); } /** The commit graph + a collapsible / drag-resizable commit-details panel. */ - private showGraphView(): void { + private showGraphView(force = false): void { + // KEEP-ALIVE: the graph is the most expensive surface in the app, and it + // used to be torn down and refetched on EVERY tab switch — leave Commits + // for two seconds and coming back cost a full reload, a loading flash, and + // your scroll position. The mount owns no Monaco (the diff lives in the + // dock; the details panel is a plain custom element), so the live DOM can + // simply be re-attached. `graphDirty` re-syncs data in place when the repo + // changed underneath while another view was showing. + if (!force && this.graphViewWrap && this.graph) { + this.viewHost.replaceChildren(this.graphViewWrap); + this.detailsEl = this.graphDetailsPane; + if (this.graphDirty) { + this.graphDirty = false; + void this.graph.reload(); + } + return; + } // Graph LEFT, commit details RIGHT — the same side-by-side arrangement the // extension's commit panel uses. Details used to live in the bottom dock, // which stacked them under the graph and left the dock competing with the @@ -3598,6 +7030,8 @@ class App { this.graphDetailsPane = detailsPane; wrap.append(graphHost, this.graphSplitResizer(wrap), detailsPane); this.viewHost.replaceChildren(wrap); + this.graphViewWrap = wrap; + this.graphDirty = false; this.detailsEl = detailsPane; this.showDetailsPlaceholder(); @@ -3607,9 +7041,13 @@ class App { onSelect: (sha) => void this.selectCommit(sha), onOpen: (sha) => void this.selectCommit(sha), onContext: (sha, x, y) => this.contextMenu.open(sha, x, y, this.refsOn(sha)), + // Ref labels are LINKS now: click a branch/tag chip in the graph and land + // on that ref in Branches, scrolled + flashed. + onRefClick: (name) => this.routeView("branches", false, { ref: name }), // Show the pane again WITHOUT re-selecting: selectCommit() would call - // closeDiffTab() and dispose a diff the user still has open. + // closeGraphDiff() and dispose a diff the user still has open. onShowDetails: () => this.setGraphDetailsVisible(true), + onEmpty: (empty) => this.setDetailsEmptyForNoHistory(empty), }); this.graph = graph; void graph.reload(); @@ -3621,7 +7059,7 @@ class App { * advertises a finished feature as unbuilt. */ private showPlaceholderView(id: string): void { this.viewHost.replaceChildren( - errorState("View unavailable", `“${id}” isn’t a known view.`, () => this.routeView("code", true)), + errorState("View unavailable", `“${id}” isn’t a known view.`, () => this.routeView("changes", true)), ); } @@ -3654,10 +7092,10 @@ class App { set("cloud", "Publish", "Publish this branch to its remote", () => void this.doSync("publish")); wrap.classList.add("has-action"); } else if (s.behind > 0) { - set("arrow-down", `Pull ${s.behind}`, `Pull ${s.behind} commit(s) from ${s.upstream}`, () => void this.doSync("pull")); + set("arrow-down", `Pull ${s.behind}`, `Pull ${plural(s.behind, "commit")} from ${s.upstream}`, () => void this.doSync("pull")); wrap.classList.add("has-action"); } else if (s.ahead > 0) { - set("arrow-up", `Push ${s.ahead}`, `Push ${s.ahead} commit(s) to ${s.upstream}`, () => void this.doSync("push")); + set("arrow-up", `Push ${s.ahead}`, `Push ${plural(s.ahead, "commit")} to ${s.upstream}`, () => void this.doSync("push")); wrap.classList.add("has-action"); } else { set("sync", "Fetch", `Up to date with ${s.upstream} — fetch for updates`, () => void this.doSync("fetch")); @@ -3718,9 +7156,11 @@ class App { toast(`${verb} successfully.`, "success"); bust(); // a fetch/pull/push changes sync/refs/branches/graph await this.updateSync(); + // refreshAll() already re-routes — and it does so WITH the current + // history target, so you keep your place. This second, targetless + // re-route undid that: pressing Push while reading PR #106 refreshed + // correctly and was then immediately replaced by the PR list. await this.refreshAll(); - // Refresh the active data view so its content reflects the sync. - this.routeView(this.currentView, true); } catch (e) { toast(cleanErr(e) || `${action} failed.`, "error"); } finally { @@ -3746,6 +7186,216 @@ class App { openMenu(anchor, items); } + /** ⌘K — one fuzzy search over sections, branches/tags, recent repos, open + * PRs/issues, and the headline actions. Local groups are instant; the + * GitHub groups stream in as they resolve. */ + private openPalette(): void { + if (!this.currentRepo) return; + const go = (v: string, t?: SectionTarget): void => this.routeView(v, false, t); + openCommandPalette({ + local: (): PaletteGroup[] => { + const views: PaletteItem[] = App.TABS.map((t) => ({ + icon: t.icon, + label: t.label, + keywords: t.id, + run: () => go(t.id), + })); + views.push({ icon: "gear", label: "Settings", run: () => go("settings") }); + // The Assistant is a routed, keep-alive view like any other, but it + // lives only behind a sparkle icon in the top bar — absent from the + // rail, from ⌘1-8, and (until now) from here. Typing "assistant" into + // the palette found a GitHub search instead of the app's own view. + views.push({ + icon: "sparkle", + label: "Assistant", + keywords: "ai chat assistant help", + run: () => go("assistant"), + }); + + const refs: PaletteItem[] = [ + ...this.refs + .filter((r) => r.type === "head") + .map((r): PaletteItem => ({ + icon: "git-branch", + label: r.name, + // Only the fact you can't see: which one you're on. + hint: r.isCurrent ? "current" : "", + keywords: `branch ${r.name}`, + run: () => go("branches", { ref: r.name }), + })), + ...this.refs + .filter((r) => r.type === "tag") + .map((r): PaletteItem => ({ + icon: "tag", + label: r.name, + keywords: `tag ${r.name}`, + run: () => go("branches", { ref: r.name }), + })), + ]; + + const actions: PaletteItem[] = [ + { icon: "add", label: "New branch…", run: () => void this.newBranch() }, + { + icon: "git-pull-request", + label: "New pull request…", + run: () => void openCreatePr(() => this.routeView("prs", true)), + }, + { icon: "issues", label: "New issue…", run: () => void openNewIssue(go) }, + { icon: "sync", label: "Fetch", run: () => void this.doSync("fetch") }, + { icon: "arrow-down", label: "Pull", run: () => void this.doSync("pull") }, + { icon: "arrow-up", label: "Push", run: () => void this.doSync("push") }, + { + icon: "repo-clone", + label: "Clone repository…", + run: () => openCloneDialog((root) => void this.openPath(root)), + }, + { icon: "folder-opened", label: "Open repository…", run: () => void this.openRepo() }, + { icon: "terminal", label: "Toggle terminal", keywords: "dock shell", run: () => this.toggleTerminal() }, + { icon: "color-mode", label: "Theme: System", keywords: "theme auto", run: () => this.setThemeMode("system") }, + { icon: "color-mode", label: "Theme: Light", keywords: "theme", run: () => this.setThemeMode("light") }, + { icon: "color-mode", label: "Theme: Dark", keywords: "theme", run: () => this.setThemeMode("dark") }, + { + icon: "cloud-download", + label: "Check for updates", + run: () => { + void host.invoke("update:check", undefined).then((r) => { + if (r.status === "uptodate") toast(`You're on the latest version (${r.current}).`, "success"); + else if (r.status === "available" && r.version) + void this.promptUpdateAvailable({ version: r.version, current: r.current }, true); + else if (r.message) toast(r.message, "info"); + }); + }, + }, + ]; + + return [ + { title: "Go to", items: views }, + { title: "Branches & tags", items: refs }, + { title: "Actions", items: actions }, + ]; + }, + remote: () => { + // ONE status call shared by every GitHub group. This used to fire + // three times per palette open — same answer, three round trips. + const status = host.invoke("github:status", undefined).catch(() => undefined); + return [ + host + .invoke("repo:recent", undefined) + .then((rs): PaletteGroup | undefined => { + const others = rs.filter((r) => r.root !== this.currentRepo?.root); + return others.length + ? { + title: "Recent repositories", + items: others.map((r) => ({ + icon: "repo", + label: r.name, + // Middle-truncated: the right-hand ellipsis ate the repo + // folder, which is the only part that tells two clones apart. + hint: middleTruncate(r.root, 46), + keywords: r.root, + run: () => void this.openPath(r.root), + })), + } + : undefined; + }), + status.then(async (st): Promise<PaletteGroup | undefined> => { + if (!st?.connected || !st.repo) return undefined; + const prs = await host.invoke("pr:list", undefined).catch(() => []); + return prs.length + ? { + title: "Pull requests", + items: prs.slice(0, 30).map((pr) => ({ + icon: "git-pull-request", + label: pr.title, + hint: `#${pr.number}`, + keywords: `#${pr.number} pr ${pr.user?.login ?? ""} ${pr.head.ref}`, + run: () => go("prs", { number: pr.number }), + })), + } + : undefined; + }), + status.then(async (st): Promise<PaletteGroup | undefined> => { + if (!st?.connected || !st.repo) return undefined; + const issues = await host.invoke("issue:list", { state: "open" }).catch(() => []); + return issues.length + ? { + title: "Issues", + items: issues.slice(0, 30).map((it) => ({ + icon: "issues", + label: it.title, + hint: `#${it.number}`, + keywords: `#${it.number} issue ${it.user?.login ?? ""}`, + run: () => go("issues", { number: it.number }), + })), + } + : undefined; + }), + ]; + }, + + // ── query-driven: global GitHub search, from ⌘K ── + // + // The pinned row is always first and always fires, so ⌘K → type → + // Enter reaches Explore even when nothing else matched. The two result + // groups share Explore's EXACT gget cache keys, so opening the full + // page after previewing here costs nothing — and code search is never + // called from the palette (10/min is too small to spend on typing). + search: (query: string) => [ + Promise.resolve<PaletteGroup>({ + title: "Search GitHub", + pinned: true, + items: [ + { + icon: "telescope", + label: `Search GitHub for “${query}”`, + hint: "", + run: () => go("explore", { id: searchTargetId("repos", query) }), + }, + ], + }), + // Never remember a rate-limit refusal. The palette shares the search + // cache with Explore, so a refusal cached here left Explore's own + // "Retry now" answering from cache — no request made — for the whole + // 60s window. Same guard Explore's fetchPage uses. + gget("search:repos", { query, sort: "best", page: 1 }, 60_000, App.SEARCH_KEEP) + .then((page): PaletteGroup | undefined => + page.items.length + ? { + title: "Repositories on GitHub", + pinned: true, + items: page.items.slice(0, 3).map((r) => ({ + icon: "repo", + label: r.fullName, + hint: r.language ?? "", + run: () => go("explore", { id: repoRouteId({ fullName: r.fullName }) }), + })), + } + : undefined, + ) + .catch(() => undefined), + gget("search:users", { query, kind: "users", page: 1 }, 60_000, App.SEARCH_KEEP) + .then((page): PaletteGroup | undefined => + page.items.length + ? { + title: "People on GitHub", + pinned: true, + items: page.items.slice(0, 3).map((u) => ({ + icon: "person", + label: u.login, + hint: u.type === "Organization" ? "org" : "person", + run: () => + go("explore", { + id: `${u.type === "Organization" ? "org" : "user"}/${u.login}`, + }), + })), + } + : undefined, + ) + .catch(() => undefined), + ], + }); + } + private topbar(info: RepoInfo): HTMLElement { const bar = el("header", "topbar"); @@ -3761,6 +7411,24 @@ class App { home.appendChild(brandMark()); home.addEventListener("click", () => void this.backToMenu()); + // Back / forward chevrons — the in-app history walkers (⌘[ / ⌘], and the + // mouse's back/forward buttons). What makes section-hopping feel like a + // real app instead of a set of disconnected tabs. + const mod = navigator.platform.toLowerCase().includes("mac") ? "⌘" : "Ctrl+"; + const backBtn = el("button", "topbar-icon topbar-nav") as HTMLButtonElement; + backBtn.title = `Back (${mod}[)`; + backBtn.setAttribute("aria-label", "Back"); + backBtn.appendChild(glyph("arrow-left")); + backBtn.addEventListener("click", () => this.navBack()); + this.navBackBtn = backBtn; + const fwdBtn = el("button", "topbar-icon topbar-nav") as HTMLButtonElement; + fwdBtn.title = `Forward (${mod}])`; + fwdBtn.setAttribute("aria-label", "Forward"); + fwdBtn.appendChild(glyph("arrow-right")); + fwdBtn.addEventListener("click", () => this.navForward()); + this.navFwdBtn = fwdBtn; + this.updateNavButtons(); + const repoSwitch = el("button", "topbar-switch"); const repoName = el("span", "switch-name"); repoName.textContent = info.name; @@ -3779,13 +7447,24 @@ class App { // Left cluster: brand + repo + branch, with the sync (fetch/pull/push) // widget sitting right next to the branch switcher. const left = el("div", "topbar-left"); - left.append(home, sidebarToggle, repoSwitch, branchSwitch, this.buildSyncWidget()); + left.append(home, sidebarToggle, backBtn, fwdBtn, repoSwitch, branchSwitch, this.buildSyncWidget()); this.syncRailToggle(); // Right edge: the notifications center (bell + unread badge) sitting right // next to the GitHub account chip — both pinned to the far right of the bar. const right = el("div", "topbar-right"); - right.append(this.buildAssistantLauncher(), this.buildNotifBell(), this.buildAccountChip()); + const cmdk = el("button", "topbar-cmdk"); + // The palette now searches GitHub itself, so the affordance says so — + // "Jump to…" undersold a box that reaches every repo on github.com. + cmdk.title = "Jump anywhere, or search GitHub (⌘K)"; + cmdk.setAttribute("aria-label", "Open the command palette"); + cmdk.append( + glyph("search"), + span("Search anything…", "topbar-cmdk-label"), + span("⌘K", "topbar-cmdk-kbd"), + ); + cmdk.addEventListener("click", () => this.openPalette()); + right.append(cmdk, this.buildAssistantLauncher(), this.buildNotifBell(), this.buildAccountChip()); bar.append(left, right); return bar; @@ -3795,13 +7474,27 @@ class App { * view. Opens the full Assistant (its chats persist + stay warm). */ private buildAssistantLauncher(): HTMLElement { const b = el("button", "topbar-icon topbar-assistant"); - b.title = "Assistant"; - b.setAttribute("aria-label", "Open the AI Assistant"); b.append(glyph("sparkle"), span("Assistant", "topbar-assistant-label")); + // The Assistant is a full view like any other, but its only entry point is + // this button — and the button looked identical whether you were in the + // Assistant or not, so the one surface with no rail item and no tab was + // also the one surface that never said you were on it. + const sync = (): void => { + const here = this.currentView === "assistant"; + b.classList.toggle("is-current", here); + b.setAttribute("aria-current", here ? "page" : "false"); + b.title = here ? "You are in the Assistant" : "Assistant"; + b.setAttribute("aria-label", here ? "Assistant (current view)" : "Open the AI Assistant"); + }; + sync(); + this.syncAssistantChip = sync; b.addEventListener("click", () => this.routeView("assistant")); return b; } + /** Repaint the Assistant launcher's current-view state after a route change. */ + private syncAssistantChip?: () => void; + /** The notifications center: a bell in the top bar (next to the account chip) * with an unread-count badge, opening the inbox as a floating panel. Replaces * the old sidebar "Notifications" section — the bell IS the center now. */ @@ -3814,23 +7507,51 @@ class App { badge.hidden = true; bell.appendChild(badge); this.notifBellBadge = badge; - bell.addEventListener("click", () => + bell.addEventListener("click", () => { + // A popover OF the page you are already reading has nothing to add, and + // it put a second copy of the Inbox on screen: two "Inbox 7" headers, + // two identical refresh buttons, two lists of the same threads. On the + // Inbox the bell just refreshes the page. + if (this.currentView === "notifications") { + this.routeView("notifications", true); + return; + } openNotificationsPanel( bell, (v, target) => this.routeView(v, false, target), () => void this.refreshNotifBadge(), - ), - ); + ); + }); void this.refreshNotifBadge(); return bell; } + /** Fill in the commit composer's branch once HEAD resolves. Without this the + * placeholder stayed "…" forever, which is worse than the wrong answer it + * replaced. */ + private syncComposerBranch(): void { + const head = this.headInfo; + if (!head) return; + const name = head.detached ? "detached HEAD" : (head.branch ?? "HEAD"); + const nameEl = document.querySelector<HTMLElement>(".dc-branch-name"); + if (nameEl) nameEl.textContent = name; + // The composer owns its own label — writing it from here made two writers + // for one string, and the other one holds the amend flag. + this.syncCommitLabel?.(); + } + /** Pull the unread count and reflect it on the bell badge (hidden at zero). */ private async refreshNotifBadge(): Promise<void> { - const badge = this.notifBellBadge; - if (!badge) return; const count = await fetchUnreadCount(); - if (!badge.isConnected) return; + this.setNotifBadge(count); + } + + /** Paint a known unread count onto the bell. The Inbox broadcasts what it + * actually loaded (gs:unread), so the badge and the panel header can never + * disagree — they used to differ by one, 200px apart. */ + private setNotifBadge(count: number): void { + const badge = this.notifBellBadge; + if (!badge || !badge.isConnected) return; const bell = badge.parentElement; if (count > 0) { badge.textContent = count > 99 ? "99+" : String(count); @@ -3852,30 +7573,100 @@ class App { const chip = el("button", "topbar-acct"); chip.append(glyph("github"), span("…", "topbar-acct-name")); chip.addEventListener("click", () => this.routeView("settings")); - void (async () => { - let status: GitHubStatus = { connected: false }; + // Asked ONCE, at construction — and the top bar is built by showRepoScreen, + // which runs only on `repo:changed`. So the chip kept naming the account + // you had signed out of for the rest of the session, avatar and all, while + // Settings one click away said "Not connected". Stored as a hook so the + // three auth sites can re-ask, the way `syncCommitLabel` and + // `syncAssistantChip` already do for their own surfaces. + let gen = 0; + let nameRetry: number | undefined; + // What the last SUCCESSFUL answer said. A failed question is not an answer: + // it used to fall through to `{connected: false}`, so a dropped IPC or a + // moment offline turned a signed-in user's chip into a "Sign in" button — + // and the chip is the only place in the window that would have said it. + let lastKnown: GitHubStatus | undefined; + const sync = async (): Promise<void> => { + const mine = ++gen; + let status: GitHubStatus; try { status = await host.invoke("github:status", undefined); + lastKnown = status; } catch { - /* offline / not connected — show the sign-in state */ + // Keep saying what we last knew to be true; only an actual answer of + // "not connected" may take the account off the top bar. + status = lastKnown ?? { connected: false }; } - if (!chip.isConnected) return; - if (status.connected && status.login) { + // A switch immediately followed by a sign-in can resolve out of order; + // the later question owns the answer. + if (!chip.isConnected || mine !== gen) return; + + // CONNECTED is the question. The login NAME is a separate, slower fact. + // + // `github:status` deliberately does not decrypt the token — doing so + // raises the OS keychain prompt on every launch — so a signed-in user + // gets `{connected: true, login: undefined}` until some real GitHub + // request unlocks it. Branching on `connected && login` put that state in + // the ELSE, so the chip said "Sign in" to someone who was signed in, and + // then flipped to their name once anything else made a request. Two + // strings one character apart that mean opposite things. + if (status.connected) { chip.classList.add("is-connected"); - chip.title = `Signed in to GitHub as ${status.login}`; - chip.replaceChildren( - avatar(status.login, `https://github.com/${status.login}.png`, 22), - span(status.login, "topbar-acct-name"), - ); + if (status.login) { + window.clearTimeout(nameRetry); + chip.title = `Signed in to GitHub as ${status.login}`; + chip.replaceChildren( + avatar(status.login, `https://github.com/${status.login}.png`, 22), + span(status.login, "topbar-acct-name"), + ); + } else { + // Signed in, name not known yet. Say so honestly rather than + // guessing, and ask again shortly — the first real request fills it + // in, and this stops only when it does. + chip.title = "Signed in to GitHub"; + chip.replaceChildren(glyph("github"), span("Signed in", "topbar-acct-name")); + window.clearTimeout(nameRetry); + nameRetry = window.setTimeout(() => void sync(), 2000); + } } else { + window.clearTimeout(nameRetry); chip.classList.remove("is-connected"); chip.title = "Sign in to GitHub"; chip.replaceChildren(glyph("github"), span("Sign in", "topbar-acct-name")); } - })(); + }; + this.syncAccountChip = sync; + // Exposed to the harness ONLY when the harness is there, so a check can + // re-ask the question under a broken channel — "a failed question is not an + // answer" is not observable any other way. + if ((window as { __GS_ROUTES?: unknown }).__GS_ROUTES) { + (window as { __gsSyncAccountChip?: () => Promise<void> }).__gsSyncAccountChip = sync; + } + void sync(); return chip; } + /** + * Everything that stops being true when the signed-in account changes. + * + * ONE helper, called from all three auth sites, because they had drifted: + * Sign out dropped the caches, Switch account dropped neither, and neither + * touched the top-bar chip. Three sites each remembering four things is how + * that happened, and splitting the fix across them again would only reset the + * clock. The toast stays at the call site — only Sign out has one to say. + */ + private async authChanged(): Promise<void> { + // Every cached GitHub answer was computed for a session that is over. + bust(); + // …and the kept-alive DOM those answers were rendered into, which `bust()` + // does not touch: a stashed view is re-attached verbatim on return, so + // Issues and PRs came back showing the previous account's pages — names, + // avatars, private titles — on a window that was signed out. + this.viewCache.clear(); + await this.syncAccountChip?.(); + void this.refreshNotifBadge(); + } + // ── Host events ────────────────────────────────────────────────────────────── private wireHostEvents(): void { @@ -3886,6 +7677,18 @@ class App { void this.showWelcome(); } }); + // Forgetting or trashing a clone changes the welcome screen's recent list + // (and the repo switcher, which re-reads on open) — repaint the one surface + // that renders it eagerly, and only when it's actually showing. + // The Inbox tells the shell what it actually loaded, so the bell badge and + // the panel header can't drift apart. + window.addEventListener("gs:unread", (e) => { + const n = (e as CustomEvent<number>).detail; + if (typeof n === "number") this.setNotifBadge(n); + }); + host.on("repo:recentChanged", () => { + if (!this.currentRepo) void this.showWelcome(); + }); host.on("app:notice", (n) => { toast(n.message, n.kind === "error" ? "error" : n.kind === "warn" ? "error" : "info"); }); @@ -3897,8 +7700,14 @@ class App { // edit in another app, switch to GitStudio — and it is also the safety net // for when the watcher could not start at all (a huge tree on Linux can // exhaust inotify), so it deliberately does not check whether one is running. + // Coming back to the front does NOT mean anything changed. This used to call + // refreshFromDisk(true) unconditionally, which drops the entire cache, throws + // away every kept-alive view and force-rebuilds the current one — so a plain + // alt-tab away and back cost a full reload of the app and ejected you from + // whatever detail page you were reading. Ask what changed first; if the + // answer is nothing, do nothing. window.addEventListener("focus", () => { - void this.refreshFromDisk(true); + void this.refreshIfDiskMoved(); }); host.on("menu:command", (msg) => { if (msg.command === "openRepo") void this.openRepo(); @@ -3906,7 +7715,75 @@ class App { else if (msg.command === "closeRepo") void this.backToMenu(); else if (msg.command === "toggleTerminal") this.toggleTerminal(); else if (msg.command === "cloneRepo") openCloneDialog((root) => void this.openPath(root)); + else if (msg.command === "toggleSidebar") this.toggleRail(); + else if (msg.command === "palette") this.openPalette(); + }); + // App updates: the main process polls; the USER decides. Nothing downloads + // or installs without a confirm here. + host.on("update:available", (u) => void this.promptUpdateAvailable(u)); + host.on("update:ready", (r) => void this.promptUpdateReady(r)); + host.on("update:progress", (p) => { + if (this.updateProgressEl) this.updateProgressEl.textContent = `Downloading… ${p.percent}%`; + }); + } + + // ── App updates (confirm → pull → apply) ──────────────────────────────────── + + /** Live label updated by update:progress while a download runs (the About + * card's status line when Settings is open; harmlessly detached otherwise). */ + private updateProgressEl?: HTMLElement; + /** Versions the user already saw a prompt for this session. */ + private readonly updatePrompted = new Set<string>(); + + private async promptUpdateAvailable( + u: { version: string; current: string }, + force = false, + ): Promise<void> { + if (!force && this.updatePrompted.has(u.version)) return; + this.updatePrompted.add(u.version); + const mac = navigator.platform.toLowerCase().includes("mac"); + const ok = await confirmDialog({ + title: `GitStudio ${u.version} is available`, + message: mac + ? `You're on ${u.current}. Download the update now? The installer lands in your Downloads folder — one drag to Applications finishes it.` + : `You're on ${u.current}. Download the update now? You'll confirm again before it restarts.`, + confirmLabel: "Download update", }); + if (!ok) return; + const r = await host.invoke("update:download", undefined); + if (!r.ok) { + toast(r.message || "Couldn't download the update.", "error"); + return; + } + toast(`Downloading GitStudio ${u.version}…`, "info"); + } + + private async promptUpdateReady(r: { + version: string; + kind: "restart" | "installer"; + }): Promise<void> { + if (this.updateProgressEl) this.updateProgressEl.textContent = ""; + if (r.kind === "restart") { + const ok = await confirmDialog({ + title: `GitStudio ${r.version} is ready`, + message: "Restart now to finish updating? If not, it's applied the next time you quit.", + confirmLabel: "Restart now", + }); + if (!ok) { + toast("The update will be applied when you quit GitStudio.", "info"); + return; + } + } else { + const ok = await confirmDialog({ + title: `GitStudio ${r.version} downloaded`, + message: + "The installer is in your Downloads folder. Open it now? Drag GitStudio to Applications to finish.", + confirmLabel: "Open installer", + }); + if (!ok) return; + } + const res = await host.invoke("update:install", undefined); + if (!res.ok) toast(res.message || "Couldn't apply the update.", "error"); } // ── Repo lifecycle (screen transitions are driven by repo:changed) ────────── @@ -3954,6 +7831,44 @@ class App { })); } + /** What the repo looked like on disk the last time we synced with it. */ + private diskFingerprint?: string; + /** One warning per run of failures, not one per revalidation. */ + private staleTreeWarned = false; + + /** + * Refresh only if the repository actually moved while we were away. + * + * The window-focus refresh is the safety net for edits made in another app — + * a real need, and the reason it deliberately does not check whether a file + * watcher is running. But it fired on EVERY focus, and its full refresh drops + * the whole cache, clears every kept-alive view and force-rebuilds. So alt-tab + * to a browser and back and the app rebuilt itself from nothing, which is both + * slow and destructive: the Settings sign-in card had to be special-cased out + * of it by hand, and any detail page you were reading was replaced by its list. + * + * Two cheap reads answer "did anything change?" — the working tree and HEAD. + * They cost a few milliseconds against the seconds a full refresh costs, and + * in the common case (nothing changed) the answer is: do nothing at all. + */ + private async refreshIfDiskMoved(): Promise<void> { + if (!this.currentRepo || this.refreshingFromDisk) return; + let print: string; + try { + const [status, head] = await Promise.all([ + host.invoke("status", undefined), + host.invoke("head:get", undefined), + ]); + print = JSON.stringify({ status, head }); + } catch { + // Could not tell — leave the screen alone rather than rebuild on a guess. + return; + } + if (this.diskFingerprint === print) return; // nothing moved while we were away + this.diskFingerprint = print; + await this.refreshFromDisk(true); + } + private async refreshFromDisk(gitDir: boolean): Promise<void> { if (!this.currentRepo || this.refreshingFromDisk) { return; @@ -3962,22 +7877,50 @@ class App { try { if (gitDir) { await this.refreshAll(); + // Our OWN refresh has just re-read the tree; record it, or the next + // window focus sees a fingerprint from before this change and rebuilds + // the app a second time for something it has already applied. + void this.recordDiskFingerprint(); + return; + } + if (this.currentView === "changes") { + // Re-read into the cache rather than deleting it. `bust("status")` + // removes the very entry showChangesView paints from, so every save in + // your editor blanked the file list to a 6-row skeleton and closed the + // diff you were reading — the same defect as staging, which this fix + // wave already corrected for the buttons but not for the watcher. + await this.repaintChanges(); return; } bust("status"); bust("diff"); - if (this.currentView === "changes") { - await this.showChangesView(); - } else if (this.currentView === "graph" && this.graph) { + if (this.currentView === "graph" && this.graph) { // The graph carries an uncommitted-changes row, so it still cares — but - // only about that row, not about re-reading the history behind it. - this.routeView(this.currentView, true); + // only about that row. Reload IN PLACE: rebuilding the whole view here + // (the old behavior) threw away the live mount on every disk change. + await this.graph.reload(); + } else if (this.graph) { + // Parked graph: its WIP row is stale now — re-sync on return. + this.graphDirty = true; } } finally { this.refreshingFromDisk = false; } } + /** Snapshot the on-disk state so the next focus can tell "changed" from "same". */ + private async recordDiskFingerprint(): Promise<void> { + try { + const [status, head] = await Promise.all([ + host.invoke("status", undefined), + host.invoke("head:get", undefined), + ]); + this.diskFingerprint = JSON.stringify({ status, head }); + } catch { + this.diskFingerprint = undefined; + } + } + private async refreshAll(): Promise<void> { if (!this.currentRepo) { return; @@ -3987,15 +7930,60 @@ class App { // being rebuilt, so a commit or a branch op made from Changes left the cached // Branches DOM untouched — and returning to it re-attached that DOM verbatim // without refetching, showing a branch list from before the change. + // + // EXCEPT a parked Assistant. Every other kept-alive view holds a rendering + // of repo state that this refresh has just invalidated; the Assistant holds + // a conversation, and possibly a turn still streaming into it. Dropping it + // here is the same defect the `currentView === "assistant"` guard below + // fixes, reached by the other door: leave the Assistant to answer something, + // go and read an issue, and the agent's own commit — or any file the build + // touched — deleted the transcript and the Stop button out from under a run + // that kept going. Held by identity, so nothing is refetched or rebuilt. + const parkedChat = this.viewCache.get("assistant"); this.viewCache.clear(); + if (parkedChat) this.viewCache.set("assistant", parkedChat); + // A parked (kept-alive) graph is now stale too — mark it before ANY early + // return below, so returning to Commits always re-syncs in place. + if (this.graph && this.currentView !== "graph") this.graphDirty = true; await this.refreshRefs(); - // The graph is only mounted while the graph view is showing; otherwise just - // re-render whatever view is active (guards against `this.graph` being unset - // on a refresh fired from a non-graph screen — Cmd+R, sync, commit). + // Settings shows NOTHING derived from the repo's disk state — and this runs + // on every window FOCUS. Rebuilding it here destroyed the GitHub device-flow + // card the instant the user came back from authorizing in the browser: the + // code vanished and the token poll died, so sign-in could never complete. + if (this.currentView === "settings") { + return; + } + // The Assistant, for the same reason and a sharper one. Nothing on it is + // derived from the repository's disk state — it is a transcript — and the + // agent's own work is what fires this: approve a commit and the file + // watcher calls refreshAll, which re-routed the view the agent was + // streaming into. The answer, the tool steps and the Stop button were all + // destroyed mid-run, while the run itself carried on in the main process + // with nothing left on screen to stop it or show it. + if (this.currentView === "assistant") { + return; + } + // The Assistant, for the same reason and a sharper one. Nothing on it is + // derived from the repository's disk state — it is a transcript — and the + // agent's own work is what fires this: approve a commit and the file + // watcher calls refreshAll, which re-routed the view the agent was + // streaming into. The answer, the tool steps and the Stop button were all + // destroyed mid-run, while the run itself carried on in the main process + // with nothing left on screen to stop it or show it. + + // The graph reloads in place when showing; when it's PARKED (kept alive + // behind another view) it's only marked dirty, so returning to Commits + // re-syncs the data without ever tearing the mount down. if (this.currentView === "graph" && this.graph) { await this.graph.reload(); } else { - this.routeView(this.currentView, true); + // Re-route to WHERE YOU ARE, not just to which section you are in. This + // passed no target, so a refresh while reading PR #106 rebuilt the PR + // LIST — you were ejected from the page you were on by a background + // event you never asked for. The history stack already knows the target; + // it is the same value Back would return you to. + const here = this.navHistory[this.navPos]; + this.routeView(this.currentView, true, here?.view === this.currentView ? here.target : undefined); } } @@ -4005,12 +7993,12 @@ class App { const recent = await host.invoke("repo:recent", undefined); const items: MenuItem[] = [ { - label: "Open Repository…", + label: "Open repository…", icon: "folder-opened", onClick: () => void this.openRepo(), }, { - label: "Clone Repository…", + label: "Clone repository…", icon: "cloud-download", onClick: () => openCloneDialog((root) => void this.openPath(root)), }, @@ -4023,7 +8011,7 @@ class App { for (const r of others) { items.push({ label: r.name, - sub: r.root, + sub: middleTruncate(r.root, 40), icon: "folder", onClick: () => void this.openPath(r.root), }); @@ -4031,8 +8019,19 @@ class App { } items.push({ separator: true }); items.push({ - label: "Back to Main Menu", - icon: "home", + label: "Manage repositories…", + icon: "repo", + title: "Every clone on this machine", + onClick: () => this.openRepoManager(), + }); + items.push({ + // Was "Back to the main menu" — a name for a destination that does not + // exist. It closes the repository, and what it opened was a full-screen + // card offering Open… / Clone… / Recent: the same three things this menu + // already offers, one row above. One name for one act. + label: "Close repository", + icon: "close", + title: "Close this repository and go back to the picker", onClick: () => void this.backToMenu(), }); openMenu(anchor, items); @@ -4040,26 +8039,69 @@ class App { /** Jump to a commit in the graph, switching to the graph view first if the * graph isn't currently mounted (the branch switcher is available on every - * screen, so `this.graph` may not exist yet). */ + * screen, so `this.graph` may not exist yet). Routing with a sha TARGET puts + * the jump in the navigation history, so back/forward reproduces it. */ private revealInGraph(sha: string): void { if (this.currentView === "graph" && this.graph) { - this.graph.reveal(sha); + const found = this.graph.reveal(sha); + void this.selectCommit(sha); + if (!found) { + toast( + `${sha.slice(0, 7)} is further back than the loaded history — its details are below.`, + "info", + ); + } return; } - this.routeView("graph"); - // The graph mounts and loads its first page asynchronously; reveal once it's - // had a frame, and retry briefly so the scroll lands even if the page is - // still streaming in (reveal is a no-op until the row exists). + this.routeView("graph", false, { sha }); + } + + /** Scroll to + select a commit once the freshly-mounted graph has rows. The + * graph loads its first page asynchronously, so retry briefly — reveal is a + * no-op until the row exists. The details pane loads immediately (it's + * IPC-driven, not row-driven). */ + private revealWhenReady(sha: string): void { + void this.selectCommit(sha); let tries = 0; const tryReveal = (): void => { - this.graph?.reveal(sha); + if (this.graph?.reveal(sha)) return; if (++tries < 6 && this.currentView === "graph") { window.setTimeout(tryReveal, 120); + return; + } + // GIVE UP OUT LOUD. The graph holds only the pages it has loaded, so a + // commit further back than that — which is most of the history in any + // real repository — can never be revealed however long we retry. The + // details pane below has loaded it either way, so the work is not lost; + // saying nothing just made the list look like it had ignored the click. + if (this.currentView === "graph") { + toast( + `${sha.slice(0, 7)} is further back than the loaded history — its details are below.`, + "info", + ); } }; requestAnimationFrame(tryReveal); } + /** + * The branch switcher. + * + * It used to switch nothing. Every row — branches, remotes and tags alike — + * called `revealInGraph`, so clicking "fix/log-stream" under a chip whose own + * tooltip reads "On branch main — switch branch" left you on main and dropped + * you in the Commits view instead. The app's most load-bearing control did + * something other than its name, silently, every time. + * + * Now a branch row CHECKS OUT. Revealing a ref in the graph is still one + * gesture away — it moved to a trailing button on the row, where it reads as + * the secondary thing it is. + * + * Remotes and tags keep reveal as their primary: checking either out detaches + * HEAD, which is not what someone picking from a branch chip is asking for. + * The remote rows offer "check out as a local branch", which is what they + * actually mean, through the same path the Branches list uses. + */ private openBranchMenu(anchor: HTMLElement): void { const locals = this.refs.filter((r) => r.type === "head"); const remotes = this.refs.filter((r) => r.type === "remote"); @@ -4072,7 +8114,15 @@ class App { label: b.name, icon: "git-branch", current: b.isCurrent, - onClick: () => this.revealInGraph(b.sha), + sub: b.isCurrent ? "current" : undefined, + title: b.isCurrent ? `Already on ${b.name}` : `Check out ${b.name}`, + onClick: () => { + if (b.isCurrent) { + this.revealInGraph(b.sha); + return; + } + void this.checkoutRef(b.name); + }, }); } } @@ -4085,7 +8135,8 @@ class App { items.push({ label: b.name, icon: "cloud", - onClick: () => this.revealInGraph(b.sha), + title: `Check out ${b.name} as a local branch`, + onClick: () => void this.checkoutRef(b.name, undefined, "remote"), }); } } @@ -4101,12 +8152,25 @@ class App { items.push({ label: t.name, icon: "tag", + title: `Show ${t.name} in Commits`, onClick: () => this.revealInGraph(t.sha), }); } } if (items.length === 0) { items.push({ label: "No branches yet", disabled: true }); + } else { + items.push({ separator: true }); + items.push({ + label: "New branch…", + icon: "add", + onClick: () => void this.newBranch(), + }); + items.push({ + label: "Manage branches…", + icon: "git-branch", + onClick: () => this.routeView("branches"), + }); } // No `searchable` override: openMenu already turns the filter on above 9 // rows, which every repo large enough to need it will exceed. @@ -4135,6 +8199,8 @@ class App { return; // a different repo is on screen now } this.refs = refs; + this.headInfo = head; + this.syncComposerBranch(); if (this.branchSwitchName) { const label = !head ? "HEAD" @@ -4152,8 +8218,34 @@ class App { private async selectCommit(sha: string): Promise<void> { this.selectedSha = sha; - const details = await host.invoke("commit:details", sha); - if (!details || this.selectedSha !== sha) { + // Loading a commit's details is a round trip, and this pane used to sit + // showing the PREVIOUS commit's files the whole time — so a slow load was + // indistinguishable from a fast one, and a FAILED load was invisible: the + // old commit stayed on screen as though it were the one you just clicked. + // The open diff belongs to the PREVIOUS commit, and `loadingState` is about + // to replace the node it lives in — so close it properly rather than + // orphaning it. Without this, a details load that FAILS returned before + // `renderDetails` ever ran, leaving `diff-open` on the wrapper: the graph + // stayed squeezed to half width around an error card, in a pane sized for a + // diff that was no longer in the DOM, and only opening another commit + // successfully could undo it. + this.closeGraphDiff(); + this.detailsEl?.replaceChildren(loadingState(`Loading ${sha.slice(0, 7)}…`)); + let details; + try { + details = await host.invoke("commit:details", sha); + } catch (e) { + if (this.selectedSha !== sha) return; + this.detailsEl?.replaceChildren( + errorState("Couldn't load this commit", cleanErr(e) || "The commit details request failed."), + ); + return; + } + if (this.selectedSha !== sha) return; + if (!details) { + this.detailsEl?.replaceChildren( + errorState("Couldn't load this commit", `Git returned nothing for ${sha.slice(0, 7)}.`), + ); return; } this.renderDetails(details); @@ -4191,7 +8283,7 @@ class App { }); panel.addEventListener("gs-copy", (e) => { const detail = (e as CustomEvent).detail as { text: string }; - void navigator.clipboard?.writeText(detail.text).catch(() => {}); + void copyText(detail.text, "Copied."); }); panel.addEventListener("gs-action", (e) => { const detail = (e as CustomEvent).detail as { id: string; sha: string }; @@ -4223,29 +8315,31 @@ class App { this.setGraphDetailsVisible(false); }); - // No diff surface here any more: the file diff opens in the bottom dock's - // "Diff" tab, so this column is purely the commit's metadata + file list. + // The file diff mounts INTO this split (`.details-diff`, see openFile), + // so the whole commit — metadata, files, editor — lives in ONE place. wrap.append(panel); if (!this.detailsEl) return; this.detailsEl.replaceChildren(wrap); // Selecting another commit makes any open diff stale — it belonged to the // previous commit's file. Drop the tab rather than leaving the wrong diff up. - this.closeDiffTab(); + this.closeGraphDiff(); // Make sure the details column beside the graph is showing. this.setGraphDetailsVisible(true); } - /** Remove the dock's Diff tab and dispose the editor living in it. */ - private closeDiffTab(): void { + /** Tear down the in-view commit diff: dispose the editor, drop the pane, + * give the graph its width back. */ + private closeGraphDiff(): void { this.diffPanel?.dispose(); this.diffPanel = undefined; if (this.activeMonacoView) { this.activeMonacoView = undefined; } this.diffSurfaceEl = undefined; - this.terminalDock?.setDetailsVisible(false); + this.graphViewWrap?.classList.remove("diff-open"); + this.detailsEl?.querySelector(".details-diff")?.remove(); } /** Show/hide the commit-details column beside the graph. */ @@ -4263,25 +8357,57 @@ class App { private graphSplitResizer(wrap: HTMLElement): HTMLElement { const MIN = 320; const KEY = "gitstudio.graphDetailsW"; - // The graph drops its Date and SHA columns below 760px (a container query in - // commit-graph), so the details column must never squeeze it past that — - // otherwise columns silently vanish and their resize handles go with them. - // Leave a little headroom above the breakpoint. - const GRAPH_FLOOR = 800; + // The graph drops its Date and SHA columns below a breakpoint of its own (a + // container query in commit-graph), so the details column must never + // squeeze it past that — otherwise columns silently vanish and their resize + // handles go with them. + // + // TAKEN FROM THE GRAPH, not restated here. This was a literal 800 beside a + // comment saying the drop happened at 760; the graph package has since + // raised it to 860, and nothing connected the two — so the resizer let you + // drag the details pane 60px past the point where the graph starts losing + // columns, which is exactly what the guard exists to prevent. Plus the + // headroom the comment always intended. + const GRAPH_FLOOR = COLUMN_DROP_TAIL_AT + 40; const maxFor = (): number => Math.max(MIN, Math.min(900, Math.round(wrap.getBoundingClientRect().width) - GRAPH_FLOOR)); const saved = Number(localStorage.getItem(KEY)); - let w = Number.isFinite(saved) && saved > 0 ? saved : 420; - const apply = (): void => wrap.style.setProperty("--graph-details-w", `${w}px`); + /** + * What the user ASKED for, kept apart from what currently fits. + * + * These used to be one variable, and clamping wrote back into it. The first + * clamp runs while `wrap` is still detached — `graphSplitResizer(wrap)` is + * called inside `wrap.append(...)` — so its width is 0, maxFor() collapses + * to the 320px floor, and the 420px default was destroyed on the way in. + * The resize handler then re-clamped 320 against every later width, so the + * column could only ever shrink: the pane opened at its hard minimum every + * time, and a width you dragged to 600px came back as 320. The comment + * promising "width persists across sessions" could not have been true. + */ + let desired = Number.isFinite(saved) && saved > 0 ? saved : 420; + let applied = desired; + const apply = (): void => { + applied = Math.min(maxFor(), Math.max(MIN, Math.round(desired))); + wrap.style.setProperty("--graph-details-w", `${applied}px`); + }; const setW = (n: number): void => { - w = Math.min(maxFor(), Math.max(MIN, Math.round(n))); + desired = Math.max(MIN, Math.round(n)); apply(); }; - setW(w); // clamp the restored value against the CURRENT window - - // Re-clamp when the window changes, so narrowing it starves the details - // column rather than the graph. - window.addEventListener("resize", () => setW(w)); + apply(); + + // Re-apply whenever the pane's own box changes — which covers both the + // window resize (narrowing starves the details column rather than the + // graph) and the first real layout after `wrap` is attached. Clamping from + // `desired` every time means widening the window grows the column back + // toward what was asked for instead of leaving it stuck at the floor. + this.graphSplitRO?.disconnect(); + if (typeof ResizeObserver !== "undefined") { + this.graphSplitRO = new ResizeObserver(() => apply()); + this.graphSplitRO.observe(wrap); + } else { + window.addEventListener("resize", () => apply()); + } const split = el("div", "cmp-vsplit graph-vsplit"); split.append(el("div", "cmp-vsplit-grip")); @@ -4290,14 +8416,15 @@ class App { label: "Resize the commit details column", min: MIN, max: maxFor, - get: () => w, + get: () => applied, set: setW, - onCommit: () => localStorage.setItem(KEY, String(w)), + inverted: true, + onCommit: () => localStorage.setItem(KEY, String(desired)), }); split.addEventListener("pointerdown", (e) => { e.preventDefault(); const startX = e.clientX; - const startW = w; + const startW = applied; document.body.classList.add("resizing-h"); // Dragging LEFT widens the details column (it is the right-hand pane). const move = (ev: PointerEvent): void => setW(startW - (ev.clientX - startX)); @@ -4305,7 +8432,7 @@ class App { document.body.classList.remove("resizing-h"); window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); - localStorage.setItem(KEY, String(w)); + localStorage.setItem(KEY, String(desired)); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); @@ -4339,16 +8466,37 @@ class App { } private async openFile(file: ChangedFile, sha?: string): Promise<void> { - // The diff opens in the bottom dock's "Diff" tab — the commit metadata - // stays put beside the graph instead of being shoved aside by the editor. - this.terminalDock?.setDetailsVisible(true); - this.diffSurfaceEl = this.terminalDock?.detailsSurface(); - // Lazily spin up the Monaco diff the first time a file is opened. + // The diff opens INSIDE the Commits view: the details column widens and + // the editor sits right beside the commit's file list. It used to open in + // the bottom dock's "Diff" tab — graph left, details right, diff BOTTOM — + // three regions for one task, the literal "split screen" complaint. + const split = this.detailsEl?.querySelector(".details-split") as HTMLElement | null; + if (!split) return; + let pane = split.querySelector(".details-diff") as HTMLElement | null; + if (!pane) { + pane = el("div", "details-diff"); + const head = el("div", "details-diff-head"); + head.append(glyph(fileIcon(file.path.split("/").pop() ?? "")), el("span", "details-diff-name")); + const close = el("button", "peek-nav-btn details-diff-close"); + close.title = "Close diff"; + close.setAttribute("aria-label", "Close diff"); + close.appendChild(glyph("close")); + close.addEventListener("click", () => this.closeGraphDiff()); + head.appendChild(close); + const surface = el("div", "details-diff-surface"); + pane.append(head, surface); + split.appendChild(pane); + this.diffSurfaceEl = surface; + } + const nameEl = pane.querySelector(".details-diff-name") as HTMLElement; + nameEl.textContent = file.path; + nameEl.title = file.path; + // Widen the details side so the editor has real room; the graph yields. + this.graphViewWrap?.classList.add("diff-open"); if (!this.diffPanel && this.diffSurfaceEl) { this.diffPanel = new DiffPanel(this.diffSurfaceEl); this.activeMonacoView = this.diffPanel; } - this.terminalDock?.openDetails(); // activate the Diff tab + expand the dock // Capture the panel — re-reading `this.diffPanel` after an await threw // "cannot read showDiff of undefined" when the user picked another commit // mid-load and the panel was disposed. `gen` discards a stale result rather @@ -4359,14 +8507,34 @@ class App { const diff = await host.invoke("file:diff", { path: file.path, sha }); if (gen !== this.diffGen || panel !== this.diffPanel) return; if (!diff) { - panel.showEmpty("No diff available."); + // The FOURTH caller of this shape, and the last one still laundering a + // failure into good news. `fileDiff` returns undefined when there is no + // repository open or the path failed its containment check — never + // because the two sides matched. Saying "no textual changes" with a green + // tick asserts something the app has no basis for, about a file that is + // in this list precisely because it differs. Changes, Compare and the + // commit page each got this treatment; this one was missed. + panel.showEmpty( + `${file.path} is listed as changed, so this is a failure to read it — not a file with nothing in it.`, + { title: "Couldn't read this file", kind: "error" }, + ); return; } if (diff.conflicted) { const model = await host.invoke("conflict:model", file.path); if (gen !== this.diffGen || panel !== this.diffPanel) return; if (model) { - panel.showMerge(model); + panel.showMerge(model, undefined, { + noText: model.binary + ? "binary" + : model.truncated + ? "too-large" + : model.bothDeleted + ? "both-deleted" + : model.missingSide + ? "modify-delete" + : undefined, + }); return; } } @@ -4378,7 +8546,7 @@ class App { const wrap = el("div", "details details-empty"); wrap.appendChild( this.currentRepo - ? emptyState("Commit details", "Select a commit in the graph to inspect its message, author, and changed files.", { + ? emptyState("Commit details", "Select a commit to inspect its message, author, and changed files.", { icon: "git-commit", }) : emptyState("No repository open", "Open a repository to start exploring its history.", { @@ -4388,6 +8556,13 @@ class App { this.detailsEl.replaceChildren(wrap); } + /** With no commits there is nothing to select, so the details pane must not + * sit beside "No commits yet" telling you to select one — two competing + * empty states, the second contradicting the first. */ + private setDetailsEmptyForNoHistory(noHistory: boolean): void { + this.graphViewWrap?.classList.toggle("graph-no-history", noHistory); + } + // ── Commit actions (context menu) ──────────────────────────────────────────── private async runAction(req: Parameters<CommitContextMenu["resolve"]>[0]): Promise<void> { @@ -4427,6 +8602,98 @@ class App { const PREFS_KEY = "gitstudio.ui.prefs"; +/** The "?" keyboard cheat sheet — every shortcut the app answers to, grouped + * the way the muscle memory works: global chrome, lists, detail pages. */ +function openShortcutsHelp(): void { + const mac = navigator.platform.toLowerCase().includes("mac"); + const mod = mac ? "⌘" : "Ctrl+"; + const groups: Array<{ title: string; rows: Array<[string, string]> }> = [ + { + title: "Everywhere", + rows: [ + [`${mod}K`, "Jump anywhere — sections, branches, PRs, actions"], + [`${mod}1–8`, "Switch between the first eight sections"], + [`${mod}[ ${mod}]`, "Back / forward through your navigation"], + [`${mod}\``, "Toggle the terminal dock"], + [`${mod},`, "Settings"], + ["?", "This cheat sheet"], + ], + }, + { + title: "Lists", + rows: [ + ["↑ ↓ or j k", "Move between rows"], + ["Enter", "Open the focused row"], + ["Home / End", "Jump to the first / last row"], + ["e", "Inbox: mark the focused thread read"], + ], + }, + { + // Branches answered to three keys this sheet had never heard of, and one + // of them CONTRADICTED what the sheet said ⌘Enter does — pressing the + // documented "submit" on a branch row checks it out. A sheet that is + // wrong about a key is worse than a sheet that omits it. + title: "Branches", + rows: [ + ["/", "Filter the list"], + [`${mod}Enter`, "Run the focused row's main action — checkout, pull, publish"], + ["Shift+F", "Fetch from every remote"], + ], + }, + { + title: "Detail pages", + rows: [ + ["Esc or ←", "Back to the list"], + [`${mod}Enter`, "Submit the open form"], + ["/", "Commit page: filter the changed files"], + ], + }, + { + // The log grew a page of its own and a keyboard to go with it, and a + // shortcut nothing advertises is a shortcut nobody has. + title: "Reading a log", + rows: [ + ["↑ ↓ PgUp PgDn", "Move through the output"], + ["Home / End", "Start / newest line"], + ["n", "Jump to the next failure"], + ["j / k", "Next / previous job in this run"], + ["Enter Shift+Enter", "Step through search matches"], + ], + }, + ]; + openModal((close) => { + const card = el("div", "modal-card shortcuts-card"); + const h = el("div", "modal-title"); + h.textContent = "Keyboard shortcuts"; + card.appendChild(h); + const cols = el("div", "shortcuts-cols"); + for (const g of groups) { + const col = el("div", "shortcuts-group"); + const t = el("div", "shortcuts-group-title"); + t.textContent = g.title; + col.appendChild(t); + for (const [keys, what] of g.rows) { + const row = el("div", "shortcuts-row"); + const k = el("kbd", "shortcuts-keys"); + k.textContent = keys; + const w = el("span", "shortcuts-what"); + w.textContent = what; + row.append(k, w); + col.appendChild(row); + } + cols.appendChild(col); + } + card.appendChild(cols); + const actions = el("div", "modal-actions"); + const ok = el("button", "btn btn-primary modal-ok"); + ok.appendChild(span("Done")); + ok.addEventListener("click", close); + actions.appendChild(ok); + card.appendChild(actions); + return { card, focusEl: ok, label: "Keyboard shortcuts", onClose: () => {} }; + }); +} + /** Load persisted UI preferences (best-effort; never throws). */ function loadPrefs(): Record<string, unknown> { try { diff --git a/apps/desktop/src/renderer/repoBrowser.ts b/apps/desktop/src/renderer/repoBrowser.ts new file mode 100644 index 0000000..6a62114 --- /dev/null +++ b/apps/desktop/src/renderer/repoBrowser.ts @@ -0,0 +1,252 @@ +// Browse a REMOTE GitHub repository in-app, without cloning — folders, files, +// and the rendered README, as drillable wide peek cards. This kills the app's +// worst dead-end: an org repo used to show a metadata card whose only real +// continuations were "Clone" or "go to github.com". Now the repo list is a +// place you can actually read code, and Clone is one click away once a repo +// earns it. +// +// Cards stack (repo → folder → file), so ← / Esc walk back out naturally. + +import { fileLines } from "./textFit"; +import { host } from "./bridge"; +import { openPeek, peekChip, peekSection, type PeekCard, type PeekContext } from "./peek"; +import { el, span, glyph, fileIcon, formatBytes, cleanErr } from "./ui"; +import { renderMarkdown } from "./markdown"; +import { openCloneDialog } from "./cloneDialog"; +import { resolveRelative, wireProseNav } from "./proseNav"; +import { openGhRepoInApp, openGhRepoChooseLocation } from "./ghOpen"; +import { highlightCode } from "./highlight"; +import type { GhRepoEntry } from "../shared/ipc"; + +/** Open the browser fresh (root of the repo). */ +export function openRemoteRepoBrowser(fullName: string): void { + openPeek(repoDirCard(fullName, "")); +} + +/** Split "owner/repo" into the shape proseNav wants for #123 resolution. */ +function ownerRepoOf(fullName: string): { owner: string; repo: string } { + const [owner, repo] = fullName.split("/", 2); + return { owner, repo }; +} + +/** + * A directory card — the repo root (with README below the listing) or any + * subfolder. Exported so the org repo peek can PUSH it onto its own stack. + */ +export function repoDirCard(fullName: string, path: string): PeekCard { + const atRoot = !path; + return { + icon: atRoot ? "repo" : "folder", + title: atRoot ? fullName : path.split("/").pop()!, + chips: atRoot ? [peekChip("no clone needed", "accent")] : [], + subtitle: atRoot ? "Reading straight from GitHub" : `${fullName}/${path}`, + wide: true, + actions: [ + { + label: "Open on GitHub", + icon: "link-external", + onClick: () => + window.open( + `https://github.com/${fullName}${path ? `/tree/HEAD/${path}` : ""}`, + "_blank", + ), + }, + { + label: "Clone…", + icon: "repo-clone", + title: "Clone to a folder you choose", + onClick: (ctx) => { + ctx.close(); + openCloneDialog((root) => void host.invoke("repo:openPath", root), { + url: `https://github.com/${fullName}.git`, + }); + }, + }, + { + label: "Choose location…", + icon: "folder-opened", + title: `Pick the folder ${fullName} is cloned into, then open it`, + onClick: () => openGhRepoChooseLocation(fullName), + }, + { + label: "Open as repo", + icon: "folder-library", + primary: true, + title: `Open ${fullName} in GitStudio as a full repo (clones itself on first open)`, + onClick: () => openGhRepoInApp(fullName), + }, + ], + async render(body, ctx) { + let entries: GhRepoEntry[]; + try { + entries = await host.invoke("ghrepo:tree", { fullName, path }); + } catch (e) { + body.replaceChildren(browseError(fullName, e)); + return; + } + body.replaceChildren(); + const { root, body: lbody } = peekSection(atRoot ? "Files" : path, entries.length); + for (const entry of entries) lbody.appendChild(entryRow(fullName, entry, ctx)); + if (!entries.length) { + const none = el("div", "peek-row"); + none.appendChild(span("This folder is empty.", "peek-row-sub")); + lbody.appendChild(none); + } + body.appendChild(root); + + // The README belongs on the root listing, rendered with THE prose system + // — the same reading experience as github.com, not a wall of small text. + if (atRoot) { + const readme = await host.invoke("ghrepo:readme", fullName).catch(() => undefined); + if (!readme || !body.isConnected) return; + const { root: rroot, body: rbody } = peekSection(readme.name); + rbody.classList.add("peek-readme"); + const prose = el("div", "gh-body-md"); + try { + prose.innerHTML = renderMarkdown(readme.text); + // #123 and github.com links resolve against the BROWSED repo, and + // RELATIVE links ("./docs/x.md") open right here as more cards. + wireProseNav(prose, undefined, ownerRepoOf(fullName), (rel) => + ctx.push(cardForPath(fullName, resolveRelative("", rel))), + ); + } catch { + prose.textContent = readme.text; + } + rbody.appendChild(prose); + body.appendChild(rroot); + } + }, + }; +} + +/** Best-guess card for a resolved relative path: an extension means a file, + * anything else a folder — a wrong guess still lands on a sensible error. */ +function cardForPath(fullName: string, path: string): PeekCard { + const last = path.split("/").pop() ?? ""; + return /\.[A-Za-z0-9]{1,8}$/.test(last) + ? repoFileCard(fullName, path) + : repoDirCard(fullName, path); +} + +function entryRow(fullName: string, entry: GhRepoEntry, ctx: PeekContext): HTMLElement { + const row = el("button", "peek-row"); + row.appendChild(glyph(fileIcon(entry.name, entry.type === "dir"))); + const main = el("div", "peek-row-main"); + const title = el("div", "peek-row-title"); + title.textContent = entry.name; + main.appendChild(title); + row.appendChild(main); + const side = el("div", "peek-row-side"); + if (entry.type === "file" && entry.size) side.appendChild(span(formatBytes(entry.size))); + const chev = glyph("chevron-right"); + chev.classList.add("peek-row-chev"); + side.appendChild(chev); + row.appendChild(side); + row.addEventListener("click", () => + ctx.push( + entry.type === "dir" ? repoDirCard(fullName, entry.path) : repoFileCard(fullName, entry.path), + ), + ); + return row; +} + +/** A file card: markdown renders as prose; code shows mono with a line gutter; + * binary/oversized files get a notice instead of garbage. */ +function repoFileCard(fullName: string, path: string): PeekCard { + const name = path.split("/").pop()!; + return { + icon: fileIcon(name), + title: name, + subtitle: `${fullName}/${path}`, + wide: true, + actions: [ + { + label: "Open on GitHub", + icon: "link-external", + onClick: () => window.open(`https://github.com/${fullName}/blob/HEAD/${path}`, "_blank"), + }, + ], + async render(body, ctx) { + let f; + try { + f = await host.invoke("ghrepo:file", { fullName, path }); + } catch (e) { + body.replaceChildren(browseError(fullName, e)); + return; + } + body.replaceChildren(); + if (f.binary || f.truncated) { + const notice = el("div", "peek-empty"); + notice.append( + glyph(f.binary ? "file-binary" : "file"), + span( + f.binary + ? `This is a binary file (${formatBytes(f.size)}) — nothing to read inline.` + : `This file is too large for a quick look (${formatBytes(f.size)}). Open it on GitHub or clone the repo.`, + ), + ); + body.appendChild(notice); + return; + } + if (/\.(md|markdown|mdx)$/i.test(name)) { + const prose = el("div", "gh-body-md"); + try { + prose.innerHTML = renderMarkdown(f.text); + const baseDir = path.split("/").slice(0, -1).join("/"); + wireProseNav(prose, undefined, ownerRepoOf(fullName), (rel) => + ctx.push(cardForPath(fullName, resolveRelative(baseDir, rel))), + ); + } catch { + prose.textContent = f.text; + } + body.appendChild(prose); + return; + } + body.appendChild(codeBlock(f.text, name)); + }, + }; +} + +/** Render code with a line-number gutter. Capped so a giant minified file + * can't lock the UI — the cap is announced, never silent. */ +const MAX_RENDER_LINES = 5000; +function codeBlock(text: string, fileName: string): HTMLElement { + const lines = fileLines(text); + const shown = lines.slice(0, MAX_RENDER_LINES); + const wrap = el("div", "ghfile"); + const gutter = el("pre", "ghfile-gutter"); + gutter.textContent = shown.map((_, i) => String(i + 1)).join("\n"); + gutter.setAttribute("aria-hidden", "true"); + const code = el("pre", "ghfile-code"); + code.textContent = shown.join("\n"); + void highlightCode(code, shown.join("\n"), fileName); + wrap.append(gutter, code); + if (lines.length > shown.length) { + const more = el("div", "ghfile-more"); + more.textContent = `Showing the first ${MAX_RENDER_LINES.toLocaleString()} of ${lines.length.toLocaleString()} lines.`; + const outer = el("div", "ghfile-outer"); + outer.append(wrap, more); + return outer; + } + return wrap; +} + +/** A browse failure with the WHY spelled out — most importantly the org + * OAuth-app restriction that GitHub reports as a bare 404, which used to + * read as "the app is broken, use the website". */ +function browseError(fullName: string, e: unknown): HTMLElement { + const msg = cleanErr(e); + const wrap = el("div", "peek-empty"); + wrap.appendChild(glyph("warning")); + const t = el("div"); + t.textContent = `Couldn't read ${fullName}: ${msg || "GitHub request failed."}`; + wrap.appendChild(t); + if (/not found|404/i.test(msg)) { + const hint = el("div", "peek-row-sub"); + hint.style.maxWidth = "440px"; + hint.textContent = + "If this repo belongs to an organization, the org may restrict OAuth-app access. An org owner can approve GitStudio under Settings → Third-party access on github.com — after that, everything here works."; + wrap.appendChild(hint); + } + return wrap; +} diff --git a/apps/desktop/src/renderer/searchDebounce.ts b/apps/desktop/src/renderer/searchDebounce.ts new file mode 100644 index 0000000..06d2c6f --- /dev/null +++ b/apps/desktop/src/renderer/searchDebounce.ts @@ -0,0 +1,77 @@ +// Query-driven search scheduling, as a pure state machine. +// +// The command palette's existing providers fire ONCE at open. A search mode is +// different: it fires per keystroke, against a rate-limited API, and answers +// arrive out of order. Three rules, and all three are easy to get subtly wrong: +// +// 1. debounce — don't spend a request on every keystroke +// 2. minimum length — one or two characters match everything +// 3. generation tokens — a slow answer to an OLD query must be DROPPED, not +// rendered over a newer one (the bug where results flicker back to what +// you typed three keystrokes ago) +// +// DOM-free and clock-free (the timer is injected), so all three are testable. + +export interface SearchScheduleOpts { + /** Wait this long after the last keystroke before searching. */ + delayMs?: number; + /** Queries shorter than this never search. */ + minChars?: number; + /** Injected for tests; defaults to window's timers. */ + setTimer?: (fn: () => void, ms: number) => number; + clearTimer?: (id: number) => void; +} + +export interface SearchScheduler { + /** A new query was typed. Returns the generation this query will run as + * (or undefined when it won't run: too short, or unchanged). */ + queue: (query: string) => number | undefined; + /** True when `gen` is still the newest generation — i.e. its results are + * still wanted. Anything older is a stale answer and must be discarded. */ + isCurrent: (gen: number) => boolean; + /** Cancel any pending search (palette closed, mode switched). */ + cancel: () => void; + /** The query the latest generation was issued for. */ + lastQuery: () => string; +} + +export function createSearchScheduler( + run: (query: string, generation: number) => void, + o: SearchScheduleOpts = {}, +): SearchScheduler { + const delay = o.delayMs ?? 300; + const minChars = o.minChars ?? 3; + const setT = o.setTimer ?? ((fn, ms) => window.setTimeout(fn, ms)); + const clearT = o.clearTimer ?? ((id) => window.clearTimeout(id)); + + let timer: number | undefined; + let generation = 0; + let issued = ""; + + const cancel = (): void => { + if (timer !== undefined) clearT(timer); + timer = undefined; + }; + + return { + queue(raw: string) { + const query = raw.trim(); + cancel(); + // Bumping the generation on EVERY queue (even ones that won't run) is + // deliberate: typing back down to two characters must invalidate the + // three-character search already in flight. + const gen = ++generation; + if (query.length < minChars) return undefined; + if (query === issued) return undefined; + timer = setT(() => { + timer = undefined; + issued = query; + run(query, gen); + }, delay); + return gen; + }, + isCurrent: (gen: number) => gen === generation, + cancel, + lastQuery: () => issued, + }; +} diff --git a/apps/desktop/src/renderer/styles/app.css b/apps/desktop/src/renderer/styles/app.css index 167c542..767c5aa 100644 --- a/apps/desktop/src/renderer/styles/app.css +++ b/apps/desktop/src/renderer/styles/app.css @@ -26,6 +26,13 @@ /* Motion — one easing + two durations the whole app shares. */ --ease: cubic-bezier(0.2, 0, 0, 1); --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + + /* Radius scale — the audit found 10 hardcoded radii in the wild; NEW work + uses these tokens and legacy values migrate as surfaces get touched. */ + --r-sm: 6px; + --r-md: 9px; + --r-lg: 12px; + --r-xl: 14px; --dur-1: 120ms; --dur-2: 180ms; @@ -34,14 +41,46 @@ comfortable prose/detail column; `wide` is for data-dense panes (orgs/members) that want more room before centering. Replaces scattered 760/820/900px one-offs. */ --measure-read: 820px; + /* Settings is a FORM, not an article: its rows carry paths, badges and + action clusters that a 820px reading measure squeezed, while leaving + ~255px of gutter on each side at 1600px. Prose inside keeps its own + measure (see .settings-sub). */ + --measure-settings: 1040px; --measure-wide: 1100px; + /* Modal widths — three sizes, not eleven. Dialogs opened seconds apart used + to be 420/480/520/540/560/640/760px wide with no structural reason. + sm = a question (confirm, prompt, a destination). md = a form. + lg = content you read or scan (shortcuts, a file list, a foreign issue). */ + --modal-sm: 440px; + --modal-md: 560px; + --modal-lg: 720px; + + /* Spacing scale — new work (the sec- and det- section pages) sizes every gap + and padding from this; legacy px values migrate as surfaces get touched. + NB: never write a star-slash pair inside a comment — a selector glob like + "sec-<star>/det-" closes it early and silently eats the next declaration. */ + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 12px; + --sp-4: 16px; + --sp-5: 20px; + --sp-6: 24px; + --sp-8: 32px; + font-synthesis: none; text-rendering: optimizeLegibility; } /* ── Dark theme tokens ─────────────────────────────────────────────────────── */ body.vscode-dark { + /* Tells the engine which palette to render NATIVE controls in. Without it a + checkbox followed the OS while the app was dark, so every one in the + modals was a solid white 15x15 block on a near-black card — and in the + Assignees picker the UNCHECKED people were the brightest thing on screen. + This one declaration fixes .modal-check, .gh-form-check and .people-row + together, plus scrollbars and the caret. */ + color-scheme: dark; --vscode-foreground: #d7dae0; --vscode-descriptionForeground: #8b93a1; --vscode-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, @@ -80,6 +119,10 @@ body.vscode-dark { /* Amber "attention" ink (pre-release, action-required) — readable on the dark canvas; the light theme darkens it for AA. Keeps amber out of literals. */ --status-warn: #e0a44e; + /* The shared graph package paints tag chips and the detached-HEAD warning + with --gs-amber and the desktop never declared it, so both fell back to + whatever colour they inherited — a tag looked like body text. */ + --gs-amber: #e0a44e; --gs-accent-ink: #a78bff; /* ── Elevation — layered ambient+key shadows, deep for the near-black canvas. @@ -103,7 +146,58 @@ body.vscode-dark { } /* ── Light theme tokens ────────────────────────────────────────────────────── */ +/* ── The VS Code vocabulary the SHARED packages speak ───────────────────── + On `body`, unqualified, so BOTH themes match it. It lived in the + `body.vscode-dark` block, which meant the light theme got none of it — + which is why a tag chip in the Commits graph rendered as bare body text + on the light page: `--gs-amber` is + `var(--vscode-gitDecoration-modifiedResourceForeground, var(--vscode-charts-yellow))` + and in light neither name existed. + Every value below is stated in terms of the desktop's OWN semantic tokens, + which each theme redeclares on this same element — so one block is + automatically correct in both, and a light value never has to be + remembered separately. */ +body { + /* The focus ring's own ink, at FULL opacity. It used to be the accent mixed + with `transparent`, which lowers alpha rather than lightness — so the ring + composited toward whatever was behind it and measured 2.19-2.90:1 on the + light page, under the 3:1 WCAG asks of a focus indicator. It is the one + thing on screen whose whole job is to be seen. */ + --gs-focus-ring: var(--gs-accent-ink, var(--gs-accent)); + + --vscode-errorForeground: var(--status-del); + --vscode-editorError-foreground: var(--status-del); + --vscode-list-warningForeground: var(--status-warn); + --vscode-testing-iconPassed: var(--status-add); + --vscode-focusBorder: var(--gs-accent); + --vscode-list-hoverBackground: var(--app-hover); + --vscode-textCodeBlock-background: var(--app-elevated); + --vscode-sideBar-background: var(--app-panel); + --vscode-badge-background: color-mix(in srgb, var(--gs-accent) 22%, transparent); + --vscode-badge-foreground: var(--gs-accent-ink, var(--gs-accent)); + --vscode-button-secondaryBackground: var(--app-elevated); + --vscode-button-secondaryForeground: var(--vscode-foreground); + --vscode-button-secondaryHoverBackground: var(--app-hover); + --vscode-list-activeSelectionBackground: var(--app-active); + --vscode-list-activeSelectionForeground: var(--vscode-foreground); + --vscode-textLink-foreground: var(--gs-accent-2, var(--gs-accent)); + /* Git decoration inks: the same four the Changes list paints A/M/D with, so + the graph and the file list can never disagree about what "modified" is. */ + --vscode-gitDecoration-modifiedResourceForeground: var(--status-mod); + --vscode-gitDecoration-untrackedResourceForeground: var(--status-add); + --vscode-gitDecoration-conflictingResourceForeground: var(--status-del); + --vscode-gitDecoration-ignoredResourceForeground: var(--app-muted); + /* Chart hues, mapped onto the app's own status palette rather than a second, + parallel set of colours nobody tuned. */ + --vscode-charts-blue: var(--status-mod); + --vscode-charts-green: var(--status-add); + --vscode-charts-red: var(--status-del); + --vscode-charts-yellow: var(--status-warn); + --vscode-charts-purple: var(--gs-accent-ink, var(--gs-accent)); +} + body.vscode-light { + color-scheme: light; --vscode-foreground: #2b2f36; --vscode-descriptionForeground: #6a7280; --vscode-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, @@ -114,8 +208,12 @@ body.vscode-light { --vscode-editor-font-size: 12.5px; --vscode-editor-background: #ffffff; --vscode-editor-foreground: #2b2f36; - --vscode-editorLineNumber-foreground: #b3b9c2; - --vscode-editorLineNumber-activeForeground: #6a7280; + /* Line numbers are read, not decoration. #b3b9c2 on white is 1.93:1 — under + the 2:1 the log pane's own gutter was raised off, in the surface where a + number is how you refer to a line at all. The active/inactive step is + kept, both above AA. */ + --vscode-editorLineNumber-foreground: #6e7781; + --vscode-editorLineNumber-activeForeground: #24292f; --vscode-editorCursor-foreground: #2b2f36; --vscode-editor-selectionBackground: #cfe2ff; --vscode-editor-inactiveSelectionBackground: #e2ecfb; @@ -143,6 +241,7 @@ body.vscode-light { --status-mod: #1a63c4; --status-del: #b82f5e; --status-warn: #92610f; + --gs-amber: #92610f; /* Light elevation — softer, cooler ink shadows; a bright top sheen. */ --shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.06), 0 1px 1px rgba(16, 24, 40, 0.04); @@ -213,6 +312,51 @@ body { -webkit-font-smoothing: antialiased; } +/* ── CONTENT IS SELECTABLE ─────────────────────────────────────────────────── + The body-level user-select:none is right for app chrome (rails, buttons, + list rows) — but it also covered every piece of CONTENT, so you couldn't + copy a stack trace from a CI log, a snippet from a README, a commit + subject, or the sign-in code. Not being able to copy text is by itself a + reason to leave for github.com. Everything a person reads is re-enabled + here; everything they click stays unselectable. */ +.gh-body-md, .code-md, .code-md-plain, .assistant-msg, +.gist-content, .actions-log, .ghfile, +.ext-item-scroll, .ext-item-title, +.gh-detail-title, .gh-detail-meta, .gh-comment, +.peek-msg, .peek-meta, .peek-subtitle, +.gh-tag-commit-card, .gh-device-code, +.settings-version, .settings-sub, +.row-meta-sub, .gh-row-sub { + user-select: text; +} +/* Row meta stays copyable but must never hijack the row's click. */ +.gh-row-sub, .row-meta-sub { cursor: default; } +/* Clickable fragments INSIDE a row's meta line (branch / author / repo). */ +.gh-sub-link { + color: inherit; + cursor: pointer; + border-radius: 4px; + /* These sit INSIDE rows that navigate somewhere else, so a link that reads as + plain muted text is a trap: it looked like the row's metadata, and clicking + it took you somewhere the row never promised. A resting underline says it is + its own target, and the padding gives it a hit box taller than one line of + 13px text. */ + padding: 3px 4px; + margin: -3px -4px; + text-decoration: underline; + text-decoration-style: dotted; + text-decoration-color: color-mix(in srgb, currentColor 45%, transparent); + text-underline-offset: 2px; + transition: color var(--dur-1) var(--ease), background var(--dur-1) var(--ease); +} +.gh-sub-link:hover { background: var(--app-hover); } +.gh-sub-link:hover { + color: var(--gs-accent-ink, var(--gs-accent)); + text-decoration-style: solid; + text-decoration-color: currentColor; +} +.gh-sub-sep { opacity: 0.7; } + #root { display: flex; flex-direction: column; @@ -349,6 +493,35 @@ body { font-size: 12.5px; padding: 8px 2px; } +/* The row is the container; the card and its Forget button are siblings. A + button inside a button is invalid, has no accessible name of its own, and + Space activates the wrong one — the same reason `clist-row` puts its copy + button beside the subject rather than inside it. */ +.recent-card-row { display: flex; align-items: stretch; gap: 6px; } +.recent-card-row > .recent-card { flex: 1 1 auto; min-width: 0; } +.recent-card-forget { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + width: 30px; + border: 1px solid transparent; + border-radius: 11px; + background: none; + color: var(--app-muted); + opacity: 0; + cursor: pointer; +} +/* Revealed on hover or keyboard focus — never only on hover, or it is + unreachable without a pointer. */ +.recent-card-row:hover .recent-card-forget, +.recent-card-forget:focus-visible { opacity: 1; } +.recent-card-forget:hover { + color: var(--status-del); + border-color: var(--app-border); + background: var(--app-elevated); +} + .recent-card { display: flex; align-items: center; @@ -424,6 +597,38 @@ button.is-busy, .btn.is-busy, .btn-primary.is-busy, .btn-danger.is-busy, @keyframes gs-busy { 0%, 100% { opacity: 0.62; } 50% { opacity: 0.38; } } @media (prefers-reduced-motion: reduce) { .is-busy { animation: none; } } +/* A consistent keyboard focus ring on every interactive surface. + The list below used to be the WHOLE story, which meant every control added + since — the ⌘K search button, row actions, the drawer's close button — fell + back to Chrome's stock blue ring in a purple app. This `:where()` rule carries + zero specificity, so it is a floor: anything focusable gets the app's ring, + and every rule below (or anywhere else in this file) still overrides it. */ +:where( + button, a[href], summary, input, select, textarea, + [role="button"], [role="option"], [role="tab"], [role="menuitem"], + [role="menuitemcheckbox"], [role="switch"], [role="checkbox"], + [role="separator"], [role="link"], [tabindex]:not([tabindex="-1"]) +):focus-visible { + outline: 2px solid var(--gs-focus-ring); + outline-offset: 2px; + border-radius: 7px; +} +/* A landing target is not a control. focusReturn parks the reader on a page's + title when a detail opens, so a screen reader announces where it just arrived + and the keyboard starts at the top — but the title is `tabindex="-1"`, not a + tab stop, and drawing a focus ring around a heading tells a sighted reader + they are ON something operable. They are not. */ +:where([tabindex="-1"]):focus, +:where([tabindex="-1"]):focus-visible { + outline: none; +} + +/* Inside a scrolling list an offset ring is clipped by the row above; pull it in. */ +:where(.list-row, .gh-row, .file-row, .code-row, .sec-row, .dropdown-item, + .ctx-menu-item, tr, li):focus-visible { + outline-offset: -2px; +} + /* A consistent keyboard focus ring on every interactive surface. */ .nav-item:focus-visible, .list-row:focus-visible, @@ -458,17 +663,16 @@ button.is-busy, .btn.is-busy, .btn-primary.is-busy, .btn-danger.is-busy, .assistant-chip:focus-visible, .ai-chip:focus-visible, .assistant-send:focus-visible, -.notif-toggle:focus-visible, .dock-chevron:focus-visible, .tab:focus-visible { - outline: 2px solid color-mix(in srgb, var(--gs-accent) 70%, transparent); + outline: 2px solid var(--gs-focus-ring); outline-offset: -2px; border-radius: 7px; } .btn:focus-visible, .btn-primary:focus-visible, .btn-danger:focus-visible { - outline: 2px solid color-mix(in srgb, var(--gs-accent) 80%, white 10%); + outline: 2px solid var(--gs-focus-ring); outline-offset: 2px; } @@ -477,7 +681,7 @@ button.is-busy, .btn.is-busy, .btn-primary.is-busy, .btn-danger.is-busy, .rail-resizer:focus-visible, .cmp-vsplit:focus-visible, .dock-resizer:focus-visible { - outline: 2px solid color-mix(in srgb, var(--gs-accent) 70%, transparent); + outline: 2px solid var(--gs-focus-ring); outline-offset: -1px; } .rail-resizer:focus-visible .rail-resizer-grip, @@ -599,7 +803,7 @@ html.is-mac .topbar { padding-left: 78px; } border-color: color-mix(in srgb, var(--gs-accent) 22%, transparent); } .topbar-home:focus-visible { - outline: 2px solid color-mix(in srgb, var(--gs-accent) 70%, transparent); + outline: 2px solid var(--gs-focus-ring); outline-offset: 2px; } /* The inline brand mark: cube hull tracks the foreground (subtle on both @@ -654,6 +858,16 @@ html.is-mac .topbar { padding-left: 78px; } cursor: pointer; } .topbar-icon:hover { background: var(--app-hover); color: var(--vscode-foreground); } +/* Back / forward history chevrons — grouped tight, right after the sidebar + toggle. Disabled = end of the stack in that direction (kept visible so the + affordance is learnable, like a browser's). */ +.topbar-nav { margin: 0 -1px; } +.topbar-nav .codicon { font-size: 15px; } +.topbar-nav:disabled { + opacity: 0.35; + cursor: default; + pointer-events: none; +} /* Sidebar toggle — sits flush at the far left, a hair before the brand. */ .topbar-sidebar { margin-right: 2px; } .topbar-sidebar .codicon { font-size: 17px; } @@ -717,7 +931,7 @@ html.is-mac .topbar { padding-left: 78px; } .recent-card:focus-visible, .dropdown-item:focus-visible, .welcome-open:focus-visible { - outline: 2px solid color-mix(in srgb, var(--gs-accent) 70%, transparent); + outline: 2px solid var(--gs-focus-ring); outline-offset: 2px; } @@ -791,7 +1005,7 @@ html.is-mac .topbar { padding-left: 78px; } box-shadow: 0 0 10px color-mix(in srgb, var(--gs-accent) 60%, transparent); } .nav-item:focus-visible { - outline: 2px solid color-mix(in srgb, var(--gs-accent) 60%, transparent); + outline: 2px solid var(--gs-focus-ring); outline-offset: -2px; } /* The footer Settings gear: a quiet, compact affordance pinned to the very @@ -843,6 +1057,12 @@ html.is-mac .topbar { padding-left: 78px; } display: flex; flex-direction: column; } +/* Soft view entrance — a 110ms settle instead of a hard DOM pop on every tab + switch. Opacity only: a transform here would give the graph's virtualizer a + containing block mid-animation. */ +.view-host > * { animation: gs-view-in 110ms var(--ease) both; } +@keyframes gs-view-in { from { opacity: 0.3; } to { opacity: 1; } } +@media (prefers-reduced-motion: reduce) { .view-host > * { animation: none; } } .graph-view { flex: 1 1 auto; min-height: 0; @@ -988,7 +1208,7 @@ html.is-mac .topbar { padding-left: 78px; } .list-row .glyph { color: var(--app-muted); flex: 0 0 auto; } .list-row:hover:not(:disabled) { background: var(--app-hover); } .list-row:focus-visible { - outline: 2px solid color-mix(in srgb, var(--gs-accent) 70%, transparent); + outline: 2px solid var(--gs-focus-ring); outline-offset: -1px; } .list-row.is-current { @@ -1026,10 +1246,10 @@ html.is-mac .topbar { padding-left: 78px; } } .list-empty { flex: 1 1 auto; - /* Fill the pane so the empty state is vertically centered regardless of whether - the parent is a flex container — keeps the list-pane and detail-pane empties - aligned across every section view (e.g. Projects' list vs its board detail). */ - min-height: 100%; + /* Anchored high rather than centred in the whole pane: the answer to "why is + this list empty?" belongs near the filter that emptied it, not 400px below + the fold. `justify-content` below still centres within this shorter box. */ + min-height: min(100%, 380px); display: flex; flex-direction: column; align-items: center; @@ -1038,6 +1258,21 @@ html.is-mac .topbar { padding-left: 78px; } padding: 48px 24px; text-align: center; } +/* The answer to "why did my search return nothing?" sits where the search is: + at the top, at the content's left edge, one row's height below the control — + not centred in the pane, which put it ~290px down and ~600px across from the + box the user is still looking at. */ +.list-empty.is-inline { + min-height: 0; + flex: 0 0 auto; + align-items: flex-start; + justify-content: flex-start; + text-align: left; + padding: var(--sp-5) var(--sp-4); + gap: var(--sp-2); +} +.list-empty.is-inline .list-empty-badge { display: none; } +.list-empty.is-inline .list-empty-desc { max-width: var(--measure-read, 820px); } .list-empty-title { font-size: 15px; font-weight: 600; color: var(--vscode-foreground); } .list-empty-desc { font-size: 12.5px; color: var(--app-muted); max-width: 360px; line-height: 1.5; } /* `.list-empty-badge` (and the `.list-empty-*` companions) get their final, @@ -1170,6 +1405,14 @@ button.list-row, } .ab-pill.ahead { color: var(--status-add); background: color-mix(in srgb, var(--status-add) 16%, transparent); } .ab-pill.behind { color: var(--status-mod); background: color-mix(in srgb, var(--status-mod) 16%, transparent); } +/* The upstream this branch tracked is gone — a state git reports and the row + used to render as "in sync". Warning-coloured, because it is a fact about + something that has disappeared, not an error. */ +.ab-pill.gone { + color: var(--status-warn); + border-color: color-mix(in srgb, var(--status-warn) 40%, var(--app-border)); + background: color-mix(in srgb, var(--status-warn) 12%, transparent); +} /* The behind pill is a real Pull button: same pill face, but interactive. */ button.ab-btn { @@ -1231,7 +1474,7 @@ button.ab-btn:disabled { cursor: default; opacity: 0.85; } } @keyframes gs-overlay-in { from { opacity: 0; } to { opacity: 1; } } .modal-card { - width: min(420px, 86vw); + width: min(var(--modal-sm), 92vw); display: flex; flex-direction: column; gap: 12px; @@ -1263,14 +1506,18 @@ button.ab-btn:disabled { cursor: default; opacity: 0.85; } .modal-input::placeholder, .modal-textarea::placeholder { color: var(--app-muted); opacity: 1; } .modal-input:focus { border-color: color-mix(in srgb, var(--gs-accent) 55%, var(--app-border)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--gs-accent) 22%, transparent); } .modal-message { font-size: 13px; line-height: 1.55; color: var(--app-muted); white-space: pre-wrap; } -.modal-actions { display: flex; justify-content: flex-end; gap: 8px; } +/* Both footer buttons are the same height and sit on the same baseline. They + were 28px and 30px, stretch-aligned, so their bottom edges disagreed by 2px + — the kind of thing you feel as "unfinished" without being able to name. */ +.modal-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; } +.modal-actions .mini-btn, .modal-actions .btn { height: 30px; } /* The modal CTA is a shorter 30px button, so drop the 13px .btn font a notch so the label isn't cramped/optically mis-centred in the smaller box. */ .modal-ok { height: 30px; padding: 0 16px; font-size: var(--text-sm); } /* A wider form modal (issue/PR edit) with a real body textarea. */ -.modal-card-form { width: min(560px, 92vw); } +.modal-card-form { width: min(var(--modal-md), 92vw); } /* Searchable, avatar-rich people picker (PR reviewers + issue assignees). */ -.people-picker { width: min(480px, 92vw); } +.people-picker { width: min(var(--modal-sm), 92vw); } .people-list { display: flex; flex-direction: column; @@ -1296,7 +1543,7 @@ button.ab-btn:disabled { cursor: default; opacity: 0.85; } .people-login { font-size: 13px; color: var(--vscode-foreground); } .people-empty { padding: 12px; text-align: center; color: var(--app-muted); font-size: 12.5px; } /* Read-only cross-repo issue/PR viewer (a notification for another repo). */ -.ext-item { width: min(640px, 94vw); max-height: min(82vh, 760px); gap: 9px; } +.ext-item { width: min(var(--modal-lg), 92vw); max-height: min(82vh, 760px); gap: 9px; } .ext-item-head { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; } .ext-item-sub { font-size: 12px; color: var(--app-muted); } .ext-item-title { font-size: 17px; font-weight: 650; color: var(--vscode-foreground); line-height: 1.3; } @@ -1316,1258 +1563,2089 @@ button.ab-btn:disabled { cursor: default; opacity: 0.85; } padding: 9px 11px; } -/* Destructive primary button (delete / discard / force ops). */ -.btn-danger { - display: inline-flex; - align-items: center; - gap: 8px; - height: 40px; - padding: 0 20px; - font-weight: 600; - color: #fff; - border: none; - background: linear-gradient(180deg, - color-mix(in srgb, var(--vscode-errorForeground, #e15a5a) 92%, white 8%), - color-mix(in srgb, var(--vscode-errorForeground, #e15a5a) 100%, black 8%)); - box-shadow: 0 4px 16px color-mix(in srgb, var(--vscode-errorForeground, #e15a5a) 34%, transparent); - transition: filter 120ms, transform 100ms; -} -/* Re-state the red face on hover so it wins over `.btn:hover`'s gray (same bug - the primary skin had — see .btn-primary:hover). */ -.btn-danger:hover { - background: linear-gradient(180deg, - color-mix(in srgb, var(--vscode-errorForeground, #e15a5a) 86%, white 14%), - color-mix(in srgb, var(--vscode-errorForeground, #e15a5a) 100%, black 2%)); - border-color: transparent; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22), - 0 8px 22px color-mix(in srgb, var(--vscode-errorForeground, #e15a5a) 42%, transparent); -} -.btn-danger:active { transform: translateY(0.5px); } -.btn-danger .glyph { color: #fff; } -.modal-ok.btn-danger { height: 30px; padding: 0 16px; } - -/* ── Toasts (non-blocking notifications) ────────────────────────────────────── */ -.toast-stack { +/* ── Peek: the Linear-style drill-in popup (peek.ts) ───────────────────────── + One overlay hosts a stack of cards (branch → commit → …) with a ← back in + the header. Reuses the modal overlay treatment so every layer of the app + dims and blurs identically. */ +.peek-overlay { position: fixed; - top: 16px; - right: 16px; - z-index: 3000; + inset: 0; + z-index: 1900; /* under .modal-overlay (2000): confirms opened FROM a peek stack above it */ display: flex; - flex-direction: column; - gap: 10px; - max-width: min(380px, 90vw); - pointer-events: none; + align-items: safe center; + justify-content: center; + background: color-mix(in srgb, var(--app-bg) 58%, transparent); + backdrop-filter: blur(4px) saturate(1.1); + -webkit-backdrop-filter: blur(4px) saturate(1.1); + animation: gs-overlay-in 140ms var(--ease) both; + -webkit-app-region: no-drag; + overflow: auto; + padding: 40px 24px; } -.toast { +.peek-card { + width: min(700px, 94vw); + max-height: min(78vh, 860px); display: flex; - align-items: flex-start; - gap: 10px; - padding: 11px 12px 11px 13px; - border-radius: 12px; + flex-direction: column; + border-radius: 14px; background: color-mix(in srgb, var(--app-elevated) 96%, var(--vscode-foreground)); border: 1px solid var(--app-border); - box-shadow: var(--sheen), var(--shadow-lg); - pointer-events: auto; - opacity: 0; - transform: translateX(14px); - transition: opacity 180ms cubic-bezier(0.2, 0, 0, 1), transform 180ms cubic-bezier(0.2, 0, 0, 1); -} -.toast.in { opacity: 1; transform: translateX(0); } -.toast.out { opacity: 0; transform: translateX(14px); } -.toast .glyph { flex: 0 0 auto; margin-top: 1px; } -.toast .glyph .codicon { font-size: 16px; } -/* Type-tint the whole toast (a faint wash + a colored left edge), not just the - border — so success/error read at a glance, GitHub/Linear-style. */ -.toast-error { - border-color: color-mix(in srgb, var(--status-del) 50%, var(--app-border)); - background: color-mix(in srgb, var(--status-del) 9%, color-mix(in srgb, var(--app-elevated) 96%, var(--vscode-foreground))); - border-left: 3px solid var(--status-del); -} -.toast-error > .glyph { color: var(--status-del); } -.toast-success { - border-color: color-mix(in srgb, var(--status-add) 48%, var(--app-border)); - background: color-mix(in srgb, var(--status-add) 9%, color-mix(in srgb, var(--app-elevated) 96%, var(--vscode-foreground))); - border-left: 3px solid var(--status-add); -} -.toast-success > .glyph { color: var(--status-add); } -.toast-info > .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } -.toast-msg { - flex: 1 1 auto; - min-width: 0; - font-size: 12.5px; - line-height: 1.45; - color: var(--vscode-foreground); - word-break: break-word; + box-shadow: var(--sheen), var(--shadow-pop); + animation: gs-card-in 160ms var(--ease-out) both; + outline: none; + overflow: hidden; } -.toast-close { +.peek-head { + display: flex; + align-items: center; + gap: 10px; + padding: 13px 14px 12px 16px; + border-bottom: 1px solid var(--app-border); flex: 0 0 auto; + /* Three buttons plus a close X took ~540px of a 700px card, leaving the + identity — the thing the card is ABOUT — around 230. The actions wrap to + their own line rather than squeezing the name. */ + flex-wrap: wrap; + row-gap: 8px; +} +.peek-nav-btn { display: inline-flex; align-items: center; justify-content: center; - width: 22px; - height: 22px; - margin: -2px -2px 0 0; + width: 28px; + height: 28px; border: none; - border-radius: 6px; background: transparent; - color: var(--app-muted); cursor: pointer; + border-radius: 8px; + color: var(--app-muted); + flex: 0 0 auto; + transition: background var(--dur-1) var(--ease), color var(--dur-1) var(--ease); } -.toast-close:hover { background: var(--app-hover); color: var(--vscode-foreground); } -.toast-close .codicon { font-size: 13px; } -@media (prefers-reduced-motion: reduce) { - .toast { transition: opacity 120ms; transform: none; } - .toast.in, .toast.out { transform: none; } +.peek-nav-btn:hover { background: var(--app-hover); color: var(--vscode-foreground); } +.peek-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: 9px; + background: var(--accent-soft); + flex: 0 0 auto; } - -/* Shared group label. */ -.group-label { - font-size: 10.5px; - font-weight: 600; - letter-spacing: 0.06em; - text-transform: uppercase; +.peek-badge .glyph { color: var(--gs-accent-ink, var(--gs-accent)); font-size: 15px; } +.peek-badge img { width: 22px; height: 22px; border-radius: 50%; } +/* A quiet description paragraph inside a peek body (repo/user bios). */ +.peek-desc { + font-size: var(--text-sm); + line-height: 1.55; + color: var(--vscode-foreground); + margin: 0; +} +.peek-topics { display: flex; flex-wrap: wrap; gap: 6px; } +.peek-titlewrap { flex: 1 1 280px; min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.peek-titlerow { display: flex; align-items: center; gap: 7px; min-width: 0; } +.peek-title { + font-size: 14.5px; + font-weight: 650; + color: var(--vscode-foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} +.peek-subtitle { + font-size: var(--text-xs); color: var(--app-muted); - padding: 12px 8px 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } - -/* Collapsible list-view category: a full-width header button (chevron + label + - count) over a body div. Mirrors the extension's .bm-sep / .bm-group-body. */ -.list-group-head { - display: flex; +.peek-chip { + display: inline-flex; align-items: center; - gap: 6px; - width: 100%; - margin: 6px 0 1px; - padding: 6px 8px; - border: none; - border-radius: 7px; - background: transparent; - color: var(--app-muted); - font-family: inherit; + height: 18px; + padding: 0 7px; + border-radius: 999px; font-size: 10.5px; font-weight: 600; - letter-spacing: 0.06em; - text-transform: uppercase; - text-align: left; - cursor: pointer; - transition: background 110ms, color 110ms; + letter-spacing: 0.02em; + flex: 0 0 auto; + border: 1px solid transparent; } -.list-group-head:hover { background: var(--app-hover); color: var(--vscode-foreground); } -.list-group-head:focus-visible { - outline: 2px solid color-mix(in srgb, var(--gs-accent) 70%, transparent); - outline-offset: -1px; +.peek-chip-accent { + color: var(--gs-accent-ink, var(--gs-accent)); + background: var(--accent-soft); + border-color: color-mix(in srgb, var(--gs-accent) 26%, transparent); } -.list-group-head .glyph { - flex: 0 0 auto; - font-size: 13px; - color: var(--app-muted); - transition: transform 140ms cubic-bezier(0.2, 0, 0, 1); +.peek-chip-ok { + color: var(--status-add); + background: color-mix(in srgb, var(--status-add) 13%, transparent); + border-color: color-mix(in srgb, var(--status-add) 28%, transparent); } -.list-group-head.collapsed .glyph { transform: rotate(-90deg); } -.list-group-label { flex: 1 1 auto; } -.list-group-count { - flex: 0 0 auto; - font-variant-numeric: tabular-nums; - letter-spacing: 0; - color: color-mix(in srgb, var(--app-muted) 75%, transparent); +.peek-chip-warn { + color: var(--status-warn); + background: color-mix(in srgb, var(--status-warn) 13%, transparent); + border-color: color-mix(in srgb, var(--status-warn) 28%, transparent); } -.list-group-body { display: block; } - -/* ── Code view (GitHub-style repo browser) ─────────────────────────────────── */ -.code-view { +.peek-chip-muted { + color: var(--app-muted); + background: color-mix(in srgb, var(--app-muted) 12%, transparent); + border-color: color-mix(in srgb, var(--app-muted) 22%, transparent); +} +.peek-actions { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; margin-left: auto; } +.peek-act { height: 28px; } +.peek-act.btn { height: 28px; padding: 0 12px; font-size: var(--text-sm); } +.peek-act-danger { color: var(--gs-danger); } +.peek-act-danger:hover { border-color: color-mix(in srgb, var(--gs-danger) 45%, var(--app-border)); } +/* Close never moves. The header's action cluster WRAPS when a card carries more + buttons, and Close travelled with it — drilling from a branch into a commit + dropped it 51px down the card, so the second click of "close this" landed on + whatever had taken its place. It is pinned to the header's top-right corner, + outside the wrapping run. */ +.peek-close { + position: absolute; + top: 10px; + right: 12px; + margin-left: 0; +} +.peek-head { position: relative; padding-right: 48px; } +.peek-body { flex: 1 1 auto; - min-height: 0; + min-height: 80px; + overflow-y: auto; + padding: 12px 16px 16px; display: flex; flex-direction: column; - background: var(--app-bg); + gap: 14px; } -.code-head { +/* Content-heavy cards (remote repo browsing, file quick-looks). */ +.peek-card-wide { width: min(980px, 96vw); max-height: min(86vh, 940px); } +/* Remote file quick-look: mono text with a line-number gutter, one shared + scroll surface so the gutter never desyncs from the code. */ +.ghfile-outer { display: flex; flex-direction: column; gap: 8px; min-height: 0; } +.ghfile { + display: flex; + align-items: flex-start; + overflow: auto; + border: 1px solid var(--app-border); + border-radius: 10px; + background: var(--app-panel); + max-height: 62vh; +} +.ghfile pre { + margin: 0; + padding: 12px 0; + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-size: 12.5px; + line-height: 1.55; +} +.ghfile-gutter { flex: 0 0 auto; + position: sticky; + left: 0; + padding: 12px 10px 12px 14px !important; + text-align: right; + color: var(--app-muted); + opacity: 0.65; + background: var(--app-panel); + border-right: 1px solid var(--app-border); + user-select: none; +} +.ghfile-code { flex: 1 1 auto; padding: 12px 16px !important; } +.ghfile-more { font-size: var(--text-xs); color: var(--app-muted); } +/* README inside a peek section: give the prose room to breathe. */ +.peek-readme { padding: 18px 22px; } + +.peek-skel { display: flex; flex-direction: column; gap: 4px; padding: 4px 0; } +.peek-empty { display: flex; + flex-direction: column; align-items: center; gap: 8px; - padding: 10px 14px; - border-bottom: 1px solid var(--app-border); + padding: 28px 12px; + color: var(--app-muted); + font-size: var(--text-sm); + text-align: center; } -.code-crumbs { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; min-width: 0; } -.code-crumb { - display: inline-flex; - align-items: center; - gap: 6px; - border: none; - background: transparent; - cursor: pointer; - color: var(--gs-accent-ink, var(--gs-accent)); - font-family: inherit; - font-size: 13px; - font-weight: 600; - padding: 2px 5px; - border-radius: 6px; +.peek-empty .glyph { font-size: 22px; opacity: 0.75; } +.peek-section { display: flex; flex-direction: column; gap: 6px; } +.peek-section-head { display: flex; align-items: center; gap: 7px; } +.peek-section-label { + font-size: var(--text-xs); + font-weight: 650; + text-transform: uppercase; + letter-spacing: var(--track-label); + color: var(--app-muted); } -.code-crumb:hover:not(.is-current) { background: var(--app-hover); } -.code-crumb:active:not(.is-current) { transform: translateY(0.5px); } -.code-crumb.is-current { color: var(--vscode-foreground); cursor: default; } -.code-crumb .glyph { color: var(--app-muted); } -.code-crumb.is-current .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } -.code-crumb-sep { color: var(--app-muted); opacity: 0.55; } -/* Folder / file count chip in the Code head — a quiet at-a-glance summary. */ -.code-count { - display: inline-flex; - align-items: center; - gap: 5px; - height: 22px; - padding: 0 9px; - border: 1px solid var(--app-border); - border-radius: 999px; - background: var(--app-elevated); - font-size: 11.5px; +.peek-section-count { + font-size: 10.5px; + font-weight: 600; color: var(--app-muted); - font-variant-numeric: tabular-nums; - white-space: nowrap; + background: color-mix(in srgb, var(--app-muted) 12%, transparent); + border-radius: 999px; + padding: 1px 7px; } -.code-count[hidden] { display: none; } -.code-count .glyph .codicon { font-size: 12px; color: var(--app-muted); } - -/* Cap + center the repo listing on the wide measure so the file table stays - comfortable on ultra-wide windows instead of stretching edge to edge. */ -.code-scroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 12px max(16px, calc((100% - var(--measure-wide)) / 2)) 28px; } - -/* The GitHub-style "latest commit" bar that crowns the repo-root listing. */ -.code-latest-slot:empty { display: none; } -.code-latest { +.peek-section-body { display: flex; - align-items: center; - gap: 10px; - padding: 10px 14px; - border-bottom: 1px solid var(--app-border); - background: linear-gradient(180deg, - color-mix(in srgb, var(--app-elevated) 55%, var(--app-panel)), - var(--app-panel)); - animation: welcome-rise 360ms var(--ease-out) both; + flex-direction: column; + border: 1px solid var(--app-border); + border-radius: 10px; + background: var(--app-panel); + overflow: hidden; } -.code-latest-av { - flex: 0 0 auto; - display: inline-flex; +.peek-meta { + display: grid; + grid-template-columns: minmax(84px, max-content) 1fr; + gap: 5px 16px; + font-size: var(--text-sm); +} +.peek-meta-key { color: var(--app-muted); white-space: nowrap; } +.peek-meta-val { + color: var(--vscode-foreground); + min-width: 0; + overflow-wrap: anywhere; + display: flex; align-items: center; - justify-content: center; - width: 24px; - height: 24px; - border-radius: 50%; - background: var(--av, var(--gs-accent)); - color: #fff; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.01em; - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16); + gap: 6px; } -.code-latest-meta { +/* Rows inside a peek section (commits, changed files) — quieter than list-view + rows; the whole row is a click target when it drills deeper. */ +.peek-row { display: flex; - align-items: baseline; - gap: 8px; - min-width: 0; - flex: 1 1 auto; + align-items: center; + gap: 9px; + padding: 7px 11px; + border: none; + background: none; + text-align: left; + width: 100%; + font: inherit; + color: var(--vscode-foreground); + border-bottom: 1px solid color-mix(in srgb, var(--app-border) 55%, transparent); + cursor: default; } -.code-latest-author { font-size: var(--text-sm); font-weight: 650; color: var(--vscode-foreground); flex: 0 0 auto; } -.code-latest-subject { +.peek-row:last-child { border-bottom: none; } +button.peek-row { cursor: pointer; transition: background var(--dur-1) var(--ease); } +button.peek-row:hover { background: var(--app-hover); } +button.peek-row:focus-visible { outline: none; box-shadow: inset var(--ring); } +.peek-row .glyph { color: var(--app-muted); flex: 0 0 auto; } +.peek-row-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px; } +.peek-row-title { font-size: var(--text-sm); - color: var(--app-muted); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.peek-row-sub { + font-size: var(--text-xs); + color: var(--app-muted); white-space: nowrap; - min-width: 0; + overflow: hidden; + text-overflow: ellipsis; } -.code-latest-sha { - display: inline-flex; +.peek-row-side { + flex: 0 0 auto; + display: flex; align-items: center; - gap: 5px; + gap: 8px; + font-size: var(--text-xs); + color: var(--app-muted); + font-family: var(--vscode-editor-font-family); +} +/* Drill affordance: a chevron that appears on hover, like Linear's rows. */ +.peek-row-chev { opacity: 0; transition: opacity var(--dur-1) var(--ease); } +button.peek-row:hover .peek-row-chev, button.peek-row:focus-visible .peek-row-chev { opacity: 0.9; } +/* Commit body inside the commit card: rendered as quiet monospace prose. */ +.peek-msg { + font-family: var(--vscode-editor-font-family); + font-size: 12px; + line-height: 1.55; + color: var(--vscode-foreground); + white-space: pre-wrap; + overflow-wrap: anywhere; + background: var(--app-panel); + border: 1px solid var(--app-border); + border-radius: 10px; + padding: 10px 12px; + margin: 0; +} +/* File-status letter (A/M/D/R) tinted like the changes view. */ +.peek-fstat { + font-family: var(--vscode-editor-font-family); + font-size: 11px; + font-weight: 700; + width: 16px; + text-align: center; flex: 0 0 auto; +} +.peek-fstat.add { color: var(--status-add); } +.peek-fstat.mod { color: var(--status-mod); } +.peek-fstat.del { color: var(--status-del); } +.peek-fstat.ren { color: var(--status-warn); } +.peek-diffstat { display: inline-flex; gap: 6px; font-family: var(--vscode-editor-font-family); font-size: 11px; } +.peek-diffstat .plus { color: var(--status-add); } +.peek-diffstat .minus { color: var(--status-del); } +.peek-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; height: 22px; - padding: 0 8px; - border: 1px solid var(--app-border); - border-radius: 7px; - background: var(--app-elevated); - color: var(--gs-accent-ink, var(--gs-accent)); - font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + border-radius: 50%; + font-size: 9px; + font-weight: 700; + color: #fff; + flex: 0 0 auto; + user-select: none; +} +.peek-mono { font-family: var(--vscode-editor-font-family); font-size: 12px; } +.peek-parent { + font-family: var(--vscode-editor-font-family); font-size: 11.5px; - font-weight: 600; + color: var(--gs-accent-ink, var(--gs-accent)); + background: var(--accent-soft); + border: 1px solid color-mix(in srgb, var(--gs-accent) 24%, transparent); + border-radius: 6px; + padding: 1px 7px; + margin-right: 6px; cursor: pointer; - transition: background var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease); + transition: border-color var(--dur-1) var(--ease); } -.code-latest-sha:hover { background: var(--app-hover); border-color: var(--accent-line); } -.code-latest-sha .glyph .codicon { font-size: 13px; } -.code-latest-when { flex: 0 0 auto; font-size: var(--text-xs); color: var(--app-muted); } -.code-latest-count { +.peek-parent:hover { border-color: color-mix(in srgb, var(--gs-accent) 55%, transparent); } +/* A clickable wrapper around a ref chip (navigates to Branches). Bare-button + reset is mandatory: app.css has no global button reset. */ +.peek-refchip { + border: none; + background: none; + padding: 0; + margin-right: 6px; + cursor: pointer; display: inline-flex; - align-items: center; - gap: 6px; - flex: 0 0 auto; - padding-left: 12px; - margin-left: 2px; - border-left: 1px solid var(--app-border); - font-size: var(--text-xs); - font-weight: 600; - color: var(--app-muted); - font-variant-numeric: tabular-nums; } -.code-latest-count .glyph .codicon { font-size: 14px; color: var(--gs-accent-ink, var(--gs-accent)); } -/* The connected file card: latest-commit header + column header + rows share - one bordered, rounded surface — github.com's directory table. */ -.code-filecard { +.peek-refchip:hover .peek-chip { border-color: color-mix(in srgb, var(--gs-accent) 60%, transparent); } +/* Homepage/website values in peek meta grids — real links. */ +.peek-ext-link { color: var(--gs-accent-ink, var(--gs-accent)); text-decoration: none; } +.peek-ext-link:hover { text-decoration: underline; } +/* Comment authors carry a real avatar now (initials tile when the image 404s). */ +.gh-comment-author { display: inline-flex; align-items: center; gap: 7px; font-weight: 600; } + +/* Settings → About: version line + the check-for-updates row. */ +.settings-version { font-family: var(--vscode-editor-font-family); font-size: 12px; } +.settings-update-row { display: flex; align-items: center; gap: 10px; } +.settings-update-status { min-height: 1em; } + +/* ── ⌘K command palette ────────────────────────────────────────────────────── + Top-anchored, Linear-style: one fuzzy search over sections, refs, repos, + PRs/issues, and actions. Sits above peeks and modals — it IS navigation. */ +.cmdk-overlay { + position: fixed; + inset: 0; + z-index: 2300; + display: flex; + align-items: flex-start; + justify-content: center; + padding: 14vh 24px 24px; + background: color-mix(in srgb, var(--app-bg) 52%, transparent); + backdrop-filter: blur(4px) saturate(1.1); + -webkit-backdrop-filter: blur(4px) saturate(1.1); + animation: gs-overlay-in 120ms var(--ease) both; + -webkit-app-region: no-drag; +} +.cmdk-card { + width: min(var(--modal-lg), 92vw); + display: flex; + flex-direction: column; + border-radius: 14px; + background: color-mix(in srgb, var(--app-elevated) 96%, var(--vscode-foreground)); border: 1px solid var(--app-border); - border-radius: 12px; + box-shadow: var(--sheen), var(--shadow-pop); + animation: gs-card-in 140ms var(--ease-out) both; overflow: hidden; - background: var(--app-panel); - box-shadow: var(--sheen), var(--shadow-sm); } -.code-listing { background: transparent; } -/* Thin column header above the rows — labels the Name / Size columns so the - listing reads as a deliberate table, not a loose stack of file links. */ -.code-listing-head { +.cmdk-input-row { display: flex; align-items: center; - padding: 7px 12px; + gap: 10px; + padding: 4px 14px; border-bottom: 1px solid var(--app-border); - background: color-mix(in srgb, var(--app-elevated) 55%, var(--app-panel)); - font-size: 11px; +} +.cmdk-input-row .glyph { color: var(--app-muted); } +.cmdk-input { + flex: 1 1 auto; + height: 46px; + border: none; + background: none; + outline: none; + color: var(--vscode-foreground); + font-family: inherit; + font-size: 14.5px; +} +.cmdk-input::placeholder { color: var(--app-muted); opacity: 0.8; } +.cmdk-esc { + flex: 0 0 auto; + font-size: 10.5px; + color: var(--app-muted); + border: 1px solid var(--app-border); + border-radius: 5px; + padding: 2px 6px; +} +.cmdk-list { + max-height: min(52vh, 480px); + overflow-y: auto; + padding: 6px; + /* A row sliced exactly in half at the card's edge reads as a rendering bug. + A fade over the last few pixels says "there is more below" instead. */ + -webkit-mask-image: linear-gradient(180deg, #000 calc(100% - 22px), transparent); + mask-image: linear-gradient(180deg, #000 calc(100% - 22px), transparent); +} +/* A short list never scrolls, so it must not fade its own last row. */ +.cmdk-list.is-short { -webkit-mask-image: none; mask-image: none; } +.cmdk-group { + font-size: var(--text-2xs, 10.5px); font-weight: 650; - letter-spacing: 0.04em; text-transform: uppercase; + letter-spacing: var(--track-label); color: var(--app-muted); + padding: 10px 10px 4px; } -.code-listing-head[hidden] { display: none; } -.code-col-name { flex: 1 1 auto; min-width: 0; } -.code-col-size { flex: 0 0 auto; } -.code-row { - border-radius: 0; - padding: 8px 12px; - border-bottom: 1px solid var(--app-border); -} -.code-up .file-path { color: var(--app-muted); } -.code-listing .code-row:last-child { border-bottom: none; } -/* These rows are buttons — give them real hover/press feedback so the file tree - doesn't read as dead/unclickable (it had only a focus ring before). */ -.code-row { transition: background var(--dur-1) var(--ease); } -.code-row:hover { background: var(--app-hover); } -.code-row:active { background: var(--app-active); } -.code-row .glyph { color: var(--gs-accent-ink, var(--gs-accent)); flex: 0 0 auto; } -.code-row .file-path { font-family: inherit; font-size: 13px; } - -.code-readme { margin-top: 18px; } -.code-readme-card { - border: 1px solid var(--app-border); - border-radius: 12px; - background: var(--app-panel); - box-shadow: var(--sheen), var(--shadow-sm); - overflow: hidden; -} -.code-readme-head { +.cmdk-row { display: flex; align-items: center; - gap: 8px; - padding: 10px 14px; - border-bottom: 1px solid var(--app-border); - font-size: 11.5px; - font-weight: 650; - color: var(--app-muted); - text-transform: uppercase; - letter-spacing: 0.05em; + gap: 10px; + padding: 8px 10px; + border-radius: 9px; + cursor: pointer; + font-size: var(--text-base); + color: var(--vscode-foreground); } -.code-readme-head .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } - -/* Rendered README markdown (GitHub-ish typography). */ -.code-md { - padding: 20px 22px; - font-size: 14px; - line-height: 1.65; +.cmdk-row .glyph { color: var(--app-muted); flex: 0 0 auto; } +/* The palette is keyboard-only: the highlighted row IS the button Enter will + press, so it has to read as one. --app-active alone measured 1.14:1 against + the panel — a wash, not a state. */ +.cmdk-row.is-selected { + background: var(--accent-soft); + box-shadow: inset 3px 0 0 var(--gs-accent); color: var(--vscode-foreground); - max-width: 920px; - word-wrap: break-word; } -.code-md-plain { - white-space: pre-wrap; - font-family: var(--vscode-editor-font-family, ui-monospace, monospace); - font-size: 12.5px; +.cmdk-row.is-selected .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } +.cmdk-label { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.code-md > :first-child { margin-top: 0; } -.code-md h1, .code-md h2 { border-bottom: 1px solid var(--app-border); padding-bottom: 6px; } -.code-md h1 { font-size: 1.6em; margin: 0.6em 0 0.4em; } -.code-md h2 { font-size: 1.35em; margin: 0.85em 0 0.4em; } -.code-md h3 { font-size: 1.15em; margin: 0.8em 0 0.3em; } -.code-md h4, .code-md h5, .code-md h6 { margin: 0.8em 0 0.3em; } -.code-md p { margin: 0.55em 0; } -.code-md ul, .code-md ol { margin: 0.45em 0; padding-left: 1.5em; } -.code-md li { margin: 0.15em 0; } -.code-md blockquote { - margin: 0.6em 0; - padding: 0.1em 1em; - border-left: 3px solid color-mix(in srgb, var(--gs-accent) 22%, var(--app-border)); - background: color-mix(in srgb, var(--app-elevated) 50%, transparent); +.cmdk-hint { + flex: 0 0 auto; + max-width: 40%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--text-xs); color: var(--app-muted); } -.code-md hr { border: none; border-top: 1px solid var(--app-border); margin: 1.4em 0; } -.code-md code { - font-family: var(--vscode-editor-font-family, ui-monospace, monospace); - font-size: 0.88em; - background: var(--app-elevated); +.cmdk-empty { padding: 26px 12px; text-align: center; color: var(--app-muted); font-size: var(--text-sm); } + +/* Topbar ⌘K affordance — quiet, discoverable, out of the way. */ +.topbar-cmdk { + display: inline-flex; + align-items: center; + gap: 7px; + height: 28px; + padding: 0 10px; + margin-right: 4px; border: 1px solid var(--app-border); - border-radius: 5px; - padding: 1px 5px; + border-radius: 8px; + background: var(--app-panel); + color: var(--app-muted); + font-family: inherit; + font-size: var(--text-xs); + cursor: pointer; + transition: border-color var(--dur-1) var(--ease), color var(--dur-1) var(--ease); } -.code-md pre { - margin: 0.8em 0; - padding: 12px 14px; - overflow-x: auto; - background: var(--app-elevated); +.topbar-cmdk:hover { color: var(--vscode-foreground); border-color: color-mix(in srgb, var(--gs-accent) 40%, var(--app-border)); } +.topbar-cmdk .glyph .codicon { font-size: 13px; } +.topbar-cmdk-kbd { + font-size: 10px; border: 1px solid var(--app-border); - border-radius: 8px; + border-radius: 4px; + padding: 1px 5px; + opacity: 0.85; } -.code-md pre code { background: none; border: none; padding: 0; font-size: 12.5px; } -.code-md a { color: var(--gs-accent-ink, var(--gs-accent)); text-decoration: none; } -.code-md a:hover { text-decoration: underline; } +@media (max-width: 1100px) { .topbar-cmdk-label { display: none; } } -/* File viewer (read-only Monaco over the listing). */ -.code-file-view .code-file-name { - font-family: var(--vscode-editor-font-family, ui-monospace, monospace); - font-size: 12px; - color: var(--app-muted); +/* Deep-link landing flash — a fading accent wash on the row a navigation + pointed at (branches ref targets), like GitHub's anchor highlight. */ +@keyframes gs-row-flash { + 0% { background: color-mix(in srgb, var(--gs-accent) 28%, transparent); } + 100% { background: transparent; } } -.code-file-surface { flex: 1 1 auto; min-height: 0; position: relative; } +.row-flash { animation: gs-row-flash 1.6s var(--ease) both; } +@media (prefers-reduced-motion: reduce) { .row-flash { animation: none; } } -/* ── Compare view ──────────────────────────────────────────────────────────── */ -.compare-view { - flex: 1 1 auto; - min-height: 0; +/* Tag detail (Releases → Tags): the commit card under the tag header. */ +.gh-tag-commit { display: flex; flex-direction: column; gap: 10px; margin-top: 6px; } +.gh-tag-commit-card { + border: 1px solid var(--app-border); + border-radius: 10px; + background: var(--app-panel); + padding: 12px 14px; display: flex; flex-direction: column; - background: var(--app-bg); + gap: 6px; } -.compare-bar { - flex: 0 0 auto; - display: flex; - align-items: center; - gap: 8px; - padding: 12px 14px; - border-bottom: 1px solid var(--app-border); - flex-wrap: wrap; +.gh-tag-commit-subject { font-size: var(--text-md); font-weight: 600; color: var(--vscode-foreground); } +.gh-tag-commit-meta { font-size: var(--text-xs); color: var(--app-muted); } +.gh-tag-commit-body { + font-family: var(--vscode-editor-font-family); + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; + color: var(--vscode-foreground); + margin: 4px 0 0; } -.compare-lbl { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--app-muted); } -.ref-pick { + +/* Destructive primary button (delete / discard / force ops). */ +.btn-danger { display: inline-flex; align-items: center; - gap: 6px; - height: 30px; - padding: 0 10px; - max-width: 260px; - border-radius: 8px; - border: 1px solid var(--app-border); - background: var(--app-elevated); - color: var(--vscode-foreground); - font-family: inherit; - font-size: 12.5px; + gap: 8px; + height: 40px; + padding: 0 20px; font-weight: 600; - cursor: pointer; -} -.ref-pick:hover { background: var(--app-hover); border-color: color-mix(in srgb, var(--gs-accent) 40%, var(--app-border)); } -.ref-pick:active { transform: translateY(0.5px); } -.ref-pick .glyph:first-child { color: var(--gs-accent-ink, var(--gs-accent)); } -.ref-pick .glyph:last-child { color: var(--app-muted); } -.ref-pick span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.cmp-mode { - display: inline-flex; - margin-left: 4px; - border: 1px solid var(--app-border); - border-radius: 8px; - overflow: hidden; -} -.cmp-mode-btn { - padding: 0 12px; - height: 28px; + color: #fff; border: none; - background: var(--app-elevated); - color: var(--app-muted); - font-family: inherit; - font-size: 12px; - font-weight: 550; - white-space: nowrap; - cursor: pointer; - transition: background var(--dur-1) var(--ease), color var(--dur-1) var(--ease), transform var(--dur-1) var(--ease); + background: linear-gradient(180deg, + color-mix(in srgb, var(--vscode-errorForeground, #e15a5a) 92%, white 8%), + color-mix(in srgb, var(--vscode-errorForeground, #e15a5a) 100%, black 8%)); + box-shadow: 0 4px 16px color-mix(in srgb, var(--vscode-errorForeground, #e15a5a) 34%, transparent); + transition: filter 120ms, transform 100ms; } -.cmp-mode-btn + .cmp-mode-btn { border-left: 1px solid var(--app-border); } -.cmp-mode-btn:hover { background: var(--app-hover); } -.cmp-mode-btn:active { transform: translateY(0.5px); } -.cmp-mode-btn.active { - background: color-mix(in srgb, var(--gs-accent) 18%, transparent); - color: var(--gs-accent-ink, var(--gs-accent)); +/* Re-state the red face on hover so it wins over `.btn:hover`'s gray (same bug + the primary skin had — see .btn-primary:hover). */ +.btn-danger:hover { + background: linear-gradient(180deg, + color-mix(in srgb, var(--vscode-errorForeground, #e15a5a) 86%, white 14%), + color-mix(in srgb, var(--vscode-errorForeground, #e15a5a) 100%, black 2%)); + border-color: transparent; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22), + 0 8px 22px color-mix(in srgb, var(--vscode-errorForeground, #e15a5a) 42%, transparent); } -/* The Commits | Changed files toggle row + summary. */ -.cmp-viewbar { - flex: 0 0 auto; +.btn-danger:active { transform: translateY(0.5px); } +.btn-danger .glyph { color: #fff; } +.modal-ok.btn-danger { height: 30px; padding: 0 16px; } + +/* ── Toasts (non-blocking notifications) ────────────────────────────────────── */ +.toast-stack { + position: fixed; + top: 16px; + right: 16px; + z-index: 3000; display: flex; - align-items: center; - gap: 12px; - padding: 8px 14px; - border-bottom: 1px solid var(--app-border); + flex-direction: column; + gap: 10px; + max-width: min(380px, 90vw); + pointer-events: none; } -.cmp-seg { - display: inline-flex; +.toast { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 11px 12px 11px 13px; + border-radius: 12px; + background: color-mix(in srgb, var(--app-elevated) 96%, var(--vscode-foreground)); border: 1px solid var(--app-border); - border-radius: 8px; - overflow: hidden; + box-shadow: var(--sheen), var(--shadow-lg); + pointer-events: auto; + opacity: 0; + transform: translateX(14px); + transition: opacity 180ms cubic-bezier(0.2, 0, 0, 1), transform 180ms cubic-bezier(0.2, 0, 0, 1); } -.cmp-seg-btn { +.toast.in { opacity: 1; transform: translateX(0); } +.toast.out { opacity: 0; transform: translateX(14px); } +.toast .glyph { flex: 0 0 auto; margin-top: 1px; } +.toast .glyph .codicon { font-size: 16px; } +/* Type-tint the whole toast (a faint wash + a colored left edge), not just the + border — so success/error read at a glance, GitHub/Linear-style. */ +.toast-error { + border-color: color-mix(in srgb, var(--status-del) 50%, var(--app-border)); + background: color-mix(in srgb, var(--status-del) 9%, color-mix(in srgb, var(--app-elevated) 96%, var(--vscode-foreground))); + border-left: 3px solid var(--status-del); +} +.toast-error > .glyph { color: var(--status-del); } +.toast-success { + border-color: color-mix(in srgb, var(--status-add) 48%, var(--app-border)); + background: color-mix(in srgb, var(--status-add) 9%, color-mix(in srgb, var(--app-elevated) 96%, var(--vscode-foreground))); + border-left: 3px solid var(--status-add); +} +.toast-success > .glyph { color: var(--status-add); } +.toast-info > .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } +.toast-msg { + flex: 1 1 auto; + min-width: 0; + font-size: 12.5px; + line-height: 1.45; + color: var(--vscode-foreground); + word-break: break-word; +} +.toast-close { + flex: 0 0 auto; display: inline-flex; align-items: center; - gap: 7px; - height: 30px; - padding: 0 12px; + justify-content: center; + width: 22px; + height: 22px; + margin: -2px -2px 0 0; border: none; - background: var(--app-elevated); + border-radius: 6px; + background: transparent; color: var(--app-muted); - font-family: inherit; - font-size: 12.5px; - font-weight: 550; cursor: pointer; } -.cmp-seg-btn + .cmp-seg-btn { border-left: 1px solid var(--app-border); } -.cmp-seg-btn .glyph .codicon { font-size: 15px; } -.cmp-seg-btn:hover { background: var(--app-hover); } -.cmp-seg-btn:active { transform: translateY(0.5px); } -.cmp-seg-btn.active { - background: color-mix(in srgb, var(--gs-accent) 18%, transparent); - color: var(--gs-accent-ink, var(--gs-accent)); -} -.cmp-seg-count { - min-width: 18px; - padding: 0 6px; - height: 18px; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 9px; - background: color-mix(in srgb, var(--app-muted) 22%, transparent); - color: var(--vscode-foreground); - font-size: 11px; - font-variant-numeric: tabular-nums; -} -.cmp-seg-btn.active .cmp-seg-count { - background: color-mix(in srgb, var(--gs-accent) 30%, transparent); -} -.cmp-summary { font-size: 12.5px; color: var(--app-muted); } - -/* The body host that swaps between the commits list and the files split. */ -.cmp-body { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; } - -/* Commits-only view. */ -.cmp-commits { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 6px 10px 10px; } -.compare-commit { - display: block; - width: 100%; - text-align: left; - border: none; - background: transparent; - color: var(--vscode-foreground); - font-family: inherit; - padding: 7px 8px; - border-radius: 7px; - cursor: pointer; - transition: background var(--dur-1) var(--ease), transform var(--dur-1) var(--ease); -} -.compare-commit:hover { background: var(--app-hover); } -.compare-commit:active { transform: translateY(0.5px); } -.compare-commit:focus-visible { - outline: 2px solid color-mix(in srgb, var(--gs-accent) 70%, transparent); - outline-offset: -2px; +.toast-close:hover { background: var(--app-hover); color: var(--vscode-foreground); } +.toast-close .codicon { font-size: 13px; } +@media (prefers-reduced-motion: reduce) { + .toast { transition: opacity 120ms; transform: none; } + .toast.in, .toast.out { transform: none; } } -.cc-subject { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.cc-meta { font-size: 11px; color: var(--app-muted); } -/* Changed-files master/detail: file list (left) + native diff (right). */ -.cmp-split { flex: 1 1 auto; min-height: 0; display: flex; } -.cmp-filelist { - display: flex; - flex-direction: column; - min-width: 0; - border-right: 1px solid var(--app-border); - background: var(--app-panel); -} -.cmp-filelist-head { - flex: 0 0 auto; - display: flex; - align-items: center; - justify-content: space-between; - height: 34px; - padding: 0 6px 0 12px; - border-bottom: 1px solid var(--app-border); -} -.cmp-filelist-title { +/* Shared group label. */ +.group-label { font-size: 10.5px; - font-weight: 650; - letter-spacing: 0.05em; + font-weight: 600; + letter-spacing: 0.06em; text-transform: uppercase; color: var(--app-muted); + padding: 12px 8px 4px; } -.cmp-collapse, .cmp-restore { - display: inline-flex; + +/* Collapsible list-view category: a full-width header button (chevron + label + + count) over a body div. Mirrors the extension's .bm-sep / .bm-group-body. */ +.list-group-head { + display: flex; align-items: center; - justify-content: center; - width: 24px; - height: 24px; + gap: 6px; + width: 100%; + margin: 6px 0 1px; + padding: 6px 8px; border: none; - border-radius: 6px; + border-radius: 7px; background: transparent; color: var(--app-muted); + font-family: inherit; + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + text-align: left; cursor: pointer; + transition: background 110ms, color 110ms; } -.cmp-collapse:hover, .cmp-restore:hover { background: var(--app-hover); color: var(--vscode-foreground); } -.cmp-file-scroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 4px; } -.cmp-vsplit { - flex: 0 0 auto; - width: 7px; - margin: 0 -3px; - z-index: 2; - cursor: col-resize; - display: flex; - align-items: center; - justify-content: center; +.list-group-head:hover { background: var(--app-hover); color: var(--vscode-foreground); } +.list-group-head:focus-visible { + outline: 2px solid var(--gs-focus-ring); + outline-offset: -1px; } -.cmp-vsplit-grip { - width: 1px; - height: 100%; - background: transparent; - transition: background 100ms; +.list-group-head .glyph { + flex: 0 0 auto; + font-size: 13px; + color: var(--app-muted); + transition: transform 140ms cubic-bezier(0.2, 0, 0, 1); } -.cmp-vsplit:hover .cmp-vsplit-grip, -body.resizing-h .cmp-vsplit-grip { background: var(--gs-accent); width: 2px; } -.cmp-diffpane { flex: 1 1 auto; min-width: 0; min-height: 0; position: relative; } -.cmp-diff-editor { position: absolute; inset: 0; } -/* The restore rail only shows once the file list is collapsed away. */ -.cmp-restore { +.list-group-head.collapsed .glyph { transform: rotate(-90deg); } +/* The label used to expand, pushing the count to the far right edge of the + window — ~1300px from the thing it counts, at 75% alpha, which in light + theme was effectively invisible. */ +.list-group-label { flex: 0 1 auto; } +.list-group-count { flex: 0 0 auto; - order: -1; - width: 26px; - height: auto; - border-radius: 0; - border-right: 1px solid var(--app-border); - background: var(--app-panel); - display: none; + margin-right: auto; + font-variant-numeric: tabular-nums; + letter-spacing: 0; + color: var(--app-muted); } -.cmp-split.files-collapsed .cmp-filelist, -.cmp-split.files-collapsed .cmp-vsplit { display: none; } -.cmp-split.files-collapsed .cmp-restore { display: flex; align-items: flex-start; padding-top: 8px; } -body.resizing-h { cursor: col-resize; user-select: none; } -body.resizing-h .cmp-diff-editor { pointer-events: none; } +.list-group-body { display: block; } -/* ── Changes view (desktop working tree) ───────────────────────────────────── */ -.changes-view { +/* ── Code view (GitHub-style repo browser) ─────────────────────────────────── */ +.code-view { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; background: var(--app-bg); } -.dc-composer { +.code-head { flex: 0 0 auto; display: flex; - flex-direction: column; + align-items: center; gap: 8px; - /* Contained card materiality (matches .settings-card) so the composer reads as - a deliberate surface, not full-bleed fields floating on the panel. */ - margin: 12px 14px 0; - padding: 12px 13px; - background: var(--app-panel); - border: 1px solid var(--app-border); - border-radius: 12px; - box-shadow: var(--sheen), var(--shadow-sm); + padding: 10px 14px; + border-bottom: 1px solid var(--app-border); } -.dc-message { - width: 100%; - min-height: 46px; - max-height: 180px; - resize: vertical; - padding: 8px 11px; - border-radius: 9px; - border: 1px solid var(--app-border); - background: var(--app-elevated); - color: var(--vscode-foreground); +.code-crumbs { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; min-width: 0; } +.code-crumb { + display: inline-flex; + align-items: center; + gap: 6px; + border: none; + background: transparent; + cursor: pointer; + color: var(--gs-accent-ink, var(--gs-accent)); font-family: inherit; font-size: 13px; - line-height: 1.5; - outline: none; -} -.dc-message:focus { - border-color: color-mix(in srgb, var(--gs-accent) 55%, var(--app-border)); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--gs-accent) 22%, transparent); + font-weight: 600; + padding: 2px 5px; + border-radius: 6px; } -.dc-branch { - display: flex; +.code-crumb:hover:not(.is-current) { background: var(--app-hover); } +.code-crumb:active:not(.is-current) { transform: translateY(0.5px); } +.code-crumb.is-current { color: var(--vscode-foreground); cursor: default; } +.code-crumb .glyph { color: var(--app-muted); } +.code-crumb.is-current .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } +.code-crumb-sep { color: var(--app-muted); opacity: 0.55; } +/* Folder / file count chip in the Code head — a quiet at-a-glance summary. */ +.code-count { + display: inline-flex; align-items: center; - gap: 6px; - font-size: var(--text-sm); + gap: 5px; + height: 22px; + padding: 0 9px; + border: 1px solid var(--app-border); + border-radius: 999px; + background: var(--app-elevated); + font-size: 11.5px; color: var(--app-muted); -} -.dc-branch .glyph { color: var(--gs-accent-ink, var(--gs-accent)); align-self: center; } -.dc-branch-name { font-weight: 650; color: var(--vscode-foreground); } -.dc-branch-sum { font-variant-numeric: tabular-nums; - color: var(--app-muted); + white-space: nowrap; } -.dc-commit-row { display: flex; gap: 8px; } -.dc-commit { height: 30px; } -/* The primary stays dominant but is capped so it reads as a confident button, - not a full-width banner across a wide composer; the secondary keeps presence. */ -.dc-commit-row .dc-commit:first-child { flex: 1 1 auto; max-width: 420px; } -/* Actions row: commit options on the left, the commit buttons up on the right. */ -.dc-actions { +.code-count[hidden] { display: none; } +.code-count .glyph .codicon { font-size: 12px; color: var(--app-muted); } + +/* Cap + center the repo listing on the wide measure so the file table stays + comfortable on ultra-wide windows instead of stretching edge to edge. */ +/* The view takes focus so its own keys ("/" for the filter, Backspace for the + parent folder) actually reach it — but it is a container, not a control, so + it must not draw a focus ring around the whole page. Rows and inputs inside + keep theirs. */ +.code-view:focus { outline: none; } + +.code-scroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 12px max(16px, calc((100% - var(--measure-wide)) / 2)) calc(28px + var(--dock-reserve, 0px)); } + +/* The GitHub-style "latest commit" bar that crowns the repo-root listing. */ +.code-latest-slot:empty { display: none; } +.code-latest { display: flex; align-items: center; - justify-content: space-between; - gap: 10px 16px; - flex-wrap: wrap; -} -.dc-actions .dc-options { flex: 1 1 auto; margin: 0; } -.dc-actions .dc-commit-row { flex: 0 0 auto; } -/* On the right, the buttons take their natural width (no full-width stretch). */ -.dc-actions .dc-commit-row .dc-commit:first-child { flex: 0 0 auto; max-width: none; } -.dc-push { - flex: 0 0 auto; - min-width: 132px; - background: var(--app-elevated); - border: 1px solid var(--app-border); - color: var(--vscode-foreground); + gap: 10px; + padding: 10px 14px; + border-bottom: 1px solid var(--app-border); + background: linear-gradient(180deg, + color-mix(in srgb, var(--app-elevated) 55%, var(--app-panel)), + var(--app-panel)); + animation: welcome-rise 360ms var(--ease-out) both; } -.dc-push:hover { background: var(--app-hover); border-color: color-mix(in srgb, var(--gs-accent) 40%, var(--app-border)); } -.dc-push .glyph { color: var(--app-muted); } -.dc-toolbar { +.code-latest-av { flex: 0 0 auto; - display: flex; - align-items: center; - gap: 8px; - padding: 8px 14px; - border-bottom: 1px solid var(--app-border); -} -.dc-toolbar-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; font-weight: 600; color: var(--app-muted); } -.dc-body { flex: 1 1 auto; min-height: 0; display: flex; } -.dc-lists { - flex: 0 0 340px; - min-width: 220px; - max-width: 680px; - overflow-y: auto; - padding: 4px 8px 8px; -} -/* The changes file-list divider reuses the compare vsplit look. */ -.dc-vsplit { border-left: 1px solid var(--app-border); } -.dc-body .diff-surface { flex: 1 1 auto; min-width: 0; } - -/* File-row hover actions (Stage / Unstage / Discard) reveal on the Changes rows, - which are .file-row/.dc-file — the shared reveal rule only covered .list-row, - so these were invisible except on keyboard focus. */ -.dc-file:hover .row-actions, -.dc-file:focus-within .row-actions { opacity: 1; } - -/* ── GitHub views (PRs / Issues / Projects) ────────────────────────────────── */ -.gh-connect { - flex: 1 1 auto; - display: flex; - flex-direction: column; + display: inline-flex; align-items: center; justify-content: center; - gap: 10px; - padding: 40px; - text-align: center; - background: - radial-gradient(720px 320px at 50% 12%, - color-mix(in srgb, var(--gs-accent) 9%, transparent), transparent 70%), - var(--app-bg); + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--av, var(--gs-accent)); + /* The ink is chosen per tile from the hue's own luminance — see avatarInk. */ + color: var(--av-ink, #ffffff); + color: #fff; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.01em; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16); } -.gh-pat { - width: min(420px, 80%); - height: 34px; - padding: 0 12px; - border-radius: 9px; - border: 1px solid var(--app-border); - background: var(--app-elevated); - color: var(--vscode-foreground); - font-family: var(--vscode-editor-font-family); - font-size: 13px; - outline: none; +.code-latest-meta { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; + flex: 1 1 auto; } -.gh-pat:focus { border-color: color-mix(in srgb, var(--gs-accent) 55%, var(--app-border)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--gs-accent) 22%, transparent); } -.gh-connect-btn { margin-top: 2px; } -.gh-err { color: var(--status-del); font-size: 12.5px; min-height: 16px; } -.gh-link { - display: inline-flex; align-items: center; gap: 6px; - border: none; background: none; cursor: pointer; - color: var(--gs-accent-ink, var(--gs-accent)); font-size: 12.5px; font-family: inherit; +.code-latest-author { font-size: var(--text-sm); font-weight: 650; color: var(--vscode-foreground); flex: 0 0 auto; } +.code-latest-subject { + font-size: var(--text-sm); + color: var(--app-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; } -.gh-link:hover { text-decoration: underline; } -.gh-link .codicon { font-size: 13px; } - -/* Sign in with GitHub (device flow) */ -.gh-signin-btn { margin-top: 6px; } -.gh-signin-btn .glyph .codicon { font-size: 17px; } -.gh-flow { width: min(440px, 86%); display: flex; flex-direction: column; align-items: center; } -.gh-flow:empty { display: none; } -.gh-device { - width: 100%; - display: flex; - flex-direction: column; +.code-latest-sha { + display: inline-flex; align-items: center; - gap: 13px; - margin-top: 6px; - padding: 20px; - border-radius: 13px; + gap: 5px; + flex: 0 0 auto; + height: 22px; + padding: 0 8px; border: 1px solid var(--app-border); + border-radius: 7px; background: var(--app-elevated); - box-shadow: var(--gs-shadow-1, 0 6px 22px rgba(0, 0, 0, 0.28)); -} -.gh-device-step { font-size: 12.5px; color: var(--app-muted); } -.gh-device-step b { color: var(--vscode-foreground); font-weight: 600; } -.gh-device-code-row { display: flex; align-items: center; gap: 8px; } -.gh-device-code { + color: var(--gs-accent-ink, var(--gs-accent)); font-family: var(--vscode-editor-font-family, ui-monospace, monospace); - font-size: 26px; - font-weight: 700; - letter-spacing: 0.16em; - color: var(--vscode-foreground); - padding: 6px 12px; - border-radius: 9px; - background: var(--app-panel); - border: 1px solid var(--app-border); + font-size: 11.5px; + font-weight: 600; + cursor: pointer; + transition: background var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease); } -.gh-device-copy { color: var(--app-muted); } -.gh-device-open { margin-top: 2px; } -.gh-device-status { - display: flex; align-items: center; gap: 9px; - font-size: 12.5px; color: var(--app-muted); +.code-latest-sha:hover { background: var(--app-hover); border-color: var(--accent-line); } +.code-latest-sha .glyph .codicon { font-size: 13px; } +.code-latest-when { flex: 0 0 auto; font-size: var(--text-xs); color: var(--app-muted); } +.code-latest-count { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + padding-left: 12px; + margin-left: 2px; + border-left: 1px solid var(--app-border); + font-size: var(--text-xs); + font-weight: 600; + color: var(--app-muted); + font-variant-numeric: tabular-nums; } -.gh-device-status .spinner { width: 15px; height: 15px; border-width: 2px; } -.gh-device-status.gh-device-failed { color: var(--vscode-errorForeground, #e15a5a); } -.gh-device-status.gh-device-failed .spinner { display: none; } - -/* Advanced: PAT fallback */ -.gh-adv-toggle { margin-top: 4px; opacity: 0.85; } -.gh-adv { - display: flex; flex-direction: column; align-items: center; gap: 8px; - width: min(440px, 86%); - margin-top: 4px; - padding-top: 12px; - border-top: 1px solid var(--app-border); +.code-latest-count .glyph .codicon { font-size: 14px; color: var(--gs-accent-ink, var(--gs-accent)); } +/* The connected file card: latest-commit header + column header + rows share + one bordered, rounded surface — github.com's directory table. */ +.code-filecard { + border: 1px solid var(--app-border); + border-radius: 12px; + overflow: hidden; + background: var(--app-panel); + box-shadow: var(--sheen), var(--shadow-sm); } -.gh-adv.hidden { display: none; } - -/* ── Settings view ──────────────────────────────────────────────────────────── */ -.nav-spacer { flex: 1 1 auto; } -.settings-view { - flex: 1 1 auto; - min-height: 0; +.code-listing { background: transparent; } +/* Thin column header above the rows — labels the Name / Size columns so the + listing reads as a deliberate table, not a loose stack of file links. */ +.code-listing-head { display: flex; - flex-direction: column; - background: var(--app-bg); -} -.settings-head { - flex: 0 0 auto; - /* Span the full pane so the bottom border runs edge-to-edge, but pad the title - in with the same centering math as the scroll body below — so the title sits - directly above the card column instead of floating in a left gutter. */ - padding: 16px max(22px, calc((100% - var(--measure-read)) / 2)); + align-items: center; + padding: 7px 12px; border-bottom: 1px solid var(--app-border); + background: color-mix(in srgb, var(--app-elevated) 55%, var(--app-panel)); + font-size: 11px; + font-weight: 650; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--app-muted); } -.settings-title { font-size: 19px; font-weight: 700; color: var(--vscode-foreground); } -.settings-scroll { - /* The scroll viewport must span the WHOLE pane — otherwise the wheel only - scrolls over a narrow middle column and the scrollbar floats mid-pane. We - keep the cards on the reading measure by centering them with horizontal - padding (same trick as .code-scroll) rather than by capping this element. */ - flex: 1 1 auto; - min-height: 0; - overflow-y: auto; - padding: 20px max(22px, calc((100% - var(--measure-read)) / 2)) 40px; - display: flex; - flex-direction: column; - gap: 16px; +.code-listing-head[hidden] { display: none; } +.code-col-name { flex: 1 1 auto; min-width: 0; } +.code-col-size { flex: 0 0 auto; } +.code-row { + border-radius: 0; + padding: 8px 12px; + border-bottom: 1px solid var(--app-border); } -.settings-card { - /* Never let the scroll column's flex-shrink compress a card — that clipped - content (the theme switcher, card bottoms) under `overflow:hidden`. */ - flex: 0 0 auto; +.code-up .file-path { color: var(--app-muted); } +.code-listing .code-row:last-child { border-bottom: none; } +/* These rows are buttons — give them real hover/press feedback so the file tree + doesn't read as dead/unclickable (it had only a focus ring before). */ +.code-row { transition: background var(--dur-1) var(--ease); } +.code-row:hover { background: var(--app-hover); } +.code-row:active { background: var(--app-active); } +.code-row .glyph { color: var(--gs-accent-ink, var(--gs-accent)); flex: 0 0 auto; } +.code-row .file-path { font-family: inherit; font-size: 13px; } + +.code-readme { margin-top: 18px; } +.code-readme-card { border: 1px solid var(--app-border); - border-radius: 14px; + border-radius: 12px; background: var(--app-panel); box-shadow: var(--sheen), var(--shadow-sm); overflow: hidden; } -/* Buttons placed straight in a card body shouldn't stretch full-width into an - ugly bar — the body is a flex column (align-items:stretch by default). */ -.settings-card-body > .btn, -.settings-card-body > .btn-primary, -.settings-card-body > .btn-danger { align-self: flex-start; } -.settings-card-head { +.code-readme-head { display: flex; align-items: center; - gap: 9px; - padding: 13px 16px; + gap: 8px; + padding: 10px 14px; border-bottom: 1px solid var(--app-border); + font-size: 11.5px; + font-weight: 650; + color: var(--app-muted); + text-transform: uppercase; + letter-spacing: 0.05em; } -.settings-card-head .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } -.settings-card-head .codicon { font-size: 16px; } -.settings-card-title { font-size: 13.5px; font-weight: 650; color: var(--vscode-foreground); } -.settings-card-body { - display: flex; - flex-direction: column; - gap: 12px; - padding: 16px; +.code-readme-head .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } + +/* Rendered README markdown (GitHub-ish typography). */ +/* README surface layout — the prose rules themselves live in THE prose system + (see ".gh-body-md, .code-md" further down), so a README here and a PR body + there can never drift apart again. */ +.code-md { + padding: 24px 28px; + max-width: 920px; } -.settings-sub { font-size: 12.5px; line-height: 1.5; color: var(--app-muted); } -.settings-warn { - color: var(--vscode-gitDecoration-modifiedResourceForeground, #c89b3c); +.code-md-plain { + white-space: pre-wrap; + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-size: 12.5px; } -.settings-empty { font-size: 12.5px; color: var(--app-muted); padding: 4px 0; } -/* Segmented theme control */ -.settings-seg { +/* File viewer (read-only Monaco over the listing). Its header is the same + breadcrumb the tree header uses, so the last crumb — the file itself — reads + as the subject and the ones before it walk back up the tree. */ +.code-file-crumbs { min-width: 0; overflow: hidden; } +.code-file-crumbs .code-crumb.is-current { + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); +} +/* Monaco fills this and paints its own scrollbar, so the reserve goes on the + surface itself — the dock overlays the bottom of the view and does not + reflow it, which left the last lines of every file behind an open terminal. */ +.code-file-surface { + flex: 1 1 auto; + min-height: 0; + position: relative; + padding-bottom: var(--dock-reserve, 0px); +} + +/* ── Compare view ──────────────────────────────────────────────────────────── */ +.compare-view { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + background: var(--app-bg); +} +.compare-bar { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 8px; + padding: 12px 14px; + border-bottom: 1px solid var(--app-border); + flex-wrap: wrap; +} +.compare-lbl { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--app-muted); } +.ref-pick { display: inline-flex; - align-self: flex-start; + align-items: center; + gap: 6px; + height: 30px; + padding: 0 10px; + max-width: 260px; + border-radius: 8px; border: 1px solid var(--app-border); - border-radius: 9px; + background: var(--app-elevated); + color: var(--vscode-foreground); + font-family: inherit; + font-size: 12.5px; + font-weight: 600; + cursor: pointer; +} +.ref-pick:hover { background: var(--app-hover); border-color: color-mix(in srgb, var(--gs-accent) 40%, var(--app-border)); } +.ref-pick:active { transform: translateY(0.5px); } +.ref-pick .glyph:first-child { color: var(--gs-accent-ink, var(--gs-accent)); } +.ref-pick .glyph:last-child { color: var(--app-muted); } +.ref-pick span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.cmp-mode { + display: inline-flex; + margin-left: 4px; + border: 1px solid var(--app-border); + border-radius: 8px; overflow: hidden; } -.settings-seg-btn { - padding: 7px 16px; +.cmp-mode-btn { + padding: 0 12px; + height: 28px; border: none; background: var(--app-elevated); color: var(--app-muted); font-family: inherit; - font-size: 12.5px; + font-size: 12px; font-weight: 550; + white-space: nowrap; cursor: pointer; transition: background var(--dur-1) var(--ease), color var(--dur-1) var(--ease), transform var(--dur-1) var(--ease); } -.settings-seg-btn + .settings-seg-btn { border-left: 1px solid var(--app-border); } -.settings-seg-btn:hover { background: var(--app-hover); } -.settings-seg-btn:active { transform: translateY(0.5px); } -.settings-seg-btn.active { +.cmp-mode-btn + .cmp-mode-btn { border-left: 1px solid var(--app-border); } +.cmp-mode-btn:hover { background: var(--app-hover); } +.cmp-mode-btn:active { transform: translateY(0.5px); } +.cmp-mode-btn.active { background: color-mix(in srgb, var(--gs-accent) 18%, transparent); color: var(--gs-accent-ink, var(--gs-accent)); } - -/* App-icon control: segmented picker with a small live preview of the mark. */ -.settings-logo-row { display: flex; align-items: center; gap: 12px; } -.settings-logo-preview { - width: 44px; - height: 44px; - border-radius: 10px; - border: 1px solid var(--app-border); - background: var(--app-elevated); - object-fit: contain; +/* The Commits | Changed files toggle row + summary. */ +.cmp-viewbar { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 12px; + padding: 8px 14px; + border-bottom: 1px solid var(--app-border); } - -/* Account */ -.settings-account-who { display: flex; align-items: center; gap: 9px; } -.settings-account-who .codicon { font-size: 18px; } -.settings-account-name { font-size: 14px; font-weight: 650; color: var(--vscode-foreground); } -.settings-actions { display: flex; gap: 8px; flex-wrap: wrap; } -.mini-btn.danger { color: var(--vscode-errorForeground, #e15a5a); } -.mini-btn.danger:hover { border-color: color-mix(in srgb, var(--vscode-errorForeground, #e15a5a) 45%, var(--app-border)); } - -/* Fields */ -.settings-field { display: flex; flex-direction: column; gap: 5px; max-width: 380px; } -.settings-field-label { font-size: 11.5px; font-weight: 600; color: var(--app-muted); } -.settings-input { - height: 32px; - padding: 0 11px; - border-radius: 8px; +.cmp-seg { + display: inline-flex; + /* Same reason as .gh-seg: clipped, not compressed. Below ~1100px the active + tab read "Changed fil" and lost its count, while the summary sentence + beside it — which wraps perfectly well — kept all the room it wanted. */ + flex: 0 0 auto; border: 1px solid var(--app-border); - background: var(--app-elevated); - color: var(--vscode-foreground); - font-family: inherit; - font-size: 13px; - outline: none; -} -.settings-input:focus { - border-color: color-mix(in srgb, var(--gs-accent) 55%, var(--app-border)); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--gs-accent) 22%, transparent); + border-radius: 8px; + overflow: hidden; } -.settings-save { align-self: flex-start; margin-top: 2px; } - -/* SSH keys */ -.settings-keys { display: flex; flex-direction: column; gap: 6px; } -.settings-key { - display: flex; +.cmp-seg-btn { + display: inline-flex; align-items: center; - gap: 10px; - padding: 9px 11px; - border: 1px solid var(--app-border); - border-radius: 9px; + gap: 7px; + height: 30px; + padding: 0 12px; + border: none; background: var(--app-elevated); -} -.settings-key > .glyph { color: var(--gs-accent-ink, var(--gs-accent)); flex: 0 0 auto; } -.settings-key-meta { flex: 1 1 auto; min-width: 0; } -.settings-key-file { font-size: 13px; font-weight: 600; color: var(--vscode-foreground); } -.settings-key-sub { - font-size: 11.5px; color: var(--app-muted); - font-family: var(--vscode-editor-font-family, ui-monospace, monospace); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + font-family: inherit; + font-size: 12.5px; + /* The selected weight, worn by every tab: see .explore-tab. */ + font-weight: 650; + cursor: pointer; } - -.gh-view { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; background: var(--app-bg); } -.gh-acct { display: flex; align-items: center; gap: 8px; } -.gh-who { font-size: 12px; color: var(--app-muted); } -.gh-body { flex: 1 1 auto; min-height: 0; display: flex; } -.gh-list { - flex: 0 0 40%; - min-width: 280px; - max-width: 560px; - overflow-y: auto; - padding: 6px; - border-right: 1px solid var(--app-border); +.cmp-seg-btn + .cmp-seg-btn { border-left: 1px solid var(--app-border); } +.cmp-seg-btn .glyph .codicon { font-size: 15px; } +.cmp-seg-btn:hover { background: var(--app-hover); } +.cmp-seg-btn:active { transform: translateY(0.5px); } +.cmp-seg-btn.active { + background: color-mix(in srgb, var(--gs-accent) 18%, transparent); + color: var(--gs-accent-ink, var(--gs-accent)); } -.gh-row { - display: flex; - flex-direction: column; - gap: 2px; - width: 100%; - text-align: left; - padding: 9px 10px; - border: 1px solid transparent; +/* An empty badge rendered as a grey dot, so the tab strip looked like it had + two unread markers. */ +.cmp-seg-count:empty { display: none; } +.cmp-seg-count { + min-width: 18px; + padding: 0 6px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; border-radius: 9px; - background: transparent; + background: color-mix(in srgb, var(--app-muted) 22%, transparent); color: var(--vscode-foreground); - font-family: inherit; - cursor: pointer; - transition: background 110ms; + font-size: 11px; + font-variant-numeric: tabular-nums; } -.gh-row:hover { background: var(--app-hover); } -.gh-row.active { background: var(--app-active); border-color: color-mix(in srgb, var(--gs-accent) 35%, transparent); } -.gh-row-title { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.gh-row-sub { font-size: 11px; color: var(--app-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.gh-pill { - align-self: flex-start; - margin-top: 3px; - font-size: var(--text-2xs); - font-weight: 600; - padding: 1px 7px; - border-radius: 999px; - color: var(--app-muted); - background: color-mix(in srgb, var(--app-muted) 18%, transparent); +/* The ACTIVE segment's badge takes the accent. The `.active` qualifier used to + sit before a comment that opened a selector list, which glued it to the next + selector as a descendant — so every badge got the accent and the active one + was indistinguishable. Fourth time a comment beside a selector has eaten a + qualifier in this file; `test/stylesheet.test.ts` now refuses the shape. */ +.cmp-seg-btn.active .cmp-seg-count { + background: color-mix(in srgb, var(--gs-accent) 30%, transparent); } -/* The detail pane is already width-constrained by the master/detail split; the - prose body (.gh-body-md) caps itself at --measure-read, so the pane just needs - comfortable padding (percentage-centering here resolves against the WRONG box). */ -.gh-detail { flex: 1 1 auto; min-width: 0; overflow-y: auto; padding: 18px 22px; } +.cmp-summary { font-size: 12.5px; color: var(--app-muted); } -/* ── Issue peek drawer (Projects board → read an issue without leaving) ──────── */ -.gh-drawer-scrim { - position: fixed; - inset: 0; - z-index: 2000; - display: flex; - justify-content: flex-end; - background: color-mix(in srgb, var(--app-bg) 55%, transparent); - backdrop-filter: blur(4px) saturate(1.1); - -webkit-backdrop-filter: blur(4px) saturate(1.1); - -webkit-app-region: no-drag; - opacity: 0; - transition: opacity 160ms var(--ease); -} -.gh-drawer-scrim.is-open { opacity: 1; } -.gh-drawer { +/* The body host that swaps between the commits list and the files split. */ +.cmp-body { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; } + +/* + * `.compare-commit` / `.cc-subject` / `.cc-meta` / `.cmp-commits` lived here: + * a subject and one grey line reading "author · sha · 3h ago", drawn twice by + * two renderers that had drifted apart. Both surfaces use `commitList()` and + * the `.clist-*` rules now — see "A list of commits" further down. + */ + + +/* Changed-files master/detail: file list (left) + native diff (right). */ +.cmp-split { flex: 1 1 auto; min-height: 0; display: flex; } +.cmp-filelist { display: flex; flex-direction: column; - width: min(760px, 94vw); - height: 100%; - min-height: 0; - background: var(--app-bg); - border-left: 1px solid var(--app-border); - box-shadow: var(--shadow-pop); - transform: translateX(28px); - transition: transform 200ms var(--ease-out); -} -.gh-drawer-scrim.is-open .gh-drawer { transform: translateX(0); } -@media (prefers-reduced-motion: reduce) { - .gh-drawer { transition: none; transform: none; } + min-width: 0; + border-right: 1px solid var(--app-border); + background: var(--app-panel); } -.gh-drawer-head { +.cmp-filelist-head { flex: 0 0 auto; display: flex; align-items: center; - gap: 10px; - padding: 10px 14px; + justify-content: space-between; + height: 34px; + padding: 0 6px 0 12px; border-bottom: 1px solid var(--app-border); - background: var(--app-panel); } -.gh-drawer-eyebrow { - display: inline-flex; - align-items: center; - gap: 7px; - font-size: 12.5px; +.cmp-filelist-title { + font-size: 10.5px; font-weight: 650; - color: var(--app-muted); + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--app-muted); } -.gh-drawer-eyebrow .glyph .codicon { color: var(--gs-accent-ink, var(--gs-accent)); } -.gh-drawer-actions { margin-left: auto; display: inline-flex; align-items: center; gap: 8px; } -.gh-drawer-close { +.cmp-collapse, .cmp-restore { display: inline-flex; align-items: center; justify-content: center; - width: 30px; - height: 30px; - border-radius: 8px; - border: 1px solid transparent; + width: 24px; + height: 24px; + border: none; + border-radius: 6px; background: transparent; color: var(--app-muted); cursor: pointer; - transition: background var(--dur-1) var(--ease), color var(--dur-1) var(--ease), - border-color var(--dur-1) var(--ease); -} -.gh-drawer-close:hover { background: var(--app-hover); color: var(--vscode-foreground); border-color: var(--app-border); } -.gh-drawer-close .codicon { font-size: 15px; } -.gh-drawer-body { flex: 1 1 auto; min-height: 0; } -.gh-detail-head { display: flex; flex-direction: column; gap: 7px; padding-bottom: 12px; border-bottom: 1px solid var(--app-border); } -.gh-detail-title { font-size: 18px; font-weight: 650; color: var(--vscode-foreground); } -.gh-detail-meta { font-size: 12px; color: var(--app-muted); display: flex; align-items: center; flex-wrap: wrap; } -.gh-detail-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; row-gap: 8px; margin-top: 6px; } -.gh-detail-actions > * { flex-shrink: 0; } -.gh-merge-btn { height: 30px; padding: 0 14px; } -.gh-checks-success { color: var(--status-add); background: color-mix(in srgb, var(--status-add) 16%, transparent); } -.gh-checks-failure, .gh-checks-error { color: var(--status-del); background: color-mix(in srgb, var(--status-del) 16%, transparent); } -.gh-checks-pending { color: var(--status-mod); background: color-mix(in srgb, var(--status-mod) 16%, transparent); } -.gh-body-md { - margin: 14px 0; - font-size: 13px; - line-height: 1.6; - color: var(--vscode-foreground); - max-width: var(--measure-read); - word-wrap: break-word; } -/* renderMarkdown emits block tags — give them the SAME tight rhythm as .code-md - (NO white-space:pre-wrap, which double-spaced every block by preserving the - inter-block newlines on top of the margins). */ -.gh-body-md > :first-child { margin-top: 0; } -.gh-body-md > :last-child { margin-bottom: 0; } -.gh-body-md h1, .gh-body-md h2, .gh-body-md h3, -.gh-body-md h4, .gh-body-md h5, .gh-body-md h6 { - font-size: 1em; font-weight: 650; margin: 0.8em 0 0.3em; line-height: 1.35; -} -.gh-body-md h1 { font-size: 1.3em; } -.gh-body-md h2 { font-size: 1.15em; } -.gh-body-md p { margin: 0.5em 0; } -.gh-body-md ul, .gh-body-md ol { margin: 0.4em 0; padding-left: 1.4em; } -.gh-body-md li { margin: 0.15em 0; } -.gh-body-md code { - font-family: var(--vscode-editor-font-family, ui-monospace, monospace); - font-size: 0.88em; background: var(--app-elevated); - border: 1px solid var(--app-border); border-radius: 5px; padding: 1px 5px; +.cmp-collapse:hover, .cmp-restore:hover { background: var(--app-hover); color: var(--vscode-foreground); } +.cmp-file-scroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 4px; } +.cmp-vsplit { + flex: 0 0 auto; + width: 7px; + margin: 0 -3px; + z-index: 2; + cursor: col-resize; + display: flex; + align-items: center; + justify-content: center; } -.gh-body-md pre { - margin: 0.7em 0; padding: 12px 14px; overflow-x: auto; - background: var(--app-elevated); border: 1px solid var(--app-border); border-radius: 8px; +.cmp-vsplit-grip { + width: 1px; + height: 100%; + background: transparent; + transition: background 100ms; } -.gh-body-md pre code { background: none; border: none; padding: 0; } -.gh-body-md blockquote { - margin: 0.6em 0; padding: 0.1em 1em; - border-left: 3px solid color-mix(in srgb, var(--gs-accent) 22%, var(--app-border)); - background: color-mix(in srgb, var(--app-elevated) 50%, transparent); - color: var(--app-muted); +.cmp-vsplit:hover .cmp-vsplit-grip, +body.resizing-h .cmp-vsplit-grip { background: var(--gs-accent); width: 2px; } +.cmp-diffpane { flex: 1 1 auto; min-width: 0; min-height: 0; position: relative; } +/* Compare mounts the SHARED DiffPanel now, so the surface inside this pane is + `.diffmode-wrap`, not the old `.cmp-diff-editor` — which stopped matching + anything the moment the panel changed, taking the fill and the drag guard + below with it. */ +.cmp-diffpane > .diffmode-wrap { position: absolute; inset: 0; } +/* The restore rail only shows once the file list is collapsed away. */ +.cmp-restore { + flex: 0 0 auto; + order: -1; + width: 26px; + height: auto; + border-radius: 0; + border-right: 1px solid var(--app-border); + background: var(--app-panel); + display: none; } -.gh-body-md a { color: var(--gs-accent-ink, var(--gs-accent)); text-decoration: none; } -.gh-body-md a:hover { text-decoration: underline; } -.gh-body-md hr { border: none; border-top: 1px solid var(--app-border); margin: 1.2em 0; } -.gh-files .file-row { cursor: default; } -.gh-adds { font-family: var(--vscode-editor-font-family); font-size: 11px; color: var(--app-muted); margin-left: auto; } +.cmp-split.files-collapsed .cmp-filelist, +.cmp-split.files-collapsed .cmp-vsplit { display: none; } +.cmp-split.files-collapsed .cmp-restore { display: flex; align-items: flex-start; padding-top: 8px; } +body.resizing-h { cursor: col-resize; user-select: none; } +/* While the divider is being dragged the editor must not take the pointer, or + the drag turns into a text selection inside Monaco. Same rule, aimed at what + Compare actually renders. */ +body.resizing-h .cmp-diffpane .diffmode-body, +body.resizing-h .cmp-diffpane .monaco-editor { pointer-events: none; } -/* PR detail sub-tabs (Conversation / Commits / Pipelines / Files). */ -.gh-subtabs { +/* ── Changes view (desktop working tree) ───────────────────────────────────── */ +.changes-view { + flex: 1 1 auto; + min-height: 0; display: flex; - gap: 2px; - margin: 14px 0 12px; - border-bottom: 1px solid var(--app-border); + flex-direction: column; + background: var(--app-bg); } -.gh-subtab { - display: inline-flex; +.dc-composer { + flex: 0 0 auto; + display: flex; + flex-direction: column; + gap: 8px; + /* Contained card materiality (matches .settings-card) so the composer reads as + a deliberate surface, not full-bleed fields floating on the panel. */ + margin: 12px 14px 0; + padding: 12px 13px; + background: var(--app-panel); + border: 1px solid var(--app-border); + border-radius: 12px; + box-shadow: var(--sheen), var(--shadow-sm); +} +.dc-message { + width: 100%; + min-height: 46px; + max-height: 180px; + resize: vertical; + padding: 8px 11px; + border-radius: 9px; + border: 1px solid var(--app-border); + background: var(--app-elevated); + color: var(--vscode-foreground); + font-family: inherit; + font-size: 13px; + line-height: 1.5; + outline: none; +} +.dc-message:focus { + border-color: color-mix(in srgb, var(--gs-accent) 55%, var(--app-border)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--gs-accent) 22%, transparent); +} +.dc-branch { + display: flex; align-items: center; gap: 6px; - height: 34px; - padding: 0 12px; - border: none; - border-bottom: 2px solid transparent; - background: transparent; + font-size: var(--text-sm); color: var(--app-muted); - font-family: inherit; - font-size: 12.5px; - font-weight: 550; - cursor: pointer; } -.gh-subtab .glyph .codicon { font-size: 15px; } -.gh-subtab:hover { color: var(--vscode-foreground); } -.gh-subtab.active { color: var(--vscode-foreground); border-bottom-color: var(--gs-accent); font-weight: 650; } -.gh-subtab.active .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } -.gh-subcontent { display: flex; flex-direction: column; } -.gh-comment { +.dc-branch .glyph { color: var(--gs-accent-ink, var(--gs-accent)); align-self: center; } +.dc-branch-name { font-weight: 650; color: var(--vscode-foreground); } +.dc-branch-sum { + font-variant-numeric: tabular-nums; + color: var(--app-muted); +} +.dc-commit-row { display: flex; gap: 8px; } +.dc-commit { height: 30px; } +/* The primary stays dominant but is capped so it reads as a confident button, + not a full-width banner across a wide composer; the secondary keeps presence. */ +.dc-commit-row .dc-commit:first-child { flex: 1 1 auto; max-width: 420px; } +/* Actions row: commit options on the left, the commit buttons up on the right. */ +.dc-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px 16px; + flex-wrap: wrap; +} +.dc-actions .dc-options { flex: 1 1 auto; margin: 0; } +.dc-actions .dc-commit-row { flex: 0 0 auto; } +/* On the right, the buttons take their natural width (no full-width stretch). */ +.dc-actions .dc-commit-row .dc-commit:first-child { flex: 0 0 auto; max-width: none; } +.dc-push { + flex: 0 0 auto; + min-width: 132px; + background: var(--app-elevated); border: 1px solid var(--app-border); - border-radius: 10px; - margin-bottom: 10px; - overflow: hidden; + color: var(--vscode-foreground); } -.gh-comment-head { +.dc-push:hover { background: var(--app-hover); border-color: color-mix(in srgb, var(--gs-accent) 40%, var(--app-border)); } +.dc-push .glyph { color: var(--app-muted); } +.dc-toolbar { + flex: 0 0 auto; display: flex; align-items: center; gap: 8px; - padding: 8px 12px; - font-size: 12px; - font-weight: 600; - color: var(--vscode-foreground); - background: var(--app-panel); + /* Below ~1005px this row simply rendered past the window edge — no scrollbar, + no overflow menu, nothing. "Stage all" and "Stash", the two primary actions + of the view, were unreachable. Wrapping costs a row of height and keeps + every control on screen. */ + flex-wrap: wrap; + row-gap: 8px; + padding: 8px 14px; border-bottom: 1px solid var(--app-border); } -.gh-comment .gh-body-md { margin: 0; padding: 10px 12px; } -.gh-review-approved { color: var(--status-add); background: color-mix(in srgb, var(--status-add) 16%, transparent); } -.gh-review-changes_requested { color: var(--status-del); background: color-mix(in srgb, var(--status-del) 16%, transparent); } +.dc-toolbar > * { flex: 0 0 auto; } +.dc-toolbar-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; font-weight: 600; color: var(--app-muted); } +.dc-body { flex: 1 1 auto; min-height: 0; display: flex; } +.dc-lists { + flex: 0 0 340px; + min-width: 220px; + max-width: 680px; + overflow-y: auto; + padding: 4px 8px 8px; +} +/* The changes file-list divider reuses the compare vsplit look. */ +.dc-vsplit { border-left: 1px solid var(--app-border); } +.dc-body .diff-surface { flex: 1 1 auto; min-width: 0; } -/* Check / workflow-run rows. */ -.gh-check-row { - display: flex; - align-items: center; - gap: 10px; - padding: 9px 8px; - border-radius: 8px; - cursor: pointer; +/* File-row hover actions (Stage / Unstage / Discard) reveal on the Changes rows, + which are .file-row/.dc-file — the shared reveal rule only covered .list-row, + so these were invisible except on keyboard focus. */ +.dc-file:hover .row-actions, +.dc-file:focus-within .row-actions { opacity: 1; } +/* The M/A letters could not be scanned down the list: the staged and unstaged + groups reserved different amounts of space for their (invisible) hover + actions, so the two columns sat ~50px apart. Fix the slot in both. */ +/* FIXED, not min-width: staged rows carry one action (Unstage) and unstaged + two (Stage, Discard), so a minimum still let the two groups reserve + different widths and the status letters sat ~50px apart. */ +/* Sized for the WIDEST case — unstaged rows carry "Stage" + "Discard", staged + rows only "Unstage", so anything narrower either clipped the buttons on + hover or let the two groups reserve different widths (which is why the M/A + letters sat ~50px apart and could not be scanned as a column). */ +/* The actions OVERLAY the row's tail instead of reserving 140px of it. + Reserving was the right instinct — it is what stopped the staged and unstaged + groups putting their M/A letters in two different columns — but in a 320px + file list it left the FILENAME 40px, so every name in the checkbox model was + truncated to "com…" while 140px of invisible buttons sat beside it. The status + letter keeps its column because every row still ends at the same x; the + buttons appear over the tail on hover, where the pointer already is. */ +.dc-file { position: relative; } +.dc-file .row-actions { + position: absolute; + right: 9px; + top: 50%; + transform: translateY(-50%); + flex: 0 0 auto; + justify-content: flex-end; + padding-left: 18px; + /* A short fade so a long filename runs under the buttons rather than into + them. The row's own ground shows through the transparent end. */ + background: linear-gradient( + to right, + transparent, + var(--app-hover) 18px, + var(--app-hover) + ); +} +.dc-file:focus-within .row-actions { background: linear-gradient(to right, transparent, var(--app-active) 18px, var(--app-active)); } +/* Staged rows have no disclosure twisty, so their content started ~29px left + of the unstaged rows and the list had two left edges. The cell is always + there; only its ink is conditional. */ +.dc-hunk-twisty.is-spacer { visibility: hidden; pointer-events: none; } +.dc-file .file-status { + flex: 0 0 auto; + width: 18px; + text-align: center; + margin-left: auto; + font-variant-numeric: tabular-nums; } -.gh-check-row:hover { background: var(--app-hover); } -.gh-check-dot { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto; background: var(--app-muted); } -.gh-check-dot.gh-checks-success { background: var(--status-add); } -.gh-check-dot.gh-checks-failure, .gh-check-dot.gh-checks-error, .gh-check-dot.gh-checks-cancelled { background: var(--status-del); } -.gh-check-dot.gh-checks-in_progress, .gh-check-dot.gh-checks-queued, .gh-check-dot.gh-checks-pending { background: var(--status-mod); } -.gh-check-name { flex: 1 1 auto; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.gh-check-state { font-size: 11.5px; color: var(--app-muted); } -/* ── Glyphs — the real VS Code codicon font ───────────────────────────────────── - * The `codicon` @font-face is registered at document scope by the imported - * graph.css (esbuild inlines the .ttf as a data URL), so the desktop renderer - * uses the SAME icon font as the extension webviews. `glyph()` emits - * `<span class="glyph codicon codicon-NAME">`; `.glyph` owns the box + alignment - * and carries every accent/muted color rule, `.codicon` owns the font. */ -.glyph { - display: inline-flex; +/* ── GitHub views (PRs / Issues / Projects) ────────────────────────────────── */ +.gh-connect { + flex: 1 1 auto; + display: flex; + flex-direction: column; align-items: center; justify-content: center; -} -.codicon { - font: normal normal normal 16px/1 "codicon"; - text-rendering: auto; + gap: 10px; + padding: 40px; text-align: center; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - user-select: none; + background: + radial-gradient(720px 320px at 50% 12%, + color-mix(in srgb, var(--gs-accent) 9%, transparent), transparent 70%), + var(--app-bg); } -/* The desktop bundles Monaco (for the diff editor), which registers a GLOBAL - * `.codicon[class*=codicon-] { display: inline-block }` plus its own older, - * partial `codicon` @font-face. Both win the cascade in the built bundle and - * sabotage our icons: the inline-block kills our flex centering (every glyph is - * shoved upward in its tile) and the partial font drops newer glyphs. Pin OUR - * glyphs to the complete @vscode/codicons font under a private family and +.gh-pat { + width: min(420px, 80%); + height: 34px; + padding: 0 12px; + border-radius: 9px; + border: 1px solid var(--app-border); + background: var(--app-elevated); + color: var(--vscode-foreground); + font-family: var(--vscode-editor-font-family); + font-size: 13px; + outline: none; +} +.gh-pat:focus { border-color: color-mix(in srgb, var(--gs-accent) 55%, var(--app-border)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--gs-accent) 22%, transparent); } +.gh-connect-btn { margin-top: 2px; } +.gh-err { color: var(--status-del); font-size: 12.5px; min-height: 16px; } +.gh-link { + display: inline-flex; align-items: center; gap: 6px; + border: none; background: none; cursor: pointer; + color: var(--gs-accent-ink, var(--gs-accent)); font-size: 12.5px; font-family: inherit; +} +.gh-link:hover { text-decoration: underline; } +.gh-link .codicon { font-size: 13px; } + +/* Sign in with GitHub (device flow) */ +.gh-signin-btn { margin-top: 6px; } +.gh-signin-btn .glyph .codicon { font-size: 17px; } +.gh-flow { width: min(440px, 86%); display: flex; flex-direction: column; align-items: center; } +.gh-flow:empty { display: none; } +.gh-device { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: 13px; + margin-top: 6px; + padding: 20px; + border-radius: 13px; + border: 1px solid var(--app-border); + background: var(--app-elevated); + box-shadow: var(--gs-shadow-1, 0 6px 22px rgba(0, 0, 0, 0.28)); +} +.gh-device-step { font-size: 12.5px; color: var(--app-muted); } +.gh-device-step b { color: var(--vscode-foreground); font-weight: 600; } +.gh-device-code-row { display: flex; align-items: center; gap: 8px; } +.gh-device-code { + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-size: 26px; + font-weight: 700; + letter-spacing: 0.16em; + color: var(--vscode-foreground); + padding: 6px 12px; + border-radius: 9px; + background: var(--app-panel); + border: 1px solid var(--app-border); +} +.gh-device-copy { color: var(--app-muted); } +.gh-device-open { margin-top: 2px; } +.gh-device-status { + display: flex; align-items: center; gap: 9px; + font-size: 12.5px; color: var(--app-muted); +} +.gh-device-status .spinner { width: 15px; height: 15px; border-width: 2px; } +.gh-device-status.gh-device-failed { color: var(--vscode-errorForeground, #e15a5a); } +.gh-device-status.gh-device-failed .spinner { display: none; } + +/* Advanced: PAT fallback */ +.gh-adv-toggle { margin-top: 4px; opacity: 0.85; } +.gh-adv { + display: flex; flex-direction: column; align-items: center; gap: 8px; + width: min(440px, 86%); + margin-top: 4px; + padding-top: 12px; + border-top: 1px solid var(--app-border); +} +.gh-adv.hidden { display: none; } + +/* ── Settings view ──────────────────────────────────────────────────────────── */ +.nav-spacer { flex: 1 1 auto; } +.settings-view { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + background: var(--app-bg); +} +.settings-head { + flex: 0 0 auto; + /* Span the full pane so the bottom border runs edge-to-edge, but pad the title + in with the same centering math as the scroll body below — so the title sits + directly above the card column instead of floating in a left gutter. */ + padding: 16px max(22px, calc((100% - var(--measure-settings)) / 2)); + border-bottom: 1px solid var(--app-border); +} +.settings-title { font-size: 19px; font-weight: 700; color: var(--vscode-foreground); } +.settings-scroll { + /* The scroll viewport must span the WHOLE pane — otherwise the wheel only + scrolls over a narrow middle column and the scrollbar floats mid-pane. We + keep the cards on the reading measure by centering them with horizontal + padding (same trick as .code-scroll) rather than by capping this element. */ + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + /* The terminal dock is an overlay footer — it does not shrink this scroller. + So with the dock open the last card sat behind it with nowhere left to + scroll, and Settings' final section was simply unreachable. The dock + publishes its height as --dock-reserve (bottomDock.publishReserve). */ + padding: 20px max(22px, calc((100% - var(--measure-settings)) / 2)) + calc(40px + var(--dock-reserve, 0px)); + display: flex; + flex-direction: column; + gap: 16px; +} +.settings-card { + /* Never let the scroll column's flex-shrink compress a card — that clipped + content (the theme switcher, card bottoms) under `overflow:hidden`. */ + flex: 0 0 auto; + border: 1px solid var(--app-border); + border-radius: 14px; + background: var(--app-panel); + box-shadow: var(--sheen), var(--shadow-sm); + overflow: hidden; +} +/* Buttons placed straight in a card body shouldn't stretch full-width into an + ugly bar — the body is a flex column (align-items:stretch by default). */ +.settings-card-body > .btn, +.settings-card-body > .btn-primary, +.settings-card-body > .btn-danger, +/* …and a mini-btn, which now stands in for the bare text links these cards + used to mix in — stretched, it read as a full-width banner. */ +.settings-card-body > .mini-btn { align-self: flex-start; } +.settings-card-head { + display: flex; + align-items: center; + gap: 9px; + padding: 13px 16px; + border-bottom: 1px solid var(--app-border); +} +.settings-card-head .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } +.settings-card-head .codicon { font-size: 16px; } +.settings-card-title { font-size: 13.5px; font-weight: 650; color: var(--vscode-foreground); } +.settings-card-body { + display: flex; + flex-direction: column; + /* The card used to space EVERY child 12px apart, so "App icon" sat as far + from the sentence explaining it as that sentence sat from the control + above — a flat list with no groups in it. Related lines sit close; a new + field pushes off from the one before it. */ + gap: 6px; + padding: 16px; +} +/* Every control in a card body pushes off from the one before it. + A comment block was once pasted INTO the middle of this selector list, which + split it in two and handed the first five selectors the next rule's + declaration — `.repo-manager-card { width: min(720px, 92vw) }`. So every + segmented control, checkbox and field label in Settings was silently given a + 720px width (a bordered rail with 440px of empty space inside it), and lost + the margin this rule exists to give them. Same failure mode as the two + comments that once closed early inside a selector glob and killed `--sp-1` + app-wide: a selector list will happily swallow whatever declaration follows + it. Keep the list contiguous. */ +.settings-card-body > * + .settings-field-label, +.settings-card-body > * + .settings-seg, +.settings-card-body > * + .settings-check, +.settings-card-body > * + .settings-actions, +.settings-card-body > * + .settings-logo-row, +.settings-card-body > * + .settings-copies, +.settings-card-body > * + .settings-row, +.settings-card-body > * + .btn, +.settings-card-body > * + .btn-primary, +.settings-card-body > * + .btn-danger { margin-top: 10px; } + +/* The repository manager: the clone list as its own surface rather than a card + buried in preferences. Wide, because these rows carry a name, an owner, a + badge, a full path and an action cluster. */ +.repo-manager-card { width: min(var(--modal-lg), 92vw); } +.repo-manager-card .settings-copies { max-height: min(58vh, 520px); overflow-y: auto; } +.repo-manager-card .settings-sub { margin-bottom: var(--sp-2); } +/* …but a control that a label introduces stays with its label. */ +.settings-card-body > .settings-field-label + * { margin-top: 0; } +/* Prose keeps a reading measure even though the column is wider than one: + a settings paragraph at the full 1040px would run ~130 characters. */ +.settings-sub { + font-size: 12.5px; + line-height: 1.5; + color: var(--app-muted); + max-width: 68ch; +} +.settings-warn { + color: var(--vscode-gitDecoration-modifiedResourceForeground, #c89b3c); +} +.settings-empty { font-size: 12.5px; color: var(--app-muted); padding: 4px 0; } + +/* Segmented theme control */ +.settings-seg { + display: inline-flex; + align-self: flex-start; + max-width: 100%; + border: 1px solid var(--app-border); + border-radius: 9px; + overflow: hidden; +} +.settings-seg-btn { + padding: 7px 16px; + border: none; + background: var(--app-elevated); + color: var(--app-muted); + font-family: inherit; + font-size: 12.5px; + font-weight: 550; + cursor: pointer; + transition: background var(--dur-1) var(--ease), color var(--dur-1) var(--ease), transform var(--dur-1) var(--ease); +} +.settings-seg-btn + .settings-seg-btn { border-left: 1px solid var(--app-border); } +.settings-seg-btn:hover { background: var(--app-hover); } +.settings-seg-btn:active { transform: translateY(0.5px); } +.settings-seg-btn.active { + background: color-mix(in srgb, var(--gs-accent) 18%, transparent); + color: var(--gs-accent-ink, var(--gs-accent)); +} + +/* App-icon control: segmented picker with a small live preview of the mark. */ +.settings-logo-row { display: flex; align-items: center; gap: var(--sp-4); } +/* No border: a bordered box beside a bordered segmented control is a fourth + segment. A soft inset plinth says "this is a preview", not "press me". */ +.settings-logo-preview { + width: 44px; + height: 44px; + border-radius: var(--r-md); + background: color-mix(in srgb, var(--vscode-foreground) 6%, transparent); + padding: 4px; + object-fit: contain; + pointer-events: none; +} + +/* Account */ +.settings-account-who { display: flex; align-items: center; gap: 9px; } +.settings-account-who .codicon { font-size: 18px; } +.settings-account-name { font-size: 14px; font-weight: 650; color: var(--vscode-foreground); } +.settings-actions { display: flex; gap: 8px; flex-wrap: wrap; } +.mini-btn.danger { color: var(--vscode-errorForeground, #e15a5a); } +.mini-btn.danger:hover { border-color: color-mix(in srgb, var(--vscode-errorForeground, #e15a5a) 45%, var(--app-border)); } + +/* Fields */ +.settings-field { display: flex; flex-direction: column; gap: 5px; max-width: 380px; } +.settings-input { + height: 32px; + padding: 0 11px; + border-radius: 8px; + border: 1px solid var(--app-border); + background: var(--app-elevated); + color: var(--vscode-foreground); + font-family: inherit; + font-size: 13px; + outline: none; +} +.settings-input:focus { + border-color: color-mix(in srgb, var(--gs-accent) 55%, var(--app-border)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--gs-accent) 22%, transparent); +} +.settings-save { align-self: flex-start; margin-top: 2px; } + +/* SSH keys */ +.settings-keys { display: flex; flex-direction: column; gap: 6px; } +.settings-key { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 11px; + border: 1px solid var(--app-border); + border-radius: 9px; + background: var(--app-elevated); +} +.settings-key > .glyph { color: var(--gs-accent-ink, var(--gs-accent)); flex: 0 0 auto; } +.settings-key-meta { flex: 1 1 auto; min-width: 0; } +.settings-key-file { font-size: 13px; font-weight: 600; color: var(--vscode-foreground); } +.settings-key-sub { + font-size: 11.5px; + color: var(--app-muted); + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.gh-view { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; background: var(--app-bg); } +.gh-acct { display: flex; align-items: center; gap: 8px; } +.gh-who { font-size: 12px; color: var(--app-muted); } +.gh-row { + display: flex; + flex-direction: column; + gap: 2px; + width: 100%; + text-align: left; + padding: 9px 10px; + border: 1px solid transparent; + border-radius: 9px; + background: transparent; + color: var(--vscode-foreground); + font-family: inherit; + cursor: pointer; + transition: background 110ms; +} +.gh-row:hover { background: var(--app-hover); } +.gh-row.active { background: var(--app-active); border-color: color-mix(in srgb, var(--gs-accent) 35%, transparent); } +.gh-row-title { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.gh-row-sub { font-size: 11px; color: var(--app-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.gh-pill { + align-self: flex-start; + /* "attempt 2" broke between the word and the number below 1150px, doubling + the pill's height and pushing its whole row taller than its neighbours. */ + white-space: nowrap; + margin-top: 3px; + font-size: var(--text-2xs); + font-weight: 600; + padding: 1px 7px; + border-radius: 999px; + color: var(--app-muted); + background: color-mix(in srgb, var(--app-muted) 18%, transparent); +} +/* The detail pane is already width-constrained by the master/detail split; the + prose body (.gh-body-md) caps itself at --measure-read, so the pane just needs + comfortable padding (percentage-centering here resolves against the WRONG box). */ +/* NOT the drawer, which is fixed and already sits above the dock — see + `.gh-drawer-body`. Every other user of this class is a scrolling detail pane + whose last rows sat behind the dock. */ +.gh-detail { flex: 1 1 auto; min-width: 0; overflow-y: auto; padding: 18px 22px; } +.gh-detail:not(.gh-drawer-body) { padding-bottom: calc(18px + var(--dock-reserve, 0px)); } + +/* ── Issue peek drawer (Projects board → read an issue without leaving) ──────── */ +.gh-drawer-scrim { + position: fixed; + inset: 0; + z-index: 2000; + display: flex; + justify-content: flex-end; + background: color-mix(in srgb, var(--app-bg) 55%, transparent); + backdrop-filter: blur(4px) saturate(1.1); + -webkit-backdrop-filter: blur(4px) saturate(1.1); + -webkit-app-region: no-drag; + opacity: 0; + transition: opacity 160ms var(--ease); +} +.gh-drawer-scrim.is-open { opacity: 1; } +.gh-drawer { + display: flex; + flex-direction: column; + width: min(var(--modal-lg), 92vw); + height: 100%; + min-height: 0; + background: var(--app-bg); + border-left: 1px solid var(--app-border); + box-shadow: var(--shadow-pop); + transform: translateX(28px); + transition: transform 200ms var(--ease-out); +} +.gh-drawer-scrim.is-open .gh-drawer { transform: translateX(0); } +@media (prefers-reduced-motion: reduce) { + .gh-drawer { transition: none; transform: none; } +} +.gh-drawer-head { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-bottom: 1px solid var(--app-border); + background: var(--app-panel); +} +.gh-drawer-eyebrow { + display: inline-flex; + align-items: center; + gap: 7px; + font-size: 12.5px; + font-weight: 650; + color: var(--app-muted); +} +.gh-drawer-eyebrow .glyph .codicon { color: var(--gs-accent-ink, var(--gs-accent)); } +.gh-drawer-actions { margin-left: auto; display: inline-flex; align-items: center; gap: 8px; } +.gh-drawer-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: 8px; + border: 1px solid transparent; + background: transparent; + color: var(--app-muted); + cursor: pointer; + transition: background var(--dur-1) var(--ease), color var(--dur-1) var(--ease), + border-color var(--dur-1) var(--ease); +} +.gh-drawer-close:hover { background: var(--app-hover); color: var(--vscode-foreground); border-color: var(--app-border); } +.gh-drawer-close .codicon { font-size: 15px; } +.gh-drawer-body { flex: 1 1 auto; min-height: 0; } +.gh-detail-head { display: flex; flex-direction: column; gap: 7px; padding-bottom: 12px; border-bottom: 1px solid var(--app-border); } +.gh-detail-title { font-size: 18px; font-weight: 650; color: var(--vscode-foreground); } +.gh-detail-meta { font-size: 12px; color: var(--app-muted); display: flex; align-items: center; flex-wrap: wrap; gap: 4px 10px; } +/* The LIVE meta row: author opens the profile card; branches land in Branches. */ +.gh-meta-bit { flex: 0 0 auto; } +.gh-meta-author { + display: inline-flex; + align-items: center; + gap: 6px; + border: none; + background: none; + padding: 2px 6px 2px 3px; + margin: -2px 0; + border-radius: 999px; + color: var(--vscode-foreground); + font: inherit; + cursor: pointer; + transition: background var(--dur-1) var(--ease); +} +.gh-meta-author:hover { background: var(--app-hover); } +.gh-meta-flow { display: inline-flex; align-items: center; gap: 6px; } +.gh-meta-arrow { color: var(--app-muted); } +.gh-branch-chip { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 260px; + padding: 1px 8px; + border: 1px solid var(--app-border); + border-radius: 999px; + background: var(--app-panel); + color: var(--gs-accent-ink, var(--gs-accent)); + font-family: var(--vscode-editor-font-family); + font-size: 11.5px; + cursor: pointer; + transition: border-color var(--dur-1) var(--ease), background var(--dur-1) var(--ease); +} +.gh-branch-chip > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.gh-branch-chip .glyph { color: inherit; font-size: 12px; } +.gh-branch-chip:hover { + border-color: color-mix(in srgb, var(--gs-accent) 50%, var(--app-border)); + background: var(--accent-soft); +} +.gh-detail-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; row-gap: 8px; margin-top: 6px; } +.gh-detail-actions > * { flex-shrink: 0; } +.gh-merge-btn { height: 30px; padding: 0 14px; } +.gh-checks-success { color: var(--status-add); background: color-mix(in srgb, var(--status-add) 16%, transparent); } +.gh-checks-failure, .gh-checks-error { color: var(--status-del); background: color-mix(in srgb, var(--status-del) 16%, transparent); } +.gh-checks-pending { color: var(--status-mod); background: color-mix(in srgb, var(--status-mod) 16%, transparent); } +.gh-body-md { + margin: 14px 0; + max-width: var(--measure-read); +} +/* ── THE prose system ──────────────────────────────────────────────────────── + ONE reading experience for every rendered-markdown surface — README cards, + PR/issue bodies, comments, release notes, external items. There used to be + two divergent stylesheets (13px .gh-body-md, 14px .code-md, different + heading scales, tables/images/task-lists unstyled anywhere), which is why + the same README read WORSE here than on github.com. GitHub renders prose at + 16px/1.5 with a real heading hierarchy; this matches that reading comfort + inside a 13px-chrome app: 15px/1.65, full-scale headings with h1/h2 rules, + and every GFM construct styled. */ +.gh-body-md, .code-md { + font-size: 15px; + line-height: 1.65; + color: var(--vscode-foreground); + word-wrap: break-word; +} +.gh-body-md > :first-child, .code-md > :first-child { margin-top: 0; } +.gh-body-md > :last-child, .code-md > :last-child { margin-bottom: 0; } +.gh-body-md h1, .gh-body-md h2, .gh-body-md h3, .gh-body-md h4, .gh-body-md h5, .gh-body-md h6, +.code-md h1, .code-md h2, .code-md h3, .code-md h4, .code-md h5, .code-md h6 { + font-weight: 650; + margin: 1.1em 0 0.45em; + line-height: 1.3; +} +.gh-body-md h1, .code-md h1 { + font-size: 1.7em; + border-bottom: 1px solid var(--app-border); + padding-bottom: 0.25em; +} +.gh-body-md h2, .code-md h2 { + font-size: 1.35em; + border-bottom: 1px solid var(--app-border); + padding-bottom: 0.25em; +} +.gh-body-md h3, .code-md h3 { font-size: 1.17em; } +.gh-body-md h4, .code-md h4 { font-size: 1.02em; } +.gh-body-md h5, .code-md h5 { font-size: 0.95em; } +.gh-body-md h6, .code-md h6 { font-size: 0.9em; color: var(--app-muted); } +.gh-body-md p, .code-md p { margin: 0.6em 0; } +.gh-body-md ul, .gh-body-md ol, .code-md ul, .code-md ol { margin: 0.5em 0; padding-left: 1.7em; } +.gh-body-md li, .code-md li { margin: 0.2em 0; } +.gh-body-md li > p, .code-md li > p { margin: 0.25em 0; } +.gh-body-md code, .code-md code { + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-size: 0.85em; + background: color-mix(in srgb, var(--app-muted) 13%, transparent); + border-radius: 6px; + padding: 2px 6px; +} +.gh-body-md pre, .code-md pre { + margin: 0.8em 0; + padding: 14px 16px; + overflow-x: auto; + background: var(--app-panel); + border: 1px solid var(--app-border); + border-radius: 10px; + line-height: 1.5; +} +.gh-body-md pre code, .code-md pre code { + background: none; + border: none; + padding: 0; + font-size: 12.8px; +} +.gh-body-md blockquote, .code-md blockquote { + margin: 0.7em 0; + padding: 0.15em 1.1em; + border-left: 3px solid color-mix(in srgb, var(--gs-accent) 30%, var(--app-border)); + color: var(--app-muted); +} +.gh-body-md a, .code-md a { color: var(--gs-accent-ink, var(--gs-accent)); text-decoration: none; } +.gh-body-md a:hover, .code-md a:hover { text-decoration: underline; } +.gh-body-md hr, .code-md hr { border: none; border-top: 2px solid var(--app-border); margin: 1.6em 0; } +.gh-body-md img, .code-md img { + max-width: 100%; + height: auto; + border-radius: 8px; +} +/* GFM tables — bordered cells, header band, row zebra (github.com's table look). */ +.gh-body-md table, .code-md table { + border-collapse: collapse; + border-spacing: 0; + margin: 0.8em 0; + display: block; + max-width: 100%; + overflow-x: auto; + font-size: 0.93em; +} +.gh-body-md th, .gh-body-md td, .code-md th, .code-md td { + border: 1px solid var(--app-border); + padding: 6px 13px; +} +.gh-body-md th, .code-md th { font-weight: 650; background: var(--app-panel); } +.gh-body-md tbody tr:nth-child(2n), .code-md tbody tr:nth-child(2n) { + background: color-mix(in srgb, var(--app-panel) 55%, transparent); +} +.gh-body-md .md-center, .code-md .md-center { text-align: center; } +.gh-body-md .md-right, .code-md .md-right { text-align: right; } +.gh-body-md .md-left, .code-md .md-left { text-align: left; } +/* Task lists — real-looking checkboxes, no list bullets. */ +.gh-body-md ul.md-task-list, .code-md ul.md-task-list { list-style: none; padding-left: 0.4em; } +.gh-body-md .md-task, .code-md .md-task { + display: inline-flex; + align-items: center; + justify-content: center; + width: 15px; + height: 15px; + margin-right: 7px; + border: 1px solid var(--app-border); + border-radius: 4px; + background: var(--app-panel); + font-size: 10px; + line-height: 1; + color: transparent; + vertical-align: -2px; +} +.gh-body-md .md-task-done, .code-md .md-task-done { + color: #fff; + background: var(--gs-accent); + border-color: var(--gs-accent); +} +/* <details>/<summary> — GitHub's disclosure pattern, everywhere in READMEs. */ +.gh-body-md details, .code-md details { + margin: 0.6em 0; + border: 1px solid var(--app-border); + border-radius: 10px; + padding: 0 14px; + background: color-mix(in srgb, var(--app-panel) 55%, transparent); +} +.gh-body-md summary, .code-md summary { + cursor: pointer; + font-weight: 600; + padding: 9px 0; + user-select: none; +} +.gh-body-md details[open] > summary, .code-md details[open] > summary { + border-bottom: 1px solid var(--app-border); + margin-bottom: 8px; +} +.gh-body-md kbd, .code-md kbd { + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-size: 0.82em; + padding: 2px 6px; + border: 1px solid var(--app-border); + border-bottom-width: 2px; + border-radius: 6px; + background: var(--app-panel); +} +.gh-body-md mark, .code-md mark { + background: color-mix(in srgb, var(--status-warn) 30%, transparent); + color: inherit; + border-radius: 3px; + padding: 0 2px; +} +.gh-body-md figure, .code-md figure { margin: 0.8em 0; } +/* Plain-text fallbacks (a non-markdown README, a render failure) stay mono at + code size — the unified block above outranked the earlier rule by order. */ +.gh-body-md.code-md-plain, .code-md.code-md-plain { font-size: 12.5px; } +.gh-body-md figcaption, .code-md figcaption { + font-size: 0.85em; + color: var(--app-muted); + text-align: center; + margin-top: 4px; +} +.gh-files .file-row { cursor: default; } +.gh-adds { font-family: var(--vscode-editor-font-family); font-size: 11px; color: var(--app-muted); margin-left: auto; } + +/* PR detail sub-tabs (Conversation / Commits / Checks / Files). */ +.gh-subtabs { + display: flex; + gap: 2px; + margin: 14px 0 12px; + border-bottom: 1px solid var(--app-border); + /* The STRIP scrolls; the page does not. A gist with several files, or any + tab set whose labels are long, ran the last tabs off the content column and + off the window with nothing to scroll — one of them sitting unclickable + under the edge. A tab strip that cannot fit scrolls sideways, which is what + every tab strip does; it must not push its own container wider. */ + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; +} +/* Tabs keep their label rather than squeezing it to nothing. */ +.gh-subtabs > .gh-subtab { flex: 0 0 auto; } +.gh-subtab { + display: inline-flex; + align-items: center; + gap: 6px; + /* A tab label never wraps: "Files (3)" put its count on a second line at + 1000px, overflowing the 34px tab box and leaving the icon aligned to + neither line. */ + white-space: nowrap; + flex: 0 0 auto; + height: 34px; + padding: 0 12px; + border: none; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--app-muted); + font-family: inherit; + font-size: 12.5px; + font-weight: 550; + cursor: pointer; +} +.gh-subtab .glyph .codicon { font-size: 15px; } +.gh-subtab:hover { color: var(--vscode-foreground); } +.gh-subtab.active { color: var(--vscode-foreground); border-bottom-color: var(--gs-accent); } +.gh-subtab.active .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } +.gh-subcontent { display: flex; flex-direction: column; } +.gh-comment { + border: 1px solid var(--app-border); + border-radius: 10px; + margin-bottom: 10px; + overflow: hidden; +} +.gh-comment-head { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + font-size: 12px; + font-weight: 600; + color: var(--vscode-foreground); + background: var(--app-panel); + border-bottom: 1px solid var(--app-border); +} +.gh-comment .gh-body-md { margin: 0; padding: 10px 12px; } +.gh-review-approved { color: var(--status-add); background: color-mix(in srgb, var(--status-add) 16%, transparent); } +.gh-review-changes_requested { color: var(--status-del); background: color-mix(in srgb, var(--status-del) 16%, transparent); } + +/* Check / workflow-run rows. */ +.gh-check-row { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 8px; + border-radius: 8px; + cursor: pointer; +} +.gh-check-row:hover { background: var(--app-hover); } +.gh-check-dot { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto; background: var(--app-muted); } +.gh-check-dot.gh-checks-success { background: var(--status-add); } +.gh-check-dot.gh-checks-failure, .gh-check-dot.gh-checks-error, .gh-check-dot.gh-checks-cancelled { background: var(--status-del); } +.gh-check-dot.gh-checks-in_progress, .gh-check-dot.gh-checks-queued, .gh-check-dot.gh-checks-pending { background: var(--status-mod); } +.gh-check-name { flex: 1 1 auto; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* The status WORD takes the status's own colour. Every state used to print in + the same grey, so "Failed" and "Passed" were distinguishable only by an icon + two columns away. */ +.gh-check-state { font-size: 11.5px; color: var(--app-muted); } +.gh-check-row:has(.is-success) .gh-check-state, +.gh-step-row:has(.is-success) .gh-check-state, +.gh-check-row:has(.gh-checks-success) .gh-check-state, +.gh-step-row:has(.gh-checks-success) .gh-check-state { color: var(--status-add); } +.gh-check-row:has(.gh-checks-failure) .gh-check-state, +.gh-step-row:has(.gh-checks-failure) .gh-check-state, +.gh-check-row:has(.gh-checks-timed_out) .gh-check-state, +.gh-step-row:has(.gh-checks-timed_out) .gh-check-state { color: var(--status-del); font-weight: 600; } +.gh-check-row:has(.gh-checks-in_progress) .gh-check-state, +.gh-step-row:has(.gh-checks-in_progress) .gh-check-state { color: var(--status-mod); } + +/* ── Glyphs — the real VS Code codicon font ───────────────────────────────────── + * The `codicon` @font-face is registered at document scope by the imported + * graph.css (esbuild inlines the .ttf as a data URL), so the desktop renderer + * uses the SAME icon font as the extension webviews. `glyph()` emits + * `<span class="glyph codicon codicon-NAME">`; `.glyph` owns the box + alignment + * and carries every accent/muted color rule, `.codicon` owns the font. */ +.glyph { + display: inline-flex; + align-items: center; + justify-content: center; +} +.codicon { + font: normal normal normal 16px/1 "codicon"; + text-rendering: auto; + text-align: center; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + user-select: none; +} +/* The desktop bundles Monaco (for the diff editor), which registers a GLOBAL + * `.codicon[class*=codicon-] { display: inline-block }` plus its own older, + * partial `codicon` @font-face. Both win the cascade in the built bundle and + * sabotage our icons: the inline-block kills our flex centering (every glyph is + * shoved upward in its tile) and the partial font drops newer glyphs. Pin OUR + * glyphs to the complete @vscode/codicons font under a private family and * re-assert the flex centering — at a specificity (0,3,0) that beats Monaco's * (0,2,0) rule regardless of bundle order, so it can never regress. */ @font-face { @@ -2575,3574 +3653,6867 @@ body.resizing-h .cmp-diff-editor { pointer-events: none; } font-display: block; src: url("@vscode/codicons/dist/codicon.ttf") format("truetype"); } -.glyph.codicon[class*="codicon-"] { - font-family: "gs-codicon"; - display: inline-flex; +.glyph.codicon[class*="codicon-"] { + font-family: "gs-codicon"; + display: inline-flex; + align-items: center; + justify-content: center; + vertical-align: middle; +} +.codicon-folder-opened::before { content: "\eaf7"; } +.codicon-folder::before { content: "\ea83"; } +.codicon-git-branch::before { content: "\ec6f"; } +.codicon-chevron-down::before { content: "\eab4"; } +.codicon-cloud::before { content: "\ebaa"; } +.codicon-tag::before { content: "\ea66"; } +.codicon-refresh::before { content: "\eb37"; } +.codicon-home::before { content: "\eb06"; } +.codicon-check::before { content: "\eab2"; } +.codicon-git-commit::before { content: "\eafc"; } +.codicon-request-changes::before { content: "\eb43"; } +.codicon-git-compare::before { content: "\eafd"; } +.codicon-archive::before { content: "\ea98"; } +.codicon-list-tree::before { content: "\eb86"; } +.codicon-git-pull-request::before { content: "\ea64"; } +.codicon-issues::before { content: "\eb0c"; } +.codicon-issue-opened::before { content: "\ea74"; } +.codicon-project::before { content: "\eb30"; } +.codicon-comment-discussion::before { content: "\eac7"; } +.codicon-link-external::before { content: "\eb14"; } +.codicon-arrow-up::before { content: "\eaa1"; } +.codicon-arrow-down::before { content: "\ea9a"; } +.codicon-sync::before { content: "\ea77"; } +.codicon-git-merge::before { content: "\eafe"; } +.codicon-code::before { content: "\eac4"; } +.codicon-play::before { content: "\eb2c"; } +.codicon-pulse::before { content: "\eb31"; } +.codicon-add::before { content: "\ea60"; } +.codicon-trash::before { content: "\ea81"; } +.codicon-file::before { content: "\ea7b"; } +.codicon-file-code::before { content: "\eae9"; } +.codicon-repo::before { content: "\ea62"; } +.codicon-book::before { content: "\eaa4"; } +.codicon-markdown::before { content: "\eb1d"; } +.codicon-chevron-right::before { content: "\eab6"; } +.codicon-chevron-left::before { content: "\eab5"; } +.codicon-arrow-left::before { content: "\ea9b"; } +.codicon-ellipsis::before { content: "\ea7c"; } +.codicon-error::before { content: "\ea87"; } +.codicon-warning::before { content: "\ea6c"; } +.codicon-info::before { content: "\ea74"; } +.codicon-close::before { content: "\ea76"; } +.codicon-copy::before { content: "\ebcc"; } +.codicon-search::before { content: "\ea6d"; } +.codicon-gear::before { content: "\eaf8"; } +.codicon-pass-filled::before { content: "\ebb3"; } +.codicon-loading::before { content: "\eb19"; } +.codicon-github::before { content: "\ea84"; } +.codicon-key::before { content: "\eb11"; } +.codicon-sign-in::before { content: "\ea6f"; } +.codicon-bell::before { content: "\eaa2"; } +.codicon-bell-dot::before { content: "\eb9a"; } +.codicon-organization::before { content: "\ea7e"; } +.codicon-rocket::before { content: "\eb44"; } +.codicon-eye::before { content: "\ea70"; } +.codicon-globe::before { content: "\eb01"; } +.codicon-lock::before { content: "\ea75"; } +.codicon-repo-forked::before { content: "\ea63"; } +.codicon-star-full::before { content: "\eb59"; } +.codicon-comment::before { content: "\ea6b"; } +.codicon-issue-closed::before { content: "\eba4"; } +.codicon-git-pull-request-closed::before { content: "\ebda"; } +.codicon-git-pull-request-draft::before { content: "\ebdb"; } +.codicon-milestone::before { content: "\eb20"; } +.codicon-edit::before { content: "\ea73"; } +.codicon-new-file::before { content: "\ea7f"; } +.codicon-debug-rerun::before { content: "\ebc0"; } +.codicon-stop-circle::before { content: "\eba5"; } +.codicon-cloud-download::before { content: "\eac2"; } +.codicon-kebab-vertical::before { content: "\eb10"; } +.codicon-circle-filled::before { content: "\ea71"; } +.codicon-inbox::before { content: "\eb09"; } +.codicon-history::before { content: "\ea82"; } +.codicon-terminal::before { content: "\ea85"; } +.codicon-link::before { content: "\eb15"; } +.codicon-repo-clone::before { content: "\ec7f"; } +.codicon-star-empty::before { content: "\eb59"; } + +/* ── Dropdown menus (repo / branch switchers) ───────────────────────────────── */ +.dropdown { + position: fixed; + /* Above the peek overlay (1900) AND the modal overlay (2000): a menu is + always anchored to the surface that opened it, so it must never render + underneath that surface — e.g. the branch ⋯ menu inside a branch peek. */ + z-index: 2100; + min-width: 230px; + max-width: 440px; + max-height: 72vh; + overflow-y: auto; + padding: 5px; + border-radius: 12px; + background: color-mix(in srgb, var(--app-elevated) 96%, var(--vscode-foreground)); + border: 1px solid var(--app-border); + box-shadow: var(--sheen), var(--shadow-pop); +} +/* A destructive menu item reads destructive — the icon-button clusters these + menus replaced had a red trash button, and losing that signal in the move to + a menu would have made Delete look like Copy path. */ +.dropdown-item.is-danger, +.dropdown-item.is-danger .dropdown-label { color: var(--gs-danger, #ff8585); } +/* …and the same for a small toolbar button. `is-danger` was styled for menu + items only, so the merge bar's "Delete the file" — the button offered for the + side of a modify/delete conflict that has no file — was pixel-identical to + the one beside it that KEEPS the file. A destructive control that looks like + its opposite is worse than an unlabelled one. */ +.mini-btn.is-danger { + color: var(--gs-danger, #ff8585); + border-color: color-mix(in srgb, var(--gs-danger, #ff8585) 40%, var(--app-border)); +} +.mini-btn.is-danger .codicon { color: var(--gs-danger, #ff8585); } +.mini-btn.is-danger:hover:not(:disabled) { + background: color-mix(in srgb, var(--gs-danger, #ff8585) 12%, transparent); + border-color: var(--gs-danger, #ff8585); +} +.dropdown-item.is-danger:hover, +.dropdown-item.is-danger:focus-visible { + background: color-mix(in srgb, var(--gs-danger, #ff8585) 14%, transparent); +} +.dropdown-sep { + height: 1px; + margin: 5px 8px; + background: var(--app-border); +} +.dropdown-sep:not(:empty) { + height: auto; + margin: 8px 9px 3px; + background: none; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--app-muted); +} +.dropdown-item { + display: flex; + align-items: center; + gap: 9px; + width: 100%; + text-align: left; + padding: 7px 9px; + border: none; + border-radius: 7px; + background: transparent; + color: var(--vscode-foreground); + font-family: inherit; + font-size: 12.5px; + cursor: pointer; +} +.dropdown-item:hover { background: var(--app-hover); } +.dropdown-item .glyph { color: var(--app-muted); flex: 0 0 auto; } +/* A leading element that carries its OWN colour keeps it — the muted rule + above turned the run-status icons in the Status filter grey, which is the + one thing they must not be. */ +.dropdown-item .run-lead .glyph, +.dropdown-item .gh-lead-icon .glyph { color: inherit; } +.dropdown-item.is-current { color: var(--gs-accent-ink, var(--gs-accent)); font-weight: 600; background: var(--accent-soft); } +.dropdown-item.is-current .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } +.dropdown-item.is-disabled { color: var(--app-muted); cursor: default; opacity: 0.5; } +.dropdown-item.is-disabled:hover { background: transparent; } +.dropdown-label { + flex: 1 1 auto; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.dropdown-sub { + flex: 0 1 auto; + font-size: 10.5px; + color: var(--app-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 46%; +} +.dropdown-item .glyph:last-child { margin-left: auto; } + +/* ── Main area ─────────────────────────────────────────────────────────────── */ +.graph-host { + flex: 1 1 55%; + min-height: 140px; + background: var(--vscode-editor-background); + overflow: hidden; +} + +gitstudio-graph { + /* Size only — the element's shadow `:host` uses `display:flex; + * flex-direction:column`. A light-DOM `display:block` here would override it + * and collapse the flex:1 row scroller to zero height (no rows render). */ + height: 100%; + width: 100%; +} + +.details { + display: flex; + flex-direction: column; + width: 100%; + min-width: 0; + overflow: hidden; +} + +.details-empty, +.diff-empty { + align-items: center; + justify-content: center; + color: var(--app-muted); + font-size: 13px; + text-align: center; + padding: 24px; +} + +.details-empty { + display: flex; +} + +.details-head { + padding: 14px 16px; + border-bottom: 1px solid var(--app-border); +} + +.details-subject { + font-size: 15px; + font-weight: 650; + margin-bottom: 8px; + user-select: text; +} + +.details-meta { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.meta-tag { + font-size: 11px; + padding: 2px 8px; + border-radius: 999px; + background: var(--app-elevated); + border: 1px solid var(--app-border); + color: var(--app-muted); + user-select: text; +} + +.meta-sha { + font-family: var(--vscode-editor-font-family); + color: var(--gs-accent-2); +} + +.details-body { + margin: 12px 0 0; + padding: 10px 12px; + background: var(--app-elevated); + border: 1px solid var(--app-border); + border-radius: 8px; + font-family: var(--vscode-editor-font-family); + font-size: 12px; + white-space: pre-wrap; + max-height: 40vh; + overflow: auto; + color: var(--vscode-foreground); + user-select: text; +} + +.details-files-title { + padding: 10px 16px 4px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--app-muted); + font-weight: 600; +} + +.details-files { + overflow-y: auto; + max-height: 30%; + flex: 0 0 auto; + padding: 0 8px 6px; +} + +.file-row { + display: flex; + align-items: center; + gap: 9px; + width: 100%; + border: none; + background: transparent; + color: var(--vscode-foreground); + padding: 4px 8px; + border-radius: 6px; + cursor: pointer; + text-align: left; + font-size: 12.5px; +} + +.file-row:hover { + background: var(--app-hover); +} + +.file-row.active { + background: var(--app-active); +} + +.file-status { + font-family: var(--vscode-editor-font-family); + font-weight: 700; + width: 16px; + text-align: center; + flex: 0 0 auto; + font-size: 11px; +} + +.status-A .file-status, +.status-C .file-status { + color: var(--status-add); +} +.status-M .file-status, +.status-R .file-status { + color: var(--status-mod); +} +.status-D .file-status { + color: var(--status-del); +} + +.file-path { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--vscode-editor-font-family); + font-size: 12px; +} + +.diff-surface { + flex: 1 1 auto; + min-height: 0; + position: relative; + border-top: 1px solid var(--app-border); +} + +/* ── Diff mode chrome: file path + Inline/Split toggle above the editors ──── */ +.diffmode-wrap { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} +.diffmode-bar { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 8px; + height: 34px; + padding: 0 8px 0 12px; + border-bottom: 1px solid var(--app-border); +} +/* Left-truncate so the filename (the informative end) survives narrow panes. */ +.diffmode-path { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + direction: rtl; + text-align: left; + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-size: 11.5px; + color: var(--app-muted); +} +/* LTR, isolated from the rtl box around it. `direction: rtl` on the parent is + what puts the ellipsis on the LEFT; without this isolate it also reorders the + neutral characters at the string's edges, and a leading dot is neutral — so + ".github/workflows/ci.yml" drew as "github/workflows/ci.yml.", a path naming + a file that does not exist. */ +.diffmode-path-text { direction: ltr; unicode-bidi: isolate; } +.diffmode-seg { flex: 0 0 auto; } +.diffmode-seg .cmp-mode-btn { + display: inline-flex; + align-items: center; + gap: 5px; + height: 24px; + padding: 0 10px; + font-size: 11.5px; +} +.diffmode-seg .cmp-mode-btn .glyph { font-size: 12px; } +.diffmode-body { + flex: 1 1 auto; + min-height: 0; + position: relative; +} + +/* The shared <gitstudio-commit-details> inspect panel + the inline diff below + * it. The diff surface stays hidden until a file is opened, then splits. */ +/* The commit-details surface inside the bottom dock fills the dock body; its + child (the details split, or the empty-state placeholder) fills + centers. */ +/* Fill the dock body (a flex row) like .term-surface does — otherwise it shrinks + to content width and the commit details ignore the available space. */ +.dock-details-surface { flex: 1 1 auto; width: 100%; min-width: 0; height: 100%; min-height: 0; display: flex; } +.dock-details-surface > * { flex: 1 1 auto; min-width: 0; min-height: 0; } + +/* Docked commit details = a wide, short split: the metadata/file panel on the + LEFT, the file diff on the RIGHT (uses the wide footer space, both visible). */ +.details-split { + display: flex; + flex-direction: row; + width: 100%; + min-width: 0; + height: 100%; + overflow: hidden; +} +/* Commit details fill the dock by default (no diff pane). The metadata/file + panel scrolls ITSELF (<gitstudio-commit-details> has :host{overflow:hidden} + + an internal .scroll) — so NO overflow here, or you get a double scrollbar. + Opening a file (.diff-open) docks the panel to a fixed left column and reveals + the diff filling the rest. */ +.details-split .details-panel { + flex: 1 1 auto; + min-width: 0; + min-height: 0; +} +/* ── In-view commit diff ───────────────────────────────────────────────────── + Opening a file docks the commit panel to a fixed-width left rail and the + Monaco diff fills the rest of the (widened) details column — the whole + commit lives in ONE region. The graph narrows but never below its column + breakpoint's floor. */ +.graph-view.diff-open .graph-host-full { flex: 0 1 34%; min-width: 300px; } +.graph-view.diff-open .graph-details { flex: 1 1 auto; } +.graph-view.diff-open .details-split .details-panel { + flex: 0 0 340px; + border-right: 1px solid var(--app-border); +} +.details-diff { + flex: 1 1 auto; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + background: var(--app-bg); +} +.details-diff-head { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 8px; + height: 34px; + padding: 0 6px 0 12px; + border-bottom: 1px solid var(--app-border); + background: var(--app-panel); +} +.details-diff-name { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + direction: rtl; /* long paths keep their FILENAME visible, not their prefix */ + text-align: left; + font-family: var(--vscode-editor-font-family); + font-size: 12px; + color: var(--vscode-foreground); + user-select: text; +} +/* Same hosting contract as the dock's old diff surface: the panel's wrap + flexes to fill (Monaco needs a real box, not absolute hacks). */ +.details-diff-surface { flex: 1 1 auto; min-width: 0; min-height: 0; display: flex; } +.details-diff-surface > * { flex: 1 1 auto; min-width: 0; min-height: 0; } + +.diff-empty { + display: flex; + height: 100%; +} + +/* ── Context menu ──────────────────────────────────────────────────────────── */ +.ctx-menu { + position: fixed; + z-index: 1000; + min-width: 200px; + background: color-mix(in srgb, var(--app-elevated) 96%, var(--vscode-foreground)); + border: 1px solid var(--app-border); + border-radius: 11px; + box-shadow: var(--sheen), var(--shadow-pop); + padding: 5px; + overflow: hidden; +} + +.ctx-menu-header { + font-family: var(--vscode-editor-font-family); + font-size: 11px; + color: var(--app-muted); + padding: 5px 10px 7px; + border-bottom: 1px solid var(--app-border); + margin-bottom: 4px; +} + +.ctx-menu-item { + display: block; + width: 100%; + border: none; + background: transparent; + color: var(--vscode-foreground); + padding: 6px 10px; + border-radius: 6px; + text-align: left; + font-size: 12.5px; + cursor: pointer; +} + +.ctx-menu-item:hover { + background: var(--app-hover); +} + +.ctx-danger { + color: var(--status-del); +} + +.ctx-danger:hover { + background: color-mix(in srgb, var(--status-del) 14%, transparent); +} + +/* Scrollbars — match the app, not the OS default. */ +::-webkit-scrollbar { + width: 11px; + height: 11px; +} +::-webkit-scrollbar-thumb { + background: var(--vscode-scrollbarSlider-background); + border-radius: 6px; + border: 3px solid transparent; + background-clip: padding-box; +} +::-webkit-scrollbar-thumb:hover { + background: var(--vscode-scrollbarSlider-hoverBackground); + background-clip: padding-box; +} +::-webkit-scrollbar-corner { + background: transparent; +} + +/* ════════════════════════════════════════════════════════════════════════ + GitHub section views (issues · PRs · actions · releases · notifications · + orgs · projects · gists) — generated per-section, integrated 2026-06-27. + ════════════════════════════════════════════════════════════════════════ */ + +/* ── issues ── */ +/* ── Issues: header tools, label/assignee chips, composer ───────────────────── */ +.gh-head-tools { display: flex; align-items: center; gap: 8px; margin-left: auto; } +/* Header action buttons (primary CTAs in a section header) are slim — the same + 28px height as the secondary mini-btn beside them, so a header never mixes a + gigantic 40px .btn with a slim button. The .btn-primary fill/sheen is kept. */ +.gh-new-btn, +.gh-run-btn, +.gh-head-primary { + height: 28px; + padding: 0 12px; + font-size: 12.5px; + font-weight: 600; + border-radius: 8px; + gap: 6px; +} + +/* Shared header search/filter field (see common.ts searchField). */ +.gh-search { + display: inline-flex; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 6px 0 9px; + /* See the .gh-head-titlewrap rule below, which sizes this from the longest + placeholder the app actually uses. */ + min-width: 150px; + max-width: 240px; + border-radius: 8px; + border: 1px solid var(--app-border); + background: var(--app-elevated); + box-shadow: var(--sheen); + transition: border-color var(--dur-1) var(--ease), box-shadow var(--dur-1) var(--ease); +} +/* Every list view's search box. An 18%-alpha halo around a 1px border was the + only sign the keyboard was in here, and the input itself carries + `outline: none` — so on the light page the field looked identical focused and + unfocused. It wears the app's own ring, like every other control. */ +.gh-search:focus-within { + border-color: var(--gs-focus-ring); + outline: 2px solid var(--gs-focus-ring); + outline-offset: 1px; +} +/* The search sits on the LEFT, inside the title cluster (.gh-head-titlewrap), + right after the page name + count. Keep it from shrinking below its min width. */ +.gh-head-titlewrap > .gh-search { flex: 0 0 auto; margin-left: 2px; } +.gh-search-icon { color: var(--app-muted); font-size: 14px; flex: 0 0 auto; } +.gh-search-input { + flex: 1 1 auto; + min-width: 0; + border: none; + background: transparent; + outline: none; + color: var(--vscode-foreground); + font-family: inherit; + font-size: 12.5px; +} +.gh-search-input::placeholder { color: var(--app-muted); } +.gh-search-input::-webkit-search-cancel-button { display: none; } +.gh-search-clear { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border: none; + background: transparent; + border-radius: 5px; + color: var(--app-muted); + cursor: pointer; +} +.gh-search-clear .codicon { font-size: 12px; } +.gh-search-clear:hover { background: var(--app-hover); color: var(--vscode-foreground); } + +/* State pills in the detail meta. */ +.gh-issue-open { color: var(--status-add); background: color-mix(in srgb, var(--status-add) 16%, transparent); } +.gh-issue-closed { color: var(--status-del); background: color-mix(in srgb, var(--status-del) 16%, transparent); } + +/* Label chips (list rows + detail). Layout only — the canonical, AA-locked + `.gh-label-chip` color rule lives further down (search ".gh-label-chip {"). */ +.gh-row-labels, .gh-detail-labels { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 5px; } +.gh-detail-labels { margin: 10px 0 4px; } + +/* Assignee row in the detail. */ +.gh-detail-assignees { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin: 6px 0 4px; } +.gh-assign-label { font-size: 11px; color: var(--app-muted); } + +/* Comment head meta + empty-body placeholder. */ +.gh-comment-when { font-weight: 400; color: var(--app-muted); } +.gh-empty-body { color: var(--app-muted); font-style: italic; } + +/* Comment composer at the bottom of the issue detail. */ +.gh-composer { margin-top: 16px; display: flex; flex-direction: column; gap: 8px; max-width: 820px; } +.gh-composer-input { + width: 100%; + min-height: 92px; + resize: vertical; + padding: 10px 12px; + border: 1px solid var(--app-border); + border-radius: 10px; + background: var(--app-panel); + color: var(--vscode-foreground); + font-family: inherit; + font-size: 13px; + line-height: 1.5; +} +.gh-composer-input:focus { + outline: none; + border-color: color-mix(in srgb, var(--gs-accent) 55%, var(--app-border)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--gs-accent) 22%, transparent); +} +.gh-composer-actions { display: flex; align-items: center; justify-content: flex-end; gap: 10px; } +/* The AI "Draft a reply" affordance is a peer of the primary "Comment", so it + reads as one button family, not a tiny chip glued to a big button: match the + primary's height + corner radius while keeping the accent-tinted AI face. */ +.gh-composer-actions .ai-chip { + height: 40px; + padding: 0 16px; + border-radius: 9px; + font-size: 13px; + font-weight: 600; + background: color-mix(in srgb, var(--gs-accent) 12%, var(--app-elevated)); + border-color: color-mix(in srgb, var(--gs-accent) 42%, var(--app-border)); +} + +/* ── prs ── */ +/* ── Pull Requests section (renderer/views/prs.ts) ─────────────────────────── + Appended after the existing PR/gh-* block. Reuses tokens already in the file: + --app-*, --gs-accent, --vscode-*, --status-*. Most surfaces reuse existing + .mini-btn / .modal-* / .gh-* classes; only the header action, the icon-only + overflow button, the detail-meta state pills, and the Create-PR / reviewer + modals need new rules. */ + +/* Header "New PR" action sits left of the account cluster in the head row. */ +.gh-head-action { margin-left: auto; } +.list-head-row .gh-head-action + .gh-acct { margin-left: 8px; } + +/* Icon-only overflow ("⋯") button in the detail action cluster. */ +.gh-icon-btn { padding: 0 8px; } +.gh-icon-btn .glyph .codicon { font-size: 16px; } + +/* The Draft pill on a list row keeps the base .gh-pill top margin; tint it. */ +.gh-pill-draft { color: var(--app-muted); background: color-mix(in srgb, var(--app-muted) 20%, transparent); } + +/* State pill in the detail meta: it lives in a horizontal flex row, so cancel + the base .gh-pill margin-top and give it a touch more presence. */ +.gh-detail-meta .gh-pill { margin-top: 0; align-self: center; } +.gh-state-open { color: var(--status-add); background: color-mix(in srgb, var(--status-add) 16%, transparent); } +.gh-state-closed { color: var(--status-del); background: color-mix(in srgb, var(--status-del) 16%, transparent); } +.gh-state-merged { color: var(--gs-accent-ink, var(--gs-accent)); background: color-mix(in srgb, var(--gs-accent) 16%, transparent); } +.gh-state-not-planned { color: var(--app-muted); background: color-mix(in srgb, var(--app-muted) 16%, transparent); } +.gh-state-draft { color: var(--app-muted); background: color-mix(in srgb, var(--app-muted) 20%, transparent); } +/* Semantic variants so Releases/Gists pills stop collapsing into gray "draft". */ +.gh-state-prerelease { color: var(--status-warn); background: color-mix(in srgb, var(--status-warn) 18%, transparent); } +.gh-state-latest { color: var(--status-add); background: color-mix(in srgb, var(--status-add) 16%, transparent); } +.gh-state-public { color: var(--status-add); background: color-mix(in srgb, var(--status-add) 15%, transparent); } +.gh-state-private { color: var(--app-muted); background: color-mix(in srgb, var(--app-muted) 18%, transparent); } +/* On the light page a 15-20% tint of a saturated ink lands close enough to the + ink itself that the word inside stops being legible. Light gets a paler tint + and a darker word, so every badge clears AA on both grounds. */ +body.vscode-light :is(.gh-state-open, .gh-state-closed, .gh-state-merged, + .gh-state-prerelease, .gh-state-latest, .gh-state-public, .gh-state-not-planned, + .gh-state-private, .gh-state-draft) { + background: color-mix(in srgb, currentColor 11%, var(--app-elevated)); +} + +/* Check rows that link out get the affordance only when clickable. */ +.gh-check-row.is-link { cursor: pointer; } + +/* Multi-field Create-PR + reviewer modals (extend the shared .modal-card). */ +.gh-pr-form { width: min(var(--modal-md), 92vw); gap: 14px; } +.gh-form-row { display: flex; flex-direction: column; gap: 5px; } +.gh-form-label { font-size: 11.5px; font-weight: 600; color: var(--app-muted); text-transform: uppercase; letter-spacing: 0.03em; } +.gh-form-select { + height: 34px; + padding: 0 10px; + border-radius: 9px; + border: 1px solid var(--app-border); + background: var(--app-panel); + color: var(--vscode-foreground); + font-family: inherit; + font-size: 13px; + outline: none; +} +.gh-form-select:focus { + border-color: color-mix(in srgb, var(--gs-accent) 55%, var(--app-border)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--gs-accent) 22%, transparent); +} +.gh-form-textarea { + min-height: 96px; + padding: 9px 12px; + border-radius: 9px; + border: 1px solid var(--app-border); + background: var(--app-panel); + color: var(--vscode-foreground); + font-family: inherit; + font-size: 13px; + line-height: 1.5; + resize: vertical; + outline: none; +} +.gh-form-textarea:focus { + border-color: color-mix(in srgb, var(--gs-accent) 55%, var(--app-border)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--gs-accent) 22%, transparent); +} +.gh-form-check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--vscode-foreground); cursor: pointer; } +.gh-form-check input { accent-color: var(--gs-accent); width: 15px; height: 15px; } + +/* Reviewer multi-select list (scrolls if many collaborators). */ +.gh-reviewer-list { display: flex; flex-direction: column; gap: 2px; max-height: 280px; overflow-y: auto; padding: 4px 2px; } +.gh-reviewer-list .gh-form-check { padding: 5px 6px; border-radius: 7px; } +.gh-reviewer-list .gh-form-check:hover { background: var(--app-hover); } + +/* ── actions ── */ +/* ── GitHub Actions section ───────────────────────────────────────────────── + Most classes already exist and are reused verbatim: gh-view, gh-list, gh-row, + gh-detail, gh-checks, gh-check, mini-btn, btn, row-btn, row-meta. Only the NEW + Actions classes, 4 missing codicon codepoints, and the extra Actions status + colours are added below. */ + +/* Header toolbar (Workflows + Run workflow) sits left of the account cluster. */ +/* The tools row carries up to a dozen controls (Actions: a segment, five + facets, Clear, Secrets, Run workflow). With no wrap and no min-width it + could only squash — clipping the search field and the facet labels. Now it + wraps onto a second line instead, and the search field is what gives up + space first. */ +.gh-head-tools { + display: flex; align-items: center; - justify-content: center; - vertical-align: middle; + gap: 8px; + /* The tools row spans from the title block to the right edge and pins its + ends: the view segment at the left, the verbs at the right. It used to be + one right-anchored cluster, so ANY width change inside it slid everything + to its left — switching Actions from Runs to Workflows dropped five facet + pills and the segment jumped ~730px; picking an Issues author widened one + pill and shunted the rest. The slack now lives in the facet region. */ + flex: 1 1 auto; + margin-left: var(--sp-4); + margin-right: 12px; + flex-wrap: wrap; + row-gap: 8px; + justify-content: flex-end; + min-width: 0; } -.codicon-folder-opened::before { content: "\eaf7"; } -.codicon-folder::before { content: "\ea83"; } -.codicon-git-branch::before { content: "\ec6f"; } -.codicon-chevron-down::before { content: "\eab4"; } -.codicon-cloud::before { content: "\ebaa"; } -.codicon-tag::before { content: "\ea66"; } -.codicon-refresh::before { content: "\eb37"; } -.codicon-home::before { content: "\eb06"; } -.codicon-check::before { content: "\eab2"; } -.codicon-git-commit::before { content: "\eafc"; } -.codicon-request-changes::before { content: "\eb43"; } -.codicon-git-compare::before { content: "\eafd"; } -.codicon-archive::before { content: "\ea98"; } -.codicon-list-tree::before { content: "\eb86"; } -.codicon-git-pull-request::before { content: "\ea64"; } -.codicon-issues::before { content: "\eb0c"; } -.codicon-issue-opened::before { content: "\ea74"; } -.codicon-project::before { content: "\eb30"; } -.codicon-comment-discussion::before { content: "\eac7"; } -.codicon-link-external::before { content: "\eb14"; } -.codicon-arrow-up::before { content: "\eaa1"; } -.codicon-arrow-down::before { content: "\ea9a"; } -.codicon-sync::before { content: "\ea77"; } -.codicon-git-merge::before { content: "\eafe"; } -.codicon-code::before { content: "\eac4"; } -.codicon-play::before { content: "\eb2c"; } -.codicon-pulse::before { content: "\eb31"; } -.codicon-add::before { content: "\ea60"; } -.codicon-trash::before { content: "\ea81"; } -.codicon-file::before { content: "\ea7b"; } -.codicon-file-code::before { content: "\eae9"; } -.codicon-repo::before { content: "\ea62"; } -.codicon-book::before { content: "\eaa4"; } -.codicon-markdown::before { content: "\eb1d"; } -.codicon-chevron-right::before { content: "\eab6"; } -.codicon-chevron-left::before { content: "\eab5"; } -.codicon-arrow-left::before { content: "\ea9b"; } -.codicon-ellipsis::before { content: "\ea7c"; } -.codicon-error::before { content: "\ea87"; } -.codicon-warning::before { content: "\ea6c"; } -.codicon-info::before { content: "\ea74"; } -.codicon-close::before { content: "\ea76"; } -.codicon-copy::before { content: "\ebcc"; } -.codicon-search::before { content: "\ea6d"; } -.codicon-gear::before { content: "\eaf8"; } -.codicon-pass-filled::before { content: "\ebb3"; } -.codicon-loading::before { content: "\eb19"; } -.codicon-github::before { content: "\ea84"; } -.codicon-key::before { content: "\eb11"; } -.codicon-sign-in::before { content: "\ea6f"; } -.codicon-bell::before { content: "\eaa2"; } -.codicon-bell-dot::before { content: "\eb9a"; } -.codicon-organization::before { content: "\ea7e"; } -.codicon-rocket::before { content: "\eb44"; } -.codicon-eye::before { content: "\ea70"; } -.codicon-globe::before { content: "\eb01"; } -.codicon-lock::before { content: "\ea75"; } -.codicon-repo-forked::before { content: "\ea63"; } -.codicon-star-full::before { content: "\eb59"; } -.codicon-comment::before { content: "\ea6b"; } -.codicon-issue-closed::before { content: "\eba4"; } -.codicon-git-pull-request-closed::before { content: "\ebda"; } -.codicon-git-pull-request-draft::before { content: "\ebdb"; } -.codicon-milestone::before { content: "\eb20"; } -.codicon-edit::before { content: "\ea73"; } -.codicon-new-file::before { content: "\ea7f"; } -.codicon-debug-rerun::before { content: "\ebc0"; } -.codicon-stop-circle::before { content: "\eba5"; } -.codicon-cloud-download::before { content: "\eac2"; } -.codicon-kebab-vertical::before { content: "\eb10"; } -.codicon-circle-filled::before { content: "\ea71"; } -.codicon-inbox::before { content: "\eb09"; } -.codicon-history::before { content: "\ea82"; } -.codicon-terminal::before { content: "\ea85"; } -.codicon-link::before { content: "\eb15"; } -.codicon-repo-clone::before { content: "\ec7f"; } -.codicon-star-empty::before { content: "\eb59"; } +/* The facet region absorbs the row's slack, so pills grow and disappear into + free space instead of pushing their neighbours. */ +.gh-head-tools > .gh-facets, +.gh-head-tools > .gh-facet-slot { + flex: 1 1 auto; + justify-content: flex-start; + min-width: 0; +} +/* Present even when empty — an empty slot is the row's spring. It also keeps + the row's HEIGHT, because an empty flex box is 0px tall: on Actions/Workflows + that collapse let the whole tools row fold back up onto the title line, and + the Runs/Workflows segment jumped 297px sideways between two tabs of the same + screen. A tools row that carries a facet slot always takes its own line, so + its controls sit in the same place whichever tab is showing. */ +.gh-facet-slot { + display: flex; + align-items: center; + gap: var(--sp-2); + min-width: 0; + min-height: 28px; +} +/* Opted into by a view whose tools row gains and loses controls as you move + between its own tabs. Actions is the case: Runs carries twelve controls and + Workflows four, so the row fitted beside the title on one tab and wrapped + below it on the other, and the Runs/Workflows segment jumped 297px between + two tabs of the same screen. Claiming the line makes the geometry a constant. + Views whose tools row never changes shape are left alone. */ +.gh-head-tools.gh-tools-own-line { + flex: 1 1 100%; + justify-content: flex-start; + margin-left: 0; + /* Ordered AFTER the refresh cluster, which is a later sibling in the DOM. + Without this the tools row claimed line 2 and stranded Refresh alone on a + line 3 of its own, at the far left — the one view in the app whose Refresh + was not where every other view's Refresh is. */ + order: 2; +} +.gh-head:has(> .gh-head-tools.gh-tools-own-line) > .gh-acct { + order: 1; + margin-left: auto; +} +.gh-head-titlewrap { min-width: 0; display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; row-gap: 8px; } +/* The search field must keep a floor and its own line when the row is tight — + below ~1200px it used to collide with the first facet pill, losing its right + border and running its placeholder under the neighbouring control. */ +/* 196px, not 180: the app's longest placeholder ("Filter this organization…") + measures 141px, and the field spends ~39px on its icon, gap and padding. At + 180 the text had four pixels of slack and clipped its own ellipsis. Sized + from the content rather than from a round number. */ +.gh-head-titlewrap .gh-search { min-width: 196px; flex: 1 1 196px; max-width: 320px; } +/* main.ts opens at 1280x820, so this rule fires at exactly the app's default + width — every GitHub header launches in the layout below. That is deliberate, + though it was arrived at the hard way: moving the breakpoint under the default + instead was tried, and it helped Issues (89px) while pushing PRs, which + carries more controls, to 161px. The header genuinely does not fit at 1280; + the honest answer is to degrade well there, not to pretend it fits. + Degrading well means TWO rows, never three. Giving the title `100%` used to + push the refresh cluster onto a line of its own, which is how the app opened + with a 125px header eating 15% of an 820px window before a single row of + content. Same controls, 89px. */ +@media (max-width: 1280px) { + /* TWO rows, never three. The title claims only what it needs and the refresh + cluster rides beside it; the tools take the second line. Giving the title + `100%` pushed refresh onto a line of its own, so the app opened — at its own + 1280x820 default — with a 125px header eating 15% of the window before a + single row of content. Same shape, 89px. */ + .gh-head-titlewrap { flex: 1 1 auto; order: 0; } + .gh-head > .gh-acct { order: 1; margin-left: auto; } + .gh-head-tools { flex: 1 1 100%; justify-content: flex-start; margin-left: 0; order: 2; } +} +/* The ≤1280px rule below gives .gh-head-titlewrap and .gh-head-tools a basis of + 100% each, which only means "take your own line" if this container WRAPS. + Without it both were simply shrunk to half a line, so crossing the breakpoint + made the header TALLER and more cramped than it was one pixel earlier + (Issues: 53px at 1600, 87px at 1281, 123px at 1280). row-gap has been sitting + here unused for the same reason. align-items goes to flex-start so the title + stops floating in the vertical middle of a four-row toolbar. */ +.gh-head { align-items: flex-start; row-gap: 8px; flex-wrap: wrap; } -/* ── Dropdown menus (repo / branch switchers) ───────────────────────────────── */ -.dropdown { +/* Run row: status dot inline with an ellipsised title. */ +.gh-row-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* Jobs list inside the run detail. */ +.gh-jobs { display: flex; flex-direction: column; gap: 8px; margin-top: 14px; } +.gh-job { border: 1px solid var(--app-border); border-radius: 8px; overflow: hidden; } +.gh-job-head { + display: flex; align-items: center; gap: 9px; width: 100%; + padding: 9px 11px; background: transparent; border: 0; cursor: pointer; + font: inherit; color: var(--vscode-foreground); text-align: left; +} +.gh-job-head:hover { background: var(--app-hover); } +.gh-job-head:focus-visible { outline: 2px solid var(--gs-focus-ring); outline-offset: -2px; } +.gh-job-head .gh-check-name { flex: 1 1 auto; } +.gh-job-chevron { transition: transform 0.12s ease; color: var(--app-muted); flex: 0 0 auto; } +.gh-job-head.open .gh-job-chevron { transform: rotate(90deg); } +.gh-job-log { margin-left: 8px; flex: 0 0 auto; } +.gh-job-steps { display: flex; flex-direction: column; border-top: 1px solid var(--app-border); } +.gh-job-steps.hidden { display: none; } +.gh-step-row { + display: flex; align-items: center; gap: 9px; + padding: 6px 11px 6px 30px; font-size: 12.5px; +} +.gh-step-row + .gh-step-row { border-top: 1px solid color-mix(in srgb, var(--app-border) 60%, transparent); } +.gh-step-empty { color: var(--app-muted); padding-left: 30px; } + +/* Workflows list in the detail pane. */ +.gh-wf-list { display: flex; flex-direction: column; margin-top: 14px; } +.gh-wf-row { + display: flex; align-items: center; gap: 10px; + padding: 9px 4px; border-bottom: 1px solid var(--app-border); +} +.gh-wf-row .row-meta { flex: 1 1 auto; min-width: 0; } +.gh-wf-row .mini-btn, .gh-wf-row .row-btn { flex: 0 0 auto; } + +/* Dispatch form rendered into the detail pane. */ +.gh-dispatch-form { display: flex; flex-direction: column; gap: 12px; margin-top: 16px; max-width: 520px; } +.gh-dispatch-row { display: flex; flex-direction: column; gap: 5px; } +.gh-dispatch-label { font-size: 12px; color: var(--app-muted); font-weight: 600; } +.gh-dispatch-input { + height: 30px; padding: 0 10px; font: inherit; font-size: 13px; + color: var(--vscode-foreground); background: var(--app-bg); + border: 1px solid var(--app-border); border-radius: 7px; outline: none; +} +.gh-dispatch-input:focus-visible { border-color: color-mix(in srgb, var(--gs-accent) 55%, var(--app-border)); } +select.gh-dispatch-input { padding-right: 6px; height: 30px; } +.gh-dispatch-note { font-size: 12.5px; color: var(--app-muted); line-height: 1.5; } + +/* Searchable combobox (comboField): an input + a filtered suggestion dropdown. */ +.gh-combo { position: relative; width: 100%; } +.gh-combo-input { width: 100%; } +.gh-combo-input:focus-within, +.gh-combo-input:focus { border-color: color-mix(in srgb, var(--gs-accent) 55%, var(--app-border)); } +/* Body-appended floating popover (position + size set in JS) — never clipped by + a scrolling container, flips above the field when there's no room below. */ +.gh-combo-menu { position: fixed; - z-index: 1000; - min-width: 230px; - max-width: 440px; - max-height: 72vh; + z-index: 2200; + max-height: 244px; overflow-y: auto; - padding: 5px; - border-radius: 12px; - background: color-mix(in srgb, var(--app-elevated) 96%, var(--vscode-foreground)); + padding: 4px; + border-radius: 9px; border: 1px solid var(--app-border); + background: var(--app-elevated); box-shadow: var(--sheen), var(--shadow-pop); + animation: gs-card-in 100ms var(--ease-out) both; } -.dropdown-sep { - height: 1px; - margin: 5px 8px; - background: var(--app-border); +.gh-combo-item { + display: block; + width: 100%; + text-align: left; + padding: 6px 9px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--vscode-foreground); + font-family: var(--vscode-editor-font-family, var(--vscode-font-family)); + font-size: 12.5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; } -.dropdown-sep:not(:empty) { - height: auto; - margin: 8px 9px 3px; - background: none; - font-size: 10px; +.gh-combo-item.active { background: color-mix(in srgb, var(--gs-accent) 18%, transparent); } +.gh-combo-item:hover { background: var(--app-hover); } +.gh-dispatch-form .gh-detail-actions { margin-top: 4px; } +.gh-meta-text { white-space: nowrap; } + +/* Codicon codepoints used by the Actions view that the curated subset lacked — + without these, the glyphs render BLANK. Verified against @vscode/codicons. */ +.codicon-list-unordered::before { content: "\eb17"; } +.codicon-debug-restart::before { content: "\ead2"; } +.codicon-output::before { content: "\eb9d"; } +.codicon-circle-slash::before { content: "\eabd"; } + +/* Extra Actions status colours not in the PR-only set (pill backgrounds). + `pending`/`in_progress`/`queued`/`cancelled` already exist for .gh-check-dot; + these add the pill-background variants + the conclusions Actions emits that + PRs didn't (cancelled/skipped/neutral/timed_out/action_required/etc.). */ +.gh-checks-timed_out, .gh-checks-startup_failure { + color: var(--status-del); background: color-mix(in srgb, var(--status-del) 16%, transparent); +} +/* "Attention" conclusions read as amber, not red — they're not outright failures. */ +.gh-checks-action_required { + color: var(--status-warn); background: color-mix(in srgb, var(--status-warn) 18%, transparent); +} +.gh-checks-skipped, .gh-checks-neutral, .gh-checks-stale, .gh-checks-cancelled { + color: var(--app-muted); background: color-mix(in srgb, var(--app-muted) 14%, transparent); +} +.gh-checks-in_progress, .gh-checks-queued, .gh-checks-requested, +.gh-checks-waiting { + color: var(--status-mod); background: color-mix(in srgb, var(--status-mod) 16%, transparent); +} +.gh-check-dot.gh-checks-skipped, .gh-check-dot.gh-checks-neutral { background: var(--app-muted); } +.gh-check-dot.gh-checks-cancelled, .gh-check-dot.gh-checks-timed_out, +.gh-check-dot.gh-checks-startup_failure, .gh-check-dot.gh-checks-stale, +.gh-check-dot.gh-checks-action_required { background: var(--status-del); } +.gh-check-dot.gh-checks-requested, .gh-check-dot.gh-checks-waiting { background: var(--status-mod); } + +/* ── releases ── */ +/* ── Releases section ───────────────────────────────────────────────────────── + Append near the other .gh-* rules (after the .gh-review-* block, ≈ line 1710). + Structure is inherited: .gh-view / .gh-body / .gh-list / .gh-row / .gh-detail / + .gh-detail-head|title|meta|actions / .gh-body-md / .gh-pill / .list-row / + .row-meta-* / .group-label / .gh-adds / .modal-* are all reused as-is. */ + +/* Releases|Tags segmented switch, injected into the header action cluster. */ +/* NEVER shrinks. It is `overflow: hidden`, so shrinking does not compress the + options — it deletes them. Below 1280px the Inbox's Unread/All control was + squeezed from 105px to 54px and "All" was not painted at all, leaving no way + to see anything but unread threads on a narrow window. */ +.gh-seg { + display: inline-flex; + flex: 0 0 auto; + border: 1px solid var(--app-border); + border-radius: 7px; + overflow: hidden; +} +.gh-seg-btn { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + height: 26px; padding: 0 11px; border: none; background: transparent; + color: var(--app-muted); font: inherit; font-size: 12px; font-weight: 650; cursor: pointer; + transition: color .12s ease, background .12s ease; +} +.gh-seg-btn .glyph .codicon { font-size: 14px; } +.gh-seg-btn + .gh-seg-btn { border-left: 1px solid var(--app-border); } +.gh-seg-btn:hover { color: var(--vscode-foreground); background: var(--app-hover); } +.gh-seg-btn:focus-visible { outline: 2px solid var(--gs-focus-ring); outline-offset: -2px; } +.gh-seg-btn.active { color: var(--gs-accent-ink, var(--gs-accent)); background: var(--app-active); } + +/* The tag glyph inside a gh-row title sits inline with the name (Tags sub-list). */ +.gh-row-title .glyph { color: var(--app-muted); margin-right: 6px; font-size: 13px; vertical-align: -2px; } + +/* Release assets list — list-row + row-meta reused; just spacing + a download tint. */ +.rel-assets { margin-top: 6px; display: flex; flex-direction: column; } +.rel-assets .list-row { cursor: pointer; } +/* The download control is a real button now (it used to be a decorative span + inside a row that was itself a button — invalid, and unreachable by keyboard + or screen reader). */ +.rel-asset-dl .codicon { font-size: 14px; color: var(--gs-accent-ink, var(--gs-accent)); } + +/* Multi-field release form (reuses .modal-overlay / .modal-input / .modal-actions). */ +.modal-form { width: min(var(--modal-md), 92vw); display: flex; flex-direction: column; gap: 12px; } +.modal-field { display: flex; flex-direction: column; gap: 5px; } +/* ONE field-label convention across every form modal. The same scaffold carried + three: uppercase letter-spaced micro-caps here, sentence case there, and a + plain bold line in the third — so the release form and the PR form did not + look like parts of the same app. This is an alias of .gh-form-label. */ +.modal-field-label, +.settings-field-label { + font-size: 11.5px; font-weight: 600; - letter-spacing: 0.07em; + color: var(--app-muted); text-transform: uppercase; + letter-spacing: 0.03em; +} +.modal-textarea { resize: vertical; min-height: 96px; font-family: var(--vscode-editor-font-family); line-height: 1.5; } +.modal-checks { display: flex; gap: 18px; flex-wrap: wrap; } +.modal-check { display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px; color: var(--vscode-foreground); cursor: pointer; } +.modal-check input { accent-color: var(--gs-accent); width: 15px; height: 15px; } + +/* Codicon codepoints used by this section that are NOT yet in app.css. Place + these alongside the other `.codicon-NAME::before` rules (≈ line 1752, next to + codicon-tag). tag(\ea66), trash(\ea81), cloud-download(\eac2), link-external are + already defined; these three are the only new ones: */ +.codicon-plus::before { content: "\ea60"; } +.codicon-pencil::before { content: "\ea73"; } +.codicon-package::before { content: "\eb29"; } + +/* ── notifications ── */ +/* ── Notifications (inbox) — built on .list-row + .row-meta + .row-actions ─── */ + +/* Header action cluster: the toggle + "Mark all read", pinned to the right + alongside the .gh-acct block (ghHeader is a space-between flex row, so + margin-left:auto pins title left and groups actions+account on the right). */ +.notif-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; } + +/* A muted count line above the list. */ +.notif-summary { + display: flex; align-items: center; gap: 7px; + padding: 4px 10px 8px; + font-size: 11.5px; color: var(--app-muted); +} +.notif-summary .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } +.notif-summary-text { font-variant-numeric: tabular-nums; } + +/* Inbox rows. */ +.notif-row { align-items: center; } +.notif-lead { display: inline-flex; align-items: center; gap: 6px; flex: 0 0 auto; } +.notif-lead .glyph { color: var(--app-muted); } +.notif-dot { + width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; + background: var(--gs-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--gs-accent) 22%, transparent); +} +/* Keeps the slot, drops the mark — one left edge for read and unread alike. */ +.notif-dot.is-read { visibility: hidden; box-shadow: none; } +/* Read rows recede — dim the title + icon so unread stands out. */ +.notif-read .row-meta-title { font-weight: 500; color: var(--app-muted); } +.notif-read .notif-lead .glyph { color: var(--app-muted); opacity: 1; } +/* Subject-type pill sits before the (hover-revealed) action cluster. */ +.notif-type { + flex: 0 0 auto; margin-left: 8px; + text-transform: none; letter-spacing: 0; +} +/* Disable interaction while a per-row mutation is in flight. */ +.notif-row.is-busy { opacity: 0.55; pointer-events: none; } + +/* ── Missing codicon codepoints (REQUIRED) ───────────────────────────────── + The desktop ships a CURATED codicon subset; a glyph not defined here renders + BLANK. `check-all` (Mark all read button) and `mail-read` (context menu) are + NOT yet in app.css — add their official @vscode/codicons codepoints. */ +.codicon-check-all::before { content: "\ebb1"; } +.codicon-mail-read::before { content: "\eb1b"; } + +/* ── orgs ── */ +/* ── Organizations view ───────────────────────────────────────────────────────── */ +.gh-avatar { + border-radius: 50%; + flex: 0 0 auto; + object-fit: cover; + background: var(--app-panel); + border: 1px solid var(--app-border); +} +.gh-avatar-fallback { color: var(--app-muted); flex: 0 0 auto; } +.gh-org-row .gh-row-title { display: flex; align-items: center; gap: 8px; } +.gh-org-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.gh-org-title { display: flex; align-items: center; gap: 9px; } +/* Org Repos/Teams/Members as a responsive card grid that uses the full pane. */ +.gh-org-grid { + display: grid; + /* 280px could not hold "public · TypeScript · ★ 2,140 · updated 1h ago", so + every card clipped mid-number ("★ 2,1…"). */ + /* auto-FIT, not auto-fill: with three repos or one team the empty tracks + collapse and the cards stretch into the full-width rows every other list + in the app uses, instead of huddling at the left edge of a wide pane. */ + grid-template-columns: repeat(auto-fit, minmax(330px, 1fr)); + gap: 8px; + align-content: start; +} +/* What the repository IS, on the card. One line, clipped with a title to + recover it — the full text is already on the row's own tooltip. */ +.gh-org-repo-desc { + font-size: var(--text-sm); color: var(--app-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + margin: 1px 0; +} + +/* Hover actions are INVISIBLE until hover, but they still took their width out + of the card — 36% of it at 1280, squeezing the description and wrapping the + meta line onto a row holding nothing but a separator. Out of flow: they + overlay the card's right edge when revealed, and cost nothing when not. */ +.gh-org-grid .row-actions { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + /* The half that was dropped when this was copied from `.dc-file`. Without it + the buttons sit ON the description added to these cards — invisible until + hover, and hover is the same gesture that reveals them, so the only reason + you would look at the card is the moment its last 110px of description + disappeared. A fade lets a long description run UNDER the buttons rather + than into them. */ + padding-left: 18px; + background: linear-gradient(to right, transparent, var(--app-hover) 18px, var(--app-hover)); +} +.gh-org-grid > *:focus-within .row-actions { + background: linear-gradient(to right, transparent, var(--app-active) 18px, var(--app-active)); +} +.gh-org-grid > * { position: relative; } + +/* People are a directory, not documents: fixed-width chips that wrap from the + left, so five members read as five people and not as five stretched cards. */ +.gh-org-grid.is-people { + grid-template-columns: repeat(auto-fill, minmax(190px, 230px)); + justify-content: start; +} +.gh-org-grid.is-people > .list-empty, +.gh-org-grid.is-people > .list-loading, +.gh-org-grid.is-people > .list-error { justify-self: stretch; } +.gh-org-grid > .list-empty, .gh-org-grid > .list-loading, .gh-org-grid > .list-error { + grid-column: 1 / -1; +} +.gh-org-grid > .list-row { + border: 1px solid var(--app-border); + background: var(--app-elevated); + border-radius: 10px; + box-shadow: var(--sheen); + padding: 9px 11px; + transition: background var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease), transform var(--dur-1) var(--ease); +} +.gh-org-grid > .list-row:hover { + border-color: color-mix(in srgb, var(--gs-accent) 32%, var(--app-border)); + background: color-mix(in srgb, var(--gs-accent) 5%, var(--app-elevated)); + transform: translateY(-1px); +} +.gh-org-grid > .list-row:active { transform: translateY(0); } +/* Card meta wraps to a second line instead of truncating: in a grid there is + vertical room, and the numbers are the point. */ +.gh-org-grid .row-meta-sub { + white-space: normal; + overflow: visible; + text-overflow: clip; + line-height: 1.45; +} +.gh-org-grid .row-meta { min-width: 0; } +/* The org sub-tab rows are clickable (open on GitHub) — restore the pointer the + shared .list-row sets to `default`, and let the avatar/icon sit inline. */ +.gh-org-repo, .gh-org-team, .gh-org-member { cursor: pointer; } +.gh-org-member .row-meta-title { font-size: 13px; } + +/* Organization header: identity (avatar · name · @login · description) on the + left, actions on the right — one block instead of four stacked bands split + by a rule. */ +.gh-org-head { + display: flex; + /* `.gh-detail-head` — the other class on this same element — sets + `flex-direction: column`, and this block never reset it. So the identity's + `flex: 1 1 auto` and the actions' `flex: 0 0 auto` below did nothing at + all: the avatar, the name, the description and the buttons stacked into + four rows 132px tall with 891px of empty space to their right, and the + description sat UNDER the buttons instead of with the name it describes. */ + flex-direction: row; + align-items: flex-start; + gap: var(--sp-4); + padding-bottom: var(--sp-4); +} +.gh-org-identity { display: flex; align-items: flex-start; gap: var(--sp-3); min-width: 0; flex: 1 1 auto; } +.gh-org-names { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.gh-org-head .gh-detail-title { font-size: var(--text-xl); } +.gh-org-head .gh-detail-meta { color: var(--app-muted); font-size: var(--text-sm); } +.gh-org-desc { margin-top: 6px; color: var(--vscode-foreground); max-width: 70ch; } +.gh-org-head .gh-detail-actions { flex: 0 0 auto; display: flex; gap: var(--sp-2); align-items: center; } + +/* No commits means nothing to select, so the details pane stands down rather + than telling you to select a commit that does not exist. */ +.graph-view.graph-no-history .graph-details, +.graph-view.graph-no-history .graph-split-resizer { display: none; } + +/* The peek header packed three buttons plus a close X into one row, leaving + the identity — the reason the card is open — about a third of the width. */ +.peek-head { gap: var(--sp-3); } +.peek-head .peek-title-wrap { flex: 1 1 auto; min-width: 0; } +.peek-actions { flex: 0 1 auto; min-width: 0; flex-wrap: wrap; justify-content: flex-end; row-gap: 6px; } + +/* ── projects ── */ +/* ── Projects board (append after the .gh-check-* block, ~line 1726 in app.css). + * Reuses gh-view/gh-body/gh-list/gh-row/gh-detail/gh-detail-head/gh-pill/ + * gh-check-dot from the PR view; only the board (columns + cards) is new. + * All colors come from existing tokens. */ + +/* The detail pane holds a horizontal board; let it scroll on the x-axis. */ +.gh-board-detail { display: flex; flex-direction: column; overflow: hidden; padding: 18px 20px; } +.gh-board-detail .gh-detail-head { flex: 0 0 auto; } +.gh-board { + flex: 1 1 auto; + min-height: 0; + display: flex; + gap: 12px; + overflow-x: auto; + overflow-y: hidden; + padding: 14px 2px 6px; +} +.gh-col { + /* Grow to fill the board (kill the horizontal void on wide windows), but stay + within a comfortable card width; many columns then scroll past min-width. */ + flex: 1 1 0; + min-width: 264px; + max-width: 360px; + display: flex; + flex-direction: column; + min-height: 0; + background: var(--app-panel); + border: 1px solid var(--app-border); + border-radius: 12px; } -.dropdown-item { +.gh-col-head { display: flex; align-items: center; - gap: 9px; - width: 100%; - text-align: left; - padding: 7px 9px; - border: none; - border-radius: 7px; - background: transparent; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid var(--app-border); + font-size: 12px; + font-weight: 650; color: var(--vscode-foreground); - font-family: inherit; - font-size: 12.5px; - cursor: pointer; } -.dropdown-item:hover { background: var(--app-hover); } -.dropdown-item .glyph { color: var(--app-muted); flex: 0 0 auto; } -.dropdown-item.is-current { color: var(--gs-accent-ink, var(--gs-accent)); font-weight: 600; background: var(--accent-soft); } -.dropdown-item.is-current .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } -.dropdown-item.is-disabled { color: var(--app-muted); cursor: default; opacity: 0.5; } -.dropdown-item.is-disabled:hover { background: transparent; } -.dropdown-label { +.gh-col-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.gh-col-head .gh-pill { margin: 0 0 0 auto; align-self: center; } +/* Clears the dock. `.dock-overlay` is absolute, opaque and does not reflow the + view above it — `--dock-reserve` is how a long scroller makes room. A project + board's columns scroll on their own and did not, so the last cards of every + column sat behind an open terminal. */ +.gh-col-body { flex: 1 1 auto; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.dropdown-sub { - flex: 0 1 auto; - font-size: 10.5px; - color: var(--app-muted); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 46%; -} -.dropdown-item .glyph:last-child { margin-left: auto; } - -/* ── Main area ─────────────────────────────────────────────────────────────── */ -.graph-host { - flex: 1 1 55%; - min-height: 140px; - background: var(--vscode-editor-background); - overflow: hidden; -} - -gitstudio-graph { - /* Size only — the element's shadow `:host` uses `display:flex; - * flex-direction:column`. A light-DOM `display:block` here would override it - * and collapse the flex:1 row scroller to zero height (no rows render). */ - height: 100%; - width: 100%; + min-height: 0; + overflow-y: auto; + padding: 8px 8px calc(8px + var(--dock-reserve, 0px)); + display: flex; + flex-direction: column; + gap: 8px; } +.gh-col-empty { min-height: 40px; } +/* An empty column shrinks to a lane and steps back, so the board's width goes + to the columns that have something in it. It stays a drop target. */ +.gh-col.is-empty { flex: 0 1 auto; min-width: 172px; opacity: 0.68; } +.gh-col.is-empty:hover, +.gh-col.is-empty.drag-over { opacity: 1; } -.details { +.gh-card { display: flex; flex-direction: column; - width: 100%; + gap: 5px; + padding: 9px 10px; + border: 1px solid var(--app-border); + border-radius: 9px; + background: var(--app-bg); + transition: border-color 110ms, background 110ms; +} +.gh-card.clickable { cursor: pointer; } +.gh-card.clickable:hover { + background: var(--app-hover); + border-color: color-mix(in srgb, var(--gs-accent) 35%, var(--app-border)); +} +.gh-card.clickable:active { transform: translateY(0.5px); } +/* A draft card (no issue/PR behind it) is inert — show it's not openable. */ +.gh-card:not(.clickable) { opacity: 0.72; } +/* While a move mutation is in flight, lock + dim the card so it's clear. */ +.gh-card.is-moving { opacity: 0.5; pointer-events: none; } +.gh-card.clickable:focus-visible { + outline: 2px solid var(--gs-focus-ring); + outline-offset: 1px; +} +.gh-card-top { display: flex; align-items: flex-start; gap: 7px; } +.gh-card-top .gh-check-dot { margin-top: 4px; } +.gh-card-title { + flex: 1 1 auto; min-width: 0; + font-size: 12.5px; + font-weight: 600; + line-height: 1.35; + color: var(--vscode-foreground); + /* two-line clamp for long titles */ + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; overflow: hidden; } - -.details-empty, -.diff-empty { +.gh-card-kebab { + flex: 0 0 auto; + display: inline-flex; align-items: center; justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; color: var(--app-muted); - font-size: 13px; - text-align: center; - padding: 24px; + cursor: pointer; + opacity: 0; + transition: opacity 110ms, background 110ms; } +.gh-card:hover .gh-card-kebab, +.gh-card.clickable:focus-visible .gh-card-kebab, +.gh-card-kebab:focus-visible { opacity: 1; } +.gh-card-kebab:hover { background: var(--app-hover); color: var(--vscode-foreground); } +.gh-card-kebab:focus-visible { opacity: 1; } +.gh-card-sub { font-size: 11px; color: var(--app-muted); } -.details-empty { - display: flex; -} +/* Item state dots, reusing the .gh-check-dot base shape (9px round). */ +.gh-check-dot.gh-state-open { background: var(--status-add); } +.gh-check-dot.gh-state-closed { background: var(--status-del); } +.gh-check-dot.gh-state-merged { background: var(--gs-accent); } +.gh-check-dot.gh-state-none { background: var(--app-muted); } -.details-head { - padding: 14px 16px; +/* ── gists ── */ +/* ── Gists: file header + read-only content + create/edit modal ──────────────── */ +.gist-file-head { + display: flex; + align-items: baseline; + gap: 10px; + padding: 10px 0 8px; border-bottom: 1px solid var(--app-border); } - -.details-subject { - font-size: 15px; +.gist-file-name { + font-size: 13px; font-weight: 650; - margin-bottom: 8px; - user-select: text; -} - -.details-meta { - display: flex; - gap: 8px; - flex-wrap: wrap; + color: var(--vscode-foreground); + word-break: break-all; } - -.meta-tag { +.gist-file-sub { font-size: 11px; - padding: 2px 8px; - border-radius: 999px; - background: var(--app-elevated); - border: 1px solid var(--app-border); color: var(--app-muted); - user-select: text; -} - -.meta-sha { - font-family: var(--vscode-editor-font-family); - color: var(--gs-accent-2); + white-space: nowrap; } - -.details-body { +.gist-content { margin: 12px 0 0; - padding: 10px 12px; - background: var(--app-elevated); + padding: 12px 14px; + background: var(--app-active, var(--vscode-textCodeBlock-background, rgba(127, 127, 127, 0.08))); border: 1px solid var(--app-border); border-radius: 8px; - font-family: var(--vscode-editor-font-family); - font-size: 12px; - white-space: pre-wrap; - max-height: 140px; overflow: auto; + max-height: calc(100vh - 260px); + font-family: var(--vscode-editor-font-family, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 12.5px; + line-height: 1.55; + white-space: pre; + tab-size: 2; +} +.gist-content code { + font-family: inherit; color: var(--vscode-foreground); - user-select: text; } -.details-files-title { - padding: 10px 16px 4px; - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.6px; - color: var(--app-muted); - font-weight: 600; +/* Create / edit gist modal — sits on the shared .modal-overlay scaffold. */ +.gist-modal { + width: min(var(--modal-md), 92vw); + display: flex; + flex-direction: column; + gap: 10px; +} +.gist-textarea { + min-height: 220px; + resize: vertical; + font-family: var(--vscode-editor-font-family, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 12.5px; + line-height: 1.5; + tab-size: 2; + white-space: pre; + overflow: auto; +} +.gist-visibility { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--vscode-foreground); + cursor: pointer; + user-select: none; +} +.gist-visibility input[type="checkbox"] { + accent-color: var(--gs-accent, var(--vscode-focusBorder)); + width: 15px; + height: 15px; +} +.gist-visibility input:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.gist-visibility:has(input:disabled) { + cursor: default; } -.details-files { - overflow-y: auto; - max-height: 30%; - flex: 0 0 auto; - padding: 0 8px 6px; +/* ════════════════════════════════════════════════════════════════════════════ + v3 SHELL — resizable/collapsible sidebar, terminal dock, clone dialog, + tonal button, skeletons. (2026-06-27 "make it a real app" pass.) + ════════════════════════════════════════════════════════════════════════════ */ + +/* ── Tonal / soft button (secondary accent action) ─────────────────────────── */ +.btn-soft { + display: inline-flex; + align-items: center; + gap: 8px; + height: 40px; + padding: 0 18px; + font-family: inherit; + font-size: 13px; + font-weight: 600; + color: var(--gs-accent-ink, var(--gs-accent)); + border: 1px solid var(--accent-line); + border-radius: 9px; + background: var(--accent-soft); + box-shadow: var(--sheen); + cursor: pointer; + transition: background var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease), + transform 100ms var(--ease); } +.btn-soft:hover { background: color-mix(in srgb, var(--gs-accent) 22%, transparent); } +.btn-soft:active { transform: translateY(0.5px); } +.btn-soft .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } -.file-row { +/* Welcome dual-action row. */ +.welcome-actions { display: flex; - align-items: center; - gap: 9px; - width: 100%; - border: none; - background: transparent; - color: var(--vscode-foreground); - padding: 4px 8px; - border-radius: 6px; - cursor: pointer; - text-align: left; - font-size: 12.5px; + gap: 10px; + margin-top: 10px; + animation: welcome-rise 700ms var(--ease-out) 180ms both; } +.welcome-actions .welcome-open, +.welcome-actions .welcome-clone { margin-top: 0; animation: none; } -.file-row:hover { - background: var(--app-hover); +/* ── Sidebar rail: collapse + drag-resize ──────────────────────────────────── */ +.nav-rail { transition: width 180ms var(--ease); } +body.resizing-h .nav-rail { transition: none; } +.nav-rail.collapsed { + width: 60px; + padding: 12px 8px; } - -.file-row.active { - background: var(--app-active); +.nav-rail.collapsed .nav-item { + justify-content: center; + gap: 0; + padding: 0; } +.nav-rail.collapsed .nav-label { display: none; } +.nav-rail.collapsed .nav-item.active::before { left: -8px; } +.nav-rail.collapsed .nav-divider { margin: 9px 10px 8px; justify-content: center; } +.nav-rail.collapsed .nav-divider-label { display: none; } +/* Collapsed, the label goes but the RULE stays: hiding both left the three + groups (Local · GitHub · Account) separated by nothing but a slightly bigger + gap, so fifteen icons read as one undifferentiated column. The group's name + moves to the divider's tooltip. */ +.nav-rail.collapsed .nav-divider::after { flex: 1 1 auto; height: 1px; } -.file-status { - font-family: var(--vscode-editor-font-family); - font-weight: 700; - width: 16px; - text-align: center; +/* ── The rail's right edge: a pure drag-to-resize handle ───────────────────── */ +.rail-resizer { flex: 0 0 auto; - font-size: 11px; + width: 8px; + margin: 0 -4px; + z-index: 4; + cursor: col-resize; + display: flex; + align-items: center; + justify-content: center; } +.rail-resizer-grip { width: 1px; height: 100%; background: transparent; transition: background 100ms var(--ease); } +.rail-resizer:hover .rail-resizer-grip, +body.resizing-h .rail-resizer-grip { background: var(--gs-accent); width: 2px; } +.nav-rail.collapsed + .rail-resizer { pointer-events: none; } -.status-A .file-status, -.status-C .file-status { - color: var(--status-add); +/* ── Main stack (routed view above the permanent terminal dock) ────────────── */ +.main-stack { + flex: 1 1 auto; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; } -.status-M .file-status, -.status-R .file-status { - color: var(--status-mod); +.main-stack > .view-host { flex: 1 1 auto; min-height: 0; } +body.resizing-v { cursor: row-resize; user-select: none; } + +/* ── Reusable collapsible bottom dock (BottomDock) ─────────────────────────── */ +.dock-mount { + /* Reserve ONLY the bar height in the layout — the whole panel floats in + .dock-overlay, so opening it never reflows the view above. */ + --dock-bar-h: 23px; + flex: 0 0 var(--dock-bar-h); + height: var(--dock-bar-h); + position: relative; + min-height: 0; } -.status-D .file-status { - color: var(--status-del); +/* Top resizer — drag the dock's top edge to resize the body height. */ +.dock-resizer { + /* The 8px grab strip sits entirely ABOVE the dock's top edge. It used to be + centred on that edge (margin: -4px 0), so its lower half covered the top + 4px of the panel tabs and the collapse chevron — you aimed at "Terminal", + got a row-resize cursor, and dragged the dock instead of switching tab. */ + flex: 0 0 8px; + margin: -8px 0 0; + z-index: 4; + cursor: row-resize; + display: flex; + align-items: center; + justify-content: center; } +.dock-resizer-grip { height: 1px; width: 100%; background: var(--app-border); transition: background 100ms var(--ease); } +.dock-resizer:hover .dock-resizer-grip, +body.resizing-v .dock-resizer-grip { background: var(--gs-accent); height: 2px; } +.dock-mount.collapsed .dock-resizer { display: none; } -.file-path { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-family: var(--vscode-editor-font-family); - font-size: 12px; +/* The whole panel floats anchored to the window bottom; children stack + resizer · BAR · body, so when open the bar sits ON TOP of the body and the + body fills beneath it down to the bottom. Collapsed, only the bar shows and + it sits exactly in the reserved bar slot at the base. */ +.dock-overlay { + position: absolute; + bottom: 0; + left: 0; + right: 0; + z-index: 30; + display: flex; + flex-direction: column; + background: var(--vscode-editor-background); +} +/* Expanded: lift the floating panel off the view (top hairline + soft shadow) + and separate the bar from the body with a hairline beneath it. */ +.dock-mount:not(.collapsed) .dock-overlay { + border-top: 1px solid var(--app-border); + box-shadow: 0 -12px 30px -16px rgba(0, 0, 0, 0.5); +} +.dock-mount:not(.collapsed) .dock-footer { + border-top: none; + border-bottom: 1px solid var(--app-border); } -.diff-surface { +/* The content area — pops UP above the footer; hidden when collapsed. */ +.dock-body { flex: 1 1 auto; min-height: 0; position: relative; - border-top: 1px solid var(--app-border); -} - -/* ── Diff mode chrome: file path + Inline/Split toggle above the editors ──── */ -.diffmode-wrap { display: flex; - flex-direction: column; - height: 100%; - min-height: 0; + animation: dock-rise 170ms var(--ease-out) both; } -.diffmode-bar { +@keyframes dock-rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } +.dock-mount.collapsed .dock-body { display: none; } + +/* ── The permanent footer bar — tiny + status-bar-like, always at the bottom ── */ +.dock-footer { flex: 0 0 auto; display: flex; align-items: center; - gap: 8px; - height: 34px; - padding: 0 8px 0 12px; - border-bottom: 1px solid var(--app-border); + gap: 2px; + height: 23px; + padding: 0 4px 0 6px; + /* Flat, minimal status-bar look (VS Code) — no gradient. */ + background: var(--app-panel); + border-top: 1px solid var(--app-border); + user-select: none; } -/* Left-truncate so the filename (the informative end) survives narrow panes. */ -.diffmode-path { - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - direction: rtl; - text-align: left; - font-family: var(--vscode-editor-font-family, ui-monospace, monospace); - font-size: 11.5px; +.dock-tabs { display: flex; min-width: 0; } +.dock-spacer { flex: 1 1 auto; align-self: stretch; } +.dock-actions { display: inline-flex; align-items: center; gap: 1px; } +.dock-icon-btn, +.dock-chevron { + display: inline-flex; + align-items: center; + justify-content: center; + /* The only pointer affordance for opening the dock, and it was 20x19 — the + smallest target in the app. As wide as the 23px footer allows, which is + still a third more area to aim at. */ + width: 26px; + height: 21px; + border: none; + border-radius: 5px; + background: transparent; color: var(--app-muted); + cursor: pointer; + transition: background var(--dur-1) var(--ease), color var(--dur-1) var(--ease); } -.diffmode-seg { flex: 0 0 auto; } -.diffmode-seg .cmp-mode-btn { +.dock-icon-btn:hover, +.dock-chevron:hover { background: var(--app-hover); color: var(--vscode-foreground); } +.dock-icon-btn:focus-visible, +/* focus ring consolidated into the shared :focus-visible group near the top */ +.dock-icon-btn .codicon { font-size: 14px; } +.dock-chevron .codicon { font-size: 13px; } + +/* ── Footer tabs (Commit details / Output / one-per-shell) — flat, VS-Code-panel + style: no boxes, just text that brightens on hover/active, with a thin accent + line on the active tab's top edge (connecting it to the panel above). ─────── */ +.dock-tabs { display: flex; align-self: stretch; min-width: 0; } +.term-tabs { display: inline-flex; align-items: stretch; gap: 1px; min-width: 0; } +.term-tab { + position: relative; display: inline-flex; align-items: center; - gap: 5px; - height: 24px; - padding: 0 10px; - font-size: 11.5px; + gap: 6px; + max-width: 168px; + height: 100%; + padding: 0 11px; + border: none; + border-radius: 0; + background: transparent; + color: var(--app-muted); + font-family: inherit; + font-size: var(--text-2xs); + font-weight: 600; + letter-spacing: 0.02em; + cursor: pointer; + transition: color var(--dur-1) var(--ease); } -.diffmode-seg .cmp-mode-btn .glyph { font-size: 12px; } -.diffmode-body { +.term-tab:hover { color: var(--vscode-foreground); } +.term-tab.active { color: var(--vscode-foreground); } +/* The only chrome on an active tab: a thin accent underline along its bottom edge. */ +.term-tab.active::after { + content: ""; + position: absolute; + left: 9px; + right: 9px; + bottom: 0; + height: 2px; + border-radius: 2px 2px 0 0; + background: var(--gs-accent); +} +/* Collapsed (just the status bar): no active indicator — nothing is "open". */ +.dock-mount.collapsed .term-tab.active::after { display: none; } +.dock-mount.collapsed .term-tab.active { color: var(--app-muted); } +/* Icons are small + grayed — no accent; the label + underline carry the state. */ +.term-tab .glyph { flex: 0 0 auto; color: var(--app-muted); } +.term-tab .glyph .codicon { font-size: 12px; } +.term-tab-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.term-tab-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 15px; + height: 15px; + margin-right: -3px; + border-radius: 5px; + color: var(--app-muted); + opacity: 0; + transition: opacity var(--dur-1) var(--ease), background var(--dur-1) var(--ease), + color var(--dur-1) var(--ease); +} +.term-tab:hover .term-tab-close, +.term-tab.active .term-tab-close { opacity: 0.65; } +.term-tab-close:hover { opacity: 1; background: var(--app-hover); color: var(--vscode-foreground); } +.term-tab-close .codicon { font-size: 10px; } + +/* The xterm surface that fills a terminal tab. */ +.term-surface { flex: 1 1 auto; min-height: 0; + width: 100%; position: relative; + padding: 6px 6px 6px 10px; + overflow: hidden; } +.term-surface .xterm { height: 100%; } -/* The shared <gitstudio-commit-details> inspect panel + the inline diff below - * it. The diff surface stays hidden until a file is opened, then splits. */ -/* The commit-details surface inside the bottom dock fills the dock body; its - child (the details split, or the empty-state placeholder) fills + centers. */ -/* Fill the dock body (a flex row) like .term-surface does — otherwise it shrinks - to content width and the commit details ignore the available space. */ -.dock-details-surface { flex: 1 1 auto; width: 100%; min-width: 0; height: 100%; min-height: 0; display: flex; } -.dock-details-surface > * { flex: 1 1 auto; min-width: 0; min-height: 0; } - -/* Docked commit details = a wide, short split: the metadata/file panel on the - LEFT, the file diff on the RIGHT (uses the wide footer space, both visible). */ -.details-split { +/* The single Terminal view: the shell stage (left) + a VS-Code-style side list + of terminals (right) with a "New terminal" button and per-shell kill button. */ +.term-group { flex: 1 1 auto; min-width: 0; min-height: 0; display: flex; } +.term-stage { flex: 1 1 auto; min-width: 0; min-height: 0; position: relative; display: flex; } +.term-empty { + flex: 1 1 auto; display: flex; - flex-direction: row; - width: 100%; - min-width: 0; - height: 100%; - overflow: hidden; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--app-muted); + font-size: 12.5px; } -/* Commit details fill the dock by default (no diff pane). The metadata/file - panel scrolls ITSELF (<gitstudio-commit-details> has :host{overflow:hidden} + - an internal .scroll) — so NO overflow here, or you get a double scrollbar. - Opening a file (.diff-open) docks the panel to a fixed left column and reveals - the diff filling the rest. */ -.details-split .details-panel { - flex: 1 1 auto; +.term-empty .codicon { font-size: 15px; } +.term-side { + /* Width is set inline + drag-resized (see TerminalDock); was a fixed 196px slab. */ + flex: 0 0 auto; min-width: 0; - min-height: 0; -} -/* The file diff lives in the bottom dock's "Diff" tab now, so this column is - details-only — no inner split, no divider. */ - -.diff-empty { display: flex; - height: 100%; + flex-direction: column; + gap: 5px; + padding: 6px; + border-left: 1px solid var(--app-border); + background: var(--app-panel); + overflow-y: auto; } - -/* ── Context menu ──────────────────────────────────────────────────────────── */ -.ctx-menu { - position: fixed; - z-index: 1000; - min-width: 200px; - background: color-mix(in srgb, var(--app-elevated) 96%, var(--vscode-foreground)); - border: 1px solid var(--app-border); - border-radius: 11px; - box-shadow: var(--sheen), var(--shadow-pop); - padding: 5px; - overflow: hidden; +/* Drag handle between the terminal stage and its list. */ +.term-side-resizer { + flex: 0 0 auto; + width: 7px; + margin: 0 -3px 0 -4px; + position: relative; + z-index: 3; + cursor: col-resize; } - -.ctx-menu-header { - font-family: var(--vscode-editor-font-family); - font-size: 11px; - color: var(--app-muted); - padding: 5px 10px 7px; - border-bottom: 1px solid var(--app-border); - margin-bottom: 4px; +.term-side-resizer-grip { + position: absolute; + inset: 6px 2px; + border-radius: 2px; + transition: background var(--dur-1) var(--ease); } - -.ctx-menu-item { - display: block; - width: 100%; - border: none; - background: transparent; +.term-side-resizer:hover .term-side-resizer-grip, +.term-side-resizer:focus-visible .term-side-resizer-grip { + background: color-mix(in srgb, var(--gs-accent) 50%, transparent); +} +.term-side-resizer:focus-visible { outline: none; } +.term-side-add { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 6px; + height: 26px; + padding: 0 9px; + border: 1px solid var(--app-border); + border-radius: 7px; + background: var(--app-elevated); + box-shadow: var(--sheen); color: var(--vscode-foreground); - padding: 6px 10px; + font-family: inherit; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease); +} +.term-side-add:hover { background: var(--app-hover); border-color: var(--accent-line); } +.term-side-add .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } +.term-side-add .codicon { font-size: 13px; } +.term-side-list { display: flex; flex-direction: column; gap: 1px; } +.term-side-row { + position: relative; + display: flex; + align-items: center; + gap: 7px; + height: 28px; + padding: 0 7px 0 9px; + border: none; border-radius: 6px; - text-align: left; + background: transparent; + color: var(--app-muted); + font-family: inherit; font-size: 12.5px; + text-align: left; cursor: pointer; + transition: background var(--dur-1) var(--ease), color var(--dur-1) var(--ease); } - -.ctx-menu-item:hover { - background: var(--app-hover); +.term-side-row:hover { background: var(--app-hover); color: var(--vscode-foreground); } +.term-side-row.active { + background: color-mix(in srgb, var(--gs-accent) 14%, transparent); + color: var(--vscode-foreground); } - -.ctx-danger { - color: var(--status-del); +.term-side-row.active::before { + content: ""; + position: absolute; + left: 0; + top: 5px; + bottom: 5px; + width: 2px; + border-radius: 0 2px 2px 0; + background: var(--gs-accent); } - -.ctx-danger:hover { - background: color-mix(in srgb, var(--status-del) 14%, transparent); +.term-side-row .codicon { font-size: 13px; } +/* A shell that has exited is not a terminal any more. It used to keep the live + label and a blinking cursor while swallowing every keystroke. */ +/* Receding by COLOUR, not by opacity. A blanket `opacity: 0.62` multiplies with + whatever each child already uses to recede, so the "exited" badge — already + at --app-muted — took both and measured 2.41:1 in light, 2.84:1 in dark. The + row's NAME is the only thing that says which shell died, and it sat at + 3.72:1. Same treatment `.notif-read` was given. */ +/* Receding by COLOUR, not by opacity. A blanket `opacity: 0.62` multiplies with + whatever each child already uses to recede, so the "exited" badge — already + at --app-muted — took both and measured 2.41:1 in light. The row's NAME is + the only thing that says which shell died, and it sat at 3.72:1. */ +.term-side-row.is-exited { opacity: 1; } +.term-side-row.is-exited .term-side-label { color: var(--app-muted); } +.term-side-row.is-exited .codicon { opacity: 0.7; } +.term-side-dead { + margin-left: auto; + font-size: 10px; + letter-spacing: 0.06em; + text-transform: uppercase; + /* Its own ink, not the muted one it sits beside — at 10px and uppercase this + is the smallest text in the dock and needs the contrast most. */ + color: var(--vscode-foreground); + opacity: 0.75; +} +.term-side-label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* The row and its kill button sit side by side rather than one inside the + other — see the comment in terminalDock.ts. The row takes the space; the + button overlays its right edge so the layout is unchanged. */ +.term-side-item { position: relative; display: flex; align-items: center; } +.term-side-item > .term-side-row { flex: 1 1 auto; min-width: 0; padding-right: 26px; } +.term-side-close { + position: absolute; + right: 7px; + border: none; + background: transparent; + cursor: pointer; + padding: 0; + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 5px; + color: var(--app-muted); + opacity: 0; + transition: opacity var(--dur-1) var(--ease), background var(--dur-1) var(--ease), color var(--dur-1) var(--ease); } +.term-side-item:hover .term-side-close, +.term-side-item:focus-within .term-side-close, +.term-side-item:has(.term-side-row.active) .term-side-close { opacity: 0.7; } +.term-side-close:hover { opacity: 1; background: var(--app-hover); color: var(--vscode-errorForeground, #e15a5a); } +.term-side-close .codicon { font-size: 12px; } +.term-unavailable { padding: 16px; color: var(--app-muted); font-size: var(--text-sm); } -/* Scrollbars — match the app, not the OS default. */ -::-webkit-scrollbar { - width: 11px; - height: 11px; +/* ── Output tab — the live git-command log ─────────────────────────────────── */ +.outputs-wrap { + flex: 1 1 auto; + min-height: 0; + width: 100%; + display: flex; + flex-direction: column; } -::-webkit-scrollbar-thumb { - background: var(--vscode-scrollbarSlider-background); - border-radius: 6px; - border: 3px solid transparent; - background-clip: padding-box; +/* The Output controls now sit in the dock's own footer beside the tabs, so + Output and Terminal share one content origin instead of Output carrying a + 32px chrome row of its own. */ +.outputs-bar { + display: inline-flex; + align-items: center; + gap: 6px; + padding-right: 6px; } -::-webkit-scrollbar-thumb:hover { - background: var(--vscode-scrollbarSlider-hoverBackground); - background-clip: padding-box; +.outputs-bar[hidden] { display: none; } +.outputs-count { font-size: var(--text-xs); color: var(--app-muted); display: inline-flex; gap: 4px; } +.outputs-count-f { color: var(--gs-danger, #ff8585); font-weight: 600; } +.outputs-failbtn.is-on { + color: var(--gs-danger, #ff8585); + border-color: color-mix(in srgb, var(--gs-danger, #ff8585) 42%, transparent); + background: color-mix(in srgb, var(--gs-danger, #ff8585) 12%, var(--app-panel, transparent)); } -::-webkit-scrollbar-corner { - background: transparent; +.outputs-panel { + flex: 1 1 auto; + min-height: 0; + width: 100%; + overflow-y: auto; + padding: 6px 12px 12px; + font-family: var(--vscode-editor-font-family, ui-monospace, Menlo, monospace); + font-size: 12px; + line-height: 1.7; } - -/* ════════════════════════════════════════════════════════════════════════ - GitHub section views (issues · PRs · actions · releases · notifications · - orgs · projects · gists) — generated per-section, integrated 2026-06-27. - ════════════════════════════════════════════════════════════════════════ */ - -/* ── issues ── */ -/* ── Issues: header tools, label/assignee chips, composer ───────────────────── */ -.gh-head-tools { display: flex; align-items: center; gap: 8px; margin-left: auto; } -/* Header action buttons (primary CTAs in a section header) are slim — the same - 28px height as the secondary mini-btn beside them, so a header never mixes a - gigantic 40px .btn with a slim button. The .btn-primary fill/sheen is kept. */ -.gh-new-btn, -.gh-run-btn, -.gh-head-primary { - height: 28px; - padding: 0 12px; - font-size: 12.5px; - font-weight: 600; - border-radius: 8px; - gap: 6px; +.outputs-empty { + display: flex; + flex-direction: column; + gap: 3px; + padding: 16px 2px; } +.outputs-empty-title { font-weight: 700; color: var(--vscode-foreground); font-size: var(--text-sm); } +.outputs-empty-sub { color: var(--app-muted); font-size: var(--text-xs); } +.outputs-list { display: flex; flex-direction: column; gap: 1px; } +/* "Errors only" filter hides successful rows and all-successful groups. */ +.outputs-wrap.failures-only .outputs-row:not(.is-failed) { display: none; } +.outputs-wrap.failures-only .outputs-group:not(.has-fail) { display: none; } -/* Shared header search/filter field (see common.ts searchField). */ -.gh-search { - display: inline-flex; +/* ── Action groups: the commands ONE user action executed, under its label ── */ +.outputs-group { margin: 3px 0; } +.outputs-group-head { + display: flex; align-items: center; gap: 6px; - height: 28px; - padding: 0 6px 0 9px; - min-width: 150px; - max-width: 240px; - border-radius: 8px; - border: 1px solid var(--app-border); - background: var(--app-elevated); - box-shadow: var(--sheen); - transition: border-color var(--dur-1) var(--ease), box-shadow var(--dur-1) var(--ease); -} -.gh-search:focus-within { - border-color: var(--accent-line); - box-shadow: var(--sheen), 0 0 0 2px color-mix(in srgb, var(--gs-accent) 18%, transparent); -} -/* The search sits on the LEFT, inside the title cluster (.gh-head-titlewrap), - right after the page name + count. Keep it from shrinking below its min width. */ -.gh-head-titlewrap > .gh-search { flex: 0 0 auto; margin-left: 2px; } -.gh-search-icon { color: var(--app-muted); font-size: 14px; flex: 0 0 auto; } -.gh-search-input { - flex: 1 1 auto; - min-width: 0; + width: 100%; + padding: 1px 6px; border: none; + border-radius: 5px; background: transparent; - outline: none; color: var(--vscode-foreground); - font-family: inherit; - font-size: 12.5px; + font: inherit; + text-align: left; + cursor: pointer; } -.gh-search-input::placeholder { color: var(--app-muted); } -.gh-search-input::-webkit-search-cancel-button { display: none; } -.gh-search-clear { +.outputs-group-head:hover { background: var(--app-hover); } +.outputs-group-chev { + font-size: 11px; + color: var(--app-muted); + transition: transform var(--dur-1, 110ms) var(--ease, ease); +} +.outputs-group.is-collapsed .outputs-group-chev { transform: rotate(-90deg); } +.outputs-group-label { + font-family: var(--vscode-font-family, sans-serif); + font-size: var(--text-xs, 11.5px); + font-weight: 650; +} +.outputs-group.has-fail .outputs-group-label { color: var(--gs-danger, #ff8585); } +.outputs-group-meta { font-size: 11px; color: var(--app-muted); } +/* Commands indent under their action along a soft rail. */ +.outputs-group-body { + margin-left: 11px; + padding-left: 8px; + border-left: 1px solid var(--app-border); +} +.outputs-group.is-collapsed .outputs-group-body { display: none; } +/* Lean rows tag their action inline, muted, in the UI font. */ +.outputs-act { flex: 0 0 auto; - display: inline-flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - border: none; - background: transparent; + font-family: var(--vscode-font-family, sans-serif); + font-size: 10.5px; + color: var(--app-muted); + opacity: 0.85; + padding: 0 5px; + border-radius: 4px; + background: color-mix(in srgb, var(--vscode-foreground) 7%, transparent); +} +.outputs-row { border-radius: 5px; } +.outputs-line { + display: flex; + align-items: baseline; + gap: 8px; + padding: 0 6px; border-radius: 5px; + white-space: nowrap; +} +.outputs-line:hover { background: var(--app-hover, var(--vscode-list-hoverBackground)); } +.outputs-time { flex: 0 0 auto; color: var(--app-muted); opacity: 0.7; font-size: 11px; } +.outputs-kw { flex: 0 0 auto; color: var(--app-muted); opacity: 0.8; } +.outputs-sub { flex: 0 0 auto; color: var(--gs-accent-ink, var(--gs-accent)); font-weight: 700; } +.outputs-args { min-width: 0; overflow: hidden; text-overflow: ellipsis; color: color-mix(in srgb, var(--vscode-foreground) 78%, transparent); } +.outputs-rep { + flex: 0 0 auto; color: var(--app-muted); - cursor: pointer; + font-size: 10.5px; + font-weight: 700; } -.gh-search-clear .codicon { font-size: 12px; } -.gh-search-clear:hover { background: var(--app-hover); color: var(--vscode-foreground); } - -/* State pills in the detail meta. */ -.gh-issue-open { color: var(--status-add); background: color-mix(in srgb, var(--status-add) 16%, transparent); } -.gh-issue-closed { color: var(--status-del); background: color-mix(in srgb, var(--status-del) 16%, transparent); } - -/* Label chips (list rows + detail). Layout only — the canonical, AA-locked - `.gh-label-chip` color rule lives further down (search ".gh-label-chip {"). */ -.gh-row-labels, .gh-detail-labels { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 5px; } -.gh-detail-labels { margin: 10px 0 4px; } - -/* Assignee row in the detail. */ -.gh-detail-assignees { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin: 6px 0 4px; } -.gh-assign-label { font-size: 11px; color: var(--app-muted); } - -/* Comment head meta + empty-body placeholder. */ -.gh-comment-when { font-weight: 400; color: var(--app-muted); } -.gh-empty-body { color: var(--app-muted); font-style: italic; } +.outputs-rep:empty { display: none; } +.outputs-dur { flex: 0 0 auto; margin-left: auto; color: var(--app-muted); opacity: 0.65; font-size: 11px; } +.outputs-dur.is-slow { color: var(--status-warn, #e0a44e); opacity: 1; } +.outputs-code { + flex: 0 0 auto; + padding: 0 6px; + border-radius: 5px; + font-size: 10.5px; + font-weight: 700; + color: #fff; + background: color-mix(in srgb, var(--gs-danger, #ff6b6b) 82%, black 8%); +} +/* Failed rows: a red-tinted block; expandable when stderr was captured. */ +.outputs-row.is-failed { + background: color-mix(in srgb, var(--gs-danger, #ff6b6b) 7%, transparent); +} +.outputs-row.is-failed .outputs-sub, +.outputs-row.is-failed .outputs-args { color: var(--gs-danger, #ff8585); } +.outputs-row.is-failed .outputs-line[role="button"] { cursor: pointer; } +.outputs-chevron { + flex: 0 0 auto; + align-self: center; + font-size: 11px; + color: var(--app-muted); + transition: transform var(--dur-1, 110ms) var(--ease, ease); +} +.outputs-row.is-open .outputs-chevron { transform: rotate(90deg); } +.outputs-stderr { + display: none; + margin: 0 6px 4px; + padding: 6px 9px; + border-radius: 5px; + border-left: 2px solid color-mix(in srgb, var(--gs-danger, #ff6b6b) 55%, transparent); + background: color-mix(in srgb, var(--gs-danger, #ff6b6b) 5%, var(--app-panel, transparent)); + color: color-mix(in srgb, var(--vscode-foreground) 82%, transparent); + font-size: 11.5px; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; +} +.outputs-row.is-open .outputs-stderr { display: block; } -/* Comment composer at the bottom of the issue detail. */ -.gh-composer { margin-top: 16px; display: flex; flex-direction: column; gap: 8px; max-width: 820px; } -.gh-composer-input { - width: 100%; - min-height: 92px; - resize: vertical; - padding: 10px 12px; +/* ── Clone dialog ──────────────────────────────────────────────────────────── */ +.clone-card { width: min(var(--modal-md), 92vw); gap: 14px; } +.clone-tabs { align-self: flex-start; } +.clone-tabs .gh-seg-btn { height: 30px; padding: 0 14px; } +.clone-panel { display: flex; flex-direction: column; gap: 10px; } +.clone-gh { gap: 10px; } +.clone-url-input, +.clone-search { width: 100%; font-family: var(--vscode-editor-font-family, ui-monospace, monospace); } +.clone-search { font-family: inherit; } +.clone-repo-list { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 320px; + overflow-y: auto; border: 1px solid var(--app-border); border-radius: 10px; - background: var(--app-panel); - color: var(--vscode-foreground); - font-family: inherit; - font-size: 13px; - line-height: 1.5; + padding: 5px; + background: var(--app-bg); } -.gh-composer-input:focus { - outline: none; - border-color: color-mix(in srgb, var(--gs-accent) 55%, var(--app-border)); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--gs-accent) 22%, transparent); +.clone-repo { align-items: flex-start; padding: 8px 10px; border-radius: 8px; } +.clone-repo.is-current { + background: var(--accent-soft); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--gs-accent) 30%, transparent); } -.gh-composer-actions { display: flex; align-items: center; justify-content: flex-end; gap: 10px; } -/* The AI "Draft a reply" affordance is a peer of the primary "Comment", so it - reads as one button family, not a tiny chip glued to a big button: match the - primary's height + corner radius while keeping the accent-tinted AI face. */ -.gh-composer-actions .ai-chip { - height: 40px; - padding: 0 16px; - border-radius: 9px; - font-size: 13px; +.clone-repo-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1 1 auto; } +.clone-repo-name { + display: flex; + align-items: center; + gap: 7px; + font-size: var(--text-base); font-weight: 600; - background: color-mix(in srgb, var(--gs-accent) 12%, var(--app-elevated)); - border-color: color-mix(in srgb, var(--gs-accent) 42%, var(--app-border)); + color: var(--vscode-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } +.clone-repo-badge { + flex: 0 0 auto; + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 1px 6px; + border-radius: 999px; + color: var(--app-muted); + background: color-mix(in srgb, var(--app-muted) 18%, transparent); +} +.clone-repo-desc { + font-size: var(--text-xs); + color: var(--app-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.clone-repo-meta { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 2px; } +.clone-meta-bit { font-size: 11px; color: var(--app-muted); font-variant-numeric: tabular-nums; } +.clone-scheme { align-self: flex-start; } +.clone-scheme .gh-seg-btn { height: 26px; padding: 0 12px; } -/* ── prs ── */ -/* ── Pull Requests section (renderer/views/prs.ts) ─────────────────────────── - Appended after the existing PR/gh-* block. Reuses tokens already in the file: - --app-*, --gs-accent, --vscode-*, --status-*. Most surfaces reuse existing - .mini-btn / .modal-* / .gh-* classes; only the header action, the icon-only - overflow button, the detail-meta state pills, and the Create-PR / reviewer - modals need new rules. */ - -/* Header "New PR" action sits left of the account cluster in the head row. */ -.gh-head-action { margin-left: auto; } -.list-head-row .gh-head-action + .gh-acct { margin-left: 8px; } - -/* Icon-only overflow ("⋯") button in the detail action cluster. */ -.gh-icon-btn { padding: 0 8px; } -.gh-icon-btn .glyph .codicon { font-size: 16px; } - -/* The Draft pill on a list row keeps the base .gh-pill top margin; tint it. */ -.gh-pill-draft { color: var(--app-muted); background: color-mix(in srgb, var(--app-muted) 20%, transparent); } - -/* State pill in the detail meta: it lives in a horizontal flex row, so cancel - the base .gh-pill margin-top and give it a touch more presence. */ -.gh-detail-meta { gap: 0 4px; } -.gh-detail-meta .gh-pill { margin-top: 0; align-self: center; } -.gh-state-open { color: var(--status-add); background: color-mix(in srgb, var(--status-add) 16%, transparent); } -.gh-state-closed { color: var(--status-del); background: color-mix(in srgb, var(--status-del) 16%, transparent); } -.gh-state-merged { color: var(--gs-accent); background: color-mix(in srgb, var(--gs-accent) 16%, transparent); } -.gh-state-draft { color: var(--app-muted); background: color-mix(in srgb, var(--app-muted) 20%, transparent); } -/* Semantic variants so Releases/Gists pills stop collapsing into gray "draft". */ -.gh-state-prerelease { color: var(--status-warn); background: color-mix(in srgb, var(--status-warn) 18%, transparent); } -.gh-state-latest { color: var(--status-add); background: color-mix(in srgb, var(--status-add) 16%, transparent); } -.gh-state-public { color: var(--status-add); background: color-mix(in srgb, var(--status-add) 15%, transparent); } -.gh-state-private { color: var(--app-muted); background: color-mix(in srgb, var(--app-muted) 18%, transparent); } - -/* Pipelines rows that link out get the affordance only when clickable. */ -.gh-check-row.is-link { cursor: pointer; } - -/* Multi-field Create-PR + reviewer modals (extend the shared .modal-card). */ -.gh-pr-form { width: min(540px, 92vw); gap: 14px; } -.gh-form-row { display: flex; flex-direction: column; gap: 5px; } -.gh-form-label { font-size: 11.5px; font-weight: 600; color: var(--app-muted); text-transform: uppercase; letter-spacing: 0.03em; } -.gh-form-select { - height: 34px; - padding: 0 10px; - border-radius: 9px; +/* One field shape: caption, then control, all on one left edge. The rows used + to be three different things — a bare input, a bordered card holding a + label-value-button row, and a bordered card holding a caption above a + BORDERED input. */ +.clone-field { display: flex; flex-direction: column; gap: 6px; min-width: 0; } +.clone-field > .modal-input { width: 100%; } +.clone-dest-control { + display: flex; + align-items: center; + gap: 10px; + min-height: 34px; + padding: 0 6px 0 12px; border: 1px solid var(--app-border); + border-radius: var(--r-md); background: var(--app-panel); - color: var(--vscode-foreground); - font-family: inherit; - font-size: 13px; - outline: none; } -.gh-form-select:focus { - border-color: color-mix(in srgb, var(--gs-accent) 55%, var(--app-border)); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--gs-accent) 22%, transparent); +.clone-dest-control > .clone-dest-path { flex: 1 1 auto; min-width: 0; } +.clone-field-label, +.clone-dest-label { + font-size: var(--text-2xs); + font-weight: 700; + letter-spacing: var(--track-label); + text-transform: uppercase; + color: var(--app-muted); } -.gh-form-textarea { - min-height: 96px; - padding: 9px 12px; - border-radius: 9px; - border: 1px solid var(--app-border); - background: var(--app-panel); +.clone-dest-path { + font-size: var(--text-sm); color: var(--vscode-foreground); - font-family: inherit; - font-size: 13px; - line-height: 1.5; - resize: vertical; - outline: none; -} -.gh-form-textarea:focus { - border-color: color-mix(in srgb, var(--gs-accent) 55%, var(--app-border)); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--gs-accent) 22%, transparent); + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.gh-form-check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--vscode-foreground); cursor: pointer; } -.gh-form-check input { accent-color: var(--gs-accent); width: 15px; height: 15px; } - -/* Reviewer multi-select list (scrolls if many collaborators). */ -.gh-reviewer-list { display: flex; flex-direction: column; gap: 2px; max-height: 280px; overflow-y: auto; padding: 4px 2px; } -.gh-reviewer-list .gh-form-check { padding: 5px 6px; border-radius: 7px; } -.gh-reviewer-list .gh-form-check:hover { background: var(--app-hover); } - -/* ── actions ── */ -/* ── GitHub Actions section ───────────────────────────────────────────────── - Append to styles/app.css. Most classes (.gh-view/.gh-list/.gh-row/.gh-detail* - /.gh-checks-*/.gh-check-*/.mini-btn/.btn/.row-btn/.row-meta*) already exist and - are reused verbatim — only the NEW Actions classes + 4 missing codicon - codepoints + the extra Actions status colours are added below. */ - -/* Header toolbar (Workflows + Run workflow) sits left of the account cluster. */ -.gh-head-tools { display: flex; align-items: center; gap: 8px; margin-left: auto; margin-right: 12px; } - -/* Run row: status dot inline with an ellipsised title. */ -.gh-row-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.clone-choose { flex: 0 0 auto; } -/* Jobs list inside the run detail. */ -.gh-jobs { display: flex; flex-direction: column; gap: 8px; margin-top: 14px; } -.gh-job { border: 1px solid var(--app-border); border-radius: 8px; overflow: hidden; } -.gh-job-head { - display: flex; align-items: center; gap: 9px; width: 100%; - padding: 9px 11px; background: transparent; border: 0; cursor: pointer; - font: inherit; color: var(--vscode-foreground); text-align: left; +/* Folder-name override (clone dialog) + the destination sheet. */ +.clone-name-input, +.dest-name-input { + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); } -.gh-job-head:hover { background: var(--app-hover); } -.gh-job-head .gh-check-name { flex: 1 1 auto; } -.gh-job-chevron { transition: transform 0.12s ease; color: var(--app-muted); flex: 0 0 auto; } -.gh-job-head.open .gh-job-chevron { transform: rotate(90deg); } -.gh-job-log { margin-left: 8px; flex: 0 0 auto; } -.gh-job-steps { display: flex; flex-direction: column; border-top: 1px solid var(--app-border); } -.gh-job-steps.hidden { display: none; } -.gh-step-row { - display: flex; align-items: center; gap: 9px; - padding: 6px 11px 6px 30px; font-size: 12.5px; +.clone-name-input.is-invalid, +.dest-name-input.is-invalid { + border-color: var(--vscode-editorError-foreground, #e5534b); } -.gh-step-row + .gh-step-row { border-top: 1px solid color-mix(in srgb, var(--app-border) 60%, transparent); } -.gh-step-empty { color: var(--app-muted); padding-left: 30px; } - -/* Workflows list in the detail pane. */ -.gh-wf-list { display: flex; flex-direction: column; margin-top: 14px; } -.gh-wf-row { - display: flex; align-items: center; gap: 10px; - padding: 9px 4px; border-bottom: 1px solid var(--app-border); +.dest-name-error { + font-size: var(--text-xs); + color: var(--vscode-editorError-foreground, #e5534b); } -.gh-wf-row .row-meta { flex: 1 1 auto; min-width: 0; } -.gh-wf-row .mini-btn, .gh-wf-row .row-btn { flex: 0 0 auto; } - -/* Dispatch form rendered into the detail pane. */ -.gh-dispatch-form { display: flex; flex-direction: column; gap: 12px; margin-top: 16px; max-width: 520px; } -.gh-dispatch-row { display: flex; flex-direction: column; gap: 5px; } -.gh-dispatch-label { font-size: 12px; color: var(--app-muted); font-weight: 600; } -.gh-dispatch-input { - height: 30px; padding: 0 10px; font: inherit; font-size: 13px; - color: var(--vscode-foreground); background: var(--app-bg); - border: 1px solid var(--app-border); border-radius: 7px; outline: none; +.dest-card { width: min(var(--modal-sm), 92vw); display: flex; flex-direction: column; gap: 12px; } +.dest-card .modal-actions { margin-top: 2px; } +.dest-name-label { + font-size: var(--text-2xs); + font-weight: 700; + letter-spacing: var(--track-label); + text-transform: uppercase; + color: var(--app-muted); + margin-bottom: -6px; } -.gh-dispatch-input:focus-visible { border-color: color-mix(in srgb, var(--gs-accent) 55%, var(--app-border)); } -select.gh-dispatch-input { padding-right: 6px; height: 30px; } -.gh-dispatch-note { font-size: 12.5px; color: var(--app-muted); line-height: 1.5; } -/* Searchable combobox (comboField): an input + a filtered suggestion dropdown. */ -.gh-combo { position: relative; width: 100%; } -.gh-combo-input { width: 100%; } -.gh-combo-input:focus-within, -.gh-combo-input:focus { border-color: color-mix(in srgb, var(--gs-accent) 55%, var(--app-border)); } -/* Body-appended floating popover (position + size set in JS) — never clipped by - a scrolling container, flips above the field when there's no room below. */ -.gh-combo-menu { - position: fixed; - z-index: 2200; - max-height: 244px; - overflow-y: auto; - padding: 4px; - border-radius: 9px; +/* Settings → Repositories card. */ +.settings-clonedir-row { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; border: 1px solid var(--app-border); - background: var(--app-elevated); - box-shadow: var(--sheen), var(--shadow-pop); - animation: gs-card-in 100ms var(--ease-out) both; + border-radius: 10px; + background: var(--app-panel); } -.gh-combo-item { - display: block; - width: 100%; - text-align: left; - padding: 6px 9px; - border: none; - border-radius: 6px; - background: transparent; +.settings-clonedir-text { flex: 1 1 auto; min-width: 0; } +.settings-clonedir-path { + font-size: var(--text-sm); color: var(--vscode-foreground); - font-family: var(--vscode-editor-font-family, var(--vscode-font-family)); - font-size: 12.5px; - white-space: nowrap; + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); overflow: hidden; text-overflow: ellipsis; - cursor: pointer; -} -.gh-combo-item.active { background: color-mix(in srgb, var(--gs-accent) 18%, transparent); } -.gh-combo-item:hover { background: var(--app-hover); } -.gh-dispatch-form .gh-detail-actions { margin-top: 4px; } -.gh-meta-text { white-space: nowrap; } - -/* Codicon codepoints used by the Actions view that the curated subset lacked — - without these, the glyphs render BLANK. Verified against @vscode/codicons. */ -.codicon-list-unordered::before { content: "\eb17"; } -.codicon-debug-restart::before { content: "\ead2"; } -.codicon-output::before { content: "\eb9d"; } -.codicon-circle-slash::before { content: "\eabd"; } - -/* Extra Actions status colours not in the PR-only set (pill backgrounds). - `pending`/`in_progress`/`queued`/`cancelled` already exist for .gh-check-dot; - these add the pill-background variants + the conclusions Actions emits that - PRs didn't (cancelled/skipped/neutral/timed_out/action_required/etc.). */ -.gh-checks-timed_out, .gh-checks-startup_failure { - color: var(--status-del); background: color-mix(in srgb, var(--status-del) 16%, transparent); -} -/* "Attention" conclusions read as amber, not red — they're not outright failures. */ -.gh-checks-action_required { - color: var(--status-warn); background: color-mix(in srgb, var(--status-warn) 18%, transparent); -} -.gh-checks-skipped, .gh-checks-neutral, .gh-checks-stale, .gh-checks-cancelled { - color: var(--app-muted); background: color-mix(in srgb, var(--app-muted) 14%, transparent); -} -.gh-checks-in_progress, .gh-checks-queued, .gh-checks-requested, -.gh-checks-waiting { - color: var(--status-mod); background: color-mix(in srgb, var(--status-mod) 16%, transparent); + white-space: nowrap; } -.gh-check-dot.gh-checks-skipped, .gh-check-dot.gh-checks-neutral { background: var(--app-muted); } -.gh-check-dot.gh-checks-cancelled, .gh-check-dot.gh-checks-timed_out, -.gh-check-dot.gh-checks-startup_failure, .gh-check-dot.gh-checks-stale, -.gh-check-dot.gh-checks-action_required { background: var(--status-del); } -.gh-check-dot.gh-checks-requested, .gh-check-dot.gh-checks-waiting { background: var(--status-mod); } - -/* ── releases ── */ -/* ── Releases section ───────────────────────────────────────────────────────── - Append near the other .gh-* rules (after the .gh-review-* block, ≈ line 1710). - Structure is inherited: .gh-view / .gh-body / .gh-list / .gh-row / .gh-detail / - .gh-detail-head|title|meta|actions / .gh-body-md / .gh-pill / .list-row / - .row-meta-* / .group-label / .gh-adds / .modal-* are all reused as-is. */ - -/* Releases|Tags segmented switch, injected into the header action cluster. */ -.gh-seg { display: inline-flex; border: 1px solid var(--app-border); border-radius: 7px; overflow: hidden; } -.gh-seg-btn { - display: inline-flex; align-items: center; justify-content: center; gap: 6px; - height: 26px; padding: 0 11px; border: none; background: transparent; - color: var(--app-muted); font: inherit; font-size: 12px; font-weight: 550; cursor: pointer; - transition: color .12s ease, background .12s ease; +.settings-clonedir-btns { display: flex; gap: 6px; flex: 0 0 auto; } +.settings-check { + display: flex; + align-items: flex-start; + gap: 10px; + cursor: pointer; + margin-top: 10px; } -.gh-seg-btn .glyph .codicon { font-size: 14px; } -.gh-seg-btn + .gh-seg-btn { border-left: 1px solid var(--app-border); } -.gh-seg-btn:hover { color: var(--vscode-foreground); background: var(--app-hover); } -.gh-seg-btn:focus-visible { outline: 2px solid var(--gs-accent); outline-offset: -2px; } -.gh-seg-btn.active { color: var(--gs-accent-ink, var(--gs-accent)); background: var(--app-active); font-weight: 650; } - -/* The tag glyph inside a gh-row title sits inline with the name (Tags sub-list). */ -.gh-row-title .glyph { color: var(--app-muted); margin-right: 6px; font-size: 13px; vertical-align: -2px; } - -/* Release assets list — list-row + row-meta reused; just spacing + a download tint. */ -.rel-assets { margin-top: 6px; display: flex; flex-direction: column; } -.rel-assets .list-row { cursor: pointer; } -.rel-assets .gh-adds .codicon { font-size: 14px; color: var(--gs-accent-ink, var(--gs-accent)); } - -/* Multi-field release form (reuses .modal-overlay / .modal-input / .modal-actions). */ -.modal-form { width: min(520px, 92vw); display: flex; flex-direction: column; gap: 12px; } -.modal-field { display: flex; flex-direction: column; gap: 5px; } -.modal-field-label { font-size: 11.5px; font-weight: 600; color: var(--app-muted); } -.modal-textarea { resize: vertical; min-height: 96px; font-family: var(--vscode-editor-font-family); line-height: 1.5; } -.modal-checks { display: flex; gap: 18px; flex-wrap: wrap; } -.modal-check { display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px; color: var(--vscode-foreground); cursor: pointer; } -.modal-check input { accent-color: var(--gs-accent); width: 15px; height: 15px; } - -/* Codicon codepoints used by this section that are NOT yet in app.css. Place - these alongside the other `.codicon-NAME::before` rules (≈ line 1752, next to - codicon-tag). tag(\ea66), trash(\ea81), cloud-download(\eac2), link-external are - already defined; these three are the only new ones: */ -.codicon-plus::before { content: "\ea60"; } -.codicon-pencil::before { content: "\ea73"; } -.codicon-package::before { content: "\eb29"; } - -/* ── notifications ── */ -/* ── Notifications (inbox) — built on .list-row + .row-meta + .row-actions ─── */ - -/* Header action cluster: the toggle + "Mark all read", pinned to the right - alongside the .gh-acct block (ghHeader is a space-between flex row, so - margin-left:auto pins title left and groups actions+account on the right). */ -.notif-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; } -.notif-toggle { height: 28px; padding: 0 11px; border-radius: 8px; font-size: 12.5px; } - -/* A muted count line above the list. */ -.notif-summary { - display: flex; align-items: center; gap: 7px; - padding: 4px 10px 8px; - font-size: 11.5px; color: var(--app-muted); +/* A native checkbox renders as a bright white box — on a dark settings page it + was the highest-contrast element on screen, louder than every heading. Draw + it from the app's own tokens instead. */ +.settings-check input[type="checkbox"] { + appearance: none; + margin: 3px 0 0; + width: 16px; + height: 16px; + flex: 0 0 auto; + border: 1px solid var(--app-border); + border-radius: 5px; + background: var(--app-panel); + cursor: pointer; + display: grid; + place-content: center; + transition: background var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease); } -.notif-summary .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } -.notif-summary-text { font-variant-numeric: tabular-nums; } - -/* Inbox rows. */ -.notif-row { align-items: center; } -.notif-lead { display: inline-flex; align-items: center; gap: 6px; flex: 0 0 auto; } -.notif-lead .glyph { color: var(--app-muted); } -.notif-dot { - width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; +.settings-check input[type="checkbox"]:hover { border-color: var(--accent-line, var(--gs-accent)); } +.settings-check input[type="checkbox"]:checked { background: var(--gs-accent); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--gs-accent) 22%, transparent); + border-color: var(--gs-accent); } -/* Read rows recede — dim the title + icon so unread stands out. */ -.notif-read .row-meta-title { font-weight: 500; color: var(--app-muted); } -.notif-read .notif-lead .glyph { opacity: 0.55; } -/* Subject-type pill sits before the (hover-revealed) action cluster. */ -.notif-type { - flex: 0 0 auto; margin-left: 8px; - text-transform: none; letter-spacing: 0; +.settings-check input[type="checkbox"]:checked::after { + content: ""; + width: 9px; + height: 5px; + border: 2px solid #fff; + border-top: 0; + border-right: 0; + transform: rotate(-45deg) translate(1px, -1px); +} +.settings-check input[type="checkbox"]:focus-visible { outline: 2px solid var(--gs-focus-ring); outline-offset: 2px; } +.settings-check-text { display: flex; flex-direction: column; gap: 2px; } +.settings-check-text .settings-sub { margin: 0; } + +/* Settings → Repositories: the local-copies manager. */ +.settings-copies-head { margin-top: 14px; } +.settings-copies { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; } +.settings-copy { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border: 1px solid var(--app-border); + border-radius: 9px; + background: var(--app-panel); +} +.settings-copy.is-missing { opacity: 0.55; } +.settings-copy.is-current { border-color: color-mix(in srgb, var(--app-accent, #6c5cf7) 55%, var(--app-border)); } +.settings-copy-meta { flex: 1 1 auto; min-width: 0; } +.settings-copy-name { + display: flex; + align-items: center; + gap: 7px; + font-size: var(--text-sm); + font-weight: 600; + color: var(--vscode-foreground); +} +.settings-copy-origin { + font-size: 11px; + font-weight: 500; + color: var(--app-muted); + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); +} +.settings-copy-badge { + font-size: 10px; + font-weight: 700; + letter-spacing: var(--track-label); + text-transform: uppercase; + color: var(--app-muted); + border: 1px solid var(--app-border); + border-radius: 999px; + padding: 1px 7px; +} +.settings-copy-path { + font-size: 11px; + color: var(--app-muted); + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.settings-copy-acts { display: flex; gap: 2px; flex: 0 0 auto; } +/* Deleting a clone from disk sat in the same neutral grey, same size and same + 2px spacing as "Copy path". It is set apart and reads as destructive before + you hover it. */ +.settings-copy-acts .icon-btn.danger { + color: color-mix(in srgb, var(--status-del) 72%, var(--app-muted)); + margin-left: 6px; +} +.settings-copy-acts .icon-btn.danger:hover { + color: var(--status-del); + background: color-mix(in srgb, var(--status-del) 14%, transparent); } -/* Disable interaction while a per-row mutation is in flight. */ -.notif-row.is-busy { opacity: 0.55; pointer-events: none; } - -/* ── Missing codicon codepoints (REQUIRED) ───────────────────────────────── - The desktop ships a CURATED codicon subset; a glyph not defined here renders - BLANK. `check-all` (Mark all read button) and `mail-read` (context menu) are - NOT yet in app.css — add their official @vscode/codicons codepoints. */ -.codicon-check-all::before { content: "\ebb1"; } -.codicon-mail-read::before { content: "\eb1b"; } -/* ── orgs ── */ -/* ── Organizations view ───────────────────────────────────────────────────────── */ -.gh-avatar { - border-radius: 50%; +/* A3 metadata: association badges, reactions, edited markers, rail extras. */ +.gh-assoc-badge { + font-size: 10px; + font-weight: 700; + letter-spacing: var(--track-label); + text-transform: uppercase; + color: var(--app-muted); + border: 1px solid var(--app-border); + border-radius: 999px; + padding: 1px 7px; flex: 0 0 auto; - object-fit: cover; - background: var(--app-panel); +} +.gh-comment-edited { + font-size: var(--text-xs); + color: var(--app-muted); + font-style: italic; +} +.gh-reactions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; } +.gh-reaction { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 9px; border: 1px solid var(--app-border); + border-radius: 999px; + background: var(--app-panel); + font-size: var(--text-xs); } -.gh-avatar-fallback { color: var(--app-muted); flex: 0 0 auto; } -.gh-org-row .gh-row-title { display: flex; align-items: center; gap: 8px; } -.gh-org-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.gh-org-title { display: flex; align-items: center; gap: 9px; } -/* Org Repos/Teams/Members as a responsive card grid that uses the full pane. */ -.gh-org-grid { +.gh-reaction-emoji { font-size: 12px; line-height: 1; } +.gh-reaction-n { color: var(--app-muted); font-variant-numeric: tabular-nums; } +/* A requested-but-not-yet-submitted reviewer reads as pending, not done. */ +.det-person { border: 1px solid transparent; } +.det-person.is-pending { + opacity: 0.75; + /* A FULL border. Setting only border-style against a base rule of + `border: none` leaves the width at the initial `medium` (3px) and the + colour at currentColor — so a pending reviewer wore a 3px near-black ring + and stood 6px taller than the settled reviewers beside it. */ + border: 1px dashed color-mix(in srgb, var(--app-muted) 60%, transparent); +} +/* A reviewer who has ANSWERED. Pending reviewers recede (dashed, dimmed); these + carry their verdict, because "who approved this" and "who is blocking it" are + the two questions this section exists for. */ +.det-person.is-approved, +.det-person.is-blocking { + opacity: 1; + padding-right: 6px; +} +.det-person.is-approved { + border-color: color-mix(in srgb, var(--status-add) 45%, transparent); + background: color-mix(in srgb, var(--status-add) 10%, transparent); +} +.det-person.is-blocking { + border-color: color-mix(in srgb, var(--status-del) 45%, transparent); + background: color-mix(in srgb, var(--status-del) 10%, transparent); +} +.det-person.is-approved > .glyph { color: var(--status-add); font-size: 12px; } +.det-person.is-blocking > .glyph { color: var(--status-del); font-size: 12px; } + +.det-milestone-chip { align-self: flex-start; } +.det-prop-when { font-size: var(--text-xs); color: var(--app-muted); align-self: center; } +.gh-fork-chip { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: var(--app-muted); +} + +/* ── Explore: global GitHub search ───────────────────────────────────────── + A search-FIRST page: the field is the hero, tabs sit under it, and results + reuse the shared .sec-row density so Explore reads like every other list. */ +.explore-view { display: flex; flex-direction: column; min-height: 0; } +.explore-head { + display: flex; + flex-direction: column; + gap: var(--sp-3); + padding: 28px 24px 14px; + border-bottom: 1px solid var(--app-border); +} +.explore-title { font-size: 22px; font-weight: 650; margin: 0; color: var(--vscode-foreground); } +.explore-sub { font-size: var(--text-sm); color: var(--app-muted); margin-top: -6px; } +.explore-search { max-width: 720px; width: 100%; } +.explore-search .gh-search-input { height: 38px; font-size: var(--text-base); } +.explore-tools { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; } +.explore-tabs { display: inline-flex; gap: 2px; } +/* Every tab carries the SELECTED weight at all times and differs by colour and + ground instead. Bolding only the active one changed its width, so each tab + crept 4-7px out from under the cursor the instant you clicked it — and the + tabs beside it moved too. */ +.explore-tab { + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 600; + height: 30px; + padding: 0 12px; + border: none; + background: transparent; + border-radius: 8px; + color: var(--app-muted); + font-size: var(--text-sm); + cursor: pointer; +} +.explore-tab:hover { background: var(--app-hover); color: var(--vscode-foreground); } +.explore-tab.active { + background: var(--app-active); + color: var(--gs-accent-ink, var(--gs-accent)); +} +.explore-sort { margin-left: auto; } +.explore-desc { + font-size: var(--text-xs); + color: var(--app-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.explore-lang { font-size: var(--text-xs); color: var(--app-muted); } +.explore-stat { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: var(--text-xs); + color: var(--app-muted); + font-variant-numeric: tabular-nums; +} +.explore-pill { align-self: center; } +/* Explore rows carry a second line (description / repo), so they grow instead + of holding the single-line section height. */ +.explore-row { height: auto; align-items: flex-start; padding-top: 10px; padding-bottom: 10px; } +/* People and org results carried only an avatar and a login in a 1350px row. + Give the identity room to breathe rather than stranding it at the far left. */ +.explore-row .sec-row-title { font-size: var(--text-base); } +.explore-row-body { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 3px; } +.explore-row-head { display: flex; align-items: center; gap: 7px; min-width: 0; } +.explore-row .sec-row-meta, +.explore-row .sec-row-time { align-self: center; } +/* Multi-line rows: the leading icon belongs beside the FIRST line, not + floating in the middle of a tall row. */ +.explore-row .sec-row-lead { align-self: flex-start; margin-top: 2px; } +/* A people result is a face and a handle — one line, read as a tight list + rather than a stack of mostly-empty cards. */ +/* The People / Organizations results wrap as a directory of chips. */ +.sec-list.is-people { display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 8px; + grid-template-columns: repeat(auto-fill, minmax(212px, 250px)); + justify-content: start; align-content: start; + gap: 8px; } -.gh-org-grid > .list-empty, .gh-org-grid > .list-loading, .gh-org-grid > .list-error { - grid-column: 1 / -1; -} -.gh-org-grid > .list-row { +.sec-list.is-people > .explore-person-row { border: 1px solid var(--app-border); + border-radius: var(--r-md); background: var(--app-elevated); - border-radius: 10px; - box-shadow: var(--sheen); - padding: 9px 11px; - transition: background var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease), transform var(--dur-1) var(--ease); + padding: 8px 10px; } -.gh-org-grid > .list-row:hover { +.sec-list.is-people > .explore-person-row:hover { border-color: color-mix(in srgb, var(--gs-accent) 32%, var(--app-border)); background: color-mix(in srgb, var(--gs-accent) 5%, var(--app-elevated)); - transform: translateY(-1px); } -.gh-org-grid > .list-row:active { transform: translateY(0); } -/* The org sub-tab rows are clickable (open on GitHub) — restore the pointer the - shared .list-row sets to `default`, and let the avatar/icon sit inline. */ -.gh-org-repo, .gh-org-team, .gh-org-member { cursor: pointer; } -.gh-org-member .row-meta-title { font-size: 13px; } - -/* ── projects ── */ -/* ── Projects board (append after the .gh-check-* block, ~line 1726 in app.css). - * Reuses gh-view/gh-body/gh-list/gh-row/gh-detail/gh-detail-head/gh-pill/ - * gh-check-dot from the PR view; only the board (columns + cards) is new. - * All colors come from existing tokens. */ - -/* The detail pane holds a horizontal board; let it scroll on the x-axis. */ -.gh-board-detail { display: flex; flex-direction: column; overflow: hidden; padding: 18px 20px; } -.gh-board-detail .gh-detail-head { flex: 0 0 auto; } -.gh-board { - flex: 1 1 auto; - min-height: 0; - display: flex; - gap: 12px; +/* Full-width children of the grid — the empty state, the footer, a skeleton. */ +.sec-list.is-people > .list-empty, +.sec-list.is-people > .list-error, +.sec-list.is-people > .explore-footer, +.sec-list.is-people > .explore-loading-more, +.sec-list.is-people > .skeleton-list { grid-column: 1 / -1; } +/* A chip has no room for a hover action cluster; the whole chip is the door. */ +.sec-list.is-people > .explore-person-row .row-actions, +.sec-list.is-people > .explore-person-row .sec-row-actions { display: none; } +.explore-person-row { padding-top: 7px; padding-bottom: 7px; align-items: center; } +.explore-person-row .sec-row-lead { align-self: center; margin-top: 0; } +.explore-person-row .sec-row-title { font-size: var(--text-md); font-weight: 600; } +.explore-code-row .sec-row-title { + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-size: var(--text-sm); +} +/* One block per hit. The stitched-sibling rules this replaces could not close + the gap the row body's own `gap` put between the boxes. */ +.explore-code-line { display: block; } +/* What actually matched. A code search that shows you three lines and leaves + you to find the string yourself has done half the job. */ +.explore-code-hit { + background: color-mix(in srgb, var(--gs-accent) 26%, transparent); + color: inherit; + border-radius: 3px; + padding: 0 1px; +} +.explore-code-gap { + display: block; + color: var(--app-muted); + opacity: 0.65; + letter-spacing: 0.2em; + user-select: none; +} +.explore-code-frag { + margin: 6px 0 0; + padding: 6px 9px; + border-radius: 7px; + background: var(--app-panel); + border: 1px solid var(--app-border); + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-size: 11px; + line-height: 1.5; + color: var(--app-muted); overflow-x: auto; - overflow-y: hidden; - padding: 14px 2px 6px; + white-space: pre; } -.gh-col { - /* Grow to fill the board (kill the horizontal void on wide windows), but stay - within a comfortable card width; many columns then scroll past min-width. */ - flex: 1 1 0; - min-width: 264px; - max-width: 360px; +/* Left-aligned with the rows it summarises: "3 matches" used to sit centred + ~600px right of the last row's text. */ +.explore-footer { display: flex; flex-direction: column; - min-height: 0; + align-items: flex-start; + gap: var(--sp-3); + padding: 18px 12px 26px; +} +.explore-footer-note { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: var(--text-xs); + color: var(--app-muted); +} +.explore-loading-more { + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 14px; + font-size: var(--text-xs); + color: var(--app-muted); +} +.explore-loading-more .codicon { animation: gs-spin 1.1s linear infinite; } + +/* ── Explore entity pages: repository + account ────────────────────────────── */ +/* The primary button's shadow bled downward past the toolbar's hairline into + the content area. Keep the lift, contain the bleed. */ +.det-split { display: inline-flex; align-items: stretch; padding: 0; overflow: hidden; box-shadow: none; } +.det-split-main { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 12px; + cursor: pointer; +} +.det-split-more { + display: inline-flex; + align-items: center; + padding: 0 8px; + cursor: pointer; + border-left: 1px solid color-mix(in srgb, #000 22%, transparent); +} +.det-split-more:hover { background: color-mix(in srgb, #000 14%, transparent); } + +.explore-crumbs { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; + margin-bottom: var(--sp-3); + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-size: var(--text-sm); +} +.explore-crumb { + border: none; + background: transparent; + color: var(--gs-accent-ink, var(--gs-accent)); + cursor: pointer; + padding: 2px 4px; + border-radius: 5px; +} +.explore-crumb:hover { background: var(--app-hover); } +.explore-crumb.is-current { color: var(--vscode-foreground); cursor: default; } +.explore-crumb-sep { color: var(--app-muted); } + +.explore-repo-head { margin-bottom: var(--sp-4); } +.explore-repo-title { + font-size: var(--text-xl); + font-weight: 650; + margin: 0; + letter-spacing: -0.01em; +} +.explore-repo-owner { color: var(--app-muted); font-weight: 500; } +.explore-repo-content { display: flex; flex-direction: column; gap: var(--sp-4); } +.explore-tree { + border: 1px solid var(--app-border); + border-radius: 10px; + overflow: hidden; background: var(--app-panel); +} +.explore-tree-row { + display: flex; + align-items: center; + gap: 9px; + width: 100%; + padding: 7px 12px; + border: none; + background: transparent; + color: var(--vscode-foreground); + font-size: var(--text-sm); + text-align: left; + cursor: pointer; +} +.explore-tree-row + .explore-tree-row { border-top: 1px solid var(--app-border); } +.explore-tree-row:hover { background: var(--app-hover); } +.explore-tree-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.explore-tree-spring { flex: 1 1 auto; } +.explore-tree-size { font-size: 11px; color: var(--app-muted); font-variant-numeric: tabular-nums; } + +.explore-readme { border: 1px solid var(--app-border); - border-radius: 12px; + border-radius: 10px; + overflow: hidden; + background: var(--app-panel); } -.gh-col-head { +.explore-readme-head { display: flex; align-items: center; - gap: 8px; - padding: 10px 12px; + gap: 7px; + padding: 9px 14px; border-bottom: 1px solid var(--app-border); - font-size: 12px; - font-weight: 650; - color: var(--vscode-foreground); -} -.gh-col-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.gh-col-head .gh-pill { margin: 0 0 0 auto; align-self: center; } -.gh-col-body { - flex: 1 1 auto; - min-height: 0; - overflow-y: auto; - padding: 8px; - display: flex; - flex-direction: column; - gap: 8px; + font-size: var(--text-xs); + font-weight: 600; + color: var(--app-muted); } -.gh-col-empty { min-height: 40px; } +.explore-readme .gh-body-md { padding: 20px 24px 26px; } -.gh-card { +.explore-file { display: flex; - flex-direction: column; - gap: 5px; - padding: 9px 10px; border: 1px solid var(--app-border); - border-radius: 9px; - background: var(--app-bg); - transition: border-color 110ms, background 110ms; -} -.gh-card.clickable { cursor: pointer; } -.gh-card.clickable:hover { - background: var(--app-hover); - border-color: color-mix(in srgb, var(--gs-accent) 35%, var(--app-border)); + border-radius: 10px; + overflow: auto; + background: var(--app-panel); + max-height: 72vh; } -.gh-card.clickable:active { transform: translateY(0.5px); } -/* A draft card (no issue/PR behind it) is inert — show it's not openable. */ -.gh-card:not(.clickable) { opacity: 0.72; } -/* While a move mutation is in flight, lock + dim the card so it's clear. */ -.gh-card.is-moving { opacity: 0.5; pointer-events: none; } -.gh-card.clickable:focus-visible { - outline: 2px solid color-mix(in srgb, var(--gs-accent) 70%, transparent); - outline-offset: 1px; +.explore-file-gutter { + flex: 0 0 auto; + padding: 12px 10px; + text-align: right; + color: var(--app-muted); + background: color-mix(in srgb, var(--app-muted) 7%, transparent); + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-size: 12px; + line-height: 1.55; + white-space: pre; + user-select: none; } -.gh-card-top { display: flex; align-items: flex-start; gap: 7px; } -.gh-card-top .gh-check-dot { margin-top: 4px; } -.gh-card-title { +.explore-file-code { flex: 1 1 auto; - min-width: 0; - font-size: 12.5px; - font-weight: 600; - line-height: 1.35; + margin: 0; + padding: 12px 16px; + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-size: 12px; + line-height: 1.55; + white-space: pre; +} +.explore-file-prose { padding: 4px 2px 20px; } +.explore-topic { margin: 0 4px 4px 0; } + +/* Go to file */ +.gotofile-card { width: min(var(--modal-lg), 92vw); display: flex; flex-direction: column; gap: 10px; } +.gotofile-input { font-family: var(--vscode-editor-font-family, ui-monospace, monospace); } +.gotofile-list { max-height: 46vh; overflow-y: auto; display: flex; flex-direction: column; } +.gotofile-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 9px; + border: none; + background: transparent; + border-radius: 7px; color: var(--vscode-foreground); - /* two-line clamp for long titles */ - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-size: var(--text-sm); + text-align: left; + cursor: pointer; } -.gh-card-kebab { - flex: 0 0 auto; - display: inline-flex; +.gotofile-row:hover { background: var(--app-hover); } +.gotofile-dir { color: var(--app-muted); } +.gotofile-empty, .gotofile-note { font-size: var(--text-xs); color: var(--app-muted); padding: 4px 2px; } + +/* Account page */ +.explore-account-head { display: flex; align-items: center; gap: 14px; margin-bottom: var(--sp-3); } +.explore-account-names { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; } +.explore-account-title { font-size: 20px; font-weight: 650; margin: 0; } +.explore-account-login { color: var(--app-muted); font-size: var(--text-sm); } +.explore-account-bio { margin: 0 0 var(--sp-4); color: var(--vscode-foreground); max-width: 70ch; } +.explore-account-repos-head { + display: flex; align-items: center; - justify-content: center; - width: 22px; - height: 22px; + gap: var(--sp-3); + justify-content: space-between; + margin-bottom: var(--sp-2); +} +.explore-account-count { font-size: var(--text-xs); font-weight: 600; color: var(--app-muted); } +.explore-account-repo { align-items: flex-start; padding: 10px 12px; } +/* The reveal rule for hover actions is scoped to `.list-row`, which this row + shape is not — so its "Open" and "Choose location…" buttons sat at opacity 0 + forever while staying clickable and focusable: invisible controls that a + pointer still hits and Tab still lands on. */ +.explore-account-repo:hover .row-actions, +.explore-account-repo:focus-within .row-actions { opacity: 1; } +.explore-account-repo-main { + display: flex; + align-items: flex-start; + gap: 9px; + flex: 1 1 auto; + min-width: 0; + color: inherit; + text-align: left; padding: 0; - border: none; - border-radius: 6px; +} +.explore-account-link { display: inline-flex; align-items: center; gap: 6px; } +.det-prop-text { font-size: var(--text-sm); color: var(--vscode-foreground); line-height: 1.5; } +.det-prop-none { font-size: var(--text-xs); color: var(--app-muted); } + +/* These two carried no width rule and inherited whatever preceded them — + the open-progress card landed on the base size by luck, and the dispatch + form borrowed the PR form's. State both. */ +.ghopen-card { width: min(var(--modal-sm), 92vw); } +.actions-dispatch-card { width: min(var(--modal-md), 92vw); } + +/* In the state the user first SEES, the primary action is often disabled — and + a washed-out purple next to a solid Cancel makes Cancel the loudest thing in + the dialog. Recede Cancel instead of shouting it. */ +.modal-actions .mini-btn { background: transparent; + border-color: var(--app-border); color: var(--app-muted); - cursor: pointer; - opacity: 0; - transition: opacity 110ms, background 110ms; } -.gh-card:hover .gh-card-kebab, -.gh-card.clickable:focus-visible .gh-card-kebab, -.gh-card-kebab:focus-visible { opacity: 1; } -.gh-card-kebab:hover { background: var(--app-hover); color: var(--vscode-foreground); } -.gh-card-kebab:focus-visible { opacity: 1; } -.gh-card-sub { font-size: 11px; color: var(--app-muted); } +.modal-actions .mini-btn:hover { color: var(--vscode-foreground); background: var(--app-hover); } +.modal-ok[disabled] { opacity: 0.55; cursor: not-allowed; } -/* Item state dots, reusing the .gh-check-dot base shape (9px round). */ -.gh-check-dot.gh-state-open { background: var(--status-add); } -.gh-check-dot.gh-state-closed { background: var(--status-del); } -.gh-check-dot.gh-state-merged { background: var(--gs-accent); } -.gh-check-dot.gh-state-none { background: var(--app-muted); } +/* Card rhythm: every block was separated by the same gap with a few ad-hoc + margins layered on top, so hierarchy read flat. A heading gets air above it; + its own explanatory line stays tight to it. (The body's flex/gap is defined + with .settings-card-body above — this only adjusts the rhythm.) */ +.settings-card-body > .settings-field-label { margin-top: var(--sp-3); } +.settings-card-body > .settings-field-label + .settings-sub { margin-top: calc(var(--sp-3) * -1 + 3px); } +.settings-card-body > *:first-child { margin-top: 0; } -/* ── gists ── */ -/* ── Gists: file header + read-only content + create/edit modal ──────────────── */ -.gist-file-head { - display: flex; - align-items: baseline; - gap: 10px; - padding: 10px 0 8px; +.cmp-swap { flex: 0 0 auto; } + +/* Typed confirmation (irreversible actions). */ +.confirm-typed-hint { margin-bottom: -4px; } +.confirm-typed-input { font-family: var(--vscode-editor-font-family, ui-monospace, monospace); } + +.clone-progress { display: flex; flex-direction: column; gap: 7px; } +.clone-progress-phase { font-size: var(--text-xs); color: var(--app-muted); font-variant-numeric: tabular-nums; } +.clone-progress-bar { + height: 7px; + border-radius: 999px; + overflow: hidden; + background: color-mix(in srgb, var(--app-muted) 20%, transparent); +} +.clone-progress-fill { + height: 100%; + width: 0; + border-radius: 999px; + background: linear-gradient(90deg, var(--gs-accent), var(--gs-accent-2)); + transition: width 200ms var(--ease); +} +.clone-progress-fill.indeterminate { animation: clone-indeterminate 1.1s var(--ease) infinite; } +@keyframes clone-indeterminate { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} +.clone-card.is-busy .clone-repo-list, +.clone-card.is-busy .clone-tabs { opacity: 0.6; pointer-events: none; } + +/* ── Skeleton loading shimmer ──────────────────────────────────────────────── */ +.sk { + position: relative; + overflow: hidden; + border-radius: 7px; + background: color-mix(in srgb, var(--app-muted) 14%, transparent); +} +.sk::after { + content: ""; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient(90deg, transparent, + color-mix(in srgb, var(--vscode-foreground) 7%, transparent), transparent); + animation: sk-shimmer 1.3s var(--ease) infinite; +} +@keyframes sk-shimmer { 100% { transform: translateX(100%); } } +@media (prefers-reduced-motion: reduce) { .sk::after { animation: none; } } +.sk-list { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; } +.sk-row { display: flex; align-items: center; gap: 11px; padding: 7px 8px; } +.sk-dot { width: 22px; height: 22px; border-radius: 50%; flex: 0 0 auto; } +.sk-lines { display: flex; flex-direction: column; gap: 6px; flex: 1 1 auto; min-width: 0; } +.sk-line { height: 9px; } +.sk-line.short { width: 38%; } +.sk-line.mid { width: 62%; } + +/* ── Menu polish: entry animation + searchable long menus ───────────────────── */ +.dropdown, .ctx-menu { + transform-origin: top left; + animation: menu-pop 140ms var(--ease-out) both; +} +@keyframes menu-pop { + from { opacity: 0; transform: translateY(-6px) scale(0.97); } + to { opacity: 1; transform: translateY(0) scale(1); } +} +@media (prefers-reduced-motion: reduce) { + .dropdown, .ctx-menu { animation: none; } +} +.dropdown-item { transition: background var(--dur-1) var(--ease); } +.dropdown-search-wrap { + position: sticky; + top: -5px; + z-index: 1; + margin: -5px -5px 4px; + padding: 7px 7px 6px; + background: linear-gradient(180deg, var(--app-elevated), color-mix(in srgb, var(--app-elevated) 92%, transparent)); border-bottom: 1px solid var(--app-border); + backdrop-filter: blur(4px); } -.gist-file-name { - font-size: 13px; - font-weight: 650; +.dropdown-search { + width: 100%; + height: 30px; + padding: 0 10px; + border-radius: 8px; + border: 1px solid var(--app-border); + background: var(--app-panel); color: var(--vscode-foreground); - word-break: break-all; + font-family: inherit; + font-size: var(--text-sm); + outline: none; } -.gist-file-sub { - font-size: 11px; - color: var(--app-muted); - white-space: nowrap; +.dropdown-search:focus { + border-color: color-mix(in srgb, var(--gs-accent) 55%, var(--app-border)); + box-shadow: var(--ring); } -.gist-content { - margin: 12px 0 0; - padding: 12px 14px; - background: var(--app-active, var(--vscode-textCodeBlock-background, rgba(127, 127, 127, 0.08))); - border: 1px solid var(--app-border); - border-radius: 8px; - overflow: auto; - max-height: calc(100vh - 260px); - font-family: var(--vscode-editor-font-family, ui-monospace, SFMono-Regular, Menlo, monospace); - font-size: 12.5px; - line-height: 1.55; - white-space: pre; - tab-size: 2; + +/* ════════════════════════════════════════════════════════════════════════════ + v4 REDESIGN — rich GitHub rows, premium empty states, header counts, avatars. + The shared master-detail surfaces (PRs/Issues/Actions/Releases/Notifications/ + Orgs/Gists) all read from these, so one redesign lifts every section. + ════════════════════════════════════════════════════════════════════════════ */ + +/* ── Avatars (real image or deterministic initials tile) ───────────────────── */ +.av { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + overflow: hidden; + font-weight: 700; + letter-spacing: 0.01em; + user-select: none; } -.gist-content code { - font-family: inherit; - color: var(--vscode-foreground); +.av-img { object-fit: cover; background: var(--app-panel); } +.av-fallback { + color: #fff; + background: var(--av, var(--gs-accent)); + /* The ink is chosen per tile from the hue's own luminance — see avatarInk. */ + color: var(--av-ink, #ffffff); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18); } -/* Create / edit gist modal — sits on the shared .modal-overlay scaffold. */ -.gist-modal { - width: min(560px, 92vw); - display: flex; - flex-direction: column; - gap: 10px; -} -.gist-textarea { - min-height: 220px; - resize: vertical; - font-family: var(--vscode-editor-font-family, ui-monospace, SFMono-Regular, Menlo, monospace); - font-size: 12.5px; - line-height: 1.5; - tab-size: 2; - white-space: pre; - overflow: auto; -} -.gist-visibility { +/* ── Rich list row (the new gh-row) ────────────────────────────────────────── */ +.gh-row-rich { display: flex; + flex-direction: row; /* override .gh-row's column — else the lead stacks above the body */ align-items: center; - gap: 8px; - font-size: 13px; + gap: 11px; + width: 100%; + text-align: left; + padding: 10px 11px; + border: 1px solid transparent; + border-radius: 11px; + background: transparent; color: var(--vscode-foreground); + font-family: inherit; cursor: pointer; - user-select: none; -} -.gist-visibility input[type="checkbox"] { - accent-color: var(--gs-accent, var(--vscode-focusBorder)); - width: 15px; - height: 15px; + transition: background var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease); } -.gist-visibility input:disabled { - opacity: 0.5; - cursor: not-allowed; +.gh-row-rich:hover { background: var(--app-hover); } +.gh-row-rich.active { + background: var(--accent-soft); + border-color: color-mix(in srgb, var(--gs-accent) 30%, transparent); } -.gist-visibility:has(input:disabled) { - cursor: default; +.gh-row-rich:focus-visible { + outline: 2px solid var(--gs-focus-ring); + outline-offset: -1px; } - -/* ════════════════════════════════════════════════════════════════════════════ - v3 SHELL — resizable/collapsible sidebar, terminal dock, clone dialog, - tonal button, skeletons. (2026-06-27 "make it a real app" pass.) - ════════════════════════════════════════════════════════════════════════════ */ - -/* ── Tonal / soft button (secondary accent action) ─────────────────────────── */ -.btn-soft { +.gh-row-lead { + flex: 0 0 auto; display: inline-flex; align-items: center; - gap: 8px; - height: 40px; - padding: 0 18px; - font-family: inherit; - font-size: 13px; + justify-content: center; + margin-top: 1px; +} +.gh-row-lead .codicon { font-size: 17px; } +.gh-lead-icon { display: inline-flex; } +.gh-lead-open, .gh-lead-open-pr { color: var(--status-add); } +.gh-lead-closed { color: var(--status-del); } +.gh-lead-merged { color: var(--gs-accent-ink, var(--gs-accent)); } +.gh-lead-draft { color: var(--app-muted); } +/* Closed as not-planned: gray, like GitHub — "we're not doing this" reads + differently from "done", and the color is the whole signal. */ +.gh-lead-not-planned { color: var(--app-muted); } +.gh-row-body { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 3px; } +.gh-row-head { display: flex; align-items: center; gap: 7px; min-width: 0; } +.gh-row-rich .gh-row-title { + font-size: var(--text-base); font-weight: 600; - color: var(--gs-accent-ink, var(--gs-accent)); - border: 1px solid var(--accent-line); - border-radius: 9px; - background: var(--accent-soft); - box-shadow: var(--sheen); - cursor: pointer; - transition: background var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease), - transform 100ms var(--ease); + color: var(--vscode-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; } -.btn-soft:hover { background: color-mix(in srgb, var(--gs-accent) 22%, transparent); } -.btn-soft:active { transform: translateY(0.5px); } -.btn-soft .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } - -/* Welcome dual-action row. */ -.welcome-actions { +.gh-row-rich .gh-row-sub { + font-size: var(--text-xs); + color: var(--app-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} +.gh-row-chips { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 2px; } +.gh-row-stats { + flex: 0 0 auto; display: flex; - gap: 10px; - margin-top: 10px; - animation: welcome-rise 700ms var(--ease-out) 180ms both; + align-items: center; + gap: 11px; + margin-top: 1px; + padding-left: 4px; } -.welcome-actions .welcome-open, -.welcome-actions .welcome-clone { margin-top: 0; animation: none; } +.gh-stat { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: var(--text-xs); + color: var(--app-muted); + font-variant-numeric: tabular-nums; +} +.gh-stat .codicon { font-size: 13px; } +.gh-stat.add { color: var(--status-add); } +.gh-stat.del { color: var(--status-del); } -/* ── Sidebar rail: collapse + drag-resize ──────────────────────────────────── */ -.nav-rail { transition: width 180ms var(--ease); } -body.resizing-h .nav-rail { transition: none; } -.nav-rail.collapsed { - width: 60px; - padding: 12px 8px; +/* Label chip — uses --chip (the GitHub hex); legible on both themes. */ +/* The GitHub label hex (--chip, set per-chip by labelChip()) drives a tinted + pill. The raw hex as TEXT can dip below AA on the near-black/near-white canvas + and on tinted/selected rows, so the text is pulled toward the theme foreground + (keeps the hue, guarantees legibility); the tint+border carry the colour. */ +.gh-label-chip { + font-size: var(--text-2xs); + font-weight: 600; + line-height: 1.5; + padding: 1px 8px; + border-radius: 999px; + /* 45/55, not 70/30. The light theme was tuned to that ratio and dark was + left at the original — so a chip whose GitHub hex is DARK (the `bug` red, + `documentation` blue) rendered at 3.20:1 on the near-black canvas: the + one theme the tuning was not applied to, carrying the one class of hex it + cannot survive. The tint and border still carry the colour. */ + color: color-mix(in srgb, var(--chip, #888) 45%, var(--vscode-foreground) 55%); + background: color-mix(in srgb, var(--chip, #888) 18%, var(--app-elevated)); + border: 1px solid color-mix(in srgb, var(--chip, #888) 34%, var(--app-border)); + white-space: nowrap; } -.nav-rail.collapsed .nav-item { - justify-content: center; - gap: 0; - padding: 0; +body.vscode-light .gh-label-chip { + color: color-mix(in srgb, var(--chip, #888) 45%, #000 55%); + background: color-mix(in srgb, var(--chip, #888) 22%, var(--app-elevated)); + border-color: color-mix(in srgb, var(--chip, #888) 55%, var(--app-border) 45%); +} +/* On a hovered/selected/active row the chip composites over a busier surface — + keep the same tested foreground so contrast never silently drops. */ +:is(.gh-row.active, .gh-row:hover, .list-row.is-current, .list-row.is-hover) .gh-label-chip { + background: color-mix(in srgb, var(--chip, #888) 20%, var(--app-panel)); } -.nav-rail.collapsed .nav-label { display: none; } -.nav-rail.collapsed .nav-item.active::before { left: -8px; } -.nav-rail.collapsed .nav-divider { margin: 10px 6px 4px; justify-content: center; } -.nav-rail.collapsed .nav-divider-label { display: none; } -.nav-rail.collapsed .nav-divider::after { display: none; } -/* ── The rail's right edge: a pure drag-to-resize handle ───────────────────── */ -.rail-resizer { - flex: 0 0 auto; - width: 8px; - margin: 0 -4px; - z-index: 4; - cursor: col-resize; - display: flex; +/* Inline state pill (open / closed / merged / draft). */ +.gh-state-pill { + display: inline-flex; + align-items: center; + gap: 5px; + /* Badges give ground before the title does, and never wrap: "Pre-release" + used to break onto a second line and double its row's height rather than + shrink by a single pixel. */ + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; + font-weight: 650; + padding: 2px 9px 2px 8px; + border-radius: 999px; +} +.gh-state-pill .codicon { font-size: 12px; } +.gh-state-open, .gh-state-pill.gh-state-open-pr { color: var(--status-add); background: color-mix(in srgb, var(--status-add) 15%, transparent); } +.gh-state-pill.gh-state-closed { color: var(--status-del); background: color-mix(in srgb, var(--status-del) 15%, transparent); } +.gh-state-pill.gh-state-merged { color: var(--gs-accent-ink, var(--gs-accent)); background: var(--accent-soft); } +.gh-state-pill.gh-state-draft { color: var(--app-muted); background: color-mix(in srgb, var(--app-muted) 16%, transparent); } + +/* ── Header: live count pill + tighter layout ──────────────────────────────── */ +.gh-head { align-items: flex-start; } +.gh-head-titlewrap { display: flex; align-items: center; gap: 9px; min-width: 0; } +.gh-head-count { + display: inline-flex; align-items: center; justify-content: center; + min-width: 22px; + height: 20px; + padding: 0 7px; + border-radius: 999px; + background: color-mix(in srgb, var(--app-muted) 20%, transparent); + color: var(--vscode-foreground); + font-size: var(--text-xs); + font-weight: 650; + font-variant-numeric: tabular-nums; } -.rail-resizer-grip { width: 1px; height: 100%; background: transparent; transition: background 100ms var(--ease); } -.rail-resizer:hover .rail-resizer-grip, -body.resizing-h .rail-resizer-grip { background: var(--gs-accent); width: 2px; } -.nav-rail.collapsed + .rail-resizer { pointer-events: none; } +.gh-acct .gh-who { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px 3px 8px; + border-radius: 999px; + border: 1px solid var(--app-border); + background: var(--app-elevated); + box-shadow: var(--sheen); + font-size: var(--text-xs); + font-weight: 600; + color: var(--vscode-foreground); +} +.gh-acct .gh-who .codicon { font-size: 14px; color: var(--app-muted); } -/* ── Main stack (routed view above the permanent terminal dock) ────────────── */ -.main-stack { - flex: 1 1 auto; - min-width: 0; - min-height: 0; - display: flex; - flex-direction: column; +/* ── Bar-level selector chip (Projects / Orgs header picker) ─────────────────── */ +.gh-picker { + display: inline-flex; + align-items: center; + gap: 7px; + height: 30px; + max-width: 360px; + padding: 0 9px 0 8px; + border-radius: 9px; + border: 1px solid var(--app-border); + background: var(--app-elevated); + box-shadow: var(--sheen); + color: var(--vscode-foreground); + font-family: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease); } -.main-stack > .view-host { flex: 1 1 auto; min-height: 0; } -body.resizing-v { cursor: row-resize; user-select: none; } - -/* ── Reusable collapsible bottom dock (BottomDock) ─────────────────────────── */ -.dock-mount { - /* Reserve ONLY the bar height in the layout — the whole panel floats in - .dock-overlay, so opening it never reflows the view above. */ - --dock-bar-h: 23px; - flex: 0 0 var(--dock-bar-h); - height: var(--dock-bar-h); - position: relative; - min-height: 0; +.gh-picker:hover { background: var(--app-hover); border-color: var(--accent-line); } +.gh-picker[aria-expanded="true"] { background: var(--app-active); border-color: var(--accent-line); } +.gh-picker:focus-visible { + outline: 2px solid var(--gs-focus-ring); + outline-offset: 2px; } -/* Top resizer — drag the dock's top edge to resize the body height. */ -.dock-resizer { - flex: 0 0 8px; - margin: -4px 0; - z-index: 4; - cursor: row-resize; - display: flex; +.gh-picker-lead { + display: inline-flex; align-items: center; - justify-content: center; + flex: 0 0 auto; } -.dock-resizer-grip { height: 1px; width: 100%; background: var(--app-border); transition: background 100ms var(--ease); } -.dock-resizer:hover .dock-resizer-grip, -body.resizing-v .dock-resizer-grip { background: var(--gs-accent); height: 2px; } -.dock-mount.collapsed .dock-resizer { display: none; } +.gh-picker-lead .glyph, +.gh-picker-lead .codicon { color: var(--gs-accent-ink, var(--gs-accent)); font-size: 16px; } +.gh-picker-lead .av, +.gh-picker-lead .gh-avatar { width: 20px; height: 20px; } +.gh-picker-lead .av-fallback { font-size: 9px; } +.gh-picker-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } +.gh-picker-chev { color: var(--app-muted); opacity: 0.7; flex: 0 0 auto; margin-left: 1px; } +.gh-picker-chev .codicon { font-size: 14px; } -/* The whole panel floats anchored to the window bottom; children stack - resizer · BAR · body, so when open the bar sits ON TOP of the body and the - body fills beneath it down to the bottom. Collapsed, only the bar shows and - it sits exactly in the reserved bar slot at the base. */ -.dock-overlay { - position: absolute; - bottom: 0; - left: 0; - right: 0; - z-index: 30; - display: flex; - flex-direction: column; - background: var(--vscode-editor-background); +/* Single full-width pane (no left list): the picked project/org board+data. */ +.gh-solo { flex: 1 1 auto; min-width: 0; border-left: none; } + +/* Lift the (single-pane) detail surface — orgs / projects still sit on it. */ +.gh-detail { background: var(--app-bg); } + +/* ── Premium empty state (also the detail pane's "nothing selected") ────────── */ +.list-empty { gap: 10px; padding: 56px 28px; } +.list-empty-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 60px; + height: 60px; + margin-bottom: 6px; + border-radius: 18px; + background: + radial-gradient(120% 120% at 50% 0%, color-mix(in srgb, var(--gs-accent) 22%, transparent), transparent 70%), + color-mix(in srgb, var(--gs-accent) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--gs-accent) 26%, transparent); + box-shadow: var(--sheen), 0 8px 24px -10px color-mix(in srgb, var(--gs-accent) 50%, transparent); } -/* Expanded: lift the floating panel off the view (top hairline + soft shadow) - and separate the bar from the body with a hairline beneath it. */ -.dock-mount:not(.collapsed) .dock-overlay { - border-top: 1px solid var(--app-border); - box-shadow: 0 -12px 30px -16px rgba(0, 0, 0, 0.5); +.list-empty-badge .codicon { font-size: 28px; color: var(--gs-accent-ink, var(--gs-accent)); } +.list-empty-title { font-size: var(--text-lg); font-weight: 700; letter-spacing: -0.01em; } +.list-empty-desc { font-size: var(--text-base); color: var(--app-muted); max-width: 380px; line-height: 1.55; } +.list-empty-action { margin-top: 14px; } +.list-empty-hint { + margin-top: 10px; + font-size: var(--text-xs); + color: color-mix(in srgb, var(--app-muted) 80%, transparent); } -.dock-mount:not(.collapsed) .dock-footer { - border-top: none; - border-bottom: 1px solid var(--app-border); +.list-error .list-empty-badge { + background: color-mix(in srgb, var(--status-del) 14%, transparent); + border-color: color-mix(in srgb, var(--status-del) 30%, transparent); + box-shadow: var(--sheen); } +.list-error .list-empty-badge .codicon { color: var(--status-del); } -/* The content area — pops UP above the footer; hidden when collapsed. */ -.dock-body { - flex: 1 1 auto; - min-height: 0; - position: relative; - display: flex; - animation: dock-rise 170ms var(--ease-out) both; +/* ── Code view: balanced reading column + richer file rows ──────────────────── */ +.code-filecard, .code-readme { + width: 100%; + max-width: 1080px; + margin-left: auto; + margin-right: auto; +} +.code-row .file-path { flex: 1 1 auto; min-width: 0; } +.code-row.is-dir .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } +.code-row:not(.is-dir) .glyph { color: var(--app-muted); } +.code-row .file-path { font-weight: 500; } +.code-row.is-dir .file-path { font-weight: 600; } +.code-row-size { + flex: 0 0 auto; + font-size: var(--text-xs); + color: var(--app-muted); + font-variant-numeric: tabular-nums; + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); } -@keyframes dock-rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } -.dock-mount.collapsed .dock-body { display: none; } -/* ── The permanent footer bar — tiny + status-bar-like, always at the bottom ── */ -.dock-footer { +/* ── Changes view: richer file rows (icon + name + dir + status) ────────────── */ +.dc-file { align-items: center; padding: 6px 9px; } +.dc-file > .glyph { flex: 0 0 auto; color: var(--app-muted); } +.dc-file.status-A > .glyph, .dc-file.status-C > .glyph { color: var(--status-add); } +.dc-file.status-D > .glyph { color: var(--status-del); } +/* `overflow: hidden` is the point: without it the NAME, which never wrapped and + never ellipsised, simply painted outside this box — in the checkbox staging + model the row is narrower and "common.ts" ran straight over the M beside it, + so the filename and its status letter overlapped, mid-glyph. */ +.dc-file-meta { display: flex; align-items: baseline; gap: 7px; flex: 1 1 auto; min-width: 0; overflow: hidden; } +.dc-file-name { + font-size: var(--text-sm); + font-weight: 550; + color: var(--vscode-foreground); + white-space: nowrap; + /* The name never shrinks and never overflows: it is the thing you are reading, + so the DIRECTORY beside it gives up its space first. It also never ellipsised + at all before, which is how "common.ts" came to paint straight over the M + beside it in the narrower checkbox-model row — the same pixels, mid-glyph. */ flex: 0 0 auto; - display: flex; - align-items: center; - gap: 2px; - height: 23px; - padding: 0 4px 0 6px; - /* Flat, minimal status-bar look (VS Code) — no gradient. */ - background: var(--app-panel); - border-top: 1px solid var(--app-border); - user-select: none; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; } -.dock-tabs { display: flex; min-width: 0; } -.dock-spacer { flex: 1 1 auto; align-self: stretch; } -.dock-actions { display: inline-flex; align-items: center; gap: 1px; } -.dock-icon-btn, -.dock-chevron { - display: inline-flex; - align-items: center; - justify-content: center; - width: 20px; - height: 19px; - border: none; - border-radius: 5px; - background: transparent; +.dc-file-dir { + font-size: var(--text-xs); color: var(--app-muted); - cursor: pointer; - transition: background var(--dur-1) var(--ease), color var(--dur-1) var(--ease); + flex: 0 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + /* Truncate from the LEFT so the meaningful tail (…/src/main) survives instead + of the directory root. The path is one LTR-resolved run (latin + "/"), so an + rtl box only flips the ellipsis side, not the segment order. */ + direction: rtl; + text-align: left; } -.dock-icon-btn:hover, -.dock-chevron:hover { background: var(--app-hover); color: var(--vscode-foreground); } -.dock-icon-btn:focus-visible, -/* focus ring consolidated into the shared :focus-visible group near the top */ -.dock-icon-btn .codicon { font-size: 14px; } -.dock-chevron .codicon { font-size: 13px; } +.dc-file .file-status { margin-left: 6px; } -/* ── Footer tabs (Commit details / Output / one-per-shell) — flat, VS-Code-panel - style: no boxes, just text that brightens on hover/active, with a thin accent - line on the active tab's top edge (connecting it to the panel above). ─────── */ -.dock-tabs { display: flex; align-self: stretch; min-width: 0; } -.term-tabs { display: inline-flex; align-items: stretch; gap: 1px; min-width: 0; } -.term-tab { - position: relative; +/* ════════════════════════════════════════════════════════════════════════════ + v4 FIXES — real bugs caught running the actual app (not the stubbed harness). + ════════════════════════════════════════════════════════════════════════════ */ + +/* `.icon-btn` was used (SSH key copy, device-flow copy) but never defined, so it + fell back to a bare white UA <button>. Style it as a proper icon button. */ +.icon-btn { display: inline-flex; align-items: center; - gap: 6px; - max-width: 168px; - height: 100%; - padding: 0 11px; - border: none; - border-radius: 0; - background: transparent; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + border: 1px solid var(--app-border); + border-radius: 8px; + background: var(--app-elevated); + box-shadow: var(--sheen); color: var(--app-muted); - font-family: inherit; - font-size: var(--text-2xs); - font-weight: 600; - letter-spacing: 0.02em; cursor: pointer; - transition: color var(--dur-1) var(--ease); + transition: background var(--dur-1) var(--ease), color var(--dur-1) var(--ease), + border-color var(--dur-1) var(--ease); } -.term-tab:hover { color: var(--vscode-foreground); } -.term-tab.active { color: var(--vscode-foreground); } -/* The only chrome on an active tab: a thin accent underline along its bottom edge. */ -.term-tab.active::after { - content: ""; - position: absolute; - left: 9px; - right: 9px; - bottom: 0; - height: 2px; - border-radius: 2px 2px 0 0; - background: var(--gs-accent); +.icon-btn:hover { background: var(--app-hover); color: var(--vscode-foreground); border-color: var(--accent-line); } +.icon-btn:focus-visible { outline: 2px solid var(--gs-focus-ring); outline-offset: 2px; } +.icon-btn .codicon { font-size: 15px; } + +/* Buttons must never wrap their label (the Actions detail cluster squished + "Re-run failed" → two lines). Keep labels on one line + let the cluster wrap + to a new row instead of shrinking each button below its content. */ +.btn, .btn-primary, .btn-danger, .btn-soft, .mini-btn, .row-btn, +.gh-merge-btn, .gh-seg-btn, .cmp-seg-btn, .settings-seg-btn { + white-space: nowrap; +} +.gh-detail-actions { flex-wrap: wrap; row-gap: 8px; } +.gh-detail-actions > * { flex-shrink: 0; } +.gh-detail-actions .mini-btn { height: 30px; } + +/* ════════════════════════════════════════════════════════════════════════════ + v5 TOP BAR — brand + repo + branch + sync/fetch (left, adjacent); GitHub + account pinned to the right edge. Refresh removed; the account no longer + repeats in every GitHub section header. + ════════════════════════════════════════════════════════════════════════════ */ +.topbar-left { display: flex; align-items: center; gap: 7px; min-width: 0; } +.topbar-right { display: flex; align-items: center; gap: 8px; margin-left: auto; flex: 0 0 auto; } +.topbar-left .topbar-switch { max-width: 240px; } +.topbar-branch { max-width: 260px; } +/* the repo glyph leads the repo switch in the brand accent (like the branch one) */ +.topbar-switch .glyph:first-child { color: var(--gs-accent-ink, var(--gs-accent)); } + +/* ── Notifications center: the bell + unread badge, next to the account chip ─── */ +.topbar-bell { position: relative; flex: 0 0 auto; } +.topbar-bell .codicon { font-size: 16px; } +.topbar-bell.has-unread { color: var(--vscode-foreground); } +.topbar-bell[aria-expanded="true"] { + background: var(--app-active); + color: var(--vscode-foreground); } -/* Collapsed (just the status bar): no active indicator — nothing is "open". */ -.dock-mount.collapsed .term-tab.active::after { display: none; } -.dock-mount.collapsed .term-tab.active { color: var(--app-muted); } -/* Icons are small + grayed — no accent; the label + underline carry the state. */ -.term-tab .glyph { flex: 0 0 auto; color: var(--app-muted); } -.term-tab .glyph .codicon { font-size: 12px; } -.term-tab-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.term-tab-close { +.topbar-bell-badge { + position: absolute; + top: -1px; + right: -1px; + min-width: 16px; + height: 16px; + padding: 0 4px; display: inline-flex; align-items: center; justify-content: center; - width: 15px; - height: 15px; - margin-right: -3px; - border-radius: 5px; - color: var(--app-muted); - opacity: 0; - transition: opacity var(--dur-1) var(--ease), background var(--dur-1) var(--ease), - color var(--dur-1) var(--ease); -} -.term-tab:hover .term-tab-close, -.term-tab.active .term-tab-close { opacity: 0.65; } -.term-tab-close:hover { opacity: 1; background: var(--app-hover); color: var(--vscode-foreground); } -.term-tab-close .codicon { font-size: 10px; } - -/* The xterm surface that fills a terminal tab. */ -.term-surface { - flex: 1 1 auto; - min-height: 0; - width: 100%; - position: relative; - padding: 6px 6px 6px 10px; - overflow: hidden; + border-radius: 999px; + background: var(--gs-accent); + color: #fff; + font-size: 9.5px; + font-weight: 700; + line-height: 1; + font-variant-numeric: tabular-nums; + border: 1.5px solid var(--app-panel); + box-shadow: 0 1px 3px -1px color-mix(in srgb, var(--gs-accent) 60%, transparent); + pointer-events: none; } -.term-surface .xterm { height: 100%; } -/* The single Terminal view: the shell stage (left) + a VS-Code-style side list - of terminals (right) with a "New terminal" button and per-shell kill button. */ -.term-group { flex: 1 1 auto; min-width: 0; min-height: 0; display: flex; } -.term-stage { flex: 1 1 auto; min-width: 0; min-height: 0; position: relative; display: flex; } -.term-empty { - flex: 1 1 auto; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - color: var(--app-muted); - font-size: 12.5px; -} -.term-empty .codicon { font-size: 15px; } -.term-side { - /* Width is set inline + drag-resized (see TerminalDock); was a fixed 196px slab. */ - flex: 0 0 auto; - min-width: 0; +/* ── Notifications center popover (the bell's floating inbox panel) ──────────── */ +.notif-pop { + position: fixed; + z-index: 1000; + /* 424px could not hold a title, a type pill, a repo, a reason and a time — + so every row clipped mid-word ("Split views make Issu…"). */ + width: 520px; + max-width: calc(100vw - 24px); + max-height: min(560px, calc(100vh - 80px)); display: flex; flex-direction: column; - gap: 5px; - padding: 6px; - border-left: 1px solid var(--app-border); - background: var(--app-panel); - overflow-y: auto; -} -/* Drag handle between the terminal stage and its list. */ -.term-side-resizer { - flex: 0 0 auto; - width: 7px; - margin: 0 -3px 0 -4px; - position: relative; - z-index: 3; - cursor: col-resize; -} -.term-side-resizer-grip { - position: absolute; - inset: 6px 2px; - border-radius: 2px; - transition: background var(--dur-1) var(--ease); + border-radius: 14px; + overflow: hidden; + background: color-mix(in srgb, var(--app-elevated) 96%, var(--vscode-foreground)); + border: 1px solid var(--app-border); + box-shadow: var(--sheen), var(--shadow-pop); + transform-origin: top right; + animation: menu-pop 140ms var(--ease-out) both; } -.term-side-resizer:hover .term-side-resizer-grip, -.term-side-resizer:focus-visible .term-side-resizer-grip { - background: color-mix(in srgb, var(--gs-accent) 50%, transparent); +@media (prefers-reduced-motion: reduce) { .notif-pop { animation: none; } } +.notif-pop-inner { flex: 1 1 auto; min-height: 0; min-width: 0; display: flex; } +/* min-width:0 lets the view shrink to the popover width — without it the flex + child sized to its widest row, blowing the header (and "Show all") past the + panel edge where overflow:hidden clipped it. */ +.notif-pop .notif-view { background: transparent; flex: 1 1 auto; min-width: 0; } +.notif-pop .list-head { padding: 12px 13px 10px; } +.notif-pop .list-body { padding: 7px; } +/* The narrow popover header was over-crowded → "Mark all read"/timestamps got + clipped under overflow:hidden. Let the header wrap (never clip) and compact the + mark-all action to an icon so the title + toggle fit on one line. */ +.notif-pop .list-head-row { flex-wrap: wrap; row-gap: 8px; align-items: center; } +/* A glance should read the whole subject: let popover titles take a second + line instead of clipping mid-word. ghRow titles are .gh-row-title. */ +.notif-pop .gh-row-title, +.notif-pop .row-meta-title { + white-space: normal; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; } -.term-side-resizer:focus-visible { outline: none; } -.term-side-add { - flex: 0 0 auto; +.notif-pop .notif-actions { flex: 0 0 auto; } +.notif-pop .notif-markall span { display: none; } +.notif-pop .notif-markall { padding: 0 9px; } +.notif-pop .gh-acct:empty { display: none; } + +/* The single account chip, pinned to the right edge of the bar. */ +.topbar-acct { display: inline-flex; align-items: center; - gap: 6px; - height: 26px; - padding: 0 9px; + gap: 8px; + height: 28px; + max-width: 240px; + padding: 0 12px 0 10px; + border-radius: 999px; border: 1px solid var(--app-border); - border-radius: 7px; background: var(--app-elevated); box-shadow: var(--sheen); color: var(--vscode-foreground); font-family: inherit; - font-size: 12px; + font-size: var(--text-sm); font-weight: 600; cursor: pointer; + -webkit-app-region: no-drag; transition: background var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease); } -.term-side-add:hover { background: var(--app-hover); border-color: var(--accent-line); } -.term-side-add .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } -.term-side-add .codicon { font-size: 13px; } -.term-side-list { display: flex; flex-direction: column; gap: 1px; } -.term-side-row { - position: relative; - display: flex; - align-items: center; - gap: 7px; - height: 28px; - padding: 0 7px 0 9px; - border: none; - border-radius: 6px; - background: transparent; - color: var(--app-muted); - font-family: inherit; - font-size: 12.5px; - text-align: left; - cursor: pointer; - transition: background var(--dur-1) var(--ease), color var(--dur-1) var(--ease); +.topbar-acct:hover { background: var(--app-hover); border-color: var(--accent-line); } +.topbar-acct:focus-visible { outline: 2px solid var(--gs-focus-ring); outline-offset: 2px; } +.topbar-acct .glyph { color: var(--app-muted); } +.topbar-acct .glyph .codicon { font-size: 15px; } +.topbar-acct .av { width: 22px; height: 22px; font-size: 9px; } +.topbar-acct.is-connected { padding-left: 4px; } +.topbar-acct-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* Not connected → an inviting accent "Sign in" pill (this is also the cue when a + fresh session has no token yet). */ +.topbar-acct:not(.is-connected) { + color: var(--gs-accent-ink, var(--gs-accent)); + background: var(--accent-soft); + border-color: var(--accent-line); } -.term-side-row:hover { background: var(--app-hover); color: var(--vscode-foreground); } -.term-side-row.active { - background: color-mix(in srgb, var(--gs-accent) 14%, transparent); - color: var(--vscode-foreground); +.topbar-acct:not(.is-connected) .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } +/* On narrow windows, collapse the account to just the avatar to avoid colliding + with the repo/branch switchers. */ +@media (max-width: 1040px) { + .topbar-acct.is-connected .topbar-acct-name { display: none; } + .topbar-acct.is-connected { padding: 0; width: 30px; justify-content: center; } +} + +/* ───────────────────────────────────────────────────────────────────────────── + AI: model-connection settings, MCP "Agent Access", and the Assistant view. + Built on the same token system (--app-*, --gs-accent) as the rest of the app. + ───────────────────────────────────────────────────────────────────────────── */ + +/* Pill variants used by the AI surfaces. */ +.pill.is-default { background: color-mix(in srgb, var(--gs-accent) 18%, var(--app-elevated)); color: color-mix(in srgb, var(--gs-accent-ink, var(--gs-accent)) 88%, var(--vscode-foreground)); } +.pill.is-local { background: color-mix(in srgb, var(--gs-accent-2, var(--gs-accent)) 16%, var(--app-elevated)); color: color-mix(in srgb, var(--gs-accent-ink, var(--gs-accent)) 88%, var(--vscode-foreground)); } +.pill.is-ready { background: color-mix(in srgb, var(--status-add) 18%, var(--app-elevated)); color: color-mix(in srgb, var(--status-add) 90%, var(--vscode-foreground)); } +.pill.is-warn { background: color-mix(in srgb, var(--status-warn) 20%, var(--app-elevated)); color: color-mix(in srgb, var(--status-warn) 90%, var(--vscode-foreground)); } + +/* ── AI Models card ── */ +.ai-conn-list { display: flex; flex-direction: column; gap: 9px; } +.ai-conn { + border: 1px solid var(--app-border); + border-radius: 11px; + background: var(--app-elevated); + overflow: hidden; +} +.ai-conn-head { display: flex; align-items: center; gap: 10px; padding: 10px 12px; } +.ai-conn-head > .glyph .codicon, .ai-conn-head > .glyph { color: var(--gs-accent-ink, var(--gs-accent)); font-size: 16px; } +.ai-conn-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1 1 auto; } +.ai-conn-name { display: flex; align-items: center; gap: 7px; font-size: 13px; font-weight: 650; color: var(--vscode-foreground); } +.ai-conn-sub { font-size: 11.5px; color: var(--app-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.ai-conn-actions { display: flex; align-items: center; gap: 3px; } +.ai-conn-editor { display: flex; flex-direction: column; gap: 10px; padding: 12px; border-top: 1px solid var(--app-border); background: var(--app-panel); } +.ai-add-btn { align-self: flex-start; } + +/* Provider gallery (modal) */ +.ai-gallery-pop { + position: fixed; inset: 0; z-index: 60; + background: color-mix(in srgb, #000 42%, transparent); + display: flex; align-items: center; justify-content: center; + animation: ai-fade 0.12s ease; +} +@keyframes ai-fade { from { opacity: 0; } to { opacity: 1; } } +.ai-gallery-panel { + width: min(var(--modal-lg), 92vw); max-height: 80vh; overflow: auto; + background: var(--app-panel); border: 1px solid var(--app-border); + border-radius: 14px; box-shadow: var(--shadow-lg, 0 18px 50px rgba(0,0,0,0.4)); +} +.ai-gallery-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid var(--app-border); font-weight: 650; font-size: 14px; } +.ai-gallery-section + .ai-gallery-section { border-top: 1px solid var(--app-border); } +.ai-gallery-section-head { padding: 14px 16px 2px; } +.ai-gallery-section-title { font-size: 12px; font-weight: 680; color: var(--vscode-foreground); text-transform: uppercase; letter-spacing: 0.04em; } +.ai-gallery-section-sub { font-size: 11.5px; color: var(--app-muted); margin-top: 2px; line-height: 1.4; } +.ai-gallery { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 12px 16px 16px; } +.ai-prov-card { + display: flex; align-items: flex-start; gap: 10px; text-align: left; + padding: 12px; border: 1px solid var(--app-border); border-radius: 11px; + background: var(--app-elevated); cursor: pointer; font-family: inherit; + transition: border-color 0.12s ease, transform 0.12s ease, background 0.12s ease; +} +.ai-prov-card:hover { border-color: color-mix(in srgb, var(--gs-accent) 55%, var(--app-border)); background: var(--app-hover); transform: translateY(-1px); } +.ai-prov-card .glyph .codicon, .ai-prov-card > .glyph { font-size: 18px; color: var(--gs-accent-ink, var(--gs-accent)); } +/* Real brand marks render neutral (like the actual logos), not accent-tinted. */ +.ai-logo { flex: 0 0 auto; color: var(--vscode-foreground); } +.ai-conn-head > .ai-logo { width: 17px; height: 17px; } +.ai-prov-card > .ai-logo { width: 20px; height: 20px; margin-top: 1px; } +.ai-prov-meta { display: flex; flex-direction: column; gap: 3px; min-width: 0; } +.ai-prov-name { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 650; color: var(--vscode-foreground); } +.ai-prov-blurb { font-size: 11.5px; color: var(--app-muted); line-height: 1.4; } + +/* ── Agent Access (MCP) card ── */ +.mcp-perm { display: flex; flex-direction: column; gap: 6px; } +.mcp-perm-desc { font-size: 12px; line-height: 1.5; color: var(--app-muted); margin-top: 1px; } +.mcp-danger-note { + display: flex; align-items: flex-start; gap: 7px; font-size: 11.5px; line-height: 1.45; + color: color-mix(in srgb, var(--vscode-list-warningForeground, #d9a84e) 85%, var(--vscode-foreground)); + background: color-mix(in srgb, var(--vscode-list-warningForeground, #d9a84e) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--vscode-list-warningForeground, #d9a84e) 30%, transparent); + border-radius: 9px; padding: 9px 11px; } -.term-side-row.active::before { - content: ""; - position: absolute; - left: 0; - top: 5px; - bottom: 5px; - width: 2px; - border-radius: 0 2px 2px 0; - background: var(--gs-accent); +.mcp-danger-note .codicon { font-size: 14px; margin-top: 1px; } +.mcp-clients { display: flex; flex-direction: column; gap: 7px; } +.mcp-client { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border: 1px solid var(--app-border); border-radius: 10px; background: var(--app-elevated); } +.mcp-client > .glyph .codicon, .mcp-client > .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } +.mcp-client-meta { flex: 1 1 auto; min-width: 0; } +.mcp-client-name { display: flex; align-items: center; gap: 7px; font-size: 12.5px; font-weight: 600; color: var(--vscode-foreground); } +.mcp-client > .mini-btn { flex: 0 0 auto; } +.mcp-snippet { border: 1px solid var(--app-border); border-radius: 10px; overflow: hidden; } +.mcp-snippet-head { display: flex; align-items: center; justify-content: space-between; padding: 8px 11px; background: var(--app-elevated); font-size: 11.5px; color: var(--app-muted); border-bottom: 1px solid var(--app-border); } +.mcp-snippet-code { margin: 0; padding: 11px 13px; font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: 11.5px; line-height: 1.5; color: var(--vscode-foreground); background: var(--app-bg); white-space: pre; overflow-x: auto; } + +/* ── Assistant view ── */ +.assistant-view { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; } +.assistant-head { + display: flex; align-items: center; gap: 14px; flex-wrap: wrap; + padding: 12px 18px; border-bottom: 1px solid var(--app-border); + background: var(--app-panel); } -.term-side-row .codicon { font-size: 13px; } -.term-side-label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.term-side-close { - flex: 0 0 auto; - display: inline-flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - border-radius: 5px; - color: var(--app-muted); - opacity: 0; - transition: opacity var(--dur-1) var(--ease), background var(--dur-1) var(--ease), color var(--dur-1) var(--ease); +.assistant-title { display: flex; align-items: center; gap: 8px; font-size: 14.5px; font-weight: 680; color: var(--vscode-foreground); } +.assistant-title .codicon { font-size: 17px; color: var(--gs-accent-ink, var(--gs-accent)); } +.assistant-model { font-size: 11.5px; color: var(--app-muted); } +.assistant-iconbtn { + display: inline-flex; align-items: center; justify-content: center; + width: 26px; height: 26px; border-radius: 7px; + border: 1px solid transparent; background: transparent; + color: var(--app-muted); cursor: pointer; transition: background 0.12s ease, color 0.12s ease; } -.term-side-row:hover .term-side-close, -.term-side-row.active .term-side-close { opacity: 0.7; } -.term-side-close:hover { opacity: 1; background: var(--app-hover); color: var(--vscode-errorForeground, #e15a5a); } -.term-side-close .codicon { font-size: 12px; } -.term-unavailable { padding: 16px; color: var(--app-muted); font-size: var(--text-sm); } +.assistant-iconbtn:hover { background: var(--app-hover); color: var(--vscode-foreground); } +.assistant-iconbtn .codicon { font-size: 15px; } +.assistant-perm { display: flex; align-items: center; gap: 9px; margin-left: auto; } +.assistant-perm-label { font-size: 11.5px; color: var(--app-muted); } +.assistant-perm-seg .settings-seg-btn { padding: 5px 11px; font-size: 11.5px; } -/* ── Output tab — the live git-command log ─────────────────────────────────── */ -.outputs-wrap { - flex: 1 1 auto; - min-height: 0; - width: 100%; - display: flex; - flex-direction: column; +/* The agent's options shown directly in the header as dropdown "chips", + populated live from the connected provider. */ +.assistant-controls { display: inline-flex; align-items: center; gap: 7px; margin-left: auto; flex-wrap: wrap; } +.assistant-controls.is-disabled { opacity: 0.5; pointer-events: none; } +.assistant-chip-ctl { + display: inline-flex; align-items: center; gap: 6px; + padding: 5px 9px; border-radius: 8px; + border: 1px solid var(--app-border); background: var(--app-elevated); + color: var(--vscode-foreground); font-family: inherit; font-size: 12px; cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease; } -.outputs-bar { - flex: 0 0 auto; - display: flex; - align-items: center; - gap: 6px; - height: 32px; - padding: 0 10px 0 12px; - border-bottom: 1px solid var(--app-border); +.assistant-chip-ctl:hover { background: var(--app-hover); border-color: color-mix(in srgb, var(--gs-accent) 50%, var(--app-border)); } +.assistant-chip-ctl > .glyph .codicon, .assistant-chip-ctl > .glyph:first-child { font-size: 13px; color: var(--gs-accent-ink, var(--gs-accent)); } +.assistant-chip-label { font-weight: 550; max-width: 170px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.assistant-chip-caret .codicon { font-size: 11px; color: var(--app-muted); } + +.assistant-transcript { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 18px; display: flex; flex-direction: column; gap: 14px; } +.assistant-bubble { + align-self: flex-end; max-width: min(680px, 86%); + padding: 9px 13px; border-radius: 13px 13px 4px 13px; + background: color-mix(in srgb, var(--gs-accent) 16%, var(--app-elevated)); + color: var(--vscode-foreground); font-size: 13px; line-height: 1.5; white-space: pre-wrap; } -.outputs-count { font-size: var(--text-xs); color: var(--app-muted); display: inline-flex; gap: 4px; } -.outputs-count-f { color: var(--gs-danger, #ff8585); font-weight: 600; } -.outputs-bar-spacer { flex: 1 1 auto; } -.outputs-failbtn.is-on { - color: var(--gs-danger, #ff8585); - border-color: color-mix(in srgb, var(--gs-danger, #ff8585) 42%, transparent); - background: color-mix(in srgb, var(--gs-danger, #ff8585) 12%, var(--app-panel, transparent)); +.assistant-turn { align-self: flex-start; max-width: min(760px, 94%); display: flex; flex-direction: column; gap: 8px; } +/* AI chat prose rides THE prose system (chatRender adds .gh-body-md to every + .assistant-msg container), scaled down to bubble size. The old standalone + ruleset here collapsed h3–h6 to 1em and skipped tables/links/details — the + same markdown rendered differently in chat than in a PR body. */ +.assistant-msg { font-size: 13.5px; } +.assistant-msg h1 { font-size: 1.35em; } +.assistant-msg h2 { font-size: 1.2em; } +.assistant-msg pre code { font-size: 12px; } +/* The live stream renders Markdown as it arrives; a soft blinking caret trails + the last rendered element until the step completes. */ +.assistant-msg.is-streaming > *:last-child::after { + content: ""; display: inline-block; width: 7px; height: 1em; margin-left: 2px; + vertical-align: text-bottom; background: var(--gs-accent); border-radius: 1px; + opacity: 0.75; animation: ai-caret 1s steps(2) infinite; } -.outputs-panel { - flex: 1 1 auto; - min-height: 0; - width: 100%; - overflow-y: auto; - padding: 6px 12px 12px; - font-family: var(--vscode-editor-font-family, ui-monospace, Menlo, monospace); - font-size: 12px; - line-height: 1.7; +@keyframes ai-caret { 50% { opacity: 0; } } + +.assistant-thinking { display: flex; align-items: center; gap: 9px; font-size: 12.5px; color: var(--app-muted); padding: 3px 0; } +/* Three pulsing dots that read as "actively thinking". */ +.ai-think-dots { display: inline-flex; gap: 4px; } +.ai-think-dots i { + width: 6px; height: 6px; border-radius: 50%; + background: var(--gs-accent); display: inline-block; + animation: ai-think-pulse 1.1s ease-in-out infinite; } -.outputs-empty { - display: flex; - flex-direction: column; - gap: 3px; - padding: 16px 2px; +.ai-think-dots i:nth-child(2) { animation-delay: 0.16s; } +.ai-think-dots i:nth-child(3) { animation-delay: 0.32s; } +@keyframes ai-think-pulse { 0%, 100% { opacity: 0.25; transform: scale(0.7); } 50% { opacity: 1; transform: scale(1); } } +/* The label gently shimmers so the whole row feels alive during long start-ups. */ +.ai-think-label { + font-weight: 600; + background: linear-gradient(90deg, var(--app-muted) 30%, var(--vscode-foreground) 50%, var(--app-muted) 70%); + background-size: 200% 100%; + -webkit-background-clip: text; background-clip: text; color: transparent; + animation: ai-think-shimmer 1.8s linear infinite; } -.outputs-empty-title { font-weight: 700; color: var(--vscode-foreground); font-size: var(--text-sm); } -.outputs-empty-sub { color: var(--app-muted); font-size: var(--text-xs); } -.outputs-list { display: flex; flex-direction: column; gap: 1px; } -/* "Errors only" filter hides successful rows and all-successful groups. */ -.outputs-wrap.failures-only .outputs-row:not(.is-failed) { display: none; } -.outputs-wrap.failures-only .outputs-group:not(.has-fail) { display: none; } +@keyframes ai-think-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } } +.ai-think-meta { font-size: 11px; color: var(--app-muted); opacity: 0.7; font-variant-numeric: tabular-nums; } -/* ── Action groups: the commands ONE user action executed, under its label ── */ -.outputs-group { margin: 3px 0; } -.outputs-group-head { - display: flex; - align-items: center; - gap: 6px; - width: 100%; - padding: 1px 6px; - border: none; - border-radius: 5px; - background: transparent; - color: var(--vscode-foreground); - font: inherit; - text-align: left; - cursor: pointer; +/* Labeled segmented control rows in the AI Assistant settings card. */ +.settings-seg-row { display: flex; flex-direction: column; gap: 5px; } +.settings-seg-row .settings-sub { margin-bottom: 2px; } + +.assistant-tool { + border: 1px solid var(--app-border); border-radius: 9px; background: var(--app-elevated); overflow: hidden; } -.outputs-group-head:hover { background: var(--app-hover); } -.outputs-group-chev { +.assistant-tool.is-expandable .assistant-tool-head { cursor: pointer; } +.assistant-tool-head { display: flex; align-items: center; gap: 8px; padding: 7px 11px; font-size: 12px; } +.assistant-tool-head > .glyph .codicon, .assistant-tool-head > .glyph { color: var(--gs-accent-ink, var(--gs-accent)); font-size: 13px; } +.assistant-tool-name { font-weight: 620; color: var(--vscode-foreground); text-transform: capitalize; } +.assistant-tool-arg { color: var(--app-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; } +.assistant-tool-spin { margin-left: auto; } +.assistant-tool-status { margin-left: auto; } +.assistant-tool.is-error .assistant-tool-status .codicon { color: var(--gs-danger, #e15a5a); } +.assistant-tool:not(.is-error) .assistant-tool-status .codicon { color: var(--vscode-testing-iconPassed, #4caf72); } +.assistant-tool.is-denied { opacity: 0.66; } +/* A declined step is neither a pass nor a failure — it is a decision. It must + not borrow the green tick that `:not(.is-error)` above would otherwise give + it, which read as "done". */ +.assistant-tool-verdict { + margin-left: auto; font-size: 11px; color: var(--app-muted); - transition: transform var(--dur-1, 110ms) var(--ease, ease); } -.outputs-group.is-collapsed .outputs-group-chev { transform: rotate(-90deg); } -.outputs-group-label { - font-family: var(--vscode-font-family, sans-serif); - font-size: var(--text-xs, 11.5px); - font-weight: 650; +.assistant-tool.is-denied .assistant-tool-status { margin-left: var(--sp-2); } +.assistant-tool.is-denied .assistant-tool-status .codicon { color: var(--app-muted); } +.assistant-tool-out { margin: 0; padding: 9px 12px; border-top: 1px solid var(--app-border); font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: 11px; line-height: 1.5; color: var(--app-muted); white-space: pre-wrap; max-height: 240px; overflow: auto; background: var(--app-bg); } + +.assistant-error { display: flex; align-items: flex-start; gap: 8px; font-size: 12.5px; color: var(--gs-danger, #e15a5a); background: color-mix(in srgb, var(--gs-danger, #e15a5a) 10%, transparent); border: 1px solid color-mix(in srgb, var(--gs-danger, #e15a5a) 28%, transparent); border-radius: 9px; padding: 9px 11px; } +.assistant-error .codicon { margin-top: 1px; } + +.assistant-empty { margin: auto; max-width: 420px; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 9px; color: var(--app-muted); } +.assistant-empty > .glyph .codicon, .assistant-empty > .glyph { font-size: 30px; color: var(--gs-accent-ink, var(--gs-accent)); } +.assistant-empty-title { font-size: 15px; font-weight: 680; color: var(--vscode-foreground); } +.assistant-empty-sub { font-size: 12.5px; line-height: 1.5; } + +.assistant-composer { + border-top: 1px solid var(--app-border); + /* The dock overlays the bottom of the view — see `.rb-foot` above. Without + this the composer, its Send button and every quick action sat behind an + open terminal, which is the one control the Assistant cannot do without. */ + padding: 12px 18px calc(14px + var(--dock-reserve, 0px)); + background: var(--app-panel); + display: flex; flex-direction: column; gap: 10px; } -.outputs-group.has-fail .outputs-group-label { color: var(--gs-danger, #ff8585); } -.outputs-group-meta { font-size: 11px; color: var(--app-muted); } -/* Commands indent under their action along a soft rail. */ -.outputs-group-body { - margin-left: 11px; - padding-left: 8px; - border-left: 1px solid var(--app-border); +.assistant-quick { display: flex; flex-wrap: wrap; gap: 7px; } +.assistant-chip { + display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px; + border: 1px solid var(--app-border); border-radius: 999px; background: var(--app-elevated); + color: var(--vscode-foreground); font-family: inherit; font-size: 12px; cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease; +} +.assistant-chip:hover { background: var(--app-hover); border-color: color-mix(in srgb, var(--gs-accent) 50%, var(--app-border)); } +.assistant-chip .codicon { font-size: 13px; color: var(--gs-accent-ink, var(--gs-accent)); } +.assistant-input-row { display: flex; align-items: flex-end; gap: 9px; } +.assistant-input { + flex: 1 1 auto; resize: none; min-height: 40px; max-height: 180px; + padding: 10px 13px; border-radius: 11px; border: 1px solid var(--app-border); + background: var(--app-elevated); color: var(--vscode-foreground); + font-family: inherit; font-size: 13px; line-height: 1.5; outline: none; +} +.assistant-input:focus { border-color: color-mix(in srgb, var(--gs-accent) 55%, var(--app-border)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--gs-accent) 20%, transparent); } +.assistant-send { flex: 0 0 auto; height: 40px; width: 44px; padding: 0; display: inline-flex; align-items: center; justify-content: center; } +.assistant-send.is-cancel { background: var(--status-del); } +/* Stop the shared .btn-primary:hover (purple) from winning on the cancel button — + a stop/cancel action must stay danger-red on hover, not flip to accent. */ +.assistant-send.is-cancel:hover { + background: color-mix(in srgb, var(--status-del) 88%, white 12%); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22), + 0 6px 18px color-mix(in srgb, var(--status-del) 40%, transparent); +} +.assistant-send .codicon { font-size: 16px; } + +/* ───────────────────────────────────────────────────────────────────────────── + Inline AI affordances (✨ chips, the footer chat tabs, header launcher) and + the Markdown table styling shared by every AI surface. + ───────────────────────────────────────────────────────────────────────────── */ + +/* The header Assistant launcher. */ +.topbar-assistant { width: auto; gap: 6px; padding: 0 11px; } +.topbar-assistant .codicon { color: var(--gs-accent-ink, var(--gs-accent)); } +.topbar-assistant-label { font-size: 12.5px; font-weight: 600; } +@media (max-width: 1040px) { .topbar-assistant-label { display: none; } .topbar-assistant { padding: 0; width: 30px; justify-content: center; } } + +/* ✨ chips + the AI variant of mini-btn. */ +.ai-chip { + display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; + border: 1px solid color-mix(in srgb, var(--gs-accent) 30%, var(--app-border)); + border-radius: 999px; background: color-mix(in srgb, var(--gs-accent) 8%, var(--app-elevated)); + color: var(--vscode-foreground); font-family: inherit; font-size: 12px; cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease; +} +.ai-chip:hover { background: color-mix(in srgb, var(--gs-accent) 16%, var(--app-elevated)); border-color: color-mix(in srgb, var(--gs-accent) 55%, var(--app-border)); } +.ai-chip:disabled { opacity: 0.6; cursor: default; } +.ai-chip .codicon { font-size: 13px; color: var(--gs-accent-ink, var(--gs-accent)); } +.ai-mini .codicon:first-child { color: var(--gs-accent-ink, var(--gs-accent)); } +.cmp-ai { display: flex; flex-wrap: wrap; gap: 7px; margin-left: auto; } +/* The commit message box + its in-corner "Write message" affordance. */ +.dc-message-wrap { position: relative; display: flex; } +.dc-message-wrap .dc-message { width: 100%; } +/* "Write message" now lives in the branch header row, pushed to the right. A + crisp accent-tinted pill (not a translucent overlay floating in the textarea). */ +.dc-ai-write { + margin-left: auto; + align-self: center; + height: 27px; + padding: 0 12px; + gap: 6px; + font-size: 12px; + font-weight: 600; + border-radius: 999px; + background: color-mix(in srgb, var(--gs-accent) 14%, var(--app-elevated)); + border-color: color-mix(in srgb, var(--gs-accent) 45%, var(--app-border)); + box-shadow: var(--sheen), var(--shadow-sm); } -.outputs-group.is-collapsed .outputs-group-body { display: none; } -/* Lean rows tag their action inline, muted, in the UI font. */ -.outputs-act { - flex: 0 0 auto; - font-family: var(--vscode-font-family, sans-serif); - font-size: 10.5px; - color: var(--app-muted); - opacity: 0.85; - padding: 0 5px; - border-radius: 4px; - background: color-mix(in srgb, var(--vscode-foreground) 7%, transparent); +.dc-ai-write:hover { + background: color-mix(in srgb, var(--gs-accent) 22%, var(--app-elevated)); + border-color: color-mix(in srgb, var(--gs-accent) 60%, var(--app-border)); } -.outputs-row { border-radius: 5px; } -.outputs-line { - display: flex; - align-items: baseline; - gap: 8px; - padding: 0 6px; - border-radius: 5px; - white-space: nowrap; +.dc-ai-write .codicon { font-size: 13px; } +/* The "Review with AI" toolbar action picks up the accent glyph like the chips. */ +.dc-review .glyph .codicon, .dc-review .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } + +/* ✨ AI chat tabs in the footer dock — the ✨ icon carries the accent so the AI + tabs read distinctly from Output / Terminal. */ +.term-tab.is-chat .glyph .codicon { color: var(--gs-accent-ink, var(--gs-accent)); } + +/* The footer AI chat panel: a compact variant of the full Assistant transcript + + composer. The action's prompt is the first turn; follow-ups continue below. */ +.chat-panel { display: flex; flex-direction: column; height: 100%; min-height: 0; } +.chat-panel-transcript { + flex: 1 1 auto; min-height: 0; overflow-y: auto; + padding: 12px 14px; display: flex; flex-direction: column; gap: 10px; } -.outputs-line:hover { background: var(--app-hover, var(--vscode-list-hoverBackground)); } -.outputs-time { flex: 0 0 auto; color: var(--app-muted); opacity: 0.7; font-size: 11px; } -.outputs-kw { flex: 0 0 auto; color: var(--app-muted); opacity: 0.8; } -.outputs-sub { flex: 0 0 auto; color: var(--gs-accent-ink, var(--gs-accent)); font-weight: 700; } -.outputs-args { min-width: 0; overflow: hidden; text-overflow: ellipsis; color: color-mix(in srgb, var(--vscode-foreground) 78%, transparent); } -.outputs-rep { - flex: 0 0 auto; - color: var(--app-muted); - font-size: 10.5px; - font-weight: 700; +.chat-panel-composer { + flex: 0 0 auto; padding: 8px 10px; + border-top: 1px solid var(--app-border); background: var(--app-panel); } -.outputs-rep:empty { display: none; } -.outputs-dur { flex: 0 0 auto; margin-left: auto; color: var(--app-muted); opacity: 0.65; font-size: 11px; } -.outputs-dur.is-slow { color: var(--status-warn, #e0a44e); opacity: 1; } -.outputs-code { - flex: 0 0 auto; - padding: 0 6px; - border-radius: 5px; - font-size: 10.5px; - font-weight: 700; - color: #fff; - background: color-mix(in srgb, var(--gs-danger, #ff6b6b) 82%, black 8%); +.chat-panel .assistant-input-row { display: flex; gap: 8px; align-items: flex-end; } +.chat-panel .assistant-input { flex: 1 1 auto; resize: none; min-height: 34px; max-height: 140px; } +.chat-panel .assistant-send { + flex: 0 0 auto; height: 34px; width: 38px; padding: 0; + display: inline-flex; align-items: center; justify-content: center; } -/* Failed rows: a red-tinted block; expandable when stderr was captured. */ -.outputs-row.is-failed { - background: color-mix(in srgb, var(--gs-danger, #ff6b6b) 7%, transparent); + +/* ── Prose supplements ─────────────────────────────────────────────────────── + The element rules live in THE prose system (".gh-body-md, .code-md" earlier + in this file — a legacy duplicate block here used to OVERRIDE it by source + order, which is why tables/kbd/details looked different per surface). Only + the bits the unified system deliberately leaves out remain here. */ +/* Badge rows (shields.io et al) sit inline and shouldn't gain a card look. */ +.code-md a img, .gh-body-md a img, .assistant-msg a img { + border-radius: 3px; + margin: 1px 2px; } -.outputs-row.is-failed .outputs-sub, -.outputs-row.is-failed .outputs-args { color: var(--gs-danger, #ff8585); } -.outputs-row.is-failed .outputs-line[role="button"] { cursor: pointer; } -.outputs-chevron { - flex: 0 0 auto; - align-self: center; - font-size: 11px; +/* GitHub-style centered headers: <p align="center"> is legacy HTML the + sanitizer keeps, but flex/grid children ignore it — force it here. */ +.code-md [align="center"], .gh-body-md [align="center"], .assistant-msg [align="center"] { text-align: center; } +.code-md [align="right"], .gh-body-md [align="right"], .assistant-msg [align="right"] { text-align: right; } +.code-md del, .gh-body-md del, .assistant-msg del { opacity: 0.68; } +.code-md kbd, .gh-body-md kbd { white-space: nowrap; } +.gh-body-md pre, .assistant-msg pre { overflow-x: auto; max-width: 100%; } +/* Elements the sanitizer allows that had NO styling anywhere (fell back to + ugly UA defaults): definition lists, footnote marks, quiet inline HTML. */ +.gh-body-md dl, .code-md dl { margin: 0.6em 0; } +.gh-body-md dt, .code-md dt { font-weight: 650; margin-top: 0.5em; } +.gh-body-md dd, .code-md dd { margin: 0.15em 0 0.15em 1.4em; color: var(--app-muted); } +.gh-body-md sup, .gh-body-md sub, .code-md sup, .code-md sub { font-size: 0.75em; } +.gh-body-md caption, .code-md caption { + caption-side: bottom; + font-size: 0.85em; color: var(--app-muted); - transition: transform var(--dur-1, 110ms) var(--ease, ease); -} -.outputs-row.is-open .outputs-chevron { transform: rotate(90deg); } -.outputs-stderr { - display: none; - margin: 0 6px 4px; - padding: 6px 9px; - border-radius: 5px; - border-left: 2px solid color-mix(in srgb, var(--gs-danger, #ff6b6b) 55%, transparent); - background: color-mix(in srgb, var(--gs-danger, #ff6b6b) 5%, var(--app-panel, transparent)); - color: color-mix(in srgb, var(--vscode-foreground) 82%, transparent); - font-size: 11.5px; - line-height: 1.55; - white-space: pre-wrap; - word-break: break-word; + padding: 4px 0; } -.outputs-row.is-open .outputs-stderr { display: block; } -/* ── Clone dialog ──────────────────────────────────────────────────────────── */ -.clone-card { width: min(560px, 92vw); gap: 14px; } -.clone-tabs { align-self: flex-start; } -.clone-tabs .gh-seg-btn { height: 30px; padding: 0 14px; } -.clone-panel { display: flex; flex-direction: column; gap: 10px; } -.clone-gh { gap: 10px; } -.clone-url-input, -.clone-search { width: 100%; font-family: var(--vscode-editor-font-family, ui-monospace, monospace); } -.clone-search { font-family: inherit; } -.clone-repo-list { - display: flex; - flex-direction: column; - gap: 2px; - max-height: 320px; - overflow-y: auto; - border: 1px solid var(--app-border); - border-radius: 10px; - padding: 5px; - background: var(--app-bg); +/* Code view: instant in-folder filter + keyboard navigation affordances. */ +.code-filter { + width: 190px; max-width: 34vw; + padding: 4px 9px; font-size: 12px; font-family: inherit; + color: var(--app-text); background: var(--app-elevated); + border: 1px solid var(--app-border); border-radius: 7px; + transition: width 140ms ease, border-color 120ms ease; } -.clone-repo { align-items: flex-start; padding: 8px 10px; border-radius: 8px; } -.clone-repo.is-current { - background: var(--accent-soft); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--gs-accent) 30%, transparent); +.code-filter::placeholder { color: var(--app-muted); } +.code-filter:focus { + width: 260px; outline: none; + border-color: var(--gs-accent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--gs-accent) 22%, transparent); } -.clone-repo-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1 1 auto; } -.clone-repo-name { - display: flex; - align-items: center; - gap: 7px; - font-size: var(--text-base); - font-weight: 600; - color: var(--vscode-foreground); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.code-row .file-path mark { + background: color-mix(in srgb, var(--gs-accent) 34%, transparent); + color: inherit; border-radius: 3px; padding: 0 1px; } -.clone-repo-badge { - flex: 0 0 auto; - font-size: 9.5px; - font-weight: 700; - letter-spacing: 0.04em; - text-transform: uppercase; - padding: 1px 6px; - border-radius: 999px; - color: var(--app-muted); - background: color-mix(in srgb, var(--app-muted) 18%, transparent); +.code-row:focus-visible { + outline: 1px solid var(--gs-accent); outline-offset: -1px; + background: var(--app-hover); } -.clone-repo-desc { - font-size: var(--text-xs); - color: var(--app-muted); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.code-listing.is-empty-filter::after { + content: "No files match that filter."; + display: block; padding: 18px 14px; color: var(--app-muted); font-size: 12.5px; } -.clone-repo-meta { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 2px; } -.clone-meta-bit { font-size: 11px; color: var(--app-muted); font-variant-numeric: tabular-nums; } -.clone-scheme { align-self: flex-start; } -.clone-scheme .gh-seg-btn { height: 26px; padding: 0 12px; } -.clone-dest { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 12px; - border: 1px solid var(--app-border); - border-radius: 10px; - background: var(--app-panel); +/* ── Interactive Rebase view ────────────────────────────────────────────────── + A commit rail with node dots, per-commit action dropdowns, plain-English + consequences, and a dimmed "onto" base row — the desktop twin of the + extension's rebase workspace. */ +.rb-view { display: flex; flex-direction: column; height: 100%; overflow: auto; } +.rb-head { + display: flex; align-items: center; gap: 10px; flex-wrap: wrap; + padding: 14px 18px 10px; position: sticky; top: 0; z-index: 5; + background: var(--app-bg); border-bottom: 1px solid var(--app-border); } -.clone-dest-text { flex: 1 1 auto; min-width: 0; } -.clone-dest-label { - font-size: var(--text-2xs); - font-weight: 700; - letter-spacing: var(--track-label); - text-transform: uppercase; - color: var(--app-muted); +.rb-title { display: flex; align-items: center; gap: 7px; font-size: 14px; font-weight: 650; } +.rb-title .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } +.rb-sub { display: flex; align-items: center; gap: 4px; color: var(--app-muted); font-size: 12px; } +.rb-sub b { color: var(--gs-accent-ink, var(--gs-accent)); font-family: var(--vscode-editor-font-family, ui-monospace, monospace); } +.rb-spacer { flex: 1 1 auto; } +.rb-hint { display: flex; align-items: center; gap: 6px; padding: 8px 18px 2px; color: var(--app-muted); font-size: 11.5px; } + +.rb-explain { + position: relative; margin: 10px 16px 2px; padding: 12px 34px 12px 14px; + border: 1px solid var(--app-border); border-radius: 10px; + background: color-mix(in srgb, var(--gs-accent) 7%, var(--app-elevated)); } -.clone-dest-path { - font-size: var(--text-sm); - color: var(--vscode-foreground); - font-family: var(--vscode-editor-font-family, ui-monospace, monospace); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.rb-explain[hidden] { display: none; } +.rb-explain-lead { display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; font-size: 12.5px; line-height: 1.55; } +.rb-explain-lead .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } +.rb-explain-x { + position: absolute; top: 7px; right: 8px; width: 22px; height: 22px; + border: none; background: transparent; color: var(--app-muted); + font-size: 16px; line-height: 1; cursor: pointer; border-radius: 6px; } -.clone-choose { flex: 0 0 auto; } +.rb-explain-x:hover { background: var(--app-hover); color: var(--app-text); } +.rb-gloss { + /* Wide enough for the longest gloss ("Squash — merge into the commit below + it, keep both messages") to sit on ONE line: two of six used to wrap, so + the legend had a ragged two-height bottom edge. */ + display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); + gap: 4px 18px; margin-top: 10px; font-size: 11.5px; color: var(--app-muted); +} +.rb-gloss > span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.rb-gloss b { font-weight: 650; margin-right: 2px; } +.rb-gloss .g-pick { color: var(--app-text); } +/* These three were a literal dark-theme blue (2.67:1 on the light page) and one + shared purple for two different actions — so the legend could not tell you + which of squash/fixup a row was set to. Each action gets its own themed ink. */ +.rb-gloss .g-reword { color: var(--status-mod); } +.rb-gloss .g-squash { color: var(--gs-accent-ink, var(--gs-accent)); } +.rb-gloss .g-fixup { color: var(--status-warn); } +.rb-gloss .g-edit { color: var(--status-warn); } +.rb-gloss .g-drop { color: var(--gs-danger); } -.clone-progress { display: flex; flex-direction: column; gap: 7px; } -.clone-progress-phase { font-size: var(--text-xs); color: var(--app-muted); font-variant-numeric: tabular-nums; } -.clone-progress-bar { - height: 7px; - border-radius: 999px; - overflow: hidden; - background: color-mix(in srgb, var(--app-muted) 20%, transparent); +.rb-list { padding: 6px 16px 8px; } +.rb-row { + display: flex; align-items: stretch; gap: 9px; position: relative; + padding: 8px 10px 8px 0; border-radius: 8px; + transition: background 120ms ease, opacity 120ms ease; } -.clone-progress-fill { - height: 100%; - width: 0; - border-radius: 999px; - background: linear-gradient(90deg, var(--gs-accent), var(--gs-accent-2)); - transition: width 200ms var(--ease); +.rb-row:hover { background: var(--app-hover); } +.rb-row.dragging { opacity: 0.4; } +/* The drop line sits on the side the pointer is on, and the insert follows it + — see `wireDrag`. A fixed line under the row only agreed with the insert + when you dragged downward. */ +.rb-row.drag-over { box-shadow: inset 0 -2px 0 var(--gs-accent); } +.rb-row.drag-over-top { box-shadow: inset 0 2px 0 var(--gs-accent); } +.rb-row:focus-visible { outline: 1px solid var(--gs-accent); outline-offset: -1px; } +.rb-row.dropped .rb-subj { text-decoration: line-through; opacity: 0.55; } + +/* The continuous rail + per-commit node dot. */ +.rb-rail { flex: 0 0 24px; position: relative; } +.rb-rail::before { + content: ""; position: absolute; left: 50%; top: 0; bottom: 0; width: 2px; + transform: translateX(-50%); background: var(--gs-accent); opacity: 0.35; } -.clone-progress-fill.indeterminate { animation: clone-indeterminate 1.1s var(--ease) infinite; } -@keyframes clone-indeterminate { - 0% { transform: translateX(-100%); } - 100% { transform: translateX(100%); } +.rb-node { + position: absolute; left: 50%; top: 13px; width: 9px; height: 9px; + transform: translate(-50%, -50%); border-radius: 50%; + background: var(--gs-accent); box-shadow: 0 0 0 3px var(--app-bg); } -.clone-card.is-busy .clone-repo-list, -.clone-card.is-busy .clone-tabs { opacity: 0.6; pointer-events: none; } - -/* ── Skeleton loading shimmer ──────────────────────────────────────────────── */ -.sk { - position: relative; - overflow: hidden; - border-radius: 7px; - background: color-mix(in srgb, var(--app-muted) 14%, transparent); +.rb-row[data-action="squash"] .rb-node, .rb-row[data-action="fixup"] .rb-node { + width: 6px; height: 6px; background: var(--app-bg); border: 2px solid var(--gs-accent); } -.sk::after { - content: ""; - position: absolute; - inset: 0; - transform: translateX(-100%); - background: linear-gradient(90deg, transparent, - color-mix(in srgb, var(--vscode-foreground) 7%, transparent), transparent); - animation: sk-shimmer 1.3s var(--ease) infinite; +.rb-row[data-action="drop"] .rb-node { background: var(--app-bg); border: 2px solid var(--gs-danger); } +.rb-row.rb-base .rb-node { background: var(--app-bg); border: 2px solid var(--app-muted); } +.rb-row.rb-base .rb-rail::before { bottom: 50%; } +.rb-row.rb-base { opacity: 0.72; } +.rb-onto { + /* Sized and aligned like the action select it stands in for. */ + align-self: flex-start; margin-top: 1px; + flex: 0 0 auto; min-width: 88px; text-align: center; + font-size: 10px; font-weight: 700; letter-spacing: 0.08em; line-height: 20px; + text-transform: uppercase; color: var(--app-muted); + border: 1px solid var(--app-border); border-radius: 6px; padding: 0 6px; } -@keyframes sk-shimmer { 100% { transform: translateX(100%); } } -@media (prefers-reduced-motion: reduce) { .sk::after { animation: none; } } -.sk-list { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; } -.sk-row { display: flex; align-items: center; gap: 11px; padding: 7px 8px; } -.sk-dot { width: 22px; height: 22px; border-radius: 50%; flex: 0 0 auto; } -.sk-lines { display: flex; flex-direction: column; gap: 6px; flex: 1 1 auto; min-width: 0; } -.sk-line { height: 9px; } -.sk-line.short { width: 38%; } -.sk-line.mid { width: 62%; } +/* Reserves the grip's column on the anchor row, which has nothing to drag. */ +.rb-grip.is-spacer { visibility: hidden; pointer-events: none; opacity: 1; } -/* ── Menu polish: entry animation + searchable long menus ───────────────────── */ -.dropdown, .ctx-menu { - transform-origin: top left; - animation: menu-pop 140ms var(--ease-out) both; -} -@keyframes menu-pop { - from { opacity: 0; transform: translateY(-6px) scale(0.97); } - to { opacity: 1; transform: translateY(0) scale(1); } +.rb-grip { display: flex; align-items: center; color: var(--app-muted); cursor: grab; opacity: 0; } +.rb-row:hover .rb-grip, .rb-row:focus-within .rb-grip { opacity: 1; } + +.rb-action { + align-self: flex-start; margin-top: 1px; min-width: 88px; + background: var(--app-elevated); color: var(--app-text); + border: 1px solid var(--app-border); border-radius: 6px; + padding: 3px 6px; font-size: 11.5px; font-weight: 600; cursor: pointer; } -@media (prefers-reduced-motion: reduce) { - .dropdown, .ctx-menu { animation: none; } +.rb-action.a-reword { color: var(--status-mod); } +.rb-action.a-squash { color: var(--gs-accent-ink, var(--gs-accent)); } +.rb-action.a-fixup { color: var(--status-warn); } +.rb-action.a-edit { color: var(--status-warn); } +.rb-action.a-drop { color: var(--gs-danger); } + +.rb-main { flex: 1 1 auto; min-width: 0; } +.rb-line { display: flex; align-items: center; gap: 8px; min-width: 0; } +.rb-subj { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; } +.rb-avatar { + flex: 0 0 auto; width: 18px; height: 18px; border-radius: 50%; + display: inline-flex; align-items: center; justify-content: center; + font-size: 9px; font-weight: 700; color: #fff; + background: hsl(var(--h, 250) 55% 45%); } -.dropdown-item { transition: background var(--dur-1) var(--ease); } -.dropdown-search-wrap { - position: sticky; - top: -5px; - z-index: 1; - margin: -5px -5px 4px; - padding: 7px 7px 6px; - background: linear-gradient(180deg, var(--app-elevated), color-mix(in srgb, var(--app-elevated) 92%, transparent)); - border-bottom: 1px solid var(--app-border); - backdrop-filter: blur(4px); +.rb-meta { flex: 0 0 auto; color: var(--app-muted); font-size: 11px; } +.rb-sha { + flex: 0 0 auto; display: inline-flex; align-items: center; gap: 3px; + color: var(--app-muted); font-size: 11px; + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); } -.dropdown-search { - width: 100%; - height: 30px; - padding: 0 10px; - border-radius: 8px; - border: 1px solid var(--app-border); - background: var(--app-panel); - color: var(--vscode-foreground); +.rb-sha .glyph { font-size: 11px; } + +/* The reword editor only appears for a `reword` row. */ +.rb-reword { display: none; margin-top: 6px; } +.rb-row[data-action="reword"] .rb-reword { display: block; } +.rb-reword textarea { + width: 100%; resize: vertical; padding: 6px 8px; font-size: 12px; + color: var(--app-text); background: var(--app-elevated); + border: 1px solid var(--app-border); border-radius: 6px; font-family: inherit; - font-size: var(--text-sm); - outline: none; } -.dropdown-search:focus { - border-color: color-mix(in srgb, var(--gs-accent) 55%, var(--app-border)); - box-shadow: var(--ring); +.rb-reword textarea:focus { outline: 1px solid var(--gs-accent); border-color: var(--gs-accent); } + +/* Inline with the subject, so showing it costs no height. See rebase.ts. */ +.rb-consequence { + display: none; + align-items: center; + gap: 5px; + flex: 0 0 auto; + white-space: nowrap; + font-size: 11.5px; + color: var(--app-muted); } +.rb-consequence .glyph { font-size: 12px; } +.rb-row[data-action="squash"] .rb-consequence, +.rb-row[data-action="fixup"] .rb-consequence, +.rb-row[data-action="edit"] .rb-consequence, +.rb-row[data-action="drop"] .rb-consequence { display: inline-flex; } +.rb-row[data-action="squash"] .rb-consequence { color: var(--gs-accent-ink, var(--gs-accent)); } +.rb-row[data-action="fixup"] .rb-consequence { color: var(--status-warn); } +.rb-row[data-action="edit"] .rb-consequence { color: var(--status-warn); } +.rb-row[data-action="drop"] .rb-consequence { color: var(--gs-danger); } +/* A squash/fixup that lost its fold target. Outranks the per-action colours above. */ +.rb-row[data-action] .rb-consequence.bad { color: var(--gs-danger); } -/* ════════════════════════════════════════════════════════════════════════════ - v4 REDESIGN — rich GitHub rows, premium empty states, header counts, avatars. - The shared master-detail surfaces (PRs/Issues/Actions/Releases/Notifications/ - Orgs/Gists) all read from these, so one redesign lifts every section. - ════════════════════════════════════════════════════════════════════════════ */ +.rb-banner { margin: 6px 16px; padding: 8px 12px; border-radius: 8px; font-size: 12px; } +.rb-banner[hidden] { display: none; } +.rb-banner.warn { background: color-mix(in srgb, var(--status-warn) 16%, transparent); border: 1px solid color-mix(in srgb, var(--status-warn) 40%, transparent); } +.rb-banner.error { background: color-mix(in srgb, var(--gs-danger) 15%, transparent); border: 1px solid color-mix(in srgb, var(--gs-danger) 40%, transparent); } -/* ── Avatars (real image or deterministic initials tile) ───────────────────── */ -.av { - flex: 0 0 auto; +/* Sticky to the bottom of the VIEW, which is not the bottom of the window when + the dock is open — it overlays the view area and publishes its height as + `--dock-reserve`. At `bottom: 0` this footer sat underneath it: the Rebase + view's Start button, its preview line and the "move stacked branches" toggle + were all behind an open terminal, with no scroll that could reach them and no + dock size that would reveal them. Same for the Assistant's composer below. */ +.rb-foot { + display: flex; align-items: center; gap: 10px; flex-wrap: wrap; + padding: 12px 18px; margin-top: auto; + position: sticky; bottom: var(--dock-reserve, 0px); background: var(--app-bg); + border-top: 1px solid var(--app-border); +} +.rb-preview { color: var(--app-muted); font-size: 11.5px; } +/* "Move stacked-a, stacked-b with the rewrite" — shown only when branches + actually sit inside the range. A rebase gives every commit a new id, so + without this those branches end up pointing at commits that are no longer in + the branch's history. */ +.rb-carry { display: inline-flex; align-items: center; - justify-content: center; - border-radius: 50%; - overflow: hidden; - font-weight: 700; - letter-spacing: 0.01em; + gap: 7px; + font-size: 11.5px; + color: var(--app-muted); + cursor: pointer; user-select: none; } -.av-img { object-fit: cover; background: var(--app-panel); } -.av-fallback { - color: #fff; - background: var(--av, var(--gs-accent)); - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18); +.rb-carry:hover { color: var(--app-text); } +.rb-carry input { margin: 0; cursor: pointer; } +.rb-btn { + display: inline-flex; align-items: center; gap: 6px; + padding: 6px 12px; border-radius: 7px; font-size: 12.5px; font-weight: 600; + border: 1px solid var(--app-border); background: var(--app-elevated); + color: var(--app-text); cursor: pointer; } +.rb-btn:hover { background: var(--app-hover); } +.rb-btn.primary { background: var(--gs-accent); border-color: var(--gs-accent); color: #fff; } +.rb-btn.primary:hover { filter: brightness(1.08); } +.rb-btn.danger { color: var(--gs-danger); border-color: color-mix(in srgb, var(--gs-danger) 45%, var(--app-border)); } +.rb-btn.ghost { background: transparent; } +.rb-btn:disabled { opacity: 0.6; cursor: default; } +.rb-btn.busy .glyph { animation: rb-spin 1s linear infinite; } +@keyframes rb-spin { to { transform: rotate(360deg); } } -/* ── Rich list row (the new gh-row) ────────────────────────────────────────── */ -.gh-list { padding: 7px; } -.gh-row-rich { +/* Was flush to the top-left corner of an otherwise blank 1600x1000 pane, with + nothing to anchor it. Centre it the way every other loading state sits. */ +.rb-loading { display: flex; - flex-direction: row; /* override .gh-row's column — else the lead stacks above the body */ align-items: center; - gap: 11px; - width: 100%; - text-align: left; - padding: 10px 11px; - border: 1px solid transparent; - border-radius: 11px; - background: transparent; - color: var(--vscode-foreground); - font-family: inherit; - cursor: pointer; - transition: background var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease); + justify-content: center; + gap: 8px; + padding: 24px; + min-height: min(100%, 320px); + color: var(--app-muted); } -.gh-row-rich:hover { background: var(--app-hover); } -.gh-row-rich.active { - background: var(--accent-soft); - border-color: color-mix(in srgb, var(--gs-accent) 30%, transparent); +.rb-loading .glyph { animation: rb-spin 1s linear infinite; } +.rb-basebar { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } +/* One-click range presets so a rebase can start without typing a ref. */ +.rb-chip { + padding: 4px 10px; border-radius: 999px; font-size: 11.5px; font-weight: 600; + border: 1px solid var(--app-border); background: var(--app-elevated); + color: var(--app-muted); cursor: pointer; } -.gh-row-rich:focus-visible { - outline: 2px solid color-mix(in srgb, var(--gs-accent) 70%, transparent); - outline-offset: -1px; +.rb-chip:hover { background: var(--app-hover); color: var(--app-text); } +/* Loading a base is work on the CONTROL, not on the workspace: the plan you + composed stays on screen while the request runs. */ +.rb-chip.is-busy, .rb-basebar button.is-busy { + opacity: 0.6; + cursor: progress; } -.gh-row-lead { +.rb-chip.is-active { + color: var(--gs-accent-ink, var(--gs-accent)); + border-color: color-mix(in srgb, var(--gs-accent) 55%, var(--app-border)); + background: color-mix(in srgb, var(--gs-accent) 12%, transparent); +} + +.rb-inprogress { margin: 22px auto; max-width: 560px; padding: 18px 20px; border: 1px solid var(--app-border); border-radius: 12px; background: var(--app-elevated); } +.rb-inprogress-head { display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 650; } +.rb-inprogress-head .glyph { color: var(--status-warn); } +.rb-inprogress-body { display: block; margin: 8px 0 14px; color: var(--app-muted); font-size: 12.5px; line-height: 1.55; } +.rb-inprogress-btns { display: flex; gap: 8px; } + +/* ════════════════════════════════════════════════════════════════════════════ + Depth styles — conflict merge bar, GitHub Actions (logs/artifacts/secrets), + commit-composer depth, op-state banner, PR review (Files diff + inline threads), + and a handful of CSS-only design-audit fixes. + + Self-contained block. Every value reuses the shared tokens (--app-*, --gs-accent*, + --status-*, --sheen/--shadow-sm, the radius ladder, --dur-1/--ease) so these + surfaces are pixel-consistent with the rest of the app and flip cleanly between + the dark and light themes. Motion is reduced-motion-safe via the global + prefers-reduced-motion reset near the top of this file. + ════════════════════════════════════════════════════════════════════════════ */ + +/* ── 1 · Conflict-resolution merge bar (diffPanel.ts) ───────────────────────── */ +/* The merge editor lives in a column: a slim toolbar on top, the Monaco merge + surface filling the rest. `.merge-surface` MUST keep min-height:0 so the editor + resolves a real pixel height inside the flex parent (else Monaco collapses). */ +.merge-wrap { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} +.merge-bar { flex: 0 0 auto; - display: inline-flex; + display: flex; align-items: center; - justify-content: center; - margin-top: 1px; + justify-content: space-between; + gap: 12px; + min-width: 0; + padding: 8px 12px; + background: var(--app-surface, var(--app-panel)); + border-bottom: 1px solid var(--app-border); } -.gh-row-lead .codicon { font-size: 17px; } -.gh-lead-icon { display: inline-flex; } -.gh-lead-open, .gh-lead-open-pr { color: var(--status-add); } -.gh-lead-closed { color: var(--status-del); } -.gh-lead-merged { color: var(--gs-accent-ink, var(--gs-accent)); } -.gh-lead-draft { color: var(--app-muted); } -.gh-row-body { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 3px; } -.gh-row-head { display: flex; align-items: center; gap: 7px; min-width: 0; } -.gh-row-rich .gh-row-title { - font-size: var(--text-base); - font-weight: 600; - color: var(--vscode-foreground); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.merge-bar-title { + display: flex; + align-items: center; + gap: 8px; min-width: 0; + flex: 1 1 auto; } -.gh-row-rich .gh-row-sub { - font-size: var(--text-xs); - color: var(--app-muted); +.merge-bar-title .glyph, +.merge-bar-title .codicon { flex: 0 0 auto; color: var(--app-muted); } +.merge-bar-title .codicon { font-size: 15px; } +.merge-bar-path { + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-variant-numeric: tabular-nums; + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-size: var(--text-sm); + color: var(--vscode-foreground); + /* Keep the meaningful tail (…/src/main.ts) when the path can't fit. */ + direction: rtl; + text-align: left; } -.gh-row-chips { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 2px; } -.gh-row-stats { +.merge-bar-actions { flex: 0 0 auto; display: flex; align-items: center; - gap: 11px; - margin-top: 1px; - padding-left: 4px; + gap: 8px; } -.gh-stat { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: var(--text-xs); - color: var(--app-muted); - font-variant-numeric: tabular-nums; +.merge-surface { + flex: 1 1 auto; + min-height: 0; /* CRITICAL — gives the Monaco merge editor real height */ + position: relative; } -.gh-stat .codicon { font-size: 13px; } -.gh-stat.add { color: var(--status-add); } -.gh-stat.del { color: var(--status-del); } +/* `.merge-resolve` already carries .btn .btn-primary .mini-btn. Those skins fully + define the face; the only thing to guard is that the 28px mini-btn height wins + over the 40px .btn-primary height so it sits flush in the slim bar. */ +.merge-resolve.mini-btn { height: 28px; padding: 0 12px; } -/* Label chip — uses --chip (the GitHub hex); legible on both themes. */ -/* The GitHub label hex (--chip, set per-chip by labelChip()) drives a tinted - pill. The raw hex as TEXT can dip below AA on the near-black/near-white canvas - and on tinted/selected rows, so the text is pulled toward the theme foreground - (keeps the hue, guarantees legibility); the tint+border carry the colour. */ -.gh-label-chip { - font-size: var(--text-2xs); - font-weight: 600; - line-height: 1.5; - padding: 1px 8px; - border-radius: 999px; - color: color-mix(in srgb, var(--chip, #888) 70%, var(--vscode-foreground) 30%); - background: color-mix(in srgb, var(--chip, #888) 18%, var(--app-elevated)); - border: 1px solid color-mix(in srgb, var(--chip, #888) 34%, var(--app-border)); - white-space: nowrap; -} -body.vscode-light .gh-label-chip { - color: color-mix(in srgb, var(--chip, #888) 45%, #000 55%); - background: color-mix(in srgb, var(--chip, #888) 22%, var(--app-elevated)); - border-color: color-mix(in srgb, var(--chip, #888) 55%, var(--app-border) 45%); +/* ── 2 · GitHub Actions — run logs / artifacts / secrets (views/actions.ts) ──── */ + +/* The streamed-log panel: an elevated card with a sticky head + a bordered, + recessed scroll body that holds the monospace <pre>. */ +.actions-log-card { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 12px; + padding: 14px; + border: 1px solid var(--app-border); + border-radius: 12px; + background: var(--app-elevated); + box-shadow: var(--sheen), var(--shadow-sm); } -/* On a hovered/selected/active row the chip composites over a busier surface — - keep the same tested foreground so contrast never silently drops. */ -:is(.gh-row.active, .gh-row:hover, .list-row.is-current, .list-row.is-hover) .gh-label-chip { - background: color-mix(in srgb, var(--chip, #888) 20%, var(--app-panel)); +.actions-log-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-width: 0; } - -/* Inline state pill (open / closed / merged / draft). */ -.gh-state-pill { - display: inline-flex; +.actions-log-title { + display: flex; align-items: center; - gap: 5px; - font-size: 11px; + gap: 8px; + min-width: 0; + font-size: var(--text-base); font-weight: 650; - padding: 2px 9px 2px 8px; - border-radius: 999px; + color: var(--vscode-foreground); } -.gh-state-pill .codicon { font-size: 12px; } -.gh-state-open, .gh-state-pill.gh-state-open-pr { color: var(--status-add); background: color-mix(in srgb, var(--status-add) 15%, transparent); } -.gh-state-pill.gh-state-closed { color: var(--status-del); background: color-mix(in srgb, var(--status-del) 15%, transparent); } -.gh-state-pill.gh-state-merged { color: var(--gs-accent-ink, var(--gs-accent)); background: var(--accent-soft); } -.gh-state-pill.gh-state-draft { color: var(--app-muted); background: color-mix(in srgb, var(--app-muted) 16%, transparent); } - -/* ── Header: live count pill + tighter layout ──────────────────────────────── */ -.gh-head { align-items: center; } -.gh-head-titlewrap { display: flex; align-items: center; gap: 9px; min-width: 0; } -.gh-head-count { - display: inline-flex; +.actions-log-title .codicon { color: var(--gs-accent-ink, var(--gs-accent)); font-size: 15px; flex: 0 0 auto; } +.actions-log-title .actions-log-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.actions-log-headactions { + flex: 0 0 auto; + display: flex; align-items: center; - justify-content: center; - min-width: 22px; - height: 20px; - padding: 0 7px; - border-radius: 999px; - background: color-mix(in srgb, var(--app-muted) 20%, transparent); - color: var(--vscode-foreground); - font-size: var(--text-xs); - font-weight: 650; - font-variant-numeric: tabular-nums; + gap: 8px; } -.gh-acct .gh-who { +.actions-log-close { display: inline-flex; align-items: center; - gap: 6px; - padding: 3px 10px 3px 8px; - border-radius: 999px; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; border: 1px solid var(--app-border); + border-radius: 8px; background: var(--app-elevated); box-shadow: var(--sheen); - font-size: var(--text-xs); - font-weight: 600; - color: var(--vscode-foreground); + color: var(--app-muted); + cursor: pointer; + transition: background var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease), + color var(--dur-1) var(--ease), transform var(--dur-1) var(--ease); +} +.actions-log-close .codicon { font-size: 14px; } +.actions-log-close:hover { background: var(--app-hover); border-color: var(--accent-line); color: var(--vscode-foreground); } +.actions-log-close:active { transform: translateY(0.5px); } +.actions-log-close:focus-visible { outline: 2px solid var(--gs-focus-ring); outline-offset: -2px; border-radius: 8px; } +.actions-log-body { + max-height: 460px; + overflow: auto; + padding: 10px 12px; + border: 1px solid var(--app-border); + border-radius: 9px; + background: var(--app-bg); +} +.actions-log { + margin: 0; + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-size: 12px; + line-height: 1.5; + white-space: pre; + tab-size: 2; + -moz-tab-size: 2; + color: var(--vscode-foreground, var(--app-text)); } -.gh-acct .gh-who .codicon { font-size: 14px; color: var(--app-muted); } -/* ── Bar-level selector chip (Projects / Orgs header picker) ─────────────────── */ -.gh-picker { - display: inline-flex; +/* Artifacts — a compact titled list of downloadable build outputs. Rows reuse the + .list-row / .row-meta vocabulary; this only frames the group + its head. */ +.gh-artifacts { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 12px; +} +.gh-artifacts-head { + display: flex; align-items: center; gap: 7px; - height: 30px; - max-width: 360px; - padding: 0 9px 0 8px; - border-radius: 9px; + padding: 2px 4px 6px; + font-size: 10.5px; + font-weight: 600; + letter-spacing: var(--track-label); + text-transform: uppercase; + color: var(--app-muted); +} +.gh-artifacts-head .codicon { font-size: 14px; color: var(--gs-accent-ink, var(--gs-accent)); } +.gh-artifact-row { align-items: center; } +.gh-artifact-row .codicon { color: var(--app-muted); } + +/* Secrets — a card grouping repo/env secret sections; each section is a key/value + table. Values are never shown (write-only), so rows are calm name + meta rows. */ +.actions-secrets-card { + display: flex; + flex-direction: column; + gap: 14px; + margin-top: 12px; + padding: 14px; border: 1px solid var(--app-border); + border-radius: 12px; background: var(--app-elevated); - box-shadow: var(--sheen); - color: var(--vscode-foreground); - font-family: inherit; - font-size: 13px; - font-weight: 600; - cursor: pointer; - transition: background var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease); + box-shadow: var(--sheen), var(--shadow-sm); +} +.actions-secrets-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-width: 0; +} +.actions-secrets-head .actions-secrets-title { + display: flex; + align-items: center; + gap: 8px; + font-size: var(--text-base); + font-weight: 650; + color: var(--vscode-foreground); } -.gh-picker:hover { background: var(--app-hover); border-color: var(--accent-line); } -.gh-picker[aria-expanded="true"] { background: var(--app-active); border-color: var(--accent-line); } -.gh-picker:focus-visible { - outline: 2px solid color-mix(in srgb, var(--gs-accent) 70%, transparent); - outline-offset: 2px; +.actions-secrets-head .codicon { color: var(--gs-accent-ink, var(--gs-accent)); font-size: 15px; } +.actions-secrets-section { + display: flex; + flex-direction: column; + gap: 2px; } -.gh-picker-lead { - display: inline-flex; +.actions-kv-head { + display: flex; align-items: center; - flex: 0 0 auto; + justify-content: space-between; + gap: 10px; + padding: 2px 4px 6px; } -.gh-picker-lead .glyph, -.gh-picker-lead .codicon { color: var(--gs-accent-ink, var(--gs-accent)); font-size: 16px; } -.gh-picker-lead .av, -.gh-picker-lead .gh-avatar { width: 20px; height: 20px; } -.gh-picker-lead .av-fallback { font-size: 9px; } -.gh-picker-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } -.gh-picker-chev { color: var(--app-muted); opacity: 0.7; flex: 0 0 auto; margin-left: 1px; } -.gh-picker-chev .codicon { font-size: 14px; } - -/* Single full-width pane (no left list): the picked project/org board+data. */ -.gh-solo { flex: 1 1 auto; min-width: 0; border-left: none; } - -/* Wider list pane so the detail isn't a 60% void; lift the detail surface. */ -.gh-list { flex: 0 0 42%; min-width: 300px; max-width: 600px; } -.gh-detail { background: var(--app-bg); } - -/* ── Premium empty state (also the detail pane's "nothing selected") ────────── */ -.list-empty { gap: 10px; padding: 56px 28px; } -.list-empty-badge { - display: inline-flex; - align-items: center; - justify-content: center; - width: 60px; - height: 60px; - margin-bottom: 6px; - border-radius: 18px; - background: - radial-gradient(120% 120% at 50% 0%, color-mix(in srgb, var(--gs-accent) 22%, transparent), transparent 70%), - color-mix(in srgb, var(--gs-accent) 12%, transparent); - border: 1px solid color-mix(in srgb, var(--gs-accent) 26%, transparent); - box-shadow: var(--sheen), 0 8px 24px -10px color-mix(in srgb, var(--gs-accent) 50%, transparent); +.actions-kv-headtitle { + font-size: 10.5px; + font-weight: 600; + letter-spacing: var(--track-label); + text-transform: uppercase; + color: var(--app-muted); } -.list-empty-badge .codicon { font-size: 28px; color: var(--gs-accent-ink, var(--gs-accent)); } -.list-empty-title { font-size: var(--text-lg); font-weight: 700; letter-spacing: -0.01em; } -.list-empty-desc { font-size: var(--text-base); color: var(--app-muted); max-width: 380px; line-height: 1.55; } -.list-empty-action { margin-top: 14px; } -.list-empty-hint { - margin-top: 10px; - font-size: var(--text-xs); - color: color-mix(in srgb, var(--app-muted) 80%, transparent); +.actions-kv-list { + display: flex; + flex-direction: column; + border: 1px solid var(--app-border); + border-radius: 9px; + overflow: hidden; + background: var(--app-bg); } -.list-error .list-empty-badge { - background: color-mix(in srgb, var(--status-del) 14%, transparent); - border-color: color-mix(in srgb, var(--status-del) 30%, transparent); - box-shadow: var(--sheen); +.actions-kv-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-width: 0; + padding: 8px 11px; + font-size: var(--text-sm); + color: var(--vscode-foreground); + transition: background var(--dur-1) var(--ease); } -.list-error .list-empty-badge .codicon { color: var(--status-del); } - -/* ── Code view: balanced reading column + richer file rows ──────────────────── */ -.code-filecard, .code-readme { - width: 100%; - max-width: 1080px; - margin-left: auto; - margin-right: auto; +.actions-kv-row + .actions-kv-row { border-top: 1px solid var(--app-border); } +.actions-kv-row .actions-kv-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-weight: 550; } -.code-row .file-path { flex: 1 1 auto; min-width: 0; } -.code-row.is-dir .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } -.code-row:not(.is-dir) .glyph { color: var(--app-muted); } -.code-row .file-path { font-weight: 500; } -.code-row.is-dir .file-path { font-weight: 600; } -.code-row-size { +.actions-kv-row .actions-kv-meta { flex: 0 0 auto; font-size: var(--text-xs); color: var(--app-muted); font-variant-numeric: tabular-nums; - font-family: var(--vscode-editor-font-family, ui-monospace, monospace); } - -/* ── Changes view: richer file rows (icon + name + dir + status) ────────────── */ -.dc-file { align-items: center; padding: 6px 9px; } -.dc-file > .glyph { flex: 0 0 auto; color: var(--app-muted); } -.dc-file.status-A > .glyph, .dc-file.status-C > .glyph { color: var(--status-add); } -.dc-file.status-D > .glyph { color: var(--status-del); } -.dc-file-meta { display: flex; align-items: baseline; gap: 7px; flex: 1 1 auto; min-width: 0; } -.dc-file-name { +.actions-kv-row:hover { background: var(--app-hover); } +.actions-kv-empty { + padding: 14px 12px; font-size: var(--text-sm); - font-weight: 550; - color: var(--vscode-foreground); - white-space: nowrap; -} -.dc-file-dir { - font-size: var(--text-xs); color: var(--app-muted); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; - font-family: var(--vscode-editor-font-family, ui-monospace, monospace); - /* Truncate from the LEFT so the meaningful tail (…/src/main) survives instead - of the directory root. The path is one LTR-resolved run (latin + "/"), so an - rtl box only flips the ellipsis side, not the segment order. */ - direction: rtl; - text-align: left; + text-align: center; } -.dc-file .file-status { margin-left: 6px; } -/* ════════════════════════════════════════════════════════════════════════════ - v4 FIXES — real bugs caught running the actual app (not the stubbed harness). - ════════════════════════════════════════════════════════════════════════════ */ +/* ── 4 · Design-audit CSS-only consistency fixes ────────────────────────────── */ -/* `.icon-btn` was used (SSH key copy, device-flow copy) but never defined, so it - fell back to a bare white UA <button>. Style it as a proper icon button. */ -.icon-btn { +/* Notifications: dim the WHOLE read row (was an inline style.opacity=0.72), so the + view can drop the inline rule and just toggle .notif-read. + + The row-level fade MULTIPLIES with the finer recede above it, and opacity is + theme-blind — fading toward a light ground costs far more contrast than + fading toward a dark one. At 0.72 the read title landed at 3.04:1 on the + light page and its type glyph, which also carried its own 0.55, at 1.75:1: + invisible. The row still recedes, but the text does its receding with a + COLOUR (which each theme picks) rather than by stacking a second fade. */ +.notif-read { opacity: 0.85; } + +/* Gists: a semantic lead-icon colour (member of the .gh-lead-* family) so the + Gists view can drop its inline style.color on the leading code glyph. */ +.gh-lead-gist { color: var(--gs-accent-ink, var(--gs-accent)); } + +/* Empty-state CTA vs error-state Retry can swap between a 40px .btn-primary and a + 28px .mini-btn; pin a shared min-height on the action slot so the layout doesn't + jump as a view moves between its empty and error states. */ +.list-empty-action { min-height: 40px; display: inline-flex; align-items: center; } + +/* ── 5 · Commit composer depth (Changes view, renderer.ts) ──────────────────── */ +/* A horizontal row of small toggle chips beneath the commit message box. */ +.dc-options { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin: 6px 0; +} +/* A small pill toggle — a subtler sibling of .mini-btn (muted face at rest). The + Amend / Sign-off switches carry role="switch" + aria-checked; the .is-on class + and aria-checked are flipped together, so we honour both for the ON face. */ +.dc-toggle { display: inline-flex; align-items: center; - justify-content: center; - width: 30px; - height: 30px; - padding: 0; + gap: 6px; + height: 26px; + padding: 0 10px; border: 1px solid var(--app-border); - border-radius: 8px; + border-radius: 999px; background: var(--app-elevated); box-shadow: var(--sheen); color: var(--app-muted); + font-family: inherit; + font-size: var(--text-xs); + font-weight: 600; cursor: pointer; - transition: background var(--dur-1) var(--ease), color var(--dur-1) var(--ease), - border-color var(--dur-1) var(--ease); + transition: background var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease), + color var(--dur-1) var(--ease), transform var(--dur-1) var(--ease); } -.icon-btn:hover { background: var(--app-hover); color: var(--vscode-foreground); border-color: var(--accent-line); } -.icon-btn:focus-visible { outline: 2px solid color-mix(in srgb, var(--gs-accent) 70%, transparent); outline-offset: 2px; } -.icon-btn .codicon { font-size: 15px; } - -/* Buttons must never wrap their label (the Actions detail cluster squished - "Re-run failed" → two lines). Keep labels on one line + let the cluster wrap - to a new row instead of shrinking each button below its content. */ -.btn, .btn-primary, .btn-danger, .btn-soft, .mini-btn, .row-btn, -.gh-merge-btn, .gh-seg-btn, .cmp-seg-btn, .settings-seg-btn { - white-space: nowrap; +.dc-toggle .glyph, +.dc-toggle .codicon { font-size: 14px; flex: 0 0 auto; } +.dc-toggle:hover { background: var(--app-hover); border-color: var(--accent-line); color: var(--vscode-foreground); } +.dc-toggle:active { transform: translateY(0.5px); } +.dc-toggle:focus-visible { outline: 2px solid var(--gs-focus-ring); outline-offset: -2px; border-radius: 999px; } +.dc-toggle.is-on, +.dc-toggle[aria-checked="true"] { + color: var(--gs-accent-ink, var(--gs-accent)); + border-color: color-mix(in srgb, var(--gs-accent) 42%, var(--app-border)); + background: color-mix(in srgb, var(--gs-accent) 14%, transparent); } -.gh-detail-actions { flex-wrap: wrap; row-gap: 8px; } -.gh-detail-actions > * { flex-shrink: 0; } -.gh-detail-actions .mini-btn { height: 30px; } - -/* ════════════════════════════════════════════════════════════════════════════ - v5 TOP BAR — brand + repo + branch + sync/fetch (left, adjacent); GitHub - account pinned to the right edge. Refresh removed; the account no longer - repeats in every GitHub section header. - ════════════════════════════════════════════════════════════════════════════ */ -.topbar-left { display: flex; align-items: center; gap: 7px; min-width: 0; } -.topbar-right { display: flex; align-items: center; gap: 8px; margin-left: auto; flex: 0 0 auto; } -.topbar-left .topbar-switch { max-width: 240px; } -.topbar-branch { max-width: 260px; } -/* the repo glyph leads the repo switch in the brand accent (like the branch one) */ -.topbar-switch .glyph:first-child { color: var(--gs-accent-ink, var(--gs-accent)); } - -/* ── Notifications center: the bell + unread badge, next to the account chip ─── */ -.topbar-bell { position: relative; flex: 0 0 auto; } -.topbar-bell .codicon { font-size: 16px; } -.topbar-bell.has-unread { color: var(--vscode-foreground); } -.topbar-bell[aria-expanded="true"] { - background: var(--app-active); +.dc-toggle.is-on .glyph, +.dc-toggle.is-on .codicon, +.dc-toggle[aria-checked="true"] .glyph, +.dc-toggle[aria-checked="true"] .codicon { color: var(--gs-accent-ink, var(--gs-accent)); } +.dc-toggle.is-on:hover, +.dc-toggle[aria-checked="true"]:hover { + background: color-mix(in srgb, var(--gs-accent) 20%, transparent); + border-color: color-mix(in srgb, var(--gs-accent) 55%, var(--app-border)); +} +/* Co-author chips below the toggles. */ +.dc-coauthors { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin: 2px 0 6px; +} +.dc-coauthor-chip { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 100%; + min-width: 0; + height: 22px; + padding: 0 4px 0 9px; + border: 1px solid var(--app-border); + border-radius: 999px; + background: var(--app-panel); color: var(--vscode-foreground); + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-size: var(--text-xs); +} +.dc-coauthor-chip > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.topbar-bell-badge { - position: absolute; - top: -1px; - right: -1px; - min-width: 16px; - height: 16px; - padding: 0 4px; +/* The chip's ✕ remove button — tiny + muted, reddening on hover (row-btn sizing). */ +.dc-chip-x { display: inline-flex; align-items: center; justify-content: center; + width: 16px; + height: 16px; + flex: 0 0 auto; + padding: 0; + border: none; border-radius: 999px; - background: var(--gs-accent); - color: #fff; - font-size: 9.5px; - font-weight: 700; - line-height: 1; - font-variant-numeric: tabular-nums; - border: 1.5px solid var(--app-panel); - box-shadow: 0 1px 3px -1px color-mix(in srgb, var(--gs-accent) 60%, transparent); - pointer-events: none; + background: transparent; + color: var(--app-muted); + cursor: pointer; + transition: background var(--dur-1) var(--ease), color var(--dur-1) var(--ease); +} +.dc-chip-x .codicon { font-size: 11px; } +.dc-chip-x:hover { color: var(--status-del); background: color-mix(in srgb, var(--status-del) 16%, transparent); } +.dc-chip-x:focus-visible { outline: 2px solid var(--gs-focus-ring); outline-offset: 1px; } +/* The "Create pull request" CTA in the Changes toolbar — a .mini-btn with a quiet + accent lean so it reads as the forward action without shouting like a primary. */ +/* Was the only accent-coloured control in the Changes toolbar, which made a + GitHub navigation action the loudest thing on a screen whose whole job is + staging and committing. It reads as a peer of Stage all now. */ +.dc-createpr .glyph, +.dc-createpr .codicon { color: var(--app-muted); } +.dc-createpr:hover { + background: var(--app-hover); + border-color: var(--app-border); } -/* ── Notifications center popover (the bell's floating inbox panel) ──────────── */ -.notif-pop { - position: fixed; - z-index: 1000; - width: 424px; - max-width: calc(100vw - 24px); - max-height: min(560px, calc(100vh - 80px)); +/* ── 6 · Op-state banner — merge/rebase in progress (Changes view) ──────────── */ +/* Pinned at the top of the Changes view when a merge/rebase/cherry-pick is mid-op; + a soft amber "attention" wash with the Abort/Continue actions on the right. */ +.dc-opbanner { display: flex; - flex-direction: column; - border-radius: 14px; - overflow: hidden; - background: color-mix(in srgb, var(--app-elevated) 96%, var(--vscode-foreground)); - border: 1px solid var(--app-border); - box-shadow: var(--sheen), var(--shadow-pop); - transform-origin: top right; - animation: menu-pop 140ms var(--ease-out) both; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 10px; + padding: 10px 12px; + border: 1px solid color-mix(in srgb, var(--status-warn) 38%, var(--app-border)); + border-left: 3px solid var(--status-warn); + border-radius: 10px; + background: color-mix(in srgb, var(--status-warn) 12%, transparent); } -@media (prefers-reduced-motion: reduce) { .notif-pop { animation: none; } } -.notif-pop-inner { flex: 1 1 auto; min-height: 0; min-width: 0; display: flex; } -/* min-width:0 lets the view shrink to the popover width — without it the flex - child sized to its widest row, blowing the header (and "Show all") past the - panel edge where overflow:hidden clipped it. */ -.notif-pop .notif-view { background: transparent; flex: 1 1 auto; min-width: 0; } -.notif-pop .list-head { padding: 12px 13px 10px; } -.notif-pop .list-body { padding: 7px; } -/* The narrow popover header was over-crowded → "Mark all read"/timestamps got - clipped under overflow:hidden. Let the header wrap (never clip) and compact the - mark-all action to an icon so the title + toggle fit on one line. */ -.notif-pop .list-head-row { flex-wrap: wrap; row-gap: 8px; align-items: center; } -.notif-pop .notif-actions { flex: 0 0 auto; } -.notif-pop .notif-markall span { display: none; } -.notif-pop .notif-markall { padding: 0 9px; } -.notif-pop .gh-acct:empty { display: none; } - -/* The single account chip, pinned to the right edge of the bar. */ -.topbar-acct { - display: inline-flex; +.dc-opbanner-text { + display: flex; align-items: center; gap: 8px; - height: 28px; - max-width: 240px; - padding: 0 12px 0 10px; - border-radius: 999px; - border: 1px solid var(--app-border); - background: var(--app-elevated); - box-shadow: var(--sheen); - color: var(--vscode-foreground); - font-family: inherit; + min-width: 0; + flex: 1 1 auto; +} +.dc-opbanner-text .glyph, +.dc-opbanner-text > .codicon { flex: 0 0 auto; color: var(--status-warn); } +.dc-opbanner-text .codicon { font-size: 15px; } +.dc-opbanner-strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; font-size: var(--text-sm); font-weight: 600; - cursor: pointer; - -webkit-app-region: no-drag; - transition: background var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease); -} -.topbar-acct:hover { background: var(--app-hover); border-color: var(--accent-line); } -.topbar-acct:focus-visible { outline: 2px solid color-mix(in srgb, var(--gs-accent) 70%, transparent); outline-offset: 2px; } -.topbar-acct .glyph { color: var(--app-muted); } -.topbar-acct .glyph .codicon { font-size: 15px; } -.topbar-acct .av { width: 22px; height: 22px; font-size: 9px; } -.topbar-acct.is-connected { padding-left: 4px; } -.topbar-acct-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -/* Not connected → an inviting accent "Sign in" pill (this is also the cue when a - fresh session has no token yet). */ -.topbar-acct:not(.is-connected) { - color: var(--gs-accent-ink, var(--gs-accent)); - background: var(--accent-soft); - border-color: var(--accent-line); + color: var(--vscode-foreground); } -.topbar-acct:not(.is-connected) .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } -/* On narrow windows, collapse the account to just the avatar to avoid colliding - with the repo/branch switchers. */ -@media (max-width: 1040px) { - .topbar-acct.is-connected .topbar-acct-name { display: none; } - .topbar-acct.is-connected { padding: 0; width: 30px; justify-content: center; } +.dc-opbanner-actions { + display: flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; } -/* ───────────────────────────────────────────────────────────────────────────── - AI: model-connection settings, MCP "Agent Access", and the Assistant view. - Built on the same token system (--app-*, --gs-accent) as the rest of the app. - ───────────────────────────────────────────────────────────────────────────── */ - -/* Pill variants used by the AI surfaces. */ -.pill.is-default { background: color-mix(in srgb, var(--gs-accent) 18%, var(--app-elevated)); color: color-mix(in srgb, var(--gs-accent-ink, var(--gs-accent)) 88%, var(--vscode-foreground)); } -.pill.is-local { background: color-mix(in srgb, var(--gs-accent-2, var(--gs-accent)) 16%, var(--app-elevated)); color: color-mix(in srgb, var(--gs-accent-ink, var(--gs-accent)) 88%, var(--vscode-foreground)); } -.pill.is-ready { background: color-mix(in srgb, var(--status-add) 18%, var(--app-elevated)); color: color-mix(in srgb, var(--status-add) 90%, var(--vscode-foreground)); } -.pill.is-warn { background: color-mix(in srgb, var(--status-warn) 20%, var(--app-elevated)); color: color-mix(in srgb, var(--status-warn) 90%, var(--vscode-foreground)); } - -/* ── AI Models card ── */ -.ai-conn-list { display: flex; flex-direction: column; gap: 9px; } -.ai-conn { - border: 1px solid var(--app-border); - border-radius: 11px; - background: var(--app-elevated); - overflow: hidden; +/* ── 7 · PR review depth — Files tab + inline threads (views/prs.ts) ─────────── */ +/* Matches the existing .gh-* detail look. The Files tab is a master/detail split: + a left file list, a right pane with the Monaco diff over an inline-threads panel. */ +.pr-files { + display: flex; + flex-direction: row; + gap: 12px; + flex: 1 1 auto; + min-height: 0; } -.ai-conn-head { display: flex; align-items: center; gap: 10px; padding: 10px 12px; } -.ai-conn-head > .glyph .codicon, .ai-conn-head > .glyph { color: var(--gs-accent-ink, var(--gs-accent)); font-size: 16px; } -.ai-conn-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1 1 auto; } -.ai-conn-name { display: flex; align-items: center; gap: 7px; font-size: 13px; font-weight: 650; color: var(--vscode-foreground); } -.ai-conn-sub { font-size: 11.5px; color: var(--app-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.ai-conn-actions { display: flex; align-items: center; gap: 3px; } -.ai-conn-editor { display: flex; flex-direction: column; gap: 10px; padding: 12px; border-top: 1px solid var(--app-border); background: var(--app-panel); } -.ai-add-btn { align-self: flex-start; } - -/* Provider gallery (modal) */ -.ai-gallery-pop { - position: fixed; inset: 0; z-index: 60; - background: color-mix(in srgb, #000 42%, transparent); - display: flex; align-items: center; justify-content: center; - animation: ai-fade 0.12s ease; +.pr-files-list { + /* A hard 268px meant every path in a real repo lost its middle while the diff + beside it had 900px of slack. The list now takes a share of the window and + still refuses to crowd out the diff. */ + flex: 0 0 auto; + width: clamp(260px, 24%, 460px); + overflow-y: auto; + padding-right: 12px; + border-right: 1px solid var(--app-border); } -@keyframes ai-fade { from { opacity: 0; } to { opacity: 1; } } -.ai-gallery-panel { - width: min(640px, 92vw); max-height: 80vh; overflow: auto; - background: var(--app-panel); border: 1px solid var(--app-border); - border-radius: 14px; box-shadow: var(--shadow-lg, 0 18px 50px rgba(0,0,0,0.4)); +.pr-files-detail { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + min-height: 0; } -.ai-gallery-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid var(--app-border); font-weight: 650; font-size: 14px; } -.ai-gallery-section + .ai-gallery-section { border-top: 1px solid var(--app-border); } -.ai-gallery-section-head { padding: 14px 16px 2px; } -.ai-gallery-section-title { font-size: 12px; font-weight: 680; color: var(--vscode-foreground); text-transform: uppercase; letter-spacing: 0.04em; } -.ai-gallery-section-sub { font-size: 11.5px; color: var(--app-muted); margin-top: 2px; line-height: 1.4; } -.ai-gallery { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 12px 16px 16px; } -.ai-prov-card { - display: flex; align-items: flex-start; gap: 10px; text-align: left; - padding: 12px; border: 1px solid var(--app-border); border-radius: 11px; - background: var(--app-elevated); cursor: pointer; font-family: inherit; - transition: border-color 0.12s ease, transform 0.12s ease, background 0.12s ease; +/* The Monaco diff host. min-height:0 is CRITICAL so the editor gets real height + (it also carries .diff-surface; we drop that block's top border in this pane). */ +.pr-diff-surface { + flex: 1 1 auto; + min-height: 0; + position: relative; + border-top: none; } -.ai-prov-card:hover { border-color: color-mix(in srgb, var(--gs-accent) 55%, var(--app-border)); background: var(--app-hover); transform: translateY(-1px); } -.ai-prov-card .glyph .codicon, .ai-prov-card > .glyph { font-size: 18px; color: var(--gs-accent-ink, var(--gs-accent)); } -/* Real brand marks render neutral (like the actual logos), not accent-tinted. */ -.ai-logo { flex: 0 0 auto; color: var(--vscode-foreground); } -.ai-conn-head > .ai-logo { width: 17px; height: 17px; } -.ai-prov-card > .ai-logo { width: 20px; height: 20px; margin-top: 1px; } -.ai-prov-meta { display: flex; flex-direction: column; gap: 3px; min-width: 0; } -.ai-prov-name { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 650; color: var(--vscode-foreground); } -.ai-prov-blurb { font-size: 11.5px; color: var(--app-muted); line-height: 1.4; } - -/* ── Agent Access (MCP) card ── */ -.mcp-perm { display: flex; flex-direction: column; gap: 6px; } -.mcp-perm-desc { font-size: 12px; line-height: 1.5; color: var(--app-muted); margin-top: 1px; } -.mcp-danger-note { - display: flex; align-items: flex-start; gap: 7px; font-size: 11.5px; line-height: 1.45; - color: color-mix(in srgb, var(--vscode-list-warningForeground, #d9a84e) 85%, var(--vscode-foreground)); - background: color-mix(in srgb, var(--vscode-list-warningForeground, #d9a84e) 12%, transparent); - border: 1px solid color-mix(in srgb, var(--vscode-list-warningForeground, #d9a84e) 30%, transparent); - border-radius: 9px; padding: 9px 11px; +/* The inline-review panel beneath the diff. */ +/* The review panel FOLDS: closed it is one summary row, open it takes at most + 42% of the pane. It used to take that 42% unconditionally, so on a file with + nothing to discuss the diff — the reason this tab exists — was left with + 354px of a 913px window. */ +.pr-threads { + flex: 0 0 auto; + display: flex; + flex-direction: column; + gap: 10px; + overflow: hidden; + padding: 4px 2px; + border-top: 1px solid var(--app-border); +} +.pr-threads.is-open { max-height: 42%; padding: 4px 2px; } +.pr-threads-body { + display: flex; + flex-direction: column; + gap: 10px; + overflow-y: auto; + padding-bottom: 4px; +} +.pr-threads-tools { display: flex; justify-content: flex-end; } +.pr-threads-head { + display: flex; + align-items: center; + gap: var(--sp-2); + width: 100%; + padding: 6px var(--sp-2); + border: none; + border-radius: var(--r-sm); + background: none; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} +.pr-threads-head:hover { background: var(--app-hover); } +.pr-threads-title { + font-size: var(--text-xs); + font-weight: 600; + letter-spacing: 0.01em; + color: var(--app-muted); } -.mcp-danger-note .codicon { font-size: 14px; margin-top: 1px; } -.mcp-clients { display: flex; flex-direction: column; gap: 7px; } -.mcp-client { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border: 1px solid var(--app-border); border-radius: 10px; background: var(--app-elevated); } -.mcp-client > .glyph .codicon, .mcp-client > .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } -.mcp-client-meta { flex: 1 1 auto; min-width: 0; } -.mcp-client-name { display: flex; align-items: center; gap: 7px; font-size: 12.5px; font-weight: 600; color: var(--vscode-foreground); } -.mcp-client > .mini-btn { flex: 0 0 auto; } -.mcp-snippet { border: 1px solid var(--app-border); border-radius: 10px; overflow: hidden; } -.mcp-snippet-head { display: flex; align-items: center; justify-content: space-between; padding: 8px 11px; background: var(--app-elevated); font-size: 11.5px; color: var(--app-muted); border-bottom: 1px solid var(--app-border); } -.mcp-snippet-code { margin: 0; padding: 11px 13px; font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: 11.5px; line-height: 1.5; color: var(--vscode-foreground); background: var(--app-bg); white-space: pre; overflow-x: auto; } - -/* ── Assistant view ── */ -.assistant-view { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; } -.assistant-head { - display: flex; align-items: center; gap: 14px; flex-wrap: wrap; - padding: 12px 18px; border-bottom: 1px solid var(--app-border); +.pr-threads-empty { + padding: 18px 12px; + font-size: var(--text-xs); + color: var(--app-muted); + text-align: center; +} +/* A thread card — extends .gh-comment; add a panel face + padding for the body. */ +.pr-thread { background: var(--app-panel); } -.assistant-title { display: flex; align-items: center; gap: 8px; font-size: 14.5px; font-weight: 680; color: var(--vscode-foreground); } -.assistant-title .codicon { font-size: 17px; color: var(--gs-accent-ink, var(--gs-accent)); } -.assistant-model { font-size: 11.5px; color: var(--app-muted); } -.assistant-iconbtn { - display: inline-flex; align-items: center; justify-content: center; - width: 26px; height: 26px; border-radius: 7px; - border: 1px solid transparent; background: transparent; - color: var(--app-muted); cursor: pointer; transition: background 0.12s ease, color 0.12s ease; +.pr-thread.is-resolved { + opacity: 0.65; + border-color: color-mix(in srgb, var(--status-add) 32%, var(--app-border)); } -.assistant-iconbtn:hover { background: var(--app-hover); color: var(--vscode-foreground); } -.assistant-iconbtn .codicon { font-size: 15px; } -.assistant-perm { display: flex; align-items: center; gap: 9px; margin-left: auto; } -.assistant-perm-label { font-size: 11.5px; color: var(--app-muted); } -.assistant-perm-seg .settings-seg-btn { padding: 5px 11px; font-size: 11.5px; } - -/* The agent's options shown directly in the header as dropdown "chips", - populated live from the connected provider. */ -.assistant-controls { display: inline-flex; align-items: center; gap: 7px; margin-left: auto; flex-wrap: wrap; } -.assistant-controls.is-disabled { opacity: 0.5; pointer-events: none; } -.assistant-chip-ctl { - display: inline-flex; align-items: center; gap: 6px; - padding: 5px 9px; border-radius: 8px; - border: 1px solid var(--app-border); background: var(--app-elevated); - color: var(--vscode-foreground); font-family: inherit; font-size: 12px; cursor: pointer; - transition: background 0.12s ease, border-color 0.12s ease; +/* .pr-thread-head extends .gh-comment-head — make it a space-between row so the + resolve toggle sits opposite the file:line anchor. */ +.pr-thread-head { justify-content: space-between; } +.pr-thread-anchor { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-size: var(--text-xs); + font-weight: 550; + color: var(--app-muted); } -.assistant-chip-ctl:hover { background: var(--app-hover); border-color: color-mix(in srgb, var(--gs-accent) 50%, var(--app-border)); } -.assistant-chip-ctl > .glyph .codicon, .assistant-chip-ctl > .glyph:first-child { font-size: 13px; color: var(--gs-accent-ink, var(--gs-accent)); } -.assistant-chip-label { font-weight: 550; max-width: 170px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.assistant-chip-caret .codicon { font-size: 11px; color: var(--app-muted); } - -.assistant-transcript { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 18px; display: flex; flex-direction: column; gap: 14px; } -.assistant-bubble { - align-self: flex-end; max-width: min(680px, 86%); - padding: 9px 13px; border-radius: 13px 13px 4px 13px; - background: color-mix(in srgb, var(--gs-accent) 16%, var(--app-elevated)); - color: var(--vscode-foreground); font-size: 13px; line-height: 1.5; white-space: pre-wrap; +.pr-thread-anchor .codicon { font-size: 13px; flex: 0 0 auto; } +.pr-thread-line { + flex: 0 0 auto; + padding: 1px 6px; + border-radius: 5px; + background: color-mix(in srgb, var(--app-muted) 16%, transparent); + color: var(--app-muted); + font-size: var(--text-2xs); + font-variant-numeric: tabular-nums; } -.assistant-turn { align-self: flex-start; max-width: min(760px, 94%); display: flex; flex-direction: column; gap: 8px; } -.assistant-msg { font-size: 13px; line-height: 1.6; color: var(--vscode-foreground); } -/* The live stream renders Markdown as it arrives; a soft blinking caret trails - the last rendered element until the step completes. */ -.assistant-msg.is-streaming > *:last-child::after { - content: ""; display: inline-block; width: 7px; height: 1em; margin-left: 2px; - vertical-align: text-bottom; background: var(--gs-accent); border-radius: 1px; - opacity: 0.75; animation: ai-caret 1s steps(2) infinite; +.pr-thread-comment { + padding: 9px 12px; + border-top: 1px solid var(--app-border); } -@keyframes ai-caret { 50% { opacity: 0; } } -.assistant-msg p { margin: 0 0 8px; } -.assistant-msg p:last-child { margin-bottom: 0; } -.assistant-msg code { font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: 12px; background: var(--app-elevated); padding: 1px 5px; border-radius: 5px; } -.assistant-msg pre { background: var(--app-bg); border: 1px solid var(--app-border); border-radius: 9px; padding: 10px 12px; overflow-x: auto; } -.assistant-msg ul, .assistant-msg ol { margin: 4px 0 8px; padding-left: 20px; } -.assistant-msg li { margin: 2px 0; } -.assistant-msg h1, .assistant-msg h2, .assistant-msg h3, -.assistant-msg h4, .assistant-msg h5, .assistant-msg h6 { - font-weight: 650; line-height: 1.35; margin: 0.7em 0 0.3em; -} -.assistant-msg h1 { font-size: 1.25em; } -.assistant-msg h2 { font-size: 1.12em; } -.assistant-msg h3, .assistant-msg h4, .assistant-msg h5, .assistant-msg h6 { font-size: 1em; } -.assistant-msg > :first-child { margin-top: 0; } -.assistant-msg blockquote { - margin: 0.5em 0; padding: 0.1em 0.9em; - border-left: 3px solid color-mix(in srgb, var(--gs-accent) 22%, var(--app-border)); +.pr-thread-comment-head { + display: flex; + align-items: center; + gap: 7px; + margin-bottom: 5px; + font-size: var(--text-xs); color: var(--app-muted); } +.pr-thread-author { color: var(--vscode-foreground); font-weight: 600; } +.pr-thread-reply { + padding: 9px 12px; + border-top: 1px solid var(--app-border); +} +/* The reply textarea — extends .gh-composer-input; just shrink it for a thread. */ +.pr-reply-input { min-height: 56px; font-size: var(--text-sm); } -.assistant-thinking { display: flex; align-items: center; gap: 9px; font-size: 12.5px; color: var(--app-muted); padding: 3px 0; } -/* Three pulsing dots that read as "actively thinking". */ -.ai-think-dots { display: inline-flex; gap: 4px; } -.ai-think-dots i { - width: 6px; height: 6px; border-radius: 50%; - background: var(--gs-accent); display: inline-block; - animation: ai-think-pulse 1.1s ease-in-out infinite; +/* — PR title inline-edit affordances — */ +.gh-detail-titlerow { + display: flex; + align-items: flex-start; + gap: 8px; + min-width: 0; } -.ai-think-dots i:nth-child(2) { animation-delay: 0.16s; } -.ai-think-dots i:nth-child(3) { animation-delay: 0.32s; } -@keyframes ai-think-pulse { 0%, 100% { opacity: 0.25; transform: scale(0.7); } 50% { opacity: 1; transform: scale(1); } } -/* The label gently shimmers so the whole row feels alive during long start-ups. */ -.ai-think-label { - font-weight: 600; - background: linear-gradient(90deg, var(--app-muted) 30%, var(--vscode-foreground) 50%, var(--app-muted) 70%); - background-size: 200% 100%; - -webkit-background-clip: text; background-clip: text; color: transparent; - animation: ai-think-shimmer 1.8s linear infinite; +.gh-detail-titlerow .gh-detail-title { flex: 1 1 auto; min-width: 0; } +/* Subtle icon buttons (pencil / inline save+cancel) — they also carry + .mini-btn.gh-icon-btn; recede to a borderless ghost at rest, reveal on hover so + the edit affordance stays quiet until reached. */ +.gh-title-edit, +.gh-inline-edit { + flex: 0 0 auto; + border-color: transparent; + background: transparent; + box-shadow: none; + color: var(--app-muted); } -@keyframes ai-think-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } } -.ai-think-meta { font-size: 11px; color: var(--app-muted); opacity: 0.7; font-variant-numeric: tabular-nums; } - -/* Labeled segmented control rows in the AI Assistant settings card. */ -.settings-seg-row { display: flex; flex-direction: column; gap: 5px; } -.settings-seg-row .settings-sub { margin-bottom: 2px; } - -.assistant-tool { - border: 1px solid var(--app-border); border-radius: 9px; background: var(--app-elevated); overflow: hidden; +.gh-title-edit:hover, +.gh-inline-edit:hover { + background: var(--app-hover); + border-color: var(--app-border); + color: var(--vscode-foreground); +} +.gh-title-edit .glyph, +.gh-inline-edit .glyph, +.gh-title-edit .codicon, +.gh-inline-edit .codicon { color: inherit; } +/* The "open / unresolved" thread state pill (sibling of .gh-review-approved). */ +.gh-thread-open { + color: var(--status-warn); + background: color-mix(in srgb, var(--status-warn) 16%, transparent); } -.assistant-tool.is-expandable .assistant-tool-head { cursor: pointer; } -.assistant-tool-head { display: flex; align-items: center; gap: 8px; padding: 7px 11px; font-size: 12px; } -.assistant-tool-head > .glyph .codicon, .assistant-tool-head > .glyph { color: var(--gs-accent-ink, var(--gs-accent)); font-size: 13px; } -.assistant-tool-name { font-weight: 620; color: var(--vscode-foreground); text-transform: capitalize; } -.assistant-tool-arg { color: var(--app-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; } -.assistant-tool-spin { margin-left: auto; } -.assistant-tool-status { margin-left: auto; } -.assistant-tool.is-error .assistant-tool-status .codicon { color: var(--gs-danger, #e15a5a); } -.assistant-tool:not(.is-error) .assistant-tool-status .codicon { color: var(--vscode-testing-iconPassed, #4caf72); } -.assistant-tool.is-denied { opacity: 0.66; } -.assistant-tool-out { margin: 0; padding: 9px 12px; border-top: 1px solid var(--app-border); font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: 11px; line-height: 1.5; color: var(--app-muted); white-space: pre-wrap; max-height: 240px; overflow: auto; background: var(--app-bg); } - -.assistant-error { display: flex; align-items: flex-start; gap: 8px; font-size: 12.5px; color: var(--gs-danger, #e15a5a); background: color-mix(in srgb, var(--gs-danger, #e15a5a) 10%, transparent); border: 1px solid color-mix(in srgb, var(--gs-danger, #e15a5a) 28%, transparent); border-radius: 9px; padding: 9px 11px; } -.assistant-error .codicon { margin-top: 1px; } -.assistant-empty { margin: auto; max-width: 420px; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 9px; color: var(--app-muted); } -.assistant-empty > .glyph .codicon, .assistant-empty > .glyph { font-size: 30px; color: var(--gs-accent-ink, var(--gs-accent)); } -.assistant-empty-title { font-size: 15px; font-weight: 680; color: var(--vscode-foreground); } -.assistant-empty-sub { font-size: 12.5px; line-height: 1.5; } +/* Checkbox staging model (issue #16). The tick is the only staging affordance in + this mode, so it gets a real hit area rather than the browser default. */ +.dc-ck { + flex: 0 0 auto; + width: 15px; + height: 15px; + margin: 0 8px 0 0; + cursor: pointer; + /* A drawn box, not the platform's. `accent-color: var(--accent)` named a + token this app has never declared (it is --gs-accent), so the one control + that IS the staging model rendered as a stock macOS blue tick in a purple + app — the most-clicked thing on the screen, visibly not part of it. */ + appearance: none; + -webkit-appearance: none; + border: 1.5px solid color-mix(in srgb, var(--vscode-foreground) 34%, transparent); + border-radius: 4px; + background: var(--app-elevated); + display: inline-grid; + place-content: center; + transition: background var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease); +} +.dc-ck::after { + content: ""; + width: 9px; + height: 9px; + transform: scale(0); + transition: transform 90ms var(--ease); + background: #fff; + /* A tick, drawn as a mask so it inherits no font and needs no glyph. */ + clip-path: polygon(14% 46%, 0 60%, 38% 96%, 100% 22%, 86% 10%, 36% 70%); +} +.dc-ck:checked { + background: var(--gs-accent); + border-color: var(--gs-accent); +} +.dc-ck:checked::after { transform: scale(1); } +.dc-ck:indeterminate { + background: var(--gs-accent); + border-color: var(--gs-accent); +} +.dc-ck:indeterminate::after { + transform: scale(1); + clip-path: none; + width: 8px; + height: 2px; + border-radius: 1px; +} +.dc-ck:hover { border-color: color-mix(in srgb, var(--gs-accent) 60%, var(--app-border)); } +.dc-ck:disabled { opacity: 0.45; cursor: not-allowed; } +.dc-ck-master { margin-left: 2px; } -.assistant-composer { border-top: 1px solid var(--app-border); padding: 12px 18px 14px; background: var(--app-panel); display: flex; flex-direction: column; gap: 10px; } -.assistant-quick { display: flex; flex-wrap: wrap; gap: 7px; } -.assistant-chip { - display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px; - border: 1px solid var(--app-border); border-radius: 999px; background: var(--app-elevated); - color: var(--vscode-foreground); font-family: inherit; font-size: 12px; cursor: pointer; - transition: background 0.12s ease, border-color 0.12s ease; +/* Per-hunk ticks in the checkbox staging model (#20). A file with unstaged work + opens up to reveal its individual changes, so partial staging survives the + move away from the staged/unstaged split. */ +.dc-hunk-twisty { + flex: 0 0 auto; + width: 16px; + height: 16px; + margin-right: 2px; + padding: 0; + border: 0; + background: transparent; + color: var(--app-muted); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transform: rotate(0deg); + transition: transform 120ms ease; } -.assistant-chip:hover { background: var(--app-hover); border-color: color-mix(in srgb, var(--gs-accent) 50%, var(--app-border)); } -.assistant-chip .codicon { font-size: 13px; color: var(--gs-accent-ink, var(--gs-accent)); } -.assistant-input-row { display: flex; align-items: flex-end; gap: 9px; } -.assistant-input { - flex: 1 1 auto; resize: none; min-height: 40px; max-height: 180px; - padding: 10px 13px; border-radius: 11px; border: 1px solid var(--app-border); - background: var(--app-elevated); color: var(--vscode-foreground); - font-family: inherit; font-size: 13px; line-height: 1.5; outline: none; +.dc-hunk-twisty.open { transform: rotate(90deg); } +.dc-hunk-twisty .codicon { font-size: 12px; line-height: 1; } +.dc-hunks { + display: flex; + flex-direction: column; + margin: 0 0 4px 46px; + border-left: 1px solid var(--app-border); +} +.dc-hunk-row { + display: flex; + align-items: center; + gap: 8px; + padding: 2px 8px; + min-height: 22px; + font-size: 12px; + color: var(--app-muted); } -.assistant-input:focus { border-color: color-mix(in srgb, var(--gs-accent) 55%, var(--app-border)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--gs-accent) 20%, transparent); } -.assistant-send { flex: 0 0 auto; height: 40px; width: 44px; padding: 0; display: inline-flex; align-items: center; justify-content: center; } -.assistant-send.is-cancel { background: var(--status-del); } -/* Stop the shared .btn-primary:hover (purple) from winning on the cancel button — - a stop/cancel action must stay danger-red on hover, not flip to accent. */ -.assistant-send.is-cancel:hover { - background: color-mix(in srgb, var(--status-del) 88%, white 12%); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22), - 0 6px 18px color-mix(in srgb, var(--status-del) 40%, transparent); +.dc-hunk-row:hover { background: var(--app-hover); } +.dc-hunk-lines { flex: 0 0 auto; font-variant-numeric: tabular-nums; opacity: 0.75; } +.dc-hunk-preview { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + color: var(--vscode-foreground); } -.assistant-send .codicon { font-size: 16px; } +.dc-hunk-empty { padding: 3px 10px; font-size: 12px; opacity: 0.7; } -/* ───────────────────────────────────────────────────────────────────────────── - Inline AI affordances (✨ chips, the footer chat tabs, header launcher) and - the Markdown table styling shared by every AI surface. - ───────────────────────────────────────────────────────────────────────────── */ +/* Inline diff mode cannot carry staging ticks — Monaco renders deletions as + view zones with no model line behind them, so a pure deletion has nothing to + attach a control to. Say so rather than leaving the gutter mysteriously bare. */ +.diff-staging-hint { + display: flex; align-items: center; gap: 6px; + padding: 6px 12px; color: var(--app-muted); font-size: 11.5px; + border-bottom: 1px solid var(--app-border); +} -/* The header Assistant launcher. */ -.topbar-assistant { width: auto; gap: 6px; padding: 0 11px; } -.topbar-assistant .codicon { color: var(--gs-accent-ink, var(--gs-accent)); } -.topbar-assistant-label { font-size: 12.5px; font-weight: 600; } -@media (max-width: 1040px) { .topbar-assistant-label { display: none; } .topbar-assistant { padding: 0; width: 30px; justify-content: center; } } +/* ---- Changes: multi-selection and drag-to-stash -------------------------- * + * Everything here is scoped to .dc-* because bare .file-row is shared with the + * compare view and the code browser, which have no notion of a selection. */ +.dc-listcol { display: flex; flex-direction: column; min-height: 0; } +/* The file column is sized in pixels from a persisted preference, so it needs a + floor to shrink toward — see the `0 1` basis set on it in renderer.ts. */ +.dc-listcol { min-width: 220px; } -/* ✨ chips + the AI variant of mini-btn. */ -.ai-chip { - display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; - border: 1px solid color-mix(in srgb, var(--gs-accent) 30%, var(--app-border)); - border-radius: 999px; background: color-mix(in srgb, var(--gs-accent) 8%, var(--app-elevated)); - color: var(--vscode-foreground); font-family: inherit; font-size: 12px; cursor: pointer; - transition: background 0.12s ease, border-color 0.12s ease; +/* The accent bar is absolutely positioned, so the row must be a containing + block. .file-row itself is static and shared, so this is set here. */ +.dc-file { position: relative; } +.dc-file.is-selected { background: color-mix(in srgb, var(--gs-accent) 16%, transparent); } +.dc-file.is-selected::before { + content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 2px; + background: var(--gs-accent); } -.ai-chip:hover { background: color-mix(in srgb, var(--gs-accent) 16%, var(--app-elevated)); border-color: color-mix(in srgb, var(--gs-accent) 55%, var(--app-border)); } -.ai-chip:disabled { opacity: 0.6; cursor: default; } -.ai-chip .codicon { font-size: 13px; color: var(--gs-accent-ink, var(--gs-accent)); } -.ai-mini .codicon:first-child { color: var(--gs-accent-ink, var(--gs-accent)); } -.cmp-ai { display: flex; flex-wrap: wrap; gap: 7px; margin-left: auto; } -/* The commit message box + its in-corner "Write message" affordance. */ -.dc-message-wrap { position: relative; display: flex; } -.dc-message-wrap .dc-message { width: 100%; } -/* "Write message" now lives in the branch header row, pushed to the right. A - crisp accent-tinted pill (not a translucent overlay floating in the textarea). */ -.dc-ai-write { - margin-left: auto; - align-self: center; - height: 27px; - padding: 0 12px; - gap: 6px; - font-size: 12px; - font-weight: 600; - border-radius: 999px; - background: color-mix(in srgb, var(--gs-accent) 14%, var(--app-elevated)); - border-color: color-mix(in srgb, var(--gs-accent) 45%, var(--app-border)); - box-shadow: var(--sheen), var(--shadow-sm); +.dc-file.dragging { opacity: 0.5; } + +.dc-selbar { + display: flex; align-items: center; gap: 8px; + padding: 5px 10px; margin: 0; + border-top: 1px solid var(--app-border); + background: var(--app-elevated); + font-size: 11.5px; flex: none; } -.dc-ai-write:hover { - background: color-mix(in srgb, var(--gs-accent) 22%, var(--app-elevated)); - border-color: color-mix(in srgb, var(--gs-accent) 60%, var(--app-border)); +.dc-selbar[hidden] { display: none; } +.dc-selbar-count { font-weight: 600; } +.dc-selbar-actions { margin-left: auto; display: flex; gap: 4px; } + +/* Revealed only mid-drag: a permanent strip would cost list height, which is + the scarce resource in this column. */ +.dc-stash-drop { + display: flex; align-items: center; justify-content: center; gap: 8px; + margin: 6px 8px 8px; padding: 14px 10px; flex: none; + border: 1px dashed var(--gs-accent); border-radius: 8px; + color: var(--app-muted); font-size: 12px; +} +.dc-stash-drop[hidden] { display: none; } +.dc-stash-drop.is-over { + background: color-mix(in srgb, var(--gs-accent) 14%, transparent); + color: var(--app-text); border-style: solid; } -.dc-ai-write .codicon { font-size: 13px; } -/* The "Review with AI" toolbar action picks up the accent glyph like the chips. */ -.dc-review .glyph .codicon, .dc-review .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } -/* ✨ AI chat tabs in the footer dock — the ✨ icon carries the accent so the AI - tabs read distinctly from Output / Terminal. */ -.term-tab.is-chat .glyph .codicon { color: var(--gs-accent-ink, var(--gs-accent)); } +/* ═══════════════════════════════════════════════════════════════════════════ + SECTION PAGES — the list ⇄ detail system (docs/desktop-redesign.md). + `sec-*` is the full-width list page; `det-*` the full-page detail. This block + is the ONE home for these styles — views never inject CSS or inline layout. + ═══════════════════════════════════════════════════════════════════════════ */ -/* The footer AI chat panel: a compact variant of the full Assistant transcript + - composer. The action's prompt is the first turn; follow-ups continue below. */ -.chat-panel { display: flex; flex-direction: column; height: 100%; min-height: 0; } -.chat-panel-transcript { - flex: 1 1 auto; min-height: 0; overflow-y: auto; - padding: 12px 14px; display: flex; flex-direction: column; gap: 10px; -} -.chat-panel-composer { - flex: 0 0 auto; padding: 8px 10px; - border-top: 1px solid var(--app-border); background: var(--app-panel); +/* ── The list page ── */ +.sec-list { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + /* …and the same reserve every long scroller needs; see .settings-scroll. */ + padding: var(--sp-1) var(--sp-4) calc(var(--sp-5) + var(--dock-reserve, 0px)); } -.chat-panel .assistant-input-row { display: flex; gap: 8px; align-items: flex-end; } -.chat-panel .assistant-input { flex: 1 1 auto; resize: none; min-height: 34px; max-height: 140px; } -.chat-panel .assistant-send { - flex: 0 0 auto; height: 34px; width: 38px; padding: 0; - display: inline-flex; align-items: center; justify-content: center; +.sec-row { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + min-height: 40px; + padding: 0 var(--sp-3); + border: none; + border-radius: var(--r-sm); + background: transparent; + color: var(--vscode-foreground); + font-family: inherit; + font-size: var(--text-base); + cursor: pointer; + text-align: left; + transition: background var(--dur-1) var(--ease); } - -/* GFM tables from the Markdown renderer — used across every AI surface. */ -.md-table { - border-collapse: collapse; margin: 8px 0; font-size: 12.5px; - display: block; max-width: 100%; overflow-x: auto; +.sec-row:hover { background: var(--app-hover); } +.sec-row:focus-visible { + outline: 2px solid var(--gs-focus-ring); + outline-offset: -2px; } -.md-table th, .md-table td { border: 1px solid var(--app-border); padding: 5px 9px; text-align: left; vertical-align: top; } -.md-table th { background: var(--app-elevated); font-weight: 650; white-space: nowrap; } -.md-table tbody tr:nth-child(even) td { background: color-mix(in srgb, var(--app-elevated) 45%, transparent); } -.md-left { text-align: left; } -.md-center { text-align: center; } -.md-right { text-align: right; } - -/* Shared Markdown element styling across every surface that renders it: - the Code-view README, GitHub issue/PR/release bodies, and AI chat. */ -.code-md img, .gh-body-md img, .assistant-msg img { - max-width: 100%; - height: auto; - border-radius: 6px; - vertical-align: middle; +/* Hairline between rows, inset past the leading icon — density with structure. */ +.sec-row + .sec-row { position: relative; } +.sec-row + .sec-row::before { + content: ""; + position: absolute; + top: -0.5px; + left: var(--sp-3); + right: var(--sp-3); + height: 1px; + background: color-mix(in srgb, var(--app-border) 55%, transparent); + pointer-events: none; } -/* Badge rows (shields.io et al) sit inline and shouldn't gain a card look. */ -.code-md a img, .gh-body-md a img, .assistant-msg a img { - border-radius: 3px; - margin: 1px 2px; +.sec-row:hover + .sec-row::before, .sec-row + .sec-row:hover::before { background: transparent; } +.sec-row-lead { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; } -/* GitHub-style centered headers: <p align="center"> is legacy HTML the - sanitizer keeps, but flex/grid children ignore it — force it here. */ -.code-md [align="center"], .gh-body-md [align="center"] { text-align: center; } -.code-md [align="right"], .gh-body-md [align="right"] { text-align: right; } - -/* Task lists — inert glyphs (never real <input>), aligned like GitHub's. */ -.md-task-list { list-style: none; padding-left: 1.1em; } -.md-task-item { position: relative; } -.md-task { - display: inline-block; - width: 1.1em; - margin-right: 0.25em; +.sec-row-lead .codicon { font-size: 15px; } +.sec-row-num { + flex: 0 0 auto; + min-width: 34px; color: var(--app-muted); - font-size: 0.95em; + font-size: var(--text-sm); + font-variant-numeric: tabular-nums; } -.md-task-done { color: var(--gs-accent-ink, var(--gs-accent)); } - -.code-md del, .gh-body-md del, .assistant-msg del { opacity: 0.68; } -.code-md kbd, .gh-body-md kbd, .assistant-msg kbd { - font-family: var(--vscode-editor-font-family, ui-monospace, monospace); - font-size: 0.82em; - padding: 1px 5px; - border: 1px solid var(--app-border); - border-bottom-width: 2px; - border-radius: 5px; - background: var(--app-elevated); +.sec-row-title { + flex: 0 1 auto; + /* A floor, so the row's IDENTITY outlives its badges. With min-width:0 the + title shrank to nothing while the pills beside it refused to give up a + pixel — at 1000px a draft release read "D…" next to full-size Draft and + Pre-release pills. The name is the one thing the row cannot do without. */ + min-width: 8ch; + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; + font-weight: 550; + color: var(--vscode-foreground); } -.code-md details, .gh-body-md details { - margin: 0.6em 0; - padding: 8px 12px; - border: 1px solid var(--app-border); - border-radius: 8px; - background: color-mix(in srgb, var(--app-elevated) 45%, transparent); -} -.code-md summary, .gh-body-md summary { cursor: pointer; font-weight: 600; } -.gh-body-md pre, .assistant-msg pre { overflow-x: auto; max-width: 100%; } - -/* Code view: instant in-folder filter + keyboard navigation affordances. */ -.code-filter { - width: 190px; max-width: 34vw; - padding: 4px 9px; font-size: 12px; font-family: inherit; - color: var(--app-text); background: var(--app-elevated); - border: 1px solid var(--app-border); border-radius: 7px; - transition: width 140ms ease, border-color 120ms ease; +/* Inline chips ride after the title and CLIP rather than wrap — one-line rows. */ +.sec-row-chips { + flex: 0 1 auto; + display: inline-flex; + gap: 5px; + min-width: 0; + overflow: hidden; + white-space: nowrap; } -.code-filter::placeholder { color: var(--app-muted); } -.code-filter:focus { - width: 260px; outline: none; - border-color: var(--gs-accent); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--gs-accent) 22%, transparent); +.sec-row-chips > * { flex: 0 0 auto; } +/* When there is not room for every label, the last one fades out instead of + being guillotined mid-character. A pill with a flat sliced edge reads as a + half-drawn element; a fade reads as "there is more". */ +.sec-row-chips { + -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 18px), transparent); + mask-image: linear-gradient(90deg, #000 calc(100% - 18px), transparent); } -.code-row .file-path mark { - background: color-mix(in srgb, var(--gs-accent) 34%, transparent); - color: inherit; border-radius: 3px; padding: 0 1px; +.sec-row-chips:not(.is-clipped) { + -webkit-mask-image: none; + mask-image: none; } -.code-row:focus-visible { - outline: 1px solid var(--gs-accent); outline-offset: -1px; - background: var(--app-hover); +.sec-row-spring { flex: 1 1 0; min-width: var(--sp-2); } +.sec-row-meta { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: var(--sp-3); + color: var(--app-muted); + font-size: var(--text-xs); + font-variant-numeric: tabular-nums; } -.code-listing.is-empty-filter::after { - content: "No files match that filter."; - display: block; padding: 18px 14px; color: var(--app-muted); font-size: 12.5px; +/* The cluster packs right-to-left, so a slot whose width depends on its + CONTENT (one avatar vs three, "+12 −4" vs "+1240 −180") drags every slot + left of it out of column. Each kind of slot reserves the same width on + every row, so the eye can scan straight down. */ +.sec-row-meta > .sec-avs { min-width: 54px; justify-content: flex-end; } +.sec-row-meta > .sec-diffstat { min-width: 104px; justify-content: flex-end; } +.sec-row-meta > .gh-stat { min-width: 44px; justify-content: flex-end; } +.sec-row-meta > .gist-filecount { min-width: 54px; text-align: right; } +.sec-row-meta > .notif-repo { min-width: 190px; text-align: right; justify-content: flex-end; } +.sec-row-meta > .notif-reason { min-width: 114px; text-align: right; } +.sec-row-meta > .rel-tag { min-width: 148px; text-align: right; } +.sec-row-meta > .rel-author { min-width: 110px; text-align: right; } +.sec-row-meta > .rel-asset-count { min-width: 42px; justify-content: flex-end; } +.sec-row-meta > .rel-downloads { min-width: 76px; justify-content: flex-end; } +/* Explore's own two — the only list in the app whose meta cluster did not form + columns, because these were the two slots nobody added here. */ +.sec-row-meta > .explore-lang { min-width: 84px; text-align: right; } +.sec-row-meta > .explore-stat { min-width: 62px; justify-content: flex-end; } +/* Actions run rows: branch, event and duration each keep their column so the + list can be scanned down rather than read row by row. */ +.sec-row-meta > .sec-run-branch { + min-width: 168px; + max-width: 240px; + justify-content: flex-end; + text-align: right; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } - -/* ── Interactive Rebase view ────────────────────────────────────────────────── - A commit rail with node dots, per-commit action dropdowns, plain-English - consequences, and a dimmed "onto" base row — the desktop twin of the - extension's rebase workspace. */ -.rb-view { display: flex; flex-direction: column; height: 100%; overflow: auto; } -.rb-head { - display: flex; align-items: center; gap: 10px; flex-wrap: wrap; - padding: 14px 18px 10px; position: sticky; top: 0; z-index: 5; - background: var(--app-bg); border-bottom: 1px solid var(--app-border); +.sec-row-meta > .sec-run-event { min-width: 76px; text-align: right; } +.sec-row-meta > .sec-run-dur { min-width: 62px; text-align: right; } +.sec-row-meta > .gh-sublink { max-width: 190px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.sec-row-time { + flex: 0 0 auto; + /* "5h ago" and "16h ago" must end on the same edge. */ + min-width: 58px; + text-align: right; + color: var(--app-muted); + font-size: var(--text-xs); + font-variant-numeric: tabular-nums; } -.rb-title { display: flex; align-items: center; gap: 7px; font-size: 14px; font-weight: 650; } -.rb-title .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } -.rb-sub { display: flex; align-items: center; gap: 4px; color: var(--app-muted); font-size: 12px; } -.rb-sub b { color: var(--gs-accent-ink, var(--gs-accent)); font-family: var(--vscode-editor-font-family, ui-monospace, monospace); } -.rb-spacer { flex: 1 1 auto; } -.rb-hint { display: flex; align-items: center; gap: 6px; padding: 8px 18px 2px; color: var(--app-muted); font-size: 11.5px; } - -.rb-explain { - position: relative; margin: 10px 16px 2px; padding: 12px 34px 12px 14px; - border: 1px solid var(--app-border); border-radius: 10px; - background: color-mix(in srgb, var(--gs-accent) 7%, var(--app-elevated)); +/* Row verbs, AT REST. + The Branches view hid its entire action surface behind `opacity: 0` until + hover — which is why every deeper verb had to be exiled into a menu, and why + none of it could be reached by keyboard or by touch at all. Muted at rest, + full contrast on hover or when anything inside the row has focus, and a fixed + width so the column holds down the page whether a row has one verb or three. */ +.sec-row-actions { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + min-width: 150px; } -.rb-explain[hidden] { display: none; } -.rb-explain-lead { display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; font-size: 12.5px; line-height: 1.55; } -.rb-explain-lead .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } -.rb-explain-x { - position: absolute; top: 7px; right: 8px; width: 22px; height: 22px; - border: none; background: transparent; color: var(--app-muted); - font-size: 16px; line-height: 1; cursor: pointer; border-radius: 6px; +.sec-row-actions > * { opacity: 0.62; transition: opacity var(--dur-1) var(--ease); } +.sec-row:hover .sec-row-actions > *, +.sec-row:focus .sec-row-actions > *, +.sec-row:focus-within .sec-row-actions > * { opacity: 1; } +/* Keyboard focus must never be the thing that is invisible. */ +.sec-row-actions > *:focus-visible { opacity: 1; } +/* Overlapping avatar stack in a row's meta cluster. */ +.sec-avs { display: inline-flex; align-items: center; } +.sec-avs .av { margin-left: -6px; outline: 2px solid var(--app-bg); border-radius: 999px; } +.sec-avs .av:first-child { margin-left: 0; } +.sec-avs-more { margin-left: 3px; font-size: var(--text-2xs); color: var(--app-muted); } +/* The row is one flat click target; the empty/loading states center themselves. */ +/* No top margin on the empty state: `.list-empty` is already `min-height:100%` + of the scroller, so a margin pushed it past the bottom, made the pane scroll, + and left the four lists' empties at different heights. */ +.sec-list > .list-loading, .sec-list > .sk-list { margin-top: var(--sp-6); } + +/* ── The detail page ── */ +.det-view { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + background: var(--app-bg); } -.rb-explain-x:hover { background: var(--app-hover); color: var(--app-text); } -.rb-gloss { - display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); - gap: 3px 18px; margin-top: 10px; font-size: 11.5px; color: var(--app-muted); +.det-topbar { + flex: 0 0 auto; + display: flex; + align-items: center; + /* WRAPS rather than pushing. A PR's action cluster is Checkout + Approve + + Review + Merge + ⋯ + Open on GitHub, none of which can shrink below its + label, and the row had no wrap and no min-width:0 — so below about 900px + the overflow pushed the whole app sideways and the topbar slid off to + reveal it. The window's own minimum is 880. A two-row header is a far + better answer than a horizontally scrolling application. */ + flex-wrap: wrap; + row-gap: var(--sp-2); + gap: 10px; + min-height: 46px; + padding: var(--sp-2) var(--sp-4); + border-bottom: 1px solid var(--app-border); + background: color-mix(in srgb, var(--app-panel) 55%, var(--app-bg)); } -.rb-gloss b { font-weight: 650; margin-right: 2px; } -.rb-gloss .g-pick { color: var(--app-text); } -.rb-gloss .g-reword { color: #4a9eda; } -.rb-gloss .g-squash, .rb-gloss .g-fixup { color: var(--gs-accent-ink, var(--gs-accent)); } -.rb-gloss .g-edit { color: var(--status-warn); } -.rb-gloss .g-drop { color: var(--gs-danger); } - -.rb-list { padding: 6px 16px 8px; } -.rb-row { - display: flex; align-items: stretch; gap: 9px; position: relative; - padding: 8px 10px 8px 0; border-radius: 8px; - transition: background 120ms ease, opacity 120ms ease; +.det-back { + display: inline-flex; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 10px 0 7px; + border: none; + border-radius: var(--r-sm); + background: transparent; + color: var(--app-muted); + font: inherit; + font-size: var(--text-sm); + font-weight: 600; + cursor: pointer; + transition: background var(--dur-1) var(--ease), color var(--dur-1) var(--ease); } -.rb-row:hover { background: var(--app-hover); } -.rb-row.dragging { opacity: 0.4; } -.rb-row.drag-over { box-shadow: inset 0 -2px 0 var(--gs-accent); } -.rb-row:focus-visible { outline: 1px solid var(--gs-accent); outline-offset: -1px; } -.rb-row.dropped .rb-subj { text-decoration: line-through; opacity: 0.55; } - -/* The continuous rail + per-commit node dot. */ -.rb-rail { flex: 0 0 24px; position: relative; } -.rb-rail::before { - content: ""; position: absolute; left: 50%; top: 0; bottom: 0; width: 2px; - transform: translateX(-50%); background: var(--gs-accent); opacity: 0.35; +.det-back:hover { background: var(--app-hover); color: var(--vscode-foreground); } +.det-back .codicon { font-size: 14px; } +.det-crumb { + color: var(--app-muted); + font-size: var(--text-sm); + font-variant-numeric: tabular-nums; } -.rb-node { - position: absolute; left: 50%; top: 13px; width: 9px; height: 9px; - transform: translate(-50%, -50%); border-radius: 50%; - background: var(--gs-accent); box-shadow: 0 0 0 3px var(--app-bg); +.det-tb-actions { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: var(--sp-2); + /* So it can be the thing that WRAPS, rather than the thing that pushes. A + PR's cluster is Checkout + Approve + Review + Merge + ⋯ + Open on GitHub, + none of which shrink below its label; with no wrap and no min-width:0 it + overflowed the body at the window's own 880px minimum and the topbar slid + off to reveal it. */ + min-width: 0; + flex-wrap: wrap; + row-gap: var(--sp-2); +} +/* Reserve room for the dock. Its body FLOATS in `.dock-overlay` (absolute, + bottom: 0) so opening it never reflows the view above — which means it COVERS + the bottom of whatever is behind it. `.code-scroll` and two other scrollers + already add `--dock-reserve` for exactly this; every detail page — issues, + pull requests, releases, commits, the log — did not, so with the dock open + the last screenful of any of them could not be brought into view at all. */ +.det-scroll { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding-bottom: var(--dock-reserve, 0px); } -.rb-row[data-action="squash"] .rb-node, .rb-row[data-action="fixup"] .rb-node { - width: 6px; height: 6px; background: var(--app-bg); border: 2px solid var(--gs-accent); +.det-body { + display: flex; + align-items: flex-start; + /* A flex item's default `min-width: auto` refuses to shrink below its + content, and this is the item that carries every detail page's body. One + unbreakable string anywhere inside — a 400-character CI log line, a long + path, a base64 blob — grew this box past `.det-scroll`, which clips on + `overflow-x: hidden`, so the page silently widened and took its own + toolbar off-screen with nothing to scroll back with. Measured: a 400-char + log line took .det-body from 1384px to 3192px. */ + min-width: 0; + gap: var(--sp-8); + padding: var(--sp-5) var(--sp-6) var(--sp-8); + /* main + gap + rail. CENTERED: uncentered, a capped column on a wide window + hugs the left and strands ~400px of dead gutter on the right — the exact + thing --measure-* was introduced to stop. */ + max-width: calc(var(--measure-read) + var(--sp-8) + 264px + var(--sp-6) * 2); + margin-inline: auto; +} +.det-main { flex: 1 1 auto; min-width: 0; max-width: var(--measure-read); } +.det-rail { + flex: 0 0 264px; + position: sticky; + top: var(--sp-4); + display: flex; + flex-direction: column; + gap: var(--sp-5); + padding-bottom: var(--sp-6); +} +@media (max-width: 980px) { + .det-body { flex-direction: column; gap: var(--sp-4); } + .det-rail { position: static; flex: none; width: 100%; flex-direction: row; flex-wrap: wrap; gap: var(--sp-5) var(--sp-8); } +} + +/* Title block at the top of det-main. */ +/* The pill sits on the title's FIRST LINE, aligned to its cap height. A hard + 4px offset against a flex-start row put it 13px above the title's optical + centre — close enough to look like a mistake rather than a choice. */ +.det-title-row { display: flex; align-items: flex-start; gap: var(--sp-3); margin: 2px 0 6px; } +.det-title-row .gh-state-pill, +.det-title-row .det-title-pills { margin-top: 3px; flex: 0 0 auto; } +.det-title-pills { display: inline-flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; } +.det-title { + font-size: 21px; + line-height: 1.25; + font-weight: 650; + letter-spacing: -0.012em; + color: var(--vscode-foreground); + overflow-wrap: anywhere; } -.rb-row[data-action="drop"] .rb-node { background: var(--app-bg); border: 2px solid var(--gs-danger); } -.rb-row.rb-base .rb-node { background: var(--app-bg); border: 2px solid var(--app-muted); } -.rb-row.rb-base .rb-rail::before { bottom: 50%; } -.rb-row.rb-base { opacity: 0.72; } -.rb-onto { - align-self: center; font-size: 10px; font-weight: 700; letter-spacing: 0.08em; - text-transform: uppercase; color: var(--app-muted); - border: 1px solid var(--app-border); border-radius: 5px; padding: 1px 5px; +.det-title-num { color: var(--app-muted); font-weight: 500; } +.det-sub { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px 6px; + margin-bottom: var(--sp-4); + font-size: var(--text-sm); + color: var(--app-muted); } +.det-sub .gh-meta-author { font-weight: 600; } -.rb-grip { display: flex; align-items: center; color: var(--app-muted); cursor: grab; opacity: 0; } -.rb-row:hover .rb-grip, .rb-row:focus-within .rb-grip { opacity: 1; } - -.rb-action { - align-self: flex-start; margin-top: 1px; min-width: 88px; - background: var(--app-elevated); color: var(--app-text); - border: 1px solid var(--app-border); border-radius: 6px; - padding: 3px 6px; font-size: 11.5px; font-weight: 600; cursor: pointer; +/* Rail properties. */ +.det-prop { display: flex; flex-direction: column; gap: 7px; min-width: 148px; } +.det-prop-label { + display: flex; + align-items: center; + gap: 6px; + font-size: var(--text-2xs); + font-weight: 650; + letter-spacing: var(--track-label); + text-transform: uppercase; + color: var(--app-muted); } -.rb-action.a-reword { color: #4a9eda; } -.rb-action.a-squash, .rb-action.a-fixup { color: var(--gs-accent-ink, var(--gs-accent)); } -.rb-action.a-edit { color: var(--status-warn); } -.rb-action.a-drop { color: var(--gs-danger); } - -.rb-main { flex: 1 1 auto; min-width: 0; } -.rb-line { display: flex; align-items: center; gap: 8px; min-width: 0; } -.rb-subj { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; } -.rb-avatar { - flex: 0 0 auto; width: 18px; height: 18px; border-radius: 50%; - display: inline-flex; align-items: center; justify-content: center; - font-size: 9px; font-weight: 700; color: #fff; - background: hsl(var(--h, 250) 55% 45%); +.det-prop-edit { + margin-left: auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border: none; + border-radius: 5px; + background: transparent; + color: var(--app-muted); + cursor: pointer; + /* Visible at rest, not on hover. A filled Assignees or Labels section showed + its people and NOTHING that said they could be changed — the only way to + find out was to sweep the pointer over the heading and hope. Hover is a + way to emphasise an affordance, never the way to discover one. */ + opacity: 0.55; + transition: opacity var(--dur-1) var(--ease), background var(--dur-1) var(--ease); +} +.det-prop:hover .det-prop-edit, .det-prop-edit:focus-visible { opacity: 1; } +.det-prop-edit:hover { background: var(--app-hover); color: var(--vscode-foreground); } +.det-prop-edit .codicon { font-size: 12px; } +.det-prop-body { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + font-size: var(--text-sm); + color: var(--vscode-foreground); } -.rb-meta { flex: 0 0 auto; color: var(--app-muted); font-size: 11px; } -.rb-sha { - flex: 0 0 auto; display: inline-flex; align-items: center; gap: 3px; - color: var(--app-muted); font-size: 11px; - font-family: var(--vscode-editor-font-family, ui-monospace, monospace); +.det-prop-none { color: var(--app-muted); } +.det-person { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 2px 8px 2px 3px; + border: none; + border-radius: 999px; + background: transparent; + color: var(--vscode-foreground); + font: inherit; + font-size: var(--text-sm); } -.rb-sha .glyph { font-size: 11px; } - -/* The reword editor only appears for a `reword` row. */ -.rb-reword { display: none; margin-top: 6px; } -.rb-row[data-action="reword"] .rb-reword { display: block; } -.rb-reword textarea { - width: 100%; resize: vertical; padding: 6px 8px; font-size: 12px; - color: var(--app-text); background: var(--app-elevated); - border: 1px solid var(--app-border); border-radius: 6px; - font-family: inherit; +button.det-person { cursor: pointer; transition: background var(--dur-1) var(--ease); } +button.det-person:hover { background: var(--app-hover); } +.det-prop-add { + display: inline-flex; + align-items: center; + gap: 5px; + height: 24px; + padding: 0 9px 0 6px; + border: 1px dashed color-mix(in srgb, var(--app-muted) 45%, transparent); + border-radius: 999px; + background: transparent; + color: var(--app-muted); + font: inherit; + font-size: var(--text-xs); + cursor: pointer; + transition: color var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease), background var(--dur-1) var(--ease); } -.rb-reword textarea:focus { outline: 1px solid var(--gs-accent); border-color: var(--gs-accent); } - -.rb-consequence { display: none; align-items: center; gap: 5px; margin-top: 5px; font-size: 11.5px; color: var(--app-muted); } -.rb-consequence .glyph { font-size: 12px; } -.rb-row[data-action="squash"] .rb-consequence, -.rb-row[data-action="fixup"] .rb-consequence, -.rb-row[data-action="edit"] .rb-consequence, -.rb-row[data-action="drop"] .rb-consequence { display: flex; } -.rb-row[data-action="squash"] .rb-consequence, -.rb-row[data-action="fixup"] .rb-consequence { color: var(--gs-accent-ink, var(--gs-accent)); } -.rb-row[data-action="edit"] .rb-consequence { color: var(--status-warn); } -.rb-row[data-action="drop"] .rb-consequence { color: var(--gs-danger); } - -.rb-banner { margin: 6px 16px; padding: 8px 12px; border-radius: 8px; font-size: 12px; } -.rb-banner[hidden] { display: none; } -.rb-banner.warn { background: color-mix(in srgb, var(--status-warn) 16%, transparent); border: 1px solid color-mix(in srgb, var(--status-warn) 40%, transparent); } -.rb-banner.error { background: color-mix(in srgb, var(--gs-danger) 15%, transparent); border: 1px solid color-mix(in srgb, var(--gs-danger) 40%, transparent); } - -.rb-foot { - display: flex; align-items: center; gap: 10px; flex-wrap: wrap; - padding: 12px 18px; margin-top: auto; - position: sticky; bottom: 0; background: var(--app-bg); - border-top: 1px solid var(--app-border); +.det-prop-add:hover { + color: var(--gs-accent-ink, var(--gs-accent)); + border-color: color-mix(in srgb, var(--gs-accent) 55%, transparent); + background: var(--accent-soft); } -.rb-preview { color: var(--app-muted); font-size: 11.5px; } -.rb-btn { - display: inline-flex; align-items: center; gap: 6px; - padding: 6px 12px; border-radius: 7px; font-size: 12.5px; font-weight: 600; - border: 1px solid var(--app-border); background: var(--app-elevated); - color: var(--app-text); cursor: pointer; +.det-prop-add .codicon { font-size: 12px; } +/* A key→value line for quiet facts (created / updated). */ +.det-prop-facts { flex-direction: column; align-items: stretch; } +.det-fact { display: flex; justify-content: space-between; gap: var(--sp-3); font-size: var(--text-xs); } +.det-fact-k { color: var(--app-muted); } +.det-fact-v { color: var(--vscode-foreground); font-variant-numeric: tabular-nums; } +.det-milestone { display: inline-flex; align-items: center; gap: 6px; font-size: var(--text-sm); } +.det-milestone .codicon { font-size: 13px; color: var(--app-muted); } +/* Drawer variant: the action cluster rides above the title. */ +.det-drawer-actions { margin: 0 0 var(--sp-3); justify-content: flex-end; } + +/* Detail top-bar buttons stay slim (match .mini-btn height). */ +.det-tb-actions .btn { height: 28px; padding: 0 12px; font-size: var(--text-sm); border-radius: var(--r-sm); gap: 6px; } +.det-tb-actions .mini-btn { border-radius: var(--r-sm); } + +/* The timeline inside det-main reuses .gh-comment cards; give the column its + rhythm without per-view margins. */ +.det-main .gh-subcontent { display: flex; flex-direction: column; gap: var(--sp-3); } +.det-main .gh-composer { margin-top: var(--sp-4); max-width: none; } + +/* Toolbar facet buttons (Label / Assignee / Milestone) — shared by the list + pages. Previously injected from issues.ts at runtime; now they live here. */ +/* The facet GROUP is one flex item in the tools row, so without its own wrap + it stayed a single unbreakable line and ran off the right edge at narrow + widths however much the parent wrapped. */ +.gh-facets { display: inline-flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; row-gap: 8px; min-width: 0; } +/* A facet pill is the same width filtered or not. + The chosen value used to be appended to the pill's own label ("Label" → + "Label: enhancement"), which widened that pill by ~90px and slid every pill + after it sideways — the next one you were aiming at had moved before your + second click landed. The value now lives in a fixed-width slot that is always + reserved, so picking one moves nothing, and the pills keep sizing to their + own labels so the toolbar around them is unchanged. */ +.gh-facet-btn { max-width: 220px; } +.gh-facet-value { + flex: 0 0 auto; + width: 18px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 5px; + font-size: 10.5px; + font-weight: 650; + font-variant-numeric: tabular-nums; + background: transparent; + color: transparent; } -.rb-btn:hover { background: var(--app-hover); } -.rb-btn.primary { background: var(--gs-accent); border-color: var(--gs-accent); color: #fff; } -.rb-btn.primary:hover { filter: brightness(1.08); } -.rb-btn.danger { color: var(--gs-danger); border-color: color-mix(in srgb, var(--gs-danger) 45%, var(--app-border)); } -.rb-btn.ghost { background: transparent; } -.rb-btn:disabled { opacity: 0.6; cursor: default; } -.rb-btn.busy .glyph { animation: rb-spin 1s linear infinite; } -@keyframes rb-spin { to { transform: rotate(360deg); } } - -.rb-loading { display: flex; align-items: center; gap: 8px; padding: 24px; color: var(--app-muted); } -.rb-loading .glyph { animation: rb-spin 1s linear infinite; } -.rb-basebar { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } -/* One-click range presets so a rebase can start without typing a ref. */ -.rb-chip { - padding: 4px 10px; border-radius: 999px; font-size: 11.5px; font-weight: 600; - border: 1px solid var(--app-border); background: var(--app-elevated); - color: var(--app-muted); cursor: pointer; +.gh-facet-btn.is-active .gh-facet-value { + background: color-mix(in srgb, var(--gs-accent) 24%, transparent); + color: var(--gs-accent-ink, var(--gs-accent)); } -.rb-chip:hover { background: var(--app-hover); color: var(--app-text); } -.rb-chip.is-active { +.gh-facet-btn > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.gh-facet-btn .codicon-chevron-down { font-size: 13px; opacity: 0.65; margin-left: -1px; } +.gh-facet-btn.is-active { color: var(--gs-accent-ink, var(--gs-accent)); - border-color: color-mix(in srgb, var(--gs-accent) 55%, var(--app-border)); - background: color-mix(in srgb, var(--gs-accent) 12%, transparent); + border-color: var(--accent-line, var(--gs-accent)); + background: var(--app-active); } +.gh-facet-btn.is-active .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } +/* "Clear" only appears while something is actually filtered, so it never + competes with the facets for attention in the resting state. */ +.gh-facet-clear { color: var(--app-muted); } +.gh-facet-clear:hover { color: var(--vscode-foreground); } +.gh-label-swatch { + display: inline-block; + width: 11px; + height: 11px; + border-radius: 50%; + box-shadow: inset 0 0 0 1px color-mix(in srgb, #000 22%, transparent); + flex: 0 0 auto; +} +/* Compact property strip used where a detail renders WITHOUT the rail (the + Projects drawer): label chips + people inline under the title. */ +.det-inline-props { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 2px 0 var(--sp-3); } -.rb-inprogress { margin: 22px auto; max-width: 560px; padding: 18px 20px; border: 1px solid var(--app-border); border-radius: 12px; background: var(--app-elevated); } -.rb-inprogress-head { display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 650; } -.rb-inprogress-head .glyph { color: var(--status-warn); } -.rb-inprogress-body { display: block; margin: 8px 0 14px; color: var(--app-muted); font-size: 12.5px; line-height: 1.55; } -.rb-inprogress-btns { display: flex; gap: 8px; } - -/* ════════════════════════════════════════════════════════════════════════════ - Depth styles — conflict merge bar, GitHub Actions (logs/artifacts/secrets), - commit-composer depth, op-state banner, PR review (Files diff + inline threads), - and a handful of CSS-only design-audit fixes. - - Self-contained block. Every value reuses the shared tokens (--app-*, --gs-accent*, - --status-*, --sheen/--shadow-sm, the radius ladder, --dur-1/--ease) so these - surfaces are pixel-consistent with the rest of the app and flip cleanly between - the dark and light themes. Motion is reduced-motion-safe via the global - prefers-reduced-motion reset near the top of this file. - ════════════════════════════════════════════════════════════════════════════ */ +/* ── PR specifics on the section-page system ── */ -/* ── 1 · Conflict-resolution merge bar (diffPanel.ts) ───────────────────────── */ -/* The merge editor lives in a column: a slim toolbar on top, the Monaco merge - surface filling the rest. `.merge-surface` MUST keep min-height:0 so the editor - resolves a real pixel height inside the flex parent (else Monaco collapses). */ -.merge-wrap { - display: flex; - flex-direction: column; - height: 100%; +/* A row's compact +/− diffstat in the meta cluster. */ +.sec-diffstat { display: inline-flex; gap: 6px; font-size: var(--text-xs); font-variant-numeric: tabular-nums; } +.sec-diffstat .add { color: var(--status-add); } +.sec-diffstat .del { color: var(--status-del); } + +/* The small pencil beside a detail title (quiet until hovered). */ +.det-title-edit { align-self: flex-start; margin-top: 3px; opacity: 0.55; } +.det-title-edit:hover { opacity: 1; } + +/* The rail's checks pill is a real button (jumps to the Checks tab). */ +.det-checks-pill { margin-top: 0; border: none; cursor: pointer; font: inherit; font-size: var(--text-2xs); font-weight: 600; } + +/* Files mode: the rail hides and the content column stretches to the full + window height — a review surface, not a document. */ +.det-view.det-files-mode .det-scroll { display: flex; flex-direction: column; overflow: hidden; } +.det-view.det-files-mode .det-body { + flex: 1 1 auto; min-height: 0; + max-width: none; + width: 100%; + /* STRETCH, not the flex default of a content-derived height. Without it this + column grows to its content — 1787px against an 804px scrollport — and the + definite height every descendant's `overflow-y: auto` depends on never + arrives. Two things were unreachable as a result: the inline review-comment + panel (984px below the fold, in a container whose overflow is hidden, so + no scrollbar and no wheel), and every file past the 19th in the file list, + whose own auto-scroll was inert because it had been stretched to its full + content height. */ + align-items: stretch; + padding-bottom: var(--sp-4); } -.merge-bar { - flex: 0 0 auto; +.det-view.det-files-mode .det-rail { display: none; } +.det-view.det-files-mode .det-main { + max-width: none; + flex: 1 1 auto; display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - min-width: 0; - padding: 8px 12px; - background: var(--app-surface, var(--app-panel)); - border-bottom: 1px solid var(--app-border); + flex-direction: column; + min-height: 0; } -.merge-bar-title { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; +.det-view.det-files-mode .det-main > .gh-subcontent { flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; } -.merge-bar-title .glyph, -.merge-bar-title .codicon { flex: 0 0 auto; color: var(--app-muted); } -.merge-bar-title .codicon { font-size: 15px; } -.merge-bar-path { + +/* Threads panel bits that used to be inline styles in prs.ts. */ +.pr-threads-add { margin-left: auto; } +.pr-thread-resolve { margin-left: auto; } +.pr-thread-body { margin: 0; padding: 0; } + +/* ── Actions on the section-page system ── */ + +/* Run-status lead icons (used to be inline color styles). */ +.run-lead.is-success { color: var(--status-add); } +.run-lead.is-failure { color: var(--status-del); } +.run-lead.is-running { color: var(--status-mod); } +.gh-lead-icon.is-muted, .run-lead.is-muted { color: var(--app-muted); } +.gh-lead-icon.is-accent { color: var(--gs-accent-ink, var(--gs-accent)); } +/* Monospace fragments in a row's meta cluster (tag names, short SHAs). */ +.sec-mono { font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: var(--text-2xs); } +/* The status word in a run row's meta cluster — a fixed slot so rows align. */ +/* The workflow's name as a muted suffix after a run row's title. */ +.sec-run-wf { + flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-family: var(--vscode-editor-font-family, ui-monospace, monospace); - font-size: var(--text-sm); - color: var(--vscode-foreground); - /* Keep the meaningful tail (…/src/main.ts) when the path can't fit. */ - direction: rtl; - text-align: left; + color: var(--app-muted); + font-size: var(--text-xs); } -.merge-bar-actions { - flex: 0 0 auto; +/* Re-run attempt badge (rows + detail title). */ +.sec-attempt { + margin-top: 0; + align-self: center; + color: var(--status-mod); + background: color-mix(in srgb, var(--status-mod) 16%, transparent); +} +.det-attempt { flex: 0 0 auto; margin-top: 6px; } +.sec-run-dur { font-variant-numeric: tabular-nums; } + +/* Job meta line: runner + queue latency, tucked at the top of the steps box. */ +.gh-job-meta { display: flex; align-items: center; - gap: 8px; + flex-wrap: wrap; + gap: var(--sp-3); + padding: 6px 12px 2px; + font-size: var(--text-xs); + color: var(--app-muted); } -.merge-surface { - flex: 1 1 auto; - min-height: 0; /* CRITICAL — gives the Monaco merge editor real height */ +.gh-job-runner { display: inline-flex; align-items: center; gap: 5px; } +.gh-job-runner .codicon { font-size: 13px; } +.gh-job-queue { font-variant-numeric: tabular-nums; } + +/* Per-step duration timeline: a thin proportional bar + a tabular duration. */ +.gh-step-bar { + flex: 0 0 90px; + height: 4px; + border-radius: 999px; + background: color-mix(in srgb, var(--app-muted) 14%, transparent); position: relative; + overflow: hidden; +} +.gh-step-bar::after { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: var(--w, 0%); + border-radius: 999px; + background: color-mix(in srgb, var(--gs-accent) 55%, transparent); +} +.gh-step-dur { + /* A floor, not a fixed width, and never a wrap: a step that has been running + for a while reads "17m 24s…", which is wider than 52px and used to fold + onto a second line — inflating every row in the job it belonged to. */ + flex: 0 0 auto; + min-width: 52px; + white-space: nowrap; + text-align: right; + font-size: var(--text-2xs); + color: var(--app-muted); + font-variant-numeric: tabular-nums; } -/* `.merge-resolve` already carries .btn .btn-primary .mini-btn. Those skins fully - define the face; the only thing to guard is that the 28px mini-btn height wins - over the 40px .btn-primary height so it sits flush in the slim bar. */ -.merge-resolve.mini-btn { height: 28px; padding: 0 12px; } -/* ── 2 · GitHub Actions — run logs / artifacts / secrets (views/actions.ts) ──── */ +/* The dispatch modal's workflow-path subtitle. */ +.actions-dispatch-path { + margin: -8px 0 2px; + font-size: var(--text-xs); + color: var(--app-muted); + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); +} -/* The streamed-log panel: an elevated card with a sticky head + a bordered, - recessed scroll body that holds the monospace <pre>. */ -.actions-log-card { - display: flex; - flex-direction: column; - gap: 10px; - margin-top: 12px; - padding: 14px; +/* The run-id copy chip in the detail rail. */ +.det-mono-btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 24px; + padding: 0 9px; border: 1px solid var(--app-border); - border-radius: 12px; + border-radius: var(--r-sm); background: var(--app-elevated); - box-shadow: var(--sheen), var(--shadow-sm); -} -.actions-log-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - min-width: 0; + color: var(--vscode-foreground); + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-size: var(--text-xs); + cursor: pointer; + transition: border-color var(--dur-1) var(--ease), background var(--dur-1) var(--ease); } -.actions-log-title { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; - font-size: var(--text-base); +.det-mono-btn:hover { border-color: var(--accent-line); background: var(--app-hover); } +.det-mono-btn .codicon { font-size: 12px; color: var(--app-muted); } + +/* Jobs / artifacts inherit their existing styles inside det-main; give the + column its rhythm. */ +.det-main .gh-jobs { margin-top: var(--sp-2); } +.det-main .gh-artifacts { margin-top: var(--sp-5); } + +/* ── Project board drag-and-drop ── */ +.gh-board .gh-card[draggable="true"] { cursor: grab; } +.gh-board .gh-card.is-dragging { opacity: 0.45; cursor: grabbing; } +.gh-board .gh-col.is-drop { + outline: 2px dashed color-mix(in srgb, var(--gs-accent) 55%, transparent); + outline-offset: -2px; + background: color-mix(in srgb, var(--gs-accent) 6%, transparent); + border-radius: var(--r-md); +} +/* The placeholder is always in the DOM so an emptied column keeps a drop zone; + it only SHOWS when it's the column's sole child. */ +.gh-col-body .gh-col-empty { display: none; } +.gh-col-body .gh-col-empty:only-child { display: block; } + +/* ── The "?" keyboard cheat sheet ── */ +.shortcuts-card { width: min(var(--modal-lg), 92vw); } +.shortcuts-cols { display: flex; gap: var(--sp-8); flex-wrap: wrap; } +.shortcuts-group { flex: 1 1 200px; min-width: 200px; display: flex; flex-direction: column; gap: 7px; } +.shortcuts-group-title { + margin-bottom: 3px; + font-size: var(--text-2xs); font-weight: 650; + letter-spacing: var(--track-label); + text-transform: uppercase; + color: var(--app-muted); +} +.shortcuts-row { display: flex; align-items: baseline; gap: var(--sp-3); font-size: var(--text-sm); } +.shortcuts-keys { + flex: 0 0 auto; + min-width: 86px; + padding: 2px 7px; + border: 1px solid var(--app-border); + border-radius: var(--r-sm); + background: var(--app-elevated); + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-size: var(--text-2xs); + text-align: center; color: var(--vscode-foreground); } -.actions-log-title .codicon { color: var(--gs-accent-ink, var(--gs-accent)); font-size: 15px; flex: 0 0 auto; } -.actions-log-title .actions-log-name { +.shortcuts-what { color: var(--app-muted); } + +/* ── Release assets: upload header + hover delete ── */ +.rel-assets-head { display: flex; align-items: center; gap: var(--sp-3); margin-top: var(--sp-4); } +.rel-assets-head .group-label { margin: 0; } +.rel-assets-none { padding: var(--sp-2) 0 0; color: var(--app-muted); font-size: var(--text-sm); } +.rel-asset-acts { display: inline-flex; align-items: center; gap: 6px; margin-left: auto; } +.rel-asset-del { opacity: 0; color: var(--app-muted); transition: opacity var(--dur-1) var(--ease), color var(--dur-1) var(--ease); } +.list-row:hover .rel-asset-del, .rel-asset-del:focus-visible { opacity: 1; } +.rel-asset-del:hover { color: var(--status-del); } + +/* ── The log pane (virtualized, ANSI, foldable, live-tail) ── */ +.log-pane { + --log-line-h: 20px; + --log-groupbar-h: 21px; + position: relative; + display: flex; + flex-direction: column; min-width: 0; + margin: var(--sp-2) 0 var(--sp-1); + border: 1px solid var(--app-border); + border-radius: var(--r-md); + background: var(--log-bg, #0b0e14); overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.actions-log-headactions { + /* Was a fixed 380px, so a three-line log still reserved the full height. */ + height: auto; + min-height: 120px; + /* 380px is ~16 lines at a 20px line height, and that IS the "scrolling the + logs is too fast" complaint: a normal trackpad flick moves 2,000-4,000px, + which against a 16-line port is six to twelve screenfuls with nothing to + read on the way past. A log is a document; give it a document's height. */ + max-height: min(62vh, 760px); +} +/* Capped to the visible viewport so the expanded pane's toolbar, body and + "Jump to latest" pill all stay reachable — 78vh from a pane already 320px + down the page ran off the bottom of the window. */ +.log-pane.log-max { max-height: min(70vh, 820px); height: min(70vh, 820px); } +/* Fill mode: the pane IS the page, so it takes its container's height instead + of carrying an opinion about how tall a log ought to be — and the expand + control widens it over the job list rather than growing a box (see the + `.joblog-split:has()` rule). The margins go too: a page does not need to be + inset from itself. */ +.log-pane.log-fill { flex: 1 1 auto; margin: 0; min-height: 0; max-height: none; height: auto; } +.log-pane.log-fill.log-max { max-height: none; height: auto; } +.log-toolbar { flex: 0 0 auto; display: flex; align-items: center; - gap: 8px; + gap: 6px; + padding: 5px 8px; + border-bottom: 1px solid var(--app-border); + /* Sits ON the pane, not on app chrome — the toolbar used to be --app-panel, + which is why the pane's top edge vanished in light theme. */ + background: color-mix(in srgb, var(--log-fg) 7%, var(--log-bg)); + flex-wrap: wrap; + row-gap: 6px; +} +.log-toolbar-spring { flex: 1 1 auto; } +.log-toolbar .gh-search { min-width: 130px; max-width: 200px; height: 24px; } +.log-match-count { font-size: var(--text-2xs); color: var(--app-muted); font-variant-numeric: tabular-nums; } +.log-tool.is-on { color: var(--gs-accent-ink, var(--gs-accent)); background: var(--accent-soft); } +/* A state toggle spells itself out; the transient verbs stay glyphs, split off + by a hairline so the bar reads as [state] | [actions] rather than as five + identical unlabelled squares. */ +.log-tool.has-label { + width: auto; + gap: 5px; + padding: 0 8px; } -.actions-log-close { - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - padding: 0; - border: 1px solid var(--app-border); - border-radius: 8px; - background: var(--app-elevated); - box-shadow: var(--sheen); - color: var(--app-muted); - cursor: pointer; - transition: background var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease), - color var(--dur-1) var(--ease), transform var(--dur-1) var(--ease); +.log-tool-label { font-size: var(--text-2xs); font-weight: 600; letter-spacing: 0.01em; } +.log-toolbar-div { + flex: 0 0 auto; + width: 1px; + align-self: stretch; + margin: 3px 3px; + background: color-mix(in srgb, var(--log-fg) 16%, transparent); } -.actions-log-close .codicon { font-size: 14px; } -.actions-log-close:hover { background: var(--app-hover); border-color: var(--accent-line); color: var(--vscode-foreground); } -.actions-log-close:active { transform: translateY(0.5px); } -.actions-log-close:focus-visible { outline: 2px solid color-mix(in srgb, var(--gs-accent) 70%, transparent); outline-offset: -2px; border-radius: 8px; } -.actions-log-body { - max-height: 460px; - overflow: auto; - padding: 10px 12px; - border: 1px solid var(--app-border); - border-radius: 9px; - background: var(--app-bg); +.log-chip-err { + border: none; + border-radius: 999px; + padding: 2px 9px; + font: inherit; + font-size: var(--text-2xs); + font-weight: 650; + cursor: pointer; + color: var(--status-del); + background: color-mix(in srgb, var(--status-del) 16%, transparent); } -.actions-log { - margin: 0; - font-family: var(--vscode-editor-font-family, ui-monospace, monospace); - font-size: 12px; - line-height: 1.5; - white-space: pre; - tab-size: 2; - -moz-tab-size: 2; - color: var(--vscode-foreground, var(--app-text)); +.log-banner { + flex: 0 0 auto; + padding: 4px 10px; + font-size: var(--text-2xs); + color: var(--status-warn); + background: color-mix(in srgb, var(--status-warn) 10%, transparent); + border-bottom: 1px solid var(--app-border); } - -/* Artifacts — a compact titled list of downloadable build outputs. Rows reuse the - .list-row / .row-meta vocabulary; this only frames the group + its head. */ -.gh-artifacts { +/* Which ##[group] the top of the port is inside. A CI log is mostly group + CONTENTS, so scrolling past the header that named them leaves you reading 400 + lines with no idea which step produced them. */ +/* The scroller plus its two floating overlays. */ +/* min-width: 0 all the way down. `.log-window` is `min-width: max-content` so + that a long line is scrollable rather than wrapped — but a flex item's + default `min-width: auto` refuses to shrink below its content, so that + intrinsic width propagated UP the whole chain instead: one 180-character + line and the log page grew past the window, taking Follow, Copy, Save and + Expand off-screen, and the pane's width oscillated as that line scrolled in + and out of the virtual window. The scroller is what must give. */ +.log-body { + position: relative; + flex: 1 1 auto; + min-height: 0; + min-width: 0; display: flex; - flex-direction: column; - gap: 4px; - margin-top: 12px; -} -.gh-artifacts-head { + /* RESERVED for the group strip. The strip is an opaque overlay pinned to the + top of the scroller, and a log row is 20px — so while it showed, which is + most of a CI log, the first line in the port was completely hidden behind + it and ArrowUp revealed nothing. Reserving the height permanently (rather + than only while the strip shows) keeps the bar from shifting the whole log + by a row every time you scroll into or out of a group, and leaves the + scroller's own coordinate space untouched — the row maths reads + scrollTop / LINE_H and must stay exact. */ + padding-top: var(--log-groupbar-h); +} +.log-groupbar { + position: absolute; + /* Against .log-body's PADDING box, so this sits in the strip reserved above + the scroller rather than on top of its first line. */ + top: 0; + height: var(--log-groupbar-h); + box-sizing: border-box; + left: 0; + right: 0; + z-index: 2; display: flex; align-items: center; - gap: 7px; - padding: 2px 4px 6px; - font-size: 10.5px; - font-weight: 600; - letter-spacing: var(--track-label); - text-transform: uppercase; - color: var(--app-muted); + gap: 6px; + width: 100%; + padding: 2px var(--sp-3); + border: none; + border-bottom: 1px solid color-mix(in srgb, var(--log-fg, #c8ccd4) 18%, transparent); + background: color-mix(in srgb, var(--log-fg, #c8ccd4) 10%, var(--log-bg, #0b0e14)); + color: color-mix(in srgb, var(--log-fg, #c8ccd4) 80%, transparent); + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-size: var(--text-xs); + text-align: left; + cursor: pointer; } -.gh-artifacts-head .codicon { font-size: 14px; color: var(--gs-accent-ink, var(--gs-accent)); } -.gh-artifact-row { align-items: center; } -.gh-artifact-row .codicon { color: var(--app-muted); } +.log-groupbar:hover { color: var(--log-fg, #c8ccd4); } +.log-groupbar-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -/* Secrets — a card grouping repo/env secret sections; each section is a key/value - table. Values are never shown (write-only), so rows are calm name + meta rows. */ -.actions-secrets-card { - display: flex; - flex-direction: column; - gap: 14px; - margin-top: 12px; - padding: 14px; - border: 1px solid var(--app-border); - border-radius: 12px; - background: var(--app-elevated); - box-shadow: var(--sheen), var(--shadow-sm); +/* Where the errors ARE, over the whole log rather than the screenful you can + see. A 20,000-line log has no shape without it: you scroll and hope. */ +.log-errmap { + position: absolute; + /* Below the group strip. An absolutely positioned box is laid out against its + ancestor's PADDING box, so `top: 2px` still measured from the top of + .log-body while the scroller now starts a strip's height lower — leaving + the map 19px taller than the log it maps and every tick a few pixels above + the line it points at. A map that does not line up is worse than none. */ + top: calc(var(--log-groupbar-h) + 2px); + right: 2px; + bottom: 2px; + width: 8px; + pointer-events: none; + z-index: 3; +} +.log-errtick { + position: absolute; + right: 0; + width: 8px; + height: 3px; + padding: 0; + border: none; + border-radius: 1px; + background: var(--log-c1, #e06c75); + opacity: 0.85; + pointer-events: auto; + cursor: pointer; +} +.log-errtick:hover { opacity: 1; width: 12px; right: 0; } + +.log-scroll { + flex: 1 1 auto; + min-width: 0; + overflow: auto; + /* The virtualized window resizes its two spacer divs on EVERY repaint, and + Chrome's scroll anchoring "helpfully" compensates by moving the scroll + offset — so the log slid on its own after a wheel gesture, by up to a + screenful, with nothing in our code writing scrollTop. A virtualized list + must opt out: the spacers are not content, they are the scrollbar's idea of + content, and anchoring to them is anchoring to nothing. */ + overflow-anchor: none; + /* The virtualized window resizes its two spacer divs on EVERY repaint, and + Chrome's scroll anchoring "helpfully" compensates by moving the scroll + offset — so the log slid on its own after a wheel gesture, by up to a + screenful, with nothing in our code writing scrollTop. A virtualized list + must opt out: the spacers are not content, they are the scrollbar's idea of + content, and anchoring to them is anchoring to nothing. */ + /* Reaching the end of a log must not start scrolling the PAGE behind it. + Without this, overscrolling at the bottom carried the whole run page away + and the log you were reading left the window. */ + overscroll-behavior: contain; +} +/* The scroller takes the keyboard, so it needs to show that it has it. */ +.log-scroll:focus-visible { + outline: 2px solid var(--gs-focus-ring, var(--gs-accent)); + outline-offset: -2px; } -.actions-secrets-head { +/* The line a failure jump landed on, flashed so the eye can find it in a wall + of monospace. */ +.log-line.is-hit { + background: color-mix(in srgb, var(--gs-accent) 22%, transparent); +} +.log-window { min-width: max-content; } +.log-spacer { overflow-anchor: none; } +/* Said, not implied. See logView's render(). */ +.log-empty { + padding: var(--sp-6) var(--sp-3); + color: color-mix(in srgb, var(--log-fg, #c8ccd4) 55%, transparent); + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-size: var(--text-sm); + font-style: italic; +} +.log-line { display: flex; align-items: center; - justify-content: space-between; - gap: 12px; - min-width: 0; + height: var(--log-line-h); + padding: 0 10px 0 0; + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-size: 12px; + line-height: var(--log-line-h); + white-space: pre; + color: var(--log-fg, #c8ccd4); } -.actions-secrets-head .actions-secrets-title { +.log-num { + flex: 0 0 auto; + width: 56px; + padding-right: 10px; + text-align: right; + /* 32% of the ink over the log's own ground is ~2:1 — a line number you can + see is there but cannot read. These are reference marks people call out to + each other ("it fails at 412"), so they have to be legible. */ + color: color-mix(in srgb, var(--log-fg, #c8ccd4) 62%, transparent); + user-select: none; + font-variant-numeric: tabular-nums; +} +.log-ts { flex: 0 0 auto; margin-right: 10px; color: color-mix(in srgb, var(--log-fg, #c8ccd4) 68%, transparent); font-variant-numeric: tabular-nums; } +.log-text { flex: 0 0 auto; } +.log-chev { flex: 0 0 auto; margin-right: 4px; font-size: 12px; color: var(--app-muted); } +.log-groupline { cursor: pointer; font-weight: 600; } +.log-groupline:focus-visible { outline: 2px solid var(--gs-focus-ring); outline-offset: -2px; } +.log-groupline:hover { background: color-mix(in srgb, var(--log-fg, #fff) 6%, transparent); } +.log-k-error { background: color-mix(in srgb, var(--status-del) 14%, transparent); } +.log-k-error .log-text { color: var(--status-del); } +.log-k-warning { background: color-mix(in srgb, var(--status-warn) 10%, transparent); } +.log-k-warning .log-text { color: var(--status-warn); } +.log-k-notice .log-text { color: var(--gs-accent-2); } +.log-k-command .log-text, .log-k-section .log-text { color: color-mix(in srgb, var(--log-fg, #c8ccd4) 62%, transparent); } +.log-k-debug .log-text { color: color-mix(in srgb, var(--log-fg, #c8ccd4) 45%, transparent); font-style: italic; } +.log-hit { background: color-mix(in srgb, var(--gs-accent) 45%, transparent); border-radius: 2px; } +.log-jump { + position: absolute; + right: 14px; + bottom: 12px; + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 11px; + border: 1px solid var(--app-border); + border-radius: 999px; + background: var(--app-elevated); + color: var(--vscode-foreground); + font: inherit; + font-size: var(--text-xs); + cursor: pointer; + box-shadow: var(--shadow-md); +} +.log-jump .codicon { font-size: 12px; } +.gh-job-logslot:empty { display: none; } +.gh-job-logslot { padding: 0 10px 10px; } + +/* ANSI 16-color palette — terminal-legible on the pane's own dark ground. */ +.log-pane { + /* Was #0d1016 — byte-identical to --app-bg, so the terminal had no ground of + its own and dissolved into the page. A shade darker than the app reads as + "this is output", the way a terminal does. */ + --log-bg: #06080d; + --log-fg: #c9ced8; + --log-c0: #3b4048; --log-c1: #e06c75; --log-c2: #98c379; --log-c3: #d19a66; + --log-c4: #61afef; --log-c5: #c678dd; --log-c6: #56b6c2; --log-c7: #abb2bf; + --log-c8: #5c6370; --log-c9: #ff7a85; --log-c10: #b5e890; --log-c11: #e5c07b; + --log-c12: #8ac6ff; --log-c13: #de9df3; --log-c14: #6fdbe8; --log-c15: #ffffff; + /* The TRUE ANSI palette, the same in both themes. + `--log-c*` is adjusted for the ground the text sits on — in light, ANSI + "bright white" as a FOREGROUND has to be dark or it is invisible on white + paper. But the moment a span sets its own BACKGROUND it stops sitting on + the page and becomes a block of terminal colour, and the author's fg/bg + pair has to keep the contrast they chose. Light mapped both c0 (black) and + c15 (bright white) to #24292f, so "bright white on black" — which CI tools + really do emit — rendered as one solid invisible block, 1.00:1. */ + --log-t0: #3b4048; --log-t1: #e06c75; --log-t2: #98c379; --log-t3: #d19a66; + --log-t4: #61afef; --log-t5: #c678dd; --log-t6: #56b6c2; --log-t7: #abb2bf; + --log-t8: #5c6370; --log-t9: #ff7a85; --log-t10: #b5e890; --log-t11: #e5c07b; + --log-t12: #8ac6ff; --log-t13: #de9df3; --log-t14: #6fdbe8; --log-t15: #ffffff; +} +body.vscode-light .log-pane { + /* Was #fafbfc against a #f7f9fb toolbar — a 1/255 difference, so the pane, + its toolbar and the page were one flat field. */ + --log-bg: #ffffff; + --log-fg: #24292f; + --log-c0: #24292f; --log-c1: #cf222e; --log-c2: #116329; --log-c3: #953800; + --log-c4: #0969da; --log-c5: #8250df; --log-c6: #1b7c83; --log-c7: #57606a; + --log-c8: #6e7781; --log-c9: #a40e26; --log-c10: #1a7f37; --log-c11: #9a6700; + --log-c12: #218bff; --log-c13: #a475f9; --log-c14: #3192aa; --log-c15: #24292f; +} +.log-fg-0 { color: var(--log-c0); } .log-fg-1 { color: var(--log-c1); } +.log-fg-2 { color: var(--log-c2); } .log-fg-3 { color: var(--log-c3); } +.log-fg-4 { color: var(--log-c4); } .log-fg-5 { color: var(--log-c5); } +.log-fg-6 { color: var(--log-c6); } .log-fg-7 { color: var(--log-c7); } +.log-fg-8 { color: var(--log-c8); } .log-fg-9 { color: var(--log-c9); } +.log-fg-10 { color: var(--log-c10); } .log-fg-11 { color: var(--log-c11); } +.log-fg-12 { color: var(--log-c12); } .log-fg-13 { color: var(--log-c13); } +.log-fg-14 { color: var(--log-c14); } .log-fg-15 { color: var(--log-c15); } +.log-bg-0 { background: var(--log-t0); } .log-bg-1 { background: var(--log-t1); } +.log-bg-2 { background: var(--log-t2); } .log-bg-3 { background: var(--log-t3); } +.log-bg-4 { background: var(--log-t4); } .log-bg-5 { background: var(--log-t5); } +.log-bg-6 { background: var(--log-t6); } .log-bg-7 { background: var(--log-t7); } +/* 8–15 existed in the PARSER and nowhere here: `clsOf()` emits `log-bg-N` for + N in 0..15 — SGR 100–107 sets 8..15 directly, and any bright 256-colour or + truecolor background maps into that range — so every bright background was + silently dropped, and a line that relied on one to be readable was not. */ +.log-bg-8 { background: var(--log-t8); } .log-bg-9 { background: var(--log-t9); } +.log-bg-10 { background: var(--log-t10); } .log-bg-11 { background: var(--log-t11); } +.log-bg-12 { background: var(--log-t12); } .log-bg-13 { background: var(--log-t13); } +.log-bg-14 { background: var(--log-t14); } .log-bg-15 { background: var(--log-t15); } +/* A background with NO foreground beside it. + `clsOf()` emits `log-fg-N` only when the code actually set one, so + `ESC[41m ERROR ESC[0m` — background set, foreground left default — produced a + span carrying `log-bg-1` and nothing else. The block is painted from the + theme-INDEPENDENT true palette (deliberately: ANSI red is red in both + themes), while the text inside it fell through to the page's default ink — + which is near-black in the light theme. Dark text on a dark ANSI block. + The terminal convention is the palette's own default foreground. */ +/* The default ink is the palette's BLACK, not its white. + This is a dark-theme palette: measured against every one of the sixteen + blocks, `--log-t0` wins on fourteen of them and `--log-t15` on two — the + greys at 0 and 8. The first version of this rule had it the other way round + and left white on bright green, bright yellow and the rest at 2.4:1. With the + split this way the worst pairing in the set is 3.26:1. */ +[class*="log-bg-"]:not([class*="log-fg-"]) { color: var(--log-t0); } +.log-bg-0:not([class*="log-fg-"]), +.log-bg-8:not([class*="log-fg-"]) { color: var(--log-t15); } + +/* A foreground sitting ON one of those blocks uses the true palette too — two + classes, so it outranks the plain `.log-fg-N` above. */ +.log-fg-0[class*="log-bg-"] { color: var(--log-t0); } +.log-fg-1[class*="log-bg-"] { color: var(--log-t1); } +.log-fg-2[class*="log-bg-"] { color: var(--log-t2); } +.log-fg-3[class*="log-bg-"] { color: var(--log-t3); } +.log-fg-4[class*="log-bg-"] { color: var(--log-t4); } +.log-fg-5[class*="log-bg-"] { color: var(--log-t5); } +.log-fg-6[class*="log-bg-"] { color: var(--log-t6); } +.log-fg-7[class*="log-bg-"] { color: var(--log-t7); } +.log-fg-8[class*="log-bg-"] { color: var(--log-t8); } +.log-fg-9[class*="log-bg-"] { color: var(--log-t9); } +.log-fg-10[class*="log-bg-"] { color: var(--log-t10); } +.log-fg-11[class*="log-bg-"] { color: var(--log-t11); } +.log-fg-12[class*="log-bg-"] { color: var(--log-t12); } +.log-fg-13[class*="log-bg-"] { color: var(--log-t13); } +.log-fg-14[class*="log-bg-"] { color: var(--log-t14); } +.log-fg-15[class*="log-bg-"] { color: var(--log-t15); } +.log-b { font-weight: 700; } .log-dim { opacity: 0.6; } +.log-i { font-style: italic; } .log-u { text-decoration: underline; } + +/* ── The PR review modal (verdict picker + body) ── */ +.review-modal { width: min(var(--modal-md), 92vw); } +.review-verdicts { display: flex; flex-direction: column; gap: 6px; } +.review-verdict { display: flex; align-items: center; - gap: 8px; - font-size: var(--text-base); - font-weight: 650; + gap: 11px; + padding: 9px 12px; + border: 1px solid var(--app-border); + border-radius: var(--r-md); + background: var(--app-panel); color: var(--vscode-foreground); + font: inherit; + text-align: left; + cursor: pointer; + transition: border-color var(--dur-1) var(--ease), background var(--dur-1) var(--ease); } -.actions-secrets-head .codicon { color: var(--gs-accent-ink, var(--gs-accent)); font-size: 15px; } -.actions-secrets-section { - display: flex; - flex-direction: column; - gap: 2px; +.review-verdict:hover { background: var(--app-hover); } +.review-verdict.is-selected { + border-color: var(--accent-line, var(--gs-accent)); + background: var(--accent-soft); } -.actions-kv-head { +.review-verdict-lead { flex: 0 0 auto; display: inline-flex; color: var(--app-muted); } +.review-verdict.is-selected .review-verdict-lead { color: var(--gs-accent-ink, var(--gs-accent)); } +.review-verdict-text { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px; } +.review-verdict-label { font-size: var(--text-base); font-weight: 600; } +.review-verdict-hint { font-size: var(--text-xs); color: var(--app-muted); } +/* The trailing check only shows on the selected verdict. */ +.review-verdict > .glyph:last-child { opacity: 0; color: var(--gs-accent-ink, var(--gs-accent)); } +.review-verdict.is-selected > .glyph:last-child { opacity: 1; } + +/* ── My Work group headers ── */ +.mywork-group { display: flex; align-items: center; - justify-content: space-between; - gap: 10px; - padding: 2px 4px 6px; -} -.actions-kv-headtitle { - font-size: 10.5px; - font-weight: 600; + gap: 7px; + margin: var(--sp-5) 0 var(--sp-2); + padding: 0 var(--sp-3); + font-size: var(--text-2xs); + font-weight: 650; letter-spacing: var(--track-label); text-transform: uppercase; color: var(--app-muted); } -.actions-kv-list { - display: flex; - flex-direction: column; - border: 1px solid var(--app-border); - border-radius: 9px; - overflow: hidden; - background: var(--app-bg); +.sec-list > .mywork-group:first-child { margin-top: var(--sp-2); } +.mywork-group .codicon { font-size: 13px; } +.mywork-group-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 16px; + padding: 0 5px; + border-radius: 999px; + background: color-mix(in srgb, var(--app-muted) 18%, transparent); + font-variant-numeric: tabular-nums; } -.actions-kv-row { +/* A group header interrupts the row run — suppress the hairline right below it. */ +.mywork-group + .sec-row::before { background: transparent; } + +/* ── Inbox single-line rows (the full page; the bell popover keeps cards) ── */ +.notif-row.notif-line { display: flex; + flex-direction: row; align-items: center; - justify-content: space-between; - gap: 12px; - min-width: 0; - padding: 8px 11px; - font-size: var(--text-sm); - color: var(--vscode-foreground); - transition: background var(--dur-1) var(--ease); + gap: 10px; + min-height: 40px; + padding: 0 var(--sp-3); + border: none; + border-radius: var(--r-sm); + cursor: pointer; } -.actions-kv-row + .actions-kv-row { border-top: 1px solid var(--app-border); } -.actions-kv-row .actions-kv-name { +.notif-line .notif-line-title { + flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-family: var(--vscode-editor-font-family, ui-monospace, monospace); - font-weight: 550; + font-size: var(--text-base); + font-weight: 600; + color: var(--vscode-foreground); } -.actions-kv-row .actions-kv-meta { - flex: 0 0 auto; - font-size: var(--text-xs); +.notif-line.notif-read .notif-line-title { font-weight: 450; color: var(--app-muted); } +/* The base .gh-pill self-aligns to flex-start (for card layouts) — in a + single-line row it must center like everything else. */ +.notif-line .gh-pill { margin-top: 0; flex: 0 0 auto; align-self: center; } +/* The hover cluster is Open, plus Mark read only while a thread is unread — + two widths, so a read row's time, reason and repo columns landed ~79px right + of an unread one's. The cluster reserves the wider of the two. */ +.notif-line .row-actions { flex: 0 0 138px; justify-content: flex-end; } +/* Time keeps a fixed right slot so rows align like the section lists. */ +.notif-line .sec-row-time { min-width: 46px; } + +/* End-of-list note for capped (paged) lists. */ +.sec-cap-note { + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: var(--sp-3) 0 var(--sp-2); color: var(--app-muted); + font-size: var(--text-xs); +} +.sec-cap-note .codicon { font-size: 13px; } + +/* A thread marked read while the "Unread only" filter is on. It keeps its slot + so nothing moves under the pointer, and reads as spent rather than present. */ +.notif-row.notif-leaving { + opacity: 0.5; + background: repeating-linear-gradient( + -45deg, + transparent 0 8px, + color-mix(in srgb, var(--vscode-foreground) 3%, transparent) 8px 16px + ); +} +.notif-row.notif-leaving:hover { opacity: 0.72; } + +/* Result counts sit beside controls people aim at, so a count going from 9 to + 10 must not widen its own box and shove the segment under the finger. */ +.gh-head-count, .gh-count, .sec-count, .notif-summary-text, .gh-seg-count { font-variant-numeric: tabular-nums; } -.actions-kv-row:hover { background: var(--app-hover); } -.actions-kv-empty { - padding: 14px 12px; - font-size: var(--text-sm); +.gh-head-count { min-width: 2.4em; text-align: center; } + +/* Match stepping sits with the counter it steps through, not with the pane's + own actions on the far side of the spring. */ +.log-match-step { margin-left: 2px; } +.log-match-step:first-of-type { margin-left: 6px; } + +/* A step still running: the bar is a measurement in progress, so it reads as + hatched rather than solid. Static hatching, not an animation — an endless + animation would keep the pane repainting for as long as the page is open. */ +.gh-step-bar.is-running { + background-image: repeating-linear-gradient( + -45deg, + color-mix(in srgb, var(--status-mod) 70%, transparent) 0 5px, + color-mix(in srgb, var(--status-mod) 30%, transparent) 5px 10px + ); +} + +/* The +/− pair is one figure, not two words: it wrapped onto a second line in + every row of a narrow file list, doubling every row's height. */ +.gh-adds { flex: 0 0 auto; white-space: nowrap; font-variant-numeric: tabular-nums; } + +/* The Go-to-file cursor. Enter has always opened one of these rows; now you can + see which. */ +.gotofile-row.is-sel { + background: var(--app-active); + box-shadow: inset 2px 0 0 var(--gs-accent); +} + +/* The repository above a file's own title — context, set below the title's + weight so the file stays the subject of the page. */ +.explore-repo-eyebrow { + display: block; + font-size: var(--text-xs); color: var(--app-muted); - text-align: center; + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + margin-bottom: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -/* ── 4 · Design-audit CSS-only consistency fixes ────────────────────────────── */ +/* The Assistant launcher, while you are in the Assistant. */ +.topbar-assistant.is-current { + background: var(--app-active); + color: var(--gs-accent-ink, var(--gs-accent)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--gs-accent) 35%, transparent); +} +.topbar-assistant.is-current .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } -/* Notifications: dim the WHOLE read row (was an inline style.opacity=0.72), so the - view can drop the inline rule and just toggle .notif-read. The existing - .notif-read title/icon rules above still apply for finer recede. */ -.notif-read { opacity: 0.72; } +/* "Create pull request" appears only once a comparison resolves, and hiding it + until then made the whole toolbar jump 171px sideways the moment the compare + landed — under a pointer already on its way to something else. It keeps its + seat and fades in instead. */ +.cmp-pr-btn { transition: opacity var(--dur-1) var(--ease), visibility var(--dur-1); } +.cmp-pr-btn[hidden] { + display: inline-flex; + visibility: hidden; + opacity: 0; + pointer-events: none; +} -/* Gists: a semantic lead-icon colour (member of the .gh-lead-* family) so the - Gists view can drop its inline style.color on the leading code glyph. */ -.gh-lead-gist { color: var(--gs-accent-ink, var(--gs-accent)); } +/* The error variant of the diff placeholder wears the warning ink, so a failed + load never reads as an instruction to select something. */ +.diff-empty.is-error .list-empty-badge { + background: color-mix(in srgb, var(--status-del) 12%, transparent); + border-color: color-mix(in srgb, var(--status-del) 30%, transparent); + box-shadow: none; +} +.diff-empty.is-error .list-empty-badge .codicon { color: var(--status-del); } -/* Empty-state CTA vs error-state Retry can swap between a 40px .btn-primary and a - 28px .mini-btn; pin a shared min-height on the action slot so the layout doesn't - jump as a view moves between its empty and error states. */ -.list-empty-action { min-height: 40px; display: inline-flex; align-items: center; } +/* Inside a BAR the primary button keeps its sheen but drops its outer glow: a + 22px purple bloom on a 40px-tall bar spilled past the bar's bottom hairline + and tinted the first rows of the view beneath it. Every bar that pins a + primary action against a hairline gets the same treatment. */ +.topbar .btn-primary, +.topbar .btn-primary:hover, +.det-topbar .btn-primary, +.det-topbar .btn-primary:hover, +.gh-head .btn-primary, +.gh-head .btn-primary:hover { + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22), 0 1px 2px rgba(0, 0, 0, 0.2); +} -/* ── 5 · Commit composer depth (Changes view, renderer.ts) ──────────────────── */ -/* A horizontal row of small toggle chips beneath the commit message box. */ -.dc-options { +/* A checkbox's own label: sentence case at reading size, unlike the uppercase + micro-caps that head a GROUP of fields. */ +.settings-check-title { + font-size: var(--text-sm); + font-weight: 550; + color: var(--vscode-foreground); +} + +/* Refresh is the same control on every screen. It was three: 28x28 + `.topbar-icon` in Code and Changes, 30x30 `.icon-btn.gh-refresh` in the twelve + GitHub views, and the graph's own — so the one button people reach for most + changed size and weight depending on which screen they were on. */ +.gh-refresh { width: 28px; height: 28px; } +/* Busy, but still focusable — see ghHeader's setBusy. `disabled` would drop the + button out of the tab order mid-refresh and take the keyboard with it. */ +.gh-refresh[aria-disabled="true"] { opacity: 0.55; pointer-events: none; } +.gh-refresh .codicon { font-size: 15px; } + +/* Why the last submit failed, shown inside the form that still holds your text + — rather than a toast over a screen that has already thrown it away. */ +.modal-note-error { + color: var(--status-del); + background: color-mix(in srgb, var(--status-del) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--status-del) 30%, transparent); + border-radius: var(--r-sm); + padding: var(--sp-2) var(--sp-3); +} + +/* "Showing the first 400 of 912 commits." A capped list that does not say it is + capped reads as the whole set — and the count beside it was the cap. */ +.list-cap-note { + padding: var(--sp-3) var(--sp-4); + font-size: var(--text-xs); + color: var(--app-muted); + border-top: 1px solid var(--app-border); +} + +/* ── The commit page ──────────────────────────────────────────────────────── + The diff IS the page. + + The first version spent 251px of a 913px window on a message and a stat bar + before the diff began, then gave the diff 504px of ~1100px because a file + list and a 264px properties rail sat beside it — the most important thing on + the screen with less than half the room. Now the header is two lines, the + rail is gone (its verbs are one menu in the top bar), and the split FILLS + whatever is left rather than being a fixed box inside a scrolling page. */ +.cmt-view .det-main { max-width: none; } +/* The shell caps its body to "reading column + rail" and centres it, which is + right for a document and wrong for a page whose content is a diff: it left a + 167px gutter on the left and the diff short of the window on the right. */ +.cmt-view .det-body { max-width: none; margin-inline: 0; } +.cmt-view .det-body { padding-bottom: var(--sp-4); } +/* The page does not scroll; the diff and the file list do. */ +.cmt-view .det-scroll { overflow: hidden; display: flex; min-height: 0; } +.cmt-view .det-body { + flex: 1 1 auto; + min-height: 0; + /* STRETCH, not flex-start. The shared detail shell top-aligns its columns, + which is right for a properties rail beside a document and wrong here — + it left the diff at its content height (210px) in a 913px window. */ + align-items: stretch; +} +.cmt-view .det-main { display: flex; flex-direction: column; min-height: 0; } + +.cmt-head { flex: 0 0 auto; } +.cmt-subject { + margin: 0 0 var(--sp-1); + font-size: var(--text-lg); + font-weight: 600; + line-height: 1.3; + letter-spacing: -0.01em; +} + +/* Who wrote it and who committed it, on ONE line. The committer half appears + only when it differs — which is the case that matters (a rebase, a + cherry-pick, a maintainer applying a patch) and the case a single "author" + line hides. */ +.cmt-identity { display: flex; flex-wrap: wrap; align-items: center; - gap: 8px; - margin: 6px 0; + gap: var(--sp-2) var(--sp-3); + font-size: var(--text-sm); + color: var(--app-muted); } -/* A small pill toggle — a subtler sibling of .mini-btn (muted face at rest). The - Amend / Sign-off switches carry role="switch" + aria-checked; the .is-on class - and aria-checked are flipped together, so we honour both for the ON face. */ -.dc-toggle { - display: inline-flex; +.cmt-who { display: inline-flex; align-items: center; gap: var(--sp-2); } +.cmt-who-name { font-weight: 600; color: var(--app-text); } + +/* Where it lives, whether it is a merge, which refs sit on it, and the way in + to a long description — one line, so none of it costs the diff any height. */ +.cmt-facts { + display: flex; + flex-wrap: wrap; align-items: center; - gap: 6px; - height: 26px; - padding: 0 10px; + gap: var(--sp-2) var(--sp-3); + margin: var(--sp-2) 0 var(--sp-3); + font-size: var(--text-xs); + color: var(--app-muted); +} +.cmt-where { color: var(--app-muted); } +.cmt-fact-merge { color: var(--gs-accent); } +.cmt-ref { + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + padding: 1px var(--sp-2); + border-radius: var(--r-sm); border: 1px solid var(--app-border); - border-radius: 999px; - background: var(--app-elevated); - box-shadow: var(--sheen); color: var(--app-muted); - font-family: inherit; +} +.cmt-ref.is-currentHead { border-color: var(--gs-accent); color: var(--gs-accent); font-weight: 600; } +.cmt-ref.is-tag { border-style: dashed; } +.cmt-body-toggle { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 1px var(--sp-2); + border: none; + border-radius: var(--r-sm); + background: none; + color: var(--app-muted); + font: inherit; font-size: var(--text-xs); - font-weight: 600; cursor: pointer; - transition: background var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease), - color var(--dur-1) var(--ease), transform var(--dur-1) var(--ease); } -.dc-toggle .glyph, -.dc-toggle .codicon { font-size: 14px; flex: 0 0 auto; } -.dc-toggle:hover { background: var(--app-hover); border-color: var(--accent-line); color: var(--vscode-foreground); } -.dc-toggle:active { transform: translateY(0.5px); } -.dc-toggle:focus-visible { outline: 2px solid color-mix(in srgb, var(--gs-accent) 70%, transparent); outline-offset: -2px; border-radius: 999px; } -.dc-toggle.is-on, -.dc-toggle[aria-checked="true"] { - color: var(--gs-accent-ink, var(--gs-accent)); - border-color: color-mix(in srgb, var(--gs-accent) 42%, var(--app-border)); - background: color-mix(in srgb, var(--gs-accent) 14%, transparent); +.cmt-body-toggle:hover { background: var(--app-hover); color: var(--app-text); } +.cmt-body { + flex: 0 0 auto; + max-height: 30vh; + overflow-y: auto; + margin: 0 0 var(--sp-3); + padding: var(--sp-3); + border: 1px solid var(--app-border); + border-radius: var(--r-md); + background: var(--app-panel); } -.dc-toggle.is-on .glyph, -.dc-toggle.is-on .codicon, -.dc-toggle[aria-checked="true"] .glyph, -.dc-toggle[aria-checked="true"] .codicon { color: var(--gs-accent-ink, var(--gs-accent)); } -.dc-toggle.is-on:hover, -.dc-toggle[aria-checked="true"]:hover { - background: color-mix(in srgb, var(--gs-accent) 20%, transparent); - border-color: color-mix(in srgb, var(--gs-accent) 55%, var(--app-border)); + +/* Files beside their diff, filling the window. */ +.cmt-split { + flex: 1 1 auto; + min-height: 0; + display: flex; + align-items: stretch; + gap: var(--sp-3); +} +.cmt-listcol { + flex: 0 0 260px; + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--sp-2); + min-height: 0; +} +.cmt-files { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 1px; + border: 1px solid var(--app-border); + border-radius: var(--r-md); + padding: var(--sp-1); + background: var(--app-panel); +} +.cmt-diff { + flex: 1 1 auto; + min-width: 0; + min-height: 0; + border: 1px solid var(--app-border); + border-radius: var(--r-md); + overflow: hidden; } -/* Co-author chips below the toggles. */ -.dc-coauthors { + +.cmt-statbar { display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px; - margin: 2px 0 6px; + align-items: baseline; + gap: var(--sp-2); + font-size: var(--text-xs); } -.dc-coauthor-chip { - display: inline-flex; +.cmt-stat-files { font-weight: 600; color: var(--app-text); } +.cmt-stat-add { color: var(--status-add); font-variant-numeric: tabular-nums; } +.cmt-stat-del { color: var(--status-del); font-variant-numeric: tabular-nums; } +.cmt-stat-bin { color: var(--app-muted); } + +.cmt-file { + display: grid; + grid-template-columns: 14px 1fr auto; align-items: center; - gap: 4px; - max-width: 100%; - min-width: 0; - height: 22px; - padding: 0 4px 0 9px; - border: 1px solid var(--app-border); - border-radius: 999px; - background: var(--app-panel); - color: var(--vscode-foreground); - font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + gap: var(--sp-2); + width: 100%; + padding: 3px var(--sp-2); + border: none; + border-radius: var(--r-sm); + background: none; + color: inherit; + font: inherit; font-size: var(--text-xs); + text-align: left; + cursor: pointer; } -.dc-coauthor-chip > span { - min-width: 0; +.cmt-file:hover { background: var(--app-hover); } +.cmt-file.is-current { background: var(--app-active); } +.cmt-file-status { + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-weight: 600; + text-align: center; +} +.cmt-file-status.is-add { color: var(--status-add); } +.cmt-file-status.is-del { color: var(--status-del); } +.cmt-file-status.is-mod { color: var(--status-mod); } +.cmt-file-status.is-ren { color: var(--gs-accent); } +.cmt-file-path { + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -/* The chip's ✕ remove button — tiny + muted, reddening on hover (row-btn sizing). */ -.dc-chip-x { +.cmt-file-counts { + display: flex; + gap: 4px; + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-variant-numeric: tabular-nums; +} +.cmt-file-add { color: var(--status-add); } +.cmt-file-del { color: var(--status-del); } +.cmt-file-bin { color: var(--app-muted); } +.cmt-sha { font-family: var(--vscode-editor-font-family, ui-monospace, monospace); } + +/* The directory every file in this commit shares, shown once so the rows can + spend their width on the filename instead of repeating the path. */ +.cmt-prefix { + display: flex; + align-items: center; + gap: var(--sp-2); + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-size: var(--text-xs); + color: var(--app-muted); + margin-bottom: var(--sp-2); +} + +/* The org repo row's overflow. + Replaces a pair of hover-revealed text buttons that were 129px wide over a + 441px card, overlaying 128px of the description — and revealed by the same + gesture that makes you look at the card, so the text vanished exactly when + you went to read it. This reserves its width permanently instead: the row + never reflows, and the actions are discoverable without hovering to find out + they exist. */ +.gh-org-grid .row-more { + flex: 0 0 auto; + width: 26px; + height: 26px; display: inline-flex; align-items: center; justify-content: center; - width: 16px; - height: 16px; - flex: 0 0 auto; - padding: 0; border: none; - border-radius: 999px; - background: transparent; + border-radius: var(--r-sm); + background: none; color: var(--app-muted); cursor: pointer; - transition: background var(--dur-1) var(--ease), color var(--dur-1) var(--ease); -} -.dc-chip-x .codicon { font-size: 11px; } -.dc-chip-x:hover { color: var(--status-del); background: color-mix(in srgb, var(--status-del) 16%, transparent); } -.dc-chip-x:focus-visible { outline: 2px solid color-mix(in srgb, var(--gs-accent) 70%, transparent); outline-offset: 1px; } -/* The "Create pull request" CTA in the Changes toolbar — a .mini-btn with a quiet - accent lean so it reads as the forward action without shouting like a primary. */ -.dc-createpr { - color: var(--gs-accent-ink, var(--gs-accent)); - border-color: color-mix(in srgb, var(--gs-accent) 38%, var(--app-border)); } -.dc-createpr .glyph, -.dc-createpr .codicon { color: var(--gs-accent-ink, var(--gs-accent)); } -.dc-createpr:hover { - background: color-mix(in srgb, var(--gs-accent) 13%, transparent); - border-color: color-mix(in srgb, var(--gs-accent) 55%, var(--app-border)); +.gh-org-grid .row-more:hover { background: var(--app-hover); color: var(--app-text); } +.gh-org-grid .row-more:focus-visible { + outline: 2px solid var(--gs-focus-ring, var(--gs-accent)); + outline-offset: -2px; } -/* ── 6 · Op-state banner — merge/rebase in progress (Changes view) ──────────── */ -/* Pinned at the top of the Changes view when a merge/rebase/cherry-pick is mid-op; - a soft amber "attention" wash with the Abort/Continue actions on the right. */ -.dc-opbanner { +/* ── The shared markdown editor ───────────────────────────────────────────── + Release notes, issue bodies and comments were each a bare textarea with no + preview and no toolbar. One component now, and its preview renders through + the same `renderMarkdown` as the published body, so the two cannot drift. */ +.md-editor { display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - flex-wrap: wrap; - margin-bottom: 10px; - padding: 10px 12px; - border: 1px solid color-mix(in srgb, var(--status-warn) 38%, var(--app-border)); - border-left: 3px solid var(--status-warn); - border-radius: 10px; - background: color-mix(in srgb, var(--status-warn) 12%, transparent); + flex-direction: column; + border: 1px solid var(--app-border); + border-radius: var(--r-md); + background: var(--app-bg); + overflow: hidden; } -.dc-opbanner-text { +.md-head { display: flex; align-items: center; - gap: 8px; - min-width: 0; - flex: 1 1 auto; + gap: var(--sp-2); + padding: 4px 6px; + border-bottom: 1px solid var(--app-border); + background: var(--app-panel); } -.dc-opbanner-text .glyph, -.dc-opbanner-text > .codicon { flex: 0 0 auto; color: var(--status-warn); } -.dc-opbanner-text .codicon { font-size: 15px; } -.dc-opbanner-strong { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.md-tabs { display: flex; gap: 2px; } +.md-tab { + padding: 3px 10px; + border: none; + border-radius: var(--r-sm); + background: none; + color: var(--app-muted); + font: inherit; font-size: var(--text-sm); - font-weight: 600; - color: var(--vscode-foreground); + cursor: pointer; } -.dc-opbanner-actions { - display: flex; +.md-tab:hover { color: var(--app-text); background: var(--app-hover); } +.md-tab.is-active { color: var(--app-text); background: var(--app-active); font-weight: 600; } +.md-toolbar { display: flex; gap: 1px; margin-left: auto; } +.md-tool { + width: 26px; + height: 24px; + display: inline-flex; align-items: center; - gap: 8px; + justify-content: center; + border: none; + border-radius: var(--r-sm); + background: none; + color: var(--app-muted); + cursor: pointer; +} +.md-tool:hover { background: var(--app-hover); color: var(--app-text); } +.md-text { + border: none; + outline: none; + resize: none; + padding: var(--sp-3); + background: none; + color: var(--app-text); + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-size: var(--text-sm); + line-height: 1.55; + min-height: 120px; +} +.md-preview { padding: var(--sp-3); overflow-y: auto; } +.md-preview-empty { color: var(--app-muted); font-style: italic; } + +/* The commit page's file filter. At 420 files the list is 13,027px tall, and + scrolling is not finding. (`.cmt-listcol` itself is defined once, up in the + layout block — a second definition here was silently overriding its width.) */ +.cmt-filterhead { display: flex; align-items: center; gap: var(--sp-2); } +.cmt-filter { + flex: 1 1 auto; + min-width: 0; + padding: 4px var(--sp-2); + border: 1px solid var(--app-border); + border-radius: var(--r-sm); + background: var(--app-bg); + color: var(--app-text); + font: inherit; + font-size: var(--text-sm); +} +.cmt-filter:focus-visible { + outline: 2px solid var(--gs-focus-ring, var(--gs-accent)); + outline-offset: -1px; +} +.cmt-filter-count { flex: 0 0 auto; + font-size: var(--text-xs); + color: var(--app-muted); + font-variant-numeric: tabular-nums; } +.cmt-filter-count.is-empty { color: var(--status-del); } -/* ── 7 · PR review depth — Files tab + inline threads (views/prs.ts) ─────────── */ -/* Matches the existing .gh-* detail look. The Files tab is a master/detail split: - a left file list, a right pane with the Monaco diff over an inline-threads panel. */ -.pr-files { +/* The directory every file in this pull request shares, shown once — the rows + left-truncate their directory, so nine files under one folder produced three + different elisions of the same prefix. */ +.pr-files-prefix { display: flex; - flex-direction: row; - gap: 12px; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-1) var(--sp-2) var(--sp-2); + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-size: var(--text-xs); + color: var(--app-muted); +} + +/* ── The job log page ─────────────────────────────────────────────────────── + The log is a document you read, sometimes tens of thousands of lines of it, + and it needs the window. As a pane inside the run page it got 523px of a + 913px window, inside a page that itself scrolled 1,048px — two nested scroll + contexts and whatever height was left over. */ +.joblog-view .det-main { max-width: none; display: flex; flex-direction: column; min-height: 0; } +.joblog-view .det-body { max-width: none; margin-inline: 0; align-items: stretch; flex: 1 1 auto; min-height: 0; } +.joblog-view .det-scroll { overflow: hidden; display: flex; min-height: 0; } + +.joblog-split { flex: 1 1 auto; min-height: 0; + min-width: 0; + display: flex; + align-items: stretch; + gap: var(--sp-3); } -.pr-files-list { - flex: 0 0 auto; - width: 268px; - min-width: 220px; - max-width: 300px; +.joblog-jobs { + flex: 0 0 230px; + min-width: 0; + min-height: 0; overflow-y: auto; - padding-right: 12px; - border-right: 1px solid var(--app-border); + display: flex; + flex-direction: column; + gap: 1px; + border: 1px solid var(--app-border); + border-radius: var(--r-md); + padding: var(--sp-1); + background: var(--app-panel); } -.pr-files-detail { +.joblog-log { flex: 1 1 auto; min-width: 0; min-height: 0; display: flex; } +/* Asking for the full width hides the job rail: on a page whose whole job is + the log, "expand" can only mean "give me the rest of the window". */ +.joblog-split:has(.log-pane.log-max) .joblog-jobs { display: none; } + +.joblog-job { + display: flex; + align-items: center; + gap: var(--sp-2); + width: 100%; + padding: var(--sp-2); + border: none; + border-radius: var(--r-sm); + background: none; + color: inherit; + font: inherit; + font-size: var(--text-sm); + text-align: left; + cursor: pointer; +} +.joblog-job:hover { background: var(--app-hover); } +.joblog-job.is-current { background: var(--app-active); } +.joblog-job.is-ok .glyph { color: var(--status-add); } +.joblog-job.is-fail .glyph { color: var(--status-del); } +.joblog-job.is-running .glyph { color: var(--gs-accent); } +.joblog-job.is-idle .glyph { color: var(--app-muted); } +.joblog-job-meta { display: flex; flex-direction: column; min-width: 0; } +.joblog-job-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.joblog-job-when { font-size: var(--text-xs); color: var(--app-muted); } + +/* ── The release composer ─────────────────────────────────────────────────── + Writing a release is a PAGE. As a modal it was a 560px card holding two ref + fields, a title, a ten-row notes box and two buttons — the notes, which are + the only part anyone spends time on, got about 180px of it. */ +.relc-view .det-main { max-width: min(980px, 100%); display: flex; flex-direction: column; min-height: 0; } +.relc-view .det-body { align-items: stretch; flex: 1 1 auto; min-height: 0; } +.relc-view .det-scroll { overflow: hidden; display: flex; min-height: 0; } + +.relc-form { flex: 1 1 auto; - min-width: 0; + min-height: 0; display: flex; flex-direction: column; - min-height: 0; + gap: var(--sp-2); +} +/* The two refs sit side by side: they are one decision — what this release + points at — and stacking them read as two unrelated questions. */ +.relc-refs { display: flex; gap: var(--sp-3); align-items: flex-end; } +.relc-refs .relc-field { flex: 1 1 0; min-width: 0; } +.relc-field { display: flex; flex-direction: column; gap: 4px; min-width: 0; } +.relc-label { + font-size: var(--text-xs); + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--app-muted); } -/* The Monaco diff host. min-height:0 is CRITICAL so the editor gets real height - (it also carries .diff-surface; we drop that block's top border in this pane). */ -.pr-diff-surface { +.relc-input { + width: 100%; + padding: 7px var(--sp-3); + border: 1px solid var(--app-border); + border-radius: var(--r-md); + background: var(--app-bg); + color: var(--app-text); + font: inherit; + font-size: var(--text-sm); +} +.relc-input:focus-visible { + outline: 2px solid var(--gs-focus-ring, var(--gs-accent)); + outline-offset: -1px; +} +.relc-input[aria-invalid="true"] { border-color: var(--status-del); } +.relc-title { font-size: var(--text-md); } +/* Whether the tag exists is the difference between releasing a tag you cut and + creating one from Target — and nothing else on the form says which. */ +.relc-note { min-height: 16px; font-size: var(--text-xs); color: var(--app-muted); } +.relc-note.is-new { color: var(--status-warn, var(--app-muted)); } +.relc-note.is-known { color: var(--status-add); } + +.relc-notes-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + margin-top: var(--sp-2); +} +/* The editor takes the rest of the window: a release note is the reason this + page exists. */ +.relc-form .md-editor { flex: 1 1 auto; min-height: 200px; } +.md-editor.md-fill { min-height: 0; } +.md-editor.md-fill .md-text { flex: 1 1 auto; min-height: 0; height: auto; } +.md-editor.md-fill .md-preview { flex: 1 1 auto; min-height: 0; } + +.relc-attrs { display: flex; flex-wrap: wrap; gap: var(--sp-2) var(--sp-5); margin-top: var(--sp-2); } +.relc-check { display: flex; align-items: flex-start; gap: var(--sp-2); cursor: pointer; } +.relc-check.is-off { opacity: 0.5; cursor: default; } +.relc-check-text { display: flex; flex-direction: column; } +.relc-check-label { font-size: var(--text-sm); } +.relc-check-hint { font-size: var(--text-xs); color: var(--app-muted); } + +.relc-error { + padding: var(--sp-2) var(--sp-3); + border: 1px solid var(--status-del); + border-radius: var(--r-md); + background: color-mix(in srgb, var(--status-del) 10%, transparent); + font-size: var(--text-sm); +} +.relc-actions { + display: flex; + align-items: center; + gap: var(--sp-2); + padding-top: var(--sp-3); + border-top: 1px solid var(--app-border); +} +.relc-spring { flex: 1 1 auto; } + +/* ── The issue composer ───────────────────────────────────────────────────── + github.com/…/issues/new is a page: the title, a body that takes the window, + and a sidebar for what you decide ABOUT the issue. `editForm` was a modal + with two boxes and nowhere to put the third of those. */ +.isc-view .det-body { align-items: stretch; flex: 1 1 auto; min-height: 0; } +.isc-view .det-scroll { overflow: hidden; display: flex; min-height: 0; } +.isc-view .det-main { display: flex; flex-direction: column; min-height: 0; } +.isc-view .det-rail { overflow-y: auto; } + +.isc-form { flex: 1 1 auto; min-height: 0; - position: relative; - border-top: none; -} -/* The inline-review panel beneath the diff. */ -.pr-threads { - flex: 0 0 auto; display: flex; flex-direction: column; - gap: 10px; - max-height: 42%; - overflow-y: auto; - padding: 12px 2px 4px; + gap: var(--sp-2); +} +.isc-field { display: flex; flex-direction: column; gap: 4px; } +.isc-label { + font-size: var(--text-xs); + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--app-muted); +} +.isc-body-label { margin-top: var(--sp-2); } +.isc-input { + width: 100%; + padding: 7px var(--sp-3); + border: 1px solid var(--app-border); + border-radius: var(--r-md); + background: var(--app-bg); + color: var(--app-text); + font: inherit; + font-size: var(--text-md); +} +.isc-input:focus-visible { + outline: 2px solid var(--gs-focus-ring, var(--gs-accent)); + outline-offset: -1px; +} +.isc-input[aria-invalid="true"] { border-color: var(--status-del); } +.isc-form .md-editor { flex: 1 1 auto; min-height: 200px; } + +.isc-error { + padding: var(--sp-2) var(--sp-3); + border: 1px solid var(--status-del); + border-radius: var(--r-md); + background: color-mix(in srgb, var(--status-del) 10%, transparent); + font-size: var(--text-sm); +} +.isc-actions { + display: flex; + align-items: center; + gap: var(--sp-2); + padding-top: var(--sp-3); border-top: 1px solid var(--app-border); } -.pr-threads-head { +.isc-spring { flex: 1 1 auto; } + +/* The sidebar: what is chosen, then one control to change it. */ +.isc-none { font-size: var(--text-sm); color: var(--app-muted); } +.isc-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: var(--sp-2); } +.isc-people { display: flex; flex-direction: column; gap: 4px; margin-bottom: var(--sp-2); } +.isc-person { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-sm); } +.isc-milestone { display: block; margin-bottom: var(--sp-2); font-size: var(--text-sm); } +.isc-add { margin-top: 4px; } + +/* A draft restored over text GitHub already has — visible, and undoable. The + composer used to refuse this case outright, which is right about the silence + and wrong about the outcome: an unsaved edit to this very object is the + reader's own work. */ +.isc-restored { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-2) var(--sp-3); + border: 1px solid color-mix(in srgb, var(--gs-accent) 40%, var(--app-border)); + border-radius: var(--r-md); + background: color-mix(in srgb, var(--gs-accent) 8%, transparent); + font-size: var(--text-sm); +} +.isc-restored-text { flex: 1 1 auto; } + +/* A diff that stops halfway through a large file must say so before the editor, + not after: a truncated diff reads as a diff, and the reader draws conclusions + from the half they can see. */ +.diff-truncated-note { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-2) var(--sp-3); + border-bottom: 1px solid var(--app-border); + background: color-mix(in srgb, var(--status-warn) 12%, transparent); + font-size: var(--text-xs); +} + +/* ── A list of commits (views/common.ts commitList) ───────────────────────── + "the bare commits view in compare and pr are not improved as i requested, you + can take example of how they look in github". They were: a subject and one + grey line reading "author · sha · 3h ago". github.com groups by the day, puts + the author's face on the row, and keeps the sha, a copy and an open control + on the right. Everything here except the avatar was already in the response + and thrown away one layer down. */ +.clist { display: flex; flex-direction: column; } +/* + * Compare's own scroller. + * + * `.compare-view` is a fixed-height flex column — the page itself does not + * scroll — so the commits list has to. The rule that did it was `.cmp-commits`, + * and it was deleted along with the old row styles when both surfaces moved to + * `commitList()`: the list kept rendering, `.cmp-body` is `overflow: visible`, + * and a branch with more commits than fit simply could not be reached. The + * pull request's tab needs no equivalent — there the PAGE scrolls (`.det-scroll`), + * and a second scroller inside it would be the box-inside-a-box this redesign + * has spent its time removing. + */ +.cmp-body > .clist { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 0 10px 10px; +} +/* Capped on a measure rather than filling the window. A commit subject is + rarely 60 characters and the sha rides the right edge, so at full width the + two ends of every row were a screen apart with nothing between them — the eye + has to travel back to find which commit a sha belongs to. The pull request's + copy of this list sits inside `.det-main`, which already caps itself. + LEFT-aligned, not centred: the toolbar above is a full-width band, and a + centred column under a left-aligned toolbar reads as a misalignment rather + than a measure. The cap goes on the children — `.clist` is the scroller, and + capping that would strand the scrollbar in the middle of the pane. */ +.cmp-body > .clist > * { max-width: var(--measure-wide, 1100px); } +.clist-day { + /* NOT shrinkable. `.clist` is a flex column, and a flex item's default + `flex-shrink: 1` let these squash to fit the pane instead of extending it — + so the scroller reported 4px of overflow while the rows spilled 48px past + its bottom edge, unreachable. A list inside a scroller must keep its + natural height; the scroller is what gives. */ + flex: 0 0 auto; display: flex; align-items: center; - justify-content: space-between; - gap: 10px; -} -.pr-threads-title { + gap: var(--sp-2); + margin: var(--sp-4) 0 var(--sp-2); font-size: var(--text-xs); font-weight: 600; - letter-spacing: 0.01em; - color: var(--app-muted); -} -.pr-threads-empty { - padding: 18px 12px; - font-size: var(--text-xs); color: var(--app-muted); - text-align: center; } -/* A thread card — extends .gh-comment; add a panel face + padding for the body. */ -.pr-thread { +.clist-day:first-child { margin-top: 0; } +.clist-group { + flex: 0 0 auto; + border: 1px solid var(--app-border); + border-radius: var(--r-md); + overflow: hidden; background: var(--app-panel); } -.pr-thread.is-resolved { - opacity: 0.65; - border-color: color-mix(in srgb, var(--status-add) 32%, var(--app-border)); +.clist-row { + display: flex; + align-items: flex-start; + gap: var(--sp-3); + padding: var(--sp-3); + border-top: 1px solid var(--app-border); } -/* .pr-thread-head extends .gh-comment-head — make it a space-between row so the - resolve toggle sits opposite the file:line anchor. */ -.pr-thread-head { justify-content: space-between; } -.pr-thread-anchor { - display: inline-flex; - align-items: center; - gap: 6px; +.clist-group > .clist-row:first-child { border-top: none; } +.clist-row:hover { background: var(--app-hover); } +.clist-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.clist-subjrow { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; } +/* The SUBJECT is the link, not the whole row: a row that is one button cannot + also hold a copy button and a disclosure. */ +.clist-subject { min-width: 0; + padding: 0; + border: none; + background: none; + color: var(--app-text); + font: inherit; + font-weight: 600; + text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-family: var(--vscode-editor-font-family, ui-monospace, monospace); - font-size: var(--text-xs); - font-weight: 550; - color: var(--app-muted); + cursor: pointer; } -.pr-thread-anchor .codicon { font-size: 13px; flex: 0 0 auto; } -.pr-thread-line { +.clist-subject:hover { color: var(--gs-accent); text-decoration: underline; } +.clist-chip { flex: 0 0 auto; - padding: 1px 6px; - border-radius: 5px; - background: color-mix(in srgb, var(--app-muted) 16%, transparent); - color: var(--app-muted); + padding: 0 6px; + border: 1px solid var(--app-border); + border-radius: 999px; font-size: var(--text-2xs); - font-variant-numeric: tabular-nums; -} -.pr-thread-comment { - padding: 9px 12px; - border-top: 1px solid var(--app-border); + color: var(--app-muted); } -.pr-thread-comment-head { - display: flex; +.clist-more { + flex: 0 0 auto; + display: inline-flex; align-items: center; - gap: 7px; - margin-bottom: 5px; - font-size: var(--text-xs); + padding: 0 4px; + border: 1px solid var(--app-border); + border-radius: var(--r-sm); + background: none; color: var(--app-muted); + cursor: pointer; } -.pr-thread-author { color: var(--vscode-foreground); font-weight: 600; } -.pr-thread-reply { - padding: 9px 12px; - border-top: 1px solid var(--app-border); +.clist-more:hover { color: var(--app-text); background: var(--app-hover); } +.clist-body { + margin: var(--sp-2) 0 0; + padding: var(--sp-2) var(--sp-3); + border-radius: var(--r-sm); + background: var(--app-bg); + color: var(--app-muted); + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-size: var(--text-xs); + white-space: pre-wrap; + overflow-x: auto; } -/* The reply textarea — extends .gh-composer-input; just shrink it for a thread. */ -.pr-reply-input { min-height: 56px; font-size: var(--text-sm); } - -/* — PR title inline-edit affordances — */ -.gh-detail-titlerow { +.clist-meta { display: flex; - align-items: flex-start; - gap: 8px; - min-width: 0; -} -.gh-detail-titlerow .gh-detail-title { flex: 1 1 auto; min-width: 0; } -/* Subtle icon buttons (pencil / inline save+cancel) — they also carry - .mini-btn.gh-icon-btn; recede to a borderless ghost at rest, reveal on hover so - the edit affordance stays quiet until reached. */ -.gh-title-edit, -.gh-inline-edit { - flex: 0 0 auto; - border-color: transparent; - background: transparent; - box-shadow: none; + flex-wrap: wrap; + gap: 4px var(--sp-2); + font-size: var(--text-xs); color: var(--app-muted); } -.gh-title-edit:hover, -.gh-inline-edit:hover { - background: var(--app-hover); - border-color: var(--app-border); - color: var(--vscode-foreground); -} -.gh-title-edit .glyph, -.gh-inline-edit .glyph, -.gh-title-edit .codicon, -.gh-inline-edit .codicon { color: inherit; } -/* The "open / unresolved" thread state pill (sibling of .gh-review-approved). */ -.gh-thread-open { - color: var(--status-warn); - background: color-mix(in srgb, var(--status-warn) 16%, transparent); +.clist-author { font-weight: 600; color: var(--app-text); } +.clist-right { flex: 0 0 auto; display: flex; align-items: center; gap: var(--sp-2); } +.clist-verified { + padding: 1px 8px; + border: 1px solid color-mix(in srgb, var(--status-add) 45%, var(--app-border)); + border-radius: 999px; + color: var(--status-add); + font-size: var(--text-2xs); + font-weight: 600; } - -/* Checkbox staging model (issue #16). The tick is the only staging affordance in - this mode, so it gets a real hit area rather than the browser default. */ -.dc-ck { - flex: 0 0 auto; - width: 14px; - height: 14px; - margin: 0 8px 0 0; - accent-color: var(--accent); +.clist-sha { + padding: 2px 8px; + border: 1px solid var(--app-border); + border-radius: var(--r-sm); + background: var(--app-bg); + color: var(--app-muted); + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-size: var(--text-xs); cursor: pointer; } -.dc-ck-master { margin-left: 2px; } - -/* Per-hunk ticks in the checkbox staging model (#20). A file with unstaged work - opens up to reveal its individual changes, so partial staging survives the - move away from the staged/unstaged split. */ -.dc-hunk-twisty { - flex: 0 0 auto; - width: 16px; - height: 16px; - margin-right: 2px; - padding: 0; - border: 0; - background: transparent; - color: var(--fg-muted); - cursor: pointer; +.clist-sha:hover { color: var(--app-text); border-color: var(--gs-accent); } +.clist-open { display: inline-flex; align-items: center; justify-content: center; - transform: rotate(0deg); - transition: transform 120ms ease; -} -.dc-hunk-twisty.open { transform: rotate(90deg); } -.dc-hunk-twisty .codicon { font-size: 12px; line-height: 1; } -.dc-hunks { - display: flex; - flex-direction: column; - margin: 0 0 4px 46px; - border-left: 1px solid var(--border-soft, var(--border)); + width: 26px; + height: 24px; + border: 1px solid transparent; + border-radius: var(--r-sm); + background: none; + color: var(--app-muted); + cursor: pointer; } -.dc-hunk-row { +.clist-open:hover { color: var(--app-text); background: var(--app-hover); border-color: var(--app-border); } + +/* ── The ref manager (Branches) ───────────────────────────────────────────── + One KIND per screen behind a segmented switch, one row anatomy, every verb + visible at rest. It was four collapsible groups of four incompatible row + shapes, with no title, no count, no facets, and Fetch — the action that makes + every ahead/behind number on the screen true — buried in one row's kebab. */ +.branches-segbar { + flex: 0 0 auto; display: flex; align-items: center; - gap: 8px; - padding: 2px 8px; - min-height: 22px; - font-size: 12px; - color: var(--fg-muted); + /* WRAPS. Five kind segments plus "Delete N finished…" is about 736px of + content in a row with no wrap and no ancestor that scrolls sideways, so + below roughly 950px the sweep button simply rendered past the window edge + — measured 132px out at 820px wide — with no way to reach it. A control + bar that cannot fit takes a second line; it does not walk off the page. */ + flex-wrap: wrap; + row-gap: var(--sp-2); + gap: var(--sp-2); + padding: var(--sp-2) var(--sp-4) 0; } -.dc-hunk-row:hover { background: var(--hover); } -.dc-hunk-lines { flex: 0 0 auto; font-variant-numeric: tabular-nums; opacity: 0.75; } -.dc-hunk-preview { - flex: 1 1 auto; +.gh-head-cta { display: flex; align-items: center; gap: var(--sp-2); } +.gh-head-cta-blank { display: none; } + +/* The tip subject, riding in the chips strip. secRow is one line, and the + subject is the only free-text field — a fixed-width meta column cannot hold + it, so it goes where the strip already fades what it cannot fit. */ +.br-subject { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-family: var(--mono, ui-monospace, monospace); - color: var(--fg); + color: var(--app-muted); + font-size: var(--text-xs); } -.dc-hunk-empty { padding: 3px 10px; font-size: 12px; opacity: 0.7; } -/* Inline diff mode cannot carry staging ticks — Monaco renders deletions as - view zones with no model line behind them, so a pure deletion has nothing to - attach a control to. Say so rather than leaving the gutter mysteriously bare. */ -.diff-staging-hint { - display: flex; align-items: center; gap: 6px; - padding: 6px 12px; color: var(--app-muted); font-size: 11.5px; - border-bottom: 1px solid var(--app-border); +/* Divergence from the DEFAULT branch, as a bar: "how far is this from main", + which a pair of upstream counts cannot answer. Scaled to the widest + divergence currently on screen, so the column is comparable down the list. */ +.br-ab { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 3px; + font-size: var(--text-2xs); + font-variant-numeric: tabular-nums; + color: var(--app-muted); } - -/* ---- Changes: multi-selection and drag-to-stash -------------------------- * - * Everything here is scoped to .dc-* because bare .file-row is shared with the - * compare view and the code browser, which have no notion of a selection. */ -.dc-listcol { display: flex; flex-direction: column; min-height: 0; } - -/* The accent bar is absolutely positioned, so the row must be a containing - block. .file-row itself is static and shared, so this is set here. */ -.dc-file { position: relative; } -.dc-file.is-selected { background: color-mix(in srgb, var(--gs-accent) 16%, transparent); } -.dc-file.is-selected::before { - content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 2px; - background: var(--gs-accent); +.br-ab-n { min-width: 14px; text-align: center; } +.br-ab-half { + display: inline-flex; + width: 32px; + height: 6px; + border-radius: 2px; + background: color-mix(in srgb, var(--app-muted) 18%, transparent); } -.dc-file.dragging { opacity: 0.5; } - -.dc-selbar { - display: flex; align-items: center; gap: 8px; - padding: 5px 10px; margin: 0; - border-top: 1px solid var(--app-border); - background: var(--app-elevated); - font-size: 11.5px; flex: none; +.br-ab-half.is-behind { justify-content: flex-end; } +.br-ab-fill { display: block; height: 6px; border-radius: 2px; } +.br-ab-half.is-behind .br-ab-fill { background: var(--status-mod); } +.br-ab-half.is-ahead .br-ab-fill { background: var(--status-add); } + +/* Fixed-width meta columns. A meta slot with no width rule never forms a + column — it just sits wherever its content ends, and the list stops being + scannable down the page. */ +/* WIDTH, not min-width. A slot that can grow does not form a column: one long + upstream ("origin/redesign/issues-detail") pushed the ahead/behind pair 12px + left of where it sat on every other row, which is exactly the unreadable + ragged edge these slots exist to prevent. Fixed width plus an ellipsis. */ +.sec-row-meta > .br-track { width: 78px; flex: 0 0 78px; display: flex; gap: 4px; justify-content: flex-end; } +.sec-row-meta > .br-upstream { + width: 160px; + flex: 0 0 160px; + text-align: right; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + direction: rtl; } -.dc-selbar[hidden] { display: none; } -.dc-selbar-count { font-weight: 600; } -.dc-selbar-actions { margin-left: auto; display: flex; gap: 4px; } +.sec-row-meta > .br-remote { width: 70px; flex: 0 0 70px; text-align: right; } +.sec-row-meta > .br-sha { width: 62px; flex: 0 0 62px; text-align: right; } +/* …and a shrink path, because none of the slots above can give up a pixel. + A branch row's non-shrinkable content sums to about 717px while the list + gives it 648 at the window's own 880px minimum — and `.sec-row` is + `overflow: visible`, so the surplus does not scroll, it renders OUTSIDE the + window. Measured: a row's actions ending at x=941 in an 880px window, + unreachable by pointer and keyboard alike. + The upstream NAME is the biggest slot and the most droppable — the + ahead/behind track beside it is the part you act on, and the name is in the + row's own tooltip. 1040px is the breakpoint the topbar already uses. */ +@media (max-width: 1040px) { + .sec-row-meta > .br-upstream, + .sec-row-meta > .br-remote { display: none; } +} +/* …and a shrink path, because none of the slots above can give up a pixel. + A branch row's non-shrinkable content sums to about 717px while the list + gives it 648 at the window's own 880px minimum — and `.sec-row` is + `overflow: visible`, so the surplus does not scroll, it renders OUTSIDE the + window. Measured: a row's actions ending at x=941 in an 880px window, + unreachable by pointer or keyboard. + The upstream NAME is the biggest slot and the most droppable — the + ahead/behind track beside it is the part you act on, and the name is in the + row's own tooltip. 1040px is the breakpoint the topbar already uses. */ + +.sec-row-meta > .stash-sel { width: 74px; flex: 0 0 74px; text-align: right; } + +/* The state pills a branch or a tag wears. */ +/* --gs-accent-ink, not the raw fill. The comment on the light block says it: + "on light surfaces the bright blue/purple accents fail under white text or + as small text". This pill is 10.5px/600 text — the smallest accent-coloured + text in the app — and it was the one label that never got the light ink. */ +.ab-pill.current { + color: var(--gs-accent-ink, var(--gs-accent)); + border-color: color-mix(in srgb, var(--gs-accent-ink, var(--gs-accent)) 40%, var(--app-border)); +} +.ab-pill.default { color: var(--app-text); } +.ab-pill.merged { color: var(--app-muted); } +.ab-pill.unpublished { color: var(--app-muted); border-style: dashed; } +.ab-pill.annotated { color: var(--status-add); border-color: color-mix(in srgb, var(--status-add) 35%, var(--app-border)); } +.ab-pill.lightweight { color: var(--app-muted); border-style: dashed; } -/* Revealed only mid-drag: a permanent strip would cost list height, which is - the scarce resource in this column. */ -.dc-stash-drop { - display: flex; align-items: center; justify-content: center; gap: 8px; - margin: 6px 8px 8px; padding: 14px 10px; flex: none; - border: 1px dashed var(--gs-accent); border-radius: 8px; - color: var(--app-muted); font-size: 12px; +/* ── A ref's page ─────────────────────────────────────────────────────────── */ +.rd-head { margin-bottom: var(--sp-4); } +.rd-title { margin: 0 0 var(--sp-2); font-size: var(--text-lg); font-weight: 600; letter-spacing: -0.01em; } +.rd-facts { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--sp-2) var(--sp-3); + font-size: var(--text-xs); + color: var(--app-muted); } -.dc-stash-drop[hidden] { display: none; } -.dc-stash-drop.is-over { - background: color-mix(in srgb, var(--gs-accent) 14%, transparent); - color: var(--app-text); border-style: solid; +.rd-kind { text-transform: uppercase; letter-spacing: 0.04em; font-weight: 600; } +.rd-subject { margin-top: var(--sp-2); color: var(--app-text); } +.rd-section-head { + display: flex; + align-items: center; + gap: var(--sp-2); + margin: var(--sp-5) 0 var(--sp-2); + font-size: var(--text-xs); + font-weight: 600; + color: var(--app-muted); +} +.rd-prop { display: flex; justify-content: space-between; gap: var(--sp-3); padding: 3px 0; font-size: var(--text-xs); } +.rd-prop-label { color: var(--app-muted); } +.rd-prop-value { text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* The filter row: facets, the age cut, and the sort. */ +.branches-facets { + flex: 0 0 auto; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--sp-2); + padding: var(--sp-2) var(--sp-4) 0; } +.branches-facets .gh-facets { margin: 0; padding: 0; border: none; } +.branches-sort { margin-left: auto; } diff --git a/apps/desktop/src/renderer/terminalDock.ts b/apps/desktop/src/renderer/terminalDock.ts index df8ac6c..eff2020 100644 --- a/apps/desktop/src/renderer/terminalDock.ts +++ b/apps/desktop/src/renderer/terminalDock.ts @@ -56,6 +56,11 @@ export interface TerminalDockOptions { onStateChange: (s: { expanded: boolean; height: number }) => void; } +/** How many shells one window will hold. A held-down "+" must not be able + * to spawn unbounded PTYs; the button reports the limit rather than + * swallowing the click. */ +const MAX_TERMINALS = 16; + export class TerminalDock { private readonly dock: BottomDock; private readonly outputs: OutputsPanel; @@ -89,7 +94,18 @@ export class TerminalDock { label: "Panel", onResize: () => this.layoutActive(), onToggle: (collapsed) => { - if (!collapsed) this.revealActive(); + if (!collapsed) { + // Remember where the keyboard WAS before the dock takes it. + this.rememberFocus(); + this.revealActive(); + } else { + // …and give it back. Closing the dock left focus on the xterm + // textarea inside it, which is now hidden: every keystroke after + // ⌘` ⌘` went into a terminal nobody could see, and the list you were + // reading before had no focus, no selection and no way back to the + // keyboard except the mouse. + this.restoreFocus(); + } this.persist(); }, onHeightChange: () => this.persist(), @@ -103,6 +119,10 @@ export class TerminalDock { this.outputs = new OutputsPanel(); this.outputs.el.style.display = "none"; this.dock.bodyEl.appendChild(this.outputs.el); + // The Output tab's controls live in the dock's footer action slot — the + // one horizontal control row the dock already has — rather than in a bar + // of Output's own, which made the body start 32px lower than Terminal's. + this.dock.actionsEl.appendChild(this.outputs.bar); // ── Terminal group: stage (surfaces) + side list. ── this.termGroup = el("div", "term-group"); @@ -128,8 +148,15 @@ export class TerminalDock { min: SIDE_MIN, max: () => SIDE_MAX, get: () => sideW, - // The list is the RIGHT pane, so "grow left pane" (→) shrinks it. - set: (w) => setSideW(SIDE_MIN + SIDE_MAX - w), + // The list is the RIGHT pane, so → has to SHRINK it. That is exactly what + // `inverted` is for. It was done by mirroring the value inside `set` + // instead — `set(MIN + MAX - w)` — while `get` returned the un-mirrored + // width, so the two disagreed and the handle oscillated between two + // widths: from 168, → gave 268, → again gave 168, forever. Two keystrokes + // and you were back where you started, with no way to reach anything in + // between from the keyboard. + set: setSideW, + inverted: true, onCommit: () => localStorage.setItem("gitstudio.termSideW", String(sideW)), }); sideResizer.addEventListener("pointerdown", (e) => { @@ -174,6 +201,9 @@ export class TerminalDock { panel: new TerminalPanel(surface), opened: false, }; + // Repaint the side list when the shell dies, so the row stops claiming to + // be a running terminal the moment it stops being one. + t.panel.onExit = () => this.renderSide(); this.terminals.push(t); this.termStage.appendChild(surface); return t; @@ -182,7 +212,8 @@ export class TerminalDock { /** Public: add a terminal, focus it, switch to the Terminal tab + expand. */ newTerminal(): void { // Cap concurrent terminals so a held-down "+" can't spawn unbounded PTYs. - if (this.terminals.length >= 16) return; + // The button says so rather than swallowing the click — see `syncAddBtn`. + if (this.terminals.length >= MAX_TERMINALS) return; const t = this.createTerminal(); this.activeTermId = t.id; this.active = "terminal"; @@ -291,10 +322,17 @@ export class TerminalDock { private renderSide(): void { this.termSide.replaceChildren(); - const addBtn = el("button", "term-side-add"); + const addBtn = el("button", "term-side-add") as HTMLButtonElement; addBtn.append(glyph("add"), span("New terminal")); - addBtn.title = "New terminal"; - addBtn.setAttribute("aria-label", "New terminal"); + // At the cap the button used to stay fully lit and do nothing — a click + // that `newTerminal`'s own guard swallowed, with no message anywhere. A + // control that cannot act says so. + const atCap = this.terminals.length >= MAX_TERMINALS; + addBtn.disabled = atCap; + addBtn.title = atCap + ? `${MAX_TERMINALS} terminals is the most this window will open at once — close one first` + : "New terminal"; + addBtn.setAttribute("aria-label", addBtn.title); addBtn.addEventListener("click", () => this.newTerminal()); this.termSide.appendChild(addBtn); @@ -324,25 +362,35 @@ export class TerminalDock { (this.termSide.querySelector(".term-side-row.active") as HTMLElement | null)?.focus(); }); rowEls.push(row); - row.append(glyph("terminal"), span(t.label, "term-side-label")); - row.title = t.label; + const dead = t.panel.isExited(); + row.classList.toggle("is-exited", dead); + row.append(glyph(dead ? "circle-slash" : "terminal"), span(t.label, "term-side-label")); + if (dead) row.append(span("exited", "term-side-dead")); + row.title = dead ? `${t.label} — the shell has exited` : t.label; row.addEventListener("click", () => this.setActiveTerm(t.id)); - const kill = el("span", "term-side-close"); + // The kill control is a SIBLING of the row, not a child of it. It used to + // be a role=button span inside the row's <button>, which is invalid: an + // interactive element cannot contain another one. The outer button's + // accessible name absorbed it, and no assistive tech could reach it — the + // tabIndex = -1 that kept it out of the Tab order was hiding the problem + // rather than solving it. `.term-side-item` positions the two together. + const kill = el("button", "term-side-close") as HTMLButtonElement; + // Roves WITH its row. The row above is a roving tabindex — only the + // active one is in the Tab order — but this sibling kept the default 0, + // so tabbing through a dock with six shells open hit six "Kill Terminal + // N" buttons and exactly one terminal row. The destructive control was + // more reachable than the thing it destroys. + kill.tabIndex = sel ? 0 : -1; kill.append(glyph("trash")); kill.title = `Kill ${t.label}`; - kill.setAttribute("role", "button"); kill.setAttribute("aria-label", `Kill ${t.label}`); - kill.tabIndex = -1; - const doKill = (e: Event): void => { + kill.addEventListener("click", (e) => { e.stopPropagation(); this.closeTerminal(t.id); - }; - kill.addEventListener("click", doKill); - kill.addEventListener("keydown", (e) => { - if (e.key === "Enter" || e.key === " ") doKill(e); }); - row.appendChild(kill); - list.appendChild(row); + const item = el("div", "term-side-item"); + item.append(row, kill); + list.appendChild(item); } this.termSide.appendChild(list); } @@ -352,6 +400,7 @@ export class TerminalDock { /** Show the active top surface; within the Terminal, the active shell. */ private showActive(): void { this.outputs.el.style.display = this.active === "output" ? "" : "none"; + this.outputs.bar.hidden = this.active !== "output"; if (this.detailsEl) this.detailsEl.style.display = this.active === "commit-details" ? "" : "none"; this.termGroup.style.display = this.active === "terminal" ? "" : "none"; @@ -368,8 +417,49 @@ export class TerminalDock { else this.revealActive(); } + /** Where the keyboard was before the dock took it, so closing hands it back. + * Null when focus was already inside the dock (or nowhere in particular). */ + private returnFocusTo: HTMLElement | null = null; + + private rememberFocus(): void { + const a = document.activeElement as HTMLElement | null; + // Only somewhere OUTSIDE the dock, and only something still focusable — + // otherwise closing would hand focus back into the panel it just hid. + this.returnFocusTo = + a && a !== document.body && !this.dock.root.contains(a) ? a : null; + } + + private restoreFocus(): void { + const back = this.returnFocusTo; + this.returnFocusTo = null; + // NOTHING to go back to is still a decision. The dock is often opened from + // INSIDE itself — the chevron, the tab strip — in which case nothing outside + // was remembered; and the remembered node may have been re-rendered away + // while it was open. Either way, closing it while the keyboard is in the + // terminal used to leave focus on <body>: the next Tab starts from the top + // of the window and no shortcut bound to a view can fire. + if (!back?.isConnected) { + const here = document.activeElement as HTMLElement | null; + if (here && here !== document.body && !this.dock.root.contains(here)) return; + // The view behind, as a whole — it carries tabIndex -1 for exactly this. + document.querySelector<HTMLElement>(".view-host")?.focus(); + return; + } + // Only when the dock still HAS the keyboard. If the reader clicked into the + // view behind while the dock was open — which is an ordinary thing to do, + // the dock is not modal — then focus is already where they put it, and + // yanking it back to wherever they happened to be when they opened the dock + // is the same unasked-for jump this was meant to fix, pointed the other way. + const here = document.activeElement as HTMLElement | null; + if (here && here !== document.body && !this.dock.root.contains(here)) return; + back.focus(); + } + /** Open the active shell's PTY (lazily) and re-fit it; focus the active chat. */ private revealActive(): void { + // The Output panel is display:none while another tab is up, so nothing that + // arrived meanwhile could position its scroller — see `OutputsPanel.reveal`. + if (this.active === "output") this.outputs.reveal(); if (this.active === "terminal") { const t = this.terminals.find((x) => x.id === this.activeTermId); if (t) this.openTerm(t); @@ -382,6 +472,17 @@ export class TerminalDock { // ── Inline AI chat tabs (✨ Explain / Review / Analyze / Draft) ─────────────── /** Open a named, closable AI chat tab seeded with `goal`, and reveal it. */ + // SUPERSEDED, and deliberately so — this method has no callers. + // + // The ✨ actions used to open a chat tab down here, which split the window in + // half; they now route to the Assistant section (renderer.ts, where + // `registerAssistantTab` says why). The machinery below — ChatPanel, the + // chat tabs, their close buttons — is therefore dead, and left in place + // rather than deleted because it is the only other consumer of chatRender and + // deleting it is a change worth making deliberately rather than in passing. + // + // If you are reading this because you changed something in chatRender: this + // is the second caller, and it is not reachable from the UI. openChat(req: AssistantTabRequest): void { const id = `chat-${++this.chatSeq}`; const panel = new ChatPanel({ seedGoal: req.goal, seedLabel: req.title, nav: req.nav }); diff --git a/apps/desktop/src/renderer/terminalPanel.ts b/apps/desktop/src/renderer/terminalPanel.ts index f5f477f..d595b67 100644 --- a/apps/desktop/src/renderer/terminalPanel.ts +++ b/apps/desktop/src/renderer/terminalPanel.ts @@ -31,6 +31,11 @@ export class TerminalPanel { /** Unsubscribe handles for the host.on subscriptions. */ private offData: (() => void) | null = null; private offExit: (() => void) | null = null; + /** The shell has exited. Its tab is not a live terminal any more, and + * anything typed into it has nowhere to go. */ + private exited = false; + /** Told when the shell exits, so the tab can stop claiming to be running. */ + onExit: (() => void) | null = null; /** Set by dispose() so an in-flight open() can bail out (and not leak the PTY). */ private disposed = false; @@ -50,28 +55,43 @@ export class TerminalPanel { "--vscode-editor-selectionBackground", "#264f78", ); + // The SIXTEEN ANSI entries follow the theme too. + // + // `background` and `foreground` were read from the live tokens while the + // palette below was hardcoded to VS Code's DARK one — so in the light theme + // the terminal painted a dark palette onto a white ground. Measured against + // #ffffff: brightWhite #ffffff is 1.00:1, literally invisible; yellow + // #e5e510 is 1.09:1; brightYellow, brightGreen and white are all under 2:1. + // Any tool that colours its output — git, npm, a test runner — printed + // whole lines nobody could read. + // + // The light row is GitHub's, which is what `body.vscode-light .log-pane` + // already ships for the job log one pane over; matching it means one ANSI + // vocabulary across both surfaces rather than two. + // The one signal every other surface keys off — see desktopTheme.ts. + const light = document.body.classList.contains("vscode-light"); + const ansi = light + ? { + black: "#24292f", red: "#cf222e", green: "#116329", yellow: "#4d2d00", + blue: "#0969da", magenta: "#8250df", cyan: "#1b7c83", white: "#6e7781", + brightBlack: "#57606a", brightRed: "#a40e26", brightGreen: "#1a7f37", + brightYellow: "#633c01", brightBlue: "#218bff", brightMagenta: "#a475f9", + brightCyan: "#3192aa", brightWhite: "#8c959f", + } + : { + black: "#000000", red: "#cd3131", green: "#0dbc79", yellow: "#e5e510", + blue: "#2472c8", magenta: "#bc3fbc", cyan: "#11a8cd", white: "#e5e5e5", + brightBlack: "#666666", brightRed: "#f14c4c", brightGreen: "#23d18b", + brightYellow: "#f5f543", brightBlue: "#3b8eea", brightMagenta: "#d670d6", + brightCyan: "#29b8db", brightWhite: "#ffffff", + }; return { background, foreground, cursor: foreground, cursorAccent: background, selectionBackground, - black: "#000000", - red: "#cd3131", - green: "#0dbc79", - yellow: "#e5e510", - blue: "#2472c8", - magenta: "#bc3fbc", - cyan: "#11a8cd", - white: "#e5e5e5", - brightBlack: "#666666", - brightRed: "#f14c4c", - brightGreen: "#23d18b", - brightYellow: "#f5f543", - brightBlue: "#3b8eea", - brightMagenta: "#d670d6", - brightCyan: "#29b8db", - brightWhite: "#ffffff", + ...ansi, }; } @@ -137,12 +157,24 @@ export class TerminalPanel { }); this.offExit = host.on("terminal:exit", (m) => { if (m.id === this.id) { - term.write("\r\n[process exited]\r\n"); + // Say it, and MEAN it. This wrote one line of text and changed nothing + // else: the tab kept its live label, the cursor kept blinking, and + // `onData` below kept posting every keystroke to a PTY that was gone — + // so a dead shell looked exactly like a working one and silently ate + // everything typed into it, with no error and no way to tell. + this.exited = true; + term.write("\r\n\x1b[2m[process exited — this shell is closed]\x1b[0m\r\n"); + // A dead terminal takes no input. Without this the caret still blinks + // in a box that cannot receive anything. + term.options.disableStdin = true; + term.options.cursorBlink = false; + this.onExit?.(); } }); term.onData((d) => { - void host.invoke("terminal:write", { id: this.id!, data: d }); + if (this.exited || !this.id) return; + void host.invoke("terminal:write", { id: this.id, data: d }); }); } @@ -167,6 +199,11 @@ export class TerminalPanel { } } + /** Has the shell behind this panel exited? */ + isExited(): boolean { + return this.exited; + } + /** Focus the xterm textarea. */ focus(): void { this.term?.focus(); diff --git a/apps/desktop/src/renderer/textFit.ts b/apps/desktop/src/renderer/textFit.ts new file mode 100644 index 0000000..a66f955 --- /dev/null +++ b/apps/desktop/src/renderer/textFit.ts @@ -0,0 +1,48 @@ +// Pure text-fitting helpers — DOM-free so they unit-test under plain node +// (ui.ts touches `window` at import time and cannot be imported from a test). + +/** + * Shorten a filesystem path from the MIDDLE. + * + * CSS ellipsis truncates from the right, which on a path removes the only part + * that distinguishes it: two clones of the same repo under different parents + * both render as "/Users/anton/Developer/GitStu…". Keeping both ends keeps the + * answer to "which one is this?". + */ +export function middleTruncate(text: string, max = 44): string { + if (text.length <= max) return text; + if (max <= 1) return "…"; + const keep = max - 1; + const head = Math.ceil(keep * 0.4); + const tail = keep - head; + return `${text.slice(0, head)}…${text.slice(text.length - tail)}`; +} + +/** + * "1 commit" / "2 commits" — never "commit(s)". + * + * That placeholder had shipped into seven visible strings, including a menu + * subtitle and the sync button's tooltip. It is the kind of thing a reader + * reads as unfinished software, because it is. + */ +export function plural(n: number, one: string, many = `${one}s`): string { + return `${n.toLocaleString()} ${n === 1 ? one : many}`; +} + +/** + * Split a file into the lines a reader would count. + * + * `"a\nb\n".split("\n")` is `["a", "b", ""]`, and every code viewer in the app + * numbered that trailing empty string as a real line. A POSIX text file ends in + * a newline, so this was not an edge case — it was every file: a 5-line file + * showed 6 numbers, and a line reference was off by one against the editor the + * reader would go on to open. + * + * Only ONE trailing empty is dropped: a file ending in a genuinely blank line + * ("a\n\n") keeps it, because that blank line is really there. + */ +export function fileLines(text: string): string[] { + const lines = text.split("\n"); + if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop(); + return lines; +} diff --git a/apps/desktop/src/renderer/ui.ts b/apps/desktop/src/renderer/ui.ts index 1f1563f..534035f 100644 --- a/apps/desktop/src/renderer/ui.ts +++ b/apps/desktop/src/renderer/ui.ts @@ -2,6 +2,9 @@ // including the per-section view modules under ./views. Pure functions (plus a // clipboard helper that toasts); no App state, so any module can import them. +import { host } from "./bridge"; +import { registerLayer } from "./overlays"; +export { middleTruncate } from "./textFit"; import { toast } from "./dialogs"; // ── tiny DOM helpers ───────────────────────────────────────────────────────── @@ -117,18 +120,76 @@ export function formatBytes(n?: number): string { /** Up to two uppercase initials from a display name, for avatar fallbacks. */ export function initials(name: string): string { - const parts = (name || "").trim().split(/\s+/).filter(Boolean); + // GitHub logins are not names: "s-ohta" split on whitespace is one part, and + // the first two characters were "s-", so the tile rendered punctuation. Treat + // dashes, dots and underscores as word breaks the way a login actually reads. + const parts = (name || "") + .trim() + .split(/[\s._-]+/) + .filter(Boolean); if (parts.length === 0) return "?"; - if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); - return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); + // Letters and digits in ANY script. The strip used to be `[^A-Za-z0-9]`, + // which deletes every Cyrillic, Greek, CJK, Arabic and Hebrew character — + // so "Пётр", "田中" and "محمد" each came out empty and rendered "?" beside + // their own correctly-spelled name, as if the app could not read them. + const letters = (s: string): string[] => [...s].filter((ch) => /\p{L}|\p{N}/u.test(ch)); + if (parts.length === 1) { + // Code POINTS, not UTF-16 units: slicing an astral character (an emoji, or + // rarer CJK) in half yields a lone surrogate, which paints as a tofu box. + const clean = letters(parts[0]); + return (clean.slice(0, 2).join("") || "?").toUpperCase(); + } + const first = letters(parts[0])[0] ?? ""; + const last = letters(parts[parts.length - 1])[0] ?? ""; + return ((first + last) || "?").toUpperCase(); } -/** A stable, pleasant avatar hue from a seed (email/name) — `hsl(...)` string. - * Deterministic so the same author always gets the same colour. */ +/** A stable avatar hue from a seed (email/name). Deterministic, so the same + * author always gets the same colour. */ export function avatarHue(seed: string): string { + return `hsl(${avatarHueDeg(seed)} 52% 44%)`; +} + +/** The raw hue in degrees — exported so the ink can be chosen from it. */ +export function avatarHueDeg(seed: string): number { let h = 0; for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0; - return `hsl(${h % 360} 52% 52%)`; + return h % 360; +} + +/** + * Black or white initials, whichever is legible on this seed's tile. + * + * The tiles hard-coded WHITE over `hsl(h 52% 52%)`, and 52% lightness is not + * one perceived brightness — it is a very different one at hue 60 (yellow) than + * at hue 240 (blue). So the same rule that gave "AN" a comfortable 5:1 gave a + * yellow-hashed login white-on-yellow at roughly 2:1. The hue is decorative and + * worth keeping; the assumption that one ink suits all of them is not. + */ +export function avatarInk(seed: string): string { + const hue = avatarHueDeg(seed); + // Relative luminance of hsl(hue 52% 44%), per WCAG's sRGB coefficients. + const [r, g, b] = hslToRgb(hue, 0.52, 0.44); + const lin = (c: number): number => + c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + const L = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); + // Contrast against white is 1.05 / (L + 0.05); against black, (L + 0.05) / 0.05. + return 1.05 / (L + 0.05) >= (L + 0.05) / 0.05 ? "#ffffff" : "#10131a"; +} + +function hslToRgb(hDeg: number, s: number, l: number): [number, number, number] { + const h = hDeg / 360; + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + const to = (t: number): number => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + return [to(h + 1 / 3), to(h), to(h - 1 / 3)]; } /** Relative time from an ISO-8601 string; "" when missing or unparseable. */ @@ -183,19 +244,68 @@ export async function runBusy(btn: HTMLElement, fn: () => Promise<void>): Promis export function textBtn( label: string, title: string, - onClick: () => void, + onClick: (btn: HTMLElement) => void, danger = false, + /** + * WHICH object this button acts on — the branch name, the file path. + * + * The focus rescue that restores the keyboard after a list rebuild matches on + * `dataset.num` first and falls back to `title`. Every row's Delete button + * carries the same title ("Delete this branch"), so without an identity the + * rescue matched the FIRST such button in the rebuilt list: after confirming + * a delete or a discard, focus landed on a different object's destructive + * button, one Enter away from acting on something nobody selected. + */ + identity?: string, ): HTMLElement { const b = el("button", "row-btn" + (danger ? " danger" : "")); b.textContent = label; b.title = title; + if (identity) { + b.dataset.num = identity; + // …and NAME the object, not just the verb. + // + // Every row in a list carries the same button: four "Stage"s, three + // "Delete"s, and a `title` that repeats the verb too ("Delete this + // branch"). Tabbing a list with a screen reader was therefore "Stage, + // Stage, Stage, Stage" — the one thing a person needs to know, WHICH file, + // being the one thing not said. It is worst on the destructive ones. + b.setAttribute("aria-label", `${label} ${identity}`); + } b.addEventListener("click", (e) => { e.stopPropagation(); - onClick(); + onClick(b); }); return b; } +/** + * A porcelain status letter as a word, for an accessible name. + * + * "M" is a column heading a sighted reader learns in a second; announced on its + * own it is the letter M. + */ +export function statusWord(status: string): string { + switch (status) { + case "M": + return "modified"; + case "A": + return "added"; + case "D": + return "deleted"; + case "R": + return "renamed"; + case "C": + return "copied"; + case "U": + return "conflicted"; + case "?": + return "untracked"; + default: + return status; + } +} + /** An uppercase muted group label used inside list/compare/changes views. */ export function groupLabel(text: string): HTMLElement { const d = el("div", "group-label"); @@ -216,15 +326,27 @@ export interface EmptyOpts { icon?: string; /** A primary call-to-action button. */ action?: { label: string; icon?: string; onClick: () => void }; + /** A quieter second action — "Clear filters" and friends. Without this the + * same five lines got hand-appended after the fact in four views (and + * unguarded in one, which offered to clear filters that weren't set). */ + secondary?: { label: string; icon?: string; onClick: () => void }; /** A muted hint line under the action (e.g. a keyboard shortcut). */ hint?: string; + /** + * `hero` (the default) centres the block in the pane — right for "there is + * nothing here at all". `inline` anchors it to the top-left of the content, + * for "your query matched nothing": that answer belongs beside the control + * that produced it, not floating 290px below and 600px to the right of the + * search box you are still looking at. + */ + anchor?: "hero" | "inline"; } /** A composed, premium empty state: an accent-tinted icon badge, a title, a * description, and an optional CTA + hint. Used for empty lists AND for the * detail pane when nothing is selected, so no surface is ever a bare void. */ export function emptyState(title: string, desc: string, opts: EmptyOpts = {}): HTMLElement { - const wrap = el("div", "list-empty"); + const wrap = el("div", "list-empty" + (opts.anchor === "inline" ? " is-inline" : "")); const badge = el("div", "list-empty-badge"); badge.appendChild(glyph(opts.icon ?? "inbox")); const t = el("div", "list-empty-title"); @@ -239,6 +361,13 @@ export function emptyState(title: string, desc: string, opts: EmptyOpts = {}): H btn.addEventListener("click", opts.action.onClick); wrap.appendChild(btn); } + if (opts.secondary) { + const b = el("button", "btn btn-soft list-empty-action"); + if (opts.secondary.icon) b.appendChild(glyph(opts.secondary.icon)); + b.appendChild(span(opts.secondary.label)); + b.addEventListener("click", opts.secondary.onClick); + wrap.appendChild(b); + } if (opts.hint) { const h = el("div", "list-empty-hint"); h.textContent = opts.hint; @@ -249,11 +378,22 @@ export function emptyState(title: string, desc: string, opts: EmptyOpts = {}): H /** An avatar: the real image when available, else a deterministic initials tile. * Works fully offline (the stub/real null avatars fall back gracefully). */ -export function avatar(login: string, url: string | null | undefined, size = 22): HTMLElement { +export function avatar( + login: string, + url: string | null | undefined, + size = 22, + /** What this person IS here — "Author", "Assignee". The same 18px circle in + * the same slot meant a different role on every list and said so nowhere. */ + role?: string, +): HTMLElement { + const label = role ? `${role}: @${login}` : `@${login}`; const fallback = (): HTMLElement => { const s = el("span", "av av-fallback"); s.textContent = initials(login || "?"); + s.title = label; + s.setAttribute("aria-label", label); s.style.setProperty("--av", avatarHue(login || "?")); + s.style.setProperty("--av-ink", avatarInk(login || "?")); s.style.width = s.style.height = `${size}px`; s.style.fontSize = `${Math.round(size * 0.42)}px`; return s; @@ -262,7 +402,8 @@ export function avatar(login: string, url: string | null | undefined, size = 22) const img = document.createElement("img"); img.className = "av av-img"; img.src = url; - img.alt = login; + img.alt = label; + img.title = label; img.referrerPolicy = "no-referrer"; img.style.width = img.style.height = `${size}px`; // If the avatar can't load (offline / 404), swap in the initials tile so the @@ -284,10 +425,39 @@ export function labelChip(name: string, hexColor: string): HTMLElement { /** A small trailing-stat bit: an optional icon + a number/label (comments, * files, +/- lines). Pass an empty icon to render text-only (e.g. "+612"). */ -export function statBit(icon: string, text: string | number, cls = ""): HTMLElement { +export function statBit(icon: string, text: string | number, cls = "", label?: string): HTMLElement { const s = el("span", `gh-stat ${cls}`.trim()); if (icon) s.appendChild(glyph(icon)); - s.appendChild(span(String(text))); + s.appendChild(span(typeof text === "number" ? text.toLocaleString() : String(text))); + // A bare "3" next to an icon is a guess; the tooltip names what it counts. + const known: Record<string, string> = { comment: "comments", file: "files", "cloud-download": "downloads" }; + const title = label ?? known[icon]; + if (title) { + s.title = typeof text === "number" ? `${text.toLocaleString()} ${title}` : title; + } + return s; +} + +/** A clickable fragment INSIDE a row's meta line (branch, author, repo). Rows + * are click targets themselves, so this stops propagation and stays a span. */ +export function subLink(text: string, title: string, onClick: () => void): HTMLElement { + const s = el("span", "gh-sub-link"); + s.textContent = text; + s.title = title; + s.setAttribute("role", "button"); + s.tabIndex = 0; + s.addEventListener("click", (e) => { + e.stopPropagation(); + onClick(); + }); + s.addEventListener("keydown", (e) => { + // Space too: a role="button" that answers Enter and ignores Space is half a + // control, and Space is the key most people reach for on a focused button. + if (e.key !== "Enter" && e.key !== " ") return; + e.preventDefault(); + e.stopPropagation(); + onClick(); + }); return s; } @@ -298,18 +468,46 @@ export function statePill(label: string, kind: string): HTMLElement { return p; } +/** The state kind for an issue: closed-as-not-planned is its OWN state, not a + * shade of closed — GitHub renders it gray, and so must we. `stateReason` + * comes straight off the wire type. */ +export function issueStateKind(state: string, stateReason?: string | null): string { + if (state !== "closed") return "open"; + return stateReason === "not_planned" ? "not-planned" : "closed"; +} + /** A colored leading state icon for a list row (open=green, closed=red, …). */ -export function stateLead(kind: string): HTMLElement { +export function stateLead(kind: string, label?: string): HTMLElement { const s = el("span", `gh-lead-icon gh-lead-${kind}`); + // When the icon is the ONLY statement of state (no pill beside it), it has + // to be readable by hover and by a screen reader. + const text = label ?? STATE_WORDS[kind]; + if (text) { + s.title = text; + s.setAttribute("aria-label", text); + s.setAttribute("role", "img"); + } s.appendChild(glyph(stateIconName(kind))); return s; } +const STATE_WORDS: Record<string, string> = { + open: "Open", + "open-pr": "Open", + closed: "Closed", + "not-planned": "Closed as not planned", + merged: "Merged", + draft: "Draft", +}; + /** Codicon name for a PR/issue state. */ export function stateIconName(kind: string): string { switch (kind) { case "merged": return "git-merge"; case "closed": return "issue-closed"; + // GitHub distinguishes closed-as-completed from closed-as-not-planned: + // a purple check vs a gray "skip" circle. Same word, different outcome. + case "not-planned": return "circle-slash"; case "draft": return "git-pull-request-draft"; case "open-pr": return "git-pull-request"; case "latest": return "verified-filled"; @@ -328,6 +526,10 @@ export interface GhRowOpts { title: string; titleSuffix?: HTMLElement[]; meta?: string; + /** LIVE meta: segments joined with " · " — strings render muted, elements + * (e.g. a clickable branch/author/repo) render as handed. Takes precedence + * over `meta`; this is what turned ~40 inert row-meta strings into links. */ + metaSegments?: Array<string | HTMLElement>; /** Tooltip for the meta line (e.g. an absolute date behind a relative time). */ metaTitle?: string; chips?: HTMLElement[]; @@ -350,7 +552,16 @@ export function ghRow(o: GhRowOpts): HTMLElement { head.appendChild(title); for (const s of o.titleSuffix ?? []) head.appendChild(s); body.appendChild(head); - if (o.meta) { + if (o.metaSegments?.length) { + const sub = el("div", "gh-row-sub"); + o.metaSegments.forEach((seg, i) => { + if (i > 0) sub.appendChild(span(" · ", "gh-sub-sep")); + if (typeof seg === "string") sub.appendChild(span(seg)); + else sub.appendChild(seg); + }); + if (o.metaTitle) sub.title = o.metaTitle; + body.appendChild(sub); + } else if (o.meta) { const sub = el("div", "gh-row-sub"); sub.textContent = o.meta; if (o.metaTitle) sub.title = o.metaTitle; @@ -430,30 +641,47 @@ export function settingsCard(title: string, icon: string): { card: HTMLElement; } /** A labeled text field (Settings + composers). */ +let fieldSeq = 0; export function settingsField( label: string, value: string, placeholder: string, ): { row: HTMLElement; input: HTMLInputElement } { const row = el("div", "settings-field"); - const l = el("label", "settings-field-label"); + const l = el("label", "settings-field-label") as HTMLLabelElement; l.textContent = label; const input = document.createElement("input"); input.className = "settings-input"; input.value = value ?? ""; input.placeholder = placeholder; + // A <label> is only a label when it points at something. These were <label> + // elements sitting NEXT TO their inputs with no `for`, so the field's + // accessible name was its placeholder — and clicking the visible label, which + // every form on every platform focuses the field, did nothing at all. + input.id = `gs-field-${++fieldSeq}`; + l.htmlFor = input.id; row.append(l, input); return { row, input }; } -/** Copy text to the clipboard with toast feedback. */ +/** Copy text to the clipboard with toast feedback. + * + * navigator.clipboard.writeText rejects without focus or a user gesture (the + * device-flow AUTO-copy has no gesture), and used to reject on permission too + * — so Copy buttons "hard-errored". The main-process clipboard has none of + * those constraints; fall back to it over IPC before declaring failure. */ export async function copyText(text: string, successMsg = "Copied."): Promise<void> { try { await navigator.clipboard.writeText(text); - toast(successMsg, "success"); } catch { - toast("Couldn't copy to the clipboard.", "error"); + try { + await host.invoke("clipboard:write", text); + } catch { + toast("Couldn't copy to the clipboard.", "error"); + return; + } } + toast(successMsg, "success"); } /** Clean a user-facing message from an error / rejection (unwraps the IPC prefix). */ @@ -493,19 +721,14 @@ export function condenseGitOutput(text: string): string { return primary.replace(/^(fatal|error):\s*/i, ""); } -/** - * True for noise the global error boundary should swallow: Monaco's language - * worker rejecting unimplemented TS/JS service methods (we bundle only the base - * editor worker, not the language workers) + ResizeObserver loop warnings. +/* + * `isBenignError` moved to `./benignErrors` — a module with no browser globals + * in its import graph, so the rule about what the crash reporter may swallow + * can be TESTED. ui.ts pulls in bridge.ts, and bridge.ts touches `window` at + * import time; a test that reached this rule through here died on that. */ -export function isBenignError(message: string, source?: string): boolean { - const m = message || ""; - if (/Missing requestHandler or method/i.test(m)) return true; - if (/ResizeObserver loop/i.test(m)) return true; - if (/Canceled|Canceled: Canceled/i.test(m)) return true; - if (source && /editor\.worker(\.[a-z0-9]+)?\.js/i.test(source)) return true; - return false; -} +export { isBenignError } from "./benignErrors"; + /** The GitStudio brand mark, inline so it tracks the theme with no asset swap. * The merge-Y lanes terminate in ringed nodes: each node — the three ends and @@ -544,9 +767,21 @@ export interface MenuItem { current?: boolean; disabled?: boolean; separator?: boolean; + /** A destructive item — reads red, like the danger buttons it replaced. */ + danger?: boolean; + /** Hover text. A row that performs a git action should be able to say which + * ("Check out fix/log-stream") without relying on its label alone. */ + title?: string; /** Don't close the menu on click — for in-place live actions (e.g. Fetch, * which spins its own icon and refreshes the view behind the open menu). */ keepOpen?: boolean; + /** A row you TICK rather than a command you run: renders as + * role="menuitemcheckbox" with a live aria-checked, keeps the menu open, and + * flips its own tick before `onClick` fires (which receives the new state). + * The label picker used to close the whole menu after every single pick, so + * labelling an issue with three labels meant opening the menu three times + * and re-finding your place in it. */ + checkable?: boolean; /** Receives the rendered menuitem element, so keepOpen actions can drive a * live state on it (spinner, disabled) while they run. */ onClick?: (itemEl: HTMLElement) => void; @@ -556,11 +791,39 @@ export interface MenuItem { * otherwise appears only for long menus). */ export interface MenuOpts { searchable?: boolean; + /** Ran once when the menu closes, however it closed. Lets a multi-select menu + * commit the whole selection in one request instead of one per tick. */ + /** + * `reason` says HOW the menu closed, because for a multi-select that is the + * difference between committing and discarding: "escape" means back out, the + * way Escape means back out everywhere else in the app. A picker that batches + * its ticks and applies them in `onClose` used to write on EVERY dismissal — + * Escape, a click away, a route change — so the one key that means "cancel" + * was the key that sent the request. + */ + onClose?: (reason: "escape" | "dismiss" | "action") => void; } /** A lightweight popover menu anchored below `anchor`; full keyboard support. */ +/** The currently open menu's close fn. Removing the previous menu's ELEMENT + * (which is all this used to do) left its capture-phase document listeners + * attached and its anchor stuck at aria-expanded="true" — a stale handler that + * still answered Escape and refocused a detached anchor. */ +let liveMenuClose: ((restoreFocus?: boolean) => void) | null = null; + +/** Close an open dropdown, if any. */ +export function closeMenu(): void { + liveMenuClose?.(false); +} + export function openMenu(anchor: HTMLElement, items: MenuItem[], opts: MenuOpts = {}): void { - document.querySelectorAll(".dropdown").forEach((n) => n.remove()); + // Re-clicking the trigger of an open menu means CLOSE, the way every menu on + // every platform behaves. + if (anchor.getAttribute("aria-expanded") === "true") { + closeMenu(); + return; + } + closeMenu(); const menu = el("div", "dropdown"); menu.setAttribute("role", "menu"); const rect = anchor.getBoundingClientRect(); @@ -572,18 +835,41 @@ export function openMenu(anchor: HTMLElement, items: MenuItem[], opts: MenuOpts const rows: HTMLElement[] = []; const seps: HTMLElement[] = []; - const close = (restoreFocus = true): void => { + let closed = false; + const close = (restoreFocus = true, reason: "escape" | "dismiss" | "action" = "dismiss"): void => { + if (closed) return; + closed = true; + if (liveMenuClose === close) liveMenuClose = null; + layer.release(); menu.remove(); document.removeEventListener("mousedown", onDoc, true); document.removeEventListener("keydown", onKey, true); anchor.setAttribute("aria-expanded", "false"); - if (restoreFocus) anchor.focus(); + // Don't pull focus back to an anchor that a route change already detached. + if (restoreFocus && anchor.isConnected) anchor.focus(); + opts.onClose?.(reason); }; + liveMenuClose = close; + const layer = registerLayer(() => close(false), "menu"); const onDoc = (e: MouseEvent): void => { - if (!menu.contains(e.target as Node)) close(false); + // The ANCHOR is not "outside". This dismiss runs on a capturing mousedown, + // so clicking the trigger of an open menu closed it here and then the + // trigger's own click opened a fresh one — the menu appeared not to + // respond, and anything typed into its filter was silently thrown away. + // The anchor's own handler now sees aria-expanded="true" and just closes. + const t = e.target as Node; + if (menu.contains(t) || anchor === t || anchor.contains(t)) return; + close(false); }; /** Currently visible (not filtered-out) menuitem rows. */ const visible = (): HTMLElement[] => rows.filter((r) => !r.hidden); + /** The caret is in a text field, so the text field owns the caret keys. */ + const typingIn = (t: EventTarget | null): boolean => { + const e2 = t as HTMLElement | null; + return ( + !!e2 && (e2.tagName === "INPUT" || e2.tagName === "TEXTAREA" || e2.isContentEditable === true) + ); + }; const focusAt = (i: number): void => { const vis = visible(); if (!vis.length) return; @@ -595,17 +881,28 @@ export function openMenu(anchor: HTMLElement, items: MenuItem[], opts: MenuOpts const cur = vis.indexOf(document.activeElement as HTMLElement); if (e.key === "Escape") { e.preventDefault(); - close(); + // The menu owns this Escape; the surfaces beneath it stand down via + // `isMenuOpen()`. stopPropagation cannot do that job — every layer + // listens on `document` ITSELF, and listeners on the same node all run + // regardless. + e.stopPropagation(); + close(true, "escape"); } else if (e.key === "ArrowDown") { e.preventDefault(); focusAt(cur < 0 ? 0 : cur + 1); } else if (e.key === "ArrowUp") { e.preventDefault(); focusAt(cur < 0 ? vis.length - 1 : cur - 1); - } else if (e.key === "Home") { + } else if (e.key === "Home" && !typingIn(e.target)) { + // NOT while the caret is in the filter field. A searchable menu (more + // than 8 rows — the branch switcher, the label pickers) puts focus in a + // text input, where Home and End mean "start / end of the line". This + // handler claimed them unconditionally and yanked focus onto a row, so + // the very next Enter activated it — which in the branch switcher is a + // checkout. e.preventDefault(); focusAt(0); - } else if (e.key === "End") { + } else if (e.key === "End" && !typingIn(e.target)) { e.preventDefault(); focusAt(vis.length - 1); } else if ((e.key === "Enter" || (e.key === " " && cur >= 0))) { @@ -618,7 +915,11 @@ export function openMenu(anchor: HTMLElement, items: MenuItem[], opts: MenuOpts vis[0].click(); } } else if (e.key === "Tab") { - close(false); + // Hand the keyboard back to the ANCHOR, exactly as Escape does. `false` + // skipped the restore, so Tab out of any of the app's 29 menus dropped + // focus on <body> and the next Tab restarted at the top of the window — + // from a menu you had opened by pressing Tab to reach in the first place. + close(); } }; @@ -633,11 +934,16 @@ export function openMenu(anchor: HTMLElement, items: MenuItem[], opts: MenuOpts } const row = el( "button", - "dropdown-item" + (it.current ? " is-current" : "") + (it.disabled ? " is-disabled" : ""), + "dropdown-item" + + (it.current ? " is-current" : "") + + (it.disabled ? " is-disabled" : "") + + (it.danger ? " is-danger" : ""), ); - row.setAttribute("role", "menuitem"); + row.setAttribute("role", it.checkable ? "menuitemcheckbox" : "menuitem"); + if (it.checkable) row.setAttribute("aria-checked", it.current ? "true" : "false"); row.tabIndex = -1; if (it.disabled) row.setAttribute("aria-disabled", "true"); + if (it.title) row.title = it.title; if (it.current) row.setAttribute("aria-current", "true"); if (it.icon) row.appendChild(glyph(it.icon)); else if (it.iconEl) row.appendChild(it.iconEl); @@ -649,16 +955,35 @@ export function openMenu(anchor: HTMLElement, items: MenuItem[], opts: MenuOpts sub.textContent = it.sub; row.appendChild(sub); } - if (it.current) row.appendChild(glyph("check")); + if (it.checkable) { + const tick = glyph("check"); + tick.classList.add("dropdown-tick"); + tick.style.visibility = it.current ? "visible" : "hidden"; + row.appendChild(tick); + } else if (it.current) row.appendChild(glyph("check")); if (!it.disabled && it.onClick) { row.addEventListener("click", () => { + if (it.checkable) { + const next = row.getAttribute("aria-checked") !== "true"; + row.setAttribute("aria-checked", String(next)); + row.classList.toggle("is-current", next); + const tick = row.querySelector<HTMLElement>(".dropdown-tick"); + if (tick) tick.style.visibility = next ? "visible" : "hidden"; + it.onClick!(row); + return; + } // A keepOpen action runs in place (live spinner on the item); a busy // in-place action must not re-fire while it's still running. if (it.keepOpen) { if (!row.classList.contains("is-busy-item")) it.onClick!(row); return; } - close(false); + // Restore focus to the trigger BEFORE running the action. openModal + // captures `document.activeElement` as the place to return the keyboard + // to, and closing the menu without restoring left that as <body> — so + // dismissing a dialog opened from a menu stranded the keyboard at the + // top of the document instead of on the control you had used. + close(true, "action"); it.onClick!(row); }); rows.push(row); @@ -692,13 +1017,26 @@ export function openMenu(anchor: HTMLElement, items: MenuItem[], opts: MenuOpts } document.body.appendChild(menu); - const mr = menu.getBoundingClientRect(); - if (mr.right > window.innerWidth - 8) { - menu.style.left = `${Math.round(window.innerWidth - mr.width - 8)}px`; - } - if (mr.bottom > window.innerHeight - 8) { - menu.style.top = `${Math.round(Math.max(8, rect.top - mr.height - 5))}px`; - } + // Keep an 8px margin on every side. The old version rounded a fractional + // width into the clamp and computed the flip from the ANCHOR rather than from + // where the menu actually ended up, so a wide menu near the right edge landed + // 1px off the window and a tall one could still hang below the fold. + const GAP = 8; + menu.style.maxWidth = `${Math.max(160, window.innerWidth - GAP * 2)}px`; + menu.style.maxHeight = `${Math.max(160, window.innerHeight - GAP * 2)}px`; + const place = (): void => { + const m = menu.getBoundingClientRect(); + if (m.right > window.innerWidth - GAP) { + menu.style.left = `${Math.floor(window.innerWidth - m.width - GAP)}px`; + } + if (m.left < GAP) menu.style.left = `${GAP}px`; + if (m.bottom > window.innerHeight - GAP) { + const above = rect.top - m.height - 5; + menu.style.top = `${Math.floor(above >= GAP ? above : Math.max(GAP, window.innerHeight - m.height - GAP))}px`; + } + }; + place(); + place(); // a clamped max-width can rewrap the rows and change the height document.addEventListener("keydown", onKey, true); setTimeout(() => { document.addEventListener("mousedown", onDoc, true); @@ -732,6 +1070,11 @@ export function wireResizerKeys( step?: number; onCommit?: () => void; disabled?: () => boolean; + /** The measured pane is on the far side of the handle, so a LARGER value + * moves the handle the other way. Without this the graph's divider walked + * left when you pressed ArrowRight, while the identical-looking divider in + * Changes walked right — the same control, two opposite answers. */ + inverted?: boolean; }, ): void { const step = opts.step ?? 16; @@ -746,17 +1089,29 @@ export function wireResizerKeys( handle.setAttribute("aria-valuenow", String(Math.round(opts.get()))); }; sync(); + // …and again whenever the control is focused, which is the moment an + // assistive technology reads the values out. `max` is a FUNCTION of the + // current layout for several of these dividers, and it was sampled once at + // wire time — before the pane had been laid out, and never again after a + // window resize. The graph's details divider announced itself as pinned at + // its maximum (320 of 320) while sitting at 420px with a real max of 584, so + // a screen-reader user was told the control could not move. + handle.addEventListener("focus", () => sync()); handle.addEventListener("keydown", (e: KeyboardEvent) => { if (opts.disabled?.()) return; // vertical divider: Right grows the left pane. horizontal divider (bottom- // anchored): Up grows the lower pane. - const dec = opts.orientation === "vertical" ? "ArrowLeft" : "ArrowDown"; - const inc = opts.orientation === "vertical" ? "ArrowRight" : "ArrowUp"; + const towardStart = opts.orientation === "vertical" ? "ArrowLeft" : "ArrowDown"; + const towardEnd = opts.orientation === "vertical" ? "ArrowRight" : "ArrowUp"; + // The keys always move the HANDLE in the direction they name; `inverted` + // says which way the measured value has to go to achieve that. + const dec = opts.inverted ? towardEnd : towardStart; + const inc = opts.inverted ? towardStart : towardEnd; let next: number | undefined; if (e.key === dec) next = opts.get() - (e.shiftKey ? step * 3 : step); else if (e.key === inc) next = opts.get() + (e.shiftKey ? step * 3 : step); - else if (e.key === "Home") next = opts.min; - else if (e.key === "End") next = opts.max(); + else if (e.key === "Home") next = opts.inverted ? opts.max() : opts.min; + else if (e.key === "End") next = opts.inverted ? opts.min : opts.max(); if (next === undefined) return; e.preventDefault(); opts.set(Math.max(opts.min, Math.min(opts.max(), next))); @@ -766,3 +1121,56 @@ export function wireResizerKeys( // Keep aria-valuenow honest after a pointer drag, too. handle.addEventListener("pointerup", () => sync()); } + +let segSeq = 0; +/** + * Give a hand-rolled segmented control the semantics it looks like it has. + * + * Settings and Compare each built one out of plain buttons carrying an `active` + * CLASS: a screen reader heard N unrelated buttons, with no group name and no + * way to tell which one was chosen. This attaches role="group", names the group + * from its own visible label, and keeps `aria-pressed` in step with the class + * however the caller toggles it — a delegated click listener re-syncs, so no + * existing toggle code has to change. + */ +export function markSegment(seg: HTMLElement, ariaLabel: string | HTMLElement, btnSel = "button"): void { + seg.setAttribute("role", "group"); + if (typeof ariaLabel === "string") seg.setAttribute("aria-label", ariaLabel); + else { + if (!ariaLabel.id) ariaLabel.id = `gs-seg-lbl-${++segSeq}`; + seg.setAttribute("aria-labelledby", ariaLabel.id); + } + const sync = (): void => { + for (const b of seg.querySelectorAll<HTMLElement>(btnSel)) { + b.setAttribute("aria-pressed", String(b.classList.contains("active"))); + } + }; + sync(); + seg.addEventListener("click", () => queueMicrotask(sync)); +} + +/** + * The longest directory prefix every path shares. + * + * A commit or a pull request usually touches one area, so without this every + * row reads `apps/desktop/src/renderer/views/…` and the only distinguishing + * part — the filename — is what gets truncated away. Worse, a list that + * truncates from the LEFT produces three different elisions of the same prefix + * ("…src/renderer/views", "…rc/renderer/views", "…top/src/renderer") and the + * reader cannot tell whether two rows are in the same folder. + * + * Shown once above the list instead, which is better than GitHub, where every + * row carries the full path. + * + * DIRECTORY boundaries only: `logView.ts` and `logModel.ts` share the + * characters "log" and share no directory, and folding on characters would + * leave rows reading "View.ts" and "Model.ts". + */ +export function commonDir(paths: string[]): string { + if (paths.length < 2) return ""; + const split = paths.map((p) => p.split("/")); + const first = split[0]; + let n = 0; + while (n < first.length - 1 && split.every((x) => x.length > n + 1 && x[n] === first[n])) n++; + return n ? `${first.slice(0, n).join("/")}/` : ""; +} diff --git a/apps/desktop/src/renderer/views/actions.ts b/apps/desktop/src/renderer/views/actions.ts index 22f6253..d0116e4 100644 --- a/apps/desktop/src/renderer/views/actions.ts +++ b/apps/desktop/src/renderer/views/actions.ts @@ -1,21 +1,23 @@ -// The Actions section view — GitHub Actions runs, jobs/steps, workflows, and a -// manual `workflow_dispatch` flow. Repo-scoped (NEEDS_REPO=true). Renders the -// same two-pane shell as Pull Requests / Issues: a runs list on the left, a -// detail pane on the right (run → jobs → steps, or the workflows list, or the -// dispatch form). Reuses every shared primitive; adds no new modal API — the -// dispatch form renders INTO the detail pane (dialogs.ts's promptInline is -// single-field only). +// The Actions section — GitHub Actions runs, jobs/steps, workflows, and the +// manual `workflow_dispatch` flow, on the section-page system +// (docs/desktop-redesign.md): a full-width list page (Runs | Workflows segment) +// whose run rows navigate to a full-page run detail (routed via `target.number` +// = the run id), with the run's facts in the right rail and jobs, steps, and +// artifacts in the content column. Dispatch is a modal; logs stay in the +// in-app viewer overlay; the Secrets & Variables manager stays a modal. // -// Re-render contract: the view re-renders itself by calling `renderActions`. -// READ invokes are wrapped in try/catch → errorState + Retry; MUTATION invokes -// return `{ ok, message }` and toast. +// READ invokes go through the SWR cache (peek → instant paint, gget → +// revalidate); MUTATIONS return `{ ok, message }`, toast, bust("actions") and +// re-render. import { host } from "../bridge"; +import { peek as cachePeek, gget, bust } from "../cache"; import { + avatar, el, span, + subLink, glyph, - pill, relTimeISO, absTimeISO, loadingState, @@ -26,24 +28,40 @@ import { copyText, formatBytes, openMenu, - ghRow, - statBit, } from "../ui"; -import { toast, confirmDialog, promptInline } from "../dialogs"; +import { toast, confirmDialog, promptInline, openModal } from "../dialogs"; import { + blankable, + facetBar, + harvestValues, + segmented, + type FacetBar, + type FacetState, + capNotice, comboField, + detailPage, ghGate, ghHeader, - ghListResizer, + LIST_CAPS, + personChip, + propSection, searchField, - trapTab, + secRow, + sectionList, + type GhGate, + type SectionNav, type SectionRender, + type SectionTarget, } from "./common"; +import { prime } from "../cache"; +import { setPageLabel } from "../navStack"; import type { + ActionsRunsFilter, ArtifactInfo, RepoSecretInfo, RepoVariableInfo, WorkflowRun, + WorkflowStep, WorkflowRunDetail, WorkflowJob, WorkflowInfo, @@ -58,265 +76,831 @@ const isLive = (status: string): boolean => LIVE_STATUSES.has(status); const btn = (className = ""): HTMLButtonElement => el("button", className) as HTMLButtonElement; -const emptyDetail = (detail: HTMLElement): void => { - detail.replaceChildren( - emptyState( - "Workflow runs", - "Select a run to inspect its jobs, steps, and status — or re-run, cancel, and open it on GitHub.", - { icon: "play", hint: "Tip: use “Run workflow” to dispatch one manually." }, - ), - ); -}; +/** "3m 42s" between two ISO stamps. For a LIVE run pass `end` empty — the + * duration runs to now (each poll repaint refreshes it). "" when unknown. */ +function fmtDuration(startIso: string, endIso: string): string { + const start = Date.parse(startIso); + if (!Number.isFinite(start)) return ""; + const end = endIso ? Date.parse(endIso) : Date.now(); + if (!Number.isFinite(end)) return ""; + const s = Math.max(0, Math.round((end - start) / 1000)); + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`; + return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`; +} -export const renderActions: SectionRender = (wrap, nav): void => { - void mount(wrap, nav); -}; +/** A run's wall-clock duration: runStartedAt → updatedAt (or now while live). */ +function runDuration(r: WorkflowRun): string { + if (!r.runStartedAt) return ""; + return fmtDuration(r.runStartedAt, isLive(r.status) ? "" : r.updatedAt); +} -async function mount(wrap: HTMLElement, nav: (view: string) => void): Promise<void> { - const refresh = (): void => renderActions(wrap, nav); +/** The section's router, captured at mount so branch chips can navigate. */ +let sectionNav: SectionNav | undefined; +/** Which list the section shows: workflow runs, or the workflow files. */ +let actionsTab: "runs" | "workflows" = "runs"; +/** The list page's live search query — survives list ⇄ detail round trips. */ +let query = ""; +/** Server-side run filters, kept across re-renders (like `query`). */ +const runFacetState: FacetState = {}; + +export const renderActions: SectionRender = (wrap, nav, target): void => { + sectionNav = nav; + void mount(wrap, nav, target); +}; - const gate = await ghGate(wrap, nav, true); +async function mount(wrap: HTMLElement, nav: SectionNav, target?: SectionTarget): Promise<void> { + const refresh = (): void => { + bust("actions"); + renderActions(wrap, nav, target); + }; + const gate = await ghGate(wrap, nav, true, refresh); if (!gate) return; - const view = el("div", "gh-view"); + if (target?.number != null) { + // A run asked for WITH a job is a request to read that job's log, and the + // log has its own route now — a page rather than a pane on this one. + if (target.jobId != null) { + nav("joblog", { number: target.number, jobId: target.jobId }); + return; + } + showRunDetailPage(wrap, nav, target.number); + return; + } + await listPage(wrap, nav, gate); +} + +// ── The list page (Runs | Workflows) ───────────────────────────────────────── + +async function listPage(wrap: HTMLElement, nav: SectionNav, gate: GhGate): Promise<void> { + const refresh = (): void => { + bust("actions"); + renderActions(wrap, nav); + }; + + const { view, listEl } = sectionList(); + // Every facet here is SERVER-side: GitHub filters runs by workflow, branch, + // actor, event and status, so narrowing fetches a different (and deeper) + // slice rather than hiding rows from the 200 already on screen. The filter + // object IS the cache key, so each combination caches independently. + const runFilter = (): ActionsRunsFilter | undefined => { + const v = runFacetState; + const f: ActionsRunsFilter = {}; + if (v.workflowId) f.workflowId = Number(v.workflowId); + if (v.branch) f.branch = v.branch; + if (v.actor) f.actor = v.actor; + if (v.event) f.event = v.event; + if (v.status) f.status = v.status; + return Object.keys(f).length ? f : undefined; + }; const header = ghHeader("Actions", gate.login, refresh); - // Toolbar: "Workflows" (list) + "Run workflow" (dispatch). Slotted to the left - // of the account cluster so it reads right-to-left: tools · @login · refresh. const tools = el("div", "gh-head-tools"); - const wfBtn = el("button", "mini-btn"); - wfBtn.append(glyph("list-unordered"), span("Workflows")); - wfBtn.title = "List this repo's workflows"; + const seg = segmented<"runs" | "workflows">({ + options: [ + { value: "runs", label: "Runs" }, + { value: "workflows", label: "Workflows" }, + ], + value: actionsTab, + ariaLabel: "Actions view", + onChange: (v) => { + actionsTab = v; + renderActions(wrap, nav); + }, + }); + const secretsBtn = el("button", "mini-btn"); secretsBtn.append(glyph("lock"), span("Secrets")); secretsBtn.title = "Manage this repo's Actions secrets and variables"; + secretsBtn.addEventListener("click", () => openSecretsManager()); + const runBtn = el("button", "btn btn-primary gh-run-btn"); runBtn.append(glyph("play"), span("Run workflow")); runBtn.title = "Manually trigger a workflow_dispatch"; - tools.append(wfBtn, secretsBtn, runBtn); - header.insertBefore(tools, header.querySelector(".gh-acct")); - - const body = el("div", "gh-body"); - const listEl = el("div", "gh-list"); - const detail = el("div", "gh-detail"); - body.append(listEl, ghListResizer(listEl), detail); - view.append(header, body); + runBtn.addEventListener("click", () => void openDispatch(runBtn, refresh)); + + // The facet slot is ALWAYS in the row, empty on Workflows. It is what holds + // the row's slack, so the segment stays at the row's left edge and the verbs + // stay at its right whichever tab you're on — without it, Workflows (which + // has no facets) let flex-end shove the segment 620px right. + const facetSlot = el("div", "gh-facet-slot"); + // This row's control set changes with the tab (Runs has five facets, Workflows + // none), so it claims its own line and stops moving between them. + tools.classList.add("gh-tools-own-line"); + tools.append(seg, facetSlot, secretsBtn, runBtn); + header.querySelector(".gh-acct")?.before(tools); + view.append(header, listEl); wrap.replaceChildren(view); - emptyDetail(detail); - wfBtn.addEventListener("click", () => void showWorkflowsList(detail)); - secretsBtn.addEventListener("click", () => openSecretsManager()); - runBtn.addEventListener("click", () => void openDispatch(view, detail)); + header.querySelector(".gh-head-titlewrap")?.appendChild( + searchField({ + placeholder: actionsTab === "runs" ? "Search runs…" : "Search workflows…", + initial: query, + onInput: (q) => { + query = q; + rerenderList(); + }, + }), + ); - // ── Runs list ── - listEl.replaceChildren(skeletonList(5)); - let runs: WorkflowRun[]; - try { - runs = await host.invoke("actions:runs", undefined); - } catch (e) { - listEl.replaceChildren( - errorState("Couldn't load workflow runs", cleanErr(e) || "GitHub request failed.", refresh), - ); - return; + + let facets: FacetBar<WorkflowRun> | undefined; + + /** Re-fetch after a server-side facet change (skeleton while it lands). */ + const reloadRuns = async (): Promise<void> => { + listEl.replaceChildren(skeletonList(6)); + try { + const fresh = await gget("actions:runs", runFilter(), 10000); + if (!view.isConnected) return; + runs = fresh; + facets?.sync(runs); + rerenderList(); + scheduleListPoll(); + } catch (e) { + if (!view.isConnected) return; + listEl.replaceChildren( + errorState("Couldn't load workflow runs", cleanErr(e) || "GitHub request failed.", () => void reloadRuns()), + ); + } + }; + + // ── data ── + let runs: WorkflowRun[] | undefined = + actionsTab === "runs" ? cachePeek("actions:runs", runFilter()) : undefined; + let workflows: WorkflowInfo[] | undefined = + actionsTab === "workflows" ? cachePeek("actions:workflows", undefined) : undefined; + if ((actionsTab === "runs" && !runs) || (actionsTab === "workflows" && !workflows)) { + listEl.replaceChildren(skeletonList(6)); } - header.setCount?.(runs.length); - listEl.replaceChildren(); - if (runs.length === 0) { - listEl.appendChild( - emptyState("No workflow runs", "No GitHub Actions runs found for this repository.", { - icon: "play", - action: { - label: "Run workflow", - icon: "play", - onClick: () => void openDispatch(view, detail), + + // Runs only — "filter workflows by branch" means nothing. + if (actionsTab === "runs") { + facets = facetBar<WorkflowRun>({ + specs: [ + { + key: "workflowId", + label: "Workflow", + icon: "play-circle", + // The rows carry a workflow NAME but the API wants its id, and a + // filtered list can't name workflows it excluded — so load them. + load: async () => { + const list = await gget("actions:workflows", undefined, 60000); + return list.map((w) => ({ value: String(w.id), label: w.name })); + }, }, - }), - ); - return; + { key: "branch", label: "Branch", icon: "git-branch", harvest: harvestValues<WorkflowRun>((r) => r.branch) }, + { key: "actor", label: "Actor", icon: "person", harvest: harvestValues<WorkflowRun>((r) => r.actor?.login) }, + { + key: "event", + label: "Event", + icon: "zap", + // "pull_request" in the menu beside "pull request" on the row was the + // same mismatch the Inbox had. + harvest: harvestValues<WorkflowRun>((r) => r.event, (v) => v.replace(/_/g, " ")), + }, + { + key: "status", + label: "Status", + icon: "pulse", + // The five states are drawn with a colour and a glyph on every run + // row, every job card and every step; in this menu they were plain + // text, so the one place you PICK a state was the one place it had + // no shape. Same lead icon the rows use. + options: [ + { value: "success", label: "Success", iconEl: () => runLead("success") }, + { value: "failure", label: "Failure", iconEl: () => runLead("failure") }, + { value: "in_progress", label: "In progress", iconEl: () => runLead("in_progress") }, + { value: "queued", label: "Queued", iconEl: () => runLead("queued") }, + { value: "cancelled", label: "Cancelled", iconEl: () => runLead("cancelled") }, + ], + }, + ], + state: runFacetState, + items: runs ?? [], + onChange: () => { + // A server facet changes WHAT WE ASK FOR, so re-fetch rather than + // re-filter — the point is to reach runs the unfiltered page never had. + void reloadRuns(); + }, + }); + facetSlot.replaceChildren(facets.el); } - const select = (r: WorkflowRun, row: HTMLElement): void => { - listEl.querySelectorAll(".gh-row.active").forEach((n) => n.classList.remove("active")); - row.classList.add("active"); - void showRunDetail(detail, r); - }; - - const buildRow = (r: WorkflowRun): HTMLElement => { - const row = runRow(r); - row.addEventListener("click", () => select(r, row)); + const runMatches = (r: WorkflowRun, q: string): boolean => + `${r.name} ${r.displayTitle} ${r.branch} ${r.event} ${r.actor?.login ?? ""} #${r.runNumber}` + .toLowerCase() + .includes(q); + const wfMatches = (w: WorkflowInfo, q: string): boolean => + `${w.name} ${w.path}`.toLowerCase().includes(q); + + const buildRunRow = (r: WorkflowRun): HTMLElement => { + const state = r.conclusion || r.status || ""; + // The workflow's name rides as a muted suffix after the run's own title — + // GitHub-style "<commit subject> · Desktop CI". + // A scheduled run's title IS its workflow name, so the suffix printed the + // same string twice in a row ("Nightly release Nightly release"). + const suffix: HTMLElement[] = r.name && r.name !== r.displayTitle ? [span(r.name, "sec-run-wf")] : []; + if (r.runAttempt > 1) { + const att = el("span", "gh-pill sec-attempt"); + att.textContent = `attempt ${r.runAttempt}`; + att.title = "This run was re-run"; + suffix.push(att); + } + // Same column contract as the other lists: every optional slot is + // reserved, so a run without a branch doesn't slide the durations out of + // line with the row above it. + const meta: HTMLElement[] = []; + meta.push(blankable(avatar(r.actor?.login ?? "?", r.actor?.avatarUrl ?? null, 18, "Actor"), !!r.actor)); + // Branch names run from "main" to "redesign/issues-detail"; without a floor + // the column moved the ACTOR AVATAR to its left by the difference, so the + // avatars zig-zagged down the list. + const branchEl = blankable( + subLink(r.branch || "—", `Show ${r.branch} in Branches`, () => + sectionNav?.("branches", { ref: r.branch }), + ), + !!r.branch, + ); + branchEl.classList.add("sec-run-branch"); + meta.push(branchEl); + meta.push(blankable(span(r.event.replace(/_/g, " "), "sec-run-event"), !!r.event)); + const dur = runDuration(r); + meta.push(blankable(span(dur || "—", "sec-run-dur"), !!dur)); + // The status word is dropped: the coloured lead icon already says it, and + // it carried a 72px min-width that pushed everything else out of line. The + // icon and the row's aria-label keep it available to a screen reader. + const row = secRow({ + lead: runLead(state, prettyState(state) || "unknown"), + num: `#${r.runNumber || r.id}`, + title: r.displayTitle, + titleSuffix: suffix, + meta, + time: relTimeISO(r.createdAt), + timeTitle: r.createdAt ? `Created ${absTimeISO(r.createdAt)}` : undefined, + ariaLabel: `Workflow run ${r.name} #${r.runNumber}: ${prettyState(state) || "unknown"}`, + onOpen: () => nav("actions", { number: r.id }), + }); + row.dataset.num = String(r.id); return row; }; - // Case-insensitive match over the fields a user would search by. - const matches = (r: WorkflowRun, q: string): boolean => { - const hay = `${r.name} ${r.branch} ${r.event} #${r.id}`.toLowerCase(); - return hay.includes(q); + const buildWfRow = (w: WorkflowInfo): HTMLElement => { + const disabled = w.state !== "active"; + // A workflow row was a name at the far left and a path at the far right + // with ~1000px of nothing between them, and carried no state at all — you + // could not tell from this list whether a workflow had ever run. + const last = runs?.find((r) => r.workflowId === w.id); + const meta: HTMLElement[] = []; + if (last) { + meta.push(runLead(last.conclusion || last.status || "", prettyState(last.conclusion || last.status || ""))); + meta.push(span(`#${last.runNumber || last.id}`, "sec-run-dur")); + } else if (runs) { + meta.push(span("never run")); + } + if (disabled) meta.push(span(w.state.replace(/_/g, " "), "gh-pill")); + return secRow({ + lead: (() => { + const s = el("span", "gh-lead-icon"); + s.appendChild(glyph("play-circle")); + if (disabled) s.classList.add("is-muted"); + return s; + })(), + title: w.name, + // The file name identifies a workflow; the ".github/workflows/" prefix + // is the same on every row and was eating the width. + titleSuffix: [span(w.path.split("/").pop() ?? w.path, "sec-run-wf")], + meta, + time: last ? relTimeISO(last.createdAt) : undefined, + ariaLabel: `Workflow ${w.name}${disabled ? " (disabled)" : ""}`, + onOpen: () => { + if (disabled) { + toast("This workflow is disabled on GitHub.", "info"); + return; + } + void showDispatchModal(w, refresh); + }, + }); }; - let autoSelected = false; - const renderList = (items: WorkflowRun[], q = ""): void => { + const rerenderList = (): void => { + if (runs) facets?.sync(runs); + const q = query.toLowerCase(); listEl.replaceChildren(); - if (items.length === 0) { - listEl.appendChild( - emptyState("No matching runs", `Nothing matches “${q}”.`, { icon: "search" }), - ); - return; - } - for (const r of items) listEl.appendChild(buildRow(r)); - // Auto-select the first run once (initial render) so the jobs panel shows; - // don't hijack the selection on every keystroke while filtering. - if (!autoSelected) { - autoSelected = true; - const first = items[0]; - const firstRow = listEl.firstElementChild as HTMLElement | null; - if (first && firstRow) select(first, firstRow); + if (actionsTab === "runs") { + if (!runs) return; + if (runs.length === 0) { + listEl.appendChild( + emptyState("No workflow runs", "No GitHub Actions runs found for this repository.", { + icon: "play", + action: { label: "Run workflow", icon: "play", onClick: () => void openDispatch(runBtn, refresh) }, + }), + ); + return; + } + const items = q ? runs.filter((r) => runMatches(r, q)) : runs; + // AFTER the filter, and with both numbers: the pill used to advertise + // the unfiltered total directly above a "No matching …" empty state. + header.setCount?.(items.length, runs.length); + if (items.length === 0) { + listEl.appendChild(emptyState("No matching runs", `Nothing matches “${query}”.`, { icon: "search", anchor: "inline" })); + return; + } + for (const r of items) listEl.appendChild(buildRunRow(r)); + // "server": the runs list IS GitHub's answer to the current filter, so + // telling the user to "search to narrow" would be a lie — the filters + // above are what reach further back. + const cap = capNotice(runs.length, LIST_CAPS.runs, "server"); + if (cap) listEl.appendChild(cap); + } else { + if (!workflows) return; + if (workflows.length === 0) { + listEl.appendChild( + emptyState("No workflows", "This repo has no .github/workflows files.", { icon: "play" }), + ); + return; + } + const items = q ? workflows.filter((w) => wfMatches(w, q)) : workflows; + // AFTER the filter, and with both numbers: the pill used to advertise + // the unfiltered total directly above a "No matching …" empty state. + header.setCount?.(items.length, workflows.length); + if (items.length === 0) { + listEl.appendChild(emptyState("No matching workflows", `Nothing matches “${query}”.`, { icon: "search", anchor: "inline" })); + return; + } + for (const w of items) listEl.appendChild(buildWfRow(w)); } }; - // A header search/filter — on the LEFT, next to the title (client-side, instant). - header.querySelector(".gh-head-titlewrap")?.appendChild( - searchField({ - placeholder: "Search runs…", - onInput: (q) => renderList(q ? runs.filter((r) => matches(r, q.toLowerCase())) : runs, q), - }), - ); - - renderList(runs); -} + if (runs || workflows) rerenderList(); + + // While any run is LIVE, quietly re-fetch the list every 12s and repaint in + // place — a CI dashboard that only updates on manual refresh isn't one. + const scheduleListPoll = (): void => { + if (actionsTab !== "runs" || !runs?.some((r) => isLive(r.status))) return; + window.setTimeout(() => { + if (!view.isConnected || actionsTab !== "runs") return; + // The poll must ask the SAME question the view is showing — polling + // unfiltered would quietly replace a filtered list with everything. + const f = runFilter(); + host + .invoke("actions:runs", f) + .then((fresh) => { + if (!view.isConnected) return; + prime("actions:runs", f, fresh); + runs = fresh; + facets?.sync(runs); + rerenderList(); + scheduleListPoll(); + }) + .catch(() => scheduleListPoll()); // transient failure — keep watching + }, 12000); + }; -/** One rich run row: a colored status lead icon, the run name + #id, then a - * muted branch · event · when line. */ -function runRow(r: WorkflowRun): HTMLElement { - const state = r.conclusion || r.status || ""; - const when = relTimeISO(r.createdAt); - const meta = [r.branch, r.event].filter(Boolean).join(" · ") + (when ? ` · ${when}` : ""); - return ghRow({ - lead: runLead(state), - title: `${r.name} #${r.id}`, - stats: [statBit("", prettyState(state) || "—")], - meta, - metaTitle: r.createdAt ? `Created ${absTimeISO(r.createdAt)}` : undefined, - ariaLabel: `Workflow run ${r.name} #${r.id}: ${prettyState(state) || "unknown"}`, - }); + try { + if (actionsTab === "runs") { + const fresh = await gget("actions:runs", runFilter(), 10000); + if (!view.isConnected) return; + runs = fresh; + facets?.sync(runs); + } else { + const [fresh, recent] = await Promise.all([ + gget("actions:workflows", undefined, 60000), + // The workflow rows show each workflow's last run. This shares the + // Runs tab's cache key, so switching tabs is free after the first + // load — and without it the rows would have to claim "never run" + // when the truth is only that we had not looked. + gget("actions:runs", undefined, 10000).catch(() => [] as WorkflowRun[]), + ]); + if (!view.isConnected) return; + workflows = fresh; + runs = recent; + } + rerenderList(); + scheduleListPoll(); + } catch (e) { + if (!view.isConnected) return; + if (!runs && !workflows) { + listEl.replaceChildren( + errorState( + actionsTab === "runs" ? "Couldn't load workflow runs" : "Couldn't load workflows", + cleanErr(e) || "GitHub request failed.", + refresh, + ), + ); + } + } } /** A colored leading status icon for a run, keyed off its conclusion/status. */ -function runLead(state: string): HTMLElement { +function runLead(state: string, label?: string): HTMLElement { let icon = "sync"; - let color = "var(--status-mod)"; // in_progress / queued / pending → blue/amber + let cls = "is-running"; // in_progress / queued / pending if (state === "success") { icon = "pass-filled"; - color = "var(--status-add)"; + cls = "is-success"; } else if ( state === "failure" || state === "error" || - state === "cancelled" || - state === "timed_out" || state === "startup_failure" || - state === "action_required" || - state === "stale" + state === "timed_out" ) { + // Failure was drawn as a hollow ring next to a SOLID success disc, so at a + // glance down a list of runs the failures read as the quieter ones. The + // state that needs you is the state that carries the weight. + // + // A timeout belongs here: the run did not finish, and nobody chose that. icon = "error"; - color = "var(--status-del)"; - } else if (state === "skipped" || state === "neutral") { + cls = "is-failure"; + } else if (state === "action_required") { + // Not a failure — it is waiting for a person. Drawing it red sent people to + // read logs for an error that had not happened. + icon = "warning"; + cls = "is-running"; + } else if ( + state === "cancelled" || + state === "stale" || + state === "skipped" || + state === "neutral" + ) { + // A CANCELLED run is not a failed one — somebody stopped it on purpose, and + // usually that somebody is you. It used to fall into the failure bucket + // here while the run's own page and the status filter both drew it muted, + // so the same run was red in the list and a non-event everywhere else. icon = "circle-slash"; - color = "var(--app-muted)"; + cls = "is-muted"; + } + const s = el("span", `gh-lead-icon run-lead ${cls}`); + // The icon is now the only place the status is stated, so it has to be + // readable — by hover and by a screen reader. + if (label) { + s.title = label; + s.setAttribute("aria-label", label); + s.setAttribute("role", "img"); } - const s = el("span", "gh-lead-icon"); - s.style.color = color; s.appendChild(glyph(icon)); return s; } -// ── Run detail ──────────────────────────────────────────────────────────────── +// ── The run detail page ────────────────────────────────────────────────────── -async function showRunDetail(detail: HTMLElement, run: WorkflowRun): Promise<void> { - detail.replaceChildren(loadingState()); - let d: WorkflowRunDetail | undefined; - try { - d = await host.invoke("actions:runDetail", run.id); - } catch (e) { - detail.replaceChildren( - errorState("Couldn't load the run", cleanErr(e) || "GitHub request failed.", () => - void showRunDetail(detail, run), - ), - ); - return; +/** + * Job cards the user expanded, per run — preserved across live-poll repaints. + * + * The set is SEEDED with every job the first time a run's cards are built, so + * it only ever means "exactly these are open". It used to be read as + * `size === 0 || has(id)`, where an empty set also meant "nothing chosen yet, + * so show everything" — and the first click then silently redefined every + * OTHER card: collapse the one job you were done with and all its siblings + * collapsed with it, because the set stopped being empty. + */ +const expandedJobs = new Set<number>(); +/** Jobs already given their default. A running workflow gains jobs as it goes, + * and one that starts after you collapsed something must still open itself — + * so the default is applied per job on first sight, not once per run. */ +const seededJobs = new Set<number>(); +let lastRunDetailId: number | undefined; +let lastRunAttempt = 0; + +function showRunDetailPage(wrap: HTMLElement, nav: SectionNav, id: number): void { + if (lastRunDetailId !== id) { + lastRunDetailId = id; + lastRunAttempt = 0; + expandedJobs.clear(); + seededJobs.clear(); } - const full = d?.run ?? run; + const back = (): void => nav("actions", { list: true }); + const reload = (): void => { + bust("actions"); + showRunDetailPage(wrap, nav, id); + }; + + const { view, main, rail, topActions } = detailPage({ + backLabel: "Actions", + // The run NUMBER is the run's identity — the crumb used to show the + // internal id ("#9100") while the title showed "#411", giving one run two + // numbers on one screen. Set once the run loads (see paint()). + crumb: "Run", + onBack: back, + }); + main.appendChild(skeletonList(4, false)); + wrap.replaceChildren(view); + + // A LIVE run keeps its page honest: re-fetch every 8s and repaint only when + // something actually changed (job/step states), stopping once it concludes. + let lastSig = ""; + const schedulePoll = (current: WorkflowRunDetail): void => { + if (!isLive(current.run.status)) return; + window.setTimeout(() => { + if (!view.isConnected) return; + host + .invoke("actions:runDetail", id) + .then((fresh) => { + if (!view.isConnected || !fresh) return; + prime("actions:runDetail", id, fresh); + const sig = JSON.stringify(fresh); + if (sig !== lastSig) { + lastSig = sig; + buildRunDetail({ main, rail, topActions, d: fresh, reload }); + } + schedulePoll(fresh); + }) + .catch(() => schedulePoll(current)); // transient failure — keep watching + }, 8000); + }; + + void (async () => { + let d: WorkflowRunDetail | undefined; + try { + d = await gget("actions:runDetail", id, 5000); + } catch (e) { + if (!view.isConnected) return; + main.replaceChildren( + errorState("Couldn't load the run", cleanErr(e) || "GitHub request failed.", reload), + ); + return; + } + if (!view.isConnected) return; + if (!d) { + main.replaceChildren(emptyState("Run unavailable", "This workflow run couldn't be loaded.")); + return; + } + lastSig = JSON.stringify(d); + buildRunDetail({ main, rail, topActions, d, reload }); + schedulePoll(d); + })(); +} + +interface RunDetailCtx { + main: HTMLElement; + rail: HTMLElement; + topActions: HTMLElement; + d: WorkflowRunDetail; + reload: () => void; +} + +function buildRunDetail(ctx: RunDetailCtx): void { + const { main, rail, topActions, d, reload } = ctx; + const full = d.run; + runMaxStepSec = Math.max(0, ...d.jobs.flatMap((j) => j.steps.map(stepSeconds))); + // One identity for this run, everywhere on the page: the run NUMBER. The + // crumb used to carry the internal id ("#9100") while the title showed + // "#411" — the same run wearing two numbers 40px apart. + // Every string that names this run to the user reads from this one value — + // including the re-run/cancel dialogs and toasts, which used to print the + // internal id and so named a run that appears nowhere in the Actions list. + const runNum = full.runNumber || full.id; + const crumbEl = main.closest(".det-view")?.querySelector<HTMLElement>(".det-crumb"); + if (crumbEl) crumbEl.textContent = `#${runNum}`; + // The run's identity is only known once it loads, so the page names itself + // here rather than at construction. A page opened FROM this one then says + // "← Run #411" instead of "← Actions". + setPageLabel(`Run #${runNum}`); const state = full.conclusion || full.status || ""; const live = isLive(full.status); - detail.replaceChildren(); - - const head = el("div", "gh-detail-head"); - const h = el("div", "gh-detail-title"); - h.textContent = full.name; - const meta = el("div", "gh-detail-meta"); - const when = relTimeISO(full.createdAt); - const metaParts = [`#${full.id}`, full.branch, full.event].filter(Boolean); - if (when) metaParts.push(when); - const metaText = el("span", "gh-meta-text"); - metaText.textContent = metaParts.join(" · ") + " · "; - meta.appendChild(metaText); - const statePill = pill(prettyState(state) || "—"); - statePill.classList.add(`gh-checks-${state}`); - meta.appendChild(statePill); - - const actions = el("div", "gh-detail-actions"); + // A re-run attempt REPLACES the logs — every pane restarts from zero. + lastRunAttempt = full.runAttempt; + main.replaceChildren(); + rail.replaceChildren(); + // ── top-bar actions ── const rerunBtn = btn("mini-btn"); rerunBtn.append(glyph("refresh"), span("Re-run")); - rerunBtn.title = "Re-run all jobs in this run"; + // A disabled button that still promises what it would do is a button you + // keep clicking. When it can't act, its tooltip says why instead. rerunBtn.disabled = live; - rerunBtn.addEventListener("click", () => void rerunRun(full.id, rerunBtn, detail, run)); + rerunBtn.title = live + ? "This run is still going — you can't re-run it until it finishes" + : "Re-run all jobs in this run"; + rerunBtn.addEventListener("click", () => void rerunRun(full.id, runNum, rerunBtn, reload)); const rerunFailedBtn = btn("mini-btn"); rerunFailedBtn.append(glyph("debug-restart"), span("Re-run failed")); - rerunFailedBtn.title = "Re-run only the failed jobs"; - rerunFailedBtn.disabled = full.conclusion === "success" || live; - rerunFailedBtn.addEventListener("click", () => void rerunFailed(full.id, rerunFailedBtn, detail, run)); + // Nothing failed, so there is nothing to re-run: don't show a dead control. + rerunFailedBtn.hidden = full.conclusion === "success" || live; + rerunFailedBtn.disabled = rerunFailedBtn.hidden; + rerunFailedBtn.title = rerunFailedBtn.disabled + ? "Nothing has failed in this run" + : "Re-run only the failed jobs"; + rerunFailedBtn.addEventListener("click", () => + void rerunFailed(full.id, runNum, rerunFailedBtn, reload), + ); const cancelBtn = btn("mini-btn danger"); cancelBtn.append(glyph("circle-slash"), span("Cancel")); cancelBtn.title = "Cancel this in-progress run"; + // A run that finished an hour ago cannot be cancelled; a greyed-out Cancel + // sitting there permanently is just noise. + cancelBtn.hidden = !live; cancelBtn.disabled = !live; - cancelBtn.addEventListener("click", () => void cancelRun(full.id, cancelBtn, detail, run)); - - // In-app logs: stream the whole run's aggregated logs into a viewer overlay — - // no browser hop. github.com stays reachable as a secondary link in the viewer. - const logsBtn = btn("mini-btn"); - logsBtn.append(glyph("output"), span("View logs")); - logsBtn.title = "View this run's logs in-app"; + cancelBtn.addEventListener("click", () => void cancelRun(full.id, runNum, cancelBtn, reload)); + + // The logs are a PAGE, not an accordion on this one. Expanding every job's + // log inline gave each of them a ~400px slot inside a page that was already + // scrolling — "the log window is too small" — and left two entry points able + // to put the same card in different states. + const logsBtn = btn("btn btn-primary"); + const failedJobs = d.jobs.filter((j) => j.conclusion === "failure" || j.conclusion === "timed_out"); + logsBtn.append(glyph("output"), span(failedJobs.length ? "Read the failing log" : "Read the logs")); + logsBtn.title = failedJobs.length + ? "Open the failing job's log full-window" + : "Open this run's logs full-window"; + logsBtn.disabled = d.jobs.length === 0; + if (logsBtn.disabled) logsBtn.title = "This run has no jobs yet"; logsBtn.addEventListener("click", () => - openLogViewer({ - title: `Logs · ${full.name} #${full.id}`, - htmlUrl: full.htmlUrl, - load: () => host.invoke("actions:runLog", { runId: full.id }), - }), + sectionNav?.("joblog", { number: full.id, jobId: (failedJobs[0] ?? d.jobs[0])?.id }), ); - actions.append(rerunBtn, rerunFailedBtn, cancelBtn, logsBtn); - head.append(h, meta, actions); - detail.appendChild(head); - - const jobs = d?.jobs ?? []; + const openBtn = btn("mini-btn gh-icon-btn"); + openBtn.append(glyph("link-external")); + openBtn.title = "Open this run on GitHub"; + openBtn.disabled = !full.htmlUrl; + if (openBtn.disabled) openBtn.title = "GitHub didn't give this run a link"; + openBtn.setAttribute("aria-label", openBtn.title); + openBtn.addEventListener("click", () => full.htmlUrl && window.open(full.htmlUrl, "_blank")); + + topActions.replaceChildren(rerunBtn, rerunFailedBtn, cancelBtn, logsBtn, openBtn); + + // ── title block ── + const titleRow = el("div", "det-title-row"); + titleRow.appendChild(runStatePill(state)); + const h = el("h1", "det-title"); + h.append(span(full.displayTitle), span(` #${runNum}`, "det-title-num")); + titleRow.appendChild(h); + if (full.runAttempt > 1) { + const att = el("span", "gh-pill sec-attempt det-attempt"); + att.textContent = `attempt ${full.runAttempt}`; + titleRow.appendChild(att); + } + main.appendChild(titleRow); + + const sub = el("div", "det-sub"); + if (full.branch) { + const chip = el("button", "gh-branch-chip"); + chip.append(glyph("git-branch"), span(full.branch)); + chip.title = `Show ${full.branch} in Branches`; + chip.addEventListener("click", () => sectionNav?.("branches", { ref: full.branch })); + sub.appendChild(chip); + } + // The commit this run built — one click from its row in the graph. + if (full.headSha) { + const commit = el("button", "gh-branch-chip det-commit-chip"); + const subject = full.headCommitMessage.split("\n", 1)[0]; + commit.append(glyph("git-commit"), span(full.headSha.slice(0, 7))); + // The COMMIT, not the graph — the last chip in the app still doing this. + // + // "it teleports u to the commit graph which tells u nothing about the + // changed files" was reported about this exact behaviour, and every other + // sha in the app was moved to the commit page for it: pull requests, + // releases, notifications, ref detail. This one was missed, so the + // complaint was still one click away from the run page. The commit page + // carries a "Show in the graph" item, so the graph stays reachable. + commit.title = subject ? `${subject} — open this commit` : "Open this commit"; + commit.addEventListener("click", () => sectionNav?.("commit", { sha: full.headSha })); + sub.appendChild(commit); + } + const subText = el("span"); + subText.textContent = `${full.event ? `${full.event} · ` : ""}started ${relTimeISO(full.runStartedAt || full.createdAt)}`; + subText.title = absTimeISO(full.runStartedAt || full.createdAt); + sub.appendChild(subText); + main.appendChild(sub); + + // ── jobs ── + const jobs = d.jobs; if (jobs.length === 0) { - detail.appendChild(emptyState("No jobs", "This run reported no jobs yet.")); + main.appendChild(emptyState("No jobs", "This run reported no jobs yet.")); } else { + // Open by default — the steps ARE the page, and a run detail whose cards + // are all shut is two hollow rows. Seeding says that once, as a fact about + // this run, instead of leaving `jobCard` to infer it from an empty set. + for (const j of jobs) { + if (seededJobs.has(j.id)) continue; + seededJobs.add(j.id); + expandedJobs.add(j.id); + } const jobsWrap = el("div", "gh-jobs"); - for (const j of jobs) jobsWrap.appendChild(jobCard(j)); - detail.appendChild(jobsWrap); + for (const j of jobs) jobsWrap.appendChild(jobCard(j, full.id)); + main.appendChild(jobsWrap); + } + + // Artifacts produced by this run — below the jobs, lazily loaded. + void showArtifacts(main, full.id); + + // ── rail ── + // No Status section: the pill sits beside the title 900px away, and the rail + // repeating it was the same word twice on one screen. + // + // A LIVE run gets a section here, but it must not be the third statement of + // the same fact — the title pill already reads "in progress", and a second + // pill saying it again beside the words "running now" made three. What the + // title cannot say is HOW LONG, and on a run you are watching that is the + // only number you actually want. + const elapsed = runDuration(full); + const statusProp = live ? propSection("Running for") : undefined; + if (statusProp) { + statusProp.body.appendChild(span(elapsed || "just started", "det-prop-value")); + } + + // WHO: the run's actor — and the re-runner, when someone else re-ran it. + const whoProp = propSection(full.triggeringActor && full.actor && full.triggeringActor.login !== full.actor.login ? "Actor · re-run by" : "Actor"); + if (full.actor) { + whoProp.body.appendChild( + personChip(full.actor.login, full.actor.avatarUrl), + ); + } else { + whoProp.body.appendChild(span("—", "det-prop-none")); + } + if (full.triggeringActor && full.actor && full.triggeringActor.login !== full.actor.login) { + whoProp.body.appendChild(personChip(full.triggeringActor.login, full.triggeringActor.avatarUrl)); + } + + const aboutProp = propSection("About"); + aboutProp.body.classList.add("det-prop-facts"); + const fact = (k: string, v: string, title?: string): HTMLElement => { + const row = el("div", "det-fact"); + const val = el("span", "det-fact-v"); + val.textContent = v; + if (title) val.title = title; + row.append(span(k, "det-fact-k"), val); + return row; + }; + if (full.name) aboutProp.body.appendChild(fact("Workflow", full.name, full.workflowPath || undefined)); + // (Branch and Trigger are the chips under the title — printing them again + // here made the rail read as an echo of the header.) + if (full.runAttempt > 1) aboutProp.body.appendChild(fact("Attempt", String(full.runAttempt))); + aboutProp.body.appendChild(fact("Jobs", String(jobs.length))); + const dur = runDuration(full); + if (dur) aboutProp.body.appendChild(fact("Duration", dur)); + aboutProp.body.appendChild(fact("Queued", relTimeISO(full.createdAt), absTimeISO(full.createdAt))); + if (full.runStartedAt) { + aboutProp.body.appendChild(fact("Started", relTimeISO(full.runStartedAt), absTimeISO(full.runStartedAt))); } - // Artifacts produced by this run — listed below the jobs, lazily loaded. - void showArtifacts(detail, full.id); + // Linked PRs — each one click from its full workspace. + let prsProp: { root: HTMLElement; body: HTMLElement } | undefined; + if (full.pullRequests.length) { + prsProp = propSection("Pull requests"); + for (const pr of full.pullRequests) { + const b = btn("det-mono-btn"); + b.append(glyph("git-pull-request"), span(`#${pr.number}`)); + b.title = `Open pull request #${pr.number}`; + b.addEventListener("click", () => sectionNav?.("prs", { number: pr.number })); + prsProp.body.appendChild(b); + } + } + + const idProp = propSection("Run ID"); + const idBtn = btn("det-mono-btn"); + idBtn.append(glyph("copy"), span(String(full.id))); + idBtn.title = "Copy the run id"; + idBtn.addEventListener("click", () => void copyText(String(full.id), "Run id copied.")); + idProp.body.appendChild(idBtn); + + rail.append(...(statusProp ? [statusProp.root] : []), whoProp.root, aboutProp.root, ...(prsProp ? [prsProp.root] : []), idProp.root); +} + +/** A run's status as a tinted state pill (success/failure/running/neutral). */ +function runStatePill(state: string): HTMLElement { + const label = prettyState(state) || "unknown"; + const p = el("span", `gh-state-pill gh-checks-${state}`); + p.textContent = label; + return p; +} + +/** One expandable job card: header row (dot + name + state + Logs) + its steps. + * Expansion is remembered in `expandedJobs` so live-poll repaints keep it; the + * Logs button leaves for the log's own page (`views/jobLog.ts`), because an + * inline pane on this page got 523px of a 913px window. */ +/** The longest step in the run being rendered — the shared scale for every + * step bar on the page. Set by buildRunDetail before the cards are built. */ +let runMaxStepSec = 0; + +/** Seconds a step took, or 0 when it hasn't finished (or never started). */ +function stepSeconds(s: WorkflowStep): number { + const a = Date.parse(s.startedAt); + if (!Number.isFinite(a)) return 0; + const b = Date.parse(s.completedAt); + // A step that has started but not finished has no completedAt, and returning + // 0 for it drew the ONE step actually running right now as the shortest bar + // in the job — the opposite of the truth, and it grew shorter the longer it + // ran. Measure a live step against the clock. + const end = Number.isFinite(b) ? b : Date.now(); + return Math.max(0, (end - a) / 1000); } -/** One expandable job card: header row (dot + name + state + Logs) + its steps. */ -function jobCard(j: WorkflowJob): HTMLElement { +function jobCard(j: WorkflowJob, runId: number): HTMLElement { const card = el("div", "gh-job"); const state = j.conclusion || j.status || ""; - const head = el("button", "gh-job-head"); + // Steps are the content of this page. They used to be collapsed by default, + // so a run detail was two hollow rows in an empty page — you had to click + // every job to see what actually ran. `buildRunDetail` seeds the default; + // this asks one question only. + const open = expandedJobs.has(j.id); + // A div, not a <button>: this header carries the job's own "Logs" button, and + // a control inside a control is invalid — the outer button's accessible name + // swallows the inner one, assistive tech cannot reach it, and Space activates + // the header rather than the thing you are on. Same shape the branch rows and + // secRow use for exactly this reason; the role, tab stop and keys are wired + // below. + const head = el("div", "gh-job-head" + (open ? " open" : "")); const chevron = glyph("chevron-right"); chevron.classList.add("gh-job-chevron"); const dot = el("span", `gh-check-dot gh-checks-${state}`); @@ -326,155 +910,113 @@ function jobCard(j: WorkflowJob): HTMLElement { st.textContent = prettyState(state); head.append(chevron, dot, name, st); - const steps = el("div", "gh-job-steps hidden"); + const steps = el("div", "gh-job-steps" + (open ? "" : " hidden")); + // WHERE it ran + how long it waited: runner name (or the requested labels + // when GitHub omits it) and the queue latency, under the job header. + const runnerBits: string[] = []; + if (j.runnerName) runnerBits.push(j.runnerName); + else if (j.labels.length) runnerBits.push(j.labels.join(", ")); + if (j.runnerGroupName && j.runnerGroupName !== "Default") runnerBits.push(j.runnerGroupName); + const queue = j.createdAt && j.startedAt ? fmtDuration(j.createdAt, j.startedAt) : ""; + if (runnerBits.length || queue) { + const metaLine = el("div", "gh-job-meta"); + if (runnerBits.length) { + const r = el("span", "gh-job-runner"); + r.append(glyph("vm"), span(runnerBits.join(" · "))); + r.title = j.labels.length ? `Requested labels: ${j.labels.join(", ")}` : "Runner"; + metaLine.appendChild(r); + } + if (queue) { + const q = el("span", "gh-job-queue"); + q.textContent = `queued ${queue}`; + q.title = "Time between queueing and the runner picking the job up"; + metaLine.appendChild(q); + } + steps.appendChild(metaLine); + } if (j.steps.length === 0) { const none = el("div", "gh-step-row gh-step-empty"); none.textContent = "No steps reported."; steps.appendChild(none); } - for (const s of j.steps) { + // Per-step durations + a proportional timeline bar (widths relative to the + // longest step, via a --w custom property — layout stays in CSS). + const stepSecs = j.steps.map(stepSeconds); + // Normalised across the WHOLE RUN, not per job: per-job scaling drew a 30s + // step and a 4m step at the same length in adjacent cards, which makes the + // bars actively misleading — they exist to be compared. + const maxSec = Math.max(1, runMaxStepSec, ...stepSecs); + j.steps.forEach((s, i) => { const row = el("div", "gh-step-row"); const sState = s.conclusion || s.status || ""; const sdot = el("span", `gh-check-dot gh-checks-${sState}`); const sname = el("span", "gh-check-name"); sname.textContent = s.name || "(step)"; + const bar = el("span", "gh-step-bar"); + bar.style.setProperty("--w", `${Math.max(2, Math.round((stepSecs[i] / maxSec) * 100))}%`); + const running = !!s.startedAt && !s.completedAt; + if (running) bar.classList.add("is-running"); + const sdur = el("span", "gh-step-dur"); + // "1m 12s" while it runs, not a blank column. The suffix marks it as still + // counting rather than a final number. + sdur.textContent = s.startedAt ? fmtDuration(s.startedAt, s.completedAt) + (running ? "…" : "") : ""; const sst = el("span", "gh-check-state"); sst.textContent = prettyState(sState); - row.append(sdot, sname, sst); + row.append(sdot, sname, bar, sdur, sst); steps.appendChild(row); - } + }); + const syncHead = (): void => { + const open = !steps.classList.contains("hidden"); + head.classList.toggle("open", open); + head.setAttribute("aria-expanded", String(open)); + }; + head.setAttribute("role", "button"); + if (head.tabIndex < 0) head.tabIndex = 0; + head.setAttribute("aria-controls", (steps.id ||= `gs-job-steps-${j.id}`)); + syncHead(); head.addEventListener("click", () => { const nowHidden = steps.classList.toggle("hidden"); - head.classList.toggle("open", !nowHidden); + syncHead(); + if (nowHidden) expandedJobs.delete(j.id); + else expandedJobs.add(j.id); + }); + head.addEventListener("keydown", (e) => { + if (e.target !== head) return; + if (e.key !== "Enter" && e.key !== " ") return; + e.preventDefault(); + head.click(); }); - const log = el("button", "row-btn gh-job-log"); + const log = el("button", "row-btn gh-job-log") as HTMLButtonElement; log.textContent = "Logs"; - log.title = "View this job's logs in-app"; + log.title = `Read ${j.name}'s log full-window`; log.addEventListener("click", (e) => { e.stopPropagation(); - openLogViewer({ - title: `Logs · ${j.name}`, - htmlUrl: j.htmlUrl, - load: () => host.invoke("actions:jobLog", { jobId: j.id }), - }); + sectionNav?.("joblog", { number: runId, jobId: j.id }); }); head.appendChild(log); + card.dataset.jobId = String(j.id); card.append(head, steps); return card; } -// ── In-app log viewer (overlay; reused for run + job logs) ───────────────────── +// ── In-app log viewer (overlay; reused for run + job logs, and by prs.ts) ────── -/** - * A scrollable, terminal-styled log overlay. Fetches the text lazily (run or job) - * with a loading/error state, and offers Copy + Open-on-GitHub. Self-contained on - * the shared `.modal-overlay` scaffold (ESC / backdrop / focus-trap), matching the - * people-picker pattern — no new modal API. - */ -function openLogViewer(opts: { - title: string; - htmlUrl?: string; - load: () => Promise<string>; -}): void { - let settled = false; - const overlay = el("div", "modal-overlay"); - overlay.setAttribute("role", "dialog"); - overlay.setAttribute("aria-modal", "true"); - overlay.setAttribute("aria-label", opts.title); - - const card = el("div", "modal-card actions-log-card"); - const head = el("div", "actions-log-head"); - const h = el("div", "modal-title actions-log-title"); - h.textContent = opts.title; - h.title = opts.title; - - const headActions = el("div", "actions-log-headactions"); - const copyBtn = btn("mini-btn"); - copyBtn.append(glyph("copy"), span("Copy")); - copyBtn.title = "Copy the full log to the clipboard"; - copyBtn.disabled = true; // enabled once the text loads - if (opts.htmlUrl) { - const ghBtn = el("button", "mini-btn"); - ghBtn.append(glyph("link-external"), span("GitHub")); - ghBtn.title = "Open these logs on github.com"; - ghBtn.addEventListener("click", () => opts.htmlUrl && window.open(opts.htmlUrl, "_blank")); - headActions.appendChild(ghBtn); - } - const closeBtn = el("button", "icon-btn actions-log-close"); - closeBtn.appendChild(glyph("close")); - closeBtn.title = "Close (Esc)"; - closeBtn.setAttribute("aria-label", "Close logs"); - headActions.append(copyBtn, closeBtn); - head.append(h, headActions); - - const body = el("div", "actions-log-body"); - body.appendChild(loadingState("Fetching logs…")); - card.append(head, body); - - const finish = (): void => { - if (settled) return; - settled = true; - overlay.remove(); - document.removeEventListener("keydown", onKey, true); - }; - const onKey = (e: KeyboardEvent): void => { - if (e.key === "Escape") { - e.preventDefault(); - finish(); - return; - } - trapTab(e, card); - }; - closeBtn.addEventListener("click", finish); - overlay.addEventListener("mousedown", (e) => { - if (e.target === overlay) finish(); - }); - overlay.appendChild(card); - document.body.appendChild(overlay); - document.addEventListener("keydown", onKey, true); - closeBtn.focus(); - - const fetchLogs = (): void => { - body.replaceChildren(loadingState("Fetching logs…")); - copyBtn.disabled = true; - opts - .load() - .then((text) => { - if (settled) return; - const content = text && text.trim().length ? text : "(no log output)"; - const pre = el("pre", "actions-log") as HTMLPreElement; - pre.textContent = content; - body.replaceChildren(pre); - if (text && text.trim().length) { - copyBtn.disabled = false; - copyBtn.onclick = () => void copyText(content, "Log copied."); - } - }) - .catch((e) => { - if (settled) return; - body.replaceChildren( - errorState("Couldn't load logs", cleanErr(e) || "GitHub request failed.", fetchLogs), - ); - }); - }; - fetchLogs(); -} // ── Artifacts (in the run detail, below the jobs) ────────────────────────────── /** Load + render a run's artifacts as a labelled section under the jobs. Silent - * on zero artifacts (the common case) so the detail pane isn't cluttered. */ -async function showArtifacts(detail: HTMLElement, runId: number): Promise<void> { + * on zero artifacts (the common case) so the detail column isn't cluttered. */ +async function showArtifacts(container: HTMLElement, runId: number): Promise<void> { let items: ArtifactInfo[]; try { items = await host.invoke("actions:artifacts", runId); } catch { return; // best-effort: a failed artifacts read never breaks the run detail } - if (!detail.isConnected || items.length === 0) return; + if (!container.isConnected || items.length === 0) return; const section = el("div", "gh-artifacts"); const label = el("div", "gh-artifacts-head"); @@ -504,7 +1046,7 @@ async function showArtifacts(detail: HTMLElement, runId: number): Promise<void> row.append(info, dl); section.appendChild(row); } - detail.appendChild(section); + container.appendChild(section); } /** Download one artifact zip → toast the saved path (or the error). */ @@ -530,8 +1072,9 @@ async function downloadArtifactZip(a: ArtifactInfo, btnEl: HTMLButtonElement): P /** * A two-section manager overlay: repo Actions secrets (names only — values are * write-only) and variables (name + value). Add/edit via `promptInline`, delete - * via `confirmDialog`. Each section reloads itself after a mutation. Built on the - * shared `.modal-overlay` scaffold (ESC / backdrop / focus-trap). + * via `confirmDialog` — those stack ABOVE this modal, and the shared scaffold's + * modal stack keeps Esc scoped to the topmost. Each section reloads itself + * after a mutation. * * Secret *creation* may be unsupported by the backend (it needs libsodium, which * isn't bundled); when so, the backend returns a clear message and we surface it @@ -539,10 +1082,6 @@ async function downloadArtifactZip(a: ArtifactInfo, btnEl: HTMLButtonElement): P */ function openSecretsManager(): void { let settled = false; - const overlay = el("div", "modal-overlay"); - overlay.setAttribute("role", "dialog"); - overlay.setAttribute("aria-modal", "true"); - overlay.setAttribute("aria-label", "Secrets and variables"); const card = el("div", "modal-card actions-secrets-card"); const head = el("div", "actions-secrets-head"); @@ -554,35 +1093,23 @@ function openSecretsManager(): void { closeBtn.setAttribute("aria-label", "Close"); head.append(h, closeBtn); - const finish = (): void => { - if (settled) return; - settled = true; - overlay.remove(); - document.removeEventListener("keydown", onKey, true); - }; - const onKey = (e: KeyboardEvent): void => { - if (e.key === "Escape") { - e.preventDefault(); - finish(); - return; - } - trapTab(e, card); - }; - closeBtn.addEventListener("click", finish); - overlay.addEventListener("mousedown", (e) => { - if (e.target === overlay) finish(); - }); - const secretsSection = el("div", "actions-secrets-section"); const variablesSection = el("div", "actions-secrets-section"); card.append(head, secretsSection, variablesSection); - overlay.appendChild(card); - document.body.appendChild(overlay); - document.addEventListener("keydown", onKey, true); - closeBtn.focus(); + openModal((close) => { + closeBtn.addEventListener("click", close); + return { + card, + focusEl: closeBtn, + label: "Secrets and variables", + onClose: () => { + settled = true; + }, + }; + }); - const alive = (): boolean => !settled && overlay.isConnected; + const alive = (): boolean => !settled && card.isConnected; void renderSecretsSection(secretsSection, alive); void renderVariablesSection(variablesSection, alive); } @@ -590,7 +1117,20 @@ function openSecretsManager(): void { /** Render the secrets list (name + updated) with an Add button and per-row delete. */ async function renderSecretsSection(section: HTMLElement, alive: () => boolean): Promise<void> { const reload = (): void => void renderSecretsSection(section, alive); - section.replaceChildren(sectionHeader("Secrets", "lock", "Add secret", () => void addSecret(reload))); + // Creating a secret cannot succeed in this build (see main/github/actions.ts: + // it needs libsodium to encrypt the value, which isn't bundled). The flow + // asked for the name, then asked for the SECRET VALUE in a plain visible + // field, and only then said so — a credential typed onto the screen for an + // operation that was never going to run. Say it before, on the control. + section.replaceChildren( + sectionHeader( + "Secrets", + "lock", + "Add secret", + () => void addSecret(reload), + CAN_SET_SECRETS ? undefined : SECRETS_UNAVAILABLE, + ), + ); const listWrap = el("div", "actions-kv-list"); listWrap.appendChild(loadingState("Loading secrets…")); section.appendChild(listWrap); @@ -665,13 +1205,27 @@ async function renderVariablesSection(section: HTMLElement, alive: () => boolean } /** A section header: an icon + title on the left, an Add button on the right. */ -function sectionHeader(title: string, icon: string, addLabel: string, onAdd: () => void): HTMLElement { +function sectionHeader( + title: string, + icon: string, + addLabel: string, + onAdd: () => void, + /** Why the add action cannot be used. Given, the button is disabled and says + * so — rather than running a flow that always ends in a refusal. */ + unavailable?: string, +): HTMLElement { const head = el("div", "actions-kv-head"); const lead = el("div", "actions-kv-headtitle"); lead.append(glyph(icon), span(title)); const add = btn("mini-btn"); add.append(glyph("add"), span(addLabel)); - add.addEventListener("click", onAdd); + if (unavailable) { + (add as HTMLButtonElement).disabled = true; + add.title = unavailable; + add.setAttribute("aria-label", `${addLabel} — ${unavailable}`); + } else { + add.addEventListener("click", onAdd); + } head.append(lead, add); return head; } @@ -709,7 +1263,28 @@ function invalidName(name: string): string | null { return null; } +/** + * Whether this build can create a secret. + * + * `main/github/actions.ts` `setSecret` refuses unconditionally: encrypting the + * value needs libsodium, which isn't bundled. Typed as `boolean` deliberately — + * the flow below is complete and correct, and starts working the moment that + * changes; narrowing it to `false` would mark it dead. + */ +const CAN_SET_SECRETS: boolean = false; +/** Why, in one sentence — used on the disabled control AND in the guard, so + * the button and the flow can never tell different stories. */ +const SECRETS_UNAVAILABLE = + "Adding secrets needs the libsodium encryption library, which isn't bundled in this build. Add one on github.com; deleting works here."; + async function addSecret(reload: () => void): Promise<void> { + // Before the first prompt, not after the second. The flow used to ask for the + // name, then ask for the SECRET VALUE in a plain visible field, and only then + // report that it could not save it. + if (!CAN_SET_SECRETS) { + toast(SECRETS_UNAVAILABLE, "info"); + return; + } const name = await promptInline("New secret", "SECRET_NAME", "", "Next"); if (name == null) return; const bad = invalidName(name); @@ -827,13 +1402,17 @@ async function deleteVariable( } } -// ── Run mutations (disable → invoke → toast → re-render detail) ──────────────── +// ── Run mutations (disable → invoke → toast → bust + re-render) ──────────────── +// `id` is GitHub's internal run id — what the API takes. `num` is the run +// NUMBER the whole page wears (crumb, title, log page). Never print `id`: it +// appears nowhere else in the UI, so a dialog naming it asks about a run the +// user cannot find. async function rerunRun( id: number, + num: number, btn: HTMLButtonElement, - detail: HTMLElement, - run: WorkflowRun, + reload: () => void, ): Promise<void> { btn.disabled = true; try { @@ -843,8 +1422,8 @@ async function rerunRun( btn.disabled = false; return; } - toast(`Re-running run #${id}.`, "success"); - void showRunDetail(detail, run); + toast(`Re-running run #${num}.`, "success"); + reload(); } catch (e) { toast(cleanErr(e) || "Couldn't re-run.", "error"); btn.disabled = false; @@ -853,9 +1432,9 @@ async function rerunRun( async function rerunFailed( id: number, + num: number, btn: HTMLButtonElement, - detail: HTMLElement, - run: WorkflowRun, + reload: () => void, ): Promise<void> { btn.disabled = true; try { @@ -865,8 +1444,8 @@ async function rerunFailed( btn.disabled = false; return; } - toast(`Re-running failed jobs for run #${id}.`, "success"); - void showRunDetail(detail, run); + toast(`Re-running failed jobs for run #${num}.`, "success"); + reload(); } catch (e) { toast(cleanErr(e) || "Couldn't re-run failed jobs.", "error"); btn.disabled = false; @@ -875,12 +1454,12 @@ async function rerunFailed( async function cancelRun( id: number, + num: number, btn: HTMLButtonElement, - detail: HTMLElement, - run: WorkflowRun, + reload: () => void, ): Promise<void> { const confirmed = await confirmDialog({ - title: `Cancel run #${id}?`, + title: `Cancel run #${num}?`, message: "This stops the in-progress run on GitHub.", confirmLabel: "Cancel run", danger: true, @@ -894,80 +1473,24 @@ async function cancelRun( btn.disabled = false; return; } - toast(`Cancelled run #${id}.`, "success"); - void showRunDetail(detail, run); + toast(`Cancelled run #${num}.`, "success"); + reload(); } catch (e) { toast(cleanErr(e) || "Couldn't cancel the run.", "error"); btn.disabled = false; } } -// ── Workflows list (in the detail pane via the toolbar) ─────────────────────── - -async function showWorkflowsList(detail: HTMLElement): Promise<void> { - detail.replaceChildren(loadingState()); - let wfs: WorkflowInfo[]; - try { - wfs = await host.invoke("actions:workflows", undefined); - } catch (e) { - detail.replaceChildren( - errorState("Couldn't load workflows", cleanErr(e) || "GitHub request failed.", () => - void showWorkflowsList(detail), - ), - ); - return; - } - detail.replaceChildren(); - const head = el("div", "gh-detail-head"); - const h = el("div", "gh-detail-title"); - h.textContent = "Workflows"; - const meta = el("div", "gh-detail-meta"); - meta.textContent = `${wfs.length} workflow${wfs.length === 1 ? "" : "s"}`; - head.append(h, meta); - detail.appendChild(head); - - if (wfs.length === 0) { - detail.appendChild(emptyState("No workflows", "This repo has no .github/workflows files.")); - return; - } - const list = el("div", "gh-wf-list"); - for (const w of wfs) { - const row = el("div", "gh-wf-row"); - const info = el("div", "row-meta"); - const t = el("div", "row-meta-title"); - t.textContent = w.name; - const sub = el("div", "row-meta-sub"); - const stateSuffix = w.state && w.state !== "active" ? " · " + w.state.replace(/_/g, " ") : ""; - sub.textContent = `${w.path}${stateSuffix}`; - info.append(t, sub); - - const runW = btn("mini-btn"); - runW.append(glyph("play"), span("Run")); - runW.title = "Trigger this workflow (workflow_dispatch)"; - runW.disabled = w.state !== "active"; - runW.addEventListener("click", () => void showDispatchForm(detail, w)); - - const openW = btn("row-btn"); - openW.textContent = "Open"; - openW.disabled = !w.htmlUrl; - openW.addEventListener("click", () => w.htmlUrl && window.open(w.htmlUrl, "_blank")); - - row.append(info, runW, openW); - list.appendChild(row); - } - detail.appendChild(list); -} - -// ── Dispatch flow ───────────────────────────────────────────────────────────── +// ── Dispatch flow (modal) ───────────────────────────────────────────────────── /** * Open the dispatch flow from the toolbar: pop a picker of active workflows - * (anchored on the Run button), then render that workflow's form into `detail`. + * (anchored on the Run button), then open that workflow's dispatch modal. */ -async function openDispatch(view: HTMLElement, detail: HTMLElement): Promise<void> { +async function openDispatch(anchor: HTMLElement, refresh: () => void): Promise<void> { let wfs: WorkflowInfo[]; try { - wfs = await host.invoke("actions:workflows", undefined); + wfs = await gget("actions:workflows", undefined, 60000); } catch (e) { toast(cleanErr(e) || "Couldn't load workflows.", "error"); return; @@ -978,154 +1501,135 @@ async function openDispatch(view: HTMLElement, detail: HTMLElement): Promise<voi return; } if (active.length === 1) { - void showDispatchForm(detail, active[0]); + void showDispatchModal(active[0], refresh); return; } - const anchor = view.querySelector<HTMLElement>(".gh-run-btn"); - if (!anchor) return; openMenu( anchor, active.map((w) => ({ label: w.name, - sub: w.path, + // The full path truncated exactly where the rows stop being identical, + // so every one read ".github/workflows/…". The file name is the part + // that distinguishes them. + sub: w.path.split("/").pop() ?? w.path, icon: "play", - onClick: () => void showDispatchForm(detail, w), + onClick: () => void showDispatchModal(w, refresh), })), ); } -/** Render a dispatch form (ref + parsed inputs) into the detail pane. */ -async function showDispatchForm(detail: HTMLElement, w: WorkflowInfo): Promise<void> { - detail.replaceChildren(loadingState("Loading inputs…")); +/** The dispatch form (ref picker + parsed inputs) as a modal card. */ +async function showDispatchModal(w: WorkflowInfo, refresh: () => void): Promise<void> { let inputs: WorkflowDispatchInput[]; try { inputs = await host.invoke("actions:dispatchInputs", w.id); } catch (e) { - detail.replaceChildren( - errorState("Couldn't read workflow inputs", cleanErr(e) || "GitHub request failed.", () => - void showDispatchForm(detail, w), - ), - ); + toast(cleanErr(e) || "Couldn't read the workflow's inputs.", "error"); return; } - - detail.replaceChildren(); - const head = el("div", "gh-detail-head"); - const h = el("div", "gh-detail-title"); - h.textContent = `Run “${w.name}”`; - const meta = el("div", "gh-detail-meta"); - meta.textContent = w.path; - head.append(h, meta); - detail.appendChild(head); - - const form = el("div", "gh-dispatch-form"); - - // Ref field — a searchable picker over the repo's branches + tags, defaulting - // to the current branch (best-effort). The user can still type any ref. const [refOptions, currentRef] = await Promise.all([loadRefOptions(), currentBranchName()]); - const refField = comboField({ - label: "Branch or tag (ref)", - placeholder: "Search branches and tags…", - value: currentRef, - options: refOptions, - }); - form.appendChild(refField.row); - - const getters: { name: string; get: () => string }[] = []; - for (const inp of inputs) { - const label = inp.name + (inp.required ? " *" : ""); - if (inp.options && inp.options.length) { - const row = el("div", "gh-dispatch-row"); - const lab = el("label", "gh-dispatch-label"); - lab.textContent = label; - const sel = document.createElement("select"); - sel.className = "gh-dispatch-input"; - for (const opt of inp.options) { - const o = document.createElement("option"); - o.value = opt; - o.textContent = opt; - if (opt === inp.default) o.selected = true; - sel.appendChild(o); - } - if (inp.description) sel.title = inp.description; - row.append(lab, sel); - form.appendChild(row); - getters.push({ name: inp.name, get: () => sel.value }); - } else if (inp.type === "boolean") { - const f = dispatchField(label, "true / false", inp.default || "false"); - if (inp.description) f.input.title = inp.description; - form.appendChild(f.row); - getters.push({ name: inp.name, get: () => f.input.value.trim() }); - } else { - const f = dispatchField(label, inp.description || inp.name, inp.default); - form.appendChild(f.row); - getters.push({ name: inp.name, get: () => f.input.value.trim() }); - } - } - if (inputs.length === 0) { - const note = el("div", "gh-dispatch-note"); - note.textContent = - "This workflow declares no inputs. It will run on the ref you choose above."; - form.appendChild(note); - } - - const actions = el("div", "gh-detail-actions"); - const submit = btn("btn btn-primary"); - submit.append(glyph("play"), span("Run workflow")); - const cancel = btn("mini-btn"); - cancel.append(span("Cancel")); - cancel.addEventListener("click", () => emptyDetail(detail)); - - submit.addEventListener("click", async () => { - const ref = refField.input.value.trim(); - if (!ref) { - refField.input.focus(); - toast("A ref (branch or tag) is required.", "error"); - return; + openModal((close) => { + const card = el("div", "modal-card gh-pr-form actions-dispatch-card"); + const h = el("div", "modal-title"); + h.textContent = `Run “${w.name}”`; + const sub = el("div", "actions-dispatch-path"); + sub.textContent = w.path; + card.append(h, sub); + + const refField = comboField({ + label: "Branch or tag (ref)", + placeholder: "Search branches and tags…", + value: currentRef, + options: refOptions, + rowClass: "gh-form-row", + labelClass: "gh-form-label", + inputClass: "modal-input", + }); + card.appendChild(refField.row); + + const getters: { name: string; get: () => string }[] = []; + for (const inp of inputs) { + const label = inp.name + (inp.required ? " *" : ""); + if (inp.options && inp.options.length) { + const row = el("label", "gh-form-row"); + row.append(span(label, "gh-form-label")); + const sel = document.createElement("select"); + sel.className = "gh-form-select"; + for (const opt of inp.options) { + const o = document.createElement("option"); + o.value = opt; + o.textContent = opt; + if (opt === inp.default) o.selected = true; + sel.appendChild(o); + } + if (inp.description) sel.title = inp.description; + row.appendChild(sel); + card.appendChild(row); + getters.push({ name: inp.name, get: () => sel.value }); + } else { + const row = el("label", "gh-form-row"); + row.append(span(label, "gh-form-label")); + const input = document.createElement("input"); + input.className = "modal-input"; + input.placeholder = + inp.type === "boolean" ? "true / false" : inp.description || inp.name; + input.value = inp.default ?? ""; + if (inp.description) input.title = inp.description; + row.appendChild(input); + card.appendChild(row); + getters.push({ name: inp.name, get: () => input.value.trim() }); + } } - const map: Record<string, string> = {}; - for (const g of getters) { - const v = g.get(); - if (v !== "") map[g.name] = v; + if (inputs.length === 0) { + const note = el("div", "gh-dispatch-note"); + note.textContent = "This workflow declares no inputs. It will run on the ref you choose above."; + card.appendChild(note); } - submit.disabled = true; - try { - const r = await host.invoke("actions:dispatch", { workflowId: w.id, ref, inputs: map }); - if (!r.ok) { - toast(r.message ?? "Couldn't start the workflow.", "error"); - submit.disabled = false; + + const actions = el("div", "modal-actions"); + const cancel = btn("mini-btn"); + cancel.append(span("Cancel")); + cancel.addEventListener("click", close); + const submit = btn("btn btn-primary modal-ok"); + submit.append(glyph("play"), span("Run workflow")); + submit.addEventListener("click", () => { + const ref = refField.input.value.trim(); + if (!ref) { + refField.input.focus(); + toast("A ref (branch or tag) is required.", "error"); return; } - toast(`Dispatched “${w.name}” on ${ref}. Refresh to see the new run.`, "success"); - emptyDetail(detail); - } catch (e) { - toast(cleanErr(e) || "Couldn't start the workflow.", "error"); - submit.disabled = false; - } - }); - - actions.append(submit, cancel); - form.appendChild(actions); - detail.appendChild(form); - refField.input.focus(); -} + const map: Record<string, string> = {}; + for (const g of getters) { + const v = g.get(); + if (v !== "") map[g.name] = v; + } + submit.disabled = true; + void (async () => { + try { + const r = await host.invoke("actions:dispatch", { workflowId: w.id, ref, inputs: map }); + if (!r.ok) { + toast(r.message ?? "Couldn't start the workflow.", "error"); + submit.disabled = false; + return; + } + toast(`Dispatched “${w.name}” on ${ref}.`, "success"); + close(); + bust("actions"); + actionsTab = "runs"; + refresh(); + } catch (e) { + toast(cleanErr(e) || "Couldn't start the workflow.", "error"); + submit.disabled = false; + } + })(); + }); + actions.append(cancel, submit); + card.appendChild(actions); -/** A labeled text input for the dispatch form. */ -function dispatchField( - label: string, - placeholder: string, - value: string, -): { row: HTMLElement; input: HTMLInputElement } { - const row = el("div", "gh-dispatch-row"); - const lab = el("label", "gh-dispatch-label"); - lab.textContent = label; - const input = document.createElement("input"); - input.className = "gh-dispatch-input"; - input.placeholder = placeholder; - input.value = value ?? ""; - row.append(lab, input); - return { row, input }; + return { card, focusEl: refField.input, label: `Run workflow ${w.name}`, onClose: () => {} }; + }); } /** Best-effort current branch name from the open repo's HEAD; "main" fallback. */ diff --git a/apps/desktop/src/renderer/views/commit.ts b/apps/desktop/src/renderer/views/commit.ts new file mode 100644 index 0000000..d6bc572 --- /dev/null +++ b/apps/desktop/src/renderer/views/commit.ts @@ -0,0 +1,545 @@ +// The commit page. +// +// Until now, every "show me this commit" in the app — a row in a pull request's +// commit list, a row in Compare, a release's tag, a notification's subject, a +// sha in prose — answered by ejecting you into the Commits GRAPH and calling +// `reveal(sha)`. That is wrong three ways, and the owner hit all three: +// +// · The graph shows a ROW. His words: "it teleports u to the commit graph +// which tells u nothing about the changed files". +// · `reveal()` returns silently when the sha is outside the loaded page, so +// from a long-lived PR the click did nothing at all. +// · It abandons wherever you were, which on a PR means losing your place in a +// review. +// +// So: a real page. What GitHub's /owner/repo/commit/<sha> shows — message, both +// identities, parents, refs, and every changed file — plus the thing github.com +// cannot offer, because the repository is right here: the git verbs. Cherry-pick +// this onto the current branch, revert it, branch from it, reset to it. + +import { host } from "../bridge"; +import { + el, + span, + glyph, + avatar, + cleanErr, + emptyState, + errorState, + skeletonList, + relTime, + absTime, + commonDir, + copyText, + openMenu, +} from "../ui"; +import { detailPage, disposeOnDetach, type SectionTarget } from "./common"; +import { confirmDialog, promptInline } from "../dialogs"; +import { renderMarkdown } from "../markdown"; +import { DiffPanel } from "../diffPanel"; +import { setPageTarget } from "../navStack"; +import { gget } from "../cache"; +import type { CommitActionRequest, CommitDetailsPayload } from "../../shared/ipc"; +import type { CommitFileChange } from "@gitstudio/host-bridge/commitDetailsProtocol"; + +/** Status letter → the word a person reads, and the class that colours it. */ +const STATUS: Record<string, { word: string; cls: string }> = { + A: { word: "added", cls: "is-add" }, + M: { word: "modified", cls: "is-mod" }, + D: { word: "deleted", cls: "is-del" }, + R: { word: "renamed", cls: "is-ren" }, + C: { word: "copied", cls: "is-ren" }, + T: { word: "type changed", cls: "is-mod" }, +}; + +function diffstat(files: CommitFileChange[]): { adds: number; dels: number; binary: number } { + let adds = 0; + let dels = 0; + let binary = 0; + for (const f of files) { + // -1 is git's "binary" marker in --numstat; adding it as a number would + // quietly subtract one from the total. + if (f.additions < 0 || f.deletions < 0) binary++; + else { + adds += f.additions; + dels += f.deletions; + } + } + return { adds, dels, binary }; +} + +/** + * The identity block. The committer row appears ONLY when it differs from the + * author — which is the case that matters (a rebase, a cherry-pick, a patch + * applied by a maintainer) and the case a single "author" line hides. + */ +function identity(d: CommitDetailsPayload): HTMLElement { + const box = el("div", "cmt-identity"); + const authored = el("div", "cmt-who"); + authored.append( + avatar(d.author, undefined, 20), + span(d.author, "cmt-who-name"), + span("authored", "cmt-who-verb"), + ); + // SECONDS. `relTime` and `absTime` both take epoch seconds — passing + // milliseconds made every commit on this page read "authored just now" + // (the clamp swallows the negative delta) with a hover date in the year + // 57000. The one thing the report asked this line for was *when*. + const t = span(relTime(d.authorDate), "cmt-who-when"); + t.title = absTime(d.authorDate); + authored.append(t); + box.appendChild(authored); + + const sameName = d.committer === d.author; + const sameTime = Math.abs(d.committerDate - d.authorDate) < 2; + if (!sameName || !sameTime) { + const committed = el("div", "cmt-who"); + committed.append( + avatar(d.committer, undefined, 20), + span(d.committer, "cmt-who-name"), + span("committed", "cmt-who-verb"), + ); + const t2 = span(relTime(d.committerDate), "cmt-who-when"); + t2.title = absTime(d.committerDate); + committed.append(t2); + box.appendChild(committed); + } + return box; +} + +/** + * Render the commit page into `wrap`. + * + * `nav` is the app's router; `target.sha` is the commit. `target.from` is not + * used — the back button pops the history and names wherever that lands, so a + * commit opened from a pull request says "← Pull Request #106" without this + * view knowing a pull request exists. + */ +export async function renderCommit( + wrap: HTMLElement, + nav: (view: string, target?: SectionTarget) => void, + target: SectionTarget | undefined, + /** The app's action handler — toasts the outcome and refreshes what changed. */ + run: (req: CommitActionRequest) => Promise<void>, +): Promise<void> { + const sha = target?.sha ?? ""; + const short = sha.slice(0, 7); + + const { view, main, rail, topActions } = detailPage({ + backLabel: "Commits", + crumb: short, + pageLabel: `Commit ${short}`, + onBack: () => nav("graph", { sha }), + }); + // The message wants a reading measure; a diff wants the window. `.cmt-view` + // lifts `.det-main`'s cap and the header re-caps itself — see app.css. + view.classList.add("cmt-view"); + main.appendChild(skeletonList(3, false)); + wrap.replaceChildren(view); + + if (!sha) { + main.replaceChildren(emptyState("No commit", "Nothing was asked for.", { icon: "git-commit" })); + return; + } + + let d: CommitDetailsPayload | undefined; + try { + d = await gget("commit:details", sha, 30_000); + } catch (e) { + main.replaceChildren( + errorState("Couldn't read this commit", cleanErr(e) || "git failed.", () => + void renderCommit(wrap, nav, target, run), + ), + ); + return; + } + + // ABANDONED while this was in flight? Then stop: everything below builds into + // a detached tree, and the auto-open at the end of it calls `setPageTarget`, + // which writes into whatever history entry is CURRENT — some other view's. + // Its target then deep-links that view to a file nobody opened, on the next + // refresh and on every Back to that entry. + // + // It also settles a second race: two refreshes in quick succession start two + // builds, and the first one finishing detached used to re-stamp its own + // default (file #0) over the file the second had just restored. + // + // `releases.ts` and `jobLog.ts` both guard their reads here; this one did not. + if (!view.isConnected) return; + + if (!d) { + // The object is not in this clone. That is an ordinary situation — a pull + // request from a fork, a commit on a branch never fetched — and it is + // exactly where the old graph jump dead-ended without saying why. + main.replaceChildren( + emptyState( + "This commit isn't in your clone", + `${short} isn't an object this repository has. It may be on a fork, or on a branch you ` + + `haven't fetched. Fetching the remote will bring it in.`, + { icon: "cloud-download" }, + ), + ); + return; + } + + // ── header ──────────────────────────────────────────────────────────────── + // + // TWO LINES. The diff is what this page is for, and the first version spent + // 251px of a 913px window on a message and a stat bar before the diff + // started, then gave the diff 504px of ~1100px because a file list and a + // properties rail were beside it. The most important thing on the page had + // less than half the room. + // + // So: subject, one identity line, one facts line. A long message hides behind + // a disclosure rather than pushing the diff off the screen — most commit + // bodies are two lines and the ones that are not are exactly the problem. + const head = el("div", "cmt-head"); + const title = el("h1", "cmt-subject"); + title.textContent = d.subject; + head.appendChild(title); + head.appendChild(identity(d)); + + const facts = el("div", "cmt-facts"); + // Where it lives: "on redesign/wave-2", or "merged into main" — the first + // question a reader has, and the page could not answer it at all. + const where = span("", "cmt-where"); + facts.appendChild(where); + void host + .invoke("commit:branches", d.sha) + .then((b) => { + if (!b || !b.branches.length) { + where.textContent = d.parents.length > 1 ? "a merge commit" : "not on any local branch"; + where.title = "No local branch contains this commit — it may only exist on a remote."; + return; + } + const others = b.branches.filter((x) => x !== b.current); + if (b.onCurrent && others.length) { + where.textContent = `on ${b.current}, and ${others.length} other branch${others.length === 1 ? "" : "es"}`; + } else if (b.onCurrent) { + where.textContent = `only on ${b.current}`; + } else { + where.textContent = `not on ${b.current ?? "this branch"} — on ${b.branches[0]}`; + } + where.title = `Contained by: ${b.branches.join(", ")}`; + }) + .catch(() => { + where.remove(); + }); + + if (d.parents.length > 1) { + const m = span(`merge of ${d.parents.length} parents`, "cmt-fact-merge"); + m.title = "A merge commit — its diff is against the first parent."; + facts.appendChild(m); + } + + // Ref chips: branch tips and tags sitting exactly here. + for (const r of d.refs) { + const chip = span(r.name, `cmt-ref is-${r.kind}`); + chip.title = `${r.kind === "tag" ? "tag" : "branch"} ${r.name}`; + facts.appendChild(chip); + } + + if (d.body.trim()) { + const toggle = el("button", "cmt-body-toggle") as HTMLButtonElement; + toggle.append(glyph("chevron-down"), span("Description")); + toggle.setAttribute("aria-expanded", "false"); + const body = el("div", "gh-body-md cmt-body"); + body.innerHTML = renderMarkdown(d.body); + body.hidden = true; + toggle.addEventListener("click", () => { + body.hidden = !body.hidden; + toggle.setAttribute("aria-expanded", String(!body.hidden)); + toggle.replaceChildren(glyph(body.hidden ? "chevron-down" : "chevron-up"), span("Description")); + }); + facts.appendChild(toggle); + head.appendChild(facts); + head.appendChild(body); + } else { + head.appendChild(facts); + } + main.replaceChildren(head); + + // ── the changed files — the whole point ─────────────────────────────────── + const { adds, dels, binary } = diffstat(d.files); + const n = d.files.length; + const statBar = el("div", "cmt-statbar"); + statBar.append( + span(`${n} file${n === 1 ? "" : "s"}`, "cmt-stat-files"), + span(`+${adds.toLocaleString()}`, "cmt-stat-add"), + span(`−${dels.toLocaleString()}`, "cmt-stat-del"), + ); + if (binary) statBar.appendChild(span(`${binary} binary`, "cmt-stat-bin")); + + if (!n) { + main.appendChild( + emptyState( + "No file changes", + "This commit records no change to any file — an empty commit, or a merge whose result " + + "matched its first parent.", + { icon: "git-commit" }, + ), + ); + } else { + const prefix = commonDir(d.files.map((f) => f.path)); + if (prefix) { + const p = el("div", "cmt-prefix"); + p.append(glyph("folder"), span(prefix)); + p.title = `Every file in this commit is under ${prefix}`; + main.appendChild(p); + } + const split = el("div", "cmt-split"); + const listCol = el("div", "cmt-listcol"); + const list = el("div", "cmt-files"); + const pane = el("div", "cmt-diff"); + + // A filter, because at any real size scrolling is not finding. + // + // Measured on a 420-file merge — an ordinary size for a codemod or a + // lockfile bump: 13,027px of file list in a 566px column. Rendering all of + // it costs 25ms, so virtualisation is not the problem and building it would + // have been the wrong work; having no way to ASK for a file is the problem. + const filter = document.createElement("input"); + filter.className = "cmt-filter"; + filter.type = "search"; + filter.placeholder = `Filter ${n} file${n === 1 ? "" : "s"}…`; + filter.setAttribute("aria-label", "Filter the changed files"); + const count = el("div", "cmt-filter-count"); + count.hidden = true; + + const filterHead = el("div", "cmt-filterhead"); + filterHead.append(filter, count); + listCol.append(statBar, filterHead, list); + split.append(listCol, pane); + main.appendChild(split); + + const diff = new DiffPanel(pane); + // `routeView` disposes only `activeMonacoView`, and this panel was never + // registered there — so every visit to a commit page left a Monaco diff + // editor behind, with its two models and their tokenizers, for the life of + // the window. Read ten commits and ten of them are still resident. + // + // No check guards this: the leak is in the editor OBJECTS, and `monaco` is + // not exposed to the page, so a probe can only count `.monaco-editor` DOM + // nodes — which go away when the host's children are replaced whether + // anything was disposed or not. Counting them reports success on the + // broken build, which is worse than not checking at all. + disposeOnDetach(view, () => diff.dispose()); + diff.showEmpty("Select a file to see what changed."); + // The parent this commit is diffed against. A root commit has none, and + // git's empty-tree hash is the standard stand-in. + const base = d.parents[0] ?? "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; + + let selected: HTMLElement | undefined; + let gen = 0; + /** Which file to open on build — carried in the route target so a rebuild + * lands where the reader was, not at the top of the list. */ + const wantFile = d.files.some((x) => x.path === target?.file) ? target?.file : undefined; + const openFile = async (f: CommitFileChange, row: HTMLElement): Promise<void> => { + // NOT once this page has gone. `setPageTarget` writes into whatever + // history entry is CURRENT, and the auto-open below fires from a render + // that may already have been abandoned — so a commit page you left while + // it was still loading stamped its file path onto the entry of the view + // you had moved to, and the next refresh re-routed that view with it. + // + // The job log's `openJob` carries this exact guard, written an hour + // before this line and then not written here. + if (!view.isConnected) return; + // Remember it, without navigating — a refresh re-routes with this target. + setPageTarget({ file: f.path }); + selected?.classList.remove("is-current"); + selected = row; + row.classList.add("is-current"); + // Click a large file then a small one and the large response can land + // last, painting over the selection — the same staleness guard every + // other diff surface here uses. + const mine = ++gen; + const fd = await host + // A rename's left side is the OLD name — without it the base is asked + // for a path it never had, and a small edit renders as a whole new file. + .invoke("compare:fileDiff", { base, head: d!.sha, path: f.path, leftPath: f.oldPath }) + .catch(() => undefined); + if (mine !== gen) return; + if (fd) { + // The path already sits above the diff, so repeating it inside each + // pane behind a 40-character sha only crowds the one thing this page + // exists for. Name the SIDES instead — which is what a reader of a + // commit diff actually needs to know, and what the pane labels never + // said. + diff.showDiff({ + ...fd, + leftLabel: d!.parents.length ? `${base.slice(0, 7)} · before` : "(new file)", + rightLabel: `${d!.shortSha} · this commit`, + }); + } + // With no `kind` this defaulted to "waiting" — the reader got the + // "Nothing selected" heading and the pick-a-file icon over a row that was + // still highlighted, so a FAILURE read as an instruction to do the thing + // they had just done. Both sibling callers (Compare, Changes) were given + // this treatment; this one was missed. + else + diff.showEmpty( + `${f.path} is listed as changed in this commit, so this is a failure to read it — not a file with nothing in it.`, + { title: "Couldn't read this file", kind: "error" }, + ); + }; + + const rowFor = new Map<HTMLElement, string>(); + d.files.forEach((f, i) => { + const row = el("button", "cmt-file") as HTMLButtonElement; + rowFor.set(row, f.path.toLowerCase()); + const st = STATUS[f.status] ?? { word: "changed", cls: "is-mod" }; + const letter = span(f.status, `cmt-file-status ${st.cls}`); + letter.title = st.word; + const path = span(f.path.slice(prefix.length), "cmt-file-path"); + path.title = f.oldPath ? `${f.oldPath} → ${f.path}` : f.path; + row.append(letter, path); + if (f.additions >= 0 || f.deletions >= 0) { + const counts = el("span", "cmt-file-counts"); + if (f.additions > 0) counts.appendChild(span(`+${f.additions}`, "cmt-file-add")); + if (f.deletions > 0) counts.appendChild(span(`−${f.deletions}`, "cmt-file-del")); + row.appendChild(counts); + } else { + row.appendChild(span("binary", "cmt-file-bin")); + } + row.setAttribute( + "aria-label", + `${st.word} ${f.path}${f.additions >= 0 ? `, ${f.additions} added, ${f.deletions} removed` : ""}`, + ); + row.addEventListener("click", () => void openFile(f, row)); + list.appendChild(row); + // The file the reader was ON, if this page is being rebuilt — falling + // back to the first one, which is what it always did. + // + // `refreshAll` re-routes the current view with its history target, and + // the file watcher fires it on ANY save anywhere in the repository. So a + // build touching a file swapped the diff you were reading for file #1, + // silently, while you were reading it. `SectionTarget.file` exists for + // exactly this and the Code browser already uses it. + if (f.path === wantFile || (!wantFile && i === 0)) void openFile(f, row); + }); + + // Every space-separated term must appear somewhere in the path, so + // "render css" finds `src/renderer/styles/app.css` — the way a person + // narrows by remembering two fragments rather than one exact prefix. + const applyFilter = (): void => { + const terms = filter.value.toLowerCase().split(/\s+/).filter(Boolean); + let shown = 0; + for (const [row, path] of rowFor) { + const hit = terms.every((t) => path.includes(t)); + row.hidden = !hit; + if (hit) shown++; + } + count.hidden = terms.length === 0; + count.textContent = shown === 0 ? "No file matches" : `${shown} of ${n}`; + count.classList.toggle("is-empty", shown === 0); + }; + filter.addEventListener("input", applyFilter); + filter.addEventListener("keydown", (e) => { + if (e.key === "Escape" && filter.value) { + // Clear before dismissing: Escape in a filter means "undo the filter", + // and only means "leave" once there is nothing to undo. + e.stopPropagation(); + filter.value = ""; + applyFilter(); + } + }); + // "/" jumps to the filter from anywhere on the page, as it does in the + // Code browser — the same key for the same job. + view.addEventListener("keydown", (e) => { + const t = e.target as HTMLElement | null; + if (e.key !== "/" || (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA"))) return; + e.preventDefault(); + filter.focus(); + }); + } + + // ── actions ─────────────────────────────────────────────────────────────── + // + // In the TOP BAR, not a rail. A 264px properties column beside the diff was + // 264px the diff did not get, to hold five buttons and two chips — and the + // diff is the page. The verbs are one menu; the parents are chips on the + // facts line; the sha is in the crumb, where it already was. + rail.remove(); + + const shaBtn = el("button", "mini-btn cmt-sha") as HTMLButtonElement; + shaBtn.append(glyph("copy"), span(d.shortSha)); + shaBtn.title = `${d.sha}\nCopy the full SHA`; + shaBtn.setAttribute("aria-label", `Copy the full SHA ${d.sha}`); + // `copyText`, like the other 28 copy buttons in the app — not a raw + // `clipboard:write`. Two things came free with it and were missing here: the + // navigator.clipboard path (the IPC channel is only its FALLBACK, for the + // contexts where the permission is refused), and the confirmation. This was + // the one sha button in the app that copied in silence, so the only way to + // know it had worked was to paste. + shaBtn.addEventListener("click", () => void copyText(d!.sha, "Copied the full SHA.")); + topActions.appendChild(shaBtn); + + // The reason to read a commit HERE rather than on github.com: the repository + // is in hand, so these are real operations rather than links. + const more = el("button", "mini-btn") as HTMLButtonElement; + more.append(glyph("kebab-vertical")); + more.title = "Actions for this commit"; + more.setAttribute("aria-label", more.title); + more.addEventListener("click", () => { + const items = [ + { label: "Check out this commit", icon: "git-branch", onClick: () => act("checkout") }, + { label: "Branch from here…", icon: "git-branch", onClick: () => act("branch") }, + { label: "Tag this commit…", icon: "tag", onClick: () => act("tag") }, + { separator: true }, + { label: "Cherry-pick onto current branch", icon: "git-commit", onClick: () => act("cherry-pick") }, + { label: "Revert this commit", icon: "discard", onClick: () => act("revert") }, + { separator: true }, + ...d!.parents.map((p, i) => ({ + label: d!.parents.length > 1 ? `Open parent ${i + 1} — ${p.slice(0, 7)}` : `Open parent ${p.slice(0, 7)}`, + icon: "git-commit", + onClick: () => nav("commit", { sha: p }), + })), + { separator: true }, + // The graph is a good way to see a commit's SHAPE — just not an answer to + // "what changed", which is why it stopped being the destination. + { label: "Show in the graph", icon: "git-commit", onClick: () => nav("graph", { sha: d!.sha }) }, + ]; + openMenu(more, items); + }); + topActions.appendChild(more); + + /** + * Run one of the page's git verbs. + * + * This used to be `void host.invoke("commit:action", {action, sha})` and + * nothing else, which was wrong in three ways at once: + * + * · "Branch from here…" and "Tag this commit…" need a NAME. Without one the + * main process finds no argv to run and answers `{ok: true}` — so both + * items reported success, having done nothing, and the ellipsis in each + * label promised a prompt that never opened. + * · The result was discarded. A cherry-pick or revert that hit conflicts — + * the common case, and the reason you'd look at the result — said nothing + * whatsoever. + * · Nothing refreshed. A revert writes a commit; the graph and the branch + * list went on showing the repository as it was before the click. + * + * `run` is the app's own action handler: the same toasts, cache busting and + * refresh the graph's context menu has always gone through. + */ + const act = async (action: CommitActionRequest["action"]): Promise<void> => { + let name: string | undefined; + if (action === "branch" || action === "tag") { + const asked = await promptInline( + action === "branch" ? "Create branch here" : "Create tag here", + action === "branch" ? "feature/my-branch" : "v1.0.0", + ); + name = asked?.trim(); + if (!name) return; + } + const confirms: Partial<Record<CommitActionRequest["action"], string>> = { + checkout: "Check out this commit directly? HEAD will be detached — not on any branch.", + revert: "Create a commit that undoes this one, on the current branch?", + }; + const message = confirms[action]; + if (message && !(await confirmDialog({ title: `${short} — ${action.replace(/-/g, " ")}`, message }))) { + return; + } + await run({ action, sha: d!.sha, name }); + }; +} diff --git a/apps/desktop/src/renderer/views/common.ts b/apps/desktop/src/renderer/views/common.ts index cd9dc11..609f514 100644 --- a/apps/desktop/src/renderer/views/common.ts +++ b/apps/desktop/src/renderer/views/common.ts @@ -5,13 +5,84 @@ // app feels like one product. import { host } from "../bridge"; -import { el, glyph, span, emptyState, wireResizerKeys, avatar } from "../ui"; +import { gget } from "../cache"; +import { openModal } from "../dialogs"; +import { focusNewPage } from "../focusReturn"; +import { pageOwnsKeys } from "../overlays"; +import { navPrev, navPop, entryLabel, setPageLabel } from "../navStack"; +import { + cleanErr, + el, + errorState, + glyph, + span, + emptyState, + avatar, + openMenu, + relTime, + absTime, + type MenuItem, +} from "../ui"; +import type { ReactionSummary } from "../../shared/ipc"; +import { + facetActiveCount, + facetPasses, + facetServerValues, + harvestValues, +} from "../facetModel"; +import type { FacetOption, FacetSpec, FacetState } from "../facetModel"; + +// The pure facet rules live in ../facetModel (node-testable); views import +// everything from here so there is still one facet import site. +export { harvestValues }; +export type { FacetOption, FacetSpec, FacetState }; /** An item another view asked this section to open on entry (e.g. the project - * board opening an issue/PR by number — keeps everything in-app, never GitHub). */ + * board opening an issue/PR by number, or a tag detail revealing its commit in + * the graph — keeps everything in-app, never GitHub). */ export interface SectionTarget { /** The issue / PR number to auto-open in the destination section. */ - number: number; + number?: number; + /** A string-keyed item to open (gist id, project id) — the string-shaped + * sibling of `number` for sections whose items aren't numbered. */ + id?: string; + /** A workflow JOB to reveal + expand on the Actions run page (rides along + * with `number` = the run id — how PR checks land on their logs). */ + jobId?: number; + /** A commit to reveal on entry (the Commits view scrolls to + selects it). */ + sha?: string; + /** A folder for the Code view to open ("" = repo root). Routing every folder + * hop through this puts the browser's history behind ⌘[/⌘] too. */ + path?: string; + /** + * A FILE for the Code view to open, with `path` naming its folder. + * + * Opening a file used to replace the view host directly without routing, so + * as far as the app was concerned you were still standing in the folder: any + * forced re-route — a window focus after you edited that very file in your + * editor, a Pull, a branch switch — rebuilt the listing on top of it and + * ejected you from what you were reading. Back landed on the wrong folder and + * Forward could not return to the file, because neither ever knew about it. + */ + file?: string; + /** A ref (branch / remote / tag / stash selector) for the Branches view to + * scroll to and flash on entry. */ + ref?: string; + /** + * Which section the user came FROM, when it is not the one that owns the + * item. + * + * Inbox and My Work both open issues and pull requests, which routes into + * those sections — so the detail page believed it belonged to Issues, its + * back button said "← Issues", the rail silently switched, and Escape landed + * you in a list you had never been in. The originating section rides along so + * back and Escape return where you actually were. + */ + from?: { view: string; label: string }; + /** Explicitly route to the section's ROOT (its list page). This is how a + * detail page's ← / Esc gets back: routeView's "already showing this view" + * no-op would otherwise swallow a target-less same-view navigation. */ + list?: boolean; } /** Routes to another sidebar view; pass a target to deep-link a specific item @@ -42,20 +113,37 @@ export async function ghGate( wrap: HTMLElement, nav: (view: string) => void, needsRepo = false, + retry?: () => void, ): Promise<GhGate | null> { let status: { connected: boolean; login?: string; repo?: { owner: string; repo: string } }; try { - status = await host.invoke("github:status", undefined); - } catch { - status = { connected: false }; + // Cached (12s TTL): a detail-page → list-page hop re-gates the section, and + // that round trip must never make Back feel like a page load. + status = await gget("github:status", undefined, 12000); + } catch (e) { + // A failed/timed-out status check is an ERROR with a Retry — it used to + // masquerade as "not connected" (misleading) or, before the client got + // request timeouts, hang the section on its skeleton forever. + wrap.replaceChildren( + errorState("Couldn't reach GitHub", cleanErr(e) || "The request timed out.", retry), + ); + return null; } if (!status.connected) { wrap.replaceChildren(connectPrompt(nav)); return null; } if (needsRepo && !status.repo) { + // A wall with a WHY and a way out — not a dead end. (This used to say + // "Not a GitHub repository" even for forks with only an `upstream` + // remote and for SSH-alias remotes; both resolve now, so reaching this + // genuinely means no remote points at github.com.) wrap.replaceChildren( - emptyState("Not a GitHub repository", "This repo's origin remote isn't on github.com."), + emptyState( + "No GitHub remote found", + "None of this repository's remotes point at github.com, so pull requests, issues, and CI can't attach here. Add one (git remote add origin …) and this section lights up on the next visit.", + { icon: "github", hint: "origin, upstream, and SSH aliases like git@github.com-work:… are all recognized." }, + ), ); return null; } @@ -86,11 +174,11 @@ export function connectPrompt(nav: (view: string) => void): HTMLElement { export function ghHeader( title: string, login: string | undefined, - onRefresh: () => void, + onRefresh: () => void | Promise<void>, count?: number, -): HTMLElement & { setCount?: (n: number) => void } { +): HTMLElement & { setCount?: (shown: number, total?: number) => void } { const headRow = el("div", "list-head list-head-row gh-head") as HTMLElement & { - setCount?: (n: number) => void; + setCount?: (shown: number, total?: number) => void; }; const left = el("div", "gh-head-titlewrap"); const t = el("div", "list-head-title"); @@ -99,19 +187,73 @@ export function ghHeader( if (typeof count === "number") countPill.textContent = String(count); else countPill.hidden = true; left.append(t, countPill); - headRow.setCount = (n: number): void => { - countPill.textContent = String(n); + // The badge reports what is ON SCREEN. It used to report the fetched page + // size and never move, so filtering to two rows still read "8" — and an + // empty result still read "8" above an empty state. When a filter is + // narrowing the list, say so: "2 of 8". + headRow.setCount = (shown: number, total?: number): void => { + const narrowed = typeof total === "number" && total !== shown; + countPill.textContent = narrowed ? `${shown} of ${total}` : String(shown); + countPill.title = narrowed + ? `${shown} shown of ${total} loaded` + : `${shown} ${shown === 1 ? "item" : "items"}`; + countPill.classList.toggle("is-narrowed", narrowed); countPill.hidden = false; }; - // The account + refresh that used to live here are gone — the account now sits - // once in the top bar, and refresh is handled by view-switch/mutation reloads. - // `.gh-acct` stays as the (empty) right-side anchor each view inserts its own - // action cluster (New PR / New Issue / …) before. `login`/`onRefresh` are kept - // in the signature because views still use them internally. + // The account chip lives once in the top bar; `.gh-acct` is the right-side + // anchor each view inserts its own action cluster (New PR / New Issue / …) + // before. It carries ONE shared control: a quiet refresh button — with the + // SWR caches making section data sticky, an explicit "get me fresh data now" + // affordance is honesty, not clutter. void login; - void onRefresh; const right = el("div", "gh-acct"); + const refreshBtn = el("button", "icon-btn gh-refresh") as HTMLButtonElement; + refreshBtn.title = "Refresh"; + refreshBtn.setAttribute("aria-label", "Refresh this view"); + refreshBtn.appendChild(glyph("refresh")); + // Say that it is working. This was `() => onRefresh()` — fire and forget, no + // feedback of any kind — in twelve views. Clicking it looked like nothing had + // happened, so people clicked it again. The local views (Code, Changes) grew a + // busy state of their own; these never did. + // `aria-disabled`, never `disabled`. A disabled control cannot hold focus and + // leaves the tab order, so the focus rescue that puts the keyboard back after + // a rebuild had nothing to put it back ON — pressing Refresh dropped focus to + // <body>, which is the exact bug the rescue exists to prevent. The re-entry + // guard is the deadline below, not the DOM. + const setBusy = (btn: HTMLButtonElement, on: boolean): void => { + btn.setAttribute("aria-disabled", String(on)); + btn.setAttribute("aria-busy", String(on)); + btn.classList.toggle("is-busy", on); + btn.querySelector(".codicon")?.classList.toggle("spin", on); + }; + // A refresh REBUILDS the view, which replaces this whole header — so the + // button that was spinning is detached the instant the answer lands, and the + // new one is built with no busy state at all. The floor below was protecting + // an element nobody could see any more. Carrying the deadline across the + // rebuild lets the freshly-built button pick the spin back up. + if (Date.now() < refreshBusyUntil) setBusy(refreshBtn, true); + refreshBtn.addEventListener("click", () => { + if (Date.now() < refreshBusyUntil) return; // still working on the last one + // A refresh answered from cache finishes in a millisecond, and a spinner + // that appears and vanishes within one frame reads as "nothing happened". + // Hold it long enough to be seen — the honest signal is "I did the thing", + // not "here is precisely how long it took". + refreshBusyUntil = Date.now() + 350; + setBusy(refreshBtn, true); + const done = (): void => { + refreshBusyUntil = 0; + if (refreshBtn.isConnected) setBusy(refreshBtn, false); + // …and whichever button the rebuild put in its place. + for (const b of document.querySelectorAll<HTMLButtonElement>(".gh-refresh.is-busy")) { + setBusy(b, false); + } + }; + + const floor = new Promise<void>((r) => setTimeout(r, 350)); + void Promise.all([Promise.resolve(onRefresh()).catch(() => {}), floor]).then(done); + }); + right.appendChild(refreshBtn); headRow.append(left, right); return headRow; } @@ -155,6 +297,14 @@ export function searchField(opts: { placeholder: string; onInput: (query: string) => void; initial?: string; + /** Debounce for onInput, ms (default 110). Explore's code tab sets a large + * value and relies on `onEnter` instead — each code search costs 1/10th of + * a minute's budget, so it must be deliberate. */ + debounceMs?: number; + /** Fired on Enter with the current value. */ + onEnter?: (query: string) => void; + /** Focus the input as soon as it mounts (search-first pages). */ + autofocus?: boolean; }): HTMLElement { const wrap = el("div", "gh-search"); const icon = glyph("search"); @@ -171,25 +321,48 @@ export function searchField(opts: { clear.appendChild(glyph("close")); clear.hidden = !input.value; let timer = 0; - const fire = (): void => { + /** + * `now` skips the debounce. + * + * CLEARING is not typing. A field with a long debounce — Explore's code + * search waits for Enter with `debounceMs: 100_000`, because every keystroke + * there costs a rate-limited request — left the ✕ and Escape queued a hundred + * seconds out, so the box emptied and the results below it did not. The box + * said one thing and the list another, and there was no way to make them + * agree short of pressing Enter on an empty query. + */ + const fire = (now = false): void => { clear.hidden = !input.value; window.clearTimeout(timer); - timer = window.setTimeout(() => opts.onInput(input.value.trim()), 110); + if (now) { + opts.onInput(input.value.trim()); + return; + } + timer = window.setTimeout(() => opts.onInput(input.value.trim()), opts.debounceMs ?? 110); }; - input.addEventListener("input", fire); + // NOT `fire` directly — it now takes a `now` flag, and the InputEvent would + // arrive as a truthy first argument, making every keystroke skip the debounce. + input.addEventListener("input", () => fire()); input.addEventListener("keydown", (e) => { if (e.key === "Escape" && input.value) { e.stopPropagation(); input.value = ""; - fire(); + fire(true); + return; + } + if (e.key === "Enter" && opts.onEnter) { + e.preventDefault(); + window.clearTimeout(timer); + opts.onEnter(input.value.trim()); } }); clear.addEventListener("click", () => { input.value = ""; - fire(); + fire(true); input.focus(); }); wrap.append(icon, input, clear); + if (opts.autofocus) setTimeout(() => input.focus(), 0); return wrap; } @@ -383,139 +556,1190 @@ export function peoplePickerModal(opts: { return new Promise((resolve) => { let settled = false; const pre = new Set(opts.selected ?? []); - const overlay = el("div", "modal-overlay"); - overlay.setAttribute("role", "dialog"); - overlay.setAttribute("aria-modal", "true"); - overlay.setAttribute("aria-label", opts.title); - const card = el("div", "modal-card modal-card-form people-picker"); - const h = el("div", "modal-title"); - h.textContent = opts.title; - - const search = document.createElement("input"); - search.className = "modal-input"; - search.placeholder = "Filter people…"; - search.setAttribute("aria-label", "Filter people"); - - const list = el("div", "people-list"); - const boxes: { login: string; cb: HTMLInputElement; row: HTMLElement }[] = []; - for (const p of opts.people) { - const row = el("label", "people-row"); - const cb = document.createElement("input"); - cb.type = "checkbox"; - cb.checked = pre.has(p.login); - row.append(cb, avatar(p.login, p.avatarUrl ?? null, 22), span(p.login, "people-login")); - list.appendChild(row); - boxes.push({ login: p.login, cb, row }); + openModal((close) => { + const card = el("div", "modal-card modal-card-form people-picker"); + const h = el("div", "modal-title"); + h.textContent = opts.title; + + const search = document.createElement("input"); + search.className = "modal-input"; + search.placeholder = "Filter people…"; + search.setAttribute("aria-label", "Filter people"); + + const list = el("div", "people-list"); + const boxes: { login: string; cb: HTMLInputElement; row: HTMLElement }[] = []; + for (const p of opts.people) { + const row = el("label", "people-row"); + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.checked = pre.has(p.login); + row.append(cb, avatar(p.login, p.avatarUrl ?? null, 22), span(p.login, "people-login")); + list.appendChild(row); + boxes.push({ login: p.login, cb, row }); + } + const empty = el("div", "people-empty"); + empty.textContent = "No people match."; + empty.hidden = true; + list.appendChild(empty); + const filter = (): void => { + const q = search.value.trim().toLowerCase(); + let shown = 0; + for (const b of boxes) { + const ok = !q || b.login.toLowerCase().includes(q); + b.row.hidden = !ok; + if (ok) shown++; + } + empty.hidden = shown > 0; + }; + search.addEventListener("input", filter); + + const actions = el("div", "modal-actions"); + const cancel = el("button", "mini-btn"); + cancel.textContent = "Cancel"; + const ok = el("button", "btn btn-primary modal-ok"); + ok.append(span(opts.okLabel)); + actions.append(cancel, ok); + card.append(h, search, list, actions); + + cancel.addEventListener("click", close); + ok.addEventListener("click", () => { + settled = true; + resolve(boxes.filter((b) => b.cb.checked).map((b) => b.login)); + close(); + }); + return { + card, + focusEl: search, + label: opts.title, + onClose: () => { + if (!settled) resolve(null); + }, + }; + }); + }); +} + +// ── SECTION PAGES — the list ⇄ detail system (docs/desktop-redesign.md) ────── +// (ghTwoPane / ghListResizer — the old master/detail split — lived here until +// every section converted; the last user disappeared with the Gists rewrite.) +// Full-width list pages and full-page details, replacing the ghTwoPane split +// view by view. `sec-*` classes are the list page, `det-*` the detail page. + +/** The full-width list page shell: the caller appends its own header (ghHeader + * + toolbar cluster) and then fills `listEl` with `secRow`s. */ +export function sectionList(): { view: HTMLElement; listEl: HTMLElement } { + const view = el("div", "gh-view"); + const listEl = el("div", "sec-list"); + wireListNav(listEl, ".sec-row"); + return { view, listEl }; +} + +/** One single-line row on a section list page: state icon, muted #number, + * strong truncating title, inline label chips, then a right-aligned meta + * cluster and a relative time. Fixed height — the density that lets a list + * read like a tracker instead of a stack of cards. */ +export interface SecRowOpts { + lead?: HTMLElement; + /** The muted leading id, e.g. "#31". */ + num?: string; + title: string; + /** Pills rendered right after the title (Draft, prerelease…). */ + titleSuffix?: HTMLElement[]; + /** Inline chips after the title (labels). Clipped, never wrapped. */ + chips?: HTMLElement[]; + /** Right-aligned cluster (avatars, stats). */ + meta?: HTMLElement[]; + /** Right-edge relative time (tabular figures, fixed slot). */ + time?: string; + timeTitle?: string; + /** + * Row verbs, rendered AFTER the time — at rest, not on hover. + * + * Deliberately its own slot rather than more `meta`: meta sits LEFT of the + * time column, and "the time is the last thing on the far right" is a rule + * every list in the app depends on to stay scannable. + * + * At rest is the point. The Branches view hid its whole action surface behind + * `opacity: 0` until hover, which is why every deeper verb had to be exiled + * into a menu, and why nothing in that view could be reached by keyboard or + * touch at all. These render muted and gain contrast on hover or focus. + */ + actions?: HTMLElement[]; + onOpen: () => void; + ariaLabel?: string; +} +/** + * Mark a label strip that has run out of room, so it can fade its last chip + * instead of guillotining it. + * + * Chips are `flex: 0 0 auto` inside an `overflow: hidden` strip, so when the + * window is narrow the last one is sliced by a hard vertical edge partway + * through a word: a rounded pill with a flat cut side, which reads as a + * half-drawn element rather than as "there is more". CSS cannot ask "am I + * overflowing", so one shared observer answers it. + * + * Shared deliberately — a list can hold hundreds of rows, and an observer each + * would cost more than the thing it is styling. + */ +let chipOverflowObserver: ResizeObserver | undefined; +function watchChipOverflow(chips: HTMLElement): void { + const sync = (el_: Element): void => { + const n = el_ as HTMLElement; + n.classList.toggle("is-clipped", n.scrollWidth > n.clientWidth + 1); + }; + if (typeof ResizeObserver === "undefined") { + // No observer (older host): fall back to a one-shot measure after layout. + requestAnimationFrame(() => sync(chips)); + return; + } + if (!chipOverflowObserver) { + chipOverflowObserver = new ResizeObserver((entries) => { + for (const e of entries) sync(e.target); + }); + } + chipOverflowObserver.observe(chips); + requestAnimationFrame(() => sync(chips)); +} + +export function secRow(o: SecRowOpts): HTMLElement { + const row = el("button", "sec-row"); + if (o.ariaLabel) row.setAttribute("aria-label", o.ariaLabel); + // Rows built from `meta` fragments can end up carrying their own controls — + // an Actions run row puts a branch sub-link in its meta cluster — and a + // control inside a <button> is invalid: the outer button's accessible name + // swallows the inner one, assistive tech cannot reach it, and Space activates + // the row rather than the thing you are actually on. When that happens the row + // becomes the app's documented div[role="button"] shape instead, which is what + // the branch rows already use for exactly this reason. See `promoteToDivRow`. + if (o.lead) { + const lead = el("span", "sec-row-lead"); + lead.appendChild(o.lead); + row.appendChild(lead); + } + if (o.num) { + const num = el("span", "sec-row-num"); + num.textContent = o.num; + row.appendChild(num); + } + const title = el("span", "sec-row-title"); + title.textContent = o.title; + title.title = o.title; + row.appendChild(title); + for (const s of o.titleSuffix ?? []) row.appendChild(s); + if (o.chips?.length) { + const chips = el("span", "sec-row-chips"); + for (const c of o.chips) chips.appendChild(c); + row.appendChild(chips); + watchChipOverflow(chips); + } + row.appendChild(el("span", "sec-row-spring")); + if (o.meta?.length) { + const meta = el("span", "sec-row-meta"); + for (const m of o.meta) meta.appendChild(m); + row.appendChild(meta); + } + if (o.time !== undefined) { + const t = el("span", "sec-row-time"); + t.textContent = o.time; + if (o.timeTitle) t.title = o.timeTitle; + row.appendChild(t); + } + if (o.actions?.length) { + const acts = el("span", "sec-row-actions"); + for (const a of o.actions) acts.appendChild(a); + // A click on a verb is not a click on the row. Without this, pressing + // Checkout would ALSO open the ref's page underneath it. + acts.addEventListener("click", (e) => e.stopPropagation()); + row.appendChild(acts); + } + row.addEventListener("click", o.onOpen); + return promoteToDivRow(row, o.onOpen); +} + +/** + * If a row ended up containing its own interactive children, re-shape it from a + * `<button>` into a `div[role="button"]` carrying the same contract: clickable, + * one tab stop, Enter and Space activate — but only when the key event started + * on the ROW, so a control inside it keeps its own keys. + */ +function promoteToDivRow(row: HTMLElement, onOpen: () => void): HTMLElement { + const inner = row.querySelector('button, a[href], [role="button"], input, select, textarea'); + if (!inner) return row; + const div = el("div", `${row.className} is-clickable`); + for (const { name, value } of [...row.attributes]) { + if (name !== "class") div.setAttribute(name, value); + } + while (row.firstChild) div.appendChild(row.firstChild); + div.setAttribute("role", "button"); + div.tabIndex = 0; + div.addEventListener("click", onOpen); + div.addEventListener("keydown", (e) => { + if (e.target !== div) return; + // UNMODIFIED only. ⌘Enter is the app's documented "run this row's main + // action" — checkout, pull, publish — and `wireListNav` implements it one + // screen away. Without this guard both fired for one keypress: the branch + // was checked out AND the row opened its page, so the list you were working + // in disappeared underneath the action you had just taken. + if (e.metaKey || e.ctrlKey || e.altKey) return; + if (e.key !== "Enter" && e.key !== " ") return; + e.preventDefault(); + onOpen(); + }); + return div; +} + +/** An overlapping avatar stack for a row's meta cluster (up to `max`). */ +export function avatarStack( + people: Array<{ login: string; avatarUrl?: string | null }>, + max = 3, + size = 18, + /** What these people ARE — "Assignee", "Author". Rendered into each avatar's + * tooltip and the stack's own label, because the same circle in the same + * slot used to mean assignees on Issues and the author on PRs, unlabelled. */ + role?: string, +): HTMLElement { + const wrap = el("span", "sec-avs"); + for (const p of people.slice(0, max)) { + wrap.appendChild(avatar(p.login, p.avatarUrl ?? null, size, role)); + } + if (people.length > max) { + const rest = people.slice(max); + const more = el("span", "sec-avs-more"); + more.textContent = `+${rest.length}`; + // The overflow chip used to hide who it stood for. + more.title = rest.map((p) => `@${p.login}`).join(", "); + wrap.appendChild(more); + } + if (role) { + wrap.setAttribute( + "aria-label", + `${role}${people.length === 1 ? "" : "s"}: ${people.map((p) => p.login).join(", ")}`, + ); + } + return wrap; +} + +/** Esc on a detail page = back to the list. Stands down whenever another layer + * consumed the key (peek/modal/palette/menu all preventDefault their Esc) or + * the focus is in a text surface; self-unhooks once the page leaves the DOM. */ +/** + * ONE listener for every detail page, and it answers "←" as well as Escape. + * + * Two things were wrong with a listener per page. It only handled Escape, while + * the app's own shortcut sheet advertises "Esc or ←" — the arrow was documented + * and implemented nowhere. And it unhooked itself on the next KEYDOWN after the + * view had detached, not when the view detached: so open a detail, switch + * section (the view is stashed, not destroyed), type anything at all, and the + * handler removed itself for good. Come back to that page and Escape was dead, + * with the page's closure pinned in memory until some later keystroke happened + * to evict it. + * + * A registry keyed by the view element fixes both. It survives the + * detach/re-attach that keep-alive does, it needs no teardown from callers that + * have none to give, and the entries are pruned whenever a new page is wired. + */ +const detailBacks = new WeakMap<HTMLElement, () => void>(); +let detailStack: HTMLElement[] = []; +let detailKeysWired = false; + +/** Until when a header refresh should read as busy. A refresh rebuilds the + * header it lives in, so the state has to survive the element. */ +let refreshBusyUntil = 0; + +function wireDetailEsc(view: HTMLElement, onBack: () => void): void { + detailBacks.set(view, onBack); + // Most recent last. Deliberately NOT filtered on `isConnected`: a kept-alive + // section is stashed OUT of the DOM while you are elsewhere, so pruning + // detached views here dropped a page that was merely put away — and a + // restore replays the cached DOM without rebuilding it, so nothing ever + // re-registered. Escape and ← were dead on every detail page you came back + // to. Dispatch already skips disconnected views, which is the right place to + // ask, because by then the answer is current. + detailStack = detailStack.filter((v) => v !== view); + detailStack.push(view); + // Bounded, since entries can now outlive their time on screen. Far more than + // any real navigation depth; the oldest is also the least likely to return. + if (detailStack.length > 64) detailStack = detailStack.slice(-64); + if (detailKeysWired) return; + detailKeysWired = true; + // CAPTURE. A modal's own Escape handler is capture-phase too, and it releases + // its layer as it closes — so a bubble-phase page handler asked + // "is a layer open?" AFTER the answer had already changed, and was left + // relying on `defaultPrevented` alone to stop it navigating out from under + // the dialog that had just closed. Registered first, in the same phase, the + // page asks while the layer is still there and stands down properly. + document.addEventListener("keydown", (e) => { + const esc = e.key === "Escape"; + const left = e.key === "ArrowLeft"; + if ((!esc && !left) || e.defaultPrevented) return; + const t = e.target as HTMLElement | null; + if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return; + // ← is Back only when the keyboard is on the PAGE. Inside a control that + // uses arrows — a tablist, a toolbar, a resizer, a list — the arrow belongs + // to that control, and stealing it would be worse than not offering it. + if (left && t && t !== document.body && t.closest('[role="tablist"], [role="toolbar"], [role="separator"], [role="listbox"], [role="menu"], .gh-seg, .settings-seg, .cmp-seg')) { + return; + } + // ANY floating layer above the page owns these keys — this handler belongs + // to the page, so everything outranks it. `ownsEscape()` is the wrong + // question here: it cannot see a peek (a "surface"), so ← started routing + // the page out from under an open peek and throwing the peek away. The + // whitelist of four CSS selectors this replaced did happen to match + // `.peek-overlay`; the registry matches every layer, present and future. + if (!pageOwnsKeys()) return; + for (let i = detailStack.length - 1; i >= 0; i--) { + const v = detailStack[i]; + if (!v.isConnected) continue; + const back = detailBacks.get(v); + if (!back) continue; + e.preventDefault(); + back(); + return; + } + }, true); +} + +export interface DetailPageOpts { + /** + * Where Back goes when there is NO history behind this page — a deep link, a + * fresh launch, a restored session. Otherwise the button pops, and its label + * names wherever that lands. + */ + backLabel: string; + /** Muted crumb after the back button, e.g. "#31". */ + crumb?: string; + /** The cold-start fallback, used only when the history is empty. */ + onBack: () => void; + /** The top-bar action cluster (rightmost); one primary action at most. */ + actions?: HTMLElement[]; + /** + * What to call THIS page in the next page's back button — "Pull Request + * #106", "Run #411". Without it the button falls back to the view's name, so + * leaving a PR for a pipeline and pressing back reads "← Pull requests" and + * lands on the list rather than the PR you were reading. + */ + pageLabel?: string; +} + +/** The full-page detail shell: a slim top bar (← back · crumb · actions) over + * a scrolling body of `main` (measure-capped content column) + `rail` (the + * sticky properties column). Esc goes back (see wireDetailEsc). */ +export function detailPage(o: DetailPageOpts): { + view: HTMLElement; + main: HTMLElement; + rail: HTMLElement; + topActions: HTMLElement; +} { + const view = el("div", "det-view"); + const bar = el("div", "det-topbar"); + const back = el("button", "det-back"); + + // POP, not push. + // + // Every caller used to pass `nav(view, {list:true})`, which APPENDS a history + // entry — so the button that should restore your place destroyed it, and the + // top bar's Forward went dead the moment you used it. It also meant Back + // could only ever name a list: leaving a pull request for a pipeline and + // pressing back landed in the Actions list rather than the pull request, + // because `from` had no way to say "Pull Request #106". + // + // The history already knows where you were. The label is read from it, so the + // button always names its real destination; `o.onBack` survives only as the + // cold-start fallback for a page nothing led to. + if (o.pageLabel) setPageLabel(o.pageLabel); + const prev = navPrev(); + const label = entryLabel(prev, o.backLabel); + const goBack = (): void => { + if (!navPop()) o.onBack(); + }; + back.append(glyph("arrow-left"), span(label)); + back.title = `Back to ${label} (Esc)`; + back.setAttribute("aria-label", back.title); + back.addEventListener("click", goBack); + bar.appendChild(back); + if (o.crumb) { + const crumb = el("span", "det-crumb"); + crumb.textContent = o.crumb; + bar.appendChild(crumb); + } + const topActions = el("div", "det-tb-actions"); + for (const a of o.actions ?? []) topActions.appendChild(a); + bar.appendChild(topActions); + const scroll = el("div", "det-scroll"); + const body = el("div", "det-body"); + const main = el("div", "det-main"); + const rail = el("div", "det-rail"); + body.append(main, rail); + scroll.appendChild(body); + view.append(bar, scroll); + // The IDENTICAL function, so Escape, ← and the button can never disagree + // about where back is. + wireDetailEsc(view, goBack); + // The page that just replaced a list takes the keyboard with it. Without + // this, pressing Enter on a row left focus on <body>, so the next Tab + // started at the top of the window — past the entire nav rail — rather than + // in the thing you had just opened. + focusNewPage(view, back); + return { view, main, rail, topActions }; +} + +/** + * One commit in a list of them — the shape github.com/…/pull/N/commits uses. + * + * "the bare commits view in compare and pr are not improved as i requested, you + * can take example of how they look in github and follow similar ui". + * + * They WERE bare: a subject and one grey line reading "author · sha · 3h ago", + * with no face, no date grouping, no way to read a commit's body, no way to + * copy a sha, and nothing marking a merge. Everything below except the avatar + * was already in the response and thrown away one layer down. + */ +export interface CommitListItem { + sha: string; + shortSha: string; + subject: string; + /** The rest of the message; the row grows a disclosure when there is one. */ + body?: string; + /** The name git recorded. */ + author: string; + /** The GitHub account, when the commit matched one. */ + login?: string; + avatarUrl?: string; + /** Epoch SECONDS. */ + date: number; + verified?: boolean; + isMerge?: boolean; +} + +/** "Commits on 25 Aug 2026" — the day a commit was authored, in local time. */ +function dayKey(epochSec: number): string { + if (!epochSec) return ""; + const d = new Date(epochSec * 1000); + return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; +} +function dayLabel(epochSec: number): string { + if (!epochSec) return "Undated"; + return new Date(epochSec * 1000).toLocaleDateString(undefined, { + day: "numeric", + month: "short", + year: "numeric", + }); +} + +/** + * A list of commits, grouped by the day they were authored. + * + * ONE renderer for both surfaces. The pull request's Commits tab and Compare + * built their own rows, from the same five fields, and had drifted: opposite + * sort orders, and Compare's rows still announcing "reveal in the graph" to + * assistive tech long after the click had been changed to open the commit. + */ +export function commitList( + items: CommitListItem[], + o: { + onOpen: (sha: string) => void; + onCopy?: (sha: string) => void; + /** + * "oldest" (the default) reads the work in the order it was done — how + * github.com renders a pull request's commits and a compare, and the only + * order that makes a narrative. + * + * "newest" is for a list that is a WINDOW on an ongoing history rather than + * a complete set: a branch's recent commits, capped at N. Oldest-first + * there opens on an arbitrary window edge — the 30th-newest commit — and + * buries the tip, the one commit every reader came to see, at the bottom. + */ + order?: "oldest" | "newest"; + }, +): HTMLElement { + const root = el("div", "clist"); + // The callers took their order from their sources and disagreed: + // `pr:commits` comes back chronological, `git log base..head` comes back + // newest-first, so the same branch read forwards on one screen and backwards + // on the other. Sorting here makes that impossible rather than merely fixed. + const dir = o.order === "newest" ? -1 : 1; + const sorted = [...items].sort((a, b) => dir * ((a.date || 0) - (b.date || 0))); + let openDay = "\u0000"; + let group: HTMLElement | undefined; + + for (const c of sorted) { + const key = dayKey(c.date); + if (key !== openDay) { + openDay = key; + const head = el("div", "clist-day"); + head.append(glyph("git-commit"), span(`Commits on ${dayLabel(c.date)}`)); + root.appendChild(head); + group = el("div", "clist-group"); + root.appendChild(group); + } + + const row = el("div", "clist-row"); + row.appendChild(avatar(c.login || c.author, c.avatarUrl, 20, "Author")); + + const main = el("div", "clist-main"); + // The SUBJECT is the link. A whole row that is one button cannot also hold + // a copy button and a disclosure — a control inside a control has no + // accessible name of its own and Space activates the wrong one. + const subject = el("button", "clist-subject") as HTMLButtonElement; + subject.textContent = c.subject; + subject.title = `Open commit ${c.shortSha}`; + subject.addEventListener("click", () => o.onOpen(c.sha)); + const subjRow = el("div", "clist-subjrow"); + subjRow.appendChild(subject); + if (c.isMerge) { + const chip = span("Merge", "clist-chip"); + chip.title = "This commit has more than one parent"; + subjRow.appendChild(chip); + } + + // A body hides behind a disclosure rather than making every row three lines + // tall — most commits have none, and the ones that do are the long ones. + let bodyEl: HTMLElement | undefined; + if (c.body && c.body.trim()) { + const more = el("button", "clist-more") as HTMLButtonElement; + more.append(glyph("ellipsis")); + more.title = "Show this commit's full message"; + more.setAttribute("aria-label", more.title); + more.setAttribute("aria-expanded", "false"); + bodyEl = el("pre", "clist-body"); + bodyEl.textContent = c.body.trim(); + bodyEl.hidden = true; + more.addEventListener("click", () => { + const showing = bodyEl!.hidden; + bodyEl!.hidden = !showing; + more.setAttribute("aria-expanded", String(showing)); + more.title = showing ? "Hide the full message" : "Show this commit's full message"; + more.setAttribute("aria-label", more.title); + }); + subjRow.appendChild(more); + } + main.appendChild(subjRow); + + const meta = el("div", "clist-meta"); + meta.appendChild(span(c.author, "clist-author")); + const when = c.date ? relTime(c.date) : ""; + if (when) { + const t = span(`committed ${when}`, "clist-when"); + t.title = absTime(c.date); + meta.appendChild(t); } - const empty = el("div", "people-empty"); - empty.textContent = "No people match."; - empty.hidden = true; - list.appendChild(empty); - const filter = (): void => { - const q = search.value.trim().toLowerCase(); - let shown = 0; - for (const b of boxes) { - const ok = !q || b.login.toLowerCase().includes(q); - b.row.hidden = !ok; - if (ok) shown++; + main.appendChild(meta); + if (bodyEl) main.appendChild(bodyEl); + + const right = el("div", "clist-right"); + if (c.verified) { + const v = span("Verified", "clist-verified"); + v.title = "GitHub verified this commit's signature"; + right.appendChild(v); + } + const sha = el("button", "clist-sha") as HTMLButtonElement; + sha.textContent = c.shortSha; + sha.title = `${c.sha}\nCopy the full SHA`; + sha.setAttribute("aria-label", `Copy the full SHA ${c.sha}`); + sha.addEventListener("click", () => o.onCopy?.(c.sha)); + right.appendChild(sha); + const openBtn = el("button", "clist-open") as HTMLButtonElement; + openBtn.append(glyph("diff")); + openBtn.title = `Open ${c.shortSha} and what it changed`; + openBtn.setAttribute("aria-label", openBtn.title); + openBtn.addEventListener("click", () => o.onOpen(c.sha)); + right.appendChild(openBtn); + + row.append(main, right); + (group ?? root).appendChild(row); + } + return root; +} + +/** One property in the detail rail: an uppercase label (with a hover-revealed + * edit affordance when `onEdit` is given) over a small value body. */ +export function propSection( + label: string, + opts: { onEdit?: (anchor: HTMLElement) => void; editTitle?: string } = {}, +): { root: HTMLElement; body: HTMLElement } { + const root = el("div", "det-prop"); + const head = el("div", "det-prop-label"); + head.appendChild(span(label)); + if (opts.onEdit) { + const b = el("button", "det-prop-edit"); + b.appendChild(glyph("edit")); + b.title = opts.editTitle ?? `Edit ${label.toLowerCase()}`; + b.setAttribute("aria-label", b.title); + b.addEventListener("click", (e) => { + e.stopPropagation(); + opts.onEdit!(b); + }); + head.appendChild(b); + } + const body = el("div", "det-prop-body"); + root.append(head, body); + return { root, body }; +} + +/** A person chip for the rail (avatar + login); clickable when given onClick. */ +export function personChip( + login: string, + avatarUrl: string | null | undefined, + onClick?: () => void, +): HTMLElement { + const chip = el(onClick ? "button" : "span", "det-person"); + chip.append(avatar(login, avatarUrl ?? null, 20), span(login)); + if (onClick) { + chip.title = `View @${login}'s profile`; + chip.addEventListener("click", onClick); + } + return chip; +} + +// ── Author association + reactions ─────────────────────────────────────────── + +/** GitHub's SCREAMING_CASE association, in words a person would use. + * "OWNER" tells you the repo owner is talking; "FIRST_TIME_CONTRIBUTOR" tells + * you to be welcoming. Both are signal the app used to throw away. */ +export function associationLabel(a: string): string { + switch (a) { + case "OWNER": return "Owner"; + case "MEMBER": return "Member"; + case "COLLABORATOR": return "Collaborator"; + case "CONTRIBUTOR": return "Contributor"; + case "FIRST_TIME_CONTRIBUTOR": return "First-time contributor"; + case "FIRST_TIMER": return "First-time on GitHub"; + case "MANNEQUIN": return "Mannequin"; + default: return a.toLowerCase().replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase()); + } +} + +/** The small badge GitHub puts beside a commenter's name. Only for + * associations that actually MEAN something — a plain "NONE"/"CONTRIBUTOR" + * badge on every comment is noise, not information. */ +export function associationBadge(a: string | undefined): HTMLElement | undefined { + if (!a || a === "NONE" || a === "CONTRIBUTOR") return undefined; + const b = span(associationLabel(a), "gh-assoc-badge"); + b.title = `This person is a repository ${associationLabel(a).toLowerCase()}`; + return b; +} + +const REACTION_EMOJI: Array<[keyof ReactionSummary, string, string]> = [ + ["plusOne", "👍", "+1"], + ["minusOne", "👎", "-1"], + ["laugh", "😄", "laugh"], + ["hooray", "🎉", "hooray"], + ["confused", "😕", "confused"], + ["heart", "❤️", "heart"], + ["rocket", "🚀", "rocket"], + ["eyes", "👀", "eyes"], +]; + +/** A read-only reaction strip — only the buckets someone actually used. + * Undefined when nobody reacted, so callers append conditionally. */ +export function reactionRow(r: ReactionSummary | undefined): HTMLElement | undefined { + if (!r || r.total <= 0) return undefined; + const row = el("div", "gh-reactions"); + for (const [key, emoji, name] of REACTION_EMOJI) { + const n = r[key] as number; + if (!n) continue; + const chip = span("", "gh-reaction"); + chip.append(span(emoji, "gh-reaction-emoji"), span(String(n), "gh-reaction-n")); + chip.title = `${n} ${name}`; + row.appendChild(chip); + } + return row.childElementCount ? row : undefined; +} + +/** The dashed "add / set" affordance used by empty rail properties. */ +export function propAddBtn(label: string, onClick: () => void): HTMLElement { + const b = el("button", "det-prop-add"); + b.append(glyph("add"), span(label)); + b.addEventListener("click", onClick); + return b; +} + +/** A muted placeholder value for an empty rail property ("None"). */ +export function propNone(text = "None"): HTMLElement { + return span(text, "det-prop-none"); +} + +/** + * The list caps the paged fetches stop at (mirrors main/githubPaging PAGE_CAPS + * × per_page). When a list arrives at exactly its cap it PROBABLY has more — + * append `capNotice` so the UI says "first N" instead of lying by omission. + */ +export const LIST_CAPS = { + issues: 300, + prs: 300, + runs: 200, + notifications: 150, +} as const; + +/** A quiet end-of-list note for a capped list; null when under the cap. + * + * `mode` keeps the wording HONEST. When the narrowing happens on GitHub's + * side ("server"), "search to narrow" is a lie — the list you're looking at + * is already the server's answer, and the fix is a filter, not a search box. */ +export function capNotice( + shown: number, + cap: number, + mode: "client" | "server" = "client", +): HTMLElement | null { + if (shown < cap) return null; + const note = el("div", "sec-cap-note"); + note.append( + glyph("info"), + span( + mode === "server" + ? `Showing the ${cap} most recent from GitHub — narrow with the filters above to see further back.` + : `Showing the ${cap} most recently updated — search to narrow the list.`, + ), + ); + return note; +} + +// ── Facets: one filter vocabulary for every section ────────────────────────── + +/** Keep an element's SPACE while hiding its ink. Row meta packs right-to-left, + * so omitting an optional slot shifts everything left of it into a different + * column and the eye can no longer scan down the list. */ +export function blankable(el_: HTMLElement, show: boolean): HTMLElement { + if (!show) { + el_.style.visibility = "hidden"; + el_.setAttribute("aria-hidden", "true"); + } + return el_; +} + +/** A tiny round color swatch for a label (menu leading element). */ +export function swatch(hexColor: string): HTMLElement { + const sw = el("span", "gh-label-swatch"); + sw.style.background = `#${(hexColor || "888888").replace(/^#/, "")}`; + return sw; +} + + +export interface FacetBar<T> { + el: HTMLElement; + /** True when `item` survives every ACTIVE client-side facet. */ + passes: (item: T) => boolean; + /** Active values for server-side facets (those with no predicate). */ + serverValues: () => Record<string, string>; + /** How many facets are currently narrowing the list. */ + activeCount: () => number; + /** Clear every facet (fires onChange once). */ + clear: () => void; + /** Re-render the buttons — call after the item list changes so harvested + * options reflect what's actually loaded. */ + sync: (items: T[]) => void; +} + +/** + * Build a facet bar. The bar owns its buttons and menus; the VIEW owns the + * state object and decides what a change means (re-filter locally, or re-fetch + * with `serverValues()`). + */ +export function facetBar<T>(o: { + specs: FacetSpec<T>[]; + /** Mutated in place, so a view can seed it from a route target. */ + state: FacetState; + items: T[]; + onChange: () => void; +}): FacetBar<T> { + const bar = el("div", "gh-facets"); + let items = o.items; + const loaded = new Map<string, FacetOption[]>(); + + const optionsFor = (spec: FacetSpec<T>): FacetOption[] => { + // An EXPLICIT list keeps its order: a state facet reads Open / Closed / + // Merged because that is the sequence a pull request moves through, and + // alphabetising it would be worse than useless. + if (spec.options) return spec.options; + // Everything harvested is sorted, wherever it was harvested. `harvestValues` + // sorts internally and the five hand-rolled harvests do not — so within one + // bar, Milestone and Base came out alphabetical while Author, Assignee and + // Label came out in list order, i.e. ordered by whichever item happened to + // be updated most recently. Those three are exactly the long menus where + // finding a name matters. + const byLabel = (a: FacetOption, b: FacetOption): number => + (a.label ?? a.value).localeCompare(b.label ?? b.value, undefined, { numeric: true }); + if (spec.harvest) return [...spec.harvest(items)].sort(byLabel); + return [...(loaded.get(spec.key) ?? [])].sort(byLabel); + }; + + /** The facet whose menu is open, so `sync` can refill it in place when the + * list finally lands. Opening a menu before the data arrives used to leave + * it saying "No assignee to filter by" for as long as it stayed open — a + * statement about the repo, made from an empty array. */ + let openSpecKey: string | undefined; + + const openFacetMenu = (spec: FacetSpec<T>, btn: HTMLElement): void => { + openSpecKey = spec.key; + const build = (opts: FacetOption[]): void => { + const current = o.state[spec.key]; + const items_: MenuItem[] = [ + { + label: spec.anyLabel ?? `Any ${spec.label.toLowerCase()}`, + icon: current == null ? "check" : "blank", + onClick: () => { + delete o.state[spec.key]; + render(); + o.onChange(); + }, + }, + ]; + if (opts.length) items_.push({ separator: true }); + for (const opt of opts) { + const selected = current === opt.value; + items_.push({ + label: opt.label ?? opt.value, + // "blank" is a zero-ink glyph that still occupies the icon slot: with + // only the selected row getting a check and nothing reserving the + // gutter for the rest, the label column jumped 25px depending on + // what was selected. + icon: selected ? "check" : opt.iconEl ? undefined : (opt.icon ?? "blank"), + iconEl: selected ? undefined : opt.iconEl?.(), + current: selected, + onClick: () => { + o.state[spec.key] = opt.value; + render(); + o.onChange(); + }, + }); } - empty.hidden = shown > 0; - }; - search.addEventListener("input", filter); - - const actions = el("div", "modal-actions"); - const cancel = el("button", "mini-btn"); - cancel.textContent = "Cancel"; - const ok = el("button", "btn btn-primary modal-ok"); - ok.append(span(opts.okLabel)); - actions.append(cancel, ok); - card.append(h, search, list, actions); - - const finish = (v: string[] | null): void => { - if (settled) return; - settled = true; - overlay.remove(); - document.removeEventListener("keydown", onKey, true); - resolve(v); + if (!opts.length) { + items_.push({ label: `No ${spec.label.toLowerCase()} to filter by`, disabled: true }); + } + // Long option lists get the menu's own filter box — scrolling 40 branches + // to find one is not filtering, it's searching by hand. + openMenu(btn, items_, { searchable: opts.length > 8 }); }; - const onKey = (e: KeyboardEvent): void => { - if (e.key === "Escape") { - e.preventDefault(); - finish(null); + + if (spec.load && !loaded.has(spec.key)) { + // Show something immediately; replace it when the load lands. A menu + // that opens empty and never updates is worse than a slow one. + void spec + .load() + .then((opts) => { + loaded.set(spec.key, opts); + build(opts); + }) + .catch(() => { + loaded.set(spec.key, []); + build([]); + }); + return; + } + build(optionsFor(spec)); + }; + + const render = (): void => { + // Where the keyboard is, before this destroys the button it is on. + // + // `openMenu` deliberately restores focus to the trigger BEFORE running the + // item's action, so at this moment the facet button IS `activeElement` — + // and `replaceChildren` then removes it, dropping focus to <body> and + // restarting the next Tab at the top of the window, past the whole nav + // rail. Every keyboard user who filtered a list was thrown out of the page. + // + // `focusReturn`'s generic rescue cannot save this one: it matches a + // replacement by title, aria-label or text, and picking a value changes ALL + // THREE at once ("Filter by label" → "Filtering by label “bug”…", "Label" → + // "Label1"). Same failure already documented in views/rebase.ts. So the bar + // puts the keyboard back itself, by slot. + const held = document.activeElement as HTMLElement | null; + const keep = held && bar.contains(held) ? [...bar.children].indexOf(held) : -1; + bar.replaceChildren(); + for (const spec of o.specs) { + const value = o.state[spec.key]; + const btn = el("button", "mini-btn gh-facet-btn") as HTMLButtonElement; + const shown = + value == null + ? undefined + : optionsFor(spec).find((x) => x.value === value)?.label ?? value; + btn.classList.toggle("is-active", value != null); + // The pill keeps its own name; the tick beside it says a value is set, + // and the tooltip (plus the menu itself) says which. Putting the value in + // the label is what made the pill grow and shove its neighbours. + const mark = el("span", "gh-facet-value"); + mark.textContent = value != null ? "1" : ""; + btn.append(glyph(spec.icon), span(spec.label), mark, glyph("chevron-down")); + btn.title = + shown != null + ? `Filtering by ${spec.label.toLowerCase()} “${shown}” — click to change` + : `Filter by ${spec.label.toLowerCase()}`; + btn.setAttribute("aria-label", btn.title); + btn.addEventListener("click", () => openFacetMenu(spec, btn)); + bar.appendChild(btn); + } + if (activeCount() > 0) { + const clearBtn = el("button", "mini-btn gh-facet-clear") as HTMLButtonElement; + clearBtn.append(glyph("clear-all"), span("Clear")); + clearBtn.title = "Clear every filter"; + clearBtn.addEventListener("click", () => api.clear()); + bar.appendChild(clearBtn); + } + // Put the keyboard back on the button in the same slot. The clamp covers + // Clear, which sits last and disappears once it has done its job — focus + // then lands on the final facet rather than on <body>. `keep === -1` when + // focus was never in the bar (the initial build, or a `sync()` rebuild + // while the dropdown itself has focus), so this never steals it. + if (keep >= 0 && bar.children.length) { + (bar.children[Math.min(keep, bar.children.length - 1)] as HTMLElement).focus({ + preventScroll: true, + }); + } + }; + + const activeCount = (): number => facetActiveCount(o.specs, o.state); + + const api: FacetBar<T> = { + el: bar, + passes: (item: T) => facetPasses(o.specs, o.state, item), + serverValues: () => facetServerValues(o.specs, o.state), + activeCount, + clear: () => { + // EVERY key in the state, not just the specs currently in the bar. + // + // A view may drop a spec on some segments — Issues hides "Closed as" on + // Open, because a closed reason can only match a closed issue. Clearing + // only the listed specs left that value set, invisible and unclearable, + // and it silently narrowed the list again the moment you switched back to + // Closed. The button's own tooltip is "Clear every filter"; this makes + // that true. + for (const key of Object.keys(o.state)) delete o.state[key]; + render(); + o.onChange(); + }, + sync: (next: T[]) => { + const had = items.length; + // Ask BEFORE the rebuild: `render()` replaces every button, so the + // aria-expanded that identifies the open menu lives on the button that is + // about to be discarded. Checking afterwards always answers "no". + const specIndex = o.specs.findIndex((sp) => sp.key === openSpecKey); + const wasOpen = + openSpecKey !== undefined && + specIndex >= 0 && + (bar.children[specIndex] as HTMLElement | undefined)?.getAttribute("aria-expanded") === + "true"; + items = next; + render(); + // A menu opened over a still-loading list is anchored to a button that + // `render()` has just replaced, and holds options harvested from nothing. + // Re-open it against the live button so it fills in; `openMenu` replaces + // any menu already up, so this is a refill rather than a second menu. + // Refill ONLY the menu that was open on THIS facet's own button. Testing + // for any `.dropdown` in the document was wrong twice over: a menu the + // user had dismissed before the data landed would pop itself back open, + // and a menu they had since opened somewhere else — a row's ⋯, the sort + // picker — would be replaced by this one. + if (!wasOpen) { + openSpecKey = undefined; return; } - trapTab(e, card); - }; - cancel.addEventListener("click", () => finish(null)); - ok.addEventListener("click", () => finish(boxes.filter((b) => b.cb.checked).map((b) => b.login))); - overlay.addEventListener("mousedown", (e) => { - if (e.target === overlay) finish(null); - }); - overlay.appendChild(card); - document.body.appendChild(overlay); - document.addEventListener("keydown", onKey, true); - setTimeout(() => search.focus(), 0); - }); + if (!had && !next.length) return; // still nothing to put in it + const btn = bar.children[specIndex] as HTMLElement | undefined; + if (btn) openFacetMenu(o.specs[specIndex], btn); + }, + }; + + render(); + return api; } -/** A list-left / detail-right scaffold matching the PR & Issue views. */ -/** Make a `.gh-list` pane user-resizable + keyboard-operable. Returns the divider - * element to place BETWEEN the list and the detail in a `.gh-body`. The width - * persists across sessions AND across every section view (one shared key), so a - * PR, an issue and a release all honour the same chosen proportion. */ -export function ghListResizer(listEl: HTMLElement): HTMLElement { - const MIN = 280; - const MAX = 720; - const saved = Number(localStorage.getItem("gitstudio.ghListW")); - let w = Number.isFinite(saved) && saved > 0 ? Math.min(MAX, Math.max(MIN, saved)) : 430; - const apply = (): void => { - listEl.style.flex = `0 0 ${w}px`; - listEl.style.minWidth = `${MIN}px`; - listEl.style.maxWidth = `${MAX}px`; + +/** The segmented control (Open / Closed / All), extracted from the two views + * that each had their own copy. Returns the element; the caller owns state. */ +/** + * The `.gh-subtabs` bar with real tab semantics. Three detail pages hand-rolled + * this as plain buttons carrying an `active` CLASS and nothing else: a screen + * reader heard four unrelated buttons and could not tell which page you were + * on, and ←/→ did nothing. One tablist, one roving tab stop, one selected tab. + * + * Returns the bar plus a `select(id)` the caller drives; the caller still owns + * what each tab renders. + */ +export function subTabs<I extends string>(o: { + tabs: ReadonlyArray<{ id: I; label: string; icon?: string }>; + ariaLabel: string; + panel?: HTMLElement; + onSelect: (id: I) => void; +}): { el: HTMLElement; select: (id: I) => void; current: () => I } { + const bar = el("div", "gh-subtabs"); + bar.setAttribute("role", "tablist"); + bar.setAttribute("aria-label", o.ariaLabel); + const btns: HTMLElement[] = []; + let active = o.tabs[0]?.id as I; + + const paint = (id: I): void => { + active = id; + for (const b of btns) { + const on = b.dataset.sub === id; + b.classList.toggle("active", on); + b.setAttribute("aria-selected", String(on)); + // One tab stop for the whole bar: Tab reaches the selected tab, arrows + // move between them. That is what a tablist is. + b.tabIndex = on ? 0 : -1; + } }; - const setW = (n: number): void => { - w = Math.min(MAX, Math.max(MIN, Math.round(n))); - apply(); + const select = (id: I): void => { + paint(id); + o.onSelect(id); }; - apply(); - - const split = el("div", "cmp-vsplit gh-vsplit"); - split.append(el("div", "cmp-vsplit-grip")); - wireResizerKeys(split, { - orientation: "vertical", - label: "Resize the list pane", - min: MIN, - max: () => MAX, - get: () => w, - set: setW, - onCommit: () => localStorage.setItem("gitstudio.ghListW", String(w)), + + for (const t of o.tabs) { + const b = el("button", "gh-subtab"); + b.setAttribute("role", "tab"); + b.dataset.sub = t.id; + if (o.panel?.id) b.setAttribute("aria-controls", o.panel.id); + if (t.icon) b.appendChild(glyph(t.icon)); + b.appendChild(span(t.label)); + b.addEventListener("click", () => select(t.id)); + btns.push(b); + bar.appendChild(b); + } + bar.addEventListener("keydown", (e) => { + const step = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0; + if (!step && e.key !== "Home" && e.key !== "End") return; + e.preventDefault(); + const i = btns.findIndex((b) => b.dataset.sub === active); + const next = + e.key === "Home" ? 0 : e.key === "End" ? btns.length - 1 : (i + step + btns.length) % btns.length; + const id = btns[next]?.dataset.sub as I | undefined; + if (id == null) return; + select(id); + btns[next].focus(); }); - split.addEventListener("pointerdown", (e) => { + if (o.panel) o.panel.setAttribute("role", "tabpanel"); + paint(active); + return { el: bar, select, current: () => active }; +} + +export function segmented<V extends string>(o: { + options: Array<{ value: V; label: string; icon?: string }>; + value: V; + ariaLabel: string; + onChange: (value: V) => void; +}): HTMLElement { + const seg = el("div", "gh-seg"); + seg.setAttribute("role", "group"); + seg.setAttribute("aria-label", o.ariaLabel); + for (const opt of o.options) { + const b = el("button", "gh-seg-btn" + (opt.value === o.value ? " active" : "")); + if (opt.icon) b.appendChild(glyph(opt.icon)); + b.appendChild(span(opt.label)); + b.setAttribute("aria-pressed", String(opt.value === o.value)); + b.addEventListener("click", () => { + if (opt.value === o.value) return; + o.onChange(opt.value); + }); + seg.appendChild(b); + } + return seg; +} + +/** + * Arrow-key traversal for a list of row buttons: ↑/↓ move focus between the + * visible rows, Home/End jump to the edges, and Enter activates (native, since + * rows are buttons). Delegated on the container so re-rendered rows need no + * re-wiring. Typing surfaces (a filter input inside the container) are left + * alone. Every browsable list wires this — it's what makes the sections feel + * keyboard-first instead of Tab-only. + */ +export function wireListNav(container: HTMLElement, selector = ".gh-row"): void { + container.addEventListener("keydown", (e) => { + // j/k are first-class aliases for ↓/↑ — the muscle memory every + // Linear/Vim/Gmail hand brings to a list. + const down = e.key === "ArrowDown" || e.key === "j"; + const up = e.key === "ArrowUp" || e.key === "k"; + const isNav = down || up || e.key === "Home" || e.key === "End"; + if (!isNav && e.key !== "Enter") return; + if (e.metaKey || e.ctrlKey || e.altKey) return; + const t = e.target as HTMLElement | null; + if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return; + const rows = Array.from(container.querySelectorAll<HTMLElement>(selector)).filter( + (r) => r.offsetParent !== null, + ); + if (!rows.length) return; + const cur = rows.indexOf(document.activeElement as HTMLElement); + if (e.key === "Enter") { + // Rows are mostly plain <div>s with click listeners — Enter must mean + // "activate this row" for them too, not just for real <button>s (whose + // native Enter→click still works and is de-duplicated by this guard). + if (cur >= 0 && !(rows[cur] instanceof HTMLButtonElement)) { + e.preventDefault(); + rows[cur].click(); + } + return; + } + let next: number; + if (e.key === "Home") next = 0; + else if (e.key === "End") next = rows.length - 1; + else if (cur === -1) next = down ? 0 : rows.length - 1; + else if (down) next = Math.min(cur + 1, rows.length - 1); + else next = Math.max(cur - 1, 0); e.preventDefault(); - const startX = e.clientX; - const startW = w; - document.body.classList.add("resizing-h"); - const move = (ev: PointerEvent): void => setW(startW + (ev.clientX - startX)); - const up = (): void => { - document.body.classList.remove("resizing-h"); - window.removeEventListener("pointermove", move); - window.removeEventListener("pointerup", up); - localStorage.setItem("gitstudio.ghListW", String(w)); - }; - window.addEventListener("pointermove", move); - window.addEventListener("pointerup", up); + const target = rows[next]; + // ghRow builds non-focusable <div>s (no caller passes onClick) — .focus() + // was a silent no-op and arrow navigation was DEAD on every gh-row list. + if (target.tabIndex < 0 && !(target instanceof HTMLButtonElement)) target.tabIndex = -1; + target.focus(); + target.scrollIntoView({ block: "nearest" }); }); - return split; } -/** The master/detail shell shared by every GitHub section view. The list⇄detail - * split is user-resizable (see ghListResizer). */ -export function ghTwoPane(): { view: HTMLElement; listEl: HTMLElement; detailEl: HTMLElement } { - const view = el("div", "gh-view"); - const body = el("div", "gh-body"); - const listEl = el("div", "gh-list"); - const detailEl = el("div", "gh-detail"); - body.append(listEl, ghListResizer(listEl), detailEl); - view.appendChild(body); - return { view, listEl, detailEl }; +/** + * GitHub's CI status/conclusion enums, in English. + * + * These arrive as `in_progress`, `action_required`, `timed_out` and were + * printed raw beside rows the rest of the app humanises — the one place in + * GitStudio where the API's vocabulary leaked onto the screen. + */ +export function checkStateLabel(state: string): string { + const map: Record<string, string> = { + success: "Passed", + failure: "Failed", + neutral: "Neutral", + cancelled: "Cancelled", + canceled: "Cancelled", + skipped: "Skipped", + stale: "Stale", + timed_out: "Timed out", + action_required: "Action required", + startup_failure: "Startup failure", + queued: "Queued", + waiting: "Waiting", + pending: "Pending", + requested: "Requested", + in_progress: "Running", + completed: "Completed", + }; + return map[state] ?? state.replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase()); +} + +/** + * Dispose something when `node` leaves the document. + * + * A section view has no teardown hook: `routeView` disposes exactly one thing — + * `activeMonacoView` — and a view that does not register there leaks whatever + * it built. That has now cost three separate leaks (the PR diff panel, the job + * log's pane and its 200,000-line document, and the commit page's Monaco diff + * editor, one per visit), each fixed with its own copy of this observer. This + * is that copy, once. + * + * Watching the whole document is deliberate: a view is removed by having its + * host's children replaced, which fires no event on the view itself. The + * observer disconnects the moment it fires, so it costs one callback per DOM + * mutation only until its node goes. + */ +export function disposeOnDetach(node: HTMLElement, dispose: () => void): () => void { + let done = false; + const stop = (): void => { + if (done) return; + done = true; + obs.disconnect(); + }; + const obs = new MutationObserver(() => { + if (node.isConnected) return; + stop(); + dispose(); + }); + obs.observe(document.body, { childList: true, subtree: true }); + return stop; } diff --git a/apps/desktop/src/renderer/views/explore.ts b/apps/desktop/src/renderer/views/explore.ts new file mode 100644 index 0000000..62edaf7 --- /dev/null +++ b/apps/desktop/src/renderer/views/explore.ts @@ -0,0 +1,704 @@ +// Explore — global GitHub search, in the app. +// +// The point of the whole redesign, applied to discovery: finding a repo, a +// person, an org or a line of code should not mean opening a browser. Four +// tabs over GitHub's search API, results you can act on (open in GitStudio, +// clone somewhere you choose), and paging that is honest about GitHub's hard +// 1000-result ceiling. +// +// The search budget is small (30/min; 10/min for code), so: +// • repos / people / orgs debounce at 300ms +// • CODE searches only on Enter — never a keystroke +// • every request goes through main's SearchGuard, and a `limited` answer +// renders a countdown instead of an error +// +// Routing: Explore states are `target.id` micro-paths, so ⌘[ / Esc walk the +// trail without adding fields to SectionTarget: +// q/<tab>/<query> the search itself +// repo/<owner>/<name> a repository page (E4 fills this in) +// user/<login> · org/<login> (E4 fills these in) + +import { host } from "./../bridge"; +import { gget, peek } from "./../cache"; +import { toast } from "./../dialogs"; +import { + avatar, + cleanErr, + el, + emptyState, + errorState, + glyph, + openMenu, + relTimeISO, + skeletonList, + span, +} from "../ui"; +import { openGhRepoInApp, openGhRepoChooseLocation } from "../ghOpen"; +import { openCloneDialog } from "../cloneDialog"; +import { openPeek } from "../peek"; +import { memberCard } from "./orgs"; +import { + parseAccountTarget, + parseExploreTarget, + parseRepoRoute, + searchTargetId, + repoRouteId, +} from "../exploreRoutes"; +export { searchTargetId } from "../exploreRoutes"; +import { renderRepoPage } from "./exploreRepo"; +import { renderAccountPage } from "./exploreUser"; +import { + ghGate, + searchField, + secRow, + sectionList, + type SectionNav, + type SectionRender, + type SectionTarget, +} from "./common"; +import type { + SearchCodeFragment, + SearchCodeItem, + SearchPage, + SearchRepoItem, + SearchSort, + SearchUserItem, +} from "../../shared/ipc"; + +type Tab = "repos" | "users" | "orgs" | "code"; + +const TABS: ReadonlyArray<{ id: Tab; label: string; icon: string }> = [ + { id: "repos", label: "Repositories", icon: "repo" }, + { id: "users", label: "People", icon: "person" }, + { id: "orgs", label: "Organizations", icon: "organization" }, + { id: "code", label: "Code", icon: "code" }, +]; + +// ── Section state (survives list ⇄ detail round trips, like the other views) ── +let tab: Tab = "repos"; +let query = ""; +let repoSort: SearchSort = "best"; +/** Pages accumulated for the CURRENT (tab, query, sort) — "Load more" appends. */ +let pages = 1; + +/** Guards against a slow earlier query overwriting a newer one's results. */ +let searchSeq = 0; +/** + * Where the result list was scrolled when we left it — and WHICH search that + * was. Keyed, because a bare number is applied to whatever runs next: leave a + * repo search scrolled to row 40, come back, switch to Code, and the new + * (shorter) list was scrolled to a position that meant nothing in it. + */ +let listScroll: { key: string; top: number } | undefined; + + + +export const renderExplore: SectionRender = (wrap, nav, target) => { + void mount(wrap, nav, target); +}; + +async function mount(wrap: HTMLElement, nav: SectionNav, target?: SectionTarget): Promise<void> { + // Entity pages are Explore states too — a repo, a person, an org. Each is a + // full page that goes BACK to the search it came from. + const backToSearch = (): void => + nav("explore", query ? { id: searchTargetId(tab, query) } : undefined); + + const repoRoute = parseRepoRoute(target?.id); + if (repoRoute) { + const gate = await ghGate(wrap, nav, true, () => renderExplore(wrap, nav, target)); + if (!gate) return; + renderRepoPage(wrap, nav, repoRoute, backToSearch); + return; + } + const account = parseAccountTarget(target?.id); + if (account) { + const gate = await ghGate(wrap, nav, true, () => renderExplore(wrap, nav, target)); + if (!gate) return; + renderAccountPage(wrap, nav, account.login, backToSearch); + return; + } + + const routed = parseExploreTarget(target?.id); + if (routed) { + // Only a DIFFERENT search starts over. Explore's whole loop is scan, open + // one, come back, open the next — and coming back re-routes to this same + // search, so resetting unconditionally meant every visit re-paged from one: + // eight "Load more" clicks thrown away, and the row you had just read gone + // back above the fold. + const same = routed.tab === tab && routed.query === query; + tab = routed.tab; + query = routed.query; + if (!same) pages = 1; + } + + const refresh = (): void => renderExplore(wrap, nav, target); + const gate = await ghGate(wrap, nav, true, refresh); + if (!gate) return; + + const { view, listEl } = sectionList(); + view.classList.add("explore-view"); + + // ── search-first header ── + const head = el("div", "explore-head"); + const title = el("h1", "explore-title"); + title.textContent = "Explore GitHub"; + const sub = el("div", "explore-sub"); + sub.textContent = "Repositories, people, organizations and code — all of GitHub, opened here."; + + const field = searchField({ + placeholder: + tab === "code" + ? "Search code — press Enter (code search is rate-limited)" + : tab === "repos" + ? "Search repositories — try stars:>1000 language:TypeScript" + : tab === "orgs" + ? "Search organizations…" + : "Search people…", + initial: query, + autofocus: true, + // Code search costs 10× more of the budget, so it never fires on a + // keystroke: the long debounce is a backstop, Enter is the real trigger. + debounceMs: tab === "code" ? 100_000 : 300, + onInput: (q) => { + // Code search costs a request per keystroke and is rate-limited hard, so + // it waits for Enter rather than typing-as-you-search. But CLEARING is + // not a search: the ✕ emptied the box and left the previous results + // sitting under it, so the field said one thing and the list another and + // the only way to agree with the box was to press Enter on nothing. + if (tab === "code" && q !== "") return; + setQuery(q); + }, + onEnter: (q) => setQuery(q), + }); + field.classList.add("explore-search"); + head.append(title, sub, field); + + const tabBar = el("div", "explore-tabs"); + tabBar.setAttribute("role", "tablist"); + for (const t of TABS) { + const b = el("button", "explore-tab" + (t.id === tab ? " active" : "")); + b.setAttribute("role", "tab"); + b.setAttribute("aria-selected", String(t.id === tab)); + b.append(glyph(t.icon), span(t.label)); + b.addEventListener("click", () => { + if (t.id === tab) return; + tab = t.id; + pages = 1; + // Re-route rather than re-render in place, so ⌘[ walks tab changes too. + if (query) nav("explore", { id: searchTargetId(tab, query) }); + else renderExplore(wrap, nav, undefined); + }); + tabBar.appendChild(b); + } + + const tools = el("div", "explore-tools"); + tools.appendChild(tabBar); + if (tab === "repos") { + const sortBtn = el("button", "mini-btn explore-sort"); + const sortLabel = (s: SearchSort): string => + s === "stars" ? "Most stars" : s === "updated" ? "Recently updated" : "Best match"; + sortBtn.append(glyph("sort-precedence"), span(sortLabel(repoSort)), glyph("chevron-down")); + sortBtn.title = "Sort results"; + sortBtn.addEventListener("click", () => + openMenu( + sortBtn, + (["best", "stars", "updated"] as SearchSort[]).map((s) => ({ + label: sortLabel(s), + icon: repoSort === s ? "check" : undefined, + onClick: () => { + if (repoSort === s) return; + repoSort = s; + pages = 1; + run(); + }, + })), + ), + ); + tools.appendChild(sortBtn); + } + head.appendChild(tools); + view.append(head, listEl); + wrap.replaceChildren(view); + + const setQuery = (q: string): void => { + if (q === query) return; + query = q; + pages = 1; + if (q) nav("explore", { id: searchTargetId(tab, q) }); + else run(); + }; + + // ── running the search ── + const run = async (append = false): Promise<void> => { + const seq = ++searchSeq; + if (!query) { + listEl.replaceChildren(startState()); + return; + } + // People and organizations are a DIRECTORY, not documents: a 40px row + // holding a login in a 1350px pane read ~93% empty. Same treatment the + // organization's Members tab uses — compact chips that wrap from the left. + listEl.classList.toggle("is-people", tab === "users" || tab === "orgs"); + if (!append) listEl.replaceChildren(skeletonList(6)); + else { + // A retry from the tail card replaces that card, so the list never + // accumulates one refusal per attempt. + // + // Both kinds, not just the rate-limit one: the error card's own Retry + // calls straight back into here, which appends a fresh spinner BELOW the + // card that is still sitting there — so a search failing three times + // ended with three "Search failed" cards stacked at the bottom of the + // list, each with its own live Retry button. + listEl.querySelector(".explore-more-note")?.remove(); + listEl.appendChild(loadingMore()); + } + + try { + const page = append ? pages + 1 : 1; + const result = await fetchPage(tab, query, repoSort, page); + if (seq !== searchSeq || !view.isConnected) return; + if (result.limited) { + // On an APPEND the loaded pages are still good — the refusal is about + // the NEXT page. Replacing the list threw away everything the user had + // scrolled through to punish them for asking for more. + const limited = limitedState(result.limited.retryInMs, () => void run(append)); + if (append) { + limited.classList.add("explore-more-note"); + const spinner = listEl.querySelector(".explore-loading-more"); + if (spinner) spinner.replaceWith(limited); + else listEl.appendChild(limited); + } else { + listEl.replaceChildren(limited); + } + return; + } + if (append) { + pages = page; + listEl.querySelector(".explore-loading-more")?.remove(); + listEl.querySelector(".explore-footer")?.remove(); + appendRows(result, false); + } else { + // Re-lay the pages this search had already accumulated — but ONLY the + // ones still in the cache. + // + // This used to `await fetchPage(...)` per page, on the premise that + // they were all cached. That holds for a minute. Read a repo page for + // longer and every Back spent one search request per accumulated page, + // sequentially, with the list visibly rebuilding a page at a time — and + // on the Code tab, where the budget is about eight requests a minute, + // returning with eight pages loaded spent the entire budget on the + // BACK, so the next thing the user typed met the app's own "Search is + // catching its breath". Paying the search budget to restore a scroll + // position is not a trade worth making; the footer still offers the + // rest, which is the same one click that loaded them the first time. + // + // (The old loop's guard was dead too: `result.items.length > 0` tests + // the FIRST page on every iteration, so it never stopped anything.) + const restore = pages; + pages = 1; + listEl.replaceChildren(); + appendRows(result, true); + for (let p = 2; p <= restore && result.items.length > 0; p++) { + const more = peekPage(tab, query, repoSort, p); + if (!more || more.limited || !more.items.length) break; + pages = p; + listEl.querySelector(".explore-footer")?.remove(); + appendRows(more, false); + } + // Only onto the search it was taken from. + if (listScroll && listScroll.key === searchTargetId(tab, query)) { + listEl.scrollTop = listScroll.top; + } + listScroll = undefined; + } + } catch (e) { + if (seq !== searchSeq || !view.isConnected) return; + // On an APPEND the loaded pages are still good — the failure is about the + // NEXT page. The `limited` branch above was given this treatment + // deliberately; the error branch was not, so one flaky request deleted + // however many pages of scanning you had done and its Retry started + // again from page 1. + const failed = errorState( + "Search failed", + cleanErr(e) || "GitHub couldn't answer that search.", + () => void run(append), + ); + if (append) { + failed.classList.add("explore-more-note"); + const spinner = listEl.querySelector(".explore-loading-more"); + if (spinner) spinner.replaceWith(failed); + else listEl.appendChild(failed); + } else { + listEl.replaceChildren(failed); + } + } + }; + + /** Every row navigates through this, so wherever you were reading is where + * you come back to. Opening a result and returning used to land you at the + * top of the list with the row you had just opened somewhere below. */ + const leaveNav: SectionNav = (section, t) => { + listScroll = { key: searchTargetId(tab, query), top: listEl.scrollTop }; + nav(section, t); + }; + + const appendRows = (result: SearchPage<unknown>, first: boolean): void => { + const items = result.items; + if (first && items.length === 0) { + listEl.replaceChildren( + emptyState("No results", `Nothing on GitHub matches “${query}”.`, { + icon: "search", + anchor: "inline", + }), + ); + return; + } + for (const item of items) { + if (tab === "repos") listEl.appendChild(repoRow(item as SearchRepoItem, leaveNav)); + else if (tab === "code") listEl.appendChild(codeRow(item as SearchCodeItem, leaveNav)); + else listEl.appendChild(userRow(item as SearchUserItem, leaveNav)); + } + listEl.appendChild(footer(result, () => void run(true))); + }; + + await run(); +} + +/** + * A page ALREADY in the cache, or undefined. Never a request. + * + * The keys must match `fetchPage`'s exactly, which is why the two sit together. + */ +function peekPage( + t: Tab, + q: string, + sort: SearchSort, + page: number, +): SearchPage<SearchRepoItem | SearchUserItem | SearchCodeItem> | undefined { + if (t === "repos") return peek("search:repos", { query: q, sort, page }, 60_000); + if (t === "code") return peek("search:code", { query: q, page }, 60_000); + return peek("search:users", { query: q, kind: t === "orgs" ? "orgs" : "users", page }, 60_000); +} + +// ── one request, cached by (tab, query, sort, page) ────────────────────────── + +function fetchPage( + t: Tab, + q: string, + sort: SearchSort, + page: number, +): Promise<SearchPage<SearchRepoItem | SearchUserItem | SearchCodeItem>> { + // 60s cache: retyping a query you just ran must not spend the budget twice. + // A rate-limit refusal is NOT an answer, so it is never remembered — cached, + // it made both the timed retry and "Retry now" no-ops for the full minute. + const keep = { cacheable: (r: { limited?: unknown }) => !r.limited }; + if (t === "repos") return gget("search:repos", { query: q, sort, page }, 60_000, keep); + if (t === "code") return gget("search:code", { query: q, page }, 60_000, keep); + return gget( + "search:users", + { query: q, kind: t === "orgs" ? "orgs" : "users", page }, + 60_000, + keep, + ); +} + +// ── rows ───────────────────────────────────────────────────────────────────── + +/** + * An Explore result row. + * + * Deliberately NOT `secRow`: that one is a single-line <button>, and these rows + * need a description line plus real hover-action buttons — which can't nest + * inside a button. Same visual density, built as a div like the Inbox rows. + */ +function exploreRow(o: { + lead: HTMLElement; + title: string; + titleSuffix?: HTMLElement[]; + sub?: string; + meta?: HTMLElement[]; + time?: string; + timeTitle?: string; + ariaLabel: string; + onOpen: () => void; + actions: Array<{ label: string; title: string; run: () => void }>; + extraClass?: string; +}): HTMLElement { + const row = el("div", "sec-row list-row explore-row" + (o.extraClass ? ` ${o.extraClass}` : "")); + row.tabIndex = 0; + row.setAttribute("role", "button"); + row.setAttribute("aria-label", o.ariaLabel); + + const lead = el("span", "sec-row-lead"); + lead.appendChild(o.lead); + row.appendChild(lead); + + const body = el("div", "explore-row-body"); + const head = el("div", "explore-row-head"); + head.appendChild(span(o.title, "sec-row-title")); + for (const suffix of o.titleSuffix ?? []) head.appendChild(suffix); + body.appendChild(head); + if (o.sub) { + const sub = el("div", "explore-desc"); + sub.textContent = o.sub; + sub.title = o.sub; + body.appendChild(sub); + } + row.appendChild(body); + + if (o.meta?.length) { + const meta = el("span", "sec-row-meta"); + meta.append(...o.meta); + row.appendChild(meta); + } + if (o.time) { + const t = el("span", "sec-row-time"); + t.textContent = o.time; + if (o.timeTitle) t.title = o.timeTitle; + row.appendChild(t); + } + row.appendChild(rowActions(o.actions)); + + row.addEventListener("click", o.onOpen); + row.addEventListener("keydown", (e) => { + if (e.target !== row) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + o.onOpen(); + } + }); + return row; +} + +function repoRow(r: SearchRepoItem, nav: SectionNav): HTMLElement { + const meta: HTMLElement[] = []; + if (r.language) meta.push(span(r.language, "explore-lang")); + // Formatted: the footer on the same screen writes "1,284 matches" while the + // rows printed "48200". + if (r.stars > 0) meta.push(statBit("star-full", r.stars)); + if (r.forks > 0) meta.push(statBit("repo-forked", r.forks)); + + return exploreRow({ + lead: glyph(r.private ? "lock" : r.fork ? "repo-forked" : "repo"), + title: r.fullName, + titleSuffix: [ + ...(r.archived ? [pill("archived")] : []), + ...(r.license ? [pill(r.license)] : []), + ], + sub: r.description ?? undefined, + meta, + time: relTimeISO(r.pushedAt || r.updatedAt), + timeTitle: r.pushedAt ? `Last pushed ${r.pushedAt}` : undefined, + ariaLabel: `Repository ${r.fullName}`, + // E4 turns this into the full in-app repository page. + onOpen: () => nav("explore", { id: `repo/${r.fullName}` }), + actions: [ + { label: "Open", title: `Clone ${r.fullName} if needed, then open it`, run: () => openGhRepoInApp(r.fullName) }, + { + label: "Choose location…", + title: "Pick the folder it's cloned into", + run: () => openGhRepoChooseLocation(r.fullName), + }, + { + label: "Clone…", + title: "Open the clone dialog for this repository", + run: () => + openCloneDialog((root) => void host.invoke("repo:openPath", root), { + url: `https://github.com/${r.fullName}.git`, + }), + }, + { label: "GitHub", title: "Open on github.com", run: () => window.open(r.htmlUrl, "_blank", "noopener") }, + ], + }); +} + +function userRow(u: SearchUserItem, nav: SectionNav): HTMLElement { + const isOrg = u.type === "Organization"; + // A 40px row with a small avatar in a 1350px pane read as ~93% empty. The + // answer is a DENSER row, not filler: the search API gives a login, an + // avatar and a type, and a sub-line repeating "Person on GitHub" on every + // row would be the same word thirty times. Bigger avatar, tighter row. + return exploreRow({ + lead: avatar(u.login, u.avatarUrl, 28), + title: u.login, + titleSuffix: isOrg ? [pill("org")] : [], + extraClass: "explore-person-row", + ariaLabel: `${isOrg ? "Organization" : "User"} ${u.login}`, + onOpen: () => nav("explore", { id: `${isOrg ? "org" : "user"}/${u.login}` }), + actions: [ + { + label: "Profile", + title: `A quick look at @${u.login}`, + run: () => openPeek(memberCard({ login: u.login, avatarUrl: u.avatarUrl, htmlUrl: u.htmlUrl })), + }, + { label: "GitHub", title: "Open on github.com", run: () => window.open(u.htmlUrl, "_blank", "noopener") }, + ], + }); +} + +/** + * One fragment with the matched text marked. + * + * A code search exists to find a STRING, and the result rendered three lines of + * code with nothing at all indicating where in them the string was — leaving + * the reader to scan for it by eye, which is the work they asked the search to + * do. GitHub returns the offsets in the same response as the fragment. + */ +function codeFragment(f: SearchCodeFragment): HTMLElement { + const line = el("span", "explore-code-line"); + let at = 0; + for (const [a, b] of f.ranges) { + // Ranges arrive sorted; skip any that overlaps one already drawn rather + // than emitting text twice. + if (a < at) continue; + if (a > at) line.appendChild(document.createTextNode(f.text.slice(at, a))); + const mark = el("mark", "explore-code-hit"); + mark.textContent = f.text.slice(a, b); + line.appendChild(mark); + at = b; + } + if (at < f.text.length) line.appendChild(document.createTextNode(f.text.slice(at))); + return line; +} + +function codeRow(c: SearchCodeItem, nav: SectionNav): HTMLElement { + const row = exploreRow({ + lead: glyph("file-code"), + title: c.path, + ariaLabel: `${c.path} in ${c.repoFullName}`, + sub: c.repoFullName, + // Open the FILE you found, not the repository it happens to live in. This + // navigated to `repo/<fullName>` and threw `c.path` away — so a code search, + // whose entire purpose is finding one file among thousands, answered a click + // by dumping you at the repo root with the result gone from the screen. + onOpen: () => + nav("explore", { + id: repoRouteId({ fullName: c.repoFullName, path: c.path, kind: "blob" }), + }), + extraClass: "explore-code-row", + actions: [ + { + label: "GitHub", + title: "Open this file on github.com", + run: () => window.open(c.htmlUrl, "_blank", "noopener"), + }, + ], + }); + const body = row.querySelector(".explore-row-body"); + // ONE code block, not one box per matched line. Three separate bordered + // <pre>s stitched by :has() sibling rules still read as three boxes (the + // row body's gap sat between them), so a single hit looked like three hits. + // Non-adjacent fragments are separated the way a diff separates hunks. + const frags = c.fragments.slice(0, 3); + if (frags.length && body) { + const pre = el("pre", "explore-code-frag"); + frags.forEach((f, i) => { + if (i > 0) pre.appendChild(span("⋯", "explore-code-gap")); + pre.appendChild(codeFragment(f)); + }); + body.appendChild(pre); + } + return row; +} + +// ── small builders ─────────────────────────────────────────────────────────── + +function pill(text: string): HTMLElement { + return span(text, "gh-pill explore-pill"); +} + +function statBit(icon: string, n: number): HTMLElement { + const s = span("", "explore-stat"); + s.append(glyph(icon), span(n.toLocaleString())); + return s; +} + +function rowActions(actions: Array<{ label: string; title: string; run: () => void }>): HTMLElement { + const acts = el("div", "row-actions"); + for (const a of actions) { + const b = el("button", "row-btn"); + b.textContent = a.label; + b.title = a.title; + b.addEventListener("click", (e) => { + e.stopPropagation(); + a.run(); + }); + acts.appendChild(b); + } + return acts; +} + +function startState(): HTMLElement { + // The header subtitle already makes the pitch; this says what to DO. + return emptyState("Start typing to search", "Try a name, an owner, or a qualifier like stars:>1000.", { + icon: "telescope", + }); +} + +function loadingMore(): HTMLElement { + const d = el("div", "explore-loading-more"); + d.append(glyph("sync"), span("Loading more…")); + return d; +} + +/** The rate-limit state: a real countdown, not a dead error. */ +function limitedState(retryInMs: number, retry: () => void): HTMLElement { + const secs = (ms: number): number => Math.max(0, Math.ceil(ms / 1000)); + const line = (n: number): string => + n > 0 + ? `GitHub allows a limited number of searches per minute. Trying again in ${n}s.` + : `GitHub allows a limited number of searches per minute. Trying again now…`; + const wrap = emptyState("Search is catching its breath", line(secs(retryInMs)), { + icon: "watch", + }); + wrap.classList.add("explore-limited"); + const btn = el("button", "btn btn-soft list-empty-action"); + btn.append(glyph("sync"), span("Retry now")); + btn.addEventListener("click", retry); + wrap.appendChild(btn); + + // Tick the countdown. It used to be rendered once, so the card said the same + // number for the whole minute — indistinguishable from a wedged screen, which + // is exactly what people reported it as. + const body = wrap.querySelector(".list-empty-desc"); + const until = Date.now() + retryInMs; + const tick = window.setInterval(() => { + if (!wrap.isConnected) return void window.clearInterval(tick); + const left = secs(until - Date.now()); + if (body) body.textContent = line(left); + if (left > 0) return; + window.clearInterval(tick); + // Retry when the window opens — the user shouldn't have to babysit it. + retry(); + }, 500); + return wrap; +} + +/** The end-of-results line: honest about totals, the ceiling, and partials. */ +function footer(result: SearchPage<unknown>, more: () => void): HTMLElement { + const f = el("div", "explore-footer"); + const bits: string[] = []; + const total = result.totalCount; + bits.push(`${total.toLocaleString()} ${total === 1 ? "match" : "matches"}`); + if (total > 1000) bits.push("GitHub serves the first 1,000"); + if (result.incomplete) bits.push("GitHub timed out and returned partial results"); + const note = el("div", "explore-footer-note"); + note.append(glyph("info"), span(bits.join(" · "))); + f.appendChild(note); + if (result.hasMore) { + const btn = el("button", "btn btn-soft explore-more"); + btn.append(glyph("chevron-down"), span("Load more")); + btn.addEventListener("click", () => { + btn.setAttribute("disabled", "true"); + more(); + }); + f.appendChild(btn); + } + return f; +} + +/** Toast helper kept for future entity pages (E4) — exported so the module's + * error path stays consistent with the rest of the app. */ +export function exploreError(e: unknown): void { + toast(cleanErr(e) || "GitHub request failed.", "error"); +} diff --git a/apps/desktop/src/renderer/views/exploreRepo.ts b/apps/desktop/src/renderer/views/exploreRepo.ts new file mode 100644 index 0000000..630081b --- /dev/null +++ b/apps/desktop/src/renderer/views/exploreRepo.ts @@ -0,0 +1,568 @@ +// The full-page in-app repository browser — any GitHub repo, without cloning. +// +// The peek-based browser (repoBrowser.ts) is a glance: a stack of cards, good +// for "what's in here?". This is the other half Anton asked for — a real place +// to READ a repository: routed breadcrumbs, a ref switcher, go-to-file across +// the whole tree, README and file contents rendered the way the Code view +// renders them, and the actions (open, clone here, clone elsewhere) sitting in +// the top bar the whole time. +// +// Everything is a routed `target.id` micro-path, so ⌘[ / Esc walk the trail: +// repo/<owner>/<name> the repo root +// repo/<owner>/<name>/tree/<ref>/<path…> +// repo/<owner>/<name>/blob/<ref>/<path…> + +import { fileLines } from "../textFit"; +import { host } from "../bridge"; +import { gget } from "../cache"; +import { toast } from "../dialogs"; +import { + cleanErr, + el, + emptyState, + errorState, + fileIcon, + formatBytes, + glyph, + openMenu, + relTimeISO, + skeletonList, + span, +} from "../ui"; +import { renderMarkdown } from "../markdown"; +import { highlightCode } from "../highlight"; +import { resolveRelative, wireProseNav } from "../proseNav"; +import { openGhRepoInApp, openGhRepoChooseLocation } from "../ghOpen"; +import { openCloneDialog } from "../cloneDialog"; +import { fuzzyScore } from "../commandPalette"; +import { parseRepoRoute, repoRouteId, type RepoRoute } from "../exploreRoutes"; +import { detailPage, propSection, type SectionNav } from "./common"; +import type { GhRepoBranch, GhRepoEntry, GhRepoFile, OrgRepoDetail } from "../../shared/ipc"; + +// The routing vocabulary is pure and lives in ../exploreRoutes (node-tested); +// re-exported here so callers have one import site for "the repo page". +export { parseRepoRoute, repoRouteId, type RepoRoute } from "../exploreRoutes"; + +/** Render an Explore repository page into `wrap`. */ +export function renderRepoPage( + wrap: HTMLElement, + nav: SectionNav, + route: RepoRoute, + onBack: () => void, +): void { + void mount(wrap, nav, route, onBack); +} + +async function mount( + wrap: HTMLElement, + nav: SectionNav, + route: RepoRoute, + onBack: () => void, +): Promise<void> { + const { fullName, path, ref, kind } = route; + const goto = (o: { path?: string; ref?: string; kind?: "tree" | "blob" }): void => + nav("explore", { id: repoRouteId({ fullName, ref, ...o }) }); + + // ── top bar ── + const openBtn = el("button", "btn btn-primary det-split"); + const openMain = el("span", "det-split-main"); + openMain.append(glyph("folder-library"), span("Open in GitStudio")); + openMain.addEventListener("click", () => openGhRepoInApp(fullName)); + const openMore = el("span", "det-split-more"); + openMore.appendChild(glyph("chevron-down")); + openMore.title = "More ways to open this repository"; + openMore.addEventListener("click", (e) => { + e.stopPropagation(); + openMenu(openBtn, [ + { + label: "Choose location…", + icon: "folder-opened", + onClick: () => openGhRepoChooseLocation(fullName), + }, + { + label: "Clone…", + icon: "repo-clone", + onClick: () => + openCloneDialog((root) => void host.invoke("repo:openPath", root), { + url: `https://github.com/${fullName}.git`, + }), + }, + ]); + }); + openBtn.append(openMain, openMore); + + const ghBtn = el("button", "mini-btn gh-icon-btn"); + ghBtn.appendChild(glyph("link-external")); + ghBtn.title = "Open this repository on GitHub"; + ghBtn.setAttribute("aria-label", ghBtn.title); + ghBtn.addEventListener("click", () => + window.open(`https://github.com/${fullName}`, "_blank", "noopener"), + ); + + const gotoBtn = el("button", "mini-btn"); + gotoBtn.append(glyph("search"), span("Go to file")); + gotoBtn.title = "Fuzzy-search every file in this repository"; + gotoBtn.addEventListener("click", () => void openGoToFile(fullName, ref, (p) => goto({ path: p, kind: "blob" }))); + + // Filled in from the repo detail once it lands (the rail fetches it anyway). + let defaultBranchLabel = "default branch"; + let defaultBranchName: string | undefined; + const refBtn = el("button", "mini-btn explore-ref-btn"); + // "default branch" described the KIND of thing selected rather than the + // selection; the rail says the default is "main", so the button said one + // thing and the rail another. + refBtn.append(glyph("git-branch"), span(ref ?? defaultBranchLabel, "explore-ref-name"), glyph("chevron-down")); + refBtn.title = "Switch branch"; + refBtn.addEventListener("click", () => + // `kind` too. It was the ONLY one of the seven goto call sites that dropped + // it, and `repoRouteId` defaults a missing kind to "tree" — so switching + // the branch while READING A FILE turned a blob route into a tree route at + // the file's own path. The title fell back to the repo name, the file body + // was replaced by a directory listing, and the breadcrumb presented the + // file as the current folder. Worse against the real API than in the + // fixture: `listRepoDir` normalises the contents endpoint's single-object + // answer with `Array.isArray(raw) ? raw : [raw]`, so the "folder" renders + // exactly one row — the file, listed inside itself. + void openRefMenu(refBtn, fullName, ref, (r) => goto({ ref: r, path, kind }), defaultBranchName), + ); + + // The page had no title at all — the only place the repo was named was 13px + // of breadcrumb in the toolbar. + const { view, main, rail } = detailPage({ + backLabel: "Explore", + crumb: fullName, + onBack, + actions: [refBtn, gotoBtn, ghBtn, openBtn], + }); + wrap.replaceChildren(view); + + // ── breadcrumbs: every segment is a routed nav, so ⌘[ walks the trail ── + if (path) { + const crumbs = el("div", "explore-crumbs"); + const rootBtn = el("button", "explore-crumb"); + rootBtn.textContent = fullName.split("/")[1] ?? fullName; + rootBtn.addEventListener("click", () => goto({ path: "", kind: "tree" })); + crumbs.appendChild(rootBtn); + const parts = path.split("/"); + parts.forEach((seg, i) => { + crumbs.appendChild(span("/", "explore-crumb-sep")); + const last = i === parts.length - 1; + if (last) { + crumbs.appendChild(span(seg, "explore-crumb is-current")); + return; + } + const b = el("button", "explore-crumb"); + b.textContent = seg; + const upto = parts.slice(0, i + 1).join("/"); + b.addEventListener("click", () => goto({ path: upto, kind: "tree" })); + crumbs.appendChild(b); + }); + main.appendChild(crumbs); + } + + // The page named itself only in 13px of toolbar breadcrumb. + const pageHead = el("div", "explore-repo-head"); + const h1 = el("h1", "explore-repo-title"); + const [ownerName, repoName] = fullName.split("/", 2); + // On a FILE page the biggest words on screen used to be the repository's — + // the same string the toolbar crumb above it already said — while the file + // you had opened appeared only in that crumb. A page is titled by its subject. + if (kind === "blob" && path) { + const fileName = path.split("/").pop() ?? path; + const dir = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : ""; + pageHead.appendChild( + span(dir ? `${fullName} / ${dir}` : fullName, "explore-repo-eyebrow"), + ); + h1.appendChild(span(fileName)); + } else { + h1.append(span(`${ownerName}/`, "explore-repo-owner"), span(repoName ?? fullName)); + } + pageHead.appendChild(h1); + main.appendChild(pageHead); + + const content = el("div", "explore-repo-content"); + content.appendChild(skeletonList(6)); + main.appendChild(content); + + // ── the rail: what this repository IS ── + void (async () => { + try { + const d: OrgRepoDetail = await gget("orgs:repoDetail", fullName, 120_000); + if (!rail.isConnected) return; + renderRepoRail(rail, d, fullName, nav); + // Name the branch the button is actually on. + if (d.defaultBranch) defaultBranchName = d.defaultBranch; + if (!ref && d.defaultBranch) { + defaultBranchLabel = d.defaultBranch; + const nameEl = refBtn.querySelector(".explore-ref-name"); + if (nameEl) nameEl.textContent = d.defaultBranch; + } + } catch { + /* the rail is context, never the point — silence beats a broken column */ + } + })(); + + // ── the body: a directory, or a file ── + try { + if (kind === "blob" && path) await renderFile(content, fullName, path, ref, goto); + else await renderDir(content, fullName, path, ref, goto); + } catch (e) { + if (!content.isConnected) return; + content.replaceChildren( + errorState( + "Couldn't read this repository", + browseHint(fullName, e), + () => renderRepoPage(wrap, nav, route, onBack), + ), + ); + } +} + +/** GitHub's 404 on an org repo usually means OAuth-app access is restricted — + * say that instead of a bare "Not Found", which sends people to the browser. */ +function browseHint(fullName: string, e: unknown): string { + const msg = cleanErr(e) || "GitHub request failed."; + if (/not found/i.test(msg)) { + const owner = fullName.split("/")[0]; + return `${msg}\n\nIf this repository is private or belongs to ${owner}, the organization may restrict OAuth app access — an owner can approve GitStudio in the org's settings.`; + } + return msg; +} + +// ── directory ──────────────────────────────────────────────────────────────── + +async function renderDir( + content: HTMLElement, + fullName: string, + path: string, + ref: string | undefined, + goto: (o: { path?: string; ref?: string; kind?: "tree" | "blob" }) => void, +): Promise<void> { + const entries = await gget("ghrepo:tree", { fullName, path, ref }, 60_000); + if (!content.isConnected) return; + content.replaceChildren(); + + const list = el("div", "explore-tree"); + for (const entry of entries) list.appendChild(entryRow(entry, goto)); + if (!entries.length) { + list.appendChild(emptyState("Empty folder", "Nothing here at this ref.", { icon: "folder" })); + } + content.appendChild(list); + + // The README belongs under the root listing, rendered with THE prose system. + if (!path) { + const readme = await host.invoke("ghrepo:readme", { fullName, ref }).catch(() => undefined); + if (!readme || !content.isConnected) return; + const card = el("div", "explore-readme"); + const head = el("div", "explore-readme-head"); + head.append(glyph("book"), span(readme.name)); + const prose = el("div", "gh-body-md"); + try { + prose.innerHTML = renderMarkdown(readme.text); + const [owner, repo] = fullName.split("/", 2); + // #123 and github.com links resolve against THIS repo; relative links + // navigate inside the page instead of leaving the app. + wireProseNav(prose, undefined, { owner, repo }, (rel) => { + const target = resolveRelative(path, rel); + goto({ path: target, kind: /\.[A-Za-z0-9]{1,8}$/.test(target) ? "blob" : "tree" }); + }); + } catch { + prose.textContent = readme.text; + } + card.append(head, prose); + content.appendChild(card); + } +} + +function entryRow( + entry: GhRepoEntry, + goto: (o: { path?: string; kind?: "tree" | "blob" }) => void, +): HTMLElement { + const row = el("button", "explore-tree-row"); + row.appendChild(glyph(fileIcon(entry.name, entry.type === "dir"))); + row.appendChild(span(entry.name, "explore-tree-name")); + row.appendChild(el("span", "explore-tree-spring")); + if (entry.type === "file" && entry.size) { + row.appendChild(span(formatBytes(entry.size), "explore-tree-size")); + } + row.addEventListener("click", () => + goto({ path: entry.path, kind: entry.type === "dir" ? "tree" : "blob" }), + ); + return row; +} + +// ── file ───────────────────────────────────────────────────────────────────── + +async function renderFile( + content: HTMLElement, + fullName: string, + path: string, + ref: string | undefined, + goto: (o: { path?: string; kind?: "tree" | "blob" }) => void, +): Promise<void> { + const file: GhRepoFile = await gget("ghrepo:file", { fullName, path, ref }, 60_000); + if (!content.isConnected) return; + content.replaceChildren(); + const name = path.split("/").pop() ?? path; + + if (file.binary || file.truncated) { + content.appendChild( + emptyState( + file.binary ? "Binary file" : "File too large to preview", + `${name}${file.size ? ` · ${formatBytes(file.size)}` : ""} — open it on GitHub, or clone the repository to read it here.`, + { icon: file.binary ? "file-binary" : "file" }, + ), + ); + return; + } + + // Markdown reads as prose (like the README); everything else as code. + if (/\.mdx?$/i.test(name)) { + const prose = el("div", "gh-body-md explore-file-prose"); + try { + prose.innerHTML = renderMarkdown(file.text); + const [owner, repo] = fullName.split("/", 2); + wireProseNav(prose, undefined, { owner, repo }, (rel) => { + // Against the file's FOLDER, not the file. `resolveRelative` takes a + // base DIRECTORY — which `path` is on the README path above, but here + // `path` is the file itself, so "./api.md" beside docs/guide.md + // resolved to docs/guide.md/api.md. Every relative link inside a + // markdown file pointed one level too deep. + const target = resolveRelative(path.split("/").slice(0, -1).join("/"), rel); + goto({ path: target, kind: /\.[A-Za-z0-9]{1,8}$/.test(target) ? "blob" : "tree" }); + }); + } catch { + prose.textContent = file.text; + } + content.appendChild(prose); + return; + } + + const lines = fileLines(file.text); + const box = el("div", "explore-file"); + const gutter = el("div", "explore-file-gutter"); + gutter.textContent = lines.map((_, i) => String(i + 1)).join("\n"); + const pre = el("pre", "explore-file-code"); + const code = el("code", ""); + code.textContent = file.text; + pre.appendChild(code); + box.append(gutter, pre); + content.appendChild(box); + // Same colorizer the Code view uses — one highlighting story in the app. + void highlightCode(code, file.text, name); +} + +// ── the rail ───────────────────────────────────────────────────────────────── + +function renderRepoRail( + rail: HTMLElement, + d: OrgRepoDetail, + fullName: string, + nav: SectionNav, +): void { + rail.replaceChildren(); + const about = propSection("About"); + if (d.description) { + const p = el("div", "det-prop-text"); + p.textContent = d.description; + about.body.appendChild(p); + } else { + about.body.appendChild(span("No description.", "det-prop-none")); + } + rail.appendChild(about.root); + + const stats = propSection("Stats"); + stats.body.classList.add("det-prop-facts"); + const fact = (k: string, v: string): void => { + const row = el("div", "det-fact"); + row.append(span(k, "det-fact-k"), span(v, "det-fact-v")); + stats.body.appendChild(row); + }; + if (d.language) fact("Language", d.language); + fact("Stars", d.stargazersCount.toLocaleString()); + fact("Forks", d.forksCount.toLocaleString()); + fact("Open issues", d.openIssuesCount.toLocaleString()); + if (d.license) fact("License", d.license); + if (d.defaultBranch) fact("Default branch", d.defaultBranch); + if (d.pushedAt) fact("Last push", relTimeISO(d.pushedAt)); + rail.appendChild(stats.root); + + if (d.topics.length) { + const topics = propSection("Topics"); + for (const t of d.topics.slice(0, 12)) topics.body.appendChild(span(t, "gh-pill explore-topic")); + rail.appendChild(topics.root); + } + if (d.fork || d.archived || d.private) { + const flags = propSection("Notes"); + if (d.private) flags.body.appendChild(span("private", "gh-pill")); + if (d.fork) flags.body.appendChild(span("fork", "gh-pill")); + if (d.archived) flags.body.appendChild(span("archived", "gh-pill")); + rail.appendChild(flags.root); + } + + const owner = propSection("Owner"); + const ownerLogin = fullName.split("/")[0]; + // It was a <button> with a pointer cursor and a tooltip promising to open the + // account, and clicking it did nothing at all. Either a control does its + // thing or it is not a control. + const ownerBtn = el("button", "det-person"); + ownerBtn.textContent = ownerLogin; + ownerBtn.title = `Open ${ownerLogin} in Explore`; + ownerBtn.setAttribute("aria-label", ownerBtn.title); + ownerBtn.addEventListener("click", () => nav("explore", { id: `user/${ownerLogin}` })); + owner.body.appendChild(ownerBtn); + rail.appendChild(owner.root); +} + +// ── ref switcher + go-to-file ──────────────────────────────────────────────── + +async function openRefMenu( + anchor: HTMLElement, + fullName: string, + current: string | undefined, + pick: (ref: string | undefined) => void, + defaultBranch?: string, +): Promise<void> { + let branches: GhRepoBranch[] = []; + try { + branches = await gget("ghrepo:branches", fullName, 120_000); + } catch (e) { + toast(cleanErr(e) || "Couldn't list branches.", "error"); + return; + } + // The default branch is one of these branches, not a separate thing. Listing + // it as its own row above the list meant `main` appeared twice — once ticked + // as "Default branch" and once, three rows down, under its own name and not + // ticked, which reads as two different branches with the same content. + const def = defaultBranch; + const selected = current ?? def; + openMenu( + anchor, + branches.map((b) => ({ + label: b.name, + sub: b.name === def ? "default" : undefined, + icon: selected === b.name ? "check" : "git-branch", + current: selected === b.name, + onClick: () => pick(b.name === def ? undefined : b.name), + })), + { searchable: branches.length > 8 }, + ); +} + +/** Go-to-file: the whole tree in one request, ranked by the palette's own + * fuzzy scorer so it feels identical to ⌘K. Honest when the tree is capped. */ +async function openGoToFile( + fullName: string, + ref: string | undefined, + pick: (path: string) => void, +): Promise<void> { + const { openModal } = await import("../dialogs"); + const card = el("div", "modal-card gotofile-card"); + let close = (): void => {}; + + const input = document.createElement("input"); + input.className = "modal-input gotofile-input"; + input.placeholder = "Go to file…"; + input.spellcheck = false; + input.setAttribute("aria-label", "Go to file"); + input.setAttribute("role", "combobox"); + input.setAttribute("aria-expanded", "true"); + input.setAttribute("aria-autocomplete", "list"); + const listEl = el("div", "gotofile-list"); + listEl.setAttribute("role", "listbox"); + listEl.id = "gs-gotofile-list"; + input.setAttribute("aria-controls", listEl.id); + const note = el("div", "gotofile-note"); + note.textContent = "Loading the file list…"; + card.append(input, listEl, note); + + openModal((c) => { + close = c; + return { card, focusEl: input, label: `Go to file in ${fullName}`, onClose: () => {} }; + }); + + let paths: string[] = []; + try { + const res = await gget("ghrepo:paths", { fullName, ref }, 300_000); + paths = res.paths; + note.textContent = res.truncated + ? `Searching ${paths.length.toLocaleString()} of ${res.total.toLocaleString()} files — this repository's tree is too large to index fully.` + : `${paths.length.toLocaleString()} files`; + } catch (e) { + note.textContent = cleanErr(e) || "Couldn't list this repository's files."; + return; + } + + // Which row Enter will open. Without one, ↑/↓ were dead keys and Enter fired + // the top row while nothing on screen said the top row was special — a picker + // with 40 identical options and an invisible cursor. + let sel = 0; + const rows = (): HTMLElement[] => [...listEl.querySelectorAll<HTMLElement>(".gotofile-row")]; + const paint = (): void => { + const rs = rows(); + if (!rs.length) return; + sel = Math.max(0, Math.min(rs.length - 1, sel)); + rs.forEach((r, i) => { + r.classList.toggle("is-sel", i === sel); + r.setAttribute("aria-selected", String(i === sel)); + }); + rs[sel].scrollIntoView({ block: "nearest" }); + if (!rs[sel].id) rs[sel].id = `gs-gotofile-${sel}`; + input.setAttribute("aria-activedescendant", rs[sel].id); + }; + + const render = (): void => { + const q = input.value.trim(); + const ranked = q + ? paths + .map((p) => ({ p, s: fuzzyScore(q, p) })) + .filter((x) => x.s > 0) + .sort((a, b) => b.s - a.s) + .slice(0, 40) + .map((x) => x.p) + : paths.slice(0, 40); + listEl.replaceChildren(); + sel = 0; + for (const p of ranked) { + const row = el("button", "gotofile-row"); + row.setAttribute("role", "option"); + row.appendChild(glyph(fileIcon(p.split("/").pop() ?? p))); + const dir = p.includes("/") ? p.slice(0, p.lastIndexOf("/") + 1) : ""; + if (dir) row.appendChild(span(dir, "gotofile-dir")); + row.appendChild(span(p.slice(dir.length), "gotofile-name")); + row.addEventListener("click", () => { + close(); + pick(p); + }); + listEl.appendChild(row); + } + if (!ranked.length) { + listEl.appendChild(span("No file matches that.", "gotofile-empty")); + input.removeAttribute("aria-activedescendant"); + return; + } + paint(); + }; + input.addEventListener("input", render); + input.addEventListener("keydown", (e) => { + const rs = rows(); + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + if (!rs.length) return; + sel = (sel + (e.key === "ArrowDown" ? 1 : -1) + rs.length) % rs.length; + paint(); + return; + } + if (e.key === "Home" || e.key === "End") { + e.preventDefault(); + sel = e.key === "Home" ? 0 : rs.length - 1; + paint(); + return; + } + if (e.key === "Enter") { + e.preventDefault(); + rs[sel]?.click(); + } + }); + render(); +} diff --git a/apps/desktop/src/renderer/views/exploreUser.ts b/apps/desktop/src/renderer/views/exploreUser.ts new file mode 100644 index 0000000..3c3306e --- /dev/null +++ b/apps/desktop/src/renderer/views/exploreUser.ts @@ -0,0 +1,272 @@ +// An account page in Explore — a person or an organization, as a full page. +// +// GitHub's own profile answers "who is this and what do they work on?"; the +// peek card only ever answered the first half. This page carries the profile +// rail (bio, company, location, links, counts) beside the thing you actually +// came for: their repositories, each one openable HERE — plus the orgs they +// belong to, which is how you discover the next place to look. +// +// Routed as `user/<login>` or `org/<login>`. Both render the same page: GitHub +// returns the same profile shape for either, and `type` tells us which words +// to use. + +import { gget } from "../cache"; +import { + avatar, + cleanErr, + el, + emptyState, + errorState, + glyph, + relTimeISO, + skeletonList, + span, +} from "../ui"; +import { openGhRepoInApp, openGhRepoChooseLocation } from "../ghOpen"; +import { repoRouteId } from "../exploreRoutes"; +import { detailPage, propSection, searchField, type SectionNav } from "./common"; +import type { GhUserInfo, OrgInfo, OrgRepo } from "../../shared/ipc"; + +export { parseAccountTarget } from "../exploreRoutes"; + +export function renderAccountPage( + wrap: HTMLElement, + nav: SectionNav, + login: string, + onBack: () => void, +): void { + void mount(wrap, nav, login, onBack); +} + +async function mount( + wrap: HTMLElement, + nav: SectionNav, + login: string, + onBack: () => void, +): Promise<void> { + const ghBtn = el("button", "mini-btn gh-icon-btn"); + ghBtn.appendChild(glyph("link-external")); + ghBtn.title = `Open @${login} on GitHub`; + ghBtn.setAttribute("aria-label", ghBtn.title); + ghBtn.addEventListener("click", () => + window.open(`https://github.com/${login}`, "_blank", "noopener"), + ); + + const { view, main, rail } = detailPage({ + backLabel: "Explore", + crumb: `@${login}`, + onBack, + actions: [ghBtn], + }); + wrap.replaceChildren(view); + + const content = el("div", "explore-repo-content"); + content.appendChild(skeletonList(6)); + main.appendChild(content); + + // ── the profile rail ── + void (async () => { + try { + const u: GhUserInfo = await gget("github:userInfo", login, 300_000); + if (!rail.isConnected) return; + renderProfileRail(rail, u, nav); + const head = el("div", "explore-account-head"); + head.append(avatar(u.login, u.avatarUrl, 44)); + const names = el("div", "explore-account-names"); + const title = el("h1", "explore-account-title"); + title.textContent = u.name || u.login; + names.appendChild(title); + if (u.name) names.appendChild(span(`@${u.login}`, "explore-account-login")); + names.appendChild( + span(u.type === "Organization" ? "Organization" : "Person", "gh-pill explore-pill"), + ); + head.appendChild(names); + main.insertBefore(head, content); + if (u.bio) { + const bio = el("p", "explore-account-bio"); + bio.textContent = u.bio; + main.insertBefore(bio, content); + } + } catch { + /* the profile is context; the repo list below is the point */ + } + })(); + + // ── their repositories ── + try { + const repos: OrgRepo[] = await gget("users:repos", login, 120_000); + if (!content.isConnected) return; + content.replaceChildren(); + if (!repos.length) { + content.appendChild( + emptyState("No public repositories", `@${login} hasn't published any.`, { icon: "repo" }), + ); + return; + } + + let filter = ""; + const list = el("div", "explore-tree"); + const paint = (): void => { + const q = filter.toLowerCase(); + const shown = q + ? repos.filter((r) => `${r.name} ${r.description ?? ""}`.toLowerCase().includes(q)) + : repos; + list.replaceChildren(); + if (!shown.length) { + list.appendChild(span("No repository matches that.", "gotofile-empty")); + return; + } + for (const r of shown) list.appendChild(repoRow(r, nav)); + }; + const head = el("div", "explore-account-repos-head"); + head.append(span(`${repos.length} repositories`, "explore-account-count")); + head.appendChild( + searchField({ + placeholder: "Filter repositories…", + onInput: (q) => { + filter = q; + paint(); + }, + }), + ); + content.append(head, list); + paint(); + } catch (e) { + if (!content.isConnected) return; + content.replaceChildren( + errorState( + "Couldn't load repositories", + cleanErr(e) || "GitHub request failed.", + () => renderAccountPage(wrap, nav, login, onBack), + ), + ); + } +} + +function repoRow(r: OrgRepo, nav: SectionNav): HTMLElement { + const row = el("div", "explore-tree-row explore-account-repo is-clickable"); + // The whole row opens the repository. The clickable area used to be a button + // wrapping only the icon and the name, so the right half of every row — the + // language, the stars, the time, and the gap between them — was dead space + // that looked exactly as clickable as the half that worked. + const open = el("div", "explore-account-repo-main"); + open.appendChild(glyph(r.private ? "lock" : r.fork ? "repo-forked" : "repo")); + const body = el("div", "explore-row-body"); + const head = el("div", "explore-row-head"); + head.appendChild(span(r.name, "sec-row-title")); + if (r.archived) head.appendChild(span("archived", "gh-pill explore-pill")); + body.appendChild(head); + if (r.description) { + const d = el("div", "explore-desc"); + d.textContent = r.description; + body.appendChild(d); + } + open.appendChild(body); + row.setAttribute("role", "button"); + row.tabIndex = 0; + row.setAttribute("aria-label", `Open ${r.fullName}`); + row.title = `Open ${r.fullName}`; + const go = (): void => nav("explore", { id: repoRouteId({ fullName: r.fullName }) }); + row.addEventListener("click", go); + row.addEventListener("keydown", (e) => { + if (e.key !== "Enter" && e.key !== " ") return; + if (e.target !== row) return; + e.preventDefault(); + go(); + }); + row.appendChild(open); + + const meta = el("span", "sec-row-meta"); + if (r.language) meta.appendChild(span(r.language, "explore-lang")); + if (r.stargazersCount > 0) { + const s = span("", "explore-stat"); + s.append(glyph("star-full"), span(r.stargazersCount.toLocaleString())); + meta.appendChild(s); + } + if (r.pushedAt) meta.appendChild(span(relTimeISO(r.pushedAt), "sec-row-time")); + row.appendChild(meta); + + const acts = el("div", "row-actions"); + const mk = (label: string, title: string, run: () => void): HTMLElement => { + const b = el("button", "row-btn"); + b.textContent = label; + b.title = title; + b.addEventListener("click", (e) => { + e.stopPropagation(); + run(); + }); + return b; + }; + acts.append( + mk("Open", `Clone ${r.fullName} if needed, then open it`, () => openGhRepoInApp(r.fullName)), + mk("Choose location…", "Pick the folder it's cloned into", () => + openGhRepoChooseLocation(r.fullName), + ), + ); + row.appendChild(acts); + return row; +} + +function renderProfileRail(rail: HTMLElement, u: GhUserInfo, nav: SectionNav): void { + rail.replaceChildren(); + + const facts = propSection("Profile"); + facts.body.classList.add("det-prop-facts"); + const fact = (k: string, v: string): void => { + const row = el("div", "det-fact"); + row.append(span(k, "det-fact-k"), span(v, "det-fact-v")); + facts.body.appendChild(row); + }; + if (u.company) fact("Company", u.company); + if (u.location) fact("Location", u.location); + fact("Repositories", u.publicRepos.toLocaleString()); + fact("Followers", u.followers.toLocaleString()); + if (u.type !== "Organization") fact("Following", u.following.toLocaleString()); + if (u.createdAt) fact("Joined", relTimeISO(u.createdAt)); + rail.appendChild(facts.root); + + const links: Array<[string, string, string]> = []; + if (u.blog) links.push(["link", u.blog, u.blog.startsWith("http") ? u.blog : `https://${u.blog}`]); + if (u.twitter) links.push(["twitter", `@${u.twitter}`, `https://twitter.com/${u.twitter}`]); + if (u.email) links.push(["mail", u.email, `mailto:${u.email}`]); + if (links.length) { + const linkProp = propSection("Links"); + for (const [icon, label, href] of links) { + const b = el("button", "gh-link explore-account-link"); + b.append(glyph(icon), span(label)); + b.title = href; + b.addEventListener("click", () => window.open(href, "_blank", "noopener")); + linkProp.body.appendChild(b); + } + rail.appendChild(linkProp.root); + } + + // Orgs load separately — a slow membership call must not hold the profile. + if (u.type !== "Organization") { + const orgProp = propSection("Organizations"); + orgProp.body.appendChild(span("…", "det-prop-none")); + rail.appendChild(orgProp.root); + void gget("users:orgs", u.login, 300_000) + .then((orgs: OrgInfo[]) => { + if (!orgProp.body.isConnected) return; + orgProp.body.replaceChildren(); + if (!orgs.length) { + orgProp.body.appendChild(span("None public.", "det-prop-none")); + return; + } + for (const o of orgs) { + // Same dead chip as the repo page's OWNER: it looked and read like a + // door and opened onto nothing. + const chip = el("button", "det-person"); + chip.append(avatar(o.login, o.avatarUrl, 20), span(o.login)); + chip.title = `Explore ${o.login}`; + chip.setAttribute("aria-label", chip.title); + chip.addEventListener("click", () => nav("explore", { id: `org/${o.login}` })); + orgProp.body.appendChild(chip); + } + }) + .catch(() => { + if (orgProp.body.isConnected) orgProp.body.replaceChildren(span("Unavailable.", "det-prop-none")); + }); + } +} diff --git a/apps/desktop/src/renderer/views/gists.ts b/apps/desktop/src/renderer/views/gists.ts index 9f1f979..c6ac9ac 100644 --- a/apps/desktop/src/renderer/views/gists.ts +++ b/apps/desktop/src/renderer/views/gists.ts @@ -1,24 +1,22 @@ -// Gists — the user-scoped GitHub section. Mirrors the PR/Issue two-pane look -// (gh-view → header + gh-body → gh-list + gh-detail) with a read-only content -// viewer and full CRUD: New gist, Edit, Delete, plus Copy raw URL / Open on -// GitHub. Gists aren't repo-scoped, so this view gates only on the GitHub -// connection (NEEDS_REPO=false) and is reachable from any repo. -// -// State (selected gist + selected file index) lives in module scope so a refresh -// re-selects what the user was looking at; it's reset when the gist list reloads -// from a mutation. Every async boundary clears its pane on refresh by rebuilding, -// so there's no stale-paint risk. - +// Gists — the user-scoped GitHub section, on the section-page system +// (docs/desktop-redesign.md): a full-width list whose rows navigate to a +// full-page detail (routed via `target.id` — gists are string-keyed), with the +// gist's files as tabs of SYNTAX-HIGHLIGHTED content (same .ghfile renderer as +// the remote repo browser) and its facts in the rail. Full CRUD: New gist, +// Edit, Delete, Copy raw URL. Gists aren't repo-scoped, so the view gates only +// on the GitHub connection (NEEDS_REPO=false). + +import { fileLines } from "../textFit"; import { host } from "../bridge"; +import { peek as cachePeek, gget, bust } from "../cache"; import { cleanErr, copyText, el, emptyState, errorState, - ghRow, glyph, - loadingState, + openMenu, relTimeISO, absTimeISO, skeletonList, @@ -26,297 +24,364 @@ import { statBit, statePill, } from "../ui"; -import { confirmDialog, toast } from "../dialogs"; -import { ghGate, ghHeader, ghListResizer, searchField, type SectionRender } from "./common"; +import { confirmDialog, openModal, toast, formWithRetry } from "../dialogs"; +import { highlightCode } from "../highlight"; +import { + detailPage, + blankable, + ghGate, + ghHeader, + personChip, + propSection, + searchField, + secRow, + sectionList, + type GhGate, + type SectionNav, + type SectionRender, + type SectionTarget, + subTabs, +} from "./common"; import type { GistInfo } from "../../shared/ipc"; -// Remember the open gist + selected file across refreshes so the view feels -// stateful. Reset on a fresh list load triggered by a mutation. -let openGistId: string | null = null; -let openFileIdx = 0; +/** The selected file tab inside a gist detail, per gist id — so re-renders + * (after an edit) restore the file the user was reading. */ +const fileTabByGist = new Map<string, number>(); +/** The list page's live search query — survives list ⇄ detail round trips. */ +let query = ""; -export const renderGists: SectionRender = (wrap, nav) => { - void mount(wrap, nav); +export const renderGists: SectionRender = (wrap, nav, target) => { + void mount(wrap, nav, target); }; -async function mount(wrap: HTMLElement, nav: (view: string) => void): Promise<void> { - const refresh = (): void => renderGists(wrap, nav); - - wrap.replaceChildren(loadingState("Loading gists…")); - const gate = await ghGate(wrap, nav, false); - if (!gate) return; - - // Shell: header (with a "New gist" action) + two-pane body. - const view = el("div", "gh-view"); - const header = ghHeader("Gists", gate.login, refresh); - const newBtn = el("button", "mini-btn"); - newBtn.append(glyph("add"), span("New gist")); - newBtn.addEventListener("click", () => void newGist(refresh)); - // Slot the New-gist action into the header's right-side cluster (the .gh-acct - // group), just left of the refresh button, so it reads as a header action. - const acct = header.querySelector(".gh-acct"); - if (acct) acct.insertBefore(newBtn, acct.firstChild); - else header.appendChild(newBtn); - view.appendChild(header); - - const body = el("div", "gh-body"); - const listEl = el("div", "gh-list"); - const detail = el("div", "gh-detail"); - body.append(listEl, ghListResizer(listEl), detail); - view.appendChild(body); - wrap.replaceChildren(view); - - const idleEmpty = (): void => { - detail.replaceChildren( - emptyState("Gists", "Select a gist to read its files and content.", { - icon: "code", - hint: "Tip: open one to edit, copy its raw URL, or delete it.", - }), - ); +async function mount(wrap: HTMLElement, nav: SectionNav, target?: SectionTarget): Promise<void> { + const refresh = (): void => { + bust("gist"); + renderGists(wrap, nav, target); }; - idleEmpty(); - listEl.replaceChildren(skeletonList(5)); - - let gists: GistInfo[]; - try { - gists = await host.invoke("gist:list", undefined); - } catch (e) { - listEl.replaceChildren( - errorState("Couldn't load gists", cleanErr(e) || "GitHub request failed.", refresh), - ); - return; - } + const gate = await ghGate(wrap, nav, false, refresh); + if (!gate) return; - header.setCount?.(gists.length); - listEl.replaceChildren(); - if (gists.length === 0) { - openGistId = null; - listEl.appendChild( - emptyState("No gists yet", "Create your first snippet with a new gist.", { - icon: "code", - action: { label: "New gist", icon: "add", onClick: () => void newGist(refresh) }, - }), - ); + if (target?.id) { + showGistDetailPage(wrap, nav, target.id); return; } + await listPage(wrap, nav, gate); +} - // If the previously open gist is gone (deleted elsewhere), drop the selection. - if (openGistId && !gists.some((g) => g.id === openGistId)) { - openGistId = null; - idleEmpty(); - } +// ── The list page ──────────────────────────────────────────────────────────── - const rows = new Map<string, HTMLElement>(); - const selectRow = (id: string): void => { - for (const [rid, r] of rows) r.classList.toggle("active", rid === id); +async function listPage(wrap: HTMLElement, nav: SectionNav, gate: GhGate): Promise<void> { + const refresh = (): void => { + bust("gist"); + renderGists(wrap, nav); }; + const { view, listEl } = sectionList(); + const header = ghHeader("Gists", gate.login, refresh); + const tools = el("div", "gh-head-tools"); + const newBtn = el("button", "btn btn-primary gh-new-btn"); + newBtn.append(glyph("add"), span("New gist")); + newBtn.addEventListener("click", () => void newGist(nav, refresh)); + tools.append(newBtn); + header.querySelector(".gh-acct")?.before(tools); + view.append(header, listEl); + wrap.replaceChildren(view); + + header.querySelector(".gh-head-titlewrap")?.appendChild( + searchField({ + placeholder: "Search gists…", + initial: query, + onInput: (q) => { + query = q; + renderList(); + }, + }), + ); + + let gists: GistInfo[] | undefined = cachePeek("gist:list", undefined); + if (!gists) listEl.replaceChildren(skeletonList(5)); + const buildRow = (g: GistInfo): HTMLElement => { // A gist has no real title — use the description or the first filename. const title = g.description || g.files[0]?.filename || "Untitled gist"; - const rel = relTimeISO(g.updatedAt); - const stats: HTMLElement[] = []; - if (typeof g.comments === "number" && g.comments > 0) stats.push(statBit("comment", g.comments)); - - // An accent-tinted code glyph as the leading icon (gists have no state). - const lead = span("", "gh-lead-icon"); - lead.style.color = "var(--gs-accent-ink, var(--gs-accent))"; + // The comment count keeps its column even at zero: dropping the element + // slid "1 file" 69px between a gist with comments and one without, so the + // list had no meta columns at all. + const meta: HTMLElement[] = [ + span(`${g.fileCount} file${g.fileCount === 1 ? "" : "s"}`, "gist-filecount"), + blankable(statBit("comment", g.comments ?? 0), (g.comments ?? 0) > 0), + ]; + const lead = el("span", "gh-lead-icon is-accent"); lead.appendChild(glyph("code")); - - const row = ghRow({ + const row = secRow({ lead, title, titleSuffix: [statePill(g.public ? "Public" : "Secret", g.public ? "public" : "private")], - meta: - `${g.fileCount} file${g.fileCount === 1 ? "" : "s"}` + - (rel ? ` · updated ${rel}` : ""), - metaTitle: g.updatedAt ? `Updated ${absTimeISO(g.updatedAt)}` : undefined, - stats, + meta, + time: relTimeISO(g.updatedAt), + timeTitle: g.updatedAt ? `Updated ${absTimeISO(g.updatedAt)}` : undefined, ariaLabel: `Gist: ${title}`, + onOpen: () => nav("gists", { id: g.id }), }); - rows.set(g.id, row); - - row.addEventListener("click", () => { - selectRow(g.id); - if (openGistId !== g.id) openFileIdx = 0; - openGistId = g.id; - void showDetail(detail, g, refresh); - }); + row.dataset.num = g.id; return row; }; - // Case-insensitive match over the fields a user would search by: the gist - // description and every filename it contains. - const matches = (g: GistInfo, q: string): boolean => { - const hay = `${g.description} ${g.files.map((f) => f.filename).join(" ")}`.toLowerCase(); - return hay.includes(q); - }; + const matches = (g: GistInfo, q: string): boolean => + `${g.description} ${g.files.map((f) => f.filename).join(" ")}`.toLowerCase().includes(q); - let autoSelected = false; - const renderList = (items: GistInfo[], q = ""): void => { - rows.clear(); + const renderList = (): void => { + if (!gists) return; listEl.replaceChildren(); - if (items.length === 0) { + if (gists.length === 0) { + header.setCount?.(0); listEl.appendChild( - emptyState("No matching gists", `Nothing matches “${q}”.`, { icon: "search" }), + emptyState("No gists yet", "Create your first snippet with a new gist.", { + icon: "code", + action: { label: "New gist", icon: "add", onClick: () => void newGist(nav, refresh) }, + }), ); return; } - for (const g of items) listEl.appendChild(buildRow(g)); - // On the initial render only, re-open the previously selected gist (e.g. - // after an edit), otherwise auto-select the first gist so the detail pane - // isn't a void. Filtering keystrokes never hijack the current selection. - if (!autoSelected) { - autoSelected = true; - const reopen = openGistId ? items.find((g) => g.id === openGistId) : items[0]; - if (reopen) { - if (openGistId !== reopen.id) openFileIdx = 0; - openGistId = reopen.id; - selectRow(reopen.id); - void showDetail(detail, reopen, refresh); - } - } else { - // Keep the active highlight in sync with the current selection. - if (openGistId) selectRow(openGistId); + const q = query.toLowerCase(); + const items = q ? gists.filter((g) => matches(g, q)) : gists; + // Same contract as the other lists: the badge counts what is on screen, so + // it can't read "2" above "No matching gists". + header.setCount?.(items.length, gists.length); + if (items.length === 0) { + listEl.appendChild(emptyState("No matching gists", `Nothing matches “${query}”.`, { icon: "search", anchor: "inline" })); + return; } + for (const g of items) listEl.appendChild(buildRow(g)); }; - // A header search/filter — on the LEFT, next to the title (client-side, instant). - header.querySelector(".gh-head-titlewrap")?.appendChild( - searchField({ - placeholder: "Search gists…", - onInput: (q) => renderList(q ? gists.filter((g) => matches(g, q.toLowerCase())) : gists, q), - }), - ); + if (gists) renderList(); - renderList(gists); + try { + const fresh = await gget("gist:list", undefined, 30000); + if (!view.isConnected) return; + gists = fresh; + renderList(); + } catch (e) { + if (!view.isConnected) return; + if (!gists) { + listEl.replaceChildren( + errorState("Couldn't load gists", cleanErr(e) || "GitHub request failed.", refresh), + ); + } + } } -// ── Detail pane ────────────────────────────────────────────────────────────── +// ── The detail page ────────────────────────────────────────────────────────── -async function showDetail( - detail: HTMLElement, - summary: GistInfo, - refresh: () => void, -): Promise<void> { - detail.replaceChildren(loadingState("Loading gist…")); +function showGistDetailPage(wrap: HTMLElement, nav: SectionNav, id: string): void { + const back = (): void => nav("gists", { list: true }); + const reload = (): void => { + bust("gist"); + showGistDetailPage(wrap, nav, id); + }; - let g: GistInfo; - try { - // The list payload omits file CONTENT — fetch the full gist for the body. - const full = await host.invoke("gist:detail", summary.id); - if (!full) { - detail.replaceChildren(emptyState("Not connected", "Sign in to view this gist.")); + const { view, main, rail, topActions } = detailPage({ + backLabel: "Gists", + pageLabel: "Gist", + onBack: back, + }); + main.appendChild(skeletonList(4, false)); + wrap.replaceChildren(view); + + void (async () => { + let g: GistInfo | undefined; + try { + // The list payload omits file CONTENT — the detail fetch carries it. + g = await gget("gist:detail", id, 15000); + } catch (e) { + if (!view.isConnected) return; + main.replaceChildren( + errorState("Couldn't load gist", cleanErr(e) || "GitHub request failed.", reload), + ); return; } - g = full; - } catch (e) { - detail.replaceChildren( - errorState("Couldn't load gist", cleanErr(e) || "GitHub request failed.", () => - void showDetail(detail, summary, refresh), - ), - ); - return; - } - - // Guard: a different gist may have been selected while this one was loading. - if (openGistId !== g.id) return; - - detail.replaceChildren(); + if (!view.isConnected) return; + if (!g) { + main.replaceChildren(emptyState("Gist unavailable", "This gist couldn't be loaded.")); + return; + } + buildGistDetail({ main, rail, topActions, g, reload, back }); + })(); +} - const head = el("div", "gh-detail-head"); - const title = el("div", "gh-detail-title"); - title.textContent = g.description || g.files[0]?.filename || "(no description)"; +interface GistDetailCtx { + main: HTMLElement; + rail: HTMLElement; + topActions: HTMLElement; + g: GistInfo; + reload: () => void; + back: () => void; +} - const meta = el("div", "gh-detail-meta"); - const rel = relTimeISO(g.updatedAt); - const owner = g.owner?.login ?? ""; - meta.textContent = - `${g.public ? "public" : "secret"} · ${g.fileCount} file${g.fileCount === 1 ? "" : "s"}` + - (owner ? ` · ${owner}` : "") + - (rel ? ` · updated ${rel}` : ""); +function buildGistDetail(ctx: GistDetailCtx): void { + const { main, rail, topActions, g, reload, back } = ctx; + main.replaceChildren(); + rail.replaceChildren(); - const actions = el("div", "gh-detail-actions"); + const fileIdx = (): number => { + const saved = fileTabByGist.get(g.id) ?? 0; + return g.files.length ? Math.min(Math.max(0, saved), g.files.length - 1) : 0; + }; + // ── top-bar actions ── const editBtn = el("button", "mini-btn"); editBtn.append(glyph("edit"), span("Edit")); - editBtn.addEventListener("click", () => void editGist(g, refresh)); + editBtn.addEventListener("click", () => void editGist(g, fileIdx(), reload)); + /** Match the affordance to what Edit will actually do — a live button that + * refuses on click is a worse answer than one that says why up front. + * Re-run whenever the selected file changes. */ + const syncEditBtn = (): void => { + const f = g.files[fileIdx()]; + const blocked = !!f?.truncated; + (editBtn as HTMLButtonElement).disabled = blocked; + editBtn.title = blocked + ? `GitStudio only received part of ${f?.filename ?? "this file"} — edit it on GitHub` + : "Edit this gist"; + }; + syncEditBtn(); const copyBtn = el("button", "mini-btn"); copyBtn.append(glyph("copy"), span("Copy raw URL")); copyBtn.title = "Copy the raw URL of the selected file"; copyBtn.addEventListener("click", () => { - const url = g.files[clampIdx(g)]?.rawUrl; + const url = g.files[fileIdx()]?.rawUrl; if (url) void copyText(url, "Raw URL copied."); else toast("This file has no raw URL.", "error"); }); - const openBtn = el("button", "mini-btn"); - openBtn.append(glyph("link-external"), span("Open on GitHub")); + const moreBtn = el("button", "mini-btn gh-icon-btn"); + moreBtn.append(glyph("ellipsis")); + moreBtn.title = "More actions"; + moreBtn.addEventListener("click", () => + openMenu(moreBtn, [ + { label: "Copy link", icon: "copy", onClick: () => void copyText(g.htmlUrl, "Copied gist link.") }, + { separator: true }, + { label: "Delete gist", icon: "trash", onClick: () => void deleteGist(g, moreBtn, back) }, + ]), + ); + + const openBtn = el("button", "mini-btn gh-icon-btn"); + openBtn.append(glyph("link-external")); + openBtn.title = "Open this gist on GitHub"; + openBtn.setAttribute("aria-label", openBtn.title); openBtn.addEventListener("click", () => window.open(g.htmlUrl, "_blank", "noopener")); - const delBtn = el("button", "mini-btn danger"); - delBtn.append(glyph("trash"), span("Delete")); - delBtn.addEventListener("click", () => void deleteGist(g, delBtn, refresh)); + topActions.replaceChildren(editBtn, copyBtn, moreBtn, openBtn); - actions.append(editBtn, copyBtn, openBtn, delBtn); - head.append(title, meta, actions); - detail.appendChild(head); + // ── title block ── + const titleRow = el("div", "det-title-row"); + const h = el("h1", "det-title"); + h.textContent = g.description || g.files[0]?.filename || "(no description)"; + titleRow.appendChild(h); + // After the title, not before it: a leading pill pushed the H1 ~110px right + // of the page's left rule, so the heading no longer started where every + // other heading starts. + titleRow.appendChild(statePill(g.public ? "Public" : "Secret", g.public ? "public" : "private")); + main.appendChild(titleRow); + + // No sub-line: it read "updated 2d ago" directly above an About rail whose + // Updated row says "2d ago". The rail is where a detail page's facts live — + // repeating one of them under the title is the same fact twice. if (g.files.length === 0) { - detail.appendChild(emptyState("Empty gist", "This gist has no files.")); - return; + main.appendChild(emptyState("Empty gist", "This gist has no files.")); + } else { + // ── file tabs + highlighted content ── + const content = el("div", "gh-subcontent"); + let selectTab: ((i: number) => void) | undefined; + + const renderFile = (idx: number): void => { + fileTabByGist.set(g.id, idx); + const f = g.files[idx]; + if (!f) return; + // Switching tabs changes which file Edit would save, so re-ask. + syncEditBtn(); + content.replaceChildren(); + + const fileHead = el("div", "gist-file-head"); + const name = span(f.filename, "gist-file-name"); + const fileSub = span( + `${f.language || f.type || "text"} · ${formatBytes(f.size)}` + + (f.truncated ? " · truncated" : ""), + "gist-file-sub", + ); + fileHead.append(name, fileSub); + content.appendChild(fileHead); + content.appendChild(codeBlock(f.content, f.filename, f.truncated)); + }; + + if (g.files.length > 1) { + content.id = "gs-gist-filepanel"; + const tabs = subTabs({ + tabs: g.files.map((f, i) => ({ id: String(i), label: f.filename, icon: "file" })), + ariaLabel: "Files in this gist", + panel: content, + onSelect: (id) => renderFile(Number(id)), + }); + main.appendChild(tabs.el); + selectTab = (i: number) => tabs.select(String(i)); + } + main.appendChild(content); + if (selectTab) selectTab(fileIdx()); + else renderFile(fileIdx()); } - // File tabs (when >1 file) + the selected file's content in a read-only <pre>. - const tabBar = el("div", "gh-subtabs"); - const content = el("div", "gh-subcontent"); - const tabBtns: HTMLElement[] = []; - - const renderFile = (idx: number): void => { - openFileIdx = idx; - for (const b of tabBtns) b.classList.toggle("active", Number(b.dataset.fileIdx) === idx); - const f = g.files[idx]; - if (!f) return; - content.replaceChildren(); - - const fileHead = el("div", "gist-file-head"); - const name = span(f.filename, "gist-file-name"); - const fileSub = span( - `${f.language || f.type || "text"} · ${formatBytes(f.size)}` + - (f.truncated ? " · truncated" : ""), - "gist-file-sub", - ); - fileHead.append(name, fileSub); - content.appendChild(fileHead); - - const pre = el("pre", "gist-content"); - const code = el("code"); - code.textContent = f.truncated - ? `${f.content}\n\n… (truncated — open on GitHub for the full file)` - : f.content; - pre.appendChild(code); - content.appendChild(pre); + // ── rail ── + const ownerProp = propSection("Owner"); + if (g.owner?.login) ownerProp.body.appendChild(personChip(g.owner.login, g.owner.avatarUrl)); + else ownerProp.body.appendChild(span("—", "det-prop-none")); + + const about = propSection("About"); + about.body.classList.add("det-prop-facts"); + const fact = (k: string, v: string, title?: string): HTMLElement => { + const row = el("div", "det-fact"); + const val = el("span", "det-fact-v"); + val.textContent = v; + if (title) val.title = title; + row.append(span(k, "det-fact-k"), val); + return row; }; + about.body.appendChild(fact("Files", String(g.fileCount))); + if (typeof g.comments === "number") about.body.appendChild(fact("Comments", String(g.comments))); + about.body.appendChild(fact("Created", relTimeISO(g.createdAt), absTimeISO(g.createdAt))); + about.body.appendChild(fact("Updated", relTimeISO(g.updatedAt), absTimeISO(g.updatedAt))); - if (g.files.length > 1) { - g.files.forEach((f, i) => { - const b = el("button", "gh-subtab"); - b.dataset.fileIdx = String(i); - b.append(glyph("file"), span(f.filename)); - b.addEventListener("click", () => renderFile(i)); - tabBtns.push(b); - tabBar.appendChild(b); - }); - detail.appendChild(tabBar); - } - detail.appendChild(content); - renderFile(clampIdx(g)); + rail.append(ownerProp.root, about.root); } -function clampIdx(g: GistInfo): number { - if (g.files.length === 0) return 0; - return Math.min(Math.max(0, openFileIdx), g.files.length - 1); +/** Render gist code with a line-number gutter + syntax highlighting — the same + * .ghfile renderer the remote repo browser uses (the old view was a plain + * un-highlighted <pre>). Capped so a giant file can't lock the UI. */ +const MAX_RENDER_LINES = 5000; +function codeBlock(text: string, fileName: string, truncated: boolean): HTMLElement { + const lines = fileLines(text); + const shown = lines.slice(0, MAX_RENDER_LINES); + const wrap = el("div", "ghfile"); + const gutter = el("pre", "ghfile-gutter"); + gutter.textContent = shown.map((_, i) => String(i + 1)).join("\n"); + gutter.setAttribute("aria-hidden", "true"); + const code = el("pre", "ghfile-code"); + code.textContent = shown.join("\n"); + void highlightCode(code, shown.join("\n"), fileName); + wrap.append(gutter, code); + const capped = lines.length > shown.length; + if (capped || truncated) { + const more = el("div", "ghfile-more"); + more.textContent = truncated + ? "GitHub truncated this file — open it on GitHub for the full content." + : `Showing the first ${MAX_RENDER_LINES.toLocaleString()} of ${lines.length.toLocaleString()} lines.`; + const outer = el("div", "ghfile-outer"); + outer.append(wrap, more); + return outer; + } + return wrap; } function formatBytes(n: number): string { @@ -328,68 +393,90 @@ function formatBytes(n: number): string { // ── Mutations ──────────────────────────────────────────────────────────────── -async function newGist(refresh: () => void): Promise<void> { - const v = await gistDialog({ title: "New gist", okLabel: "Create gist" }); - if (!v) return; - try { - const r = await host.invoke("gist:create", { - description: v.description, - filename: v.filename, - content: v.content, - public: v.public, - }); - if (!r.ok) { - toast(r.message || "Couldn't create the gist.", "error"); - return; - } - // The created id comes back in `message` — select it on the next render. - openGistId = r.message ?? null; - openFileIdx = 0; - toast("Gist created.", "success"); - refresh(); - } catch (e) { - toast(cleanErr(e) || "Couldn't create the gist.", "error"); - } +async function newGist(nav: SectionNav, refresh: () => void): Promise<void> { + // The form closed before the request was even sent, so a rejected create + // answered a whole file of typing with a toast over an empty screen. + await formWithRetry<GistDialogResult>( + (seed, error) => + gistDialog({ + title: "New gist", + okLabel: "Create gist", + description: seed?.description, + filename: seed?.filename, + content: seed?.content, + public: seed?.public, + error, + }), + async (v) => { + try { + const r = await host.invoke("gist:create", { + description: v.description, + filename: v.filename, + content: v.content, + public: v.public, + }); + if (!r.ok) return r.message || "Couldn't create the gist."; + toast("Gist created.", "success"); + bust("gist"); + // The created id comes back in `message` — open the new gist directly. + if (r.message) nav("gists", { id: r.message }); + else refresh(); + return undefined; + } catch (e) { + return cleanErr(e) || "Couldn't create the gist."; + } + }, + ); } -async function editGist(g: GistInfo, refresh: () => void): Promise<void> { - const file = g.files[clampIdx(g)] ?? g.files[0]; +async function editGist(g: GistInfo, fileIdx: number, reload: () => void): Promise<void> { + const file = g.files[fileIdx] ?? g.files[0]; if (!file) return; - const v = await gistDialog({ - title: "Edit gist", - okLabel: "Save changes", - description: g.description, - filename: file.filename, - content: file.content, - public: g.public, - lockVisibility: true, // GitHub can't flip public↔secret on an existing gist - }); - if (!v) return; - try { - const r = await host.invoke("gist:update", { - id: g.id, - description: v.description, - filename: file.filename, // current name = the API key - content: v.content, - newFilename: v.filename, // rename when changed - }); - if (!r.ok) { - toast(r.message || "Couldn't save the gist.", "error"); - return; - } - openGistId = g.id; - toast("Gist saved.", "success"); - refresh(); - } catch (e) { - toast(cleanErr(e) || "Couldn't save the gist.", "error"); + // GitHub only sends the first megabyte of a large gist file, and the view + // already SAYS so ("GitHub truncated this file"). Editing loaded that partial + // text into the box and saved it back as the whole file, so opening a big + // gist and pressing Save — changing nothing — silently deleted everything + // past the truncation point. The one place that knows is here. + if (file.truncated) { + toast( + `GitStudio only received part of ${file.filename}. Edit it on GitHub so the rest isn't overwritten.`, + "error", + ); + return; } + await formWithRetry<GistDialogResult>( + (seed, error) => + gistDialog({ + title: "Edit gist", + okLabel: "Save changes", + description: seed?.description ?? g.description, + filename: seed?.filename ?? file.filename, + content: seed?.content ?? file.content, + public: g.public, + lockVisibility: true, // GitHub can't flip public↔secret on an existing gist + error, + }), + async (v) => { + try { + const r = await host.invoke("gist:update", { + id: g.id, + description: v.description, + filename: file.filename, // current name = the API key + content: v.content, + newFilename: v.filename, // rename when changed + }); + if (!r.ok) return r.message || "Couldn't save the gist."; + toast("Gist saved.", "success"); + reload(); + return undefined; + } catch (e) { + return cleanErr(e) || "Couldn't save the gist."; + } + }, + ); } -async function deleteGist( - g: GistInfo, - btn: HTMLElement, - refresh: () => void, -): Promise<void> { +async function deleteGist(g: GistInfo, btn: HTMLElement, back: () => void): Promise<void> { const ok = await confirmDialog({ title: "Delete this gist?", message: `“${g.description || g.files[0]?.filename || g.id}” will be permanently deleted on GitHub. This can't be undone.`, @@ -405,9 +492,9 @@ async function deleteGist( (btn as HTMLButtonElement).disabled = false; return; } - if (openGistId === g.id) openGistId = null; toast("Gist deleted.", "success"); - refresh(); + bust("gist"); + back(); // the detail's subject no longer exists — land on the list } catch (e) { (btn as HTMLButtonElement).disabled = false; toast(cleanErr(e) || "Couldn't delete the gist.", "error"); @@ -418,8 +505,7 @@ async function deleteGist( // // promptInline is single-line only, so this section ships a dedicated modal that // reuses the shared .modal-* scaffold (overlay, focus-trap, Esc-to-close) plus -// the gist-specific .gist-* classes. Self-contained so the view stays in its own -// two files (no edit to dialogs.ts). +// the gist-specific .gist-* classes. interface GistDialogResult { description: string; @@ -437,127 +523,118 @@ function gistDialog(opts: { public?: boolean; /** When true, the public/secret toggle is shown disabled (edit can't flip it). */ lockVisibility?: boolean; + /** Why the previous attempt failed, shown inside the form that still holds + * the text. See `formWithRetry`. */ + error?: string; }): Promise<GistDialogResult | null> { return new Promise((resolve) => { let settled = false; - const prevFocus = document.activeElement as HTMLElement | null; - const overlay = el("div", "modal-overlay"); - overlay.setAttribute("role", "dialog"); - overlay.setAttribute("aria-modal", "true"); - - const finish = (v: GistDialogResult | null): void => { - if (settled) return; - settled = true; - overlay.remove(); - document.removeEventListener("keydown", onKey, true); - prevFocus?.focus?.(); - resolve(v); - }; - - const card = el("div", "modal-card gist-modal"); - - const heading = el("div", "modal-title"); - heading.textContent = opts.title; - heading.id = "gist-modal-title"; - overlay.setAttribute("aria-labelledby", heading.id); - - const descIn = document.createElement("input"); - descIn.className = "modal-input"; - descIn.placeholder = "Description (optional)"; - descIn.value = opts.description ?? ""; - - const fileIn = document.createElement("input"); - fileIn.className = "modal-input"; - fileIn.placeholder = "Filename including extension…"; - fileIn.value = opts.filename ?? ""; - fileIn.spellcheck = false; - fileIn.autocapitalize = "off"; - - const contentIn = document.createElement("textarea"); - contentIn.className = "modal-input gist-textarea"; - contentIn.placeholder = "Gist content…"; - contentIn.value = opts.content ?? ""; - contentIn.spellcheck = false; - - const visRow = el("label", "gist-visibility"); - const vis = document.createElement("input"); - vis.type = "checkbox"; - vis.checked = opts.public ?? false; - if (opts.lockVisibility) { - vis.disabled = true; - visRow.title = "A gist's visibility can't be changed after it's created."; - } - const visLabel = span(`${vis.checked ? "Public" : "Secret"} gist`); - visRow.append(vis, visLabel); - if (!opts.lockVisibility) { - vis.addEventListener("change", () => { - visLabel.textContent = vis.checked ? "Public gist" : "Secret gist"; - }); - } - - const actions = el("div", "modal-actions"); - const cancel = el("button", "mini-btn"); - cancel.textContent = "Cancel"; - const ok = el("button", "btn btn-primary modal-ok"); - ok.appendChild(span(opts.okLabel)); - actions.append(cancel, ok); - - card.append(heading, descIn, fileIn, contentIn, visRow, actions); - - const submit = (): void => { - const filename = fileIn.value.trim(); - if (!filename) { - fileIn.focus(); // filename is required (GitHub rejects an empty key) - return; + openModal((close) => { + const finish = (v: GistDialogResult | null): void => { + if (settled) return; + settled = true; + resolve(v); + close(); + }; + + const card = el("div", "modal-card gist-modal"); + + const heading = el("div", "modal-title"); + heading.textContent = opts.title; + heading.id = "gist-modal-title"; + + const descIn = document.createElement("input"); + descIn.className = "modal-input"; + descIn.placeholder = "Description (optional)"; + descIn.value = opts.description ?? ""; + + const fileIn = document.createElement("input"); + fileIn.className = "modal-input"; + fileIn.placeholder = "Filename including extension…"; + fileIn.value = opts.filename ?? ""; + fileIn.spellcheck = false; + fileIn.autocapitalize = "off"; + + const contentIn = document.createElement("textarea"); + contentIn.className = "modal-input gist-textarea"; + contentIn.placeholder = "Gist content…"; + contentIn.value = opts.content ?? ""; + contentIn.spellcheck = false; + + const visRow = el("label", "gist-visibility"); + const vis = document.createElement("input"); + vis.type = "checkbox"; + vis.checked = opts.public ?? false; + if (opts.lockVisibility) { + vis.disabled = true; + visRow.title = "A gist's visibility can't be changed after it's created."; + } + const visLabel = span(`${vis.checked ? "Public" : "Secret"} gist`); + visRow.append(vis, visLabel); + if (!opts.lockVisibility) { + vis.addEventListener("change", () => { + visLabel.textContent = vis.checked ? "Public gist" : "Secret gist"; + }); } - finish({ - description: descIn.value.trim(), - filename, - content: contentIn.value, - public: vis.checked, - }); - }; - - cancel.addEventListener("click", () => finish(null)); - ok.addEventListener("click", submit); - const onKey = (e: KeyboardEvent): void => { - if (e.key === "Escape") { - e.preventDefault(); - finish(null); - return; + const actions = el("div", "modal-actions"); + const cancel = el("button", "mini-btn"); + cancel.textContent = "Cancel"; + const ok = el("button", "btn btn-primary modal-ok"); + ok.appendChild(span(opts.okLabel)); + actions.append(cancel, ok); + + card.append(heading, descIn, fileIn, contentIn, visRow); + if (opts.error) { + const note = el("div", "modal-note-error"); + note.textContent = opts.error; + card.appendChild(note); } + card.appendChild(actions); + + const submit = (): void => { + const filename = fileIn.value.trim(); + if (!filename) { + fileIn.focus(); // filename is required (GitHub rejects an empty key) + return; + } + finish({ + description: descIn.value.trim(), + filename, + content: contentIn.value, + public: vis.checked, + }); + }; + + cancel.addEventListener("click", () => finish(null)); + ok.addEventListener("click", submit); + // Cmd/Ctrl+Enter submits from the textarea (Enter alone inserts newlines). - if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - submit(); - return; - } - if (e.key !== "Tab") return; - const f = Array.from( - card.querySelectorAll<HTMLElement>( - "button, input, textarea, [tabindex]:not([tabindex='-1'])", - ), - ).filter((n) => !n.hasAttribute("disabled")); - if (!f.length) return; - const first = f[0]; - const last = f[f.length - 1]; - if (e.shiftKey && document.activeElement === first) { - e.preventDefault(); - last.focus(); - } else if (!e.shiftKey && document.activeElement === last) { - e.preventDefault(); - first.focus(); - } - }; + // On the card, not document — openModal owns Escape and the Tab trap. + card.addEventListener("keydown", (e) => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + submit(); + } + }); - overlay.appendChild(card); - document.body.appendChild(overlay); - overlay.addEventListener("mousedown", (e) => { - if (e.target === overlay) finish(null); + return { + card, + // On edit, the filename is known → focus the content; on create, the filename. + focusEl: opts.filename ? contentIn : fileIn, + label: opts.title, + // A BACKGROUND teardown — a route change, and a window focus routes — + // must not take a file someone is typing. Esc, the backdrop and Cancel + // are unaffected: those are the user asking. `formWithRetry` gives the + // text back after a failed SUBMIT; this is the other half. + hasUnsavedWork: () => + descIn.value !== (opts.description ?? "") || + fileIn.value !== (opts.filename ?? "") || + contentIn.value !== (opts.content ?? ""), + onClose: () => { + if (!settled) resolve(null); + }, + }; }); - document.addEventListener("keydown", onKey, true); - // On edit, the filename is known → focus the content; on create, focus the filename. - setTimeout(() => (opts.filename ? contentIn : fileIn).focus(), 0); }); } diff --git a/apps/desktop/src/renderer/views/issueCompose.ts b/apps/desktop/src/renderer/views/issueCompose.ts new file mode 100644 index 0000000..f99b6f4 --- /dev/null +++ b/apps/desktop/src/renderer/views/issueCompose.ts @@ -0,0 +1,444 @@ +// Writing an issue, as a PAGE. +// +// It used to be `editForm` — a shared modal with a title input and a body box. +// The owner's report: "Same goes for issues creating and editing, the +// create/edit window is utter garbage, look at real github page to see how its +// done." +// +// github.com/…/issues/new is a full page: the title, a Write/Preview body that +// takes the window, and a sidebar for the things you decide ABOUT the issue — +// assignees, labels, milestone. The modal could offer none of that, so every +// one of those was a second trip through the issue's detail page after the +// issue already existed (and had already notified everyone watching). +// +// This is that page. Labels and assignees ride along with the create request +// rather than being patched on afterwards, because a follow-up request can +// fail on its own and leave an announced issue missing what its author chose. + +import { host } from "../bridge"; +import { el, span, glyph, avatar, labelChip, cleanErr, errorState, skeletonList, openMenu } from "../ui"; +import { toast } from "../dialogs"; +import { detailPage, propSection, type SectionTarget, type SectionNav } from "./common"; +import { mdEditor } from "../mdEditor"; +import { wireDraft } from "../draftStore"; +import { setPageLabel } from "../navStack"; +import { bust, gget } from "../cache"; +import type { IssueDetail, PullRequest, RepoLabel, RepoCollaborator, MilestoneInfo } from "../../shared/ipc"; + +/** + * Which thing is being written. + * + * A pull request's title and body are the same two fields in the same shape, + * and editing one was the last surface still doing it in `editForm` — a modal + * with no draft at all, so Escape took everything. It gets this page too; it + * just has no sidebar, because a pull request's labels and reviewers already + * live on its own page. + */ +export type ComposeKind = "issue" | "pr"; + +/** Everything the sidebar can offer, fetched once and never blocking the form. */ +interface Choices { + labels: RepoLabel[]; + people: RepoCollaborator[]; + milestones: MilestoneInfo[]; +} + +async function loadChoices(): Promise<Choices> { + const [labels, people, milestones] = await Promise.all([ + gget("issue:labels", undefined, 60000).catch(() => [] as RepoLabel[]), + gget("pr:reviewers", undefined, 60000).catch(() => [] as RepoCollaborator[]), + gget("issue:milestones", undefined, 60000).catch(() => [] as MilestoneInfo[]), + ]); + return { labels, people, milestones }; +} + +/** + * The composer. `target.number` edits that issue; without one it opens a new + * one. + */ +export async function renderIssueCompose( + wrap: HTMLElement, + nav: SectionNav, + target: SectionTarget | undefined, + kind: ComposeKind = "issue", +): Promise<void> { + const editNo = target?.number; + const isPr = kind === "pr"; + // There is no "compose a pull request" here — a PR is opened from a branch, + // not written from nothing — so a `predit` with no number is a routing bug, + // and it must not quietly turn into the NEW-ISSUE form (which would file an + // issue when you asked to edit a pull request). + const section = isPr ? "prs" : "issues"; + const noun = isPr ? "pull request" : "issue"; + const { view, main, rail, topActions } = detailPage({ + backLabel: isPr ? "Pull requests" : "Issues", + crumb: editNo ? `Edit #${editNo}` : "New issue", + pageLabel: editNo ? `Edit ${noun} #${editNo}` : "New issue", + onBack: () => nav(section, editNo ? { number: editNo } : { list: true }), + }); + view.classList.add("isc-view"); + topActions.remove(); + wrap.replaceChildren(view); + main.appendChild(skeletonList(3, false)); + + if (isPr && editNo == null) { + // No Retry here — retrying a routing bug does nothing. The top bar's + // "← Pull requests" is the way out, and it is already on screen. + main.replaceChildren(errorState("No pull request", "Nothing was named to edit.")); + return; + } + + let existing: IssueDetail | undefined; + let existingPr: PullRequest | undefined; + if (editNo != null) { + try { + if (isPr) existingPr = (await host.invoke("pr:detail", editNo))?.pr; + else existing = await host.invoke("issue:detail", editNo); + } catch (e) { + if (!view.isConnected) return; + main.replaceChildren( + errorState(`Couldn't load this ${noun}`, cleanErr(e) || "GitHub request failed.", () => + void renderIssueCompose(wrap, nav, target, kind), + ), + ); + return; + } + if (!view.isConnected) return; + if (!existing && !existingPr) { + main.replaceChildren( + errorState( + isPr ? "Pull request unavailable" : "Issue unavailable", + `This ${noun} couldn't be read from GitHub.`, + ), + ); + return; + } + } + + const initTitle = existingPr?.title ?? existing?.issue.title ?? ""; + const initBody = existingPr?.body ?? existing?.issue.body ?? ""; + // Editing an issue changes its TEXT. Labels, assignees and the milestone are + // separate GitHub requests and the issue's own page already owns them — so + // the sidebar is offered while composing (where it saves a round trip and a + // premature notification) and not while editing (where it would duplicate, + // and disagree with, controls that already exist). + const composing = editNo == null; + + const form = el("div", "isc-form"); + main.replaceChildren(form); + + const draftId = `${kind}:${editNo == null ? "new" : String(editNo)}`; + + const titleField = el("div", "isc-field"); + const titleLabel = el("label", "isc-label"); + titleLabel.textContent = "Title"; + const title = document.createElement("input"); + title.className = "isc-input isc-title"; + title.placeholder = isPr ? "What does this change do?" : "Say what happened, in one line"; + title.value = initTitle; + title.id = "isc-title"; + (titleLabel as HTMLLabelElement).htmlFor = title.id; + titleField.append(titleLabel, title); + form.appendChild(titleField); + // The title survives leaving too. It used to be the one field a draft did not + // cover, so "never mind" (Escape) kept the paragraph you wrote and threw away + // the line you wrote first. + const titleDraft = wireDraft(`${kind}-title`, draftId, (t) => { + if (!title.value || title.value === initTitle) title.value = t; + if (initTitle && t !== initTitle) queueMicrotask(() => showRestored()); + }); + title.addEventListener("input", () => titleDraft.save(title.value)); + + const bodyLabel = el("div", "isc-label isc-body-label"); + bodyLabel.textContent = "Description"; + form.appendChild(bodyLabel); + + const body = mdEditor({ + value: initBody, + placeholder: isPr + ? "What changed, why, and anything a reviewer should look at first. Markdown is supported." + : "What happened, what you expected, and how to reproduce it. Markdown is supported — drop in a code block with ```.", + fill: true, + label: isPr ? "Pull request description" : "Issue description", + onInput: (v) => bodyDraft.save(v), + onSubmit: () => submitBtn.click(), + }); + // A draft is restored over an EMPTY field silently, and over text GitHub + // already has only WITH A NOTICE. The first version refused the second case + // outright — "never rewrite the server's text behind your back" — which is + // right about the silence and wrong about the outcome: an unsaved edit to + // this very object is the reader's own work, and losing it to a stray Escape + // is the thing they were promised would not happen. + const restored = el("div", "isc-restored"); + restored.hidden = true; + const bodyDraft = wireDraft(kind, draftId, (text) => { + if (!initBody) { + body.set(text); + return; + } + if (text === initBody) return; + body.set(text); + showRestored(); + }); + form.append(restored, body.root); + + function showRestored(): void { + if (!restored.hidden) return; + restored.hidden = false; + restored.replaceChildren( + glyph("history"), + span("Restored unsaved changes from this device.", "isc-restored-text"), + ); + const discard = el("button", "mini-btn") as HTMLButtonElement; + discard.textContent = `Use the version on GitHub`; + discard.addEventListener("click", () => { + body.set(initBody); + title.value = initTitle; + bodyDraft.clear(); + titleDraft.clear(); + restored.hidden = true; + }); + restored.appendChild(discard); + } + + const note = el("div", "isc-error"); + note.setAttribute("role", "alert"); + note.hidden = true; + form.appendChild(note); + const showError = (msg: string): void => { + note.hidden = false; + note.textContent = msg; + }; + + const bar = el("div", "isc-actions"); + const cancel = el("button", "mini-btn") as HTMLButtonElement; + cancel.textContent = "Cancel"; + cancel.addEventListener("click", () => nav(section, editNo ? { number: editNo } : { list: true })); + const submitBtn = el("button", "btn btn-primary") as HTMLButtonElement; + const submitLabel = span(composing ? "Create issue" : "Save changes"); + submitBtn.append(glyph(composing ? "issues" : "save"), submitLabel); + submitBtn.title = composing + ? "Open this issue on GitHub — everyone watching the repository is notified" + : "Save the title and description"; + bar.append(cancel, el("span", "isc-spring"), submitBtn); + form.appendChild(bar); + + // ── the sidebar ─────────────────────────────────────────────────────────── + const pickedLabels = new Set<string>(); + const pickedPeople = new Set<string>(); + let pickedMilestone: number | undefined; + + if (!composing) { + // Editing changes the TEXT. A pull request's labels and reviewers, and an + // issue's labels, assignees and milestone, already live on its own page — + // a second set of controls here would duplicate and then disagree with them. + rail.remove(); + } else { + const labelProp = propSection("Labels"); + const labelBody = labelProp.body; + const assignProp = propSection("Assignees"); + const assignBody = assignProp.body; + const mileProp = propSection("Milestone"); + const mileBody = mileProp.body; + rail.append(labelProp.root, assignProp.root, mileProp.root); + + const empty = (parent: HTMLElement, text: string): void => { + parent.appendChild(span(text, "isc-none")); + }; + const addBtn = (text: string, onClick: (anchor: HTMLElement) => void): HTMLElement => { + const b = el("button", "mini-btn isc-add") as HTMLButtonElement; + b.append(glyph("add"), span(text)); + b.addEventListener("click", () => onClick(b)); + return b; + }; + + const choices = await loadChoices(); + if (!view.isConnected) return; + + const paintLabels = (): void => { + labelBody.replaceChildren(); + if (pickedLabels.size) { + const chips = el("div", "isc-chips"); + // The SAME chip the lists and the issue page draw, so a label cannot + // look like one thing while you pick it and another once it is on. + for (const name of pickedLabels) { + const l = choices.labels.find((x) => x.name === name); + chips.appendChild(labelChip(name, l?.color ?? "")); + } + labelBody.appendChild(chips); + } else empty(labelBody, "None yet"); + labelBody.appendChild( + addBtn(pickedLabels.size ? "Edit labels" : "Add labels", (anchor) => { + if (!choices.labels.length) { + toast("This repository has no labels defined.", "info"); + return; + } + openMenu( + anchor, + choices.labels.map((l) => ({ + label: l.name, + checkable: true, + current: pickedLabels.has(l.name), + onClick: () => { + if (pickedLabels.has(l.name)) pickedLabels.delete(l.name); + else pickedLabels.add(l.name); + paintLabels(); + }, + })), + ); + }), + ); + }; + paintLabels(); + + const paintPeople = (): void => { + assignBody.replaceChildren(); + if (pickedPeople.size) { + const row = el("div", "isc-people"); + for (const login of pickedPeople) { + const p = choices.people.find((x) => x.login === login); + const one = el("span", "isc-person"); + one.append(avatar(login, p?.avatarUrl, 18, "Assignee"), span(login)); + row.appendChild(one); + } + assignBody.appendChild(row); + } else empty(assignBody, "No one — leave it unassigned"); + assignBody.appendChild( + addBtn(pickedPeople.size ? "Edit assignees" : "Assign people", (anchor) => { + if (!choices.people.length) { + toast("Couldn't read this repository's collaborators.", "info"); + return; + } + openMenu( + anchor, + choices.people.map((p) => ({ + label: p.login, + iconEl: avatar(p.login, p.avatarUrl, 18), + checkable: true, + current: pickedPeople.has(p.login), + onClick: () => { + if (pickedPeople.has(p.login)) pickedPeople.delete(p.login); + else pickedPeople.add(p.login); + paintPeople(); + }, + })), + ); + }), + ); + }; + paintPeople(); + + const paintMilestone = (): void => { + mileBody.replaceChildren(); + const m = choices.milestones.find((x) => x.number === pickedMilestone); + if (m) mileBody.appendChild(span(m.title, "isc-milestone")); + else empty(mileBody, "No milestone"); + mileBody.appendChild( + addBtn(m ? "Change milestone" : "Set milestone", (anchor) => { + const open = choices.milestones.filter((x) => x.state === "open"); + if (!open.length) { + toast("This repository has no open milestones.", "info"); + return; + } + openMenu(anchor, [ + { + label: "No milestone", + icon: "circle-slash", + current: pickedMilestone === undefined, + onClick: () => { + pickedMilestone = undefined; + paintMilestone(); + }, + }, + { separator: true }, + ...open.map((x) => ({ + label: `${x.title} — ${x.openIssues} open`, + icon: "milestone", + current: pickedMilestone === x.number, + onClick: () => { + pickedMilestone = x.number; + paintMilestone(); + }, + })), + ]); + }), + ); + }; + paintMilestone(); + } + + // ── submit ──────────────────────────────────────────────────────────────── + let busy = false; + const submit = async (): Promise<void> => { + if (busy) return; + const t = title.value.trim(); + if (!t) { + showError("An issue needs a title — it is what everyone reads first."); + title.setAttribute("aria-invalid", "true"); + title.focus(); + return; + } + note.hidden = true; + busy = true; + submitBtn.disabled = cancel.disabled = true; + submitLabel.textContent = composing ? "Creating…" : "Saving…"; + try { + if (composing) { + const r = await host.invoke("issue:create", { + title: t, + body: body.get(), + labels: [...pickedLabels], + assignees: [...pickedPeople], + milestone: pickedMilestone, + }); + if (!r.ok) { + showError(r.message ?? "GitHub rejected the issue."); + return; + } + bodyDraft.clear(); + titleDraft.clear(); + bust("issue"); + toast(r.number ? `Opened issue #${r.number}.` : "Issue created.", "success"); + nav("issues", r.number ? { number: r.number } : { list: true }); + } else { + if (t === initTitle && body.get() === initBody) { + nav(section, { number: editNo }); + return; + } + const r = isPr + ? await host.invoke("pr:edit", { number: editNo!, title: t, body: body.get() }) + : await host.invoke("issue:edit", { number: editNo!, title: t, body: body.get() }); + if (!r.ok) { + showError(r.message ?? "GitHub rejected the change."); + return; + } + bodyDraft.clear(); + titleDraft.clear(); + bust(isPr ? "pr" : "issue"); + toast(`Updated ${noun} #${editNo}.`, "success"); + nav(section, { number: editNo }); + } + } catch (e) { + showError(cleanErr(e) || "Couldn't reach GitHub."); + } finally { + busy = false; + submitBtn.disabled = cancel.disabled = false; + submitLabel.textContent = composing ? "Create issue" : "Save changes"; + } + }; + submitBtn.addEventListener("click", () => void submit()); + title.addEventListener("input", () => { + if (!title.value.trim()) return; + title.removeAttribute("aria-invalid"); + note.hidden = true; + }); + form.addEventListener("keydown", (e) => { + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + e.preventDefault(); + void submit(); + } + }); + + setPageLabel(editNo ? `Edit ${noun} #${editNo}` : "New issue"); + (initTitle ? body.textarea : title).focus(); +} diff --git a/apps/desktop/src/renderer/views/issues.ts b/apps/desktop/src/renderer/views/issues.ts index b2c2971..c484e3e 100644 --- a/apps/desktop/src/renderer/views/issues.ts +++ b/apps/desktop/src/renderer/views/issues.ts @@ -1,124 +1,128 @@ -// Issues — the repo-scoped GitHub Issues section. +// Issues — the repo-scoped GitHub Issues section, on the section-page system +// (docs/desktop-redesign.md): a full-width list page whose rows navigate to a +// full-page detail (routed via `target.number`, so ⌘[/Esc walk back like a +// browser), with the issue's properties in an inline-editable right rail. // -// A two-pane view (list ⟷ detail) mirroring the Pull Requests surface: a header -// with an open/closed filter + "New Issue", a list of issue rows with label -// chips, and a rich detail pane (markdown body, comment timeline, composer, and -// the full CRUD action cluster — edit, labels, assignees, close/reopen). -// -// Self-contained: it renders into the `wrap` it's handed and re-renders by -// calling itself, so all state survives a refresh. Every mutation disables its -// trigger, toasts the result, and re-fetches so the UI stays authoritative. +// Self-contained: it renders into the `wrap` it's handed and re-renders through +// the section router. Every mutation disables its trigger, toasts the result, +// busts the SWR cache and re-fetches so the UI stays authoritative. import { host } from "../bridge"; +import { peek as cachePeek, gget, bust, cacheScope } from "../cache"; import { avatar, cleanErr, el, emptyState, errorState, - ghRow, glyph, labelChip, - loadingState, openMenu, - pill, relTimeISO, absTimeISO, skeletonList, span, statBit, + issueStateKind, stateLead, + statePill, } from "../ui"; -import { confirmDialog, promptInline, editForm, toast } from "../dialogs"; +import { confirmDialog, promptInline, toast, formWithRetry} from "../dialogs"; import { renderMarkdown } from "../markdown"; +import { wireProseNav } from "../proseNav"; +import { openPeek } from "../peek"; +import { memberCard } from "./orgs"; import { aiChip, openAssistantTab, streamInto, aiEnabled } from "../aiAssist"; -import { ghGate, ghHeader, ghListResizer, peoplePickerModal, searchField, type SectionRender, type SectionNav, type SectionTarget } from "./common"; -import type { IssueDetail, IssueInfo, MilestoneInfo, RepoCollaborator, RepoLabel } from "../../shared/ipc"; +import { + associationBadge, + blankable, + facetBar, + harvestValues, + segmented, + swatch, + type FacetSpec, + type FacetState, + reactionRow, + avatarStack, + capNotice, + detailPage, + ghGate, + ghHeader, + LIST_CAPS, + peoplePickerModal, + personChip, + propAddBtn, + propNone, + propSection, + searchField, + secRow, + sectionList, + type GhGate, + type SectionRender, + type SectionNav, + type SectionTarget, +} from "./common"; +import type { + IssueDetail, + IssueInfo, + MilestoneInfo, + ReactionSummary, + RepoCollaborator, + RepoLabel, +} from "../../shared/ipc"; + +// ── Section state (module-level so it survives list ⇄ detail round trips) ──── -/** The Open / Closed / All filter, persisted across re-renders within the section. */ let issueState: "open" | "closed" | "all" = "open"; - -/** - * Client-side facet filters applied to the already-loaded list (the API only - * filters by state). `null` means "any". These persist across re-renders so the - * active facet survives a refresh, mirroring `issueState`. - */ -let facetLabel: string | null = null; -let facetAssignee: string | null = null; -let facetMilestone: string | null = null; - -// ── Section-scoped styles ──────────────────────────────────────────────────── - +/** Client-side facets over the loaded list (shared facetBar vocabulary). */ +const issueFacets: FacetState = {}; +/** The live text query — kept so Back from a detail restores the search. */ +let query = ""; /** - * Inject the few classes this view adds (facet buttons, label swatch, avatar - * assignee chips) once. App-wide CSS lives in app.css, but these are local to - * Issues depth, so they ship with the view. Tokens mirror app.css exactly - * (.mini-btn / .gh-seg-btn) so the look is indistinguishable from native rules. + * Unsent comment drafts, per issue — navigating away must never eat one. + * + * Keyed by REPO and number. Keyed by number alone, a draft written on issue #31 + * in one repository was handed to issue #31 in the next one you opened — + * pre-filled into its composer, ready to send to strangers. Numbers collide + * across repos constantly; the low ones always do. */ -function ensureIssuesStyles(): void { - if (document.getElementById("issues-depth-styles")) return; - const s = document.createElement("style"); - s.id = "issues-depth-styles"; - s.textContent = ` -.gh-issue-facets { display: inline-flex; align-items: center; gap: 8px; } -.gh-facet-btn { max-width: 220px; } -.gh-facet-btn > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.gh-facet-btn .codicon-chevron-down { font-size: 13px; opacity: .65; margin-left: -1px; } -.gh-facet-btn.is-active { - color: var(--gs-accent-ink, var(--gs-accent)); - border-color: var(--accent-line, var(--gs-accent)); - background: var(--app-active); -} -.gh-facet-btn.is-active .glyph { color: var(--gs-accent-ink, var(--gs-accent)); } -.gh-label-swatch { - display: inline-block; width: 11px; height: 11px; border-radius: 50%; - box-shadow: inset 0 0 0 1px color-mix(in srgb, #000 22%, transparent); - flex: 0 0 auto; -} -.gh-assignee-chip { - display: inline-flex; align-items: center; gap: 6px; - height: 24px; padding: 0 9px 0 4px; border-radius: 999px; - border: 1px solid var(--app-border); background: var(--app-elevated); - font-size: 12px; color: var(--vscode-foreground); -} -.gh-assignee-chip .av { flex: 0 0 auto; } -`; - document.head.appendChild(s); -} +const commentDrafts = new Map<string, string>(); +const draftKey = (n: number): string => `${cacheScope()}#${n}`; // ── Small DOM builders ─────────────────────────────────────────────────────── -/** A tiny round color swatch for a label, tinted from its hex (menu leading el). */ -function swatch(hexColor: string): HTMLElement { - const s = el("span", "gh-label-swatch"); - s.style.background = `#${(hexColor || "888888").replace(/^#/, "")}`; - return s; -} - -/** - * Render a facet button's label + state. Shows "<name>: <value>" with a - * trailing caret when a value is picked (and an `is-active` accent), or just the - * neutral "<name>" + caret when "any". Keeps the header cluster compact. - */ -function setFacetButton( - btn: HTMLButtonElement, - icon: string, - name: string, - value: string | null, -): void { - btn.replaceChildren(); - btn.classList.toggle("is-active", value != null); - btn.append(glyph(icon), span(value != null ? `${name}: ${value}` : name), glyph("chevron-down")); - btn.title = value != null ? `Filtering by ${name.toLowerCase()} “${value}” — click to change` : `Filter by ${name.toLowerCase()}`; - btn.setAttribute("aria-label", btn.title); -} - /** One timeline card: the issue body (first) or a comment. Markdown body. */ -function commentCard(author: string, action: string, body: string, createdAt: string): HTMLElement { +function commentCard( + author: string, + action: string, + body: string, + createdAt: string, + extra: { + /** Later than createdAt ⇒ show an "edited" marker, like GitHub. */ + updatedAt?: string; + association?: string; + reactions?: ReactionSummary; + } = {}, +): HTMLElement { const card = el("div", "gh-comment"); const hd = el("div", "gh-comment-head"); - const who = span(author, ""); - hd.append(who, span(`${action} · ${relTimeISO(createdAt)}`, "gh-comment-when")); + const who = el("span", "gh-comment-author"); + who.append(avatar(author, `https://github.com/${author}.png`, 18), span(author)); + hd.append(who); + const badge = associationBadge(extra.association); + if (badge) hd.appendChild(badge); + // "3 days ago" alone cannot answer "before or after the release?" — the exact + // time is one hover away rather than nowhere. + const when = span(`${action} · ${relTimeISO(createdAt)}`, "gh-comment-when"); + when.title = absTimeISO(createdAt); + hd.appendChild(when); + // A comment edited after posting is a different artifact from what people + // replied to — GitHub says so, and silence here has burned readers. + if (extra.updatedAt && extra.updatedAt !== createdAt) { + const ed = span("edited", "gh-comment-edited"); + ed.title = `Edited ${absTimeISO(extra.updatedAt)}`; + hd.appendChild(ed); + } card.appendChild(hd); const bd = el("div", "gh-body-md"); if (body.trim()) { @@ -135,328 +139,287 @@ function commentCard(author: string, action: string, body: string, createdAt: st bd.textContent = "No description provided."; } card.appendChild(bd); + const reactions = reactionRow(extra.reactions); + if (reactions) card.appendChild(reactions); return card; } // ── The section view ───────────────────────────────────────────────────────── +/** The section's router, so a detail page nested inside it can leave for + * another view — the issue composer is a page of its own now, not a modal. */ +let sectionNav: SectionNav | undefined; + export const renderIssues: SectionRender = (wrap, nav, target) => { + sectionNav = nav; void mount(wrap, nav, target); }; async function mount(wrap: HTMLElement, nav: SectionNav, target?: SectionTarget): Promise<void> { - const refresh = (): void => renderIssues(wrap, nav); - ensureIssuesStyles(); - - const gate = await ghGate(wrap, nav, true); + const refresh = (): void => { + bust("issue"); + renderIssues(wrap, nav, target); + }; + const gate = await ghGate(wrap, nav, true, refresh); if (!gate) return; - // Shell: gh-view → header (+ tools) → gh-body (list | detail). - const view = el("div", "gh-view"); - const header = ghHeader("Issues", gate.login, refresh); + if (target?.number != null) { + showDetailPage(wrap, nav, target.number, target.from); + return; + } + await listPage(wrap, nav, gate); +} - // Right-side cluster in the header: a 3-way state segmented control, the facet - // filters (label / assignee / milestone, populated once the list loads), and - // New Issue. The state segment re-fetches; the facets filter client-side. - const tools = el("div", "gh-head-tools"); +// ── The list page ──────────────────────────────────────────────────────────── - const seg = el("div", "gh-seg"); - const segBtn = (label: string, value: "open" | "closed" | "all"): HTMLElement => { - const b = el("button", "gh-seg-btn"); - b.textContent = label; - b.classList.toggle("active", issueState === value); - b.setAttribute("aria-pressed", String(issueState === value)); - b.addEventListener("click", () => { - if (issueState === value) return; - issueState = value; - refresh(); - }); - return b; +async function listPage(wrap: HTMLElement, nav: SectionNav, gate: GhGate): Promise<void> { + const refresh = (): void => { + bust("issue"); + renderIssues(wrap, nav); }; - seg.append(segBtn("Open", "open"), segBtn("Closed", "closed"), segBtn("All", "all")); - // The facet-filter buttons live in their own slot so we can populate their - // behavior after `issues` loads (they need the loaded set + repo metadata). - const facets = el("div", "gh-issue-facets"); + const { view, listEl } = sectionList(); + const header = ghHeader("Issues", gate.login, refresh); + + // Toolbar: state segment · facets · New Issue (search rides in the titlewrap). + const tools = el("div", "gh-head-tools"); + const seg = segmented<"open" | "closed" | "all">({ + options: [ + { value: "open", label: "Open" }, + { value: "closed", label: "Closed" }, + { value: "all", label: "All" }, + ], + value: issueState, + ariaLabel: "Issue state", + onChange: (v) => { + issueState = v; + renderIssues(wrap, nav); + }, + }); + const facetSlot = el("div", "gh-facet-slot"); const newBtn = el("button", "btn btn-primary gh-new-btn"); - newBtn.append(glyph("add"), span("New Issue")); - newBtn.addEventListener("click", () => void newIssue(wrap, nav)); - tools.append(seg, facets, newBtn); + newBtn.append(glyph("add"), span("New issue")); + newBtn.addEventListener("click", () => nav("issuenew")); + tools.append(seg, facetSlot, newBtn); header.querySelector(".gh-acct")?.before(tools); - view.appendChild(header); - - const body = el("div", "gh-body"); - const listEl = el("div", "gh-list"); - const detail = el("div", "gh-detail"); - body.append(listEl, ghListResizer(listEl), detail); - view.appendChild(body); + view.append(header, listEl); wrap.replaceChildren(view); - const idleEmpty = (): void => { - detail.replaceChildren( - emptyState( - "Issues", - "Select an issue to read its description, comment, manage labels and assignees, or close it.", - { - icon: "issue-opened", - hint: "Tip: switch Open / Closed / All, or filter by label and assignee, to focus the list.", - }, - ), - ); - }; - idleEmpty(); - // Load the list (the API filters by state — the Open/Closed toggle drives it). - listEl.replaceChildren(skeletonList(5)); - let issues: IssueInfo[]; - try { - issues = await host.invoke("issue:list", { state: issueState }); - } catch (e) { - listEl.replaceChildren( - errorState("Couldn't load issues", cleanErr(e) || "GitHub request failed.", refresh), - ); - return; - } - - header.setCount?.(issues.length); - listEl.replaceChildren(); - if (issues.length === 0) { - idleEmpty(); - const emptyCopy: Record<typeof issueState, { title: string; desc: string; icon: string }> = { - open: { - title: "No open issues", - desc: "You're all caught up — there's nothing open to triage right now.", - icon: "issue-opened", - }, - closed: { - title: "No closed issues", - desc: "Closed issues will show here once you close some.", - icon: "issue-closed", - }, - all: { - title: "No issues yet", - desc: "This repo has no issues. Open the first one to start tracking work.", - icon: "issue-opened", - }, - }; - const c = emptyCopy[issueState]; - listEl.appendChild( - emptyState( - c.title, - c.desc, - issueState === "closed" - ? { icon: c.icon } - : { - icon: c.icon, - action: { label: "New issue", icon: "add", onClick: () => void newIssue(wrap, nav) }, - }, - ), - ); - return; - } - - const select = (it: IssueInfo, row: HTMLElement): void => { - listEl.querySelectorAll(".gh-row.active").forEach((n) => n.classList.remove("active")); - row.classList.add("active"); - void showDetail(detail, it.number, wrap, nav); - }; + // ── data: paint from cache instantly, revalidate in the background ── + let issues: IssueInfo[] | undefined = cachePeek("issue:list", { state: issueState }); + if (!issues) listEl.replaceChildren(skeletonList(6)); const buildRow = (it: IssueInfo): HTMLElement => { - const chips = it.labels.map((l) => labelChip(l.name, l.color)); - const stats: HTMLElement[] = []; - if (it.comments > 0) stats.push(statBit("comment", it.comments)); - // A small trailing avatar cluster for up to three assignees. - if (it.assignees.length) { - const cluster = el("span", "gh-row-assignees"); - for (const a of it.assignees.slice(0, 3)) cluster.appendChild(avatar(a.login, a.avatarUrl, 18)); - stats.push(cluster); - } - const author = it.user?.login ?? "unknown"; - const row = ghRow({ - lead: stateLead(it.state === "closed" ? "closed" : "open"), + // One order across every list: who wrote it, who owns it, then the counts. + const meta: HTMLElement[] = []; + if (it.user) meta.push(avatarStack([it.user], 1, 18, "Author")); + // Reserved even when empty, so the author avatar keeps its column on rows + // that happen to have no assignee. + meta.push(blankable(avatarStack(it.assignees, 3, 18, "Assignee"), it.assignees.length > 0)); + // Rendered even at zero (blanked, not omitted): the meta cluster packs + // right-to-left, so an absent count used to slide the avatars into the + // column where every other row shows its comments. + meta.push(blankable(statBit("comment", it.comments), it.comments > 0)); + const row = secRow({ + lead: stateLead(issueStateKind(it.state, it.stateReason)), + num: `#${it.number}`, title: it.title, - meta: `#${it.number} · ${author} · opened ${relTimeISO(it.createdAt)}`, - metaTitle: it.createdAt ? `Opened ${absTimeISO(it.createdAt)}` : undefined, - chips, - stats, + // The leading slash-circle icon already says "not planned" two glyphs + // away; a pill repeating it was the same fact twice on one row. The icon + // carries the words in its tooltip instead. + titleSuffix: [], + chips: it.labels.map((l) => labelChip(l.name, l.color)), + meta, + // The list arrives sorted by LAST UPDATED, so the date column has to be + // the updated date. It showed — and its tooltip labelled — the CREATED + // date, which made the order look arbitrary: a two-year-old issue + // commented on this morning sat at the top reading "opened 2 years ago". + // Both dates are in the tooltip; only one can be the sorted column. + time: relTimeISO(it.updatedAt), + timeTitle: it.updatedAt + ? `Updated ${absTimeISO(it.updatedAt)}` + + (it.createdAt ? `\nOpened ${absTimeISO(it.createdAt)}` : "") + : undefined, ariaLabel: `Issue #${it.number}: ${it.title}`, + onOpen: () => nav("issues", { number: it.number }), }); row.dataset.num = String(it.number); - row.addEventListener("click", () => select(it, row)); return row; }; - // Case-insensitive match over the fields a user would search by. const matches = (it: IssueInfo, q: string): boolean => { const hay = `${it.title} #${it.number} ${it.user?.login ?? ""} ${it.labels .map((l) => l.name) .join(" ")}`.toLowerCase(); return hay.includes(q); }; + const passesFacets = (it: IssueInfo): boolean => facets.passes(it); + const facetsActive = (): boolean => facets.activeCount() > 0; - // The live text query, kept alongside the persisted facets so any one of them - // changing re-applies the whole filter pipeline against the loaded `issues`. - let query = ""; - const passesFacets = (it: IssueInfo): boolean => { - if (facetLabel && !it.labels.some((l) => l.name === facetLabel)) return false; - if (facetAssignee && !it.assignees.some((a) => a.login === facetAssignee)) return false; - return true; - }; - const filtered = (): IssueInfo[] => { - const q = query.toLowerCase(); - return issues.filter((it) => passesFacets(it) && (q ? matches(it, q) : true)); - }; - const facetsActive = (): boolean => facetLabel != null || facetAssignee != null; - - let autoSelected = false; const renderList = (): void => { - const items = filtered(); + if (!issues) return; + // Re-harvest before painting: the bar is built before the first fetch + // lands, and a facet menu that offers nothing is worse than no facet. + facets.sync(issues); + const q = query.toLowerCase(); + const items = issues.filter((it) => passesFacets(it) && (q ? matches(it, q) : true)); + header.setCount?.(items.length, issues.length); listEl.replaceChildren(); + if (issues.length === 0) { + const emptyCopy: Record<typeof issueState, { title: string; desc: string; icon: string }> = { + open: { + title: "No open issues", + desc: "You're all caught up — there's nothing open to triage right now.", + icon: "issue-opened", + }, + closed: { + title: "No closed issues", + desc: "Closed issues will show here once you close some.", + icon: "issue-closed", + }, + all: { + title: "No issues yet", + desc: "This repo has no issues. Open the first one to start tracking work.", + icon: "issue-opened", + }, + }; + const c = emptyCopy[issueState]; + listEl.appendChild( + emptyState( + c.title, + c.desc, + issueState === "closed" + ? { icon: c.icon } + : { + icon: c.icon, + action: { label: "New issue", icon: "add", onClick: () => nav("issuenew") }, + }, + ), + ); + return; + } if (items.length === 0) { - // Distinguish "your text matched nothing" from "your facet filters did". - const desc = query - ? `Nothing matches “${query}”.` - : "No issues match the active filters."; - const empty = emptyState("No matching issues", desc, { icon: "search" }); - if (facetsActive()) { - const clear = el("button", "btn btn-soft list-empty-action"); - clear.append(glyph("clear-all"), span("Clear filters")); - clear.addEventListener("click", () => { - facetLabel = null; - facetAssignee = null; - syncFacetButtons(); - renderList(); - }); - empty.appendChild(clear); - } - listEl.appendChild(empty); + const desc = query ? `Nothing matches “${query}”.` : "No issues match the active filters."; + listEl.appendChild( + emptyState("No matching issues", desc, { + icon: "search", + anchor: "inline", + secondary: + facets.activeCount() > 0 + ? { label: "Clear filters", icon: "clear-all", onClick: () => facets.clear() } + : undefined, + }), + ); return; } for (const it of items) listEl.appendChild(buildRow(it)); - // Auto-select the first issue once (initial render) so the detail isn't a - // void; don't hijack the selection on every keystroke while filtering. - if (!autoSelected) { - autoSelected = true; - const first = items[0]; - const firstRow = listEl.firstElementChild as HTMLElement | null; - if (first && firstRow) select(first, firstRow); - } + const cap = capNotice(issues.length, LIST_CAPS.issues); + if (cap) listEl.appendChild(cap); }; - // ── Facet filters (label / assignee) ─────────────────────────────────────── - // These filter the already-loaded set client-side. Milestone is intentionally - // not a list facet: the wire `IssueInfo` carries no per-issue milestone, so it - // can't be filtered here (the detail still sets/clears it). Labels come from - // the repo; assignees are derived from whoever's actually assigned in view. + // ── facets: one shared bar (label / assignee / milestone / author / reason) ── + // Repo labels are fetched so the menu can offer labels no loaded issue uses; + // everything else is harvested from what's on screen, which is honest — + // these are CLIENT-side facets over the fetched page. let repoLabels: RepoLabel[] = []; - void host - .invoke("issue:labels", undefined) + void gget("issue:labels", undefined, 60000) .then((ls) => { repoLabels = ls; + facets.sync(issues ?? []); }) .catch(() => { - /* best-effort — the label facet just falls back to in-list label names */ + /* best-effort — the label facet falls back to in-list label names */ }); - // Rebuilt whenever a facet changes so the active value shows on its button. - let labelBtn: HTMLButtonElement; - let assignBtn: HTMLButtonElement; - const syncFacetButtons = (): void => { - setFacetButton(labelBtn, "tag", "Label", facetLabel); - setFacetButton(assignBtn, "person", "Assignee", facetAssignee); + // Only meaningful for closed issues, so it is left out entirely on the Open + // tab rather than offered as a filter that can only ever match zero rows. + const closedReasonSpec: FacetSpec<IssueInfo> = { + key: "reason", + label: "Closed as", + icon: "circle-slash", + anyLabel: "Any reason", + options: [ + { value: "completed", label: "Completed", icon: "issue-closed" }, + { value: "not_planned", label: "Not planned", icon: "circle-slash" }, + ], + predicate: (it, v) => + v === "completed" + ? it.state === "closed" && it.stateReason !== "not_planned" + : it.stateReason === "not_planned", }; - const openLabelFacet = (): void => { - // Prefer the repo's full label set (with colors); fall back to labels seen - // in the loaded issues if the repo fetch hasn't landed / was denied. - const fromRepo = repoLabels.map((l) => ({ name: l.name, color: l.color })); - const seen = new Map<string, string>(); - for (const it of issues) for (const l of it.labels) seen.set(l.name, l.color); - const all = fromRepo.length - ? fromRepo - : [...seen].map(([name, color]) => ({ name, color })); - if (all.length === 0) { - toast("This repo has no labels to filter by.", "info"); - return; - } - openMenu( - labelBtn, - [ - { - label: "All labels", - icon: facetLabel == null ? "check" : "dash", - current: facetLabel == null, - onClick: () => { - facetLabel = null; - syncFacetButtons(); - renderList(); - }, + const facets = facetBar<IssueInfo>({ + specs: [ + { + key: "label", + label: "Label", + icon: "tag", + anyLabel: "All labels", + harvest: (items) => { + const seen = new Map<string, string>(); + for (const l of repoLabels) seen.set(l.name, l.color); + for (const it of items) for (const l of it.labels) if (!seen.has(l.name)) seen.set(l.name, l.color); + return [...seen].map(([name, color]) => ({ value: name, iconEl: () => swatch(color) })); }, - { separator: true }, - ...all.map((l) => ({ - label: l.name, - iconEl: swatch(l.color), - current: facetLabel === l.name, - onClick: () => { - facetLabel = l.name; - syncFacetButtons(); - renderList(); - }, - })), - ], - { searchable: all.length > 8 }, - ); - }; - - const openAssigneeFacet = (): void => { - // Distinct assignees across the loaded issues (avatars in the menu). - const seen = new Map<string, string | null>(); - for (const it of issues) for (const a of it.assignees) if (!seen.has(a.login)) seen.set(a.login, a.avatarUrl); - const people = [...seen].map(([login, avatarUrl]) => ({ login, avatarUrl })); - if (people.length === 0) { - toast("No assignees on the issues in view.", "info"); - return; - } - openMenu( - assignBtn, - [ - { - label: "Anyone", - icon: facetAssignee == null ? "check" : "dash", - current: facetAssignee == null, - onClick: () => { - facetAssignee = null; - syncFacetButtons(); - renderList(); - }, + predicate: (it, v) => it.labels.some((l) => l.name === v), + }, + { + key: "assignee", + label: "Assignee", + icon: "person", + anyLabel: "Anyone", + harvest: (items) => { + const seen = new Map<string, string | null>(); + for (const it of items) for (const a of it.assignees) if (!seen.has(a.login)) seen.set(a.login, a.avatarUrl); + return [...seen].map(([login, avatarUrl]) => ({ + value: login, + label: `@${login}`, + iconEl: () => avatar(login, avatarUrl, 18), + })); }, - { separator: true }, - ...people.map((p) => ({ - label: `@${p.login}`, - iconEl: avatar(p.login, p.avatarUrl, 18), - current: facetAssignee === p.login, - onClick: () => { - facetAssignee = p.login; - syncFacetButtons(); - renderList(); - }, - })), - ], - { searchable: people.length > 8 }, - ); - }; - - labelBtn = el("button", "mini-btn gh-facet-btn") as HTMLButtonElement; - labelBtn.addEventListener("click", () => openLabelFacet()); - assignBtn = el("button", "mini-btn gh-facet-btn") as HTMLButtonElement; - assignBtn.addEventListener("click", () => openAssigneeFacet()); - facets.append(labelBtn, assignBtn); - syncFacetButtons(); + predicate: (it, v) => it.assignees.some((a) => a.login === v), + }, + { + key: "milestone", + label: "Milestone", + icon: "milestone", + anyLabel: "Any milestone", + harvest: harvestValues<IssueInfo>((it) => it.milestone?.title), + predicate: (it, v) => it.milestone?.title === v, + }, + { + key: "author", + label: "Author", + icon: "account", + anyLabel: "Anyone", + harvest: (items) => { + const seen = new Map<string, string | null>(); + for (const it of items) if (it.user && !seen.has(it.user.login)) seen.set(it.user.login, it.user.avatarUrl); + return [...seen].map(([login, avatarUrl]) => ({ + value: login, + label: `@${login}`, + iconEl: () => avatar(login, avatarUrl, 18), + })); + }, + predicate: (it, v) => it.user?.login === v, + }, + // Only meaningful for closed issues, so it is hidden entirely on the Open + // tab rather than offered there as a filter that can only ever match zero + // rows. GitHub calls this "closed as"; "Reason" said nothing next to the + // Open/Closed/All segment. + ...(issueState === "open" ? [] : [closedReasonSpec]), + ], + state: issueFacets, + items: issues ?? [], + onChange: () => renderList(), + }); + facetSlot.replaceChildren(facets.el); - // A header search/filter — on the LEFT, next to the title (client-side, instant). header.querySelector(".gh-head-titlewrap")?.appendChild( searchField({ placeholder: "Search issues…", + initial: query, onInput: (q) => { query = q; renderList(); @@ -464,129 +427,140 @@ async function mount(wrap: HTMLElement, nav: SectionNav, target?: SectionTarget) }), ); - // If another view deep-linked an issue (e.g. the project board), open it on - // entry instead of the auto-selected first item — never bounce out to GitHub. - if (target?.number != null) autoSelected = true; - renderList(); - if (target?.number != null) { - const n = target.number; - const it = issues.find((i) => i.number === n); - const row = listEl.querySelector(`[data-num="${n}"]`) as HTMLElement | null; - if (it && row) { - select(it, row); - row.scrollIntoView({ block: "nearest" }); - } else { - // Not in the current state filter — open its detail directly by number. - void showDetail(detail, n, wrap, nav); + if (issues) renderList(); // instant paint from cache + + try { + const fresh = await gget("issue:list", { state: issueState }, 15000); + if (!view.isConnected) return; + issues = fresh; + renderList(); + } catch (e) { + if (!view.isConnected) return; + if (!issues) { + listEl.replaceChildren( + errorState("Couldn't load issues", cleanErr(e) || "GitHub request failed.", refresh), + ); } } } -// ── Detail pane ────────────────────────────────────────────────────────────── +// ── The detail page ────────────────────────────────────────────────────────── + +function showDetailPage( + wrap: HTMLElement, + nav: SectionNav, + n: number, + from?: { view: string; label: string }, +): void { + // Back goes where you CAME from. Inbox and My Work both open items that live + // in this section, so without this the bar read "← Issues", the rail + // switched under you, and Escape dropped you in a list you had never opened. + const back = (): void => nav(from?.view ?? "issues", { list: true }); + const reload = (): void => { + bust("issue"); + showDetailPage(wrap, nav, n, from); + }; + + const { view, main, rail, topActions } = detailPage({ + backLabel: from?.label ?? "Issues", + crumb: `#${n}`, + pageLabel: `Issue #${n}`, + onBack: back, + }); + main.appendChild(skeletonList(4, false)); + wrap.replaceChildren(view); + + void (async () => { + let d: IssueDetail | undefined; + try { + d = await gget("issue:detail", n, 8000); + } catch (e) { + if (!view.isConnected) return; + main.replaceChildren( + errorState("Couldn't load issue", cleanErr(e) || "GitHub request failed.", reload), + ); + return; + } + if (!view.isConnected) return; + if (!d) { + main.replaceChildren(emptyState("Issue unavailable", "This issue couldn't be loaded.")); + return; + } + buildDetail({ main, rail, topActions, d, nav, reload }); + })(); +} /** - * Render a single issue's full detail (body, timeline, composer, action cluster) - * into any container — used by the Projects board to peek an issue inline in a - * slide-over drawer, so you never leave the board to read or reply to one. All - * in-detail mutations re-render inside `container`, so the drawer is fully live. + * Render a single issue's full detail into any container — used by the Projects + * board's slide-over drawer, so you never leave the board to read or reply to + * one. The drawer has no rail: properties render as a compact inline strip. */ export async function renderIssueDetailInto( container: HTMLElement, number: number, nav: SectionNav, + /** Called whenever something in here CHANGED the issue — closing it, + * relabelling it, assigning it. The Projects board hosts this detail in a + * drawer, and the card behind the drawer went on reading "open" after the + * issue was closed in front of it, because nothing told the board. */ + onMutated?: () => void, ): Promise<void> { - await showDetail(container, number, container, nav); -} - -async function showDetail( - detail: HTMLElement, - n: number, - wrap: HTMLElement, - nav: (view: string) => void, -): Promise<void> { - const reload = (): void => void showDetail(detail, n, wrap, nav); - - detail.replaceChildren(loadingState()); + const reload = (): void => { + bust("issue"); + onMutated?.(); + void renderIssueDetailInto(container, number, nav, onMutated); + }; + container.replaceChildren(skeletonList(4, false)); let d: IssueDetail | undefined; try { - d = await host.invoke("issue:detail", n); + d = await gget("issue:detail", number, 8000); } catch (e) { - detail.replaceChildren( + container.replaceChildren( errorState("Couldn't load issue", cleanErr(e) || "GitHub request failed.", reload), ); return; } + if (!container.isConnected) return; if (!d) { - detail.replaceChildren(emptyState("Issue unavailable", "This issue couldn't be loaded.")); + container.replaceChildren(emptyState("Issue unavailable", "This issue couldn't be loaded.")); return; } - const it = d.issue; - const assignees = d.assignees; - detail.replaceChildren(); - - // Head: title + meta + actions. - const head = el("div", "gh-detail-head"); - const h = el("div", "gh-detail-title"); - h.textContent = it.title; - - const meta = el("div", "gh-detail-meta"); - const statePill = pill(it.state === "open" ? "Open" : "Closed"); - statePill.classList.add(it.state === "open" ? "gh-issue-open" : "gh-issue-closed"); - meta.append( - statePill, - document.createTextNode( - ` #${it.number} · ${it.user?.login ?? ""} · ${it.comments} comment${ - it.comments === 1 ? "" : "s" - } · ${relTimeISO(it.createdAt)}`, - ), - ); - - const actions = el("div", "gh-detail-actions"); - - const editBtn = el("button", "mini-btn"); - editBtn.append(glyph("edit"), span("Edit")); - editBtn.addEventListener("click", () => void editIssue(detail, it, wrap, nav)); - - const labelsBtn = el("button", "mini-btn"); - labelsBtn.append(glyph("tag"), span("Labels")); - labelsBtn.addEventListener("click", () => void labelsMenu(labelsBtn, detail, it, wrap, nav)); - - const assignBtn = el("button", "mini-btn"); - assignBtn.append(glyph("organization"), span("Assignees")); - assignBtn.addEventListener("click", () => void editAssignees(detail, it, assignees, wrap, nav)); - - const milestoneBtn = el("button", "mini-btn"); - milestoneBtn.append(glyph("milestone"), span("Milestone")); - milestoneBtn.addEventListener("click", () => void milestoneMenu(milestoneBtn, detail, it, wrap, nav)); + const main = el("div", "det-main det-main-drawer"); + container.replaceChildren(main); + buildDetail({ main, rail: null, topActions: null, d, nav, reload }); +} - const closing = it.state === "open"; - const stateBtn = el("button", "mini-btn"); - stateBtn.append(glyph(closing ? "issue-closed" : "issue-opened"), span(closing ? "Close" : "Reopen")); - stateBtn.addEventListener("click", () => - void changeState(detail, it.number, closing ? "closed" : "open", stateBtn, wrap, nav), - ); +interface DetailCtx { + main: HTMLElement; + /** null = drawer variant (compact inline props instead of the rail). */ + rail: HTMLElement | null; + /** null = drawer variant (actions render above the title instead). */ + topActions: HTMLElement | null; + d: IssueDetail; + nav: SectionNav; + reload: () => void; +} - // A de-emphasized icon-only escape hatch — everything here is doable in-app, so - // "Open on GitHub" is a secondary affordance, not a peer of the real actions. - const openBtn = el("button", "mini-btn gh-icon-btn"); - openBtn.append(glyph("link-external")); - openBtn.title = "Open this issue on GitHub"; - openBtn.setAttribute("aria-label", "Open this issue on GitHub"); - openBtn.addEventListener("click", () => window.open(it.htmlUrl, "_blank")); +function buildDetail(ctx: DetailCtx): void { + const { main, rail, d, nav, reload } = ctx; + const it = d.issue; + main.replaceChildren(); + rail?.replaceChildren(); - // ✨ AI: analyze the issue (problem / cause / suggested approach). The same - // issue context powers the "Draft a reply" chip on the composer below. - // Bound the context: issue/comment bodies are arbitrary user content, so cap how - // much we send (keep the most-recent comments) to stay under the model's window. + // Bounded AI context (issue + most-recent comments). const aiCtx = (): string => { const MAX_COMMENTS = 20; - const clip = (s: string, n: number): string => (s.length > n ? `${s.slice(0, n)}\n…(truncated)` : s); + const clip = (s: string, max: number): string => (s.length > max ? `${s.slice(0, max)}\n…(truncated)` : s); const recent = d.comments.slice(-MAX_COMMENTS); const omitted = d.comments.length - recent.length; const comments = recent.map((c) => `${c.author?.login ?? "?"}: ${clip(c.body, 1500)}`).join("\n\n"); const omittedNote = omitted > 0 ? `(${omitted} earlier comment${omitted === 1 ? "" : "s"} omitted)\n\n` : ""; return `Issue: ${it.title}\n\n${clip(it.body ?? "", 4000)}${comments ? `\n\nComments:\n${omittedNote}${comments}` : ""}`; }; + + // ── action cluster (detail top bar; drawer renders it above the title) ── + const actions: HTMLElement[] = []; + const analyzeBtn = el("button", "mini-btn ai-mini"); analyzeBtn.hidden = true; analyzeBtn.append(glyph("sparkle"), span("Analyze")); @@ -598,53 +572,208 @@ async function showDetail( }), ); void aiEnabled().then((ok) => (analyzeBtn.hidden = !ok)); + actions.push(analyzeBtn); + + const editBtn = el("button", "mini-btn"); + editBtn.append(glyph("edit"), span("Edit")); + editBtn.addEventListener("click", () => sectionNav?.("issuenew", { number: it.number })); + actions.push(editBtn); - actions.append(editBtn, labelsBtn, assignBtn, milestoneBtn, analyzeBtn, stateBtn, openBtn); - head.append(h, meta, actions); - detail.appendChild(head); + const closing = it.state === "open"; + const stateBtn = el("button", closing ? "btn btn-primary" : "mini-btn"); + stateBtn.append(glyph(closing ? "issue-closed" : "issue-opened"), span(closing ? "Close issue" : "Reopen")); + stateBtn.addEventListener("click", () => + void changeState(it.number, closing ? "closed" : "open", stateBtn, reload), + ); + actions.push(stateBtn); - // Live label chips. - if (it.labels.length) { - const labelRow = el("div", "gh-detail-labels"); - for (const l of it.labels) labelRow.appendChild(labelChip(l.name, l.color)); - detail.appendChild(labelRow); + // The de-emphasized escape hatch: everything above is doable in-app. + const openBtn = el("button", "mini-btn gh-icon-btn"); + openBtn.append(glyph("link-external")); + openBtn.title = "Open this issue on GitHub"; + openBtn.setAttribute("aria-label", openBtn.title); + openBtn.addEventListener("click", () => window.open(it.htmlUrl, "_blank")); + actions.push(openBtn); + + if (ctx.topActions) { + ctx.topActions.replaceChildren(...actions); + } else { + const bar = el("div", "det-tb-actions det-drawer-actions"); + bar.append(...actions); + main.appendChild(bar); + } + + // ── title block ── + const titleRow = el("div", "det-title-row"); + const stKind = issueStateKind(it.state, it.stateReason); + titleRow.appendChild( + statePill( + stKind === "open" ? "Open" : stKind === "not-planned" ? "Closed as not planned" : "Closed", + stKind, + ), + ); + const h = el("h1", "det-title"); + h.append(span(it.title), span(` #${it.number}`, "det-title-num")); + titleRow.appendChild(h); + main.appendChild(titleRow); + + const sub = el("div", "det-sub"); + const author = it.user?.login; + if (author) { + const chip = el("button", "gh-meta-author"); + chip.append(avatar(author, it.user?.avatarUrl ?? null, 18), span(author)); + chip.title = `View @${author}'s profile`; + chip.addEventListener("click", () => + openPeek(memberCard({ login: author, avatarUrl: it.user?.avatarUrl ?? null, htmlUrl: `https://github.com/${author}` })), + ); + sub.appendChild(chip); } - // Assignee chips — avatar + @login. Prefer the rich user objects (they carry - // avatar URLs); fall back to the login-only list if a user object is missing. - if (assignees.length) { - const aRow = el("div", "gh-detail-assignees"); - aRow.appendChild(span("Assigned:", "gh-assign-label")); - const byLogin = new Map(it.assignees.map((a) => [a.login, a])); - for (const login of assignees) { - const chip = el("span", "gh-assignee-chip"); - const u = byLogin.get(login); - chip.append(avatar(login, u?.avatarUrl ?? null, 18), span(`@${login}`)); - aRow.appendChild(chip); + const subText = el("span"); + subText.textContent = `opened ${relTimeISO(it.createdAt)} · ${it.comments} comment${it.comments === 1 ? "" : "s"}`; + subText.title = absTimeISO(it.createdAt); + sub.appendChild(subText); + main.appendChild(sub); + + // ── properties (rail on the page; inline strip in the drawer) ── + const labelsEdit = (anchor: HTMLElement): void => void labelsMenu(anchor, it, reload); + const assigneesEdit = (): void => void editAssignees(it, d.assignees, reload); + const milestoneEdit = (anchor: HTMLElement): void => void milestoneMenu(anchor, it, reload); + + if (rail) { + const assignProp = propSection("Assignees", { onEdit: assigneesEdit, editTitle: "Edit assignees" }); + if (d.assignees.length) { + const byLogin = new Map(it.assignees.map((a) => [a.login, a])); + for (const login of d.assignees) { + assignProp.body.appendChild( + personChip(login, byLogin.get(login)?.avatarUrl, () => + openPeek(memberCard({ login, avatarUrl: byLogin.get(login)?.avatarUrl ?? null, htmlUrl: `https://github.com/${login}` })), + ), + ); + } + } else { + assignProp.body.appendChild(propAddBtn("Assign", assigneesEdit)); + } + + const labelProp = propSection("Labels", { onEdit: labelsEdit, editTitle: "Edit labels" }); + if (it.labels.length) { + for (const l of it.labels) labelProp.body.appendChild(labelChip(l.name, l.color)); + } else { + labelProp.body.appendChild(propAddBtn("Add labels", () => labelsEdit(labelProp.root))); + } + + // Who closed it, when, and WHY — the three questions a closed issue raises + // and the app used to answer with silence. + if (it.state === "closed" && (it.closedBy || it.closedAt)) { + const closedProp = propSection( + it.stateReason === "not_planned" ? "Closed as not planned" : "Closed", + ); + const cb = it.closedBy; + if (cb) { + closedProp.body.appendChild( + personChip(cb.login, cb.avatarUrl, () => + openPeek(memberCard({ login: cb.login, avatarUrl: cb.avatarUrl, htmlUrl: `https://github.com/${cb.login}` })), + ), + ); + } + if (it.closedAt) { + const when = span(relTimeISO(it.closedAt), "det-prop-when"); + when.title = absTimeISO(it.closedAt); + closedProp.body.appendChild(when); + } + rail.appendChild(closedProp.root); + } + + const msProp = propSection("Milestone", { onEdit: milestoneEdit, editTitle: "Set milestone" }); + if (it.milestone) { + const m = el("span", "det-milestone"); + m.append(glyph("milestone"), span(it.milestone.title)); + msProp.body.appendChild(m); + } else { + msProp.body.appendChild(propAddBtn("Set milestone", () => milestoneEdit(msProp.root))); + } + + const about = propSection("About"); + const fact = (k: string, iso: string): HTMLElement => { + const row = el("div", "det-fact"); + const v = el("span", "det-fact-v"); + v.textContent = relTimeISO(iso); + v.title = absTimeISO(iso); + row.append(span(k, "det-fact-k"), v); + return row; + }; + about.body.classList.add("det-prop-facts"); + about.body.append(fact("Created", it.createdAt), fact("Updated", it.updatedAt)); + + rail.append(assignProp.root, labelProp.root, msProp.root, about.root); + } else { + // Drawer: one compact strip under the title. + const strip = el("div", "det-inline-props"); + for (const l of it.labels) strip.appendChild(labelChip(l.name, l.color)); + if (d.assignees.length) { + const byLogin = new Map(it.assignees.map((a) => [a.login, a])); + strip.appendChild(avatarStack(d.assignees.map((login) => ({ login, avatarUrl: byLogin.get(login)?.avatarUrl })))); } - detail.appendChild(aRow); + if (it.milestone) { + const m = el("span", "det-milestone"); + m.append(glyph("milestone"), span(it.milestone.title)); + strip.appendChild(m); + } + if (strip.childElementCount) main.appendChild(strip); } - // Timeline: the body as the first card, then each comment. + // ── timeline ── const timeline = el("div", "gh-subcontent"); + // Prose nav is scoped to the timeline: titles and rail properties are NOT + // prose, and the linkifier must never touch them (it once underlined the + // whole h1 by grabbing the "#31" suffix). + wireProseNav(timeline, nav); timeline.appendChild( - commentCard(it.user?.login ?? "author", "opened this issue", it.body ?? "", it.createdAt), + commentCard(it.user?.login ?? "author", "opened this issue", it.body ?? "", it.createdAt, { + association: it.authorAssociation, + reactions: it.reactions, + }), ); for (const c of d.comments) { - timeline.appendChild(commentCard(c.author?.login ?? "unknown", "commented", c.body, c.createdAt)); + timeline.appendChild( + commentCard(c.author?.login ?? "unknown", "commented", c.body, c.createdAt, { + updatedAt: c.updatedAt, + association: c.authorAssociation, + reactions: c.reactions, + }), + ); } - detail.appendChild(timeline); + main.appendChild(timeline); - // Comment composer. + // ── composer ── const composer = el("div", "gh-composer"); const ta = document.createElement("textarea"); ta.className = "gh-composer-input"; ta.placeholder = "Leave a comment…"; ta.rows = 4; + ta.value = commentDrafts.get(draftKey(it.number)) ?? ""; + ta.addEventListener("input", () => { + if (ta.value.trim()) commentDrafts.set(draftKey(it.number), ta.value); + else commentDrafts.delete(draftKey(it.number)); + }); const crow = el("div", "gh-composer-actions"); - const send = el("button", "btn btn-primary"); + const send = el("button", "btn btn-primary") as HTMLButtonElement; send.append(glyph("comment"), span("Comment")); - send.addEventListener("click", () => void postComment(detail, it.number, ta, send, wrap, nav)); - // ✨ Draft a reply straight into the composer. + const syncSend = (): void => { + const ready = ta.value.trim().length > 0; + send.disabled = !ready; + send.title = ready ? "Post this comment" : "Write something first"; + }; + ta.addEventListener("input", syncSend); + // ⌘Enter posts, which the shortcut sheet has been promising and neither + // composer implemented — so the one keystroke people reach for after + // typing a comment did nothing at all, on both detail pages. + ta.addEventListener("keydown", (e) => { + if (e.key !== "Enter" || !(e.metaKey || e.ctrlKey)) return; + e.preventDefault(); + if (!send.disabled) send.click(); + }); + syncSend(); + send.addEventListener("click", () => void postComment(it.number, ta, send, reload)); const draftChip = aiChip("Draft a reply", () => void streamInto( "assist", @@ -657,37 +786,28 @@ async function showDetail( void aiEnabled().then((ok) => (draftChip.hidden = !ok)); crow.append(draftChip, send); composer.append(ta, crow); - detail.appendChild(composer); + main.appendChild(composer); } -// ── Mutations (disable trigger → toast → re-fetch) ─────────────────────────── - -async function newIssue(wrap: HTMLElement, nav: (view: string) => void): Promise<void> { - const title = await promptInline("New issue", "Issue title", "", "Next"); - if (!title) return; - // Body is optional — the user can cancel the 2nd step and still create. - const bodyRaw = await promptInline("Issue description (optional)", "Describe the issue…", "", "Create"); - try { - const r = await host.invoke("issue:create", { title, body: bodyRaw ?? "" }); - if (!r.ok) { - toast(r.message ?? "Couldn't create the issue.", "error"); - return; - } - toast(r.number ? `Opened issue #${r.number}.` : "Issue created.", "success"); - issueState = "open"; - renderIssues(wrap, nav); - } catch (e) { - toast(cleanErr(e) || "Couldn't create the issue.", "error"); - } +// ── Mutations (disable trigger → toast → bust cache → re-fetch) ────────────── + +/** The New-issue flow — exported so the command palette can launch it from + * anywhere, not just the Issues toolbar. + * + * It used to open `editForm`, a modal with a title input and a body box. It is + * a routed PAGE now (`views/issueCompose.ts`) with a Write/Preview body that + * fills the window and a sidebar for labels, assignees and the milestone — + * none of which a modal could offer, so all three used to mean a second trip + * through the issue's own page AFTER it had been announced. */ +export function openNewIssue(nav: SectionNav): void { + nav("issuenew"); } async function postComment( - detail: HTMLElement, n: number, ta: HTMLTextAreaElement, btn: HTMLElement, - wrap: HTMLElement, - nav: (view: string) => void, + reload: () => void, ): Promise<void> { const body = ta.value.trim(); if (!body) { @@ -703,7 +823,8 @@ async function postComment( return; } toast("Comment posted.", "success"); - await showDetail(detail, n, wrap, nav); + commentDrafts.delete(draftKey(n)); + reload(); } catch (e) { toast(cleanErr(e) || "Couldn't post the comment.", "error"); } finally { @@ -713,12 +834,10 @@ async function postComment( } async function changeState( - detail: HTMLElement, n: number, state: "open" | "closed", btn: HTMLElement, - wrap: HTMLElement, - nav: (view: string) => void, + reload: () => void, ): Promise<void> { if (state === "closed") { const ok = await confirmDialog({ @@ -736,7 +855,7 @@ async function changeState( return; } toast(state === "closed" ? `Closed issue #${n}.` : `Reopened issue #${n}.`, "success"); - await showDetail(detail, n, wrap, nav); + reload(); } catch (e) { toast(cleanErr(e) || "Couldn't update the issue.", "error"); } finally { @@ -744,50 +863,10 @@ async function changeState( } } -async function editIssue( - detail: HTMLElement, - it: IssueInfo, - wrap: HTMLElement, - nav: (view: string) => void, -): Promise<void> { - // One unified form (title + body together) — not a chain of one-line prompts. - const res = await editForm({ - title: `Edit issue #${it.number}`, - okLabel: "Save", - titleValue: it.title, - titlePlaceholder: "Issue title", - bodyValue: it.body ?? "", - bodyPlaceholder: "Describe the issue…", - }); - if (!res) return; - if (res.title === it.title && res.body === (it.body ?? "")) return; // nothing changed - try { - const r = await host.invoke("issue:edit", { - number: it.number, - title: res.title, - body: res.body, - }); - if (!r.ok) { - toast(r.message ?? "Couldn't edit the issue.", "error"); - return; - } - toast("Issue updated.", "success"); - await showDetail(detail, it.number, wrap, nav); - } catch (e) { - toast(cleanErr(e) || "Couldn't edit the issue.", "error"); - } -} - -async function labelsMenu( - anchor: HTMLElement, - detail: HTMLElement, - it: IssueInfo, - wrap: HTMLElement, - nav: (view: string) => void, -): Promise<void> { +async function labelsMenu(anchor: HTMLElement, it: IssueInfo, reload: () => void): Promise<void> { let repoLabels: RepoLabel[] = []; try { - repoLabels = await host.invoke("issue:labels", undefined); + repoLabels = await gget("issue:labels", undefined, 60000); } catch (e) { toast(cleanErr(e) || "Couldn't load labels.", "error"); return; @@ -796,31 +875,40 @@ async function labelsMenu( toast("This repo has no labels defined.", "info"); return; } - const current = new Set(it.labels.map((l) => l.name)); + const before = new Set(it.labels.map((l) => l.name)); + const picked = new Set(before); + // Labelling is a multi-select: tick as many as you mean, and the whole + // selection is sent once when the menu closes. It used to close — and fire a + // request — after every single tick. openMenu( anchor, repoLabels.map((l) => ({ label: l.name, iconEl: swatch(l.color), - current: current.has(l.name), + checkable: true, + current: picked.has(l.name), onClick: () => { - const next = new Set(current); - if (next.has(l.name)) next.delete(l.name); - else next.add(l.name); - void applyLabels(detail, it.number, [...next], wrap, nav); + if (picked.has(l.name)) picked.delete(l.name); + else picked.add(l.name); }, })), - { searchable: repoLabels.length > 8 }, + { + searchable: repoLabels.length > 8, + // Escape DISCARDS. Everything else about this control was right — ticks + // are batched and sent once, rather than firing a request per tick — but + // `onClose` ran on every dismissal, so the one key that means "back out" + // everywhere else in the app was the key that wrote to GitHub. There was + // no way to change your mind after the first tick. + onClose: (reason) => { + if (reason === "escape") return; + const same = picked.size === before.size && [...picked].every((x) => before.has(x)); + if (!same) void applyLabels(it.number, [...picked], reload); + }, + }, ); } -async function applyLabels( - detail: HTMLElement, - n: number, - labels: string[], - wrap: HTMLElement, - nav: (view: string) => void, -): Promise<void> { +async function applyLabels(n: number, labels: string[], reload: () => void): Promise<void> { try { const r = await host.invoke("issue:setLabels", { number: n, labels }); if (!r.ok) { @@ -828,28 +916,18 @@ async function applyLabels( return; } toast("Labels updated.", "success"); - await showDetail(detail, n, wrap, nav); + reload(); } catch (e) { toast(cleanErr(e) || "Couldn't update labels.", "error"); } } -/** - * A picker of the repo's milestones (open milestones first, with progress as the - * `sub` line) plus a "No milestone" choice to clear. The wire `IssueInfo` does - * not carry the issue's current milestone, so we can't pre-check the active one; - * the detail re-fetches after the set so the result is authoritative regardless. - */ -async function milestoneMenu( - anchor: HTMLElement, - detail: HTMLElement, - it: IssueInfo, - wrap: HTMLElement, - nav: (view: string) => void, -): Promise<void> { +/** A picker of the repo's milestones (open first, progress as the sub line) + * plus a "No milestone" choice to clear. */ +async function milestoneMenu(anchor: HTMLElement, it: IssueInfo, reload: () => void): Promise<void> { let ms: MilestoneInfo[] = []; try { - ms = await host.invoke("issue:milestones", undefined); + ms = await gget("issue:milestones", undefined, 60000); } catch (e) { toast(cleanErr(e) || "Couldn't load milestones.", "error"); return; @@ -858,7 +936,6 @@ async function milestoneMenu( toast("This repo has no milestones defined.", "info"); return; } - // Open milestones first, then closed; within a group keep the API order. const ordered = [...ms].sort((a, b) => (a.state === b.state ? 0 : a.state === "open" ? -1 : 1)); openMenu( anchor, @@ -866,7 +943,8 @@ async function milestoneMenu( { label: "No milestone", icon: "circle-slash", - onClick: () => void applyMilestone(detail, it.number, null, wrap, nav), + current: !it.milestone, + onClick: () => void applyMilestone(it.number, null, reload), }, { separator: true }, ...ordered.map((m) => { @@ -875,8 +953,9 @@ async function milestoneMenu( return { label: m.title, icon: "milestone", + current: it.milestone?.number === m.number, sub: m.state === "closed" ? `closed · ${progress}` : progress, - onClick: () => void applyMilestone(detail, it.number, m.number, wrap, nav), + onClick: () => void applyMilestone(it.number, m.number, reload), }; }), ], @@ -884,13 +963,7 @@ async function milestoneMenu( ); } -async function applyMilestone( - detail: HTMLElement, - n: number, - milestone: number | null, - wrap: HTMLElement, - nav: (view: string) => void, -): Promise<void> { +async function applyMilestone(n: number, milestone: number | null, reload: () => void): Promise<void> { try { const r = await host.invoke("issue:setMilestone", { number: n, milestone }); if (!r.ok) { @@ -898,25 +971,18 @@ async function applyMilestone( return; } toast(milestone == null ? "Milestone cleared." : "Milestone updated.", "success"); - await showDetail(detail, n, wrap, nav); + reload(); } catch (e) { toast(cleanErr(e) || "Couldn't update the milestone.", "error"); } } -async function editAssignees( - detail: HTMLElement, - it: IssueInfo, - current: string[], - wrap: HTMLElement, - nav: (view: string) => void, -): Promise<void> { - // A searchable, avatar-rich picker of repo collaborators (GitHub-style), with - // the current assignees pre-checked. Falls back to a CSV prompt if the - // collaborator list can't be fetched (e.g. limited token scope). +async function editAssignees(it: IssueInfo, current: string[], reload: () => void): Promise<void> { + // A searchable, avatar-rich picker of repo collaborators, pre-checked with the + // current assignees. Falls back to a CSV prompt if the list can't be fetched. let people: RepoCollaborator[] = []; try { - people = await host.invoke("pr:reviewers", undefined); + people = await gget("pr:reviewers", undefined, 60000); } catch { /* fall through to the free-text path */ } @@ -940,7 +1006,7 @@ async function editAssignees( return; } toast("Assignees updated.", "success"); - await showDetail(detail, it.number, wrap, nav); + reload(); } catch (e) { toast(cleanErr(e) || "Couldn't update assignees.", "error"); } diff --git a/apps/desktop/src/renderer/views/jobLog.ts b/apps/desktop/src/renderer/views/jobLog.ts new file mode 100644 index 0000000..c89337c --- /dev/null +++ b/apps/desktop/src/renderer/views/jobLog.ts @@ -0,0 +1,365 @@ +// The job log, as a PAGE. +// +// The log used to live in a pane inside the run detail page, which meant two +// nested scroll contexts and a viewport of whatever was left over. Measured: +// 523px of log inside a 913px window, on a page that itself scrolled 1,048px. +// +// The owner reported it twice: "scrolling the logs is still trash ux, its too +// fast and not easy to use and practical at all" and, after the pane was made +// taller, "scrolling is too fast and the log window is too small, pls do it +// properly, you havent even touched that part." +// +// Making the pane bigger was treating the symptom. A CI log is a document you +// read, sometimes tens of thousands of lines of it, and it needs the window — +// not a box inside a page that also scrolls. So: its own route, the jobs in a +// rail beside it, and the log filling everything else. The chunked loader, the +// live tail and the save-to-Downloads action moved here with it; the run page +// no longer hosts logs at all, which also ends the two-entry-points-leave-the +// -card-in-different-states class of bug. + +import { host } from "../bridge"; +import { el, span, glyph, cleanErr, errorState, skeletonList } from "../ui"; +import { toast } from "../dialogs"; +import { detailPage, disposeOnDetach, type SectionTarget, type SectionNav } from "./common"; +import { createLogPane, type LogPane } from "../logView"; +import { setPageLabel, setPageTarget } from "../navStack"; +import type { WorkflowRunDetail, WorkflowJob } from "../../shared/ipc"; + +/** A job's state as one glyph, so the rail scans vertically. */ +function jobGlyph(j: WorkflowJob): HTMLElement { + const done = j.conclusion || ""; + if (done === "success") return glyph("pass-filled"); + if (done === "failure" || done === "timed_out") return glyph("error"); + if (done === "cancelled" || done === "skipped") return glyph("circle-slash"); + if (j.status === "in_progress") return glyph("sync"); + return glyph("circle-outline"); +} + +function jobClass(j: WorkflowJob): string { + const done = j.conclusion || ""; + if (done === "success") return "is-ok"; + if (done === "failure" || done === "timed_out") return "is-fail"; + if (j.status === "in_progress") return "is-running"; + return "is-idle"; +} + +function jobWhen(j: WorkflowJob): string { + if (!j.startedAt) return j.status === "queued" ? "queued" : ""; + if (!j.completedAt) return "running"; + const s = Math.max(0, Math.round((Date.parse(j.completedAt) - Date.parse(j.startedAt)) / 1000)); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + return m < 60 ? `${m}m ${s % 60}s` : `${Math.floor(m / 60)}h ${m % 60}m`; +} + +/** Everything one open job needs; torn down when another job is picked. */ +interface Session { + jobId: number; + pane: LogPane; + offset: number; + unchangedPolls: number; + alive: boolean; + /** A tail loop is running for this session. A job opened while QUEUED has + * none, and needs one started the moment the runner picks it up. */ + tailing: boolean; +} + +export async function renderJobLog( + wrap: HTMLElement, + nav: SectionNav, + target: SectionTarget | undefined, +): Promise<void> { + const runId = target?.number; + const { view, main, rail, topActions } = detailPage({ + backLabel: "Actions", + crumb: runId ? `Run ${runId}` : "Log", + onBack: () => nav("actions", { number: runId }), + }); + view.classList.add("joblog-view"); + rail.remove(); + wrap.replaceChildren(view); + main.appendChild(skeletonList(3, false)); + + if (!runId) { + main.replaceChildren(errorState("No run", "Nothing was asked for.")); + return; + } + + let d: WorkflowRunDetail | undefined; + try { + d = await host.invoke("actions:runDetail", runId); + } catch (e) { + if (!view.isConnected) return; + main.replaceChildren( + errorState("Couldn't load this run", cleanErr(e) || "GitHub request failed.", () => + void renderJobLog(wrap, nav, target), + ), + ); + return; + } + if (!view.isConnected) return; + if (!d) { + main.replaceChildren(errorState("Couldn't load this run", "The run could not be read.")); + return; + } + + const run = d.run; + const runNo = run.runNumber || run.id; + setPageLabel(`Run #${runNo} log`); + // The crumb names the log you are READING, not just the run — on a matrix + // build "#411" alone leaves the page unable to say which of nine jobs is on + // screen. Updated as jobs are switched, below. + const crumb = view.querySelector(".det-crumb"); + const setCrumb = (jobName?: string): void => { + if (crumb) crumb.textContent = jobName ? `#${runNo} · ${jobName}` : `#${runNo}`; + }; + setCrumb(); + + const split = el("div", "joblog-split"); + const jobsCol = el("div", "joblog-jobs"); + jobsCol.setAttribute("role", "list"); + jobsCol.setAttribute("aria-label", "Jobs in this run"); + const logCol = el("div", "joblog-log"); + split.append(jobsCol, logCol); + main.replaceChildren(split); + + // The failing job first when nothing was asked for: on a failed run that is + // the reason you opened it, and making someone pick it out of a list is a + // step with no decision in it. + let jobs = d.jobs; + const failing = jobs.find((j) => j.conclusion === "failure" || j.conclusion === "timed_out"); + let currentId = target?.jobId ?? failing?.id ?? jobs[0]?.id; + + const rows = new Map<number, HTMLElement>(); + let session: Session | undefined; + /** Cancels the detach watch — see `watchPageDetach`. */ + let stopDetachWatch: (() => void) | undefined; + + /** Status as of the freshest poll — the tail asks before every delta. */ + const statusOf = (id: number): string => jobs.find((j) => j.id === id)?.status ?? ""; + + /** Poll deltas while the job runs; back off to 8s after two quiet polls. */ + const tail = (s: Session): void => { + if (s.tailing) return; + s.tailing = true; + // The pane may have been built for a QUEUED job — `live: false`, Follow + // disabled, no pill — and only now has the runner picked it up. Tell it, + // or it spends the rest of the run unable to follow the output it is + // receiving. + s.pane.setProducing(true); + const step = (): void => { + window.setTimeout(() => { + void (async () => { + if (!s.alive || !view.isConnected) return; + const live = statusOf(s.jobId) === "in_progress"; + try { + const chunk = await host.invoke("actions:jobLogChunk", { + jobId: s.jobId, + offset: s.offset, + }); + if (!s.alive) return; + if (chunk.reset) s.pane.reset(chunk.text, { truncated: chunk.truncated }); + else if (chunk.text) s.pane.append(chunk.text); + s.unchangedPolls = chunk.text ? 0 : s.unchangedPolls + 1; + s.offset = chunk.totalLength; + } catch { + s.unchangedPolls++; + } + if (live) step(); + else { + s.tailing = false; + s.pane.finish(); + } + })(); + }, s.unchangedPolls >= 2 ? 8000 : 4000); + }; + step(); + }; + + /** + * Tear the pane down when this PAGE goes away, not only when the job changes. + * + * `destroy()` was called on a job SWITCH, so leaving the page — Back, the + * rail, a deep link, ⌘[ — left the last session's pane alive: its window + * resize listener still attached, and its whole parsed document (up to + * 200,000 lines, plus the ANSI spans for the window it had rendered) still + * reachable from that listener's closure. Open six runs' logs in a session + * and six of them are held for the life of the window. + * + * A mutation observer, the same shape the PR diff panel uses for the same + * reason: nothing else fires on the way out of a section view. + */ + const watchPageDetach = (): void => { + stopDetachWatch?.(); + stopDetachWatch = disposeOnDetach(view, () => { + stopDetachWatch = undefined; + if (session) { + session.alive = false; + session.pane.destroy(); + session = undefined; + } + }); + }; + + const openJob = async (j: WorkflowJob): Promise<void> => { + // Not once this page has gone. `setPageTarget` below writes into whatever + // history entry is CURRENT, so an openJob that ran after the reader had + // already navigated away would stamp a jobId onto another view's target — + // and that view would then be re-routed with it. + if (!view.isConnected) return; + currentId = j.id; + // Tell the history WHICH job, or a refresh re-routes with the job this page + // was entered on and swaps the reader's output out from under them. + setPageTarget({ jobId: j.id }); + setCrumb(j.name); + for (const [id, row] of rows) { + const on = id === j.id; + row.classList.toggle("is-current", on); + row.setAttribute("aria-current", on ? "true" : "false"); + } + + // One session at a time: the previous pane's tail stops on `alive`, so a + // delta that was already in flight can't paint into the log you replaced. + if (session) { + session.alive = false; + session.pane.destroy(); + } + watchPageDetach(); + const pane = createLogPane({ + fill: true, + // Follow only a job that is still producing. A completed log opens at the + // TOP, where a document starts — it used to slam to the last line before + // you had read a word of it. + live: statusOf(j.id) === "in_progress", + queued: statusOf(j.id) === "queued", + ariaLabel: `Log for ${j.name}`, + onCopy: () => host.invoke("actions:jobLog", { jobId: j.id }), + onDownload: () => { + void host.invoke("actions:saveLog", { jobId: j.id, name: j.name }).then((r) => { + toast( + r.ok ? (r.message ?? "Log saved.") : (r.message ?? "Couldn't save the log."), + r.ok ? "success" : "error", + ); + }); + }, + }); + const s: Session = { jobId: j.id, pane, offset: 0, unchangedPolls: 0, alive: true, tailing: false }; + session = s; + logCol.replaceChildren(pane.el); + // The keyboard belongs IN the log. `detailPage` focuses the Back button on + // every new page, which is right for a page you read top-down and wrong for + // this one: the log's own keys — j/k, n/N between failures, Home, End, / + // for search — all live on the scroller, so the page opened with none of + // them working and no sign of why. Only when the reader has not already + // put the keyboard somewhere themselves. + const here = document.activeElement; + if (!here || here === document.body || view.contains(here)) pane.focusReader(); + + try { + const chunk = await host.invoke("actions:jobLogChunk", { jobId: j.id, offset: 0 }); + if (!s.alive) return; + pane.reset(chunk.text, { truncated: chunk.truncated }); + s.offset = chunk.totalLength; + if (statusOf(j.id) === "in_progress") tail(s); + else pane.finish(); + } catch (e) { + if (!s.alive) return; + logCol.replaceChildren( + errorState("Couldn't load this log", cleanErr(e) || "GitHub request failed.", () => + void openJob(j), + ), + ); + } + }; + + const paintRows = (): void => { + rows.clear(); + jobsCol.replaceChildren(); + for (const j of jobs) { + const row = el("button", `joblog-job ${jobClass(j)}`) as HTMLButtonElement; + row.setAttribute("role", "listitem"); + row.append(jobGlyph(j)); + const meta = el("span", "joblog-job-meta"); + meta.appendChild(span(j.name, "joblog-job-name")); + const when = jobWhen(j); + if (when) meta.appendChild(span(when, "joblog-job-when")); + row.appendChild(meta); + row.title = `${j.name} — ${j.conclusion || j.status}`; + row.setAttribute("aria-label", row.title); + row.classList.toggle("is-current", j.id === currentId); + row.setAttribute("aria-current", j.id === currentId ? "true" : "false"); + row.addEventListener("click", () => { + // "Already reading it" — unless it has since started producing and this + // session never got a tail, in which case re-clicking is the only thing + // the reader can do and it used to do nothing at all. + const stuck = !!session && !session.tailing && statusOf(j.id) === "in_progress"; + if (j.id === currentId && session && !stuck) return; + void openJob(j); + }); + rows.set(j.id, row); + jobsCol.appendChild(row); + } + }; + paintRows(); + + // A live run keeps the rail honest — job states change under you — but the + // LOG is never rebuilt by the poll: it has its own tail, and a repaint that + // reset the reader's scroll position is exactly what a tail must not do. + const pollJobs = (): void => { + if (!jobs.some((j) => j.status === "in_progress" || j.status === "queued")) return; + window.setTimeout(() => { + if (!view.isConnected) return; + host + .invoke("actions:runDetail", runId) + .then((fresh) => { + if (!view.isConnected || !fresh) return; + const sig = JSON.stringify(fresh.jobs.map((j) => [j.id, j.status, j.conclusion])); + const was = JSON.stringify(jobs.map((j) => [j.id, j.status, j.conclusion])); + jobs = fresh.jobs; + if (sig !== was) paintRows(); + // A job you opened while it was QUEUED has no tail: openJob decides + // liveness once, and a queued job takes the "finished document" + // branch. The runner then picks it up, the rail visibly flips to + // in_progress — and the log stays frozen for the rest of the run, + // with a re-click blocked by the "already reading it" guard. The + // poll already knows the moment it changes, so it starts the tail. + if (session?.alive && !session.tailing && statusOf(session.jobId) === "in_progress") { + tail(session); + } + pollJobs(); + }) + .catch(() => pollJobs()); + }, 8000); + }; + pollJobs(); + + const open = jobs.find((j) => j.id === currentId) ?? jobs[0]; + if (open) void openJob(open); + else logCol.replaceChildren(errorState("No jobs", "This run has no jobs to show.")); + + // `j` and `k` walk the rail without leaving the log's keyboard: reading one + // job's failure and then the next is the whole reason a matrix run is open. + view.addEventListener("keydown", (e) => { + if (e.key !== "j" && e.key !== "k") return; + // ⌘K is the command palette, everywhere in this app. Unmodified j/k only — + // otherwise opening the palette from the log page ALSO stepped the rail to + // the previous job, so you came back from the palette looking at a + // different job's output than the one you left. + if (e.metaKey || e.ctrlKey || e.altKey) return; + const t = e.target as HTMLElement | null; + if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return; + const i = jobs.findIndex((x) => x.id === currentId); + const next = jobs[i + (e.key === "j" ? 1 : -1)]; + if (!next) return; + e.preventDefault(); + void openJob(next); + rows.get(next.id)?.scrollIntoView({ block: "nearest" }); + }); + + // The run page is one click away, for the parts of a run that are not the log. + const toRun = el("button", "mini-btn") as HTMLButtonElement; + toRun.append(glyph("list-unordered"), span("Run details")); + toRun.title = "Steps, artifacts and re-run controls for this run"; + toRun.addEventListener("click", () => nav("actions", { number: runId })); + topActions.appendChild(toRun); +} diff --git a/apps/desktop/src/renderer/views/mywork.ts b/apps/desktop/src/renderer/views/mywork.ts new file mode 100644 index 0000000..2369bab --- /dev/null +++ b/apps/desktop/src/renderer/views/mywork.ts @@ -0,0 +1,204 @@ +// My Work — the workday-first page (docs/desktop-redesign.md): everything in +// the current repo that involves YOU, grouped by what it needs from you — +// reviews you were asked for, items assigned to you, your own PRs, mentions. +// Rows navigate straight into the full PR / issue pages. This is the answer to +// "where do I start?" that a pile of GitHub tabs never gives. + +import { + cleanErr, + el, + span, + glyph, + relTimeISO, + absTimeISO, + skeletonList, + errorState, + emptyState, + statBit, + stateLead, + statePill, +} from "../ui"; +import { peek as cachePeek, gget, bust } from "../cache"; +import { + avatarStack, + blankable, + facetBar, + type FacetState, + ghGate, + ghHeader, + searchField, + secRow, + sectionList, + type SectionNav, + type SectionRender, +} from "./common"; +import type { MyWorkItem } from "../../shared/ipc"; + +/** The live filter — survives re-renders like every section's query does. */ +let query = ""; +/** My Work facets (kind / type), kept across refreshes. */ +const myWorkFacets: FacetState = {}; + +const GROUPS: ReadonlyArray<{ kind: MyWorkItem["kind"]; label: string; icon: string }> = [ + { kind: "review-requested", label: "Review requested", icon: "eye" }, + { kind: "assigned", label: "Assigned to you", icon: "person" }, + { kind: "my-prs", label: "Your pull requests", icon: "git-pull-request" }, + { kind: "mentions", label: "Mentions", icon: "mention" }, +]; + +export const renderMyWork: SectionRender = (wrap, nav) => { + void mount(wrap, nav); +}; + +async function mount(wrap: HTMLElement, nav: SectionNav): Promise<void> { + const refresh = (): void => { + bust("github:myWork"); + renderMyWork(wrap, nav); + }; + const gate = await ghGate(wrap, nav, true, refresh); + if (!gate) return; + + const { view, listEl } = sectionList(); + const header = ghHeader("My Work", gate.login, refresh); + header.querySelector(".gh-head-titlewrap")?.appendChild( + searchField({ + placeholder: "Filter my work…", + initial: query, + onInput: (q) => { + query = q; + renderList(); + }, + }), + ); + const tools = el("div", "gh-head-tools"); + header.querySelector(".gh-acct")?.before(tools); + view.append(header, listEl); + wrap.replaceChildren(view); + + let items: MyWorkItem[] | undefined = cachePeek("github:myWork", undefined); + + // "Just the review requests" is the most common thing to want here; the + // group headings already exist, so the facet just narrows to one of them. + const facets = facetBar<MyWorkItem>({ + specs: [ + { + key: "kind", + label: "Why it's here", + icon: "list-filter", + anyLabel: "Any reason", + options: GROUPS.map((g) => ({ value: g.kind, label: g.label, icon: g.icon })), + predicate: (it, v) => it.kind === v, + }, + { + key: "type", + label: "Kind", + icon: "git-pull-request", + anyLabel: "Issues and PRs", + options: [ + { value: "pr", label: "Pull requests", icon: "git-pull-request" }, + { value: "issue", label: "Issues", icon: "issues" }, + ], + predicate: (it, v) => it.type === v, + }, + ], + state: myWorkFacets, + items: items ?? [], + onChange: () => renderList(), + }); + tools.appendChild(facets.el); + if (!items) listEl.replaceChildren(skeletonList(6)); + + const buildRow = (it: MyWorkItem): HTMLElement => { + const kind = + it.type === "pr" ? (it.draft ? "draft" : it.state === "closed" ? "closed" : "open-pr") + : it.state === "closed" ? "closed" : "open"; + // Same meta contract as Issues and PRs — this list showed the author as + // bare text while its siblings showed an avatar in the same slot. + const meta: HTMLElement[] = []; + if (it.author) meta.push(avatarStack([{ login: it.author }], 1, 18, "Author")); + meta.push(blankable(statBit("comment", it.comments), it.comments > 0)); + const row = secRow({ + lead: stateLead(kind), + num: `#${it.number}`, + title: it.title, + titleSuffix: it.draft ? [statePill("Draft", "draft")] : [], + meta, + time: relTimeISO(it.updatedAt), + timeTitle: it.updatedAt ? `Updated ${absTimeISO(it.updatedAt)}` : undefined, + ariaLabel: `${it.type === "pr" ? "Pull request" : "Issue"} #${it.number}: ${it.title}`, + // `from` so the detail's back button and Escape return to My Work rather + // than dumping you in the Issues or Pull Requests list, which is a + // grouped view you were never in. + onOpen: () => + nav(it.type === "pr" ? "prs" : "issues", { + number: it.number, + from: { view: "mywork", label: "My Work" }, + }), + }); + row.dataset.num = String(it.number); + return row; + }; + + const renderList = (): void => { + if (!items) return; + const q = query.trim().toLowerCase(); + const shown = items.filter( + (it) => + facets.passes(it) && + (q ? `${it.title} #${it.number} ${it.author ?? ""}`.toLowerCase().includes(q) : true), + ); + facets.sync(items); + header.setCount?.(shown.length, items.length); + listEl.replaceChildren(); + if (items.length === 0) { + listEl.appendChild( + emptyState( + "All clear", + "Nothing in this repository needs you right now — no review requests, assignments, or mentions.", + { icon: "pass" }, + ), + ); + return; + } + if (shown.length === 0) { + listEl.appendChild( + emptyState( + "No matches", + query.trim() ? `Nothing matches “${query.trim()}”.` : "Nothing matches these filters.", + { + icon: "search", + anchor: "inline", + secondary: facets.activeCount() > 0 + ? { label: "Clear filters", icon: "clear-all", onClick: () => facets.clear() } + : undefined, + }, + ), + ); + return; + } + for (const g of GROUPS) { + const group = shown.filter((it) => it.kind === g.kind); + if (!group.length) continue; + const head = el("div", "mywork-group"); + head.append(glyph(g.icon), span(g.label), span(String(group.length), "mywork-group-count")); + listEl.appendChild(head); + for (const it of group) listEl.appendChild(buildRow(it)); + } + }; + + if (items) renderList(); + + try { + const fresh = await gget("github:myWork", undefined, 30000); + if (!view.isConnected) return; + items = fresh; + renderList(); + } catch (e) { + if (!view.isConnected) return; + if (!items) { + listEl.replaceChildren( + errorState("Couldn't load your work", cleanErr(e) || "GitHub request failed.", refresh), + ); + } + } +} diff --git a/apps/desktop/src/renderer/views/notifications.ts b/apps/desktop/src/renderer/views/notifications.ts index 5ce8ee8..ccbe8ae 100644 --- a/apps/desktop/src/renderer/views/notifications.ts +++ b/apps/desktop/src/renderer/views/notifications.ts @@ -22,30 +22,48 @@ import { openMenu, textBtn, ghRow, + subLink, parseGitHubItemUrl, } from "../ui"; -import { toast, confirmDialog } from "../dialogs"; +import { toast, confirmDialog, openModal } from "../dialogs"; import { renderMarkdown } from "../markdown"; -import { ghGate, ghHeader, type SectionRender, type SectionNav } from "./common"; +import { registerLayer } from "../overlays"; +import { openRemoteRepoBrowser } from "../repoBrowser"; +import { + facetBar, + blankable, + ghGate, + segmented, + ghHeader, + harvestValues, + searchField, + wireListNav, + type FacetState, + type SectionRender, + type SectionNav, +} from "./common"; import type { NotificationThread } from "../../shared/ipc"; /** Persisted across re-renders of this view: include already-read threads? */ let notifAll = false; +/** Inbox text query — the only one of the four lists that had no search. */ +let notifQuery = ""; +/** Inbox facets (type / reason / repo), kept across refreshes. */ +const notifFacets: FacetState = {}; /** The dismiss handle for the open notifications popover (so the bell toggles). */ -let closePanel: (() => void) | null = null; +let closePanel: ((restoreFocus?: boolean) => void) | null = null; export const renderNotifications: SectionRender = (wrap, nav) => { void mount(wrap, nav); }; async function mount(wrap: HTMLElement, nav: SectionNav): Promise<void> { + const refresh = (): void => renderNotifications(wrap, nav); // Gate first (NEEDS_REPO = false — the inbox is account-wide). - const gate = await ghGate(wrap, nav, false); + const gate = await ghGate(wrap, nav, false, refresh); if (!gate) return; - const refresh = (): void => renderNotifications(wrap, nav); - // The in-app issue/PR views are scoped to the CURRENT repo, so a notification // can only deep-link in-app when it belongs to that repo (else it's genuinely // another repo and "Open" still goes to GitHub). Resolve the current slug once. @@ -62,15 +80,23 @@ async function mount(wrap: HTMLElement, nav: SectionNav): Promise<void> { // Header: title + signed-in @login + a refresh, then splice in the inbox-wide // action cluster (toggle + mark-all-read) so the chrome matches the other // section views while exposing the actions unique to a list-of-actions view. - const header = ghHeader("Notifications", gate.login, refresh); + const header = ghHeader("Inbox", gate.login, refresh); const actions = el("div", "notif-actions"); - const toggleBtn = el("button", "row-btn notif-toggle"); - toggleBtn.textContent = notifAll ? "Unread only" : "Show all"; - toggleBtn.title = notifAll ? "Show only unread threads" : "Include already-read threads"; - toggleBtn.addEventListener("click", () => { - notifAll = !notifAll; - refresh(); + // A segment shows which mode you are IN. The old button was labelled with the + // action it would perform ("Show all"), styled identically in both states, so + // nothing on screen said whether you were looking at everything or not. + const toggleBtn = segmented<"unread" | "all">({ + options: [ + { value: "unread", label: "Unread" }, + { value: "all", label: "All" }, + ], + value: notifAll ? "all" : "unread", + ariaLabel: "Which notifications to show", + onChange: (v) => { + notifAll = v === "all"; + refresh(); + }, }); const markAllBtn = el("button", "mini-btn notif-markall"); @@ -78,6 +104,68 @@ async function mount(wrap: HTMLElement, nav: SectionNav): Promise<void> { markAllBtn.title = "Mark all read"; markAllBtn.addEventListener("click", () => void markAllRead(markAllBtn, refresh)); + // The facet bar belongs to the full Inbox page. In the 520px bell popover it + // pushed the refresh button onto a second line, leaving a 40px band that was + // 90% empty — and filtering is not what a glance at the bell is for. + const inPopover = !!wrap.closest(".notif-pop"); + // …and because the popover shows no filter UI, it must not APPLY the page's + // either. Sharing the module state meant filtering the Inbox to "review + // requested" silently made the bell hide every other unread thread, with + // nothing on screen to say why or any way to clear it. The bell is a glance + // at what is unread; it keeps its own (empty) filters. + const facetState: FacetState = inPopover ? {} : notifFacets; + let query = inPopover ? "" : notifQuery; + + // Type / reason facets over the fetched inbox — triage is exactly "show me + // only the review requests", and scrolling for them is not triage. + const facets = facetBar<NotificationThread>({ + specs: [ + { + key: "type", + label: "Type", + icon: "inbox", + anyLabel: "Anything", + harvest: harvestValues<NotificationThread>((t) => t.type, notifTypeLabel), + predicate: (t, v) => t.type === v, + }, + { + key: "reason", + label: "Reason", + icon: "question", + anyLabel: "Any reason", + harvest: harvestValues<NotificationThread>((t) => t.reason, notifReasonLabel), + predicate: (t, v) => t.reason === v, + }, + { + key: "repo", + label: "Repo", + icon: "repo", + anyLabel: "All repos", + harvest: harvestValues<NotificationThread>((t) => t.repo), + predicate: (t, v) => t.repo === v, + }, + ], + state: facetState, + items: [], + onChange: () => renderThreads(), + }); + + // Every sibling list has a search field; the Inbox's header was a title on + // the far left and a control cluster on the far right with a gap between. + if (!inPopover) { + header.querySelector(".gh-head-titlewrap")?.appendChild( + searchField({ + placeholder: "Search notifications…", + initial: query, + onInput: (q) => { + query = q; + notifQuery = q; + renderThreads(); + }, + }), + ); + } + if (!inPopover) actions.appendChild(facets.el); actions.append(toggleBtn, markAllBtn); // ghHeader returns a flex row: [title] [.gh-acct]. Insert the action cluster // just before the account block so it reads: title … [actions] @login ↻. @@ -90,6 +178,21 @@ async function mount(wrap: HTMLElement, nav: SectionNav): Promise<void> { view.appendChild(body); wrap.replaceChildren(view); + // Keyboard triage: ↑/↓ move, Enter opens (wireListNav), and `e` archives — + // marks the focused row's thread read, the way every inbox does it. + wireListNav(body, ".notif-row"); + const rowThreads = new Map<HTMLElement, NotificationThread>(); + body.addEventListener("keydown", (ev) => { + if (ev.key !== "e" || ev.metaKey || ev.ctrlKey || ev.altKey) return; + const target = ev.target as HTMLElement | null; + if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return; + const row = (document.activeElement as HTMLElement | null)?.closest?.(".notif-row") as HTMLElement | null; + const t = row ? rowThreads.get(row) : undefined; + if (!row || !t || !t.unread) return; + ev.preventDefault(); + void markRead(t, row, body, refresh, syncCounts); + }); + // Load. body.replaceChildren(skeletonList(6)); let threads: NotificationThread[]; @@ -107,11 +210,75 @@ async function mount(wrap: HTMLElement, nav: SectionNav): Promise<void> { } if (!body.isConnected) return; - // Keep the "Mark all read" affordance honest: nothing unread → nothing to do. - const unreadCount = threads.filter((t) => t.unread).length; - (markAllBtn as HTMLButtonElement).disabled = unreadCount === 0; - // The header count reflects what's actionable: unread threads. - header.setCount?.(unreadCount); + /** + * The counts that describe the whole inbox, not the filtered view of it. + * + * "Mark all read" is honest only if it knows whether anything is unread, and + * the top-bar bell must agree with the panel — the badge said 3 while the + * header said 4, 200px apart. Both are computed from `threads`, which is + * every thread loaded, so a filter can never leak into a global number. + */ + const syncCounts = (): void => { + const unread = threads.filter((t) => t.unread).length; + (markAllBtn as HTMLButtonElement).disabled = unread === 0; + window.dispatchEvent(new CustomEvent("gs:unread", { detail: unread })); + }; + syncCounts(); + facets.sync(threads); + + const renderThreads = (): void => { + const q = query.trim().toLowerCase(); + const shown = threads.filter( + (t) => + facets.passes(t) && + (q + ? `${t.title} ${t.repo} ${notifReasonLabel(t.reason)} ${notifTypeLabel(t.type)}` + .toLowerCase() + .includes(q) + : true), + ); + // Same contract as every other list: the badge counts what's on screen. + header.setCount?.(shown.length, threads.length); + body.replaceChildren(); + if (shown.length === 0) { + const filtered = facets.activeCount() > 0 || !!q; + body.appendChild( + emptyState( + filtered ? "No matching notifications" : notifAll ? "Inbox zero" : "You're all caught up", + filtered + ? q + ? `Nothing in your inbox matches “${query.trim()}”.` + : "Nothing in your inbox matches these filters." + : notifAll + ? "You have no notifications." + : "No unread notifications right now — nothing needs your attention.", + { + icon: filtered ? "filter" : "bell", + // A filtered-empty inbox answers a question you asked in the + // toolbar; an unfiltered-empty one is the whole view's state. + anchor: filtered ? "inline" : "hero", + secondary: facets.activeCount() > 0 + ? { label: "Clear filters", icon: "clear-all", onClick: () => facets.clear() } + : undefined, + }, + ), + ); + return; + } + // (The "N threads · M unread" summary line used to live here; the header + // badge already says how many are shown, so it was the same fact twice.) + // ONE roving tab stop for the list: Tab reaches the Inbox and lands on the + // first thread, ↑/↓ move between them. Every row was `tabIndex = -1`, which + // made arrow traversal work but left the whole list unreachable from the + // keyboard in the first place — Tab skipped straight past it. Every other + // list in the app is enterable; this one was not. + shown.forEach((t, i) => { + const row = notificationRow(t, body, refresh, nav, currentRepo, syncCounts); + rowThreads.set(row, t); + row.tabIndex = i === 0 ? 0 : -1; + body.appendChild(row); + }); + }; if (threads.length === 0) { body.replaceChildren( @@ -126,11 +293,7 @@ async function mount(wrap: HTMLElement, nav: SectionNav): Promise<void> { return; } - body.replaceChildren(); - body.appendChild(notifSummary(threads.length, unreadCount)); - for (const t of threads) { - body.appendChild(notificationRow(t, body, refresh, nav, currentRepo)); - } + renderThreads(); } // ── Top-bar notification center (the bell popover, next to the profile) ──────── @@ -164,11 +327,17 @@ export function openNotificationsPanel( ): void { // Toggle: a second click on the bell (or while open) closes the panel. if (closePanel) { - closePanel(); + closePanel(true); return; } const panel = el("div", "notif-pop"); + // A floating panel of interactive rows that announced itself as a plain div: + // no role, no name, and focus left behind on the bell, so a keyboard user + // could open it and then Tab through the whole page before reaching it. + panel.setAttribute("role", "dialog"); + panel.setAttribute("aria-label", "Notifications"); + panel.tabIndex = -1; const inner = el("div", "notif-pop-inner"); panel.appendChild(inner); document.body.appendChild(panel); @@ -182,25 +351,43 @@ export function openNotificationsPanel( }; const onDoc = (e: MouseEvent): void => { + // While a dialog opened FROM this popover is up, the popover itself is + // `inert` — held back by `holdBackground` like the rest of the page. So a + // click anywhere in that dialog is "outside the panel" by the test below, + // and dismissed the popover the user was working from. Asking whether we + // are inert answers it for every layer, present and future, without a + // whitelist of class names to keep up to date. + if (panel.hasAttribute("inert")) return; const t = e.target as Node; if (!panel.contains(t) && t !== anchor && !anchor.contains(t)) close(); }; const onKey = (e: KeyboardEvent): void => { if (e.key === "Escape") { + // Whatever opened AFTER the popover owns Escape — otherwise one press + // closed the dialog AND the popover that opened it. Asked as "am I the + // top layer?"; see `registerLayer`. + // "Nothing opened after me." See `registerLayer`. + if (!layer.isTop()) return; e.preventDefault(); - close(); + close(true); } }; - const close = (): void => { + let closed = false; + const close = (restoreFocus = false): void => { + if (closed) return; + closed = true; + layer.release(); panel.remove(); document.removeEventListener("mousedown", onDoc, true); document.removeEventListener("keydown", onKey, true); window.removeEventListener("resize", position); anchor.setAttribute("aria-expanded", "false"); closePanel = null; + if (restoreFocus && anchor.isConnected) anchor.focus(); onClose?.(); }; closePanel = close; + const layer = registerLayer(close); anchor.setAttribute("aria-haspopup", "dialog"); anchor.setAttribute("aria-expanded", "true"); @@ -213,6 +400,9 @@ export function openNotificationsPanel( }); position(); + // Move into the panel so the keyboard is where the eye is; Escape hands focus + // straight back to the bell. + (panel.querySelector<HTMLElement>("button, [tabindex='0'], a[href]") ?? panel).focus(); setTimeout(() => { position(); document.addEventListener("mousedown", onDoc, true); @@ -230,19 +420,18 @@ export function openExternalItem(o: { kind: "issue" | "pull"; htmlUrl: string; }): void { - const overlay = el("div", "modal-overlay"); const card = el("div", "modal-card modal-card-form ext-item"); - overlay.appendChild(card); - document.body.appendChild(overlay); - const close = (): void => { - overlay.remove(); - document.removeEventListener("keydown", onKey, true); - }; - const onKey = (e: KeyboardEvent): void => { - if (e.key === "Escape") { e.preventDefault(); close(); } - }; - document.addEventListener("keydown", onKey, true); - overlay.addEventListener("mousedown", (e) => { if (e.target === overlay) close(); }); + card.tabIndex = -1; + let close = (): void => {}; + openModal((c) => { + close = c; + return { + card, + focusEl: card, + label: `${o.owner}/${o.repo} #${o.number}`, + onClose: () => {}, + }; + }); card.appendChild(loadingState("Loading…")); void (async () => { @@ -254,7 +443,7 @@ export function openExternalItem(o: { } catch { /* fall through to the unavailable state */ } - if (!overlay.isConnected) return; + if (!card.isConnected) return; card.replaceChildren(); if (!item) { card.appendChild( @@ -314,15 +503,6 @@ export function openExternalItem(o: { })(); } -/** A small count summary above the list (e.g. "12 threads · 3 unread"). */ -function notifSummary(total: number, unread: number): HTMLElement { - const row = el("div", "notif-summary"); - row.appendChild(glyph(unread > 0 ? "bell-dot" : "inbox")); - const parts = `${total} ${total === 1 ? "thread" : "threads"}` + (unread > 0 ? ` · ${unread} unread` : ""); - row.appendChild(span(parts, "notif-summary-text")); - return row; -} - /** One inbox row in the rich `ghRow` shape: an accent-wrapped subject-type icon * (prefixed by an unread dot), a bold/muted title, a `repo · reason · time` meta * line, a subject-type pill, and a hover-revealed Open / Mark-read cluster. @@ -332,49 +512,117 @@ function notificationRow( body: HTMLElement, refresh: () => void, nav: SectionNav, - currentRepo?: string, + currentRepo: string | undefined, + syncCounts: () => void, ): HTMLElement { // Leading: unread dot (when unread) + the subject-type glyph. We reuse the // existing .notif-lead/.notif-dot styling so unread emphasis + the read-state // icon dimming keep working inside the gh-row lead slot. const lead = el("span", "notif-lead"); - if (t.unread) lead.appendChild(el("span", "notif-dot")); + // The unread dot ALWAYS takes its space — hidden, not absent, on read rows. + // Omitting it shifted every read row 14px left of its unread neighbours, so + // the list had two different left edges. + const dot = el("span", "notif-dot"); + if (!t.unread) dot.classList.add("is-read"); + lead.appendChild(dot); lead.appendChild(glyph(notifIcon(t.type))); const when = relTimeISO(t.updatedAt); - const meta = - `${t.repo}` + - (t.reason ? ` · ${notifReasonLabel(t.reason)}` : "") + - (when ? ` · ${when}` : ""); - - const row = ghRow({ - lead, - title: t.title || "(untitled)", - titleSuffix: t.type ? [pill(notifTypeLabel(t.type), "notif-type")] : [], - meta, - metaTitle: t.updatedAt ? `Updated ${absTimeISO(t.updatedAt)}` : undefined, - ariaLabel: `${notifTypeLabel(t.type)} notification: ${t.title || "(untitled)"}${t.unread ? " (unread)" : ""}`, - }); - // ghRow returns a <div> here (no onClick passed). Tag it as an inbox row so the - // read/unread emphasis CSS applies, and as a .list-row so the existing - // `.list-row:hover .row-actions` reveal lights up the Open / Mark-read cluster - // (gh-row-rich's later layout rules still win on padding/radius/alignment). - row.classList.add("notif-row", "list-row"); - if (!t.unread) { - row.classList.add("notif-read"); - row.style.opacity = "0.72"; - } + const aria = `${notifTypeLabel(t.type)} notification: ${t.title || "(untitled)"}${t.unread ? " (unread)" : ""}`; + // The repo name is a door, not a label: browse that repo in-app. + const repoLink = (): HTMLElement => + // The full Explore page, not the peek stack: from an inbox row, "what IS + // this repo?" deserves breadcrumbs, a README and a way to open it. + subLink(t.repo, `Explore ${t.repo} in GitStudio`, () => + nav ? nav("explore", { id: `repo/${t.repo}` }) : openRemoteRepoBrowser(t.repo), + ); - // Open the subject IN-APP. Current-repo issues/PRs deep-link into the full - // Issues/PRs view (you can act on them); OTHER repos open a read-only in-app - // viewer. Only genuinely non-issue/PR subjects (commits/discussions/releases) - // fall back to github.com. + // Two shapes, one behavior: the FULL inbox page gets a dense single line + // (title · type ······ repo · reason · time); the 424px bell POPOVER keeps + // the two-line card, which reads better at that width. + const compact = !!body.closest(".notif-pop"); + let row: HTMLElement; + if (compact) { + const segments: Array<string | HTMLElement> = [repoLink()]; + if (t.reason) segments.push(notifReasonLabel(t.reason)); + if (when) segments.push(when); + row = ghRow({ + lead, + title: t.title || "(untitled)", + titleSuffix: t.type ? [pill(notifTypeLabel(t.type), "notif-type")] : [], + metaSegments: segments, + metaTitle: t.updatedAt ? `Updated ${absTimeISO(t.updatedAt)}` : undefined, + ariaLabel: aria, + }); + } else { + row = el("div", "notif-line"); + row.setAttribute("aria-label", aria); + row.appendChild(lead); + const title = el("span", "notif-line-title"); + title.textContent = t.title || "(untitled)"; + title.title = t.title; + row.appendChild(title); + // The leading glyph already encodes the type; a pill repeating it made + // every row say "Issue" twice. The glyph carries the word on hover. + lead.title = notifTypeLabel(t.type); + lead.setAttribute("aria-label", notifTypeLabel(t.type)); + row.appendChild(el("span", "sec-row-spring")); + const meta = el("span", "sec-row-meta"); + const repo = repoLink(); + repo.classList.add("notif-repo"); + meta.appendChild(repo); + // The reason keeps its column even when absent, or a thread without one + // slid its repo name out of line with the rows around it. + meta.appendChild( + blankable(span(t.reason ? notifReasonLabel(t.reason) : "", "notif-reason"), !!t.reason), + ); + row.appendChild(meta); + const time = el("span", "sec-row-time"); + time.textContent = when; + if (t.updatedAt) time.title = `Updated ${absTimeISO(t.updatedAt)}`; + row.appendChild(time); + } + // .notif-row = the inbox read/unread emphasis; .list-row = the shared + // `.list-row:hover .row-actions` reveal for the Open / Mark-read cluster. + row.classList.add("notif-row", "list-row"); + if (!t.unread) row.classList.add("notif-read"); + + // Open the subject IN-APP. + // + // The thread now carries what it's ABOUT (subjectKind + subjectNumber/Sha, + // parsed from the API subject url in github/maps.ts), so Releases and + // Commits — which used to bounce to github.com because their web urls don't + // look like issue links — route in-app too: + // issue / pull → this repo's Issues|PRs page, or the read-only viewer + // for another repo + // release → the Releases detail page (subjectNumber IS the id) + // commit → reveal in the graph + // Anything genuinely unsupported (Discussions) still opens on GitHub, and + // the row SAYS so rather than promising an in-app open. const item = parseGitHubItemUrl(t.htmlUrl); - const sameRepo = item && currentRepo && item.repo === currentRepo; - const openable = !!item; // any issue/PR can be read in-app + const threadRepo = (t.repo || "").toLowerCase(); + const sameRepo = !!currentRepo && threadRepo === currentRepo; + const kind = t.subjectKind; + const inAppRelease = sameRepo && kind === "release" && t.subjectNumber != null; + const inAppCommit = sameRepo && kind === "commit" && !!t.subjectSha; + const openable = !!item || inAppRelease || inAppCommit; const open = (): void => { - if (sameRepo && item) { - nav(item.kind, { number: item.number }); + // Every in-app open carries where it came from. Release and commit threads + // did not, so opening one from the Inbox retitled the back button + // "Releases", switched the rail under you, and Escape put you in a list you + // had never opened — with the thread you were reading nowhere in sight. + const origin = { view: "notifications", label: "Inbox" }; + if (inAppRelease) { + nav("releases", { number: t.subjectNumber, from: origin }); + } else if (inAppCommit) { + // No `from` here on purpose: Commits is a SECTION, not a detail page, so + // there is no back bar to retitle — going back is the nav history's job + // (⌘[), exactly as it is for every other section-to-section move. + nav("commit", { sha: t.subjectSha }); + } else if (item && sameRepo) { + // Same as My Work: back and Escape belong to the Inbox, not to whichever + // section happens to own the thread's subject. + nav(item.kind, { number: item.number, from: origin }); } else if (item) { const [owner, repo] = item.repo.split("/"); openExternalItem({ owner, repo, number: item.number, kind: item.kind === "prs" ? "pull" : "issue", htmlUrl: t.htmlUrl }); @@ -387,25 +635,69 @@ function notificationRow( // Right-side action cluster (hover-revealed via .row-actions, like other rows). const acts = el("div", "row-actions"); - acts.appendChild(textBtn("Open", openable ? "Open in GitStudio" : "Open the subject on GitHub", open)); + // NAME the thread, not just the verb. This is the densest list of identical + // row actions in the app — eleven buttons announcing "Open" seven times and + // "Mark read" four — and the one list with a roving tab stop, so for every + // row but the first these buttons are the ONLY things Tab lands on. + acts.appendChild( + textBtn( + "Open", + openable ? "Open in GitStudio" : "Open the subject on GitHub", + open, + false, + t.title, + ), + ); if (t.unread) { acts.appendChild( - textBtn("Mark read", "Mark this thread as read", () => void markRead(t, row, body, refresh)), + textBtn( + "Mark read", + "Mark this thread as read", + () => void markRead(t, row, body, refresh, syncCounts), + false, + t.title, + ), ); } row.appendChild(acts); // Whole-row click opens the subject (matches Actions / Projects rows). The // action buttons stopPropagation (textBtn does), so they don't double-fire. + // Every other list in the app builds a row you can reach and operate; these + // were the only ones that were not. They already carried the hover, the + // pointer cursor and an accessible NAME — but no role, no tab stop and no + // keys, so a keyboard user could read the Inbox and not open anything in it. + // The row holds its own action buttons, so it takes the app's documented + // div[role="button"] shape rather than becoming a <button>. + row.setAttribute("role", "button"); + row.tabIndex = 0; + row.classList.add("is-clickable"); row.addEventListener("click", open); + row.addEventListener("keydown", (e) => { + if (e.target !== row) return; // the row's own buttons keep their keys + if (e.key !== "Enter" && e.key !== " ") return; + e.preventDefault(); + open(); + }); // Right-click → a context menu mirroring the row actions. row.addEventListener("contextmenu", (e) => { e.preventDefault(); openMenu(row, [ - { label: "Open on GitHub", icon: "link-external", onClick: open }, + openable + ? { label: "Open in GitStudio", icon: "arrow-right", onClick: open } + : { label: "Open on GitHub", icon: "link-external", onClick: open }, + ...(openable && t.htmlUrl + ? [ + { + label: "Open on GitHub", + icon: "link-external", + onClick: () => window.open(t.htmlUrl, "_blank", "noopener"), + }, + ] + : []), ...(t.unread - ? [{ label: "Mark as read", icon: "mail-read", onClick: () => void markRead(t, row, body, refresh) }] + ? [{ label: "Mark as read", icon: "mail-read", onClick: () => void markRead(t, row, body, refresh, syncCounts) }] : []), ]); }); @@ -419,6 +711,8 @@ async function markRead( row: HTMLElement, body: HTMLElement, refresh: () => void, + /** Recompute the counts from the FULL thread list — see below. */ + syncCounts: () => void, ): Promise<void> { if (row.classList.contains("is-busy")) return; row.classList.add("is-busy"); @@ -429,36 +723,30 @@ async function markRead( return; } t.unread = false; + // Whichever filter is on, the row stays where it is and simply changes + // style. Under "Unread only" it used to be REMOVED on the spot: the list + // collapsed under the pointer, and the next row's own Mark-read button slid + // into the exact pixel you had just clicked — so a second click marked a + // thread you never chose. It leaves on the next refresh instead, which is + // when you are looking at the list rather than at one row in it. + row.classList.add("notif-read"); + // The dot is the read/unread MARKER, not decoration: removing it collapsed + // the 14px lead slot (so the row's text jumped left of every other row's) + // and destroyed the only thing the unread tally could be counted from. + row.querySelector(".notif-dot")?.classList.add("is-read"); + row.querySelectorAll<HTMLElement>(".row-actions .row-btn").forEach((b) => { + if (b.textContent === "Mark read") b.setAttribute("hidden", ""); + }); if (!notifAll) { - // "Unread only" filter active → the row no longer belongs; drop it. - row.remove(); - if (body.querySelectorAll(".notif-row").length === 0) refresh(); - } else { - // "Show all" → flip the row to its read style in place: recede it, drop - // the unread dot, and remove the now-irrelevant "Mark read" action. - row.classList.add("notif-read"); - row.style.opacity = "0.72"; - row.querySelector(".notif-dot")?.remove(); - row.querySelectorAll<HTMLElement>(".row-actions .row-btn").forEach((b) => { - if (b.textContent === "Mark read") b.remove(); - }); - } - // Keep the summary + "Mark all read" in sync after the in-place change. - const unread = body.querySelectorAll(".notif-dot").length; - const total = body.querySelectorAll(".notif-row").length; - const sumEl = document.querySelector(".notif-summary-text"); - if (sumEl) { - sumEl.textContent = - `${total} ${total === 1 ? "thread" : "threads"}` + (unread > 0 ? ` · ${unread} unread` : ""); - } - const markAll = document.querySelector<HTMLButtonElement>(".notif-markall"); - if (markAll) markAll.disabled = unread === 0; - // Keep the header's unread count pill honest after the in-place change. - const countPill = document.querySelector<HTMLElement>(".gh-head-count"); - if (countPill) { - countPill.textContent = String(unread); - countPill.hidden = false; + row.classList.add("notif-leaving"); + row.title = "Marked read — leaves this list on the next refresh"; } + // Counting is the caller's job, because only the caller can see the whole + // inbox. Counting the DOM here counted the RENDERED rows — i.e. whatever + // survived the current filter — so marking one thread read while filtered + // to a single repo wrote that repo's unread total into the global bell + // badge, and rewrote the header's "12 of 40" as "shown of shown". + syncCounts(); toast("Marked as read.", "success"); } catch (e) { toast(cleanErr(e) || "Couldn't mark the notification read.", "error"); @@ -514,7 +802,7 @@ function notifIcon(type: string): string { } /** A short human label for a subject type. */ -function notifTypeLabel(type: string): string { +export function notifTypeLabel(type: string): string { switch (type) { case "PullRequest": return "PR"; @@ -532,7 +820,7 @@ function notifTypeLabel(type: string): string { } /** A human label for GitHub's notification `reason`. */ -function notifReasonLabel(reason: string): string { +export function notifReasonLabel(reason: string): string { switch (reason) { case "assign": return "assigned"; diff --git a/apps/desktop/src/renderer/views/orgs.ts b/apps/desktop/src/renderer/views/orgs.ts index dfd84b1..44bf0ec 100644 --- a/apps/desktop/src/renderer/views/orgs.ts +++ b/apps/desktop/src/renderer/views/orgs.ts @@ -1,11 +1,13 @@ -// The Organizations section view (read-only). Pick an org from a searchable -// dropdown (with avatars) in the title bar, and its detail pane — Repositories / -// Teams / Members sub-tabs, each lazy-loaded on demand — fills the whole pane -// below. No left list pane, so the detail gets the full width. +// The Organizations section view. Pick an org from a searchable dropdown (with +// avatars) in the title bar, and its detail pane — Repositories / Teams / +// Members sub-tabs, each lazy-loaded on demand — fills the whole pane below. +// No left list pane, so the detail gets the full width. // -// Read-only by design (the "Mostly read v1" bar): the only interactions are -// open-on-GitHub (window.open) and copy-to-clipboard. There is no create/update/ -// delete, so no confirms or mutation toasts beyond the copy feedback. +// Every row is browsable IN-APP: a repo opens a peek (full record + Clone… +// straight into the app), a team opens a peek listing its members (drillable to +// their profiles), a member opens their profile peek. "Open on GitHub" stays +// available on each peek as a deliberate action — a plain click never leaves +// the app any more. Still read-only toward GitHub (no create/update/delete). // // Orgs are USER-scoped, not repo-scoped, so this view gates on the token only // (ghGate with needsRepo=false) — it works even when the open repo's origin is @@ -25,10 +27,31 @@ import { relTimeISO, absTimeISO, span, + textBtn, type MenuItem, } from "../ui"; -import { ghGate, ghHeader, headerPicker, type SectionRender } from "./common"; -import type { OrgInfo, OrgMember, OrgRepo, OrgTeam } from "../../shared/ipc"; +import { + openPeek, + peekChip, + peekMetaGrid, + peekSection, + type PeekCard, +} from "../peek"; +import { openCloneDialog } from "../cloneDialog"; +import { repoDirCard } from "../repoBrowser"; +import { openGhRepoInApp, openGhRepoChooseLocation } from "../ghOpen"; +import { peek as cachePeek, gget, bust } from "../cache"; +import { + ghGate, + ghHeader, + headerPicker, + searchField, + wireListNav, + type SectionNav, + type SectionRender, + subTabs, +} from "./common"; +import type { GhUserInfo, OrgInfo, OrgMember, OrgRepo, OrgRepoDetail, OrgTeam } from "../../shared/ipc"; // ── In-session state (in-memory only, like prSubTab — not persisted) ────────── @@ -36,6 +59,10 @@ import type { OrgInfo, OrgMember, OrgRepo, OrgTeam } from "../../shared/ipc"; let selectedOrg: string | undefined; /** The active detail sub-tab; persists across orgs within a session. */ let orgSubTab: SubTabId = "repos"; +/** The live filter over the active sub-tab (a big org has hundreds of repos). */ +let query = ""; +/** Re-renders the active sub-tab — the search field's hook into the detail. */ +let rerenderActiveTab: (() => void) | null = null; /** * A monotonically increasing token. Every full render bumps it; in-flight async @@ -53,12 +80,23 @@ type SubTabId = "repos" | "teams" | "members"; * codicon when the URL is missing or fails to load — so a null avatar or a * CSP-blocked image never leaves a broken-image glyph in the UI. */ +/** The ORGANIZATION's own avatar. People use {@link avatar} instead — its + * fallback is initials, where this one's is the three-person org glyph, which + * on a member row said "this person is an organization". */ function orgAvatar(url: string | null, alt: string, size = 18): HTMLElement { - if (!url) { + // The fallback has to be the size that was ASKED for. It wasn't: the header + // requests 44px and got a bare 16px codicon in a 44px hole whenever the + // image was missing or failed to load, so the identity block collapsed + // around it. `avatar()` in ui.ts has always sized its own fallback. + const fallback = (): HTMLElement => { const g = glyph("organization"); g.classList.add("gh-avatar-fallback"); + g.style.width = `${size}px`; + g.style.height = `${size}px`; + g.style.fontSize = `${Math.round(size * 0.62)}px`; return g; - } + }; + if (!url) return fallback(); const img = document.createElement("img"); img.className = "gh-avatar"; img.src = url; @@ -69,25 +107,46 @@ function orgAvatar(url: string | null, alt: string, size = 18): HTMLElement { img.style.width = `${size}px`; img.style.height = `${size}px`; img.addEventListener("error", () => { - const g = glyph("organization"); - g.classList.add("gh-avatar-fallback"); - img.replaceWith(g); + img.replaceWith(fallback()); }); return img; } // ── The section entry point ─────────────────────────────────────────────────── +/** The routed nav, kept module-level so deep hover actions (Browse → the full + * Explore repository page) can reach it without threading it through every + * row builder. */ +let sectionNav: SectionNav | undefined; + +/** + * Give the person peek a router, from wherever the app happens to be. + * + * `memberCard` is opened by every person chip in the app — an issue's author, + * a reviewer, a commit's committer — and its PRIMARY action routes into + * Explore. But `sectionNav` was only ever set by `renderOrgs`, so until you had + * visited Organizations in that session the app's most-reachable primary button + * did nothing at all: no route, no error, no toast. Set once from the router's + * own mount point, so it is there before any peek can open. + */ +export function setPeekNav(nav: SectionNav): void { + sectionNav = nav; +} + export const renderOrgs: SectionRender = (wrap, nav) => { + sectionNav = nav; void mount(wrap, nav); }; async function mount(wrap: HTMLElement, nav: (view: string) => void): Promise<void> { - const refresh = (): void => renderOrgs(wrap, nav); + const refresh = (): void => { + bust("orgs"); + renderOrgs(wrap, nav); + }; // Gate first — orgs are user-scoped, so needsRepo stays false (works even when // the open repo isn't on github.com). On no token → ghGate renders the prompt. - const gate = await ghGate(wrap, nav, false); + const gate = await ghGate(wrap, nav, false, refresh); if (!gate) return; const gen = ++renderGen; @@ -95,23 +154,37 @@ async function mount(wrap: HTMLElement, nav: (view: string) => void): Promise<vo const header = ghHeader("Organizations", gate.login, refresh); const view = el("div", "gh-view"); view.appendChild(header); + // The live filter over whichever sub-tab is showing (repos / teams / members). + header.querySelector(".gh-head-titlewrap")?.appendChild( + searchField({ + // Was "Filter repos, teams, members…" — clipped to "Filter repos, teams, mer". + placeholder: "Filter this organization…", + initial: query, + onInput: (q) => { + query = q; + rerenderActiveTab?.(); + }, + }), + ); // One full-width pane: the selected org's detail (head + sub-tabs) lives here. const detail = el("div", "gh-detail gh-solo"); view.appendChild(detail); wrap.replaceChildren(view); - detail.replaceChildren(loadingState()); - let orgs: OrgInfo[]; + let orgs: OrgInfo[] | undefined = cachePeek("orgs:list", undefined); + if (!orgs) detail.replaceChildren(loadingState()); try { - orgs = await host.invoke("orgs:list", undefined); + orgs = await gget("orgs:list", undefined, 60000); } catch (e) { if (gen !== renderGen) return; - detail.replaceChildren( - errorState("Couldn't load organizations", cleanErr(e) || "GitHub request failed.", refresh), - ); - return; + if (!orgs) { + detail.replaceChildren( + errorState("Couldn't load organizations", cleanErr(e) || "GitHub request failed.", refresh), + ); + return; + } } - if (gen !== renderGen) return; + if (gen !== renderGen || !orgs) return; header.setCount?.(orgs.length); if (orgs.length === 0) { @@ -156,54 +229,64 @@ async function mount(wrap: HTMLElement, nav: (view: string) => void): Promise<vo function showOrgDetail(detail: HTMLElement, org: OrgInfo, gen: number): void { detail.replaceChildren(); - const head = el("div", "gh-detail-head"); + // Identity first, then what it is, then what you can do with it. The old + // order was name → @login → buttons → a full-width rule → description, so + // the description was separated from the thing it described by the actions + // and a divider, and "Copy login" — a trivial action — led the page. + const head = el("div", "gh-detail-head gh-org-head"); + const identity = el("div", "gh-org-identity"); + identity.appendChild(orgAvatar(org.avatarUrl, org.login, 44)); + const names = el("div", "gh-org-names"); const titleRow = el("div", "gh-detail-title gh-org-title"); - titleRow.append(orgAvatar(org.avatarUrl, org.login, 28), span(org.name || org.login, "")); + titleRow.append(span(org.name || org.login, "")); const meta = el("div", "gh-detail-meta"); meta.textContent = `@${org.login}`; + names.append(titleRow, meta); + if (org.description) { + const d = el("div", "gh-org-desc"); + d.textContent = org.description; + names.appendChild(d); + } + identity.appendChild(names); const actions = el("div", "gh-detail-actions"); const openBtn = el("button", "mini-btn"); - openBtn.append(glyph("link-external"), span("Open on GitHub")); + openBtn.append(glyph("link-external"), span("GitHub")); + openBtn.title = "Open this organization on GitHub"; openBtn.addEventListener("click", () => window.open(org.htmlUrl, "_blank")); - const copyBtn = el("button", "mini-btn"); - copyBtn.append(glyph("copy"), span("Copy login")); + const copyBtn = el("button", "mini-btn gh-icon-btn"); + copyBtn.append(glyph("copy")); + copyBtn.title = `Copy @${org.login}`; + copyBtn.setAttribute("aria-label", copyBtn.title); copyBtn.addEventListener("click", () => void copyText(org.login, "Org login copied.")); actions.append(openBtn, copyBtn); - head.append(titleRow, meta, actions); + head.append(identity, actions); detail.appendChild(head); - if (org.description) { - const d = el("div", "gh-body-md"); - d.textContent = org.description; - detail.appendChild(d); - } - // Sub-tabs: Repositories · Teams · Members. The content is a responsive card // grid so it fills the full-width pane instead of a narrow column of rows. - const subBar = el("div", "gh-subtabs"); const content = el("div", "gh-subcontent gh-org-grid"); + wireListNav(content, ".list-row"); const subDefs: ReadonlyArray<{ id: SubTabId; label: string; icon: string }> = [ { id: "repos", label: "Repositories", icon: "repo" }, { id: "teams", label: "Teams", icon: "organization" }, { id: "members", label: "Members", icon: "organization" }, ]; - const subBtns: HTMLElement[] = []; - const selectSub = (id: SubTabId): void => { - orgSubTab = id; - for (const b of subBtns) b.classList.toggle("active", b.dataset.sub === id); - void renderSubTab(content, org.login, id, gen); - }; - for (const t of subDefs) { - const b = el("button", "gh-subtab"); - b.dataset.sub = t.id; - b.append(glyph(t.icon), span(t.label)); - b.addEventListener("click", () => selectSub(t.id)); - subBtns.push(b); - subBar.appendChild(b); - } - detail.append(subBar, content); + content.id = "gs-org-subpanel"; + const tabs = subTabs({ + tabs: subDefs, + ariaLabel: "Organization sections", + panel: content, + onSelect: (id) => { + orgSubTab = id; + void renderSubTab(content, org.login, id, gen); + }, + }); + const selectSub = tabs.select; + // Hook the header search into whichever tab is active right now. + rerenderActiveTab = () => selectSub(orgSubTab); + detail.append(tabs.el, content); selectSub(orgSubTab); } @@ -221,21 +304,34 @@ async function renderSubTab( id: SubTabId, gen: number, ): Promise<void> { - content.replaceChildren(loadingState()); const retry = (): void => void renderSubTab(content, org, id, gen); + // Each sub-tab holds a different DENSITY of card, and one grid track size + // suited none of them: a lone team card sat in a 330px column with 1200px of + // dead space beside it, and a member card — a 20px avatar and a login — was + // 90% empty at the same width. The tab tells the grid what it is holding. + content.classList.toggle("is-people", id === "members"); + const q = query.trim().toLowerCase(); + const noMatches = (): HTMLElement => + emptyState("No matches", `Nothing matches “${query.trim()}”.`, { + icon: "search", + anchor: "inline", + }); if (id === "repos") { - let repos: OrgRepo[]; + let repos: OrgRepo[] | undefined = cachePeek("orgs:repos", org); + if (!repos) content.replaceChildren(loadingState()); try { - repos = await host.invoke("orgs:repos", org); + repos = await gget("orgs:repos", org, 60000); } catch (e) { if (isStale(org, gen)) return; - content.replaceChildren( - errorState("Couldn't load repositories", cleanErr(e) || "GitHub request failed.", retry), - ); - return; + if (!repos) { + content.replaceChildren( + errorState("Couldn't load repositories", cleanErr(e) || "GitHub request failed.", retry), + ); + return; + } } - if (isStale(org, gen)) return; + if (isStale(org, gen) || !repos) return; content.replaceChildren(); if (repos.length === 0) { content.appendChild( @@ -243,22 +339,34 @@ async function renderSubTab( ); return; } - for (const r of repos) renderRepoRow(content, r); + const shown = q + ? repos.filter((r) => + `${r.name} ${r.description ?? ""} ${r.language ?? ""}`.toLowerCase().includes(q), + ) + : repos; + if (shown.length === 0) { + content.appendChild(noMatches()); + return; + } + for (const r of shown) renderRepoRow(content, r); return; } if (id === "teams") { - let teams: OrgTeam[]; + let teams: OrgTeam[] | undefined = cachePeek("orgs:teams", org); + if (!teams) content.replaceChildren(loadingState()); try { - teams = await host.invoke("orgs:teams", org); + teams = await gget("orgs:teams", org, 60000); } catch (e) { if (isStale(org, gen)) return; - content.replaceChildren( - errorState("Couldn't load teams", cleanErr(e) || "GitHub request failed.", retry), - ); - return; + if (!teams) { + content.replaceChildren( + errorState("Couldn't load teams", cleanErr(e) || "GitHub request failed.", retry), + ); + return; + } } - if (isStale(org, gen)) return; + if (isStale(org, gen) || !teams) return; content.replaceChildren(); if (teams.length === 0) { content.appendChild( @@ -266,22 +374,32 @@ async function renderSubTab( ); return; } - for (const t of teams) renderTeamRow(content, t); + const shown = q + ? teams.filter((t) => `${t.name} ${t.slug} ${t.description ?? ""}`.toLowerCase().includes(q)) + : teams; + if (shown.length === 0) { + content.appendChild(noMatches()); + return; + } + for (const t of shown) renderTeamRow(content, org, t); return; } // members - let members: OrgMember[]; + let members: OrgMember[] | undefined = cachePeek("orgs:members", org); + if (!members) content.replaceChildren(loadingState()); try { - members = await host.invoke("orgs:members", org); + members = await gget("orgs:members", org, 60000); } catch (e) { if (isStale(org, gen)) return; - content.replaceChildren( - errorState("Couldn't load members", cleanErr(e) || "GitHub request failed.", retry), - ); - return; + if (!members) { + content.replaceChildren( + errorState("Couldn't load members", cleanErr(e) || "GitHub request failed.", retry), + ); + return; + } } - if (isStale(org, gen)) return; + if (isStale(org, gen) || !members) return; content.replaceChildren(); if (members.length === 0) { content.appendChild( @@ -289,13 +407,32 @@ async function renderSubTab( ); return; } - for (const u of members) renderMemberRow(content, u); + const shown = q ? members.filter((u) => u.login.toLowerCase().includes(q)) : members; + if (shown.length === 0) { + content.appendChild(noMatches()); + return; + } + for (const u of shown) renderMemberRow(content, u); } // ── Row builders ────────────────────────────────────────────────────────────── function renderRepoRow(content: HTMLElement, r: OrgRepo): void { - const row = el("button", "list-row gh-org-repo"); + // CLICK = BROWSE, the same as clicking a repo anywhere else in the app. + // + // It used to mean CLONE: one click on a row that looks exactly like Explore's + // repo rows downloaded the whole repository to disk and replaced the app's + // entire working context with it. Two identical-looking rows, two very + // different outcomes — and the destructive one was the default, with no + // confirmation and nothing on the row to warn you. Adopting a repository is a + // deliberate act; reading one is not, and reading is what a click means + // everywhere else here. + // + // "Open in GitStudio" keeps the clone, as a named action you choose. + const row = el("div", "list-row gh-org-repo is-clickable"); + row.setAttribute("role", "button"); + row.tabIndex = 0; + row.setAttribute("aria-label", `Browse ${r.fullName}`); row.appendChild(glyph(r.fork ? "repo-forked" : "repo")); const m = el("div", "row-meta"); const t = el("div", "row-meta-title"); @@ -303,21 +440,81 @@ function renderRepoRow(content: HTMLElement, r: OrgRepo): void { const sub = el("div", "row-meta-sub"); const bits = [r.private ? "private" : "public"]; if (r.language) bits.push(r.language); - if (r.stargazersCount) bits.push(`★ ${r.stargazersCount}`); + if (r.stargazersCount) bits.push(`★ ${r.stargazersCount.toLocaleString()}`); if (r.archived) bits.push("archived"); const when = relTimeISO(r.pushedAt); if (when) bits.push(`updated ${when}`); sub.textContent = bits.join(" · "); if (r.pushedAt) sub.title = `Last pushed ${absTimeISO(r.pushedAt)}`; - m.append(t, sub); + // The description, on the card rather than only in its tooltip. + // + // It is the one line that tells you what a repository IS, and the card had + // room for it — 570px wide, carrying a name and a row of facts. Hiding the + // only distinguishing text behind a hover made a directory of repos read as + // a directory of names. + if (r.description) { + const d = el("div", "gh-org-repo-desc"); + d.textContent = r.description; + m.append(t, d, sub); + } else { + m.append(t, sub); + } row.appendChild(m); - if (r.description) row.title = r.description; - // Clicking a repo opens it on GitHub (read v1 — no local clone yet). - row.addEventListener("click", () => window.open(r.htmlUrl, "_blank")); + row.title = r.description + ? `${r.description}\n\nClick to browse ${r.name}` + : `Click to browse ${r.fullName}`; + const browse = (): void => { + if (sectionNav) sectionNav("explore", { id: `repo/${r.fullName}` }); + else openPeek(repoDirCard(r.fullName, "")); + }; + row.addEventListener("click", browse); + row.addEventListener("keydown", (e) => { + // Only the ROW itself: Enter on a nested hover button must activate THAT + // button rather than the row behind it. + if (e.target !== row) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + browse(); + } + }); + // ONE always-visible control, not two buttons revealed on hover. + // + // Measured on the shipping build: the hover pair was 129px wide overlaying + // 128px of a 441px card — roughly a third of the content — and it appeared on + // the same gesture that makes you look at the card, so the description + // vanished exactly when you went to read it. A fade was added to soften that + // and the buttons still won. + // + // An overflow reserves ~26px permanently instead of taking 129px on hover: + // the row never reflows, nothing is hidden by pointing at it, and the actions + // are discoverable without hovering to find out they exist. + const more = el("button", "row-more") as HTMLButtonElement; + more.append(glyph("kebab-vertical")); + more.title = `More actions for ${r.name}`; + more.setAttribute("aria-label", more.title); + more.addEventListener("click", (e) => { + // The row itself browses; the overflow must not also trigger that. + e.stopPropagation(); + openMenu(more, [ + { + label: "Details", + sub: "Stars, licence, activity", + icon: "info", + onClick: () => openRepoPeek(r), + }, + { + label: "Open in GitStudio", + sub: "Clone it if needed", + icon: "repo-clone", + onClick: () => openGhRepoInApp(r.fullName), + }, + ]); + }); + row.appendChild(more); content.appendChild(row); } -function renderTeamRow(content: HTMLElement, t: OrgTeam): void { +function renderTeamRow(content: HTMLElement, org: string, t: OrgTeam): void { const row = el("button", "list-row gh-org-team"); row.appendChild(glyph(t.privacy === "secret" ? "lock" : "organization")); const m = el("div", "row-meta"); @@ -327,18 +524,227 @@ function renderTeamRow(content: HTMLElement, t: OrgTeam): void { sub.textContent = t.description || `@${t.slug}${t.privacy ? " · " + t.privacy : ""}`; m.append(ttl, sub); row.appendChild(m); - if (t.htmlUrl) row.addEventListener("click", () => window.open(t.htmlUrl, "_blank")); + row.setAttribute("aria-haspopup", "dialog"); + row.addEventListener("click", () => openTeamPeek(org, t)); content.appendChild(row); } function renderMemberRow(content: HTMLElement, u: OrgMember): void { const row = el("button", "list-row gh-org-member"); - row.appendChild(orgAvatar(u.avatarUrl, u.login, 20)); + row.appendChild(avatar(u.login, u.avatarUrl, 20, "Member")); const m = el("div", "row-meta"); const t = el("div", "row-meta-title"); t.textContent = u.login; m.appendChild(t); row.appendChild(m); - row.addEventListener("click", () => window.open(u.htmlUrl, "_blank")); + row.setAttribute("aria-haspopup", "dialog"); + row.addEventListener("click", () => openPeek(memberCard(u))); content.appendChild(row); } + +// ── Peeks: the in-app drill-ins behind every row ────────────────────────────── + +/** A homepage/website value rendered as a real link, not inert text. */ +function extLink(url: string): HTMLElement { + const href = /^https?:\/\//i.test(url) ? url : `https://${url}`; + const a = document.createElement("a"); + a.href = href; + a.textContent = url; + a.className = "peek-ext-link"; + a.addEventListener("click", (e) => { + e.preventDefault(); + window.open(href, "_blank"); + }); + return a; +} + +/** The repo peek: the full record, with Clone… as the primary action — the org + * browser stops being a launcher for github.com and becomes a way IN. */ +function openRepoPeek(r: OrgRepo): void { + const chips = [peekChip(r.private ? "private" : "public", r.private ? "warn" : "muted")]; + if (r.fork) chips.push(peekChip("fork", "muted")); + if (r.archived) chips.push(peekChip("archived", "warn")); + openPeek({ + icon: r.fork ? "repo-forked" : "repo", + title: r.name, + chips, + subtitle: r.fullName, + actions: [ + { + label: "Open on GitHub", + icon: "link-external", + onClick: () => window.open(r.htmlUrl, "_blank"), + }, + { + label: "Clone…", + icon: "repo-clone", + title: "Clone this repository and open it in GitStudio", + onClick: (ctx) => { + ctx.close(); + openCloneDialog((root) => void host.invoke("repo:openPath", root), { + url: `${r.htmlUrl}.git`, + }); + }, + }, + { + // Browsing beats bouncing: read the code + README right here, no + // clone, no github.com. + label: "Browse files", + icon: "folder-opened", + onClick: (ctx) => ctx.push(repoDirCard(r.fullName, "")), + }, + { + label: "Choose location…", + icon: "folder-opened", + title: `Pick the folder ${r.fullName} is cloned into, then open it`, + onClick: () => openGhRepoChooseLocation(r.fullName), + }, + { + // THE action: open this like any local repo — Code, Commits, + // Branches, PRs, everything. Reuses an existing clone or makes one + // in the configured clone folder, no questions asked. + label: "Open", + icon: "folder-library", + primary: true, + title: `Open ${r.fullName} in GitStudio as a full repo`, + onClick: () => openGhRepoInApp(r.fullName), + }, + ], + async render(body) { + const d: OrgRepoDetail = await host.invoke("orgs:repoDetail", r.fullName); + body.replaceChildren(); + if (d.description) { + const p = el("p", "peek-desc"); + p.textContent = d.description; + body.appendChild(p); + } + const meta: Array<[string, string | HTMLElement]> = [ + ["Language", d.language ?? ""], + // Real zeros — hiding "0 stars" made new repos look broken, not new. + ["Stars", d.stargazersCount.toLocaleString()], + ["Forks", d.forksCount.toLocaleString()], + ["Open issues", d.openIssuesCount.toLocaleString()], + ["Default branch", d.defaultBranch], + ["License", d.license ?? ""], + ["Last pushed", d.pushedAt ? relTimeISO(d.pushedAt) : ""], + ["Created", d.createdAt ? relTimeISO(d.createdAt) : ""], + ["Homepage", d.homepage ? extLink(d.homepage) : ""], + ]; + body.appendChild(peekMetaGrid(meta)); + if (d.topics.length) { + const topics = el("div", "peek-topics"); + for (const topic of d.topics) topics.appendChild(peekChip(topic, "accent")); + body.appendChild(topics); + } + }, + }); +} + +/** The team peek: description + the member list, each member drillable. */ +function openTeamPeek(org: string, t: OrgTeam): void { + openPeek({ + icon: t.privacy === "secret" ? "lock" : "organization", + title: t.name, + chips: t.privacy ? [peekChip(t.privacy, "muted")] : [], + subtitle: `@${org}/${t.slug}`, + actions: t.htmlUrl + ? [ + { + label: "Open on GitHub", + icon: "link-external", + onClick: () => window.open(t.htmlUrl, "_blank"), + }, + ] + : [], + async render(body, ctx) { + const members = await host.invoke("orgs:teamMembers", { org, slug: t.slug }); + body.replaceChildren(); + if (t.description) { + const p = el("p", "peek-desc"); + p.textContent = t.description; + body.appendChild(p); + } + const { root, body: mbody } = peekSection("Members", members.length); + for (const m of members) { + const row = el("button", "peek-row"); + row.appendChild(avatar(m.login, m.avatarUrl, 22, "Member")); + const main = el("div", "peek-row-main"); + const title = el("div", "peek-row-title"); + title.textContent = m.login; + main.appendChild(title); + row.appendChild(main); + const side = el("div", "peek-row-side"); + const chev = glyph("chevron-right"); + chev.classList.add("peek-row-chev"); + side.appendChild(chev); + row.appendChild(side); + row.addEventListener("click", () => ctx.push(memberCard(m))); + mbody.appendChild(row); + } + if (!members.length) { + const none = el("div", "peek-row"); + none.appendChild(span("No members visible to you.", "peek-row-sub")); + mbody.appendChild(none); + } + body.appendChild(root); + }, + }); +} + +/** The member/profile card — openable directly or pushed from a team peek. */ +export function memberCard(u: OrgMember): PeekCard { + return { + icon: "account", + // Always the PERSON's avatar — initials and a per-login hue when there is + // no image — so the peek shows the same face as the row that opened it + // instead of a generic account glyph. + iconEl: avatar(u.login, u.avatarUrl, 22), + title: u.login, + subtitle: "GitHub profile", + actions: [ + { + label: "Copy login", + icon: "copy", + onClick: () => void copyText(u.login, "Login copied."), + }, + // The peek is a glance; the full page is where their repositories are. + // Every person chip in the app opens this peek, so this one action makes + // every author, assignee and reviewer a doorway into Explore. + { + label: "View full profile", + icon: "person", + primary: true, + title: `Open @${u.login}'s profile page in Explore`, + onClick: (ctx) => { + ctx.close(); + sectionNav?.("explore", { id: `user/${u.login}` }); + }, + }, + { + label: "Open on GitHub", + icon: "link-external", + onClick: () => window.open(u.htmlUrl, "_blank"), + }, + ], + async render(body, ctx) { + const info: GhUserInfo = await host.invoke("github:userInfo", u.login); + body.replaceChildren(); + ctx.retitle(info.name || info.login, `@${info.login}`); + if (info.bio) { + const p = el("p", "peek-desc"); + p.textContent = info.bio; + body.appendChild(p); + } + body.appendChild( + peekMetaGrid([ + ["Company", info.company ?? ""], + ["Location", info.location ?? ""], + ["Website", info.blog ? extLink(info.blog) : ""], + ["Followers", typeof info.followers === "number" ? info.followers.toLocaleString() : ""], + ["Public repos", typeof info.publicRepos === "number" ? info.publicRepos.toLocaleString() : ""], + ["Joined", info.createdAt ? relTimeISO(info.createdAt) : ""], + ]), + ); + }, + }; +} diff --git a/apps/desktop/src/renderer/views/projects.ts b/apps/desktop/src/renderer/views/projects.ts index dff587c..4ae4a7b 100644 --- a/apps/desktop/src/renderer/views/projects.ts +++ b/apps/desktop/src/renderer/views/projects.ts @@ -3,11 +3,11 @@ // cards) fills the whole pane below — no left list pane, so the board gets the // full width. // -// The board is gh-board / gh-col / gh-card. Reads throw → errorState + Retry; the -// move mutation toasts + re-renders. The whole view re-renders by calling -// renderProjects again; module-local `selectedProjectId` makes the picker -// re-select the project the user was on, so a move reloads its board with the -// card in its new column — the same refresh-after-mutation UX the PR view gets. +// Cards move two ways: DRAG one onto another column (optimistic — the card +// lands instantly and reverts on failure), or the kebab's "Move to" menu (the +// keyboard path). Issues peek in a slide-over drawer hosting the full live +// issue detail; PRs open their full workspace. Reads go through the SWR cache; +// the move mutation toasts + busts. import { host } from "../bridge"; import { @@ -16,7 +16,6 @@ import { glyph, pill, relTimeISO, - absTimeISO, loadingState, errorState, emptyState, @@ -24,7 +23,9 @@ import { cleanErr, type MenuItem, } from "../ui"; +import { peek as cachePeek, gget, bust } from "../cache"; import { toast } from "../dialogs"; +import { holdBackground, registerLayer } from "../overlays"; import { ghGate, ghHeader, headerPicker, type SectionRender, type SectionNav } from "./common"; import { renderIssueDetailInto } from "./issues"; import type { ProjectBoard, ProjectInfo, ProjectItem } from "../../shared/ipc"; @@ -38,11 +39,13 @@ export const renderProjects: SectionRender = (wrap, nav) => { }; async function renderProjectsAsync(wrap: HTMLElement, nav: SectionNav): Promise<void> { - const gate = await ghGate(wrap, nav, true); + const refresh = (): void => { + bust("project"); + renderProjects(wrap, nav); + }; + const gate = await ghGate(wrap, nav, true, refresh); if (!gate) return; - const refresh = (): void => renderProjects(wrap, nav); - const header = ghHeader("Projects", gate.login, refresh); const view = el("div", "gh-view"); view.appendChild(header); @@ -51,16 +54,20 @@ async function renderProjectsAsync(wrap: HTMLElement, nav: SectionNav): Promise< view.appendChild(board); wrap.replaceChildren(view); - board.replaceChildren(loadingState()); - let projects: ProjectInfo[]; + let projects: ProjectInfo[] | undefined = cachePeek("project:list", undefined); + if (!projects) board.replaceChildren(loadingState()); try { - projects = await host.invoke("project:list", undefined); + projects = await gget("project:list", undefined, 30000); } catch (e) { - board.replaceChildren( - errorState("Couldn't load projects", cleanErr(e) || "GitHub request failed.", refresh), - ); - return; + if (!view.isConnected) return; + if (!projects) { + board.replaceChildren( + errorState("Couldn't load projects", cleanErr(e) || "GitHub request failed.", refresh), + ); + return; + } } + if (!view.isConnected || !projects) return; header.setCount?.(projects.length); if (projects.length === 0) { @@ -73,6 +80,7 @@ async function renderProjectsAsync(wrap: HTMLElement, nav: SectionNav): Promise< return; } + const all = projects; const select = (p: ProjectInfo): void => { selectedProjectId = p.id; picker.set(glyph("project"), p.title); @@ -83,7 +91,7 @@ async function renderProjectsAsync(wrap: HTMLElement, nav: SectionNav): Promise< // loads its board full-width below. const picker = headerPicker({ onOpen: (anchor) => { - const items: MenuItem[] = projects.map((p) => ({ + const items: MenuItem[] = all.map((p) => ({ label: p.title, sub: `#${p.number} · ${p.itemCount} item${p.itemCount === 1 ? "" : "s"}` + @@ -98,46 +106,69 @@ async function renderProjectsAsync(wrap: HTMLElement, nav: SectionNav): Promise< header.querySelector(".gh-head-titlewrap")?.appendChild(picker.el); // Reopen the project the user was on (else the first) so the board is never a void. - const initial = projects.find((p) => p.id === selectedProjectId) ?? projects[0]; + const initial = all.find((p) => p.id === selectedProjectId) ?? all[0]; select(initial); } /** - * The detail pane: a header (title + meta + "Open on GitHub") above a horizontal + * The board: a header (title + meta + "Open on GitHub") above a horizontal * scroller of columns — one per Status option, plus a leading "No Status" bucket * for unset items. Projects with no Status single-select field fall back to a - * single "All items" column. + * single "All items" column. Columns are drop targets; cards are draggable. */ async function showProjectBoard( detail: HTMLElement, p: ProjectInfo, refresh: () => void, nav: SectionNav, + /** A board already in hand — repaint from it instead of reading at all. + * After a confirmed move the caller HAS the truth: it applied the change + * locally and the server agreed. Going back through the cache to redraw it + * meant `bust("project")` had just deleted the entry, so `cachePeek` missed, + * the whole board was replaced with a spinner, and a successful drag blanked + * the screen and refetched over the network before showing the card where + * the user had already watched it land. */ + have?: ProjectBoard, ): Promise<void> { - detail.replaceChildren(loadingState()); - let board: ProjectBoard; + let board: ProjectBoard | undefined = have ?? cachePeek("project:board", p.id); + if (!board) detail.replaceChildren(loadingState()); try { - board = await host.invoke("project:board", p.id); + if (!have) board = await gget("project:board", p.id, 15000); } catch (e) { - detail.replaceChildren( - errorState("Couldn't load board", cleanErr(e) || "GitHub request failed.", () => - void showProjectBoard(detail, p, refresh, nav), - ), - ); - return; + if (!board) { + detail.replaceChildren( + errorState("Couldn't load board", cleanErr(e) || "GitHub request failed.", () => + void showProjectBoard(detail, p, refresh, nav), + ), + ); + return; + } } + if (!detail.isConnected || !board || selectedProjectId !== p.id) return; + const b = board; detail.replaceChildren(); const head = el("div", "gh-detail-head"); const h = el("div", "gh-detail-title"); h.textContent = p.title; const meta = el("div", "gh-detail-meta"); - meta.textContent = - `#${p.number} · ${board.items.length} item${board.items.length === 1 ? "" : "s"}` + - `${board.field ? "" : " · no Status field"}`; + // BOTH numbers when they disagree. `p.itemCount` is what the project says it + // holds and what the picker directly above prints; `b.items.length` is what + // this board actually loaded. Printing only the second made the header + // contradict the control above it and silently swallowed everything past the + // page — the same "N of M" rule every list in this app follows. + const loaded = b.items.length; + const total = p.itemCount; + const count = + typeof total === "number" && total > loaded + ? `${loaded} of ${total} items` + : `${loaded} item${loaded === 1 ? "" : "s"}`; + meta.textContent = `#${p.number} · ${count}${b.field ? "" : " · no Status field"}`; const actions = el("div", "gh-detail-actions"); + // Labelled, like the Organizations header: a lone unlabelled glyph on its own + // row is a guess, and this is the page's only action. const openBtn = el("button", "mini-btn"); - openBtn.append(glyph("link-external"), span("Open on GitHub")); + openBtn.append(glyph("link-external"), span("GitHub")); openBtn.title = "Open this project on github.com"; openBtn.addEventListener("click", () => window.open(p.url, "_blank")); actions.appendChild(openBtn); @@ -146,47 +177,172 @@ async function showProjectBoard( // Columns = Status options, with a leading "No Status" bucket. With no Status // field, a single "All items" column holds everything. - const columns: { id: string | null; name: string }[] = board.field - ? [{ id: null, name: "No Status" }, ...board.field.options.map((o) => ({ id: o.id, name: o.name }))] + const columns: { id: string | null; name: string }[] = b.field + ? [{ id: null, name: "No status" }, ...b.field.options.map((o) => ({ id: o.id, name: o.name }))] : [{ id: null, name: "All items" }]; + const itemsById = new Map(b.items.map((it) => [it.id, it])); + const cardsById = new Map<string, HTMLElement>(); + const cols = new Map<string | null, { el: HTMLElement; body: HTMLElement; count: HTMLElement }>(); + + /** Keep each column's count pill honest after an optimistic move. */ + const syncCounts = (): void => { + for (const [, c] of cols) { + c.count.textContent = String(c.body.querySelectorAll(".gh-card").length); + } + }; + + /** The optimistic drop: land the card in the target column immediately, then + * confirm with the API; revert (full re-render) + toast on failure. */ + const dropItem = async (itemId: string, targetId: string | null): Promise<void> => { + const field = b.field; + if (!field) return; + const it = itemsById.get(itemId); + const card = cardsById.get(itemId); + const target = cols.get(targetId); + if (!it || !card || !target || it.statusOptionId === targetId) return; + const fromId = it.statusOptionId; + target.body.appendChild(card); + it.statusOptionId = targetId; + syncCounts(); + card.classList.add("is-moving"); + try { + const r = await host.invoke("project:moveItem", { + projectId: p.id, + itemId: it.id, + fieldId: field.id, + optionId: targetId, + }); + if (!r.ok) { + toast(r.message ?? "Couldn't move the item.", "error"); + it.statusOptionId = fromId; + void showProjectBoard(detail, p, refresh, nav); // revert to the truth + return; + } + card.classList.remove("is-moving"); + bust("project"); // the next board read refetches the confirmed state + // RE-RENDER, as the failure path above does. Busting the cache only + // affects the NEXT read: the board on screen kept the column widths and + // the empty-column dimming it had computed before the move, so a card + // dragged into an empty column left that column still drawn as empty and + // narrow, and the one it came from still drawn as though it held the + // card. + // + // From `b`, NOT through the cache — the line above just deleted the entry + // this would have read. The optimistic `statusOptionId` is already + // written to `b` and the server has confirmed it, so `b` IS the truth and + // repainting from it costs neither a spinner nor a request. + void showProjectBoard(detail, p, refresh, nav, b).then(() => { + // Show the reader where it landed. A DRAG ends under the pointer, so + // the card is on screen by construction — but the kebab's "Move to" + // runs this same code from a menu, and its target column can be well + // off the right edge of a wide board. Without this the menu closed, the + // card vanished from where it was, and nothing said where it went. + detail + .querySelector(`.gh-card[data-item="${CSS.escape(it.id)}"]`) + ?.scrollIntoView({ block: "nearest", inline: "nearest" }); + }); + } catch (e) { + toast(cleanErr(e) || "Couldn't move the item.", "error"); + it.statusOptionId = fromId; + void showProjectBoard(detail, p, refresh, nav); + } + }; + const boardEl = el("div", "gh-board"); for (const col of columns) { - const items = board.items.filter((it) => (board.field ? it.statusOptionId === col.id : true)); - const colEl = el("div", "gh-col"); + const items = b.items.filter((it) => (b.field ? it.statusOptionId === col.id : true)); + // An empty bucket is still a drop target, but it must not claim an equal + // quarter of the board: "No status · 0" was a 400px column of nothing + // beside three columns holding the actual work. + const colEl = el("div", "gh-col" + (items.length === 0 ? " is-empty" : "")); const colHead = el("div", "gh-col-head"); const colName = el("span", "gh-col-name"); colName.textContent = col.name; colName.title = col.name; - colHead.append(colName, pill(String(items.length))); + const count = pill(String(items.length)); + colHead.append(colName, count); colEl.appendChild(colHead); const colBody = el("div", "gh-col-body"); - if (items.length === 0) { - colBody.appendChild(el("div", "gh-col-empty")); - } + // The empty placeholder is ALWAYS present (CSS shows it via :only-child), so + // a column emptied by a drag keeps a visible drop zone. + colBody.appendChild(el("div", "gh-col-empty")); for (const it of items) { - colBody.appendChild(projectCard(p, board, it, refresh, nav)); + colBody.insertBefore( + projectCard(p, b, it, refresh, nav, cardsById, dropItem), + colBody.querySelector(".gh-col-empty"), + ); } colEl.appendChild(colBody); + cols.set(col.id, { el: colEl, body: colBody, count }); + + // Drop target wiring (only meaningful with a Status field to write to). + if (b.field) { + colEl.addEventListener("dragover", (e) => { + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; + colEl.classList.add("is-drop"); + }); + colEl.addEventListener("dragleave", (e) => { + if (!colEl.contains(e.relatedTarget as Node)) colEl.classList.remove("is-drop"); + }); + colEl.addEventListener("drop", (e) => { + e.preventDefault(); + colEl.classList.remove("is-drop"); + const id = e.dataTransfer?.getData("text/plain"); + if (id) void dropItem(id, col.id); + }); + } boardEl.appendChild(colEl); } detail.appendChild(boardEl); + syncCounts(); } /** One board card: a state dot + title + number/author/updated meta + a type pill, - * plus a kebab to move/open the item. Clicking the body opens the issue/PR. */ + * plus a kebab to move/open the item. Clicking the body opens the issue/PR; + * dragging it onto another column moves it. */ function projectCard( p: ProjectInfo, board: ProjectBoard, it: ProjectItem, refresh: () => void, nav: SectionNav, + registry: Map<string, HTMLElement> | undefined, + /** The board's move — handed to the kebab so the menu and a drag run the + * same code. */ + move: (itemId: string, targetId: string | null) => Promise<void>, ): HTMLElement { const card = el("div", "gh-card"); + // Addressable after a re-render — the move handler uses this to bring the + // card it just moved back into view. + card.dataset.item = it.id; + registry?.set(it.id, card); + + // Draggable between Status columns (the kebab menu stays as the keyboard path). + if (board.field) { + card.draggable = true; + card.addEventListener("dragstart", (e) => { + e.dataTransfer?.setData("text/plain", it.id); + if (e.dataTransfer) e.dataTransfer.effectAllowed = "move"; + card.classList.add("is-dragging"); + }); + card.addEventListener("dragend", () => card.classList.remove("is-dragging")); + } const top = el("div", "gh-card-top"); const stateKey = it.state ? it.state.toLowerCase() : ""; const dot = el("span", `gh-check-dot gh-state-${stateKey || "none"}`); + // A card wrote its TYPE in words ("Issue", "PR") and its STATE — open, closed, + // merged, the thing that decides whether it still needs you — as a 9px dot + // with no label at all. The dot keeps its place; the word joins the sub-line. + if (stateKey) { + const stateWord = + stateKey === "merged" ? "Merged" : stateKey === "closed" ? "Closed" : "Open"; + dot.title = stateWord; + dot.setAttribute("role", "img"); + dot.setAttribute("aria-label", stateWord); + } const title = el("div", "gh-card-title"); title.textContent = it.title; top.append(dot, title); @@ -197,7 +353,7 @@ function projectCard( kebab.appendChild(glyph("kebab-vertical")); kebab.addEventListener("click", (e) => { e.stopPropagation(); - projectItemMenu(kebab, p, board, it, refresh); + projectItemMenu(kebab, p, board, it, move); }); top.appendChild(kebab); card.appendChild(top); @@ -205,7 +361,16 @@ function projectCard( const sub = el("div", "gh-card-sub"); const num = it.number != null ? `#${it.number}` : it.type === "DRAFT_ISSUE" ? "draft" : ""; const when = relTimeISO(it.updatedAt); - sub.textContent = [num, it.author && `@${it.author}`, when].filter(Boolean).join(" · "); + const stateWord = stateKey + ? stateKey === "merged" + ? "merged" + : stateKey === "closed" + ? "closed" + : "open" + : ""; + sub.textContent = [num, stateWord, it.author && `@${it.author}`, when] + .filter(Boolean) + .join(" · "); card.appendChild(sub); const typePill = pill( @@ -225,7 +390,7 @@ function projectCard( const isPr = it.type === "PULL_REQUEST"; const open = (): void => { if (isPr) nav("prs", { number: num }); - else openIssueDrawer(num, nav); + else openIssueDrawer(num, nav, refresh); }; card.classList.add("clickable"); card.tabIndex = 0; @@ -233,6 +398,11 @@ function projectCard( card.title = isPr ? `Open pull request #${num}` : `Peek issue #${num}`; card.addEventListener("click", open); card.addEventListener("keydown", (e) => { + if (e.target !== card) return; + // Only the CARD itself. Enter on the kebab has to open the item menu — + // it is the documented keyboard path for what a drag does with the + // pointer — and without this it opened the issue instead, so "Move to" + // was unreachable from the keyboard entirely. if (e.key === "Enter" || e.key === " ") { e.preventDefault(); open(); @@ -247,9 +417,13 @@ function projectCard( * to hand off to the Issues screen to read, comment on, or triage an item. The * drawer hosts the full issue detail (body, timeline, composer, action cluster), * all of whose mutations re-render inside it. "Open in Issues" escalates to the - * two-pane workspace when you want the list alongside. + * full-page issue workspace when you want the section around it. */ -function openIssueDrawer(number: number, nav: SectionNav): void { +function openIssueDrawer(number: number, nav: SectionNav, onChanged?: () => void): void { + /** Did anything in the drawer change the issue? Refreshing the board on + * every close would re-fetch on a plain read; refreshing on none left a + * closed issue showing as open on the card behind. */ + let changed = false; const opener = document.activeElement as HTMLElement | null; const scrim = el("div", "gh-drawer-scrim"); const drawer = el("div", "gh-drawer"); @@ -262,8 +436,8 @@ function openIssueDrawer(number: number, nav: SectionNav): void { eyebrow.append(glyph("issue-opened"), span(`Issue #${number}`)); const headActions = el("div", "gh-drawer-actions"); const openFull = el("button", "mini-btn"); - openFull.append(glyph("link-external"), span("Open in Issues")); - openFull.title = "Open this issue in the full Issues workspace"; + openFull.append(glyph("issues"), span("Open in Issues")); + openFull.title = "Open this issue as a full page in the Issues section"; const closeBtn = el("button", "gh-drawer-close"); closeBtn.setAttribute("aria-label", "Close"); closeBtn.title = "Close (Esc)"; @@ -275,16 +449,36 @@ function openIssueDrawer(number: number, nav: SectionNav): void { drawer.append(head, body); scrim.appendChild(drawer); document.body.appendChild(scrim); + const releaseBackground = holdBackground(scrim); - const dispose = (): void => { + let disposed = false; + const dispose = (restoreFocus = true): void => { + if (disposed) return; + disposed = true; + layer.release(); + releaseBackground(); document.removeEventListener("keydown", onKey, true); scrim.classList.remove("is-open"); // Let the slide-out play, then remove; restore focus to the card. window.setTimeout(() => scrim.remove(), 200); - opener?.focus?.(); + if (restoreFocus) opener?.focus?.(); + if (changed) onChanged?.(); }; + // A route change dismisses this drawer like every other floating layer. It + // was the one surface that never registered, so navigating away left it + // hanging over the next view — and now that it makes the page behind it + // `inert`, a drawer that outlived its own view would have frozen the app. + const layer = registerLayer(() => dispose(false)); const onKey = (e: KeyboardEvent): void => { if (e.key === "Escape") { + // Anything opened AFTER the drawer owns Escape — a dialog, a menu, or a + // peek drilled into from the card. Without this, one press closed the + // thing you aimed at and took the drawer under it too, discarding + // whatever the card was showing. Asked as "am I the top layer?", because + // "is a modal open?" cannot see a peek. See `registerLayer`. + // "Nothing opened after me." Every layer registers, so this needs no + // list of the kinds that can outrank a drawer. + if (!layer.isTop()) return; e.preventDefault(); dispose(); } @@ -293,7 +487,7 @@ function openIssueDrawer(number: number, nav: SectionNav): void { scrim.addEventListener("mousedown", (e) => { if (e.target === scrim) dispose(); }); - closeBtn.addEventListener("click", dispose); + closeBtn.addEventListener("click", () => dispose()); openFull.addEventListener("click", () => { dispose(); nav("issues", { number }); @@ -301,37 +495,53 @@ function openIssueDrawer(number: number, nav: SectionNav): void { requestAnimationFrame(() => scrim.classList.add("is-open")); closeBtn.focus(); - void renderIssueDetailInto(body, number, nav); + void renderIssueDetailInto(body, number, nav, () => { + changed = true; + }); } -/** Kebab menu: "Open on GitHub" + "Move to → <Status option>" (the write path). */ +/** Kebab menu: "Open on GitHub" + "Move to → <Status option>" (the keyboard + * path for what drag-and-drop does with the pointer). */ function projectItemMenu( anchor: HTMLElement, p: ProjectInfo, board: ProjectBoard, it: ProjectItem, - refresh: () => void, + /** The board's own move — the SAME one a drag runs. This menu is "the + * keyboard path for what drag-and-drop does with the pointer", and it used + * to be a second implementation that took the other path through the cache: + * it refetched the whole section, so moving by keyboard blanked the board + * and moving by pointer did not. */ + move: (itemId: string, targetId: string | null) => Promise<void>, ): void { const items: MenuItem[] = []; if (it.url) { const url = it.url; items.push({ label: "Open on GitHub", icon: "link-external", onClick: () => window.open(url, "_blank") }); } - const card = anchor.closest(".gh-card") as HTMLElement | null; const field = board.field; if (field) { - if (items.length) items.push({ separator: true, label: "Move to" }); + // ALWAYS the group label, not only when something sits above it. A DRAFT + // item has no `url`, so nothing was pushed before this and the header was + // skipped — leaving the menu a bare list of status names ("No status", + // "Todo", "In progress", "Done") with nothing saying what picking one does. + // `separator: true` unconditionally — it is what MAKES this a group label. + // `separator: items.length > 0` was false for exactly the draft case the + // comment above describes, and openMenu renders a non-separator item as a + // command button: a live, focusable "Move to" row that did nothing at all, + // sitting above the four statuses it was supposed to be introducing. + items.push({ separator: true, label: "Move to" }); // "No Status" target (clears the field). items.push({ - label: "No Status", + label: "No status", current: it.statusOptionId === null, - onClick: () => void projectMoveItem(p, field.id, it, null, refresh, card), + onClick: () => void move(it.id, null), }); for (const opt of field.options) { items.push({ label: opt.name, current: it.statusOptionId === opt.id, - onClick: () => void projectMoveItem(p, field.id, it, opt.id, refresh, card), + onClick: () => void move(it.id, opt.id), }); } } @@ -340,37 +550,3 @@ function projectItemMenu( } openMenu(anchor, items); } - -/** Move an item's Status, then re-render the section (mutation → toast → refresh). */ -async function projectMoveItem( - p: ProjectInfo, - fieldId: string, - it: ProjectItem, - optionId: string | null, - refresh: () => void, - card?: HTMLElement | null, -): Promise<void> { - if (it.statusOptionId === optionId) return; // no-op - // Lock + dim the card while the move is in flight so it's clear it's working. - card?.classList.add("is-moving"); - try { - const r = await host.invoke("project:moveItem", { - projectId: p.id, - itemId: it.id, - fieldId, - optionId, - }); - if (!r.ok) { - card?.classList.remove("is-moving"); - toast(r.message ?? "Couldn't move the item.", "error"); - return; - } - toast("Moved item.", "success"); - // Re-render the whole section; selectedProjectId reselects this project, - // reloading its board with the new Status in place (which replaces the card). - refresh(); - } catch (e) { - card?.classList.remove("is-moving"); - toast(cleanErr(e) || "Couldn't move the item.", "error"); - } -} diff --git a/apps/desktop/src/renderer/views/prs.ts b/apps/desktop/src/renderer/views/prs.ts index 69b9a28..4b2c717 100644 --- a/apps/desktop/src/renderer/views/prs.ts +++ b/apps/desktop/src/renderer/views/prs.ts @@ -1,17 +1,17 @@ -// The Pull Requests section view — a full, GitHub-grade two-pane PR workspace. -// -// Left: the open-PR list. Right: the selected PR's detail with Conversation / -// Commits / Pipelines / Files sub-tabs and the full action cluster (Checkout, -// Approve, Review ▾, Mark ready, Merge ▾, Open on GitHub, ⋯). The header carries -// a primary "New PR" action that opens a multi-field create modal. +// The Pull Requests section — a full, GitHub-grade PR workspace on the +// section-page system (docs/desktop-redesign.md): a full-width list page whose +// rows navigate to a full-page detail (routed via `target.number`), with the +// PR's properties in an inline-editable right rail and Conversation / Commits / +// Checks / Files sub-tabs in the content column. The Files tab widens to the +// whole window (the rail hides) — diffs get the space they deserve. // // Everything routes through `host.invoke` against the typed IPC contract. Reads -// that fail render an errorState with Retry; every mutation disables its trigger, -// confirms destructive ops, toasts success/error, and re-renders the affected -// surface (the list and/or the detail) — never the local git graph, since none -// of these PR API writes touch the working tree. +// that fail render an errorState with Retry; every mutation disables its +// trigger, confirms destructive ops, toasts success/error, busts the SWR cache +// and re-renders the affected surface. import { host } from "../bridge"; +import { peek as cachePeek, gget, bust, prime, cacheScope } from "../cache"; import { el, span, @@ -26,26 +26,62 @@ import { copyText, cleanErr, openMenu, - ghRow, avatar, labelChip, statBit, statePill, stateLead, -} from "../ui"; -import { toast, confirmDialog, promptInline, editForm } from "../dialogs"; + commonDir,} from "../ui"; +import { toast, confirmDialog, promptInline, openModal, formWithRetry } from "../dialogs"; import { renderMarkdown } from "../markdown"; import { openAssistantTab, aiEnabled } from "../aiAssist"; import { DiffPanel } from "../diffPanel"; -import { ghGate, ghHeader, ghTwoPane, peoplePickerModal, searchField, trapTab, type SectionRender, type SectionNav, type SectionTarget } from "./common"; +import { + associationBadge, + blankable, + facetBar, + harvestValues, + segmented, + swatch, + type FacetState, + associationLabel, + reactionRow, + avatarStack, + capNotice, + commitList, + detailPage, + ghGate, + ghHeader, + LIST_CAPS, + peoplePickerModal, + personChip, + propAddBtn, + propNone, + propSection, + searchField, + secRow, + sectionList, + disposeOnDetach, + type GhGate, + type SectionRender, + type SectionNav, + type SectionTarget, + subTabs, + checkStateLabel, +} from "./common"; +import { wireProseNav } from "../proseNav"; +import { openPeek } from "../peek"; +import { memberCard } from "./orgs"; import type { BranchRef, FileDiff, PrComment, PrDetail, PrFile, + PrReviewEvent, PrReviewThread, PullRequest, + ReactionSummary, RepoCollaborator, RepoLabel, } from "../../shared/ipc"; @@ -53,9 +89,40 @@ import type { // Persist the active sub-tab across re-renders so a comment / state change keeps // the user on the tab they were reading. let activeSubTab = "conversation"; +/** The section's router, captured at mount so detail components (branch chips, + * author profile, check rows) can navigate — mirrors the other module state. */ +let sectionNav: SectionNav | undefined; // The file selected within the Files tab, persisted so re-rendering the detail // (after a mutation) keeps the same diff open. let activeFilePath: string | undefined; +/** Which PR the detail page last showed — tab state resets when it changes. */ +let lastDetailNumber: number | undefined; +/** The list page's live search query — survives list ⇄ detail round trips. */ +let query = ""; +/** Which PRs to fetch. GitHub has no "merged" state — merged PRs arrive under + * `closed` carrying `mergedAt` — so "merged" asks for closed and narrows here. */ +let prState: "open" | "closed" | "merged" | "all" = "open"; +/** Client-side PR facets, kept across list ⇄ detail round trips. */ +const prFacets: FacetState = {}; +/** + * Unsent comment drafts, per PR — navigating away must never eat one. + * + * Keyed by REPO and number. Keyed by number alone, a draft written on PR #31 + * in one repository was handed to PR #31 in the next one you opened — + * pre-filled into its composer, ready to send to strangers. Numbers collide + * across repos constantly; the low ones always do. + */ +const commentDrafts = new Map<string, string>(); +const draftKey = (n: number): string => `${cacheScope()}#${n}`; +/** + * Unsent inline replies, per review thread. + * + * Resolving ANY thread reloads the file's whole thread panel, which rebuilds + * every card from scratch — so a reply half-written in one thread vanished + * because a different thread was resolved. Same rule as the composer above: + * text the user typed is not the app's to throw away on a repaint. + */ +const replyDrafts = new Map<string, string>(); // ── Monaco diff lifecycle (self-contained; we can't touch renderer.ts) ───────── // @@ -67,115 +134,149 @@ let activeFilePath: string | undefined; // • when our surface is detached from the DOM (navigating to another section) — // caught by a MutationObserver so the editor never leaks. let prDiffPanel: DiffPanel | undefined; -let prDiffDetachObs: MutationObserver | undefined; +/** Cancels the detach watch below. */ +let stopPrDiffWatch: (() => void) | undefined; function disposePrDiff(): void { - prDiffDetachObs?.disconnect(); - prDiffDetachObs = undefined; + stopPrDiffWatch?.(); + stopPrDiffWatch = undefined; prDiffPanel?.dispose(); prDiffPanel = undefined; } -/** Tear the diff down automatically once its surface leaves the document (e.g. a - * route change replaces the view host) so the Monaco editor never lingers. */ -function watchDiffDetach(surface: HTMLElement): void { - prDiffDetachObs?.disconnect(); - const obs = new MutationObserver(() => { - if (!surface.isConnected) disposePrDiff(); +/** + * Tear the diff down once its surface leaves the document (a route change + * replacing the view host), so the Monaco editor never lingers. + * + * It watches the WHOLE document for any mutation, so it fires constantly — and + * it used to call `disposePrDiff()` on behalf of whatever panel happened to be + * current, clearing `prDiffPanel` even when the surface it was watching was not + * the live one. After that every `prDiffPanel !== panel` guard in the load path + * was true and the tab stayed blank forever, in both modes, until another file + * was picked. It disposes only the panel it was created for, and only while + * that panel is still the live one. + */ +function watchDiffDetach(surface: HTMLElement, panel: DiffPanel): void { + stopPrDiffWatch?.(); + stopPrDiffWatch = disposeOnDetach(surface, () => { + // Only the panel this watch was created for, and only while it is still the + // live one — see the note above. + if (prDiffPanel !== panel) return; + disposePrDiff(); }); - obs.observe(document.body, { childList: true, subtree: true }); - prDiffDetachObs = obs; +} + +/** The PR's display state: merged beats closed beats draft beats open. */ +function prKind(pr: PullRequest): "open-pr" | "draft" | "merged" | "closed" { + if (pr.mergedAt) return "merged"; + if (pr.state === "closed") return "closed"; + if (pr.draft) return "draft"; + return "open-pr"; +} +function prKindLabel(kind: ReturnType<typeof prKind>): string { + return kind === "open-pr" ? "Open" : kind === "draft" ? "Draft" : kind === "merged" ? "Merged" : "Closed"; } export const renderPrs: SectionRender = (wrap, nav, target) => { void mount(wrap, nav, target); }; -const refresher = (wrap: HTMLElement, nav: SectionNav) => () => renderPrs(wrap, nav); - async function mount(wrap: HTMLElement, nav: SectionNav, target?: SectionTarget): Promise<void> { + sectionNav = nav; // A re-render replaces the whole view subtree — drop any live Monaco diff from // the previous render so it can't leak or write into detached DOM. disposePrDiff(); - const gate = await ghGate(wrap, nav, true); + const refresh = (): void => { + bust("pr"); + renderPrs(wrap, nav, target); + }; + const gate = await ghGate(wrap, nav, true, refresh); if (!gate) return; - const refresh = refresher(wrap, nav); + if (target?.number != null) { + showDetailPage(wrap, nav, target.number, target.from); + return; + } + await listPage(wrap, nav, gate); +} + +// ── The list page ──────────────────────────────────────────────────────────── + +async function listPage(wrap: HTMLElement, nav: SectionNav, gate: GhGate): Promise<void> { + const refresh = (): void => { + bust("pr"); + renderPrs(wrap, nav); + }; + + const { view, listEl } = sectionList(); const header = ghHeader("Pull Requests", gate.login, refresh); - // A primary "New PR" action lives left of the account cluster in the head row. - const newBtn = el("button", "mini-btn gh-head-action"); + const tools = el("div", "gh-head-tools"); + // Pull Requests was permanently open-only while Issues had a state control + // one rail item away. "Merged" is a fourth option because it is the state + // people actually look for, even though GitHub does not have it. + const stateSeg = segmented<"open" | "closed" | "merged" | "all">({ + options: [ + { value: "open", label: "Open" }, + { value: "merged", label: "Merged" }, + { value: "closed", label: "Closed" }, + { value: "all", label: "All" }, + ], + value: prState, + ariaLabel: "Pull request state", + onChange: (v) => { + prState = v; + renderPrs(wrap, nav); + }, + }); + const facetSlot = el("div", "gh-facet-slot"); + const newBtn = el("button", "btn btn-primary gh-new-btn"); newBtn.append(glyph("git-pull-request"), span("New PR")); newBtn.title = "Open a new pull request"; newBtn.addEventListener("click", () => void openCreatePr(refresh)); - const acct = header.querySelector(".gh-acct"); - if (acct) acct.before(newBtn); - else header.appendChild(newBtn); - - const { view, listEl, detailEl } = ghTwoPane(); - wrap.replaceChildren(header, view); - const idleEmpty = (): void => { - detailEl.replaceChildren( - emptyState( - "Pull requests", - "Select a pull request to read its description, review the diff, and check CI.", - { icon: "git-pull-request", hint: "Tip: open one to approve, merge, or check out the branch." }, - ), - ); - }; - idleEmpty(); - - listEl.replaceChildren(skeletonList(5)); - let prs: PullRequest[]; - try { - prs = await host.invoke("pr:list", undefined); - } catch (e) { - listEl.replaceChildren( - errorState("Couldn't load pull requests", cleanErr(e) || "GitHub request failed.", refresh), - ); - return; - } - header.setCount?.(prs.length); - listEl.replaceChildren(); - if (prs.length === 0) { - listEl.appendChild( - emptyState("No open pull requests", "You're all caught up — nothing to review right now.", { - icon: "git-pull-request", - action: { label: "New pull request", icon: "git-pull-request", onClick: () => void openCreatePr(refresh) }, - }), - ); - return; - } + tools.append(stateSeg, facetSlot, newBtn); + header.querySelector(".gh-acct")?.before(tools); + view.append(header, listEl); + wrap.replaceChildren(view); - const select = (pr: PullRequest, row: HTMLElement): void => { - listEl.querySelectorAll(".gh-row.active").forEach((n) => n.classList.remove("active")); - row.classList.add("active"); - void showDetail(detailEl, pr, refresh); - }; + const fetchState = prState === "merged" ? "closed" : prState; + let prs: PullRequest[] | undefined = cachePeek("pr:list", { state: fetchState }); + if (!prs) listEl.replaceChildren(skeletonList(5)); const buildRow = (pr: PullRequest): HTMLElement => { - const kind = pr.draft ? "draft" : "open-pr"; - const chips = pr.labels.map((l) => labelChip(l.name, l.color)); - const stats: HTMLElement[] = []; - if (typeof pr.comments === "number" && pr.comments > 0) stats.push(statBit("comment", pr.comments)); - if (typeof pr.additions === "number") stats.push(statBit("", `+${pr.additions}`, "add")); - if (typeof pr.deletions === "number") stats.push(statBit("", `−${pr.deletions}`, "del")); - const updated = relTimeISO(pr.updatedAt); - const row = ghRow({ + const kind = prKind(pr); + // One order across every list: who wrote it, who owns it, then the counts. + const meta: HTMLElement[] = []; + if (pr.user) meta.push(avatarStack([pr.user], 1, 18, "Author")); + meta.push(blankable(avatarStack(pr.assignees ?? [], 3, 18, "Assignee"), !!pr.assignees?.length)); + if (typeof pr.additions === "number" || typeof pr.deletions === "number") { + const stat = el("span", "sec-diffstat"); + if (typeof pr.additions === "number") stat.appendChild(span(`+${pr.additions}`, "add")); + if (typeof pr.deletions === "number") stat.appendChild(span(`−${pr.deletions}`, "del")); + meta.push(stat); + } + meta.push(blankable(statBit("comment", pr.comments ?? 0), (pr.comments ?? 0) > 0)); + + const row = secRow({ lead: stateLead(kind), + num: `#${pr.number}`, title: pr.title, - titleSuffix: pr.draft ? [statePill("Draft", "draft")] : [], - meta: `#${pr.number} ${pr.head.ref} → ${pr.base.ref} · ${pr.user?.login ?? "unknown"}${updated ? ` · ${updated}` : ""}`, - metaTitle: pr.updatedAt ? `Updated ${absTimeISO(pr.updatedAt)}` : undefined, - chips, - stats, + titleSuffix: [ + ...(pr.draft ? [statePill("Draft", "draft")] : []), + // A PR from a fork runs someone else's branch through your CI — a fact + // worth seeing in the LIST, not only after you open it. + ...(pr.headRepoFullName ? [forkChip(pr.headRepoFullName)] : []), + ], + chips: pr.labels.map((l) => labelChip(l.name, l.color)), + meta, + time: relTimeISO(pr.updatedAt), + timeTitle: pr.updatedAt ? `Updated ${absTimeISO(pr.updatedAt)}` : undefined, ariaLabel: `Pull request #${pr.number}: ${pr.title}`, + onOpen: () => nav("prs", { number: pr.number }), }); row.dataset.num = String(pr.number); - row.addEventListener("click", () => select(pr, row)); return row; }; - // Case-insensitive match over the fields a user would search by. const matches = (pr: PullRequest, q: string): boolean => { const hay = `${pr.title} #${pr.number} ${pr.head.ref} ${pr.base.ref} ${pr.user?.login ?? ""} ${pr.labels .map((l) => l.name) @@ -183,313 +284,346 @@ async function mount(wrap: HTMLElement, nav: SectionNav, target?: SectionTarget) return hay.includes(q); }; - let autoSelected = false; - const renderList = (items: PullRequest[], q = ""): void => { + /** Narrows the fetched page to the segment. "Closed" means closed-and-not- + * merged, so Merged and Closed are disjoint rather than one containing the + * other — which is what people mean when they pick one. */ + const stateMatches = (pr: PullRequest): boolean => { + if (prState === "merged") return !!pr.mergedAt; + if (prState === "closed") return pr.state === "closed" && !pr.mergedAt; + return true; + }; + + // Client-side facets: the PR list is one fetch of open PRs, so narrowing it + // is honest filtering of what's already here — no re-fetch, no cache key. + const facets = facetBar<PullRequest>({ + specs: [ + { + key: "author", + label: "Author", + icon: "account", + anyLabel: "Anyone", + harvest: (items) => { + const seen = new Map<string, string | null>(); + for (const pr of items) if (pr.user && !seen.has(pr.user.login)) seen.set(pr.user.login, pr.user.avatarUrl); + return [...seen].map(([login, avatarUrl]) => ({ + value: login, + label: `@${login}`, + iconEl: () => avatar(login, avatarUrl, 18), + })); + }, + predicate: (pr, v) => pr.user?.login === v, + }, + { + key: "label", + label: "Label", + icon: "tag", + anyLabel: "All labels", + harvest: (items) => { + const seen = new Map<string, string>(); + for (const pr of items) for (const l of pr.labels) if (!seen.has(l.name)) seen.set(l.name, l.color); + return [...seen].map(([name, color]) => ({ value: name, iconEl: () => swatch(color) })); + }, + predicate: (pr, v) => pr.labels.some((l) => l.name === v), + }, + { + key: "base", + label: "Base", + icon: "git-branch", + anyLabel: "Any base", + harvest: harvestValues<PullRequest>((pr) => pr.base.ref), + predicate: (pr, v) => pr.base.ref === v, + }, + // Was called "State" and contained no states — draft-ness and where the + // head branch lives are two different questions, and neither is a state. + { + key: "draft", + label: "Review", + icon: "git-pull-request", + anyLabel: "Ready and draft", + options: [ + { value: "ready", label: "Ready for review", icon: "git-pull-request" }, + { value: "draft", label: "Draft", icon: "git-pull-request-draft" }, + ], + predicate: (pr, v) => (v === "draft" ? pr.draft : !pr.draft), + }, + { + key: "origin", + label: "Origin", + icon: "repo-forked", + anyLabel: "Anywhere", + options: [ + { value: "same", label: "This repository", icon: "repo" }, + { value: "fork", label: "From a fork", icon: "repo-forked" }, + ], + predicate: (pr, v) => (v === "fork" ? !!pr.headRepoFullName : !pr.headRepoFullName), + }, + ], + state: prFacets, + items: prs ?? [], + onChange: () => renderList(), + }); + facetSlot.replaceChildren(facets.el); + + const renderList = (): void => { + if (!prs) return; + const inSegment = prs.filter(stateMatches); + // Harvested from the SEGMENT, not the superset behind it. Merged and Closed + // come from one "closed" fetch, so on Merged the Author menu listed + // everyone who has a closed pull request and the Label menu every label on + // one — options that filter the visible list down to nothing, offered as if + // they were choices. + facets.sync(inSegment); + const q = query.toLowerCase(); + // The SEGMENT's own set is the total. `prs` is a superset — Merged and + // Closed are fetched together — so counting against it put the badge in its + // narrowed "N of M" form, with the accent and the "N shown of M loaded" + // tooltip, on a segment where no filter was set at all: "0 of 5" above + // "No closed pull requests". The "of" is a statement that something is + // being filtered OUT, and picking a segment is not filtering. + const items = inSegment.filter((pr) => facets.passes(pr) && (q ? matches(pr, q) : true)); + header.setCount?.(items.length, inSegment.length); listEl.replaceChildren(); + // The empty state has to answer the question the SEGMENT asked. It was + // hardcoded to the open-state copy, so "Closed" reported "No open pull + // requests — you're all caught up", which is about a different set entirely. + // Same table Issues already uses. + const emptyCopy: Record<typeof prState, { title: string; desc: string; icon: string }> = { + open: { + title: "No open pull requests", + desc: "You're all caught up — nothing to review right now.", + icon: "git-pull-request", + }, + merged: { + title: "No merged pull requests", + desc: "Merged pull requests will show here once some land.", + icon: "git-merge", + }, + closed: { + title: "No closed pull requests", + desc: "Pull requests closed without merging will show here.", + icon: "git-pull-request-closed", + }, + all: { + title: "No pull requests yet", + desc: "This repo has none. Open the first one to propose a change.", + icon: "git-pull-request", + }, + }; + const ec = emptyCopy[prState]; + if (prs.length === 0) { + listEl.appendChild( + emptyState(ec.title, ec.desc, { + icon: ec.icon, + // Only offer to open one where opening one is the natural next step. + action: + prState === "open" || prState === "all" + ? { label: "New pull request", icon: "git-pull-request", onClick: () => void openCreatePr(refresh) } + : undefined, + }), + ); + return; + } if (items.length === 0) { + // Nothing filtered it — the segment did. Blaming filters that are not set + // ("0 of 5 … matches these filters") sends people hunting for a control + // that is already clear. + const bySegment = facets.activeCount() === 0 && !query; listEl.appendChild( - emptyState("No matching pull requests", `Nothing matches “${q}”.`, { icon: "search" }), + emptyState( + bySegment ? ec.title : "No matching pull requests", + bySegment + ? ec.desc + : query + ? `Nothing matches “${query}”.` + : "No pull request matches these filters.", + { + icon: bySegment ? ec.icon : "search", + // A filtered-empty list answers a question the toolbar asked, so it + // sits beside that control; a segment-empty one is the whole view's + // state and gets the hero — same rule the Inbox uses. Forcing + // `inline` here also hid the segment icon we just picked, since + // `.is-inline` drops the badge. + anchor: bySegment ? "hero" : "inline", + secondary: facets.activeCount() > 0 + ? { label: "Clear filters", icon: "clear-all", onClick: () => facets.clear() } + : undefined, + }, + ), ); return; } for (const pr of items) listEl.appendChild(buildRow(pr)); - // Auto-select the first PR once (initial render) so the detail isn't a void; - // don't hijack the selection on every keystroke while filtering. - if (!autoSelected) { - autoSelected = true; - const first = items[0]; - const firstRow = listEl.firstElementChild as HTMLElement | null; - if (first && firstRow) select(first, firstRow); - } + const cap = capNotice(prs.length, LIST_CAPS.prs); + if (cap) listEl.appendChild(cap); }; - // A header search/filter — on the LEFT, next to the title (client-side, instant). header.querySelector(".gh-head-titlewrap")?.appendChild( searchField({ placeholder: "Search pull requests…", - onInput: (q) => renderList(q ? prs.filter((pr) => matches(pr, q.toLowerCase())) : prs, q), + initial: query, + onInput: (q) => { + query = q; + renderList(); + }, }), ); - // Deep-link: open a specific PR on entry (e.g. from the project board) rather - // than the auto-selected first — keep it in-app, never open GitHub. - if (target?.number != null) autoSelected = true; - renderList(prs); - if (target?.number != null) { - const n = target.number; - const inList = prs.find((pr) => pr.number === n); - const row = listEl.querySelector(`[data-num="${n}"]`) as HTMLElement | null; - if (inList && row) { - select(inList, row); - row.scrollIntoView({ block: "nearest" }); - } else { - // Not in the current list (e.g. a closed PR) — fetch it and open its detail. - void (async () => { - try { - const d = await host.invoke("pr:detail", n); - if (d) void showDetail(detailEl, d.pr, refresh); - } catch { - /* leave the idle empty state if the PR can't be loaded */ - } - })(); + if (prs) renderList(); + + try { + const fresh = await gget("pr:list", { state: fetchState }, 15000); + if (!view.isConnected) return; + prs = fresh; + renderList(); + } catch (e) { + if (!view.isConnected) return; + if (!prs) { + listEl.replaceChildren( + errorState("Couldn't load pull requests", cleanErr(e) || "GitHub request failed.", refresh), + ); } } } -// ── Detail panel ────────────────────────────────────────────────────────────── +// ── The detail page ────────────────────────────────────────────────────────── -async function showDetail( - detail: HTMLElement, - pr: PullRequest, - refreshList: () => void, -): Promise<void> { - // Switching PRs (or reloading this one) must drop the prior file's Monaco diff. +function showDetailPage( + wrap: HTMLElement, + nav: SectionNav, + n: number, + from?: { view: string; label: string }, +): void { + sectionNav = nav; disposePrDiff(); - detail.replaceChildren(loadingState()); - let d: PrDetail | undefined; - try { - d = await host.invoke("pr:detail", pr.number); - } catch (e) { - detail.replaceChildren( - errorState("Couldn't load this pull request", cleanErr(e) || "GitHub request failed.", () => - void showDetail(detail, pr, refreshList), - ), - ); - return; - } - const full = d?.pr ?? pr; - detail.replaceChildren(); - - const head = el("div", "gh-detail-head"); - // Title + an inline edit (pencil) affordance, mirroring the Issues view. - const titleRow = el("div", "gh-detail-titlerow"); - titleRow.style.display = "flex"; - titleRow.style.alignItems = "center"; - titleRow.style.gap = "8px"; - const h = el("div", "gh-detail-title"); - h.textContent = full.title; - h.style.flex = "1 1 auto"; - h.style.minWidth = "0"; - const editTitleBtn = el("button", "mini-btn gh-icon-btn gh-title-edit"); - editTitleBtn.append(glyph("pencil")); - editTitleBtn.title = "Edit title & description"; - editTitleBtn.setAttribute("aria-label", "Edit pull request title and description"); - editTitleBtn.addEventListener("click", () => void doEdit(detail, full, refreshList)); - titleRow.append(h, editTitleBtn); - - const meta = el("div", "gh-detail-meta"); - const statePill = pill(full.draft ? "draft" : full.state); - statePill.classList.add(full.draft ? "gh-state-draft" : `gh-state-${full.state}`); - meta.append( - statePill, - span(` #${full.number}`), - span(` ${full.user?.login ?? ""}`), - span(` ${full.head.ref} → ${full.base.ref}`), - ); - if (d) meta.appendChild(span(` ${d.files.length} file${d.files.length === 1 ? "" : "s"}`)); - if (d?.checks) { - const c = pill(`checks: ${d.checks}`); - c.classList.add(`gh-checks-${d.checks}`); - meta.append(span(" "), c); + // A DIFFERENT PR starts on Conversation with no file pre-selected — the + // module-scoped tab used to leak: open PR B and land on PR A's Files tab. + if (lastDetailNumber !== n) { + lastDetailNumber = n; + activeSubTab = "conversation"; + activeFilePath = undefined; } - - const actions = buildActions(detail, full, d, refreshList); - head.append(titleRow, meta, actions); - detail.appendChild(head); - - // Live label chips (data already on the PR) with an inline "edit labels" pill. - const labelRow = el("div", "gh-detail-labels"); - for (const l of full.labels) labelRow.appendChild(labelChip(l.name, l.color)); - const editLabels = el("button", "mini-btn gh-icon-btn gh-inline-edit"); - editLabels.append(glyph("tag")); - editLabels.title = "Edit labels"; - editLabels.setAttribute("aria-label", "Edit labels"); - editLabels.addEventListener("click", () => void doLabels(editLabels, detail, full, refreshList)); - labelRow.appendChild(editLabels); - detail.appendChild(labelRow); - - // Sub-tabs: Conversation · Commits · Pipelines · Files (mirrors github.com). - const subBar = el("div", "gh-subtabs"); - const content = el("div", "gh-subcontent"); - const subDefs = [ - { id: "conversation", label: "Conversation", icon: "comment-discussion" }, - { id: "commits", label: "Commits", icon: "git-commit" }, - { id: "checks", label: "Pipelines", icon: "play" }, - { id: "files", label: d ? `Files (${d.files.length})` : "Files", icon: "code" }, - ]; - const subBtns: HTMLElement[] = []; - const selectSub = (id: string): void => { - // Leaving the Files tab tears the Monaco diff down (only Files mounts one). - if (activeSubTab === "files" && id !== "files") disposePrDiff(); - activeSubTab = id; - for (const b of subBtns) b.classList.toggle("active", b.dataset.sub === id); - void renderSubTab(content, full, d, id); + // Back goes where you CAME from — see the note in issues.ts. + const back = (): void => nav(from?.view ?? "prs", { list: true }); + const reload = (): void => { + bust("pr"); + showDetailPage(wrap, nav, n, from); }; - for (const t of subDefs) { - const b = el("button", "gh-subtab"); - b.dataset.sub = t.id; - b.append(glyph(t.icon), span(t.label)); - b.addEventListener("click", () => selectSub(t.id)); - subBtns.push(b); - subBar.appendChild(b); - } - detail.append(subBar, content); - selectSub(subDefs.some((s) => s.id === activeSubTab) ? activeSubTab : "conversation"); -} - -function buildActions( - detail: HTMLElement, - full: PullRequest, - d: PrDetail | undefined, - refreshList: () => void, -): HTMLElement { - const actions = el("div", "gh-detail-actions"); - const reload = (): void => void showDetail(detail, full, refreshList); - const checkoutBtn = el("button", "mini-btn"); - checkoutBtn.append(glyph("git-branch"), span("Checkout")); - checkoutBtn.title = `Fetch and check out this PR as pr/${full.number}`; - checkoutBtn.addEventListener("click", () => void doCheckout(full.number, checkoutBtn)); - - const approveBtn = el("button", "mini-btn"); - approveBtn.append(glyph("check"), span("Approve")); - approveBtn.title = "Approve this pull request"; - approveBtn.addEventListener("click", () => void doApprove(full.number, approveBtn, refreshList)); + const { view, main, rail, topActions } = detailPage({ + backLabel: from?.label ?? "Pull Requests", + crumb: `#${n}`, + // What the NEXT page's back button calls this one. Without it, leaving for + // a pipeline and pressing back read "← Pull requests" and landed on the + // list rather than the pull request you were reading. + pageLabel: `Pull Request #${n}`, + onBack: back, + }); + main.appendChild(skeletonList(4, false)); + wrap.replaceChildren(view); + + // While CI is PENDING, quietly re-fetch and repaint when something changed — + // the checks pill and Checks tab keep themselves honest. Stands down while + // the Files tab is open (a repaint would tear down the Monaco diff mid-read). + let lastSig = ""; + /** What the CI poll watches: the check state and the counts its sub-tab + * labels are built from. Everything else changing is not a reason to throw + * the page away and rebuild it. */ + const pollSig = (d: PrDetail): string => + JSON.stringify([ + d.checks, + d.pr.state, + d.pr.draft, + d.pr.mergedAt ?? null, + d.pr.closedAt ?? null, + d.pr.comments ?? 0, + d.pr.reviewComments ?? 0, + d.files.length, + ]); - const reviewBtn = el("button", "mini-btn"); - reviewBtn.append(glyph("comment"), span("Review"), glyph("chevron-down")); - reviewBtn.title = "Submit a review"; - reviewBtn.addEventListener("click", () => - openMenu(reviewBtn, [ - { - label: "Comment", - icon: "comment", - onClick: () => void doReview(full.number, "COMMENT", reviewBtn, refreshList), - }, - { - label: "Request changes", - icon: "request-changes", - onClick: () => void doReview(full.number, "REQUEST_CHANGES", reviewBtn, refreshList), - }, - { separator: true }, - { - label: "Approve", - icon: "check", - onClick: () => void doApprove(full.number, approveBtn, refreshList), - }, - ]), - ); + const schedulePoll = (current: PrDetail): void => { + if (current.checks !== "pending") return; + window.setTimeout(() => { + if (!view.isConnected) return; + if (activeSubTab === "files") { + schedulePoll(current); + return; + } + // Never rebuild the page out from under someone who is typing in it. The + // rebuild moves focus to the top of the new DOM, so a comment written + // across two 15-second ticks lost the caret mid-sentence — and the + // keystrokes after it went nowhere. + const focused = document.activeElement; + if (focused && view.contains(focused) && /^(TEXTAREA|INPUT)$/.test(focused.tagName)) { + schedulePoll(current); + return; + } + host + .invoke("pr:detail", n) + .then((fresh) => { + if (!view.isConnected || !fresh) return; + prime("pr:detail", n, fresh); + // Compare only what this poll EXISTS to watch. Signing the whole + // detail meant any unrelated field — an `updatedAt` bump from someone + // else's comment — rebuilt the entire page, scroll and all, every 15 + // seconds on a busy PR. + const sig = pollSig(fresh); + if (sig !== lastSig) { + lastSig = sig; + buildDetail({ view, main, rail, topActions, d: fresh, nav, reload }); + } + schedulePoll(fresh); + }) + .catch(() => schedulePoll(current)); // transient failure — keep watching + }, 15000); + }; - // "Mark ready" appears ONLY for drafts. - let readyBtn: HTMLElement | undefined; - if (full.draft) { - readyBtn = el("button", "mini-btn"); - readyBtn.append(glyph("eye"), span("Mark ready")); - readyBtn.title = "Convert this draft to ready for review"; - const rb = readyBtn; - readyBtn.addEventListener("click", () => void doMarkReady(full.number, rb, reload, refreshList)); - } + void (async () => { + let d: PrDetail | undefined; + try { + d = await gget("pr:detail", n, 8000); + } catch (e) { + if (!view.isConnected) return; + main.replaceChildren( + errorState("Couldn't load this pull request", cleanErr(e) || "GitHub request failed.", reload), + ); + return; + } + if (!view.isConnected) return; + if (!d) { + main.replaceChildren(emptyState("Pull request unavailable", "This pull request couldn't be loaded.")); + return; + } + lastSig = pollSig(d); + buildDetail({ view, main, rail, topActions, d, nav, reload }); + schedulePoll(d); + })(); +} - const mergeBtn = el("button", "btn btn-primary gh-merge-btn"); - mergeBtn.append(glyph("git-merge"), span("Merge"), glyph("chevron-down")); - // A closed/merged PR can't be merged — disable rather than letting the user - // open the menu and hit a confusing error toast. - const mergeable = full.state === "open" && !full.draft; - (mergeBtn as HTMLButtonElement).disabled = !mergeable; - mergeBtn.title = mergeable - ? "Merge this pull request" - : full.draft - ? "Mark the draft ready before merging" - : "This pull request is closed"; - mergeBtn.addEventListener("click", () => { - if (!mergeable) return; - openMenu(mergeBtn, [ - { label: "Create a merge commit", icon: "git-merge", onClick: () => void doMerge(full.number, "merge", refreshList) }, - { label: "Squash and merge", icon: "git-commit", onClick: () => void doMerge(full.number, "squash", refreshList) }, - { label: "Rebase and merge", icon: "git-compare", onClick: () => void doMerge(full.number, "rebase", refreshList) }, - ]); - }); +interface DetailCtx { + view: HTMLElement; + main: HTMLElement; + rail: HTMLElement; + topActions: HTMLElement; + d: PrDetail; + nav: SectionNav; + reload: () => void; +} - // "Update branch" — merge the latest base into the PR head. We don't know the - // behind-state here, so it's always shown for an open, non-draft PR; a no-op - // (already up to date) just toasts the API's verbatim message. It sits in the - // merge area as a secondary action, left of the primary Merge button. - let updateBtn: HTMLElement | undefined; - if (full.state === "open") { - updateBtn = el("button", "mini-btn"); - updateBtn.append(glyph("git-merge"), span("Update branch")); - updateBtn.title = "Merge the latest changes from the base branch into this PR"; - const ub = updateBtn; - updateBtn.addEventListener("click", () => void doUpdateBranch(full.number, reload, refreshList, ub)); - } +function buildDetail(ctx: DetailCtx): void { + const { view, main, rail, topActions, d, nav, reload } = ctx; + const full = d.pr; + const kind = prKind(full); + main.replaceChildren(); + rail.replaceChildren(); - const moreBtn = el("button", "mini-btn gh-icon-btn"); - moreBtn.append(glyph("ellipsis")); - moreBtn.title = "More actions"; - moreBtn.addEventListener("click", () => - openMenu(moreBtn, [ - { - label: "Add a comment", - icon: "comment", - onClick: () => void doComment(full.number, detail, full, refreshList), - }, - { - label: "Edit title & description", - icon: "pencil", - onClick: () => void doEdit(detail, full, refreshList), - }, - { separator: true }, - { - label: "Labels", - icon: "tag", - onClick: () => void doLabels(moreBtn, detail, full, refreshList), - }, - { - label: "Assignees", - icon: "person", - onClick: () => void doAssignees(moreBtn, detail, full, refreshList), - }, - { - label: "Request reviewers", - icon: "organization", - onClick: () => void doRequestReviewers(full.number), - }, - { - label: "Re-request review", - icon: "sync", - onClick: () => void doRequestReviewers(full.number, true), - }, - { separator: true }, - { - label: "Update branch", - icon: "git-merge", - onClick: () => void doUpdateBranch(full.number, reload, refreshList), - }, - full.state === "open" - ? { - label: "Close pull request", - icon: "git-pull-request-closed", - onClick: () => void doSetState(full.number, "closed", reload, refreshList), - } - : { - label: "Reopen pull request", - icon: "git-pull-request", - onClick: () => void doSetState(full.number, "open", reload, refreshList), - }, - { separator: true }, - { label: "Copy link", icon: "copy", onClick: () => void copyText(full.htmlUrl, "Copied PR link.") }, - { label: "Open on GitHub", icon: "link-external", onClick: () => window.open(full.htmlUrl, "_blank") }, - ]), - ); + // ── top-bar action cluster ── + const actions: HTMLElement[] = []; - // ✨ AI: explain / review the PR's diff, or draft a comment — each opens a - // conversational chat tab in the footer. Hidden until a model is connected. + // ✨ AI: explain / review the PR's diff, or draft a comment. Hidden until a + // model is connected. SHAs, not branch names — they resolve once fetched. const aiBtn = el("button", "mini-btn ai-mini"); aiBtn.hidden = true; aiBtn.append(glyph("sparkle"), span("AI"), glyph("chevron-down")); - // Use the commit SHAs, not the branch names: a PR's head branch usually isn't a - // local ref (you'd have origin/<branch>), but the SHA resolves whenever the - // object has been fetched — so the diff is exact when it can be gathered at all. const diffCmd = `git diff ${full.base.sha}..${full.head.sha}`; aiBtn.addEventListener("click", () => openMenu(aiBtn, [ @@ -524,52 +658,433 @@ function buildActions( ]), ); void aiEnabled().then((ok) => (aiBtn.hidden = !ok)); + actions.push(aiBtn); + + const checkoutBtn = el("button", "mini-btn"); + checkoutBtn.append(glyph("git-branch"), span("Checkout")); + checkoutBtn.title = `Fetch and check out this PR as pr/${full.number}`; + checkoutBtn.addEventListener("click", () => void doCheckout(full.number, checkoutBtn)); + actions.push(checkoutBtn); + + // Approving is a public, named act on someone else's work, and this button + // used to post it on the first click — 8px from a button that merely opens a + // menu, with a second "Approve" inside that menu doing the same thing. Both + // now open the review modal with APPROVE preselected, which is also where the + // review body the modal exists for finally gets used. + const approveBtn = el("button", "mini-btn"); + approveBtn.append(glyph("check"), span("Approve")); + approveBtn.title = "Approve this pull request — opens the review composer"; + approveBtn.addEventListener("click", () => void doReview(full.number, "APPROVE", approveBtn, reload)); + + const reviewBtn = el("button", "mini-btn"); + reviewBtn.append(glyph("comment"), span("Review"), glyph("chevron-down")); + reviewBtn.title = "Submit a review"; + reviewBtn.addEventListener("click", () => + openMenu(reviewBtn, [ + { label: "Comment", icon: "comment", onClick: () => void doReview(full.number, "COMMENT", reviewBtn, reload) }, + { label: "Request changes", icon: "request-changes", onClick: () => void doReview(full.number, "REQUEST_CHANGES", reviewBtn, reload) }, + ]), + ); + if (kind === "open-pr" || kind === "draft") actions.push(approveBtn, reviewBtn); + + // The primary slot: Merge for an open PR, Mark ready for a draft. + if (kind === "draft") { + const readyBtn = el("button", "btn btn-primary"); + readyBtn.append(glyph("eye"), span("Mark ready")); + readyBtn.title = "Convert this draft to ready for review"; + readyBtn.addEventListener("click", () => void doMarkReady(full.number, readyBtn, reload)); + actions.push(readyBtn); + } else if (kind === "open-pr") { + const mergeBtn = el("button", "btn btn-primary gh-merge-btn"); + mergeBtn.append(glyph("git-merge"), span("Merge"), glyph("chevron-down")); + mergeBtn.title = "Merge this pull request"; + mergeBtn.addEventListener("click", () => + openMenu(mergeBtn, [ + { label: "Create a merge commit", icon: "git-merge", onClick: () => void doMerge(full.number, "merge", reload) }, + { label: "Squash and merge", icon: "git-commit", onClick: () => void doMerge(full.number, "squash", reload) }, + { label: "Rebase and merge", icon: "git-compare", onClick: () => void doMerge(full.number, "rebase", reload) }, + ]), + ); + actions.push(mergeBtn); + } + + const moreBtn = el("button", "mini-btn gh-icon-btn"); + moreBtn.append(glyph("ellipsis")); + moreBtn.title = "More actions"; + moreBtn.addEventListener("click", () => + openMenu(moreBtn, [ + { label: "Edit title & description", icon: "pencil", onClick: () => sectionNav?.("predit", { number: full.number }) }, + { label: "Update branch", icon: "git-merge", onClick: () => void doUpdateBranch(full.number, reload) }, + { separator: true }, + full.state === "open" + ? { label: "Close pull request", icon: "git-pull-request-closed", onClick: () => void doSetState(full.number, "closed", reload) } + : { label: "Reopen pull request", icon: "git-pull-request", onClick: () => void doSetState(full.number, "open", reload) }, + { separator: true }, + { label: "Copy link", icon: "copy", onClick: () => void copyText(full.htmlUrl, "Copied PR link.") }, + ]), + ); + actions.push(moreBtn); + + const openBtn = el("button", "mini-btn gh-icon-btn"); + openBtn.append(glyph("link-external")); + openBtn.title = "Open this pull request on GitHub"; + openBtn.setAttribute("aria-label", openBtn.title); + openBtn.addEventListener("click", () => window.open(full.htmlUrl, "_blank")); + actions.push(openBtn); + + topActions.replaceChildren(...actions); + + // ── title block ── + const titleRow = el("div", "det-title-row"); + titleRow.appendChild(statePill(prKindLabel(kind), kind)); + const h = el("h1", "det-title"); + h.append(span(full.title), span(` #${full.number}`, "det-title-num")); + titleRow.appendChild(h); + const editTitleBtn = el("button", "mini-btn gh-icon-btn det-title-edit"); + editTitleBtn.append(glyph("pencil")); + editTitleBtn.title = "Edit title & description"; + editTitleBtn.setAttribute("aria-label", "Edit pull request title and description"); + editTitleBtn.addEventListener("click", () => sectionNav?.("predit", { number: full.number })); + titleRow.appendChild(editTitleBtn); + main.appendChild(titleRow); + + const sub = el("div", "det-sub"); + const author = full.user; + if (author?.login) { + const who = el("button", "gh-meta-author"); + who.append(avatar(author.login, author.avatarUrl, 18), span(author.login)); + who.title = `View @${author.login}'s profile`; + who.addEventListener("click", () => + openPeek(memberCard({ login: author.login, avatarUrl: author.avatarUrl, htmlUrl: `https://github.com/${author.login}` })), + ); + sub.appendChild(who); + } + const when = el("span"); + when.textContent = `opened ${relTimeISO(full.createdAt)} · updated ${relTimeISO(full.updatedAt)}`; + when.title = full.updatedAt ? `Updated ${absTimeISO(full.updatedAt)}` : ""; + sub.appendChild(when); + main.appendChild(sub); + + // ── sub-tabs ── + const content = el("div", "gh-subcontent"); + const subDefs = [ + { id: "conversation", label: "Conversation", icon: "comment-discussion" }, + { id: "commits", label: `Commits${typeof full.commits === "number" ? ` (${full.commits})` : ""}`, icon: "git-commit" }, + { id: "checks", label: "Checks", icon: "play" }, + // The tab's count is the PR's OWN total, not the length of the page we + // happened to fetch. GitHub caps the files response, so the two disagreed + // on the same screen: the rail read 412 and this tab read 300. + { id: "files", label: `Files (${full.changedFiles ?? d.files.length})`, icon: "code" }, + ]; + content.id = "gs-pr-subpanel"; + const tabs = subTabs({ + tabs: subDefs, + ariaLabel: "Pull request sections", + panel: content, + onSelect: (id) => { + if (activeSubTab === "files" && id !== "files") disposePrDiff(); + activeSubTab = id; + // Files mode: the rail hides and the content column stretches to the full + // window — a review surface, not a document. + view.classList.toggle("det-files-mode", id === "files"); + void renderSubTab(content, full, d, id, reload, nav); + }, + }); + const selectSub = tabs.select; + main.append(tabs.el, content); + + // ── property rail ── + const reviewersProp = propSection("Reviewers", { + onEdit: () => void doRequestReviewers(full.number), + editTitle: "Request reviewers", + }); + // Who was ASKED but hasn't answered — the single most useful thing a PR rail + // can tell you, and previously invisible (the section only offered "add"). + if (full.requestedReviewers?.length) { + for (const r of full.requestedReviewers) { + const chip = personChip(r.login, r.avatarUrl, () => + openPeek(memberCard({ login: r.login, avatarUrl: r.avatarUrl, htmlUrl: `https://github.com/${r.login}` })), + ); + chip.title = `@${r.login} — review requested, not yet submitted`; + chip.classList.add("is-pending"); + reviewersProp.body.appendChild(chip); + } + } + const requestBtn = propAddBtn("Request review", () => void doRequestReviewers(full.number)); + reviewersProp.body.appendChild(requestBtn); + // GitHub drops a reviewer from `requestedReviewers` the moment they SUBMIT, + // so this section listed only the people who had not answered yet — and told + // you each of them had "not yet submitted". Anyone who had actually approved + // or requested changes appeared nowhere in the rail at all, which is the one + // question the rail exists to answer. The conversation carries their verdicts; + // it is fetched through the cache the Conversation tab already fills, so this + // costs nothing when that tab loads. + void gget("pr:conversation", full.number, 30_000) + .then((conv) => { + if (!reviewersProp.body.isConnected) return; + // Only a person's LATEST verdict counts — GitHub shows the same. + const latest = new Map<string, string>(); + for (const c of conv) { + if (c.kind !== "review" || !c.state) continue; + const st = c.state.toUpperCase(); + if (st === "COMMENTED" || st === "DISMISSED") continue; + latest.set(c.author, st); + } + if (!latest.size) return; + const pending = new Set((full.requestedReviewers ?? []).map((r) => r.login)); + for (const [login, state] of latest) { + if (pending.has(login)) continue; // still waiting on a re-review + const approved = state === "APPROVED"; + const chip = personChip(login, `https://github.com/${login}.png`, () => + openPeek(memberCard({ login, avatarUrl: null, htmlUrl: `https://github.com/${login}` })), + ); + chip.classList.add(approved ? "is-approved" : "is-blocking"); + chip.title = `@${login} — ${approved ? "approved" : "requested changes"}`; + chip.append(glyph(approved ? "check" : "request-changes")); + reviewersProp.body.insertBefore(chip, requestBtn); + } + }) + .catch(() => { + /* offline — the requested reviewers above are still true */ + }); + + const assignProp = propSection("Assignees", { + onEdit: () => void doAssignees(full, reload), + editTitle: "Edit assignees", + }); + if (full.assignees?.length) { + for (const a of full.assignees) { + assignProp.body.appendChild( + personChip(a.login, a.avatarUrl, () => + openPeek(memberCard({ login: a.login, avatarUrl: a.avatarUrl, htmlUrl: `https://github.com/${a.login}` })), + ), + ); + } + } else { + assignProp.body.appendChild(propAddBtn("Assign", () => void doAssignees(full, reload))); + } + + const labelProp = propSection("Labels", { + onEdit: (anchor) => void doLabels(anchor, full, reload), + editTitle: "Edit labels", + }); + if (full.labels.length) { + for (const l of full.labels) labelProp.body.appendChild(labelChip(l.name, l.color)); + } else { + labelProp.body.appendChild(propAddBtn("Add labels", () => void doLabels(labelProp.root, full, reload))); + } + + const branchesProp = propSection("Branches"); + const branchChip = (ref: string): HTMLElement => { + const b = el("button", "gh-branch-chip"); + b.append(glyph("git-branch"), span(ref)); + b.title = `Show ${ref} in Branches`; + b.addEventListener("click", () => sectionNav?.("branches", { ref })); + return b; + }; + const flow = el("span", "gh-meta-flow"); + flow.append(branchChip(full.head.ref), span("→", "gh-meta-arrow"), branchChip(full.base.ref)); + branchesProp.body.appendChild(flow); - actions.append( - checkoutBtn, - approveBtn, - reviewBtn, - ...(readyBtn ? [readyBtn] : []), - aiBtn, - ...(updateBtn ? [updateBtn] : []), - mergeBtn, - moreBtn, + const checksProp = propSection("Checks"); + if (d.checks) { + const c = el("button", "gh-pill det-checks-pill"); + c.classList.add(`gh-checks-${d.checks}`); + // Humanised, like every other status in the app. This pill sat one column + // from a Checks tab that says "Passed"/"Running" and read a raw lowercase + // `success` / `pending`. + c.textContent = checkStateLabel(d.checks); + c.title = "Open the Checks tab"; + c.addEventListener("click", () => selectSub("checks")); + checksProp.body.appendChild(c); + } else { + checksProp.body.appendChild(propNone("No checks")); + } + + // Who pressed merge — often NOT the author, and the answer to "who shipped + // this?" that used to require opening github.com. + const mergedProp = full.mergedBy ? propSection("Merged by") : undefined; + if (mergedProp && full.mergedBy) { + const mb = full.mergedBy; + mergedProp.body.appendChild( + personChip(mb.login, mb.avatarUrl, () => + openPeek(memberCard({ login: mb.login, avatarUrl: mb.avatarUrl, htmlUrl: `https://github.com/${mb.login}` })), + ), + ); + } + + const msProp = full.milestone ? propSection("Milestone") : undefined; + if (msProp && full.milestone) { + const chip = el("span", "gh-pill det-milestone-chip"); + chip.append(glyph("milestone"), span(full.milestone.title)); + msProp.body.appendChild(chip); + } + + const about = propSection("About"); + about.body.classList.add("det-prop-facts"); + const fact = (k: string, v: string, title?: string): HTMLElement => { + const row = el("div", "det-fact"); + const val = el("span", "det-fact-v"); + val.textContent = v; + if (title) val.title = title; + row.append(span(k, "det-fact-k"), val); + return row; + }; + // GitHub caps the files response, so `d.files.length` is what we FETCHED — + // it read "300" on a 412-file PR while the Files tab said something else. + // `changedFiles` is the PR's own count; fall back only when it is absent. + about.body.appendChild( + fact( + "Files changed", + String(full.changedFiles ?? d.files.length), + full.changedFiles != null && full.changedFiles !== d.files.length + ? `${d.files.length} of ${full.changedFiles} loaded` + : undefined, + ), ); - return actions; + if (typeof full.additions === "number" || typeof full.deletions === "number") { + about.body.appendChild(fact("Lines", `+${full.additions ?? 0} −${full.deletions ?? 0}`)); + } + if (typeof full.commits === "number") about.body.appendChild(fact("Commits", String(full.commits))); + if (typeof full.reviewComments === "number" && full.reviewComments > 0) { + about.body.appendChild(fact("Review comments", String(full.reviewComments))); + } + if (full.headRepoFullName) { + // A PR from a fork runs CI from someone else's branch — worth saying out loud. + about.body.appendChild(fact("From fork", full.headRepoFullName, full.headRepoFullName)); + } + if (full.authorAssociation && full.authorAssociation !== "NONE") { + about.body.appendChild(fact("Author is", associationLabel(full.authorAssociation))); + } + about.body.appendChild(fact("Created", relTimeISO(full.createdAt), absTimeISO(full.createdAt))); + about.body.appendChild(fact("Updated", relTimeISO(full.updatedAt), absTimeISO(full.updatedAt))); + if (full.mergedAt) { + about.body.appendChild(fact("Merged", relTimeISO(full.mergedAt), absTimeISO(full.mergedAt))); + } else if (full.closedAt) { + about.body.appendChild(fact("Closed", relTimeISO(full.closedAt), absTimeISO(full.closedAt))); + } + + // People → classification → where it lands → how it's doing → the facts. + rail.append( + reviewersProp.root, + assignProp.root, + ...(mergedProp ? [mergedProp.root] : []), + labelProp.root, + ...(msProp ? [msProp.root] : []), + branchesProp.root, + checksProp.root, + about.root, + ); + + selectSub(subDefs.some((s) => s.id === activeSubTab) ? activeSubTab : "conversation"); } +// ── Sub-tab content ────────────────────────────────────────────────────────── + async function renderSubTab( content: HTMLElement, full: PullRequest, - d: PrDetail | undefined, + d: PrDetail, id: string, + reload: () => void, + nav: SectionNav, ): Promise<void> { content.replaceChildren(loadingState()); if (id === "conversation") { let conv: PrComment[] = []; + let convFailed: unknown; try { - conv = await host.invoke("pr:conversation", full.number); - } catch { - /* the description still renders; the timeline simply stays empty */ + conv = await gget("pr:conversation", full.number, 30_000); + } catch (e) { + // The description still renders, so this is not fatal — but a timeline + // that silently stays empty is the app claiming the discussion is empty. + // Said, not swallowed. + convFailed = e; } if (activeSubTab !== id) return; // a newer tab was selected mid-fetch content.replaceChildren(); + const timeline = el("div", "gh-subcontent"); + wireProseNav(timeline, sectionNav); if (full.body && full.body.trim()) { - content.appendChild(commentCard(full.user?.login ?? "author", "description", full.body, undefined)); + timeline.appendChild( + commentCard(full.user?.login ?? "author", "description", full.body, undefined, { + association: full.authorAssociation, + reactions: full.reactions, + createdAt: full.createdAt, + }), + ); + } + if (convFailed) { + // Between the description and the composer, where the discussion would + // have been — so it reads as "this part is missing", not as "there is + // nothing here". + timeline.appendChild( + errorState( + "Couldn't load the discussion", + cleanErr(convFailed) || "GitHub request failed.", + reload, + ), + ); } for (const c of conv) { - content.appendChild(commentCard(c.author, undefined, c.body, c.kind === "review" ? c.state : undefined)); + timeline.appendChild( + commentCard(c.author, undefined, c.body, c.kind === "review" ? c.state : undefined, { + createdAt: c.createdAt, + }), + ); } if ((!full.body || !full.body.trim()) && conv.length === 0) { - content.appendChild(emptyState("No conversation yet", "No description or comments on this PR.")); + timeline.appendChild(emptyState("No conversation yet", "No description or comments on this PR.")); } + content.appendChild(timeline); + + // A real composer (not a prompt) — same pattern as the Issues detail. + const composer = el("div", "gh-composer"); + const ta = document.createElement("textarea"); + ta.className = "gh-composer-input"; + ta.placeholder = "Leave a comment…"; + ta.rows = 3; + ta.value = commentDrafts.get(draftKey(full.number)) ?? ""; + ta.addEventListener("input", () => { + if (ta.value.trim()) commentDrafts.set(draftKey(full.number), ta.value); + else commentDrafts.delete(draftKey(full.number)); + }); + const crow = el("div", "gh-composer-actions"); + const send = el("button", "btn btn-primary") as HTMLButtonElement; + send.append(glyph("comment"), span("Comment")); + // An empty composer used to leave this button in full accent, and clicking + // it answered with a toast telling you off. A button that cannot do + // anything should look like it cannot do anything. + const syncSend = (): void => { + const ready = ta.value.trim().length > 0; + send.disabled = !ready; + send.title = ready ? "Post this comment" : "Write something first"; + }; + ta.addEventListener("input", syncSend); + // ⌘Enter posts, which the shortcut sheet has been promising and neither + // composer implemented — so the one keystroke people reach for after + // typing a comment did nothing at all, on both detail pages. + ta.addEventListener("keydown", (e) => { + if (e.key !== "Enter" || !(e.metaKey || e.ctrlKey)) return; + e.preventDefault(); + if (!send.disabled) send.click(); + }); + syncSend(); + send.addEventListener("click", () => void doComment(full.number, ta, send, reload)); + crow.appendChild(send); + composer.append(ta, crow); + content.appendChild(composer); } else if (id === "commits") { let commits; try { commits = await host.invoke("pr:commits", full.number); } catch (e) { if (activeSubTab !== id) return; - content.replaceChildren(errorState("Couldn't load commits", cleanErr(e) || "GitHub request failed.")); + // `reload` is in scope and was simply never passed, so a failed read had + // no way back short of leaving the pull request and returning. + content.replaceChildren( + errorState("Couldn't load commits", cleanErr(e) || "GitHub request failed.", reload), + ); return; } if (activeSubTab !== id) return; @@ -578,22 +1093,41 @@ async function renderSubTab( content.appendChild(emptyState("No commits", "This PR has no commits yet.")); return; } - for (const c of commits) { - const row = el("div", "compare-commit"); - const subj = el("div", "cc-subject"); - subj.textContent = c.message; - const m = el("div", "cc-meta"); - m.textContent = `${c.author} · ${c.shortSha}`; - row.append(subj, m); - content.appendChild(row); - } + // The SHARED list — the same rows Compare draws, grouped by day, with the + // author's face, the body behind a disclosure, a copyable sha and a badge + // on a signed or a merge commit. Every one of those except the avatar was + // already in this response and thrown away in the client mapper. + content.appendChild( + commitList( + commits.map((c) => ({ + sha: c.sha, + shortSha: c.shortSha, + subject: c.message, + body: c.body, + author: c.author, + login: c.login, + avatarUrl: c.avatarUrl, + date: c.date ? Math.floor(Date.parse(c.date) / 1000) : 0, + verified: c.verified, + isMerge: c.isMerge, + })), + { + // The COMMIT, not the graph. This used to eject you out of the pull + // request into a graph row that shows no files at all. + onOpen: (sha) => nav("commit", { sha }), + onCopy: (sha) => void copyText(sha, "Copied the full SHA."), + }, + ), + ); } else if (id === "checks") { let checks; try { checks = await host.invoke("pr:checks", full.number); } catch (e) { if (activeSubTab !== id) return; - content.replaceChildren(errorState("Couldn't load checks", cleanErr(e) || "GitHub request failed.")); + content.replaceChildren( + errorState("Couldn't load checks", cleanErr(e) || "GitHub request failed.", reload), + ); return; } if (activeSubTab !== id) return; @@ -609,18 +1143,37 @@ async function renderSubTab( const name = el("span", "gh-check-name"); name.textContent = c.name; const st = el("span", "gh-check-state"); - st.textContent = state; + st.textContent = checkStateLabel(state); row.append(dot, name, st); if (c.detailsUrl) { + // A row only claims to be clickable when there is somewhere to go. The + // rest used to carry the same pointer cursor and hover as the linked + // ones and swallow the click. row.classList.add("is-link"); - row.addEventListener("click", () => window.open(c.detailsUrl!, "_blank")); + // A failing check is a question about a LOG, so a check that names a + // job goes straight to that log's page — not to the run page, which + // would put a list of jobs between you and the thing you clicked, and + // would make Back mean "Actions" instead of the pull request you left. + // External CI keeps the browser. + const gha = /\/actions\/runs\/(\d+)(?:\/jobs?\/(\d+))?/.exec(c.detailsUrl); + row.title = gha ? "Open the run's logs in-app" : "Open check details"; + row.addEventListener("click", () => { + if (gha) { + const jobId = gha[2] ? Number(gha[2]) : undefined; + const runId = Number(gha[1]); + if (jobId != null) sectionNav?.("joblog", { number: runId, jobId }); + else sectionNav?.("actions", { number: runId }); + } else { + window.open(c.detailsUrl!, "_blank"); + } + }); } content.appendChild(row); } } else { - // ── Files = a real master/detail review surface ────────────────────────── + // ── Files = the full-width review surface ── content.replaceChildren(); - const files = d?.files ?? []; + const files = d.files; if (files.length === 0) { content.appendChild(emptyState("No files changed", "This PR doesn't change any files.")); return; @@ -630,10 +1183,11 @@ async function renderSubTab( } /** - * The Files tab: a left file list (master) and a right pane (detail) showing the - * selected file's real Monaco diff with its inline review threads beneath it. - * Clicking a file fetches `pr:fileDiff`; the threads come from `pr:reviewThreads` - * filtered to that file. A composer adds a new inline comment at a chosen line. + * The Files tab: a left file list and the selected file's real Monaco diff with + * its inline review threads beneath it. Clicking a file fetches `pr:fileDiff`; + * the threads come from `pr:reviewThreads` filtered to that file. In files mode + * the whole page column stretches, so the diff gets real height. Layout comes + * from the .pr-files* CSS — no inline styles. */ function renderFilesTab(content: HTMLElement, full: PullRequest, files: PrFile[]): void { const layout = el("div", "pr-files"); @@ -641,25 +1195,9 @@ function renderFilesTab(content: HTMLElement, full: PullRequest, files: PrFile[] const detail = el("div", "pr-files-detail"); layout.append(list, detail); content.appendChild(layout); - // Structural layout is applied inline so the master/detail + Monaco surface size - // correctly even before the integrator adds the polished .pr-files* CSS. Visual - // theming (borders, colors, radii) is left to those classes. - layout.style.display = "flex"; - layout.style.gap = "12px"; - layout.style.minHeight = "420px"; - layout.style.height = "60vh"; - list.style.flex = "0 0 240px"; - list.style.overflowY = "auto"; - list.style.minWidth = "0"; - detail.style.flex = "1 1 auto"; - detail.style.minWidth = "0"; - detail.style.display = "flex"; - detail.style.flexDirection = "column"; - detail.style.overflow = "hidden"; // Threads are (re)fetched on each file open so a just-added comment / resolve - // shows immediately. A failure is non-fatal — the diff still renders; we just - // show no existing comments. + // shows immediately. A failure is non-fatal — the diff still renders. const loadThreads = async (): Promise<PrReviewThread[]> => { try { return await host.invoke("pr:reviewThreads", full.number); @@ -675,20 +1213,59 @@ function renderFilesTab(content: HTMLElement, full: PullRequest, files: PrFile[] void showFileDiff(detail, full, f, loadThreads); }; + // GitHub sends WORDS; the CSS and the reader both want git's letters. Taking + // the first character collapses "removed" and "renamed" onto the same "R" — + // so a deleted file and a moved one rendered identically, in the same amber, + // on the one screen where telling them apart is the point. "changed" and + // "copied" land on C and are equally wrong. + const STATUS_LETTER: Record<string, string> = { + added: "A", + removed: "D", + modified: "M", + renamed: "R", + copied: "C", + changed: "M", + unchanged: "M", + }; + // The directory every changed file shares, shown ONCE above the list. + // + // The row already leads with the filename and trails the directory, but the + // directory is left-truncated by CSS — so nine files under one folder gave + // three different elisions of the same prefix ("…src/renderer/views", + // "…rc/renderer/views", "…top/src/renderer") and no way to tell whether two + // rows were even in the same place. Folding the shared part leaves the + // distinguishing part short enough to show whole. + const shared = commonDir(files.map((f) => f.filename)); + if (shared) { + const head = el("div", "pr-files-prefix"); + head.append(glyph("folder"), span(shared)); + head.title = `Every file in this pull request is under ${shared}`; + list.appendChild(head); + } + for (const f of files) { - const letter = f.status.charAt(0).toUpperCase(); + const letter = STATUS_LETTER[f.status.toLowerCase()] ?? f.status.charAt(0).toUpperCase(); const row = el("button", `file-row status-${letter}`); (row as HTMLButtonElement).type = "button"; const st = el("span", "file-status"); st.textContent = letter; - const path = el("span", "file-path"); - // Left-truncate long paths so the filename (the part you read) stays visible. - path.textContent = f.filename; - path.title = f.filename; - path.dir = "rtl"; + // Lead with the FILE NAME and trail the directory, the way Changes and + // Compare already do. This list showed one raw rtl-truncated path per row, + // so in a 268px column every row read "…/components/" and the name you were + // actually looking for was the part that got cut. + const rest = f.filename.slice(shared.length); + const cut = rest.lastIndexOf("/"); + const meta = el("span", "dc-file-meta"); + meta.appendChild(span(cut < 0 ? rest : rest.slice(cut + 1), "dc-file-name")); + if (cut > 0) meta.appendChild(span(rest.slice(0, cut), "dc-file-dir")); + // The FULL path in the tooltip, including the folded prefix — the header + // says where you are, but a row still has to be able to answer on its own. + meta.title = f.previousFilename + ? `${f.previousFilename} → ${f.filename}` + : f.filename; const adds = el("span", "gh-adds"); adds.textContent = `+${f.additions} −${f.deletions}`; - row.append(st, path, adds); + row.append(st, meta, adds); row.addEventListener("click", () => openFile(f)); rows.set(f.filename, row); list.appendChild(row); @@ -701,10 +1278,9 @@ function renderFilesTab(content: HTMLElement, full: PullRequest, files: PrFile[] /** * Render one file's diff (left = base, right = head) into a shared DiffPanel, - * with a threads panel beneath it. The DiffPanel is module-owned so it survives - * re-renders of the threads panel but is disposed by the lifecycle hooks above. - * `loadThreads` is re-invoked (not the diff) whenever a review action lands, so - * the comments refresh in place without the Monaco editor flickering. + * with a threads panel beneath it. `loadThreads` is re-invoked (not the diff) + * whenever a review action lands, so the comments refresh in place without the + * Monaco editor flickering. */ async function showFileDiff( detail: HTMLElement, @@ -712,29 +1288,22 @@ async function showFileDiff( f: PrFile, loadThreads: () => Promise<PrReviewThread[]>, ): Promise<void> { - // Build the stable shell ONCE per file open: a diff surface + a threads slot. const surface = el("div", "diff-surface pr-diff-surface"); const threadsSlot = el("div", "pr-threads"); - // Structural sizing inline (theming via the classes): the diff fills the upper - // half, the threads panel scrolls below it. - surface.style.flex = "1 1 60%"; - surface.style.minHeight = "200px"; - threadsSlot.style.flex = "0 1 auto"; - threadsSlot.style.overflowY = "auto"; - threadsSlot.style.maxHeight = "40%"; - threadsSlot.style.marginTop = "10px"; detail.replaceChildren(surface, threadsSlot); threadsSlot.replaceChildren(loadingState("Loading diff…")); - // (Re)create the Monaco panel against the fresh surface and arm the detach - // watcher so it's torn down if the view goes away. disposePrDiff(); const panel = new DiffPanel(surface); prDiffPanel = panel; - watchDiffDetach(surface); + // The diff arrives over the network. `new DiffPanel(surface)` paints NOTHING, + // so the pane sat blank for the whole round trip — and every guard below is a + // bare `if (prDiffPanel !== panel) return`, so anything that superseded this + // open left the blank there permanently, with no message. Say what is + // happening from the first frame. + panel.showEmpty(`Loading ${f.filename}…`, { title: "Reading the diff", kind: "waiting" }); + watchDiffDetach(surface, panel); - // Refresh ONLY the threads panel (re-fetch + re-render) after a review action — - // the diff itself is unchanged, so leave the Monaco editor untouched. const refreshThreads = async (): Promise<void> => { if (prDiffPanel !== panel) return; // the file/view changed under us threadsSlot.replaceChildren(loadingState("Refreshing comments…")); @@ -749,15 +1318,14 @@ async function showFileDiff( try { diff = await host.invoke("pr:fileDiff", { number: full.number, path: f.filename }); } catch (e) { - // Surface the error inside the diff area; the file list stays usable. if (prDiffPanel !== panel) return; // superseded by another open - panel.showEmpty(cleanErr(e) || "Couldn't load this file's diff."); + panel.showEmpty(cleanErr(e) || "GitHub did not return this file's diff.", { kind: "error" }); threadsSlot.replaceChildren(); return; } if (prDiffPanel !== panel) return; // a newer file was opened mid-fetch if (!diff) { - panel.showEmpty("No diff available for this file."); + panel.showEmpty("GitHub reports no textual changes in this file.", { kind: "none" }); } else { panel.showDiff(diff); } @@ -781,38 +1349,61 @@ function renderThreadsPanel( .filter((t) => t.path === f.filename) .sort((a, b) => (a.line ?? 0) - (b.line ?? 0)); - const head = el("div", "pr-threads-head"); - // Flex row with the add-comment action pushed to the right (structural inline). - head.style.display = "flex"; - head.style.alignItems = "center"; - head.style.gap = "8px"; - head.style.margin = "4px 0 8px"; - const title = span(`Review comments (${mine.length})`, "pr-threads-title"); - title.style.fontWeight = "600"; - head.append(glyph("comment-discussion"), title); - const addBtn = el("button", "mini-btn"); - addBtn.style.marginLeft = "auto"; + // The panel FOLDS. It used to take 42% of the pane unconditionally, so the + // diff — the reason the Files tab exists — got 354px of a 913px window even + // on a file with nothing to discuss. It opens by itself when this file has an + // unresolved thread, which is the case where the comment is the point. + const unresolved = mine.filter((t) => !t.isResolved).length; + const open = unresolved > 0; + slot.classList.toggle("is-open", open); + + const head = el("button", "pr-threads-head") as HTMLButtonElement; + head.setAttribute("aria-expanded", String(open)); + const chevron = glyph(open ? "chevron-down" : "chevron-right"); + const title = span( + mine.length === 0 + ? "No comments on this file" + : unresolved + ? `Review comments (${unresolved} open of ${mine.length})` + : `Review comments (${mine.length}, all resolved)`, + "pr-threads-title", + ); + head.append(chevron, glyph("comment-discussion"), title); + head.title = open ? "Hide the review comments" : "Show the review comments"; + slot.appendChild(head); + + const bodyEl = el("div", "pr-threads-body"); + bodyEl.hidden = !open; + const addBtn = el("button", "mini-btn pr-threads-add"); addBtn.append(glyph("comment"), span("Add a comment")); addBtn.title = "Comment on a line of this file"; addBtn.addEventListener("click", () => void addInlineComment(full.number, f.filename, addBtn, reloadFile)); - head.appendChild(addBtn); - slot.appendChild(head); + const tools = el("div", "pr-threads-tools"); + tools.appendChild(addBtn); + bodyEl.appendChild(tools); + + head.addEventListener("click", () => { + const now = bodyEl.hidden === true; + bodyEl.hidden = !now; + slot.classList.toggle("is-open", now); + head.setAttribute("aria-expanded", String(now)); + head.title = now ? "Hide the review comments" : "Show the review comments"; + head.replaceChildren(glyph(now ? "chevron-down" : "chevron-right"), glyph("comment-discussion"), title); + }); if (mine.length === 0) { const none = el("div", "pr-threads-empty"); none.textContent = "No inline comments on this file yet."; - slot.appendChild(none); - return; + bodyEl.appendChild(none); + } else { + for (const t of mine) bodyEl.appendChild(threadCard(full.number, t, reloadFile)); } - for (const t of mine) slot.appendChild(threadCard(full.number, t, reloadFile)); + slot.appendChild(bodyEl); } -/** One review thread: a line anchor + its comments + resolve / reply controls. - * Built on the existing `.gh-comment` shell (border / radius) for instant polish, - * with `.pr-thread*` hooks the integrator can theme further. */ +/** One review thread: a line anchor + its comments + resolve / reply controls. */ function threadCard(prNumber: number, t: PrReviewThread, reloadFile: () => void): HTMLElement { const card = el("div", `gh-comment pr-thread${t.isResolved ? " is-resolved" : ""}`); - if (t.isResolved) card.style.opacity = "0.72"; const hd = el("div", "gh-comment-head pr-thread-head"); const anchor = el("span", "pr-thread-anchor"); @@ -823,8 +1414,7 @@ function threadCard(prNumber: number, t: PrReviewThread, reloadFile: () => void) statusPill.classList.add(t.isResolved ? "gh-review-approved" : "gh-thread-open"); hd.appendChild(statusPill); - const resolveBtn = el("button", "mini-btn gh-inline-edit"); - resolveBtn.style.marginLeft = "auto"; + const resolveBtn = el("button", "mini-btn gh-inline-edit pr-thread-resolve"); resolveBtn.append(glyph(t.isResolved ? "issue-reopened" : "check"), span(t.isResolved ? "Unresolve" : "Resolve")); resolveBtn.addEventListener("click", () => void toggleResolve(t.id, !t.isResolved, resolveBtn, reloadFile), @@ -834,13 +1424,7 @@ function threadCard(prNumber: number, t: PrReviewThread, reloadFile: () => void) for (const c of t.comments) { const cm = el("div", "pr-thread-comment"); - cm.style.padding = "8px 12px"; - cm.style.borderTop = "1px solid var(--app-border)"; const ch = el("div", "pr-thread-comment-head"); - ch.style.display = "flex"; - ch.style.alignItems = "center"; - ch.style.gap = "7px"; - ch.style.marginBottom = "4px"; ch.append(avatar(c.author.login, c.author.avatarUrl, 20), span(c.author.login, "pr-thread-author")); if (c.createdAt) { const when = span(relTimeISO(c.createdAt), "gh-comment-when"); @@ -848,9 +1432,7 @@ function threadCard(prNumber: number, t: PrReviewThread, reloadFile: () => void) ch.appendChild(when); } cm.appendChild(ch); - const bd = el("div", "gh-body-md"); - bd.style.margin = "0"; - bd.style.padding = "0"; + const bd = el("div", "gh-body-md pr-thread-body"); if (c.body.trim()) { try { bd.innerHTML = renderMarkdown(c.body); @@ -868,16 +1450,15 @@ function threadCard(prNumber: number, t: PrReviewThread, reloadFile: () => void) // Reply box (inline) — Enter submits, Shift+Enter for a newline. const replyRow = el("div", "pr-thread-reply"); - replyRow.style.display = "flex"; - replyRow.style.gap = "8px"; - replyRow.style.alignItems = "flex-end"; - replyRow.style.padding = "8px 12px"; - replyRow.style.borderTop = "1px solid var(--app-border)"; const ta = document.createElement("textarea"); ta.className = "gh-composer-input pr-reply-input"; - ta.style.flex = "1 1 auto"; ta.placeholder = "Reply…"; ta.rows = 2; + ta.value = replyDrafts.get(t.id) ?? ""; + ta.addEventListener("input", () => { + if (ta.value.trim()) replyDrafts.set(t.id, ta.value); + else replyDrafts.delete(t.id); + }); const replyBtn = el("button", "btn btn-primary"); replyBtn.append(span("Reply")); replyBtn.addEventListener("click", () => void replyToThread(prNumber, t.id, ta, replyBtn, reloadFile)); @@ -892,27 +1473,52 @@ function threadCard(prNumber: number, t: PrReviewThread, reloadFile: () => void) return card; } -function commentCard(author: string, suffix: string | undefined, body: string, reviewState?: string): HTMLElement { +/** "fork" marker naming the head repo — hover for the full owner/repo. */ +function forkChip(headRepo: string): HTMLElement { + const c = el("span", "gh-fork-chip"); + c.append(glyph("repo-forked"), span("fork")); + c.title = `Head branch lives in ${headRepo}`; + return c; +} + +function commentCard( + author: string, + suffix: string | undefined, + body: string, + reviewState?: string, + extra: { association?: string; reactions?: ReactionSummary; createdAt?: string } = {}, +): HTMLElement { const card = el("div", "gh-comment"); const hd = el("div", "gh-comment-head"); const who = el("span", "gh-comment-author"); - who.textContent = suffix ? `${author} · ${suffix}` : author; + who.append(avatar(author, `https://github.com/${author}.png`, 18), span(suffix ? `${author} · ${suffix}` : author)); hd.appendChild(who); + const assoc = associationBadge(extra.association); + if (assoc) hd.appendChild(assoc); if (reviewState) { const badge = pill(reviewState.toLowerCase().replace(/_/g, " ")); badge.classList.add(`gh-review-${reviewState.toLowerCase()}`); hd.appendChild(badge); } + // The conversation carried NO time at all — a wall of comments with no way to + // tell a reply from last October from one posted an hour ago. + if (extra.createdAt) { + const when = span(relTimeISO(extra.createdAt), "gh-comment-when"); + when.title = absTimeISO(extra.createdAt); + hd.appendChild(when); + } card.appendChild(hd); if (body && body.trim()) { const bd = el("div", "gh-body-md"); bd.innerHTML = renderMarkdown(body); card.appendChild(bd); } + const reactions = reactionRow(extra.reactions); + if (reactions) card.appendChild(reactions); return card; } -// ── Mutations ───────────────────────────────────────────────────────────────── +// ── Mutations (disable trigger → toast → bust cache → re-render) ────────────── async function doCheckout(n: number, btn: HTMLElement): Promise<void> { (btn as HTMLButtonElement).disabled = true; @@ -930,67 +1536,174 @@ async function doCheckout(n: number, btn: HTMLElement): Promise<void> { } } -async function doApprove(n: number, btn: HTMLElement, refreshList: () => void): Promise<void> { - (btn as HTMLButtonElement).disabled = true; - try { - const r = await host.invoke("pr:approve", n); - if (!r.ok) { - toast(r.message ?? "Couldn't approve the PR.", "error"); - return; - } - btn.replaceChildren(glyph("check"), span("Approved")); - toast(`Approved pull request #${n}.`, "success"); - refreshList(); - } catch (e) { - toast(cleanErr(e) || "Couldn't approve the PR.", "error"); - } finally { - (btn as HTMLButtonElement).disabled = false; - } -} - +/** + * The review modal — verdict (Comment / Approve / Request changes) + a real + * multi-line body in ONE surface, GitHub-style, instead of the old one-line + * prompt. `event` preselects the verdict the caller chose; the user can still + * change it here. A body is required only for "Request changes". + */ async function doReview( n: number, - event: "COMMENT" | "REQUEST_CHANGES", + event: PrReviewEvent, btn: HTMLElement, - refreshList: () => void, + reload: () => void, ): Promise<void> { - const verb = event === "COMMENT" ? "Comment" : "Request changes"; - const body = await promptInline( - `${verb} on PR #${n}`, - event === "COMMENT" ? "Leave a comment…" : "Describe the changes you'd like…", - "", - "Submit", - true, // allowEmpty: distinguish an empty submit ("") from a cancel (null) - ); - if (body === null) return; // cancelled - if (!body && event === "REQUEST_CHANGES") { - toast("A comment is required to request changes.", "error"); - return; - } + // A review is the longest thing anyone writes in this app, and it used to be + // collected, the card closed, and only THEN sent — so a rejected submit + // answered several paragraphs of considered feedback with a toast over an + // empty screen, with no way back to the text. `formWithRetry` re-opens the + // card carrying exactly what was written, and says why inside it. (btn as HTMLButtonElement).disabled = true; try { - const r = await host.invoke("pr:review", { number: n, event, body: body || undefined }); - if (!r.ok) { - toast(r.message ?? "Couldn't submit the review.", "error"); - return; - } - toast(`Review submitted on PR #${n}.`, "success"); - refreshList(); - } catch (e) { - toast(cleanErr(e) || "Couldn't submit the review.", "error"); + await formWithRetry<{ event: PrReviewEvent; body: string }>( + (seed, error) => reviewModal(n, seed?.event ?? event, seed?.body ?? "", error), + async (choice) => { + try { + const r = await host.invoke("pr:review", { + number: n, + event: choice.event, + body: choice.body || undefined, + }); + if (!r.ok) return r.message ?? "Couldn't submit the review."; + } catch (e) { + return cleanErr(e) || "Couldn't submit the review."; + } + toast(`Review submitted on PR #${n}.`, "success"); + reload(); + return undefined; + }, + ); } finally { (btn as HTMLButtonElement).disabled = false; } } +const REVIEW_VERDICTS: ReadonlyArray<{ event: PrReviewEvent; label: string; icon: string; hint: string }> = [ + { event: "COMMENT", label: "Comment", icon: "comment", hint: "Feedback without an explicit approval" }, + { event: "APPROVE", label: "Approve", icon: "check", hint: "The change is good to merge" }, + { event: "REQUEST_CHANGES", label: "Request changes", icon: "request-changes", hint: "Must be addressed before merging" }, +]; + +function reviewModal( + n: number, + initial: PrReviewEvent, + initialBody = "", + error?: string, +): Promise<{ event: PrReviewEvent; body: string } | null> { + return new Promise((resolve) => { + let settled = false; + openModal((close) => { + const finish = (v: { event: PrReviewEvent; body: string } | null): void => { + if (settled) return; + settled = true; + resolve(v); + close(); + }; + + const card = el("div", "modal-card gh-pr-form review-modal"); + const h = el("div", "modal-title"); + h.textContent = `Review pull request #${n}`; + card.appendChild(h); + + let selected: PrReviewEvent = initial; + const verdictRows: HTMLElement[] = []; + const verdicts = el("div", "review-verdicts"); + const syncVerdicts = (): void => { + verdictRows.forEach((r) => r.classList.toggle("is-selected", r.dataset.event === selected)); + submitLabel.textContent = + selected === "APPROVE" ? "Approve" : selected === "REQUEST_CHANGES" ? "Request changes" : "Submit review"; + }; + for (const v of REVIEW_VERDICTS) { + const row = el("button", "review-verdict"); + (row as HTMLButtonElement).type = "button"; + row.dataset.event = v.event; + const lead = el("span", "review-verdict-lead"); + lead.appendChild(glyph(v.icon)); + const text = el("span", "review-verdict-text"); + const l = el("span", "review-verdict-label"); + l.textContent = v.label; + const hint = el("span", "review-verdict-hint"); + hint.textContent = v.hint; + text.append(l, hint); + row.append(lead, text, glyph("check")); + row.addEventListener("click", () => { + selected = v.event; + syncVerdicts(); + }); + verdictRows.push(row); + verdicts.appendChild(row); + } + card.appendChild(verdicts); + + const ta = document.createElement("textarea"); + ta.className = "gh-form-textarea"; + ta.placeholder = "Leave a review comment… (required for Request changes)"; + ta.rows = 5; + ta.value = initialBody; + card.appendChild(ta); + // Why the last attempt failed, shown WITH the text it failed on — a toast + // over a closed card told you nothing you could act on. + if (error) { + const note = el("div", "modal-note-error"); + note.textContent = error; + card.appendChild(note); + } + + const actions = el("div", "modal-actions"); + const cancel = el("button", "mini-btn"); + cancel.textContent = "Cancel"; + const ok = el("button", "btn btn-primary modal-ok"); + const submitLabel = span("Submit review"); + ok.appendChild(submitLabel); + actions.append(cancel, ok); + card.appendChild(actions); + syncVerdicts(); + + const submit = (): void => { + const body = ta.value.trim(); + if (!body && selected === "REQUEST_CHANGES") { + ta.focus(); + toast("A comment is required to request changes.", "error"); + return; + } + finish({ event: selected, body }); + }; + cancel.addEventListener("click", () => finish(null)); + ok.addEventListener("click", submit); + card.addEventListener("keydown", (e) => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + submit(); + } + }); + + return { + card, + focusEl: ta, + label: `Review pull request #${n}`, + // A route change (a window focus counts) must not take a written review. + hasUnsavedWork: () => ta.value.trim() !== initialBody.trim(), + onClose: () => { + if (!settled) resolve(null); + }, + }; + }); + }); +} + async function doComment( n: number, - detail: HTMLElement, - pr: PullRequest, - refreshList: () => void, + ta: HTMLTextAreaElement, + btn: HTMLElement, + reload: () => void, ): Promise<void> { - const body = await promptInline(`Comment on PR #${n}`, "Write a comment…", "", "Comment"); - if (!body) return; + const body = ta.value.trim(); + if (!body) { + toast("Write a comment first.", "info"); + return; + } + (btn as HTMLButtonElement).disabled = true; + ta.disabled = true; try { const r = await host.invoke("pr:comment", { number: n, body }); if (!r.ok) { @@ -998,20 +1711,18 @@ async function doComment( return; } toast(`Commented on PR #${n}.`, "success"); - // re-render the detail so the Conversation tab reloads with the new comment + commentDrafts.delete(draftKey(n)); activeSubTab = "conversation"; - void showDetail(detail, pr, refreshList); + reload(); } catch (e) { toast(cleanErr(e) || "Couldn't post the comment.", "error"); + } finally { + (btn as HTMLButtonElement).disabled = false; + ta.disabled = false; } } -async function doSetState( - n: number, - state: "open" | "closed", - reload: () => void, - refreshList: () => void, -): Promise<void> { +async function doSetState(n: number, state: "open" | "closed", reload: () => void): Promise<void> { if (state === "closed") { const ok = await confirmDialog({ title: `Close pull request #${n}?`, @@ -1028,19 +1739,13 @@ async function doSetState( return; } toast(state === "closed" ? `Closed PR #${n}.` : `Reopened PR #${n}.`, "success"); - reload(); // refetch + re-render the detail so the action cluster flips - if (state === "closed") refreshList(); // a closed PR leaves the open list + reload(); } catch (e) { toast(cleanErr(e) || "Couldn't update the pull request.", "error"); } } -async function doMarkReady( - n: number, - btn: HTMLElement, - reload: () => void, - refreshList: () => void, -): Promise<void> { +async function doMarkReady(n: number, btn: HTMLElement, reload: () => void): Promise<void> { (btn as HTMLButtonElement).disabled = true; try { const r = await host.invoke("pr:markReady", n); @@ -1049,8 +1754,7 @@ async function doMarkReady( return; } toast(`PR #${n} is ready for review.`, "success"); - reload(); // the draft pill + "Mark ready" button disappear - refreshList(); + reload(); } catch (e) { toast(cleanErr(e) || "Couldn't mark the PR ready.", "error"); } finally { @@ -1058,11 +1762,7 @@ async function doMarkReady( } } -async function doMerge( - n: number, - method: "merge" | "squash" | "rebase", - refreshList: () => void, -): Promise<void> { +async function doMerge(n: number, method: "merge" | "squash" | "rebase", reload: () => void): Promise<void> { const ok = await confirmDialog({ title: `Merge pull request #${n}?`, message: `This performs a ${method} merge on GitHub and can't be undone here.`, @@ -1076,7 +1776,7 @@ async function doMerge( return; } toast(`Merged pull request #${n}.`, "success"); - refreshList(); + reload(); } catch (e) { toast(cleanErr(e) || "Merge failed.", "error"); } @@ -1087,7 +1787,7 @@ async function doMerge( async function doRequestReviewers(n: number, reRequest = false): Promise<void> { let people: RepoCollaborator[] = []; try { - people = await host.invoke("pr:reviewers", undefined); + people = await gget("pr:reviewers", undefined, 60000); } catch { /* fall through to the free-text path */ } @@ -1122,76 +1822,65 @@ async function doRequestReviewers(n: number, reRequest = false): Promise<void> { } } -// ── PR review-depth mutations (edit · labels · assignees · update · inline) ───── - -/** Edit the PR's title + body in one unified form, then PATCH via pr:edit. */ -async function doEdit(detail: HTMLElement, pr: PullRequest, refreshList: () => void): Promise<void> { - const res = await editForm({ - title: `Edit pull request #${pr.number}`, - okLabel: "Save", - titleValue: pr.title, - titlePlaceholder: "Pull request title", - bodyValue: pr.body ?? "", - bodyPlaceholder: "Describe the change…", - }); - if (!res) return; - if (res.title === pr.title && res.body === (pr.body ?? "")) return; // nothing changed - try { - const r = await host.invoke("pr:edit", { number: pr.number, title: res.title, body: res.body }); - if (!r.ok) { - toast(r.message ?? "Couldn't edit the pull request.", "error"); - return; - } - toast(`Updated pull request #${pr.number}.`, "success"); - activeSubTab = "conversation"; // the description card reflects the new body - void showDetail(detail, pr, refreshList); - } catch (e) { - toast(cleanErr(e) || "Couldn't edit the pull request.", "error"); - } -} +/* + * `doEdit` used to open `editForm` here: a modal with a title box and a body + * box, and — unlike the issue form — no draft, so Escape took everything + * written. It is `views/issueCompose.ts` now, routed as "predit". + */ + /** A toggle-menu of the repo's labels (current ones checked) → pr:setLabels. */ -async function doLabels( - anchor: HTMLElement, - detail: HTMLElement, - pr: PullRequest, - refreshList: () => void, -): Promise<void> { +async function doLabels(anchor: HTMLElement, pr: PullRequest, reload: () => void): Promise<void> { let repoLabels: RepoLabel[] = []; try { - repoLabels = await host.invoke("pr:labels", undefined); + repoLabels = await gget("pr:labels", undefined, 60000); } catch (e) { toast(cleanErr(e) || "Couldn't load labels.", "error"); return; } - if (repoLabels.length === 0) { + // Optional-chained, but only as belt and braces: in the app `pr:labels` goes + // through `withRepo`, which either returns the handler's array or throws an + // ExpectedError — it cannot resolve undefined. It was the HARNESS that had no + // fixture for this channel and answered undefined, so reading `.length` threw + // into the unhandled-rejection boundary and the picker could not be opened in + // a test at all. Which is why the real defect below — a request fired per + // tick — had never been checked. + if (!repoLabels?.length) { toast("This repo has no labels defined.", "info"); return; } - const current = new Set(pr.labels.map((l) => l.name)); + // The SAME control as the issue's, which it was not: because these items were + // plain (not `checkable`), openMenu took the close-then-act path, so every + // tick closed the menu and fired its own request. Labelling something with + // three labels meant reopening the picker three times and writing three + // times. Ticks are batched here too, sent once on close — and Escape + // discards, the way Escape does everywhere else. + const before = new Set(pr.labels.map((l) => l.name)); + const picked = new Set(before); openMenu( anchor, repoLabels.map((l) => ({ label: l.name, - icon: "tag", - current: current.has(l.name), + iconEl: swatch(l.color), + checkable: true, + current: picked.has(l.name), onClick: () => { - const next = new Set(current); - if (next.has(l.name)) next.delete(l.name); - else next.add(l.name); - void applyLabels(detail, pr, [...next], refreshList); + if (picked.has(l.name)) picked.delete(l.name); + else picked.add(l.name); }, })), - { searchable: true }, + { + searchable: repoLabels.length > 8, + onClose: (reason) => { + if (reason === "escape") return; + const same = picked.size === before.size && [...picked].every((x) => before.has(x)); + if (!same) void applyLabels(pr, [...picked], reload); + }, + }, ); } -async function applyLabels( - detail: HTMLElement, - pr: PullRequest, - labelsList: string[], - refreshList: () => void, -): Promise<void> { +async function applyLabels(pr: PullRequest, labelsList: string[], reload: () => void): Promise<void> { try { const r = await host.invoke("pr:setLabels", { number: pr.number, labels: labelsList }); if (!r.ok) { @@ -1199,31 +1888,26 @@ async function applyLabels( return; } toast("Labels updated.", "success"); - void showDetail(detail, pr, refreshList); + reload(); } catch (e) { toast(cleanErr(e) || "Couldn't update labels.", "error"); } } /** Edit the PR's assignees via the avatar-rich people picker → pr:setAssignees. */ -async function doAssignees( - anchor: HTMLElement, - detail: HTMLElement, - pr: PullRequest, - refreshList: () => void, -): Promise<void> { - void anchor; +async function doAssignees(pr: PullRequest, reload: () => void): Promise<void> { let people: RepoCollaborator[] = []; try { - people = await host.invoke("pr:reviewers", undefined); + people = await gget("pr:reviewers", undefined, 60000); } catch { /* fall through to the free-text path */ } + const current = (pr.assignees ?? []).map((a) => a.login); let assignees: string[] | null; if (people.length) { - assignees = await peoplePickerModal({ title: "Assignees", okLabel: "Save", people, selected: [] }); + assignees = await peoplePickerModal({ title: "Assignees", okLabel: "Save", people, selected: current }); } else { - const csv = await promptInline("Assignees", "comma-separated logins, e.g. octocat, hubot", "", "Save"); + const csv = await promptInline("Assignees", "comma-separated logins, e.g. octocat, hubot", current.join(", "), "Save"); assignees = csv === null ? null : csv.split(",").map((s) => s.trim().replace(/^@/, "")).filter(Boolean); } @@ -1235,19 +1919,14 @@ async function doAssignees( return; } toast("Assignees updated.", "success"); - void showDetail(detail, pr, refreshList); + reload(); } catch (e) { toast(cleanErr(e) || "Couldn't update assignees.", "error"); } } /** Merge the latest base into the PR head (pr:updateBranch). */ -async function doUpdateBranch( - n: number, - reload: () => void, - refreshList: () => void, - btn?: HTMLElement, -): Promise<void> { +async function doUpdateBranch(n: number, reload: () => void, btn?: HTMLElement): Promise<void> { if (btn) (btn as HTMLButtonElement).disabled = true; try { const r = await host.invoke("pr:updateBranch", n); @@ -1257,7 +1936,6 @@ async function doUpdateBranch( } toast(`Updated PR #${n} with the base branch.`, "success"); reload(); // the head SHA moved — refetch the detail (files / checks change) - refreshList(); } catch (e) { toast(cleanErr(e) || "Couldn't update the branch.", "error"); } finally { @@ -1324,6 +2002,8 @@ async function replyToThread( return; } toast("Reply posted.", "success"); + // Spent, and only now — a failed post keeps the text for the retry. + replyDrafts.delete(threadId); reloadFile(); } catch (e) { toast(cleanErr(e) || "Couldn't post the reply.", "error"); @@ -1405,6 +2085,7 @@ export async function openCreatePr( return; } toast(`Created pull request ${r.message ?? ""}.`.trim(), "success"); + bust("pr"); refresh(); } catch (e) { toast(cleanErr(e) || "Couldn't create the pull request.", "error"); @@ -1428,92 +2109,77 @@ function createPrModal(opts: { }): Promise<CreatePrResult | null> { return new Promise((resolve) => { let settled = false; - const overlay = el("div", "modal-overlay"); - overlay.setAttribute("role", "dialog"); - overlay.setAttribute("aria-modal", "true"); - overlay.setAttribute("aria-label", "New pull request"); - const card = el("div", "modal-card gh-pr-form"); - const h = el("div", "modal-title"); - h.textContent = "New pull request"; - - const mkSelect = (label: string, selected: string): { row: HTMLElement; sel: HTMLSelectElement } => { - const row = el("label", "gh-form-row"); - row.append(span(label, "gh-form-label")); - const sel = document.createElement("select"); - sel.className = "gh-form-select"; - for (const b of opts.branches) { - const o = document.createElement("option"); - o.value = b.name; - o.textContent = b.name + (b.isDefault ? " (default)" : ""); - if (b.name === selected) o.selected = true; - sel.appendChild(o); - } - row.appendChild(sel); - return { row, sel }; - }; - const head = mkSelect("Compare (head)", opts.defaultHead); - const base = mkSelect("Into (base)", opts.defaultBase); - - const titleRow = el("label", "gh-form-row"); - titleRow.append(span("Title", "gh-form-label")); - const title = document.createElement("input"); - title.className = "modal-input"; - title.placeholder = "Pull request title"; - titleRow.appendChild(title); - - const bodyRow = el("label", "gh-form-row"); - bodyRow.append(span("Description", "gh-form-label")); - const body = document.createElement("textarea"); - body.className = "gh-form-textarea"; - body.placeholder = "Describe the change… (optional)"; - body.rows = 5; - bodyRow.appendChild(body); - - const draftRow = el("label", "gh-form-check"); - const draft = document.createElement("input"); - draft.type = "checkbox"; - draftRow.append(draft, span("Create as draft")); - - const actions = el("div", "modal-actions"); - const cancel = el("button", "mini-btn"); - cancel.textContent = "Cancel"; - const ok = el("button", "btn btn-primary modal-ok"); - ok.append(span("Create pull request")); - actions.append(cancel, ok); - card.append(h, head.row, base.row, titleRow, bodyRow, draftRow, actions); - - const finish = (v: CreatePrResult | null): void => { - if (settled) return; - settled = true; - overlay.remove(); - document.removeEventListener("keydown", onKey, true); - resolve(v); - }; - const onKey = (e: KeyboardEvent): void => { - if (e.key === "Escape") { - e.preventDefault(); - finish(null); - return; - } - trapTab(e, card); - }; - cancel.addEventListener("click", () => finish(null)); - ok.addEventListener("click", () => - finish({ - title: title.value, - head: head.sel.value, - base: base.sel.value, - body: body.value, - draft: draft.checked, - }), - ); - overlay.addEventListener("mousedown", (e) => { - if (e.target === overlay) finish(null); + openModal((close) => { + const card = el("div", "modal-card gh-pr-form"); + const h = el("div", "modal-title"); + h.textContent = "New pull request"; + + const mkSelect = (label: string, selected: string): { row: HTMLElement; sel: HTMLSelectElement } => { + const row = el("label", "gh-form-row"); + row.append(span(label, "gh-form-label")); + const sel = document.createElement("select"); + sel.className = "gh-form-select"; + for (const b of opts.branches) { + const o = document.createElement("option"); + o.value = b.name; + o.textContent = b.name + (b.isDefault ? " (default)" : ""); + if (b.name === selected) o.selected = true; + sel.appendChild(o); + } + row.appendChild(sel); + return { row, sel }; + }; + const head = mkSelect("Compare (head)", opts.defaultHead); + const base = mkSelect("Into (base)", opts.defaultBase); + + const titleRow = el("label", "gh-form-row"); + titleRow.append(span("Title", "gh-form-label")); + const title = document.createElement("input"); + title.className = "modal-input"; + title.placeholder = "Pull request title"; + titleRow.appendChild(title); + + const bodyRow = el("label", "gh-form-row"); + bodyRow.append(span("Description", "gh-form-label")); + const body = document.createElement("textarea"); + body.className = "gh-form-textarea"; + body.placeholder = "Describe the change… (optional)"; + body.rows = 5; + bodyRow.appendChild(body); + + const draftRow = el("label", "gh-form-check"); + const draft = document.createElement("input"); + draft.type = "checkbox"; + draftRow.append(draft, span("Create as draft")); + + const actions = el("div", "modal-actions"); + const cancel = el("button", "mini-btn"); + cancel.textContent = "Cancel"; + const ok = el("button", "btn btn-primary modal-ok"); + ok.append(span("Create pull request")); + actions.append(cancel, ok); + card.append(h, head.row, base.row, titleRow, bodyRow, draftRow, actions); + + cancel.addEventListener("click", close); + ok.addEventListener("click", () => { + settled = true; + resolve({ + title: title.value, + head: head.sel.value, + base: base.sel.value, + body: body.value, + draft: draft.checked, + }); + close(); + }); + return { + card, + focusEl: title, + label: "New pull request", + onClose: () => { + if (!settled) resolve(null); + }, + }; }); - overlay.appendChild(card); - document.body.appendChild(overlay); - document.addEventListener("keydown", onKey, true); - setTimeout(() => title.focus(), 0); }); } - diff --git a/apps/desktop/src/renderer/views/rebase.ts b/apps/desktop/src/renderer/views/rebase.ts index d39503d..b5c537b 100644 --- a/apps/desktop/src/renderer/views/rebase.ts +++ b/apps/desktop/src/renderer/views/rebase.ts @@ -44,10 +44,17 @@ async function mount(wrap: HTMLElement, nav: (view: string) => void): Promise<vo let state: RebasePlanState; try { state = await host.invoke("rebase:load", {}); + // The shape check belongs INSIDE the try: reading `state.ok` outside it + // meant an unexpected response threw past the error path and left the + // loading spinner on screen forever, with no message and no retry. + if (!state || typeof state.ok !== "boolean") { + throw new Error("The rebase plan came back in an unexpected shape."); + } } catch (err) { wrap.replaceChildren( emptyState("Couldn't load the rebase plan", cleanErr(err), { icon: "warning", + action: { label: "Try again", icon: "refresh", onClick: () => void mount(wrap, nav) }, }), ); return; @@ -69,10 +76,15 @@ async function mount(wrap: HTMLElement, nav: (view: string) => void): Promise<vo } if (!state.commits.length) { + // The host's note, when it has one, is the REASON the list is empty — most + // often "a merge commit in this range isn't listed", because a range made + // only of merges leaves nothing to pick. Printing the hardcoded sentence + // over it stated a falsehood: there ARE commits between those two refs. wrap.replaceChildren( emptyState( "Nothing to rebase", - `No commits between ${short(state.base)} and ${state.branch}. Pick a different base to reach further back.`, + state.message ?? + `No commits between ${short(state.base)} and ${state.branch}. Pick a different base to reach further back.`, { icon: "git-commit" }, ), baseBar(state, wrap, nav), @@ -86,13 +98,20 @@ async function mount(wrap: HTMLElement, nav: (view: string) => void): Promise<vo // ── the workspace ──────────────────────────────────────────────────────────── function build(wrap: HTMLElement, nav: (view: string) => void, state: RebasePlanState): void { - const original: Row[] = state.commits.map((c) => ({ ...c, action: "pick", message: c.subject })); + // Seed the reword box from the FULL message, not the subject. Seeding from + // the subject meant choosing Reword and changing nothing still deleted the + // body and every trailer under it. + const original: Row[] = state.commits.map((c) => ({ + ...c, + action: "pick", + message: c.body ?? c.subject, + })); let rows: Row[] = original.map((r) => ({ ...r })); let busy = false; const head = el("div", "rb-head"); const title = el("div", "rb-title"); - title.append(glyph("list-ordered"), span("Interactive Rebase")); + title.append(glyph("list-ordered"), span("Interactive rebase")); const sub = el("div", "rb-sub"); const branchB = el("b", "rb-branch"); branchB.textContent = state.branch; @@ -114,22 +133,59 @@ function build(wrap: HTMLElement, nav: (view: string) => void, state: RebasePlan resetBtn.append(glyph("discard"), span("Reset plan")); const preview = span("", "rb-preview"); const applyBtn = el("button", "rb-btn primary") as HTMLButtonElement; - const applyLabel = span("Start Rebase"); + const applyLabel = span("Start rebase"); applyBtn.append(glyph("play"), applyLabel); + + /** + * Carry other local branches through the rewrite. + * + * Every commit gets a NEW sha, so a branch pointing at an old one is not left + * alone by the rebase — it is left on a parallel line nothing references. The + * plan builder emits `update-ref` for these, and the desktop never asked it + * to, so a stack of branches inside the range was silently orphaned. Defaults + * to the repo's own `rebase.updateRefs`, which is what the user's git would + * have done; the control only appears when there is actually something to + * carry. + */ + const carried = [...new Set(rows.flatMap((r) => r.branches ?? []))]; + let carryBranches = state.updateRefs ?? false; + if (carried.length) { + const wrapEl = el("label", "rb-carry"); + const box = document.createElement("input"); + box.type = "checkbox"; + box.checked = carryBranches; + box.addEventListener("change", () => { + carryBranches = box.checked; + }); + const names = carried.length <= 3 ? carried.join(", ") : `${carried.length} other branches`; + wrapEl.append(box, span(`Move ${names} with the rewrite`)); + wrapEl.title = + `These branches point at commits in this range: ${carried.join(", ")}. ` + + `Rewriting gives those commits new ids, so unless they are moved too they ` + + `will point at commits that are no longer in ${state.branch}.`; + foot.appendChild(wrapEl); + } foot.append(resetBtn, el("span", "rb-spacer"), preview, applyBtn); wrap.replaceChildren(head, explain, hintBar(), list, banner, foot); // A note from the host (base fell back, or the list was capped) is worth // showing — otherwise the range silently isn't what the user asked for. - if (state.message) { + /** The host's own note — the base fell back, or the list was capped. It is + * the only thing telling the reader the range is not what they asked for, + * and it is PERSISTENT: it belongs on screen for as long as the plan does. */ + const showHostNote = (): void => { + if (!state.message) { + banner.hidden = true; + return; + } banner.textContent = state.message; banner.className = "rb-banner warn"; banner.hidden = false; - } + }; + showHostNote(); // ── model helpers ── - const firstKeptIndex = (): number => rows.findIndex((r) => r.action !== "drop"); /** * The commit a squash/fixup folds INTO. git melds into the entry BEFORE it in @@ -146,23 +202,76 @@ function build(wrap: HTMLElement, nav: (view: string) => void, state: RebasePlan return null; }; + /** + * A squash/fixup whose fold target has GONE — the plan `buildRebasePlan` will + * refuse ("the oldest commit can't be squash"). + * + * `setAction` refuses to create that state, but nothing re-checked it after, + * and two ordinary gestures walk straight into it: drop the commit the squash + * folds into (`foldTargetSubject` skips drops, so the target vanishes), or + * drag the squash row to the bottom, which is this view's headline feature. + * The row kept saying "Folds down into the commit below it" about a commit + * that does not exist — and with the row at the bottom the thing physically + * below it is the dimmed `onto` base, so it read as "folds into the base" — + * while the footer counted the fold and Start rebase walked the user through + * the force-push confirm for a plan the view could already see was dead. + * + * Above the display cap this is NOT an error: apply() appends the older + * commits below the cap as plain picks, so the bottom row really does have + * something below it to fold into and the rebase runs correctly. + */ + const hiddenTail = (): boolean => (state.replayCount ?? rows.length) > rows.length; + const foldOrphan = (i: number): boolean => + (rows[i].action === "squash" || rows[i].action === "fixup") && + foldTargetSubject(i) === null && + !hiddenTail(); + + /** Transient messages share the banner with the host's note — so a flash has + * to give it BACK. It used to hide the banner outright after four seconds, + * which permanently destroyed the note saying the range had been capped or + * the base substituted: one refused squash, and the reader lost the only + * statement that their plan was not the whole story, for the rest of the + * session. Sequenced, so an interrupted flash cannot restore over a newer + * one. */ + let flashSeq = 0; const flashBanner = (msg: string, kind: "warn" | "error" = "warn"): void => { + const mine = ++flashSeq; banner.textContent = msg; banner.className = `rb-banner ${kind}`; banner.hidden = false; window.setTimeout(() => { - banner.hidden = true; + if (mine !== flashSeq) return; + showHostNote(); }, 4200); }; const setAction = (i: number, action: RebaseAction): void => { - if ((action === "squash" || action === "fixup") && i === firstKeptIndex()) { - flashBanner("The top commit has nothing above it to fold into."); + // A squash folds into the nearest kept commit BELOW — the list is + // newest-first (issue #18), and git melds into the entry before it in the + // todo file. So the commit that CANNOT be squashed is the last kept one, + // not the first. + // + // The guard checked `i === firstKeptIndex()`, the TOP of the list. That + // refused the most ordinary interactive rebase there is — fold my latest + // commit into the one before it — while happily accepting a squash on the + // oldest commit, which git cannot execute, letting an impossible plan reach + // the "you'll need to force-push" dialog. `firstKeptIndex` was a leftover + // from before the ordering flip; `foldTargetSubject` already scans the right + // way and already skips drop/squash/fixup chains, so ask it. + if ((action === "squash" || action === "fixup") && foldTargetSubject(i) === null) { + flashBanner("The oldest commit has nothing below it to fold into."); render(); return; } rows[i].action = action; render(); + // Put the keyboard back on the control that was just used, the way `move` + // does. Relying on the generic focus rescue alone left it on whichever row + // happened to match — a different commit's dropdown, one keystroke from + // setting an action nobody chose. + (list.children[i] as HTMLElement | undefined) + ?.querySelector<HTMLSelectElement>(".rb-action") + ?.focus(); }; const move = (from: number, to: number): void => { @@ -178,11 +287,46 @@ function build(wrap: HTMLElement, nav: (view: string) => void, state: RebasePlan const kept = rows.filter((r) => r.action !== "drop" && r.action !== "squash" && r.action !== "fixup").length; const dropped = rows.filter((r) => r.action === "drop").length; const folded = rows.filter((r) => r.action === "squash" || r.action === "fixup").length; - const bits = [`${rows.length} → ${kept} commit${kept === 1 ? "" : "s"}`]; + // The TOTAL the rebase will replay, not the number of rows on screen. + // + // The list is capped, and `state.replayCount` is how many commits the + // rebase actually covers — which is what the confirm dialog beside this + // preview was fixed to use. The preview went on counting rows, so on a + // range longer than the cap it read "50 → 50 commits" for a rebase the + // dialog one line down correctly called 214, and the reader was told a + // smaller, safer number by the more prominent of the two. + const total = Math.max(state.replayCount ?? rows.length, rows.length); + const hidden = total - rows.length; + const bits = [`${total} → ${total - dropped - folded} commit${total - dropped - folded === 1 ? "" : "s"}`]; + if (hidden) bits.push(`${hidden} not shown`); if (folded) bits.push(`${folded} folded`); if (dropped) bits.push(`${dropped} dropped`); preview.textContent = bits.join(" · "); - count.textContent = `${rows.length} commit${rows.length === 1 ? "" : "s"}`; + count.textContent = `${total} commit${total === 1 ? "" : "s"}`; + // Re-validate the WHOLE plan on every render, not just the action being + // set. A fold target can disappear long after the squash was chosen. + const orphan = rows.some((_, i) => foldOrphan(i)); + // A plan that keeps NOTHING is not a rebase. Dropping every commit and + // pressing Apply erases the whole range and then offers to force-push it, + // which is `git reset --hard` wearing a rebase's clothes — and the preview + // said "N → 0 commits" while the button beside it stayed lit. Nothing in + // the flow named the consequence, and the force-push confirm downstream + // talks about rewriting history, not about deleting all of it. + // …and only when the list IS the plan. `kept` counts the rows on screen, + // and the list is capped: above the cap the rebase replays commits this + // view never drew, so "every visible commit is dropped" is not "the plan + // keeps nothing". `total` four lines up was taught to use `replayCount` + // and this was not — two fixes from the same batch, in the same function, + // disagreeing. `foldOrphan` already applies `hiddenTail()` for this reason. + const emptyPlan = rows.length > 0 && kept === 0 && !hiddenTail(); + applyBtn.disabled = orphan || emptyPlan || busy; + applyBtn.title = orphan + ? "A squash or fixup has nothing below it to fold into — git can't run this plan." + : emptyPlan + ? "This plan keeps no commits at all. To move the branch back to " + + `${short(state.base)} instead, use Reset — a rebase that drops everything ` + + "does the same thing with no way to tell that is what happened." + : ""; }; // ── rendering ── @@ -206,6 +350,13 @@ function build(wrap: HTMLElement, nav: (view: string) => void, state: RebasePlan const sel = document.createElement("select"); sel.className = `rb-action a-${r.action}`; + // Which commit this control belongs to. Changing an action rebuilds the + // list, and the focus rescue then matched on `title` — which is derived + // from the action, so it changes at exactly the moment the rescue needs it + // stable, and every other "Pick" row matched instead. Focus landed on a + // DIFFERENT commit's dropdown, where the next keystroke set an action on a + // commit the user never selected. `sameThing` checks dataset.num first. + sel.dataset.num = r.sha; for (const a of ACTIONS) { const o = document.createElement("option"); o.value = a.id; @@ -226,7 +377,16 @@ function build(wrap: HTMLElement, nav: (view: string) => void, state: RebasePlan av.title = r.author; const sha = el("span", "rb-sha"); sha.append(glyph("git-commit"), span(r.shortSha)); - line.append(subj, av, span(r.rel, "rb-meta"), sha); + // The consequence rides the SUBJECT line, not a line of its own. Revealing a + // second line under the row grew it by 17px the instant you chose an action, + // which shoved every row below — including the next row's action dropdown, + // the very control you reach for next. Choosing "squash" moved the thing you + // were about to click before your hand got there. + const orphan = foldOrphan(i); + const cons = el("span", `rb-consequence${orphan ? " bad" : ""}`); + const c = consequence(r.action, foldTargetSubject(i), orphan); + if (c) cons.append(glyph(c.icon), span(c.text)); + line.append(subj, cons, av, span(r.rel, "rb-meta"), sha); main.appendChild(line); // Reword editor — only visible for `reword` (CSS-driven off data-action). @@ -241,12 +401,6 @@ function build(wrap: HTMLElement, nav: (view: string) => void, state: RebasePlan rw.appendChild(ta); main.appendChild(rw); - const cons = el("div", "rb-consequence"); - const c = consequence(r.action, foldTargetSubject(i)); - if (c) { - cons.append(glyph(c.icon), span(c.text)); - } - main.appendChild(cons); row.appendChild(main); wireDrag(row, i); @@ -268,16 +422,38 @@ function build(wrap: HTMLElement, nav: (view: string) => void, state: RebasePlan row.classList.add("dragging"); }); row.addEventListener("dragend", () => row.classList.remove("dragging")); + /** Which half of the row the pointer is in — the drop lands on that side. + * + * The indicator was a fixed line under the row and the insert was always + * `move(from, i)`, and those two only agree when you drag DOWN. Dragging + * UP, `splice(i, 0, …)` puts the commit ABOVE the row while the line + * underneath it promised below — so every upward drag landed one row off + * from where the app said it would, on the view whose whole job is to say + * where commits will land. It also made position 0 unreachable by pointer. + */ + const half = (e: DragEvent): "before" | "after" => { + const r = row.getBoundingClientRect(); + return e.clientY < r.top + r.height / 2 ? "before" : "after"; + }; + const paint = (side: "before" | "after" | null): void => { + row.classList.toggle("drag-over-top", side === "before"); + row.classList.toggle("drag-over", side === "after"); + }; row.addEventListener("dragover", (e) => { e.preventDefault(); - row.classList.add("drag-over"); + paint(half(e)); }); - row.addEventListener("dragleave", () => row.classList.remove("drag-over")); + row.addEventListener("dragleave", () => paint(null)); row.addEventListener("drop", (e) => { e.preventDefault(); - row.classList.remove("drag-over"); + const side = half(e); + paint(null); const from = Number(e.dataTransfer?.getData("text/plain")); - if (!Number.isNaN(from)) move(from, i); + if (Number.isNaN(from)) return; + // Where it goes in the ORIGINAL array… + const at = side === "before" ? i : i + 1; + // …corrected for the row about to be removed from in front of it. + move(from, from < at ? at - 1 : at); }); } @@ -285,7 +461,12 @@ function build(wrap: HTMLElement, nav: (view: string) => void, state: RebasePlan const row = el("div", "rb-row rb-base"); const rail = el("div", "rb-rail"); rail.appendChild(el("span", "rb-node")); - row.append(rail, span("onto", "rb-onto")); + // The anchor row occupies the SAME columns as a commit row — an invisible + // grip, then the ONTO badge in the action slot — so its subject starts on + // the same left edge as every subject above it instead of 80px earlier. + const grip = el("span", "rb-grip is-spacer"); + grip.appendChild(glyph("gripper")); + row.append(rail, grip, span("onto", "rb-onto")); const main = el("div", "rb-main"); const line = el("div", "rb-line"); const subj = span(state.baseCommit?.subject ?? short(state.base), "rb-subj"); @@ -314,14 +495,19 @@ function build(wrap: HTMLElement, nav: (view: string) => void, state: RebasePlan void (async () => { if (busy) return; const dropped = rows.filter((r) => r.action === "drop").length; - const rewritten = rows.length; + // What the rebase REWRITES, not what is on screen. Above the display cap + // the commits below it are replayed too — that is what stops them being + // deleted — and a replay gives every one of them a new id as soon as the + // base has moved. "This rewrites 200 commits" on a range of 260 was the + // dialog understating the blast radius of the one irreversible button. + const rewritten = Math.max(state.replayCount ?? rows.length, rows.length); const ok = await confirmDialog({ title: "Start interactive rebase?", message: `This rewrites ${rewritten} commit${rewritten === 1 ? "" : "s"} on ${state.branch}` + (dropped ? `, deleting ${dropped}` : "") + `. If the branch is already pushed you'll need to force-push afterwards.`, - confirmLabel: "Start Rebase", + confirmLabel: "Start rebase", }); if (!ok) return; @@ -335,8 +521,22 @@ function build(wrap: HTMLElement, nav: (view: string) => void, state: RebasePlan sha: r.sha, subject: r.subject, message: r.action === "reword" ? r.message : undefined, + // The branches sitting on this commit. Without them the plan builder + // has nothing to emit `update-ref` for, and every branch inside the + // rewritten range is left pointing at a commit that is no longer in + // this branch's history. + branches: r.branches, })); - const outcome = await host.invoke("rebase:apply", { base: state.base, rows: payload }); + const outcome = await host.invoke("rebase:apply", { + base: state.base, + rows: payload, + updateRefs: carryBranches, + // The tip this plan was built against. The host refuses if it has + // moved since — otherwise a commit made in a terminal while the + // workspace was open is silently replayed as the OLDEST commit on the + // branch. + headSha: state.headSha, + }); if (outcome.status === "done") { toast("Rebase complete.", "success"); void mount(wrap, nav); // reload the (now shorter) plan @@ -352,7 +552,7 @@ function build(wrap: HTMLElement, nav: (view: string) => void, state: RebasePlan busy = false; applyBtn.classList.remove("busy"); applyBtn.disabled = false; - applyLabel.textContent = "Start Rebase"; + applyLabel.textContent = "Start rebase"; } })(); }); @@ -394,7 +594,7 @@ function buildExplainer(): HTMLElement { glyph("lightbulb"), strong, span( - " Reorder by dragging, or pick what happens to each commit below. Nothing changes until you press Start Rebase.", + " Reorder by dragging, or pick what happens to each commit below. Nothing changes until you press Start rebase.", ), ); const gloss = el("div", "rb-gloss"); @@ -420,11 +620,35 @@ function buildExplainer(): HTMLElement { function baseBar(state: RebasePlanState, wrap: HTMLElement, nav: (v: string) => void): HTMLElement { const bar = el("div", "rb-basebar"); - const load = (base: string): void => { + /** + * Load a new base. + * + * The composed plan — every action, the reordering, any message typed into a + * reword — lives in the DOM this builds. So it must not be torn down until + * there is something to replace it WITH. It used to blank the view to a + * loading card first and, on any failure, "fall back" by rebuilding from the + * original `state`: same commits, every action reset to pick, every edit + * gone. Trying a base and finding it empty silently threw away the plan. + * + * The in-flight state goes on the control you pressed, not on the workspace. + */ + const load = (base: string, btn?: HTMLButtonElement): void => { void (async () => { - wrap.replaceChildren(loadingCard()); + if (btn) { + btn.disabled = true; + btn.classList.add("is-busy"); + } try { const re = await host.invoke("rebase:load", { base }); + // A rebase already running owns this view. Building the Start-rebase + // workspace over a live rebase offers a plan that cannot be applied — + // `runRebasePlan` refuses while one is in progress — so the user is + // handed a screen whose only button is dead. Checked BEFORE the + // commits test, because a mid-rebase load legitimately has rows. + if (re.ok && re.inProgress) { + void mount(wrap, nav); + return; + } if (re.ok && re.commits.length) { build(wrap, nav, re); return; @@ -432,10 +656,15 @@ function baseBar(state: RebasePlanState, wrap: HTMLElement, nav: (v: string) => toast(re.message || `No commits between ${short(base)} and HEAD.`, "error"); } catch (err) { toast(cleanErr(err), "error"); + } finally { + if (btn?.isConnected) { + btn.disabled = false; + btn.classList.remove("is-busy"); + } } - // Fall back to whatever was on screen before. - if (state.commits.length) build(wrap, nav, state); - else void mount(wrap, nav); + // Nothing to show for the new base — so show what is still on screen. + // There is no rebuild here on purpose: the live plan is untouched. + if (!state.commits.length) void mount(wrap, nav); })(); }; @@ -444,7 +673,7 @@ function baseBar(state: RebasePlanState, wrap: HTMLElement, nav: (v: string) => b.textContent = label; b.title = title; if (base === state.base) b.classList.add("is-active"); - b.addEventListener("click", () => load(base)); + b.addEventListener("click", () => load(base, b)); bar.appendChild(b); }; @@ -464,7 +693,7 @@ function baseBar(state: RebasePlanState, wrap: HTMLElement, nav: (v: string) => state.base === "--root" ? "" : state.base, "Load commits", ); - if (next && next.trim()) load(next.trim()); + if (next && next.trim()) load(next.trim(), btn as HTMLButtonElement); })(); }); bar.appendChild(btn); @@ -477,7 +706,10 @@ function inProgressCard(reload: () => void): HTMLElement { const head = el("div", "rb-inprogress-head"); head.append(glyph("debug-pause"), span("A rebase is in progress")); const body = span( - "Git stopped part-way — resolve any conflicts in the Changes view, then continue. Aborting restores the branch to where it started.", + // Not "resolve any conflicts": a rebase can stop with a perfectly clean tree — + // git refusing a todo it cannot execute is one way — and telling someone to + // resolve conflicts that do not exist sends them looking for nothing. + "Git stopped part-way. If there are conflicts, resolve them in the Changes view first, then continue. Aborting restores the branch to exactly where it started.", "rb-inprogress-body", ); const btns = el("div", "rb-inprogress-btns"); @@ -489,6 +721,12 @@ function inProgressCard(reload: () => void): HTMLElement { const r = await host.invoke("rebase:continue", undefined); if (r.ok) { toast("Rebase continued.", "success"); + } else if (r.expected) { + // A PAUSE is not a failure. Continuing into an `edit` row, or into the + // next conflict, is the plan working — the bridge marks those + // `expected`, and painting them red told the user something had gone + // wrong when nothing had. + toast(r.message || "Rebase paused again — the plan asked for it.", "info", 5000); } else { toast(r.message || "Couldn't continue — unresolved conflicts?", "error", 6000); } @@ -517,7 +755,14 @@ function inProgressCard(reload: () => void): HTMLElement { return card; } -function consequence(action: RebaseAction, target: string | null): { icon: string; text: string } | null { +function consequence( + action: RebaseAction, + target: string | null, + orphan = false, +): { icon: string; text: string } | null { + if (orphan && (action === "squash" || action === "fixup")) { + return { icon: "warning", text: "Nothing below it to fold into — pick a different action or move it up" }; + } const into = target ? `“${clip(target, 44)}”` : "the commit below it"; switch (action) { case "squash": diff --git a/apps/desktop/src/renderer/views/refDetail.ts b/apps/desktop/src/renderer/views/refDetail.ts new file mode 100644 index 0000000..6d41097 --- /dev/null +++ b/apps/desktop/src/renderer/views/refDetail.ts @@ -0,0 +1,329 @@ +// A ref, as a PAGE. +// +// A branch's history used to be a modal peek: no route, no entry in the back +// stack, no ⌘[ / ⌘], and it evaporated on Escape. For a remote branch, a tag or +// a stash that modal was worse than inconvenient — it was the ONLY door to +// every action those rows had, because none of them carried any. +// +// So each kind of ref gets a page: what it points at, what is on it, and its +// verbs in the top bar where a page's verbs live. + +import { host } from "../bridge"; +import { el, span, glyph, cleanErr, errorState, skeletonList, relTime, absTime, copyText } from "../ui"; +import { toast, confirmDialog } from "../dialogs"; +import { detailPage, commitList, type SectionTarget, type SectionNav } from "./common"; +import { setPageLabel } from "../navStack"; +import type { CompareCommit, RefInfo, StashInfo } from "../../shared/ipc"; + +/** Which kind of ref this page is showing — it arrives on `target.id`. */ +type RefKind = "head" | "remote" | "tag" | "stash"; + +function kindLabel(kind: RefKind): string { + return kind === "head" ? "branch" : kind === "remote" ? "remote branch" : kind; +} + +/** + * Render one ref's page. + * + * `target.ref` is the ref's name (or a stash selector); `target.id` is the kind. + */ +export async function renderRefDetail( + wrap: HTMLElement, + nav: SectionNav, + target: SectionTarget | undefined, +): Promise<void> { + const name = target?.ref; + const kind = (target?.id as RefKind) || "head"; + const { view, main, rail, topActions } = detailPage({ + backLabel: "Branches", + crumb: name ?? "Ref", + pageLabel: name, + onBack: () => nav("branches", { list: true }), + }); + view.classList.add("refdetail-view"); + wrap.replaceChildren(view); + main.appendChild(skeletonList(4, false)); + + if (!name) { + main.replaceChildren(errorState("No ref", "Nothing was named to open.")); + return; + } + setPageLabel(name); + + // What git knows about it, from the read the list already ran. + let refs: RefInfo[] = []; + let stashes: StashInfo[] = []; + try { + [refs, stashes] = await Promise.all([ + host.invoke("refs:list", undefined), + kind === "stash" ? host.invoke("stash:list", undefined) : Promise.resolve([] as StashInfo[]), + ]); + } catch (e) { + if (!view.isConnected) return; + main.replaceChildren( + errorState("Couldn't read this ref", cleanErr(e) || "Git did not answer.", () => + void renderRefDetail(wrap, nav, target), + ), + ); + return; + } + if (!view.isConnected) return; + + const stash = kind === "stash" ? stashes.find((s) => s.ref === name) : undefined; + const ref = kind === "stash" ? undefined : refs.find((r) => r.name === name && r.type === kind); + if (!ref && !stash) { + main.replaceChildren( + errorState( + `That ${kindLabel(kind)} is not here`, + `${name} was not found in this repository. It may have been deleted, or the list you came from is stale.`, + () => nav("branches", { list: true }), + ), + ); + return; + } + + const sha = stash?.sha ?? ref?.sha ?? ""; + const when = stash?.time ?? ref?.date; + + // ── the head ────────────────────────────────────────────────────────────── + const head = el("div", "rd-head"); + const title = el("h1", "rd-title"); + title.textContent = stash?.message || name; + head.appendChild(title); + + const facts = el("div", "rd-facts"); + facts.appendChild(span(kindLabel(kind), "rd-kind")); + if (ref?.objectType === "tag") { + const a = span("annotated", "ab-pill annotated"); + a.title = "This tag is its own object, with a tagger and a message"; + facts.appendChild(a); + } + if (ref?.isCurrent) facts.appendChild(span("checked out", "ab-pill current")); + if (ref?.gone) { + const g = span("upstream gone", "ab-pill gone"); + g.title = `${ref.upstream ?? "Its upstream"} no longer exists.`; + facts.appendChild(g); + } + if (ref?.upstream) facts.appendChild(span(`tracks ${ref.upstream}`, "rd-fact")); + if (stash) facts.appendChild(span(stash.ref, "rd-fact sec-mono")); + if (when) { + const t = span(relTime(when), "rd-fact"); + t.title = absTime(when); + facts.appendChild(t); + } + head.appendChild(facts); + if (ref?.subject) { + const s = el("div", "rd-subject"); + s.textContent = ref.subject; + head.appendChild(s); + } + main.replaceChildren(head); + + // ── the top bar's verbs ─────────────────────────────────────────────────── + const shaBtn = el("button", "mini-btn") as HTMLButtonElement; + shaBtn.append(glyph("copy"), span(sha.slice(0, 7) || "no sha")); + shaBtn.title = `${sha}\nCopy the full SHA`; + shaBtn.setAttribute("aria-label", `Copy the full SHA ${sha}`); + shaBtn.disabled = !sha; + shaBtn.addEventListener("click", () => void copyText(sha, "Copied the full SHA.")); + topActions.appendChild(shaBtn); + + const act = (label: string, icon: string, title: string, run: () => void, primary = false): void => { + const b = el("button", primary ? "btn btn-primary" : "mini-btn") as HTMLButtonElement; + b.append(glyph(icon), span(label)); + b.title = title; + b.addEventListener("click", run); + topActions.appendChild(b); + }; + + if (kind === "head" && !ref?.isCurrent) { + act("Check out", "git-branch", `Check out ${name}`, () => + void checkout(name, "head", `Checked out ${name}.`), true); + } + if (kind === "remote") { + const local = name.split("/").slice(1).join("/") || name; + act("Check out here", "git-branch", `Create ${local} from ${name} and check it out`, () => + void checkout(name, "remote", `Checked out ${local}.`), true); + } + if (kind === "tag") { + act("Push", "cloud-upload", `Publish ${name} to the remote`, () => void pushTag()); + act("Delete…", "trash", `Delete ${name} from this clone`, () => void deleteTag()); + } + if (kind === "stash") { + act("Apply", "arrow-down", `Apply ${name}, keeping it in the list`, () => void stashAct("apply"), true); + act("Pop", "arrow-up", `Apply ${name} and remove it`, () => void stashAct("pop")); + act("Drop…", "trash", `Delete ${name} permanently`, () => void stashAct("drop")); + } + if (sha) { + act("Show in the graph", "git-commit", "Find this commit in the graph", () => + nav("graph", { sha })); + } + + // ── the rail ────────────────────────────────────────────────────────────── + const prop = (label: string, value: string, title?: string): void => { + const row = el("div", "rd-prop"); + row.appendChild(span(label, "rd-prop-label")); + const v = span(value, "rd-prop-value"); + if (title) v.title = title; + row.appendChild(v); + rail.appendChild(row); + }; + prop("Kind", kindLabel(kind)); + if (sha) prop("Commit", sha.slice(0, 7), sha); + if (ref?.upstream) prop("Upstream", ref.upstream); + // RefInfo carries no ahead/behind: `%(upstream:track)` is only ever populated + // on LOCAL heads, so the field would be empty on every remote and tag row — + // the branch list is where that pair lives. + if (when) prop("Updated", relTime(when), absTime(when)); + prop("Full name", ref?.fullName ?? name, ref?.fullName ?? name); + + // ── what is on it ───────────────────────────────────────────────────────── + const historyHead = el("div", "rd-section-head"); + historyHead.append(glyph("git-commit"), span(kind === "stash" ? "The commit it holds" : "Recent commits")); + main.appendChild(historyHead); + const historyBody = el("div", "rd-history"); + historyBody.appendChild(skeletonList(4, false)); + main.appendChild(historyBody); + + let log: CompareCommit[] = []; + try { + // A STASH IS ONE COMMIT. `git log stash@{0}` walks its ancestry, so this + // section — headed "The commit it holds", singular — filled with thirty + // rows: the WIP commit, then git's internal "index on <branch>: …" commit + // (the stash's second parent, an implementation detail no UI should show), + // then the branch history it was taken from. Asking for one gets one. + log = await host.invoke("ref:log", { ref: name, maxCount: kind === "stash" ? 1 : 30 }); + } catch (e) { + if (!view.isConnected) return; + historyBody.replaceChildren( + errorState("Couldn't read this ref's history", cleanErr(e) || "Git did not answer."), + ); + return; + } + if (!view.isConnected) return; + historyBody.replaceChildren( + log.length + ? commitList( + log.map((c) => ({ + sha: c.sha, + shortSha: c.shortSha, + subject: c.subject, + body: c.body, + author: c.author, + date: c.date, + isMerge: c.isMerge, + })), + { + onOpen: (s) => nav("commit", { sha: s }), + onCopy: (s) => void copyText(s, "Copied the full SHA."), + // NEWEST first: this is a capped window on an ongoing history, not + // a complete set. Oldest-first opened on the 30th-newest commit and + // put the branch tip — the commit every reader is here for — at the + // bottom, below a fold on any branch with real history. + order: "newest", + }, + ) + : errorState("No history", "Git returned no commits for this ref."), + ); + + // ── the verbs' plumbing ─────────────────────────────────────────────────── + /** + * Check out a ref. + * + * `checkout-ref`, not `checkout` — the plain action detaches HEAD at whatever + * `sha` names, so handing it "origin/foo" leaves you on a detached + * remote-tracking ref with no branch and no upstream, while the toast claims + * the branch was checked out. The kind is what turns a remote into a real + * local tracking branch (issues #12/#19). + */ + async function checkout(ref: string, refKind: "head" | "remote", ok: string): Promise<void> { + let r; + try { + r = await host.invoke("commit:action", { + action: "checkout-ref", + sha: ref, + name: ref, + refKind, + } as never); + } catch (e) { + toast(cleanErr(e) || "Couldn't check out.", "error"); + return; + } + // Arrives over IPC: a channel that failed to register hands back undefined, + // and reading `.ok` off it throws inside an async handler — no toast, no + // error, the click simply doing nothing. + if (!r?.ok) { + toast(r?.message || "Couldn't check out — you may have uncommitted changes.", "error"); + return; + } + toast(ok, "success"); + nav("branches", { list: true }); + } + + async function pushTag(): Promise<void> { + const r = await host.invoke("tag:push", { name: name! }); + toast(r.ok ? `Pushed ${name}.` : (r.message ?? "Couldn't push the tag."), r.ok ? "success" : "error"); + } + + async function deleteTag(): Promise<void> { + const ok = await confirmDialog({ + title: `Delete tag ${name}?`, + message: + "This removes the tag from this clone only. If it has already been pushed, the copy " + + "on the remote is untouched and a fetch brings it straight back.", + confirmLabel: "Delete locally", + danger: true, + }); + if (!ok) return; + const r = await host.invoke("tag:delete", name!); + if (!r.ok) { + toast(r.message ?? "Couldn't delete the tag.", r.expected ? "info" : "error"); + return; + } + toast(`Deleted tag ${name} locally.`, "success"); + nav("branches", { list: true }); + } + + /** + * `stash@{n}` is a POSITION, not an identity — dropping one renumbers every + * stash below it. Re-read and compare the commit before acting, or this page + * can name one stash and destroy another. + */ + async function stashAct(action: "apply" | "pop" | "drop"): Promise<void> { + if (action === "drop") { + const ok = await confirmDialog({ + title: `Drop ${name}?`, + message: `“${stash?.message || name}” is deleted permanently. This cannot be undone.`, + confirmLabel: "Drop", + danger: true, + }); + if (!ok) return; + } + let fresh: StashInfo[]; + try { + fresh = await host.invoke("stash:list", undefined); + } catch { + toast("Couldn't re-read the stash list — nothing was changed.", "error"); + return; + } + const still = fresh.find((x) => x.ref === name); + if (!still || (stash?.sha && still.sha !== stash.sha)) { + toast(`${name} is not the stash it was — the list changed underneath.`, "info"); + nav("branches", { list: true }); + return; + } + const r = await host.invoke( + action === "apply" ? "stash:apply" : action === "pop" ? "stash:pop" : "stash:drop", + name!, + ); + if (!r.ok) { + toast(r.message ?? `Couldn't ${action} ${name}.`, r.expected ? "info" : "error"); + return; + } + toast( + action === "apply" ? `Applied ${name}.` : action === "pop" ? `Popped ${name}.` : `Dropped ${name}.`, + "success", + ); + if (action !== "apply") nav("branches", { list: true }); + } +} diff --git a/apps/desktop/src/renderer/views/releaseCompose.ts b/apps/desktop/src/renderer/views/releaseCompose.ts new file mode 100644 index 0000000..6b1295e --- /dev/null +++ b/apps/desktop/src/renderer/views/releaseCompose.ts @@ -0,0 +1,459 @@ +// Writing a release, as a PAGE. +// +// It used to be a modal: a 560px card holding a tag field, a target field, a +// title, a ten-row notes box and two buttons, with the notes — the only part +// anyone spends time on — getting about 180px of it. +// +// The owner's report was blunt: "editing a release is still complete garbage +// compared to github ui ux, look https://github.com/GitStudioHQ/gitstudio/ +// releases/new", and "same is for publishing releases." +// +// So this is that page, and then the parts GitHub has that the modal never +// could: the tag is a combobox over the repository's real tags that says +// out loud when it will CREATE one, the target is a real ref picker, the notes +// fill the window with Write/Preview, "Generate release notes" asks GitHub for +// the changelog it would have written, "Set as the latest release" is finally +// askable, and publish-vs-draft is two named buttons rather than a checkbox. + +import { host } from "../bridge"; +import { el, span, glyph, cleanErr, errorState, skeletonList } from "../ui"; +import { toast } from "../dialogs"; +import { detailPage, comboField, type SectionTarget, type SectionNav } from "./common"; +import { mdEditor } from "../mdEditor"; +import { wireDraft } from "../draftStore"; +import { setPageLabel } from "../navStack"; +import { bust } from "../cache"; +import type { ReleaseInfo, ReleaseInput } from "../../shared/ipc"; + +/** Branch and tag names for the two pickers. */ +async function refOptions(): Promise<{ branches: string[]; tags: string[] }> { + try { + const refs = await host.invoke("refs:list", undefined); + const branches = new Set<string>(); + const tags = new Set<string>(); + for (const r of refs) { + if (r.type === "head") branches.add(r.name); + else if (r.type === "remote") { + const short = r.name.replace(/^[^/]+\//, ""); + if (short && short !== "HEAD") branches.add(short); + } else if (r.type === "tag") tags.add(r.name); + } + const cmp = (a: string, b: string): number => a.localeCompare(b, undefined, { numeric: true }); + return { branches: [...branches].sort(cmp), tags: [...tags].sort(cmp).reverse() }; + } catch { + return { branches: [], tags: [] }; + } +} + +async function defaultTarget(): Promise<string> { + try { + const head = await host.invoke("head:get", undefined); + if (head && !head.detached && head.branch) return head.branch; + } catch { + /* not a repo yet — the placeholder says "main", GitHub decides */ + } + return ""; +} + +/** + * The composer. + * + * `target.number` is a release id to EDIT; without one this composes a new + * release, and `target.ref` pre-fills the tag (how "Draft a release" from a tag + * row arrives). + */ +export async function renderReleaseCompose( + wrap: HTMLElement, + nav: SectionNav, + target: SectionTarget | undefined, +): Promise<void> { + const editId = target?.number; + const { view, main, rail, topActions } = detailPage({ + backLabel: "Releases", + crumb: editId ? "Edit release" : "New release", + pageLabel: editId ? "Edit release" : "New release", + onBack: () => nav("releases", { list: true }), + }); + view.classList.add("relc-view"); + rail.remove(); + topActions.remove(); + wrap.replaceChildren(view); + main.appendChild(skeletonList(4, false)); + + let existing: ReleaseInfo | undefined; + if (editId != null) { + try { + existing = await host.invoke("release:detail", editId); + } catch (e) { + if (!view.isConnected) return; + main.replaceChildren( + errorState("Couldn't load this release", cleanErr(e) || "GitHub request failed.", () => + void renderReleaseCompose(wrap, nav, target), + ), + ); + return; + } + if (!view.isConnected) return; + if (!existing) { + main.replaceChildren( + errorState("Release unavailable", "This release couldn't be read from GitHub."), + ); + return; + } + } + + const [{ branches, tags }, headBranch, remoteTags] = await Promise.all([ + refOptions(), + defaultTarget(), + // The LOCAL refs are not the whole truth: a tag pushed from CI, or one made + // on github.com, exists on the remote and not in this clone — and calling + // it "new" would promise to create a tag that is already there. + host.invoke("release:tags", undefined).catch(() => []), + ]); + if (!view.isConnected) return; + + // Which release holds the "Latest" badge right now — the newest published, + // non-pre-release one, exactly as the list computes it. Read here so editing + // a release can leave the badge alone by default; a failed read is treated as + // "not this one", which is the safe direction (the badge does not move). + let isCurrentlyLatest = false; + if (existing) { + try { + const all = await host.invoke("release:list", undefined); + isCurrentlyLatest = all.find((r) => !r.draft && !r.prerelease)?.id === existing.id; + } catch { + isCurrentlyLatest = false; + } + if (!view.isConnected) return; + } + + const knownTags = new Set([...tags, ...remoteTags.map((t) => t.name)]); + const tagOptions = [...knownTags].sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); + const init: ReleaseInput = existing + ? { + id: existing.id, + tagName: existing.tagName, + targetCommitish: existing.targetCommitish, + name: existing.name, + body: existing.body ?? "", + draft: existing.draft, + prerelease: existing.prerelease, + } + : { + tagName: target?.ref ?? "", + targetCommitish: "", + name: "", + body: "", + draft: false, + prerelease: false, + }; + + const form = el("div", "relc-form"); + main.replaceChildren(form); + + // ── the two refs ────────────────────────────────────────────────────────── + const refRow = el("div", "relc-refs"); + const tagField = comboField({ + label: "Tag", + placeholder: "v1.0.0", + value: init.tagName, + options: tagOptions, + rowClass: "relc-field", + labelClass: "relc-label", + inputClass: "relc-input", + }); + const targetField = comboField({ + label: "Target", + placeholder: headBranch || "main", + value: init.targetCommitish ?? "", + options: branches, + rowClass: "relc-field", + labelClass: "relc-label", + inputClass: "relc-input", + }); + refRow.append(tagField.row, targetField.row); + form.appendChild(refRow); + + // Saying which of the two things a tag name means. GitHub's composer does, + // and it is the difference between "I am releasing the tag I cut" and "I am + // about to create a tag on whatever Target says" — which nothing else on the + // form tells you, and which is not undoable from here. + const tagNote = el("div", "relc-note"); + form.appendChild(tagNote); + const syncTagNote = (): void => { + const t = tagField.input.value.trim(); + if (!t) { + tagNote.textContent = ""; + tagNote.className = "relc-note"; + return; + } + if (existing && t === existing.tagName) { + tagNote.className = "relc-note is-known"; + tagNote.textContent = `This release points at ${t}.`; + } else if (knownTags.has(t)) { + tagNote.className = "relc-note is-known"; + tagNote.textContent = `Existing tag — this release will point at ${t}.`; + } else { + tagNote.className = "relc-note is-new"; + const at = targetField.input.value.trim() || headBranch || "the default branch"; + tagNote.textContent = `New tag — GitHub will create ${t} from ${at} when you publish.`; + } + }; + tagField.input.addEventListener("input", syncTagNote); + targetField.input.addEventListener("input", syncTagNote); + syncTagNote(); + + // ── title ───────────────────────────────────────────────────────────────── + const titleField = el("div", "relc-field relc-field-wide"); + const titleLabel = el("label", "relc-label"); + titleLabel.textContent = "Title"; + const title = document.createElement("input"); + title.className = "relc-input relc-title"; + title.placeholder = "Release title"; + title.value = init.name ?? ""; + title.id = "relc-title"; + (titleLabel as HTMLLabelElement).htmlFor = title.id; + titleField.append(titleLabel, title); + form.appendChild(titleField); + + // ── notes ───────────────────────────────────────────────────────────────── + const notesHead = el("div", "relc-notes-head"); + const notesLabel = el("span", "relc-label"); + notesLabel.textContent = "Notes"; + const genBtn = el("button", "mini-btn relc-gen") as HTMLButtonElement; + genBtn.append(glyph("sparkle"), span("Generate release notes")); + genBtn.title = "Ask GitHub for the changelog it would write from the merged pull requests"; + notesHead.append(notesLabel, genBtn); + form.appendChild(notesHead); + + const draftId = init.id === undefined ? "new" : String(init.id); + // The tag and the title are drafted alongside the notes. Leaving used to keep + // the paragraph and lose the two lines above it, which is the half of a + // half-written release you cannot reconstruct from memory. + const headDraft = wireDraft("release-head", draftId, (t) => { + try { + const saved = JSON.parse(t) as { tag?: string; title?: string }; + if (!init.tagName && !tagField.input.value && saved.tag) tagField.input.value = saved.tag; + if (!init.name && !title.value && saved.title) title.value = saved.title; + syncTagNote(); + } catch { + /* a draft we cannot read is a draft we ignore */ + } + }); + const saveHead = (): void => + headDraft.save(JSON.stringify({ tag: tagField.input.value, title: title.value })); + tagField.input.addEventListener("input", saveHead); + title.addEventListener("input", saveHead); + + const notes = mdEditor({ + value: init.body ?? "", + placeholder: "Describe this release. Markdown is supported — and “Generate release notes” writes a first draft from the merged pull requests.", + fill: true, + label: "Release notes", + onInput: (v) => notesDraft.save(v), + onSubmit: () => publishBtn.click(), + }); + // Only over an EMPTY field: a local draft must never silently replace notes + // GitHub already has, which would read as the app rewriting a published + // release behind your back. + const notesDraft = wireDraft("release", draftId, (text) => { + if (!init.body) notes.set(text); + }); + form.appendChild(notes.root); + + genBtn.addEventListener("click", () => { + const tagName = tagField.input.value.trim(); + if (!tagName) { + showError("A tag is needed first — the notes are the changes since the previous one."); + tagField.input.focus(); + return; + } + const had = notes.get().trim(); + genBtn.disabled = true; + const label = genBtn.querySelector("span"); + if (label) label.textContent = "Asking GitHub…"; + void host + .invoke("release:generateNotes", { + tagName, + targetCommitish: targetField.input.value.trim() || undefined, + }) + .then((g) => { + // Never over the top of writing someone already did: append below it, + // and let them delete what they don't want. + notes.set(had ? `${had}\n\n${g.body}` : g.body); + if (!title.value.trim() && g.name) title.value = g.name; + notesDraft.save(notes.get()); + toast("Release notes generated from GitHub.", "success"); + }) + .catch((e) => { + showError(cleanErr(e) || "GitHub couldn't generate notes for this tag."); + }) + .finally(() => { + genBtn.disabled = false; + if (label) label.textContent = "Generate release notes"; + }); + }); + + // ── attributes ──────────────────────────────────────────────────────────── + const attrs = el("div", "relc-attrs"); + const check = (labelText: string, hint: string, on: boolean): { row: HTMLElement; input: HTMLInputElement } => { + const row = el("label", "relc-check"); + const input = document.createElement("input"); + input.type = "checkbox"; + input.checked = on; + const txt = el("span", "relc-check-text"); + txt.appendChild(span(labelText, "relc-check-label")); + txt.appendChild(span(hint, "relc-check-hint")); + row.append(input, txt); + return { row, input }; + }; + const pre = check( + "Set as a pre-release", + "Marked as not production-ready. It never becomes the latest release.", + !!init.prerelease, + ); + // NOT `!init.prerelease`. That pre-ticked the box for every published + // non-pre-release, so opening an OLD release to fix a typo in its notes and + // pressing Save moved the repository's "Latest" badge onto it — silently, + // outward, and visible to everyone reading the repo. Editing a release must + // default to leaving that badge exactly where it is. + // + // The rule is github.com's own, and the same one the list uses: the newest + // published, non-pre-release release holds it. + const latest = check( + "Set as the latest release", + "Moves the repository's “Latest” badge onto this release.", + init.id === undefined ? !init.prerelease : isCurrentlyLatest, + ); + attrs.append(pre.row, latest.row); + form.appendChild(attrs); + // A pre-release cannot also be the latest release — GitHub refuses it, and a + // form that lets you ask for both just turns into an error after the fact. + const syncLatest = (): void => { + latest.input.disabled = pre.input.checked; + if (pre.input.checked) latest.input.checked = false; + latest.row.classList.toggle("is-off", pre.input.checked); + latest.row.title = pre.input.checked ? "A pre-release is never the latest release" : ""; + }; + pre.input.addEventListener("change", syncLatest); + syncLatest(); + + // ── errors + actions ────────────────────────────────────────────────────── + const note = el("div", "relc-error"); + note.setAttribute("role", "alert"); + note.hidden = true; + form.appendChild(note); + function showError(msg: string): void { + note.hidden = false; + note.textContent = msg; + note.scrollIntoView({ block: "nearest" }); + } + + const bar = el("div", "relc-actions"); + const cancel = el("button", "mini-btn") as HTMLButtonElement; + cancel.textContent = "Cancel"; + // Back to the release you were editing, not to the list — which is where the + // ← button and Escape both go, and where Save lands you. Three exits from one + // page were doing two different things, and the visible one was the odd one + // out: it threw away your place in a list you may have scrolled a long way + // down. Creating a NEW release has no release to return to, so that keeps the + // list. Same rule the issue composer already follows. + cancel.addEventListener("click", () => + nav("releases", editId != null ? { number: editId } : { list: true }), + ); + + // On an already-published release "Save draft" would silently UNPUBLISH it — + // a destructive act behind an innocuous label. That release gets one button. + const alreadyPublished = init.id !== undefined && !init.draft; + const draftBtn = el("button", "mini-btn") as HTMLButtonElement; + draftBtn.append(glyph("save"), span("Save draft")); + draftBtn.title = "Keep this private — nobody is notified and it stays off the releases page"; + const publishBtn = el("button", "btn btn-primary") as HTMLButtonElement; + const publishLabel = span(alreadyPublished ? "Save changes" : "Publish release"); + publishBtn.append(glyph(alreadyPublished ? "save" : "rocket"), publishLabel); + publishBtn.title = alreadyPublished + ? "Save your changes to this published release" + : "Publish now — everyone watching this repository is notified"; + bar.append(cancel, el("span", "relc-spring")); + if (!alreadyPublished) bar.appendChild(draftBtn); + bar.appendChild(publishBtn); + form.appendChild(bar); + + let busy = false; + const submit = async (asDraft: boolean): Promise<void> => { + if (busy) return; + const tagName = tagField.input.value.trim(); + if (!tagName) { + showError("A tag is required — it is what the release points at."); + tagField.input.setAttribute("aria-invalid", "true"); + tagField.input.focus(); + return; + } + note.hidden = true; + busy = true; + for (const b of [publishBtn, draftBtn, cancel]) b.disabled = true; + publishLabel.textContent = asDraft ? "Saving…" : alreadyPublished ? "Saving…" : "Publishing…"; + const input: ReleaseInput = { + id: init.id, + tagName, + targetCommitish: targetField.input.value.trim() || undefined, + name: title.value.trim(), + body: notes.get(), + draft: asDraft, + prerelease: pre.input.checked, + // Only sent when the answer is meaningful: a pre-release is never latest, + // and on a draft the question does not arise until it is published. + makeLatest: pre.input.checked || asDraft ? undefined : latest.input.checked, + }; + try { + const r = await host.invoke(init.id === undefined ? "release:create" : "release:update", input); + if (!r.ok) { + showError(r.message ?? "GitHub rejected the release."); + return; + } + // The text is on its way to GitHub, so the local draft has done its job. + // Left behind, re-opening the composer would restore a copy of what was + // just published over the top of it. + notesDraft.clear(); + headDraft.clear(); + bust("release"); + toast( + init.id === undefined + ? asDraft + ? `Saved ${tagName} as a draft.` + : `Published ${tagName}.` + : `Updated ${tagName}.`, + "success", + ); + // Land ON the release, not on a list of every release with the new one + // somewhere in it. A draft has no page of its own yet, so that one goes + // to the list — where a draft is exactly what you are looking for. + const landOn = init.id ?? ("id" in r ? r.id : undefined); + nav("releases", landOn !== undefined && !asDraft ? { number: landOn } : { list: true }); + } catch (e) { + showError(cleanErr(e) || "Couldn't reach GitHub."); + } finally { + busy = false; + for (const b of [publishBtn, draftBtn, cancel]) b.disabled = false; + publishLabel.textContent = alreadyPublished ? "Save changes" : "Publish release"; + } + }; + publishBtn.addEventListener("click", () => void submit(false)); + draftBtn.addEventListener("click", () => void submit(alreadyPublished ? false : true)); + tagField.input.addEventListener("input", () => { + if (!tagField.input.value.trim()) return; + tagField.input.removeAttribute("aria-invalid"); + note.hidden = true; + }); + // ⌘/Ctrl+Enter submits from anywhere on the form, matching every other + // composer in the app (and the editor's own shortcut). + form.addEventListener("keydown", (e) => { + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + e.preventDefault(); + void submit(false); + } + }); + + setPageLabel(editId ? `Edit ${init.tagName || "release"}` : "New release"); + (init.tagName ? title : tagField.input).focus(); +} diff --git a/apps/desktop/src/renderer/views/releases.ts b/apps/desktop/src/renderer/views/releases.ts index b68664c..44c84b4 100644 --- a/apps/desktop/src/renderer/views/releases.ts +++ b/apps/desktop/src/renderer/views/releases.ts @@ -1,41 +1,73 @@ -// GitHub Releases — the section view. A two-pane (list + detail) surface mirroring -// the PR / Issue views: the left pane toggles between Releases and raw git Tags; -// the right pane shows a release's rendered notes, assets, tag/target/dates, and -// Edit / Delete / Open actions. Full CRUD: New release, Edit, Delete (confirmed), -// and "cut a release from a tag" by clicking a tag row. +// GitHub Releases — the section view, on the section-page system +// (docs/desktop-redesign.md): a full-width list (Releases | Tags segment) whose +// release rows navigate to a full-page detail (routed via `target.number` = the +// release id) with rendered notes + assets in the content column and the +// release's facts in the rail. Tags open a lightweight peek (commit info + +// draft-a-release), matching how refs peek everywhere else in the app. // -// The module is self-contained per the section contract: it gates first, renders -// into the handed `wrap`, and re-renders by calling itself. The multi-field -// release form is a local modal (dialogs.ts only exports a single-field prompt), -// built on the shared .modal-* CSS so it matches the rest of the app. +// Full CRUD: New release, Edit, Delete (confirmed) — the multi-field form is a +// local modal on the shared .modal-* CSS. import { host } from "../bridge"; import { el, span, glyph, - pill, + relTime, + absTime, relTimeISO, absTimeISO, - loadingState, + copyText, skeletonList, errorState, emptyState, cleanErr, groupLabel, - ghRow, + openMenu, statBit, statePill, + runBusy, } from "../ui"; -import { toast, confirmDialog } from "../dialogs"; +import { peek as cachePeek, gget, bust } from "../cache"; +import { plural } from "../textFit"; +import { toast, confirmDialog, openModal, formWithRetry } from "../dialogs"; import { renderMarkdown } from "../markdown"; -import { ghGate, ghHeader, ghListResizer, searchField, type SectionRender } from "./common"; -import type { ReleaseInfo, ReleaseInput, TagInfo } from "../../shared/ipc"; - -/** Which sub-list the left pane is showing. Module-scoped so it survives a re-render. */ +import { wireProseNav } from "../proseNav"; +import { openPeek } from "../peek"; +import { + segmented, + detailPage, + blankable, + ghGate, + ghHeader, + personChip, + propSection, + searchField, + secRow, + sectionList, + type GhGate, + type SectionNav, + type SectionRender, + type SectionTarget, +} from "./common"; +import { mdEditor } from "../mdEditor"; +import { wireDraft } from "../draftStore"; +import { setPageLabel } from "../navStack"; +import { pruneOnFetch } from "../prefs"; +import type { CommitDetailsPayload, ReleaseInfo, ReleaseInput, TagInfo } from "../../shared/ipc"; + +/** Which sub-list the section shows. Module-scoped so it survives re-renders. */ let releaseTab: "releases" | "tags" = "releases"; +/** The list page's live search query — survives list ⇄ detail round trips. */ +let query = ""; /** Human file size for release assets. */ +/** Tag an element with an extra class and return it — for column widths. */ +function withClass(node: HTMLElement, cls: string): HTMLElement { + node.classList.add(cls); + return node; +} + function fmtBytes(n: number): string { if (!Number.isFinite(n) || n <= 0) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; @@ -43,394 +75,632 @@ function fmtBytes(n: number): string { return `${(n / Math.pow(1024, i)).toFixed(i ? 1 : 0)} ${units[i]}`; } -export const renderReleases: SectionRender = (wrap, nav) => { - void mount(wrap, nav); -}; +/** The section's router, so nested builders can leave for another view — the + * release composer is a page of its own now, not a modal over this one. */ +let sectionNav: SectionNav | undefined; -async function mount(wrap: HTMLElement, nav: (view: string) => void): Promise<void> { - const refresh = (): void => renderReleases(wrap, nav); +export const renderReleases: SectionRender = (wrap, nav, target) => { + sectionNav = nav; + void mount(wrap, nav, target); +}; - const gate = await ghGate(wrap, nav, true); +async function mount(wrap: HTMLElement, nav: SectionNav, target?: SectionTarget): Promise<void> { + const refresh = (): void => { + bust("release"); + renderReleases(wrap, nav, target); + }; + const gate = await ghGate(wrap, nav, true, refresh); if (!gate) return; - const view = el("div", "gh-view"); - - // Header: title + @login + refresh, with a Releases|Tags segment and a - // "New release" action injected into its right-hand cluster. - const header = ghHeader("Releases", gate.login, refresh); - const acct = header.querySelector(".gh-acct"); - if (acct instanceof HTMLElement) { - const seg = el("div", "gh-seg"); - const relBtn = el("button", "gh-seg-btn"); - relBtn.textContent = "Releases"; - relBtn.classList.toggle("active", releaseTab === "releases"); - relBtn.addEventListener("click", () => { - releaseTab = "releases"; - refresh(); - }); - const tagBtn = el("button", "gh-seg-btn"); - tagBtn.textContent = "Tags"; - tagBtn.classList.toggle("active", releaseTab === "tags"); - tagBtn.addEventListener("click", () => { - releaseTab = "tags"; - refresh(); - }); - seg.append(relBtn, tagBtn); + if (target?.number != null) { + showReleaseDetailPage(wrap, nav, target.number, target.from); + return; + } + await listPage(wrap, nav, gate); +} - const newBtn = el("button", "mini-btn"); - newBtn.append(glyph("plus"), span("New release")); - newBtn.title = "Draft a new release"; - newBtn.addEventListener("click", () => void createRelease(refresh, "")); +// ── The list page (Releases | Tags) ────────────────────────────────────────── - acct.prepend(seg, newBtn); - } - view.appendChild(header); +async function listPage(wrap: HTMLElement, nav: SectionNav, gate: GhGate): Promise<void> { + const refresh = (): void => { + bust("release"); + renderReleases(wrap, nav); + }; - const body = el("div", "gh-body"); - const listEl = el("div", "gh-list"); - const detail = el("div", "gh-detail"); - body.append(listEl, ghListResizer(listEl), detail); - view.appendChild(body); - wrap.replaceChildren(view); + const { view, listEl } = sectionList(); + const header = ghHeader("Releases", gate.login, refresh); - if (releaseTab === "tags") { - detail.replaceChildren( - emptyState( - "Tags", - "Every git tag in this repository. Cut a release from one with “New release”.", - { icon: "tag", hint: "Tip: click a tag to draft a release from it." }, - ), - ); - await loadTags(listEl, header, refresh, nav); - return; - } + const tools = el("div", "gh-head-tools"); + const seg = segmented<"releases" | "tags">({ + options: [ + { value: "releases", label: "Releases" }, + { value: "tags", label: "Tags" }, + ], + value: releaseTab, + ariaLabel: "Releases view", + onChange: (v) => { + releaseTab = v; + renderReleases(wrap, nav); + }, + }); - await loadReleases(listEl, detail, header, refresh, nav); -} + const newBtn = el("button", "btn btn-primary gh-new-btn"); + newBtn.append(glyph("plus"), span("New release")); + newBtn.title = "Draft a new release"; + newBtn.addEventListener("click", () => sectionNav?.("releasenew")); -// ── The Releases list (left pane) ── + tools.append(seg, newBtn); + header.querySelector(".gh-acct")?.before(tools); + view.append(header, listEl); + wrap.replaceChildren(view); -async function loadReleases( - listEl: HTMLElement, - detail: HTMLElement, - header: HTMLElement & { setCount?: (n: number) => void }, - refresh: () => void, - nav: (view: string) => void, -): Promise<void> { - detail.replaceChildren( - emptyState("Releases", "Select a release to read its notes, browse assets, and inspect the tag.", { - icon: "tag", - hint: "Tip: open one to edit, delete, or download its assets.", + header.querySelector(".gh-head-titlewrap")?.appendChild( + searchField({ + placeholder: releaseTab === "releases" ? "Search releases…" : "Search tags…", + initial: query, + onInput: (q) => { + query = q; + rerenderList(); + }, }), ); - listEl.replaceChildren(skeletonList(5)); - let releases: ReleaseInfo[]; - try { - releases = await host.invoke("release:list", undefined); - } catch (e) { - listEl.replaceChildren( - errorState("Couldn't load releases", cleanErr(e) || "GitHub request failed.", refresh), - ); - return; + // ── data ── + let releases: ReleaseInfo[] | undefined = + releaseTab === "releases" ? cachePeek("release:list", undefined) : undefined; + let tags: TagInfo[] | undefined = + releaseTab === "tags" ? cachePeek("release:tags", undefined) : undefined; + if ((releaseTab === "releases" && !releases) || (releaseTab === "tags" && !tags)) { + listEl.replaceChildren(skeletonList(5)); } - header.setCount?.(releases.length); - listEl.replaceChildren(); - if (releases.length === 0) { - listEl.appendChild( - emptyState("No releases yet", "Publish your first release to share builds and notes.", { - icon: "tag", - action: { label: "New release", icon: "plus", onClick: () => void createRelease(refresh, "") }, - }), - ); - return; - } - - const select = (rel: ReleaseInfo, row: HTMLElement): void => { - listEl.querySelectorAll(".gh-row.active").forEach((n) => n.classList.remove("active")); - row.classList.add("active"); - void showReleaseDetail(detail, rel, refresh); - }; - - // "Latest" marks the first non-draft (published) release, mirroring github.com. - // Anchored to the unique id of the true latest published release (computed over - // the whole loaded list), so the badge renders correctly on every filtered - // render too — no cross-render latch. - const latestId = releases.find((r) => !r.draft)?.id; - - const buildRow = (rel: ReleaseInfo): HTMLElement => { + const buildReleaseRow = (rel: ReleaseInfo, latestId: number | undefined): HTMLElement => { const lead = el("span", "gh-lead-icon gh-lead-merged"); lead.appendChild(glyph("tag")); const suffix: HTMLElement[] = []; if (rel.draft) suffix.push(statePill("Draft", "draft")); if (rel.prerelease) suffix.push(statePill("Pre-release", "prerelease")); - if (!rel.draft && rel.id === latestId) { - suffix.push(statePill("Latest", "latest")); - } - - const when = rel.publishedAt ? `published ${relTimeISO(rel.publishedAt)}` : "draft"; - const author = rel.author?.login ? ` · ${rel.author.login}` : ""; + if (!rel.draft && rel.id === latestId) suffix.push(statePill("Latest", "latest")); - const stats: HTMLElement[] = []; - if (rel.assets.length) stats.push(statBit("file", rel.assets.length)); + // The meta cluster packs right-to-left, so an unpublished draft (no assets, + // no downloads) used to shove its tag and author 90px right of every other + // row's. Each datum keeps its column and goes invisible instead of absent. const downloads = rel.assets.reduce((sum, a) => sum + (a.downloadCount || 0), 0); - if (downloads > 0) stats.push(statBit("cloud-download", downloads)); + const meta: HTMLElement[] = [ + span(rel.tagName, "sec-mono rel-tag"), + blankable(span(rel.author?.login ?? "", "rel-author"), !!rel.author?.login), + withClass( + blankable(statBit("file", rel.assets.length, "", "assets"), rel.assets.length > 0), + // NOT "rel-assets" — that name already belongs to the detail page's + // vertical asset list (`flex-direction: column`), which stacked this + // row's file icon over its number and pushed the digit onto the row's + // bottom border. + "rel-asset-count", + ), + // Download counts run from "12" to "1,240"; without a floor the column + // moved every element to its LEFT by the difference. + withClass( + blankable(statBit("cloud-download", downloads), downloads > 0), + "rel-downloads", + ), + ]; - const row = ghRow({ + const row = secRow({ lead, title: rel.name || rel.tagName, titleSuffix: suffix, - meta: `${rel.tagName} · ${when}${author}`, - metaTitle: rel.publishedAt ? `Published ${absTimeISO(rel.publishedAt)}` : undefined, - stats, + meta, + time: rel.publishedAt ? relTimeISO(rel.publishedAt) : "draft", + timeTitle: rel.publishedAt ? `Published ${absTimeISO(rel.publishedAt)}` : "Unpublished draft", ariaLabel: `Release ${rel.name || rel.tagName}`, + onOpen: () => nav("releases", { number: rel.id }), }); - row.addEventListener("click", () => select(rel, row)); + row.dataset.num = String(rel.id); return row; }; - // Case-insensitive match over the fields a user would search by. - const matches = (rel: ReleaseInfo, q: string): boolean => { - const hay = `${rel.name} ${rel.tagName}`.toLowerCase(); - return hay.includes(q); - }; + const buildTagRow = (t: TagInfo): HTMLElement => + secRow({ + lead: (() => { + const s = el("span", "gh-lead-icon is-muted"); + s.appendChild(glyph("tag")); + return s; + })(), + title: t.name, + meta: [span(t.sha.slice(0, 7), "sec-mono")], + ariaLabel: `Tag ${t.name}`, + onOpen: () => openTagPeek(t, nav, refresh), + }); - let autoSelected = false; - const renderList = (items: ReleaseInfo[], q = ""): void => { + const rerenderList = (): void => { + const q = query.toLowerCase(); listEl.replaceChildren(); - if (items.length === 0) { - listEl.appendChild( - emptyState("No matching releases", `Nothing matches “${q}”.`, { icon: "search" }), - ); - return; - } - for (const rel of items) listEl.appendChild(buildRow(rel)); - // Auto-select the first release once (initial render) so the detail isn't a - // void; don't hijack the selection on every keystroke while filtering. - if (!autoSelected) { - autoSelected = true; - const first = items[0]; - const firstRow = listEl.firstElementChild as HTMLElement | null; - if (first && firstRow) select(first, firstRow); + if (releaseTab === "releases") { + if (!releases) return; + if (releases.length === 0) { + listEl.appendChild( + emptyState("No releases yet", "Publish your first release to share builds and notes.", { + icon: "tag", + action: { label: "New release", icon: "plus", onClick: () => sectionNav?.("releasenew") }, + }), + ); + return; + } + // "Latest" is the newest published, NON-PRE-RELEASE release — github.com's + // own rule. Taking the first non-draft awarded the badge to a release + // candidate whenever one was newest, so the list pointed at the RC instead + // of the build people are actually running. "Which version is current?" is + // the question this badge exists to answer. + const latestId = releases.find((r) => !r.draft && !r.prerelease)?.id; + const items = q + ? releases.filter((rel) => `${rel.name} ${rel.tagName}`.toLowerCase().includes(q)) + : releases; + // AFTER the filter, and with both numbers: the pill used to advertise + // the unfiltered total directly above a "No matching …" empty state. + header.setCount?.(items.length, releases.length); + if (items.length === 0) { + listEl.appendChild(emptyState("No matching releases", `Nothing matches “${query}”.`, { icon: "search", anchor: "inline" })); + return; + } + for (const rel of items) listEl.appendChild(buildReleaseRow(rel, latestId)); + } else { + if (!tags) return; + if (tags.length === 0) { + listEl.appendChild(emptyState("No tags", "This repository has no git tags yet.", { icon: "tag" })); + return; + } + const items = q ? tags.filter((t) => t.name.toLowerCase().includes(q)) : tags; + header.setCount?.(items.length, tags.length); + if (items.length === 0) { + listEl.appendChild(emptyState("No matching tags", `Nothing matches “${query}”.`, { icon: "search", anchor: "inline" })); + return; + } + for (const t of items) listEl.appendChild(buildTagRow(t)); } }; - // A header search/filter over the loaded list (client-side, instant). Inserted - // at the front of the action cluster (the .gh-acct that already holds the - // Releases|Tags segment and the New-release button), mirroring the Issues view. - // A header search/filter — on the LEFT, next to the title (client-side, instant). - header.querySelector(".gh-head-titlewrap")?.appendChild( - searchField({ - placeholder: "Search releases…", - onInput: (q) => - renderList(q ? releases.filter((rel) => matches(rel, q.toLowerCase())) : releases, q), - }), - ); - - renderList(releases); - void nav; // nav reserved for symmetry with the tags loader -} + if (releases || tags) rerenderList(); -// ── The Tags sub-list (left pane) ── - -async function loadTags( - listEl: HTMLElement, - header: HTMLElement & { setCount?: (n: number) => void }, - refresh: () => void, - nav: (view: string) => void, -): Promise<void> { - listEl.replaceChildren(skeletonList(5)); - - let tags: TagInfo[]; try { - tags = await host.invoke("release:tags", undefined); + if (releaseTab === "releases") { + const fresh = await gget("release:list", undefined, 30000); + if (!view.isConnected) return; + releases = fresh; + } else { + const fresh = await gget("release:tags", undefined, 30000); + if (!view.isConnected) return; + tags = fresh; + } + rerenderList(); } catch (e) { - listEl.replaceChildren( - errorState("Couldn't load tags", cleanErr(e) || "GitHub request failed.", refresh), - ); - return; - } - - header.setCount?.(tags.length); - listEl.replaceChildren(); - if (tags.length === 0) { - listEl.appendChild( - emptyState("No tags", "This repository has no git tags yet.", { icon: "tag" }), - ); - return; + if (!view.isConnected) return; + if (!releases && !tags) { + listEl.replaceChildren( + errorState( + releaseTab === "releases" ? "Couldn't load releases" : "Couldn't load tags", + cleanErr(e) || "GitHub request failed.", + refresh, + ), + ); + } } +} - for (const t of tags) { - const row = el("button", "gh-row"); - const top = el("div", "gh-row-title"); - top.append(glyph("tag"), span(t.name)); - const sub = el("div", "gh-row-sub"); - sub.textContent = t.sha.slice(0, 7); - row.append(top, sub); - row.title = `Draft a release from ${t.name}`; - // A tag row drafts a release from that tag (prefilled). - row.addEventListener("click", () => void createRelease(refresh, t.name)); - listEl.appendChild(row); - } - void nav; +// ── The tag peek (commit info + deliberate actions) ────────────────────────── + +/** A tag's lightweight drill-in: what it points at (from the LOCAL clone, when + * fetched) + Draft release / View in Commits / Copy SHA. */ +function openTagPeek(t: TagInfo, nav: SectionNav, refresh: () => void): void { + openPeek({ + icon: "tag", + title: t.name, + subtitle: t.sha ? `at ${t.sha.slice(0, 7)}` : undefined, + actions: [ + { + label: "Copy SHA", + icon: "copy", + onClick: () => void copyText(t.sha, "Tag SHA copied."), + }, + { + label: "View in Commits", + icon: "git-commit", + title: "Open this tag's commit", + onClick: (ctx) => { + ctx.close(); + nav("commit", { sha: t.sha }); + }, + }, + { + label: "Draft release", + icon: "plus", + primary: true, + title: `Draft a new release from ${t.name}`, + onClick: (ctx) => { + ctx.close(); + sectionNav?.("releasenew", { ref: t.name }); + }, + }, + ], + async render(body) { + // The tag's commit, read from the local clone — the sha comes from GitHub, + // so an unfetched tag simply isn't inspectable yet (a state, not an error). + let d: CommitDetailsPayload | undefined; + try { + d = t.sha ? await host.invoke("commit:details", t.sha) : undefined; + } catch { + d = undefined; + } + body.replaceChildren(); + if (!d) { + // The copy told you to fetch and then offered no way to do it — an + // instruction with no affordance is a dead end. + body.appendChild( + emptyState( + "Commit not in the local clone", + "Fetch from the remote to inspect what this tag points at.", + { + icon: "cloud-download", + action: { + label: "Fetch", + icon: "sync", + onClick: () => { + void host + .invoke("sync:fetch", { prune: pruneOnFetch() }) + .then(() => { + toast("Fetched. Reopen the tag to inspect its commit.", "success"); + }) + .catch((e) => toast(cleanErr(e) || "Fetch failed.", "error")); + }, + }, + }, + ), + ); + return; + } + const card = el("div", "gh-tag-commit-card"); + const subj = el("div", "gh-tag-commit-subject"); + subj.textContent = d.subject; + const who = el("div", "gh-tag-commit-meta"); + who.textContent = `${d.author} · ${relTime(d.authorDate)} · ${d.files.length} file${d.files.length === 1 ? "" : "s"} changed`; + who.title = absTime(d.authorDate); + card.append(subj, who); + if (d.body) { + const msg = el("pre", "gh-tag-commit-body"); + msg.textContent = d.body; + card.appendChild(msg); + } + body.appendChild(card); + }, + }); } -// ── The detail (right pane) ── +// ── The release detail page ────────────────────────────────────────────────── + +function showReleaseDetailPage( + wrap: HTMLElement, + nav: SectionNav, + id: number, + /** Where this was opened FROM. The Inbox opens releases, and back and Escape + * belong to the Inbox — not to whichever section owns the subject. Same + * contract Issues and PRs already honour. */ + from?: { view: string; label: string }, +): void { + const back = (): void => nav(from?.view ?? "releases", { list: true }); + const reload = (): void => { + bust("release"); + showReleaseDetailPage(wrap, nav, id, from); + }; -async function showReleaseDetail( - detail: HTMLElement, - rel: ReleaseInfo, - refresh: () => void, -): Promise<void> { - detail.replaceChildren(loadingState()); + const { view, main, rail, topActions } = detailPage({ + backLabel: from?.label ?? "Releases", + onBack: back, + }); + main.appendChild(skeletonList(4, false)); + wrap.replaceChildren(view); - // Refetch the single release so body/assets are guaranteed complete; fall back - // to the list-row data if the detail fetch fails so the pane never blanks. - let full: ReleaseInfo; - try { - full = (await host.invoke("release:detail", rel.id)) ?? rel; - } catch { - full = rel; - } - detail.replaceChildren(); + void (async () => { + let full: ReleaseInfo | undefined; + try { + full = await gget("release:detail", id, 15000); + } catch (e) { + if (!view.isConnected) return; + main.replaceChildren( + errorState("Couldn't load the release", cleanErr(e) || "GitHub request failed.", reload), + ); + return; + } + if (!view.isConnected) return; + if (!full) { + main.replaceChildren(emptyState("Release unavailable", "This release couldn't be loaded.")); + return; + } + // Name this page in the history. Every other detail page does; without it + // `detailPage` falls back to the VIEW's name, so leaving a release for its + // editor and pressing Back read "← Releases" while actually returning to + // the release — the button described the wrong destination. + setPageLabel(full.name || full.tagName); + buildReleaseDetail({ main, rail, topActions, rel: full, nav, reload, back }); + })(); +} - const head = el("div", "gh-detail-head"); - const h = el("div", "gh-detail-title"); - h.textContent = full.name || full.tagName; +interface ReleaseDetailCtx { + main: HTMLElement; + rail: HTMLElement; + topActions: HTMLElement; + rel: ReleaseInfo; + nav: SectionNav; + reload: () => void; + back: () => void; +} - const meta = el("div", "gh-detail-meta"); - const when = full.publishedAt - ? `published ${relTimeISO(full.publishedAt)}` - : "unpublished draft"; - const target = full.targetCommitish ? ` ← ${full.targetCommitish}` : ""; - const author = full.author?.login ? ` · ${full.author.login}` : ""; - meta.textContent = `${full.tagName}${target}${author} · ${when}`; - if (full.draft) { - meta.appendChild(document.createTextNode(" ")); - meta.appendChild(pill("Draft", "gh-pill-draft")); - } - if (full.prerelease) { - meta.appendChild(document.createTextNode(" ")); - meta.appendChild(pill("Pre-release", "gh-state-prerelease")); - } +function buildReleaseDetail(ctx: ReleaseDetailCtx): void { + const { main, rail, topActions, rel, nav, reload, back } = ctx; + main.replaceChildren(); + rail.replaceChildren(); + wireProseNav(main, nav); - const actions = el("div", "gh-detail-actions"); + // ── top-bar actions ── const editBtn = el("button", "mini-btn"); editBtn.append(glyph("pencil"), span("Edit")); editBtn.title = "Edit this release"; - editBtn.addEventListener("click", () => void editRelease(full, detail, refresh)); - - const delBtn = el("button", "mini-btn danger"); - delBtn.append(glyph("trash"), span("Delete")); - delBtn.title = "Delete this release"; - delBtn.addEventListener("click", () => void deleteRelease(full, delBtn, refresh)); - - const openBtn = el("button", "mini-btn"); - openBtn.append(glyph("link-external"), span("Open on GitHub")); - openBtn.title = "Open this release on github.com"; - openBtn.addEventListener("click", () => window.open(full.htmlUrl, "_blank")); + editBtn.addEventListener("click", () => sectionNav?.("releasenew", { number: rel.id })); + + // On a DRAFT, publishing is not "another action" — it is the one thing the + // word draft exists to prompt, and it was three clicks deep behind a kebab + // whose own icon says nothing. A draft release page now leads with it. + const publishBtn = el("button", "btn btn-primary") as HTMLButtonElement; + publishBtn.append(glyph("rocket"), span("Publish release")); + publishBtn.title = `Publish ${rel.tagName} — everyone watching this repository is notified`; + publishBtn.addEventListener("click", () => void publishRelease(rel, publishBtn, reload)); + + const moreBtn = el("button", "mini-btn gh-icon-btn"); + moreBtn.append(glyph("ellipsis")); + moreBtn.title = "More actions"; + moreBtn.addEventListener("click", () => + openMenu(moreBtn, [ + { label: "Copy link", icon: "copy", onClick: () => void copyText(rel.htmlUrl, "Copied release link.") }, + { separator: true }, + { + label: "Delete release", + icon: "trash", + onClick: () => void deleteRelease(rel, moreBtn, back), + }, + ]), + ); - actions.append(editBtn, delBtn, openBtn); - head.append(h, meta, actions); - detail.appendChild(head); + const openBtn = el("button", "mini-btn gh-icon-btn"); + openBtn.append(glyph("link-external")); + openBtn.title = "Open this release on GitHub"; + openBtn.setAttribute("aria-label", openBtn.title); + openBtn.addEventListener("click", () => window.open(rel.htmlUrl, "_blank")); + + // Publish leads on a draft and is absent everywhere else — a greyed-out + // "Publish release" sitting permanently on a published one is just noise. + topActions.replaceChildren(...(rel.draft ? [publishBtn] : []), editBtn, moreBtn, openBtn); + + // ── title block ── + const titleRow = el("div", "det-title-row"); + // The list marks a release with every badge that applies — Pre-release AND + // Latest, say — and this page reduced all of it to one, so opening a row you + // had picked out as "Pre-release" showed you a release labelled "Published". + // Same pills, same rules, computed from the same list. + const pills = el("div", "det-title-pills"); + if (rel.draft) pills.appendChild(statePill("Draft", "draft")); + if (rel.prerelease) pills.appendChild(statePill("Pre-release", "prerelease")); + const published = !rel.draft && !rel.prerelease ? statePill("Published", "latest") : undefined; + if (published) pills.appendChild(published); + titleRow.appendChild(pills); + // "Latest" is a property of the LIST, not of one release, so it needs the + // list to answer — from cache, without blocking the page on a request. + void gget("release:list", undefined, 30000) + .then((all) => { + if (!pills.isConnected) return; + if (!rel.draft && !rel.prerelease && all.find((r) => !r.draft && !r.prerelease)?.id === rel.id) { + // REPLACING "Published", not sitting beside it. Both resolve to the same + // green check pill, so the latest release wore two badges that were + // pixel-identical and said the same thing twice — a release cannot be + // Latest without being Published, and the stronger word is the one worth + // the space. + published?.remove(); + pills.appendChild(statePill("Latest", "latest")); + } + }) + .catch(() => { + /* offline — the other pills still tell the truth */ + }); + const h = el("h1", "det-title"); + h.textContent = rel.name || rel.tagName; + titleRow.appendChild(h); + main.appendChild(titleRow); + + const sub = el("div", "det-sub"); + const tagChip = el("button", "gh-branch-chip"); + tagChip.append(glyph("tag"), span(rel.tagName)); + tagChip.title = "Copy the tag name"; + tagChip.addEventListener("click", () => void copyText(rel.tagName, "Tag name copied.")); + sub.appendChild(tagChip); + const when = el("span"); + when.textContent = rel.publishedAt + ? `published ${relTimeISO(rel.publishedAt)}` + : "unpublished draft"; + if (rel.publishedAt) when.title = absTimeISO(rel.publishedAt); + sub.appendChild(when); + main.appendChild(sub); - // Notes — rendered markdown (renderMarkdown sanitizes/escapes, so innerHTML is - // the intended path here, matching the commit-body renderer). - if (full.body && full.body.trim()) { + // ── notes ── + if (rel.body && rel.body.trim()) { const notes = el("div", "gh-body-md"); - notes.innerHTML = renderMarkdown(full.body); - detail.appendChild(notes); + notes.innerHTML = renderMarkdown(rel.body); + main.appendChild(notes); } else { - detail.appendChild(emptyState("No release notes", "This release has no description.")); + main.appendChild(emptyState("No release notes", "This release has no description.")); } - // Assets table. - if (full.assets.length) { - detail.appendChild(groupLabel(`Assets (${full.assets.length})`)); + // ── assets (download / upload / delete — no browser round-trips) ── + const assetsHead = el("div", "rel-assets-head"); + assetsHead.appendChild(groupLabel(`Assets (${rel.assets.length})`)); + const uploadBtn = el("button", "mini-btn"); + uploadBtn.append(glyph("cloud-upload"), span("Upload assets…")); + uploadBtn.title = "Attach local files to this release"; + uploadBtn.addEventListener("click", () => void uploadAssets(rel, uploadBtn, reload)); + assetsHead.appendChild(uploadBtn); + main.appendChild(assetsHead); + if (rel.assets.length) { const list = el("div", "rel-assets"); - for (const a of full.assets) { - const row = el("button", "list-row"); + for (const a of rel.assets) { + // The row is a DIV, not a button. It used to be a <button> with the + // delete <button> nested inside it, which is invalid: the outer row's + // accessible name swallows the inner control ("Download x" absorbing + // "Delete x from this release"), assistive tech cannot reach the inner + // one, and a single click can dispatch on both. The download is now the + // row's own trailing action, and delete is its sibling. + const row = el("div", "list-row is-clickable rel-asset-row"); + row.setAttribute("role", "button"); + row.tabIndex = 0; row.appendChild(glyph("package")); const m = el("div", "row-meta"); const t = el("div", "row-meta-title"); t.textContent = a.label || a.name; - const sub = el("div", "row-meta-sub"); - sub.textContent = `${fmtBytes(a.size)} · ${a.downloadCount} download${a.downloadCount === 1 ? "" : "s"}`; - m.append(t, sub); - const dl = el("span", "gh-adds"); + const subT = el("div", "row-meta-sub"); + // Grouped like every other count in the app — the rail above this list already + // wrote the same figure as "48,200" while these rows wrote "48200". + subT.textContent = `${fmtBytes(a.size)} · ${plural(a.downloadCount, "download")}`; + m.append(t, subT); + row.append(m); + const download = (): void => void window.open(a.downloadUrl, "_blank"); + const acts = el("span", "rel-asset-acts"); + const del = el("button", "icon-btn rel-asset-del"); + del.appendChild(glyph("trash")); + del.title = `Delete ${a.name} from this release`; + del.setAttribute("aria-label", del.title); + del.addEventListener("click", (e) => { + e.stopPropagation(); + void deleteAsset(a.id, a.name, del, reload); + }); + const dl = el("button", "icon-btn rel-asset-dl"); dl.appendChild(glyph("cloud-download")); - row.append(m, dl); + dl.title = `Download ${a.name}`; + dl.setAttribute("aria-label", dl.title); + dl.addEventListener("click", (e) => { + e.stopPropagation(); + download(); + }); + acts.append(del, dl); + row.appendChild(acts); + row.setAttribute("aria-label", `Download ${a.name}`); row.title = `Download ${a.name}`; - row.addEventListener("click", () => window.open(a.downloadUrl, "_blank")); + row.addEventListener("click", download); + row.addEventListener("keydown", (e) => { + // Only the ROW itself. This row carries a Delete button, and without + // the guard Enter on it ran the row's own action instead — so the + // keyboard path to the destructive control silently downloaded the + // asset, and the control could not be reached by keyboard at all. + if (e.target !== row) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + download(); + } + }); list.appendChild(row); } - detail.appendChild(list); + main.appendChild(list); + } else { + const none = el("div", "rel-assets-none"); + none.textContent = "No assets on this release yet."; + main.appendChild(none); } -} -// ── CRUD actions ── + // ── rail ── + const tagProp = propSection("Tag"); + const tagBtn = el("button", "det-mono-btn"); + tagBtn.append(glyph("copy"), span(rel.tagName)); + tagBtn.title = "Copy the tag name"; + tagBtn.addEventListener("click", () => void copyText(rel.tagName, "Tag name copied.")); + tagProp.body.appendChild(tagBtn); + + const authorProp = propSection("Author"); + if (rel.author?.login) { + authorProp.body.appendChild(personChip(rel.author.login, rel.author.avatarUrl)); + } else { + authorProp.body.appendChild(span("—", "det-prop-none")); + } -/** Draft a new release; `prefillTag` comes from a Tags-row click. */ -async function createRelease(refresh: () => void, prefillTag: string): Promise<void> { - const input = await releaseFormDialog("New release", { - tagName: prefillTag, - targetCommitish: "", - name: "", - body: "", - draft: false, - prerelease: false, - }); - if (!input) return; + const about = propSection("About"); + about.body.classList.add("det-prop-facts"); + const fact = (k: string, v: string, title?: string): HTMLElement => { + const row = el("div", "det-fact"); + const val = el("span", "det-fact-v"); + val.textContent = v; + if (title) val.title = title; + row.append(span(k, "det-fact-k"), val); + return row; + }; + if (rel.targetCommitish) about.body.appendChild(fact("Target", rel.targetCommitish)); + about.body.appendChild(fact("Assets", String(rel.assets.length))); + const downloads = rel.assets.reduce((sum, a) => sum + (a.downloadCount || 0), 0); + if (downloads > 0) about.body.appendChild(fact("Downloads", downloads.toLocaleString())); + about.body.appendChild(fact("Created", relTimeISO(rel.createdAt), absTimeISO(rel.createdAt))); + if (rel.publishedAt) { + about.body.appendChild(fact("Published", relTimeISO(rel.publishedAt), absTimeISO(rel.publishedAt))); + } + + rail.append(tagProp.root, authorProp.root, about.root); +} + +/** Pick local files (native dialog, in MAIN) and upload them as release assets. */ +async function uploadAssets(rel: ReleaseInfo, btn: HTMLElement, reload: () => void): Promise<void> { + const b = btn as HTMLButtonElement; + b.disabled = true; try { - const r = await host.invoke("release:create", input); + const r = await host.invoke("release:uploadAssets", { id: rel.id }); if (!r.ok) { - toast(r.message ?? "Couldn't create the release.", "error"); + // A cancelled picker is a non-event, not an error toast. + if (!r.expected) toast(r.message ?? "Couldn't upload the assets.", "error"); return; } - toast(`Created release ${input.tagName}.`, "success"); - releaseTab = "releases"; - refresh(); + toast(r.message ?? "Assets uploaded.", "success"); + reload(); } catch (e) { - toast(cleanErr(e) || "Couldn't create the release.", "error"); + toast(cleanErr(e) || "Couldn't upload the assets.", "error"); + } finally { + b.disabled = false; } } -async function editRelease( - rel: ReleaseInfo, - detail: HTMLElement, - refresh: () => void, +async function deleteAsset( + id: number, + name: string, + btn: HTMLElement, + reload: () => void, ): Promise<void> { - const input = await releaseFormDialog("Edit release", { - id: rel.id, - tagName: rel.tagName, - targetCommitish: rel.targetCommitish, - name: rel.name, - body: rel.body ?? "", - draft: rel.draft, - prerelease: rel.prerelease, + const ok = await confirmDialog({ + title: `Delete asset ${name}?`, + message: "This permanently removes the file from the release on GitHub.", + confirmLabel: "Delete asset", + danger: true, }); - if (!input) return; + if (!ok) return; + (btn as HTMLButtonElement).disabled = true; try { - const r = await host.invoke("release:update", input); + const r = await host.invoke("release:deleteAsset", id); if (!r.ok) { - toast(r.message ?? "Couldn't update the release.", "error"); + toast(r.message ?? "Couldn't delete the asset.", "error"); return; } - toast(`Updated release ${input.tagName}.`, "success"); - refresh(); - void showReleaseDetail(detail, rel, refresh); // keep the detail open with fresh data + toast(`Deleted ${name}.`, "success"); + reload(); } catch (e) { - toast(cleanErr(e) || "Couldn't update the release.", "error"); + toast(cleanErr(e) || "Couldn't delete the asset.", "error"); + } finally { + (btn as HTMLButtonElement).disabled = false; } } -async function deleteRelease( - rel: ReleaseInfo, - btn: HTMLElement, - refresh: () => void, -): Promise<void> { +// ── CRUD actions ── + +/** Draft a new release; `prefillTag` comes from a tag peek. */ +async function deleteRelease(rel: ReleaseInfo, btn: HTMLElement, back: () => void): Promise<void> { const ok = await confirmDialog({ title: `Delete release ${rel.name || rel.tagName}?`, message: `This permanently deletes the release on GitHub. The git tag ${rel.tagName} is not removed. This can't be undone.`, @@ -446,7 +716,8 @@ async function deleteRelease( return; } toast(`Deleted release ${rel.name || rel.tagName}.`, "success"); - refresh(); + bust("release"); + back(); // the detail's subject no longer exists — land on the list } catch (e) { toast(cleanErr(e) || "Couldn't delete the release.", "error"); } finally { @@ -454,165 +725,41 @@ async function deleteRelease( } } -// ── The multi-field release form dialog ── -// -// dialogs.ts keeps its `modal()` scaffold private and only exports the -// single-field promptInline, so this section ships its own modal. It reuses the -// shared .modal-overlay / .modal-* CSS, traps focus, closes on Esc / backdrop / -// Cancel, and submits on the primary button or ⌘/Ctrl+Enter. - -function mkDialogEl(tag: string, cls = ""): HTMLElement { - const n = document.createElement(tag); - if (cls) n.className = cls; - return n; -} +/* + * `createRelease`, `editRelease` and `releaseFormDialog` used to live here: a + * 560px modal card with the release notes squeezed into ~180px of it. They are + * `views/releaseCompose.ts` now — a routed page, reached through + * `sectionNav("releasenew")` above. + */ -function releaseFormDialog(title: string, init: ReleaseInput): Promise<ReleaseInput | null> { - return new Promise((resolve) => { - let settled = false; - const prevFocus = document.activeElement as HTMLElement | null; - const overlay = mkDialogEl("div", "modal-overlay"); - overlay.setAttribute("role", "dialog"); - overlay.setAttribute("aria-modal", "true"); - - const finish = (value: ReleaseInput | null): void => { - if (settled) return; - settled = true; - overlay.remove(); - document.removeEventListener("keydown", onKey, true); - prevFocus?.focus?.(); - resolve(value); - }; - - const card = mkDialogEl("div", "modal-card modal-form"); - const h = mkDialogEl("div", "modal-title"); - h.textContent = title; - - const field = (label: string, ctrl: HTMLElement): HTMLElement => { - const f = mkDialogEl("label", "modal-field"); - const l = mkDialogEl("span", "modal-field-label"); - l.textContent = label; - f.append(l, ctrl); - return f; - }; - - const tag = document.createElement("input"); - tag.className = "modal-input"; - tag.placeholder = "v1.0.0"; - tag.value = init.tagName ?? ""; - - const target = document.createElement("input"); - target.className = "modal-input"; - target.placeholder = "main (target branch or commit)"; - target.value = init.targetCommitish ?? ""; - - const name = document.createElement("input"); - name.className = "modal-input"; - name.placeholder = "Release title"; - name.value = init.name ?? ""; - - const bodyInput = document.createElement("textarea"); - bodyInput.className = "modal-input modal-textarea"; - bodyInput.rows = 6; - bodyInput.placeholder = "Release notes (Markdown supported)…"; - bodyInput.value = init.body ?? ""; - - const draft = document.createElement("input"); - draft.type = "checkbox"; - draft.checked = !!init.draft; - const pre = document.createElement("input"); - pre.type = "checkbox"; - pre.checked = !!init.prerelease; - - const checks = mkDialogEl("div", "modal-checks"); - const checkWrap = (cb: HTMLInputElement, text: string): HTMLElement => { - const w = mkDialogEl("label", "modal-check"); - const t = mkDialogEl("span"); - t.textContent = text; - w.append(cb, t); - return w; - }; - checks.append( - checkWrap(draft, "Draft (don't publish yet)"), - checkWrap(pre, "Pre-release"), - ); - - const actions = mkDialogEl("div", "modal-actions"); - const cancel = mkDialogEl("button", "mini-btn"); - cancel.textContent = "Cancel"; - const ok = mkDialogEl("button", "btn btn-primary modal-ok"); - const okSpan = mkDialogEl("span"); - okSpan.textContent = init.id === undefined ? "Create" : "Save"; - ok.appendChild(okSpan); - actions.append(cancel, ok); - - card.append( - h, - field("Tag", tag), - field("Target", target), - field("Title", name), - field("Notes", bodyInput), - checks, - actions, - ); - - const submit = (): void => { - const tagName = tag.value.trim(); - if (!tagName) { - tag.focus(); - return; - } - finish({ - id: init.id, - tagName, - targetCommitish: target.value.trim() || undefined, - name: name.value.trim(), - body: bodyInput.value, - draft: draft.checked, - prerelease: pre.checked, +/** Flip a draft release to published — the action the word "draft" implies. */ +async function publishRelease(rel: ReleaseInfo, btn: HTMLElement, reload: () => void): Promise<void> { + const ok = await confirmDialog({ + title: `Publish ${rel.tagName}?`, + message: + "The release becomes visible to everyone with access to the repository, and its assets become downloadable.", + confirmLabel: "Publish", + }); + if (!ok) return; + await runBusy(btn, async () => { + try { + const r = await host.invoke("release:update", { + id: rel.id, + tagName: rel.tagName, + name: rel.name ?? undefined, + body: rel.body ?? undefined, + prerelease: rel.prerelease, + draft: false, }); - }; - - cancel.addEventListener("click", () => finish(null)); - ok.addEventListener("click", submit); - overlay.addEventListener("mousedown", (e) => { - if (e.target === overlay) finish(null); - }); - // ⌘/Ctrl+Enter submits from anywhere in the form (textarea included). - card.addEventListener("keydown", (e) => { - if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { - e.preventDefault(); - submit(); - } - }); - - const onKey = (e: KeyboardEvent): void => { - if (e.key === "Escape") { - e.preventDefault(); - finish(null); + if (!r.ok) { + toast(r.message ?? "Couldn't publish the release.", "error"); return; } - if (e.key !== "Tab") return; - const focusables = Array.from( - card.querySelectorAll<HTMLElement>( - "button, input, textarea, [tabindex]:not([tabindex='-1'])", - ), - ).filter((n) => !n.hasAttribute("disabled")); - if (!focusables.length) return; - const first = focusables[0]; - const last = focusables[focusables.length - 1]; - if (e.shiftKey && document.activeElement === first) { - e.preventDefault(); - last.focus(); - } else if (!e.shiftKey && document.activeElement === last) { - e.preventDefault(); - first.focus(); - } - }; - - overlay.appendChild(card); - document.body.appendChild(overlay); - document.addEventListener("keydown", onKey, true); - setTimeout(() => tag.focus(), 0); + toast(`Published ${rel.tagName}.`, "success"); + bust(); + reload(); + } catch (e) { + toast(cleanErr(e) || "Couldn't publish the release.", "error"); + } }); } diff --git a/apps/desktop/src/shared/cloneName.ts b/apps/desktop/src/shared/cloneName.ts new file mode 100644 index 0000000..a4653cf --- /dev/null +++ b/apps/desktop/src/shared/cloneName.ts @@ -0,0 +1,24 @@ +// Pure clone-target-name helpers, shared by the main process (cloneBridge +// validates before spawning git) and the renderer (the clone dialog and the +// destination sheet validate live, as the user types). Type-only-adjacent — +// imports nothing host-specific. + +/** Derive a folder name from a git URL's last path segment ("repo" from + * "https://github.com/owner/repo.git"). Undefined when nothing usable. */ +export function deriveNameFromUrl(url: string): string | undefined { + const trimmed = url.trim().replace(/\/+$/, ""); + const seg = trimmed.split(/[\\/:]/).pop() ?? ""; + const name = seg.replace(/\.git$/i, ""); + return name || undefined; +} + +/** Why a folder-name override is unusable, or null when it's fine. + * An EMPTY name is fine — it means "use the derived name". */ +export function validateTargetName(name: string): string | null { + const n = name.trim(); + if (!n) return null; + if (n.startsWith("-")) return "A folder name can't start with a dash."; + if (/[\\/]/.test(n)) return "A folder name can't contain path separators."; + if (n === "." || n === "..") return "That isn't a usable folder name."; + return null; +} diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index fac2b31..f66749b 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -26,6 +26,17 @@ export interface RefInfo { sha: string; isCurrent: boolean; upstream?: string; + /** The tracked upstream no longer exists (git's `[gone]`). */ + gone?: boolean; + /** Tip commit date, epoch seconds. */ + date?: number; + /** Tip commit subject — what this ref actually points at. */ + subject?: string; + /** "tag" for an ANNOTATED tag, "commit" otherwise. */ + objectType?: string; + /** For a remote's own HEAD ref, the DEFAULT branch it points at. (Written + * without the literal path, because the star-slash in it closes a comment.) */ + symref?: string; } /** The current HEAD, for the sidebar's "on branch …" affordance. */ @@ -50,8 +61,32 @@ export interface ChangedFile { path: string; /** Single-letter git status: A(dded) M(odified) D(eleted) R(enamed) … */ status: string; + /** + * The path on the BASE side, when this is a rename or a copy. + * + * The base does not have the file under its new name, so a diff asked for + * `path` on both sides comes back empty on the left and renders a rename as + * a brand-new file. Callers pass this as `compare:fileDiff`'s `leftPath`. + */ + oldPath?: string; /** Present for working-tree changes: is the change staged (in the index)? */ staged?: boolean; + /** + * An UNMERGED path — a merge conflict, not an edit. + * + * Porcelain v1's two status columns normally mean index-half and + * worktree-half, which is what the parser assumed. For an unmerged path they + * mean something else entirely: the two SIDES of the merge. Reading `UD` as + * "staged U, unstaged D" produced two rows for one file, one of them claiming + * a deletion of a file sitting on disk, and the phantom "staged" copy carried + * an Unstage button that destroys the merge stages. + * + * Nothing downstream could tell a conflict from an edit because nothing said + * so. This is that flag. + */ + conflicted?: boolean; + /** For an unmerged path, the raw two-letter code (UU, AA, DU, UD, …). */ + conflictKind?: string; } /** One entry in a HEAD directory listing, for the GitHub-style Code browser. */ @@ -104,9 +139,13 @@ export interface CommitDetails { parents: string[]; author: string; authorEmail: string; + /** Epoch SECONDS — what git's `%at` gives, and what `relTime`/`absTime` in + * the renderer take. Passing milliseconds reads as "just now" forever, + * because the negative delta is clamped to zero. */ authorDate: number; committer: string; committerEmail: string; + /** Epoch seconds — see `authorDate`. */ committerDate: number; subject: string; body: string; @@ -132,6 +171,36 @@ export interface FileDiff { * Undefined for a commit diff, which has nothing to stage. */ indexText?: string; + /** + * This file is BINARY, so there is no text diff to show. + * + * `git show` on a PNG or a font decodes to a wall of U+FFFD (or, with a NUL + * byte in it, to nothing). Handing that to a diff editor produced two empty + * panes and no explanation — "the diff doesn't show". The renderer says so + * instead of mounting an editor over nothing. + */ + binary?: boolean; + /** One side was longer than the read cap and is shown only in part. */ + truncated?: boolean; + /** + * The file is not on disk. + * + * Two empty sides are not always an empty file: a path added to the index and + * then deleted from the working tree (git's `AD`) reads as empty on both + * sides of a HEAD-vs-working diff, and calling that "an empty file" is a + * different claim from "you deleted it". The producer knows which; the panel + * cannot tell from the text. + */ + deleted?: boolean; + /** + * The file exists on ONE side only. + * + * For a text file the two panes show this plainly. For a BINARY one both + * texts are empty by construction — the producer refuses to decode it — so + * nothing downstream could tell an added image from a deleted one from an + * edited one, and all three were described as "its contents changed". + */ + onlySide?: "added" | "deleted"; } /** One change since HEAD, and how much of it the index already holds. */ @@ -151,6 +220,24 @@ export interface ConflictModel { result: string; oursLabel: string; theirsLabel: string; + /** No text to merge — the file is binary on at least one side. */ + binary?: boolean; + /** The working copy was read only in part, so `result` is not the file and + * writing it back would truncate it. No text merge is possible. */ + truncated?: boolean; + /** A MODIFY/DELETE conflict: this side has no version of the file at all + * (the index holds no stage for it). Not the same as a side that emptied + * it, which is what an empty string alone looks like. */ + missingSide?: "ours" | "theirs"; + /** + * NEITHER side has this file — git's `DD`. + * + * Distinct from a modify/delete: the index lists the path with stage 1 and + * neither 2 nor 3. Folded into `missingSide` it was drawn as "changed on one + * side, deleted on the other" and offered a "Take <side>" button for a side + * that has nothing to take, which `conflictTakeSide` then refuses. + */ + bothDeleted?: boolean; } /** A git action requested from the graph context menu. */ @@ -231,9 +318,14 @@ export interface CompareCommit { sha: string; shortSha: string; subject: string; + /** The rest of the message. `git log` already parses it; this used to drop + * it, so a commit list had no way to show a commit's reasoning. */ + body?: string; author: string; /** Author date, epoch seconds. */ date: number; + /** More than one parent — reads completely differently in a list. */ + isMerge?: boolean; } /** The result of comparing two refs (base…head). */ @@ -242,8 +334,13 @@ export interface CompareResult { commits: CompareCommit[]; /** Files changed between base and head. */ files: ChangedFile[]; + /** The REAL count of commits in base..head — not the length of `commits`, + * which is capped. See `commitsTruncated`. */ ahead: number; behind: number; + /** True when `commits` holds only the first N of `ahead`. The list has to be + * able to say so; a count that is silently a cap is worse than no count. */ + commitsTruncated?: boolean; } /** Diff range mode for Compare: ".." (direct) or "..." (since merge-base). */ @@ -268,6 +365,31 @@ export interface BranchInfo { upstream?: string; ahead: number; behind: number; + /** + * The upstream this branch tracks NO LONGER EXISTS (git's `[gone]`). + * + * The most common state in this app's own workflow — GitHub deletes the head + * branch when a pull request merges — and it used to be thrown away in + * `parseTrack`, so the branch read as `0 ahead, 0 behind`: perfectly in sync + * with a remote that is not there. It is also the clearest signal that a + * branch is finished and safe to delete. + */ + gone?: boolean; + /** + * Divergence from the repository's DEFAULT branch, not from the upstream. + * + * A different and more useful question than ahead/behind-upstream: "how far + * is this from main". `aheadDefault === 0` means every commit here is + * already reachable from the default branch — which is what MERGED means, + * and therefore what "safe to delete" means. + * + * Absent on git < 2.41, which does not have `%(ahead-behind:)`. Absent is + * not zero: the UI must render nothing rather than a bar of zero. + */ + aheadDefault?: number; + behindDefault?: number; + /** Every commit on this branch is reachable from the default branch. */ + merged?: boolean; /** Subject of the branch tip commit. */ subject: string; /** Tip commit author date, epoch seconds. */ @@ -288,6 +410,24 @@ export interface PrRef { ref: string; sha: string; } +/** The emoji reaction tallies GitHub keeps on issues, PRs, and comments. + * Only non-zero buckets are rendered, so a quiet item shows nothing. */ +export interface ReactionSummary { + total: number; + plusOne: number; + minusOne: number; + laugh: number; + hooray: number; + confused: number; + heart: number; + rocket: number; + eyes: number; +} + +/** How the author relates to the repo (OWNER / MEMBER / CONTRIBUTOR / …) — + * GitHub badges this next to a name and it's real signal about who's talking. */ +export type AuthorAssociation = string; + export interface PullRequest { number: number; title: string; @@ -305,12 +445,40 @@ export interface PullRequest { additions?: number; deletions?: number; changedFiles?: number; + /** Assigned users, so the detail rail can SHOW them (not just set them). */ + assignees?: GitHubUser[]; + /** Set when the PR was merged — "closed" and "merged" are different states. */ + mergedAt?: string | null; + /** When it closed (merged or not). */ + closedAt?: string | null; + /** Who actually pressed merge — often NOT the author. */ + mergedBy?: GitHubUser | null; + /** Review-thread comment count (distinct from `comments`, the conversation). */ + reviewComments?: number; + /** Commits in the PR. */ + commits?: number; + /** Reviewers who were asked but haven't reviewed yet. */ + requestedReviewers?: GitHubUser[]; + milestone?: { number: number; title: string } | null; + authorAssociation?: AuthorAssociation; + /** "owner/repo" of the HEAD branch's repo — set when the PR comes from a + * fork, which changes how much you trust its CI. */ + headRepoFullName?: string | null; + reactions?: ReactionSummary; } export interface PrFile { filename: string; status: string; additions: number; deletions: number; + /** + * Where a renamed or copied file came FROM. + * + * GitHub sends it and the mapper dropped it, so an `R` row could say a file + * was renamed and never say from what — which is the only fact that makes a + * rename readable. Costs nothing: it is in the response already. + */ + previousFilename?: string; } /** A PR detail bundle for the PR detail panel. */ export interface PrDetail { @@ -331,6 +499,15 @@ export interface IssueInfo { comments: number; labels: PrLabel[]; assignees: GitHubUser[]; + /** The issue's milestone, so the detail rail can SHOW it (not just set it). */ + milestone?: { number: number; title: string } | null; + closedAt?: string | null; + closedBy?: GitHubUser | null; + /** "completed" | "not_planned" | "reopened" — GitHub renders a closed issue + * differently depending on WHY, and so must we (purple vs gray). */ + stateReason?: string | null; + authorAssociation?: AuthorAssociation; + reactions?: ReactionSummary; } export interface ProjectInfo { /** GraphQL node id (ProjectV2) — the handle for item queries + mutations. */ @@ -355,12 +532,31 @@ export interface IssueComment { author: GitHubUser | null; body: string; createdAt: string; + /** Later than createdAt ⇒ the comment was edited after posting. */ + updatedAt?: string; + authorAssociation?: AuthorAssociation; + reactions?: ReactionSummary; } export interface IssueDetail { issue: IssueInfo; comments: IssueComment[]; assignees: string[]; } +/** One entry on the My Work page: something in this repo that involves YOU — + * a review you were asked for, an item assigned to you, a PR you authored, or + * a mention. The workday-first surface (docs/desktop-redesign.md). */ +export interface MyWorkItem { + kind: "review-requested" | "assigned" | "my-prs" | "mentions"; + type: "issue" | "pr"; + number: number; + title: string; + state: string; + draft: boolean; + updatedAt: string; + comments: number; + author: string | null; +} + /** A unified, read-only issue/PR snapshot from ANY repo — for viewing a * cross-repo notification subject in-app rather than opening github.com. */ export interface ExternalItemDetail { @@ -405,16 +601,48 @@ export interface WorkflowStep { status: string; conclusion: string; number: number; + /** Step timing — the raw material for the per-step duration timeline. */ + startedAt: string; + completedAt: string; } export interface WorkflowJob { id: number; + /** The run this job belongs to (deep-link key). */ + runId: number; + runAttempt: number; name: string; status: string; conclusion: string; htmlUrl: string; + /** Queued time — `startedAt − createdAt` is the queue latency. */ + createdAt: string; startedAt: string; completedAt: string; steps: WorkflowStep[]; + /** WHERE it ran: the runner's name ("GitHub Actions 12") + group. */ + runnerName: string; + runnerGroupName: string; + /** The requested runner labels ("ubuntu-latest", "self-hosted"…). */ + labels: string[]; + workflowName: string; + headBranch: string; +} +/** One increment of a job's log for the live-tail pipeline (see + * main/github/logTail.ts for the append/reset/truncate semantics). */ +export interface LogDelta { + text: string; + totalLength: number; + reset: boolean; + truncated: boolean; +} +/** Server-side filters for the runs list — GitHub filters these at the API. */ +export interface ActionsRunsFilter { + workflowId?: number; + branch?: string; + actor?: string; + /** Status OR conclusion (GitHub treats the param as either). */ + status?: string; + event?: string; } export interface WorkflowRunDetail { run: WorkflowRun; @@ -474,6 +702,21 @@ export interface ReleaseInput { body?: string; draft?: boolean; prerelease?: boolean; + /** + * Whether this release becomes the repository's "Latest" one. + * + * GitHub's own composer asks; ours could not, so publishing an old + * back-ported tag silently moved the Latest badge onto it. Undefined leaves + * GitHub's default (it picks by date), which is what a caller that never + * asked the question should get. + */ + makeLatest?: boolean; +} + +/** What GitHub's generate-notes endpoint answers with. */ +export interface GeneratedNotes { + name: string; + body: string; } // ── Notifications ── @@ -487,6 +730,15 @@ export interface NotificationThread { updatedAt: string; unread: boolean; htmlUrl: string; + /** When you last read this thread (null = never). */ + lastReadAt?: string | null; + /** The subject, parsed from subject.url — the key to deep-linking IN-APP + * instead of bouncing to github.com. See `subjectRef()` in github/maps.ts. */ + subjectKind?: "issue" | "pull" | "release" | "commit" | "discussion" | "other"; + /** Issue/PR/release number, when the subject has one. */ + subjectNumber?: number; + /** Commit sha, for Commit subjects. */ + subjectSha?: string; } export interface NotificationActionResult { ok: boolean; @@ -527,6 +779,62 @@ export interface OrgMember { avatarUrl: string | null; htmlUrl: string; } +/** The full repo record behind an org-repo peek (GET /repos/{owner}/{repo}). */ +export interface OrgRepoDetail { + fullName: string; + description: string | null; + htmlUrl: string; + cloneUrl: string; + sshUrl: string; + defaultBranch: string; + openIssuesCount: number; + forksCount: number; + stargazersCount: number; + topics: string[]; + license: string | null; + language: string | null; + private: boolean; + archived: boolean; + fork: boolean; + pushedAt: string; + createdAt: string; + homepage: string | null; +} +/** One entry when browsing a REMOTE repo in-app (no clone needed). */ +export interface GhRepoEntry { + name: string; + path: string; + type: "dir" | "file"; + size?: number; +} +/** A remote repo file's text (or why it can't be shown inline). */ +export interface GhRepoFile { + path: string; + text: string; + /** Too large for an inline look (the contents API caps at 1MB anyway). */ + truncated: boolean; + binary: boolean; + size: number; +} +/** A user profile for the member peek (GET /users/{login}). */ +export interface GhUserInfo { + login: string; + name: string | null; + avatarUrl: string | null; + bio: string | null; + company: string | null; + location: string | null; + blog: string | null; + htmlUrl: string; + followers: number; + following: number; + publicRepos: number; + createdAt: string; + /** "User" or "Organization" — an account page renders differently for each. */ + type: string; + twitter: string | null; + email: string | null; +} // ── Projects v2 (board) ── export interface ProjectStatusOption { @@ -594,6 +902,123 @@ export interface GistUpdate { export type MergeMethod = "merge" | "squash" | "rebase"; +/** One branch of a remote repo (the Explore ref switcher). */ +export interface GhRepoBranch { + name: string; + sha: string; + protected: boolean; +} + +/** Every blob path in a remote repo — the go-to-file index. */ +export interface GhRepoPaths { + paths: string[]; + /** GitHub truncated the tree, or we capped it. Say so; never pretend. */ + truncated: boolean; + /** How many blobs the tree actually had (before our cap). */ + total: number; +} + +// ── Global GitHub search (Explore) ── + +/** One repository in a search result. */ +export interface SearchRepoItem { + id: number; + fullName: string; + owner: string; + ownerAvatarUrl: string | null; + description: string | null; + language: string | null; + stars: number; + forks: number; + openIssues: number; + updatedAt: string; + pushedAt: string; + private: boolean; + fork: boolean; + archived: boolean; + topics: string[]; + license: string | null; + htmlUrl: string; + defaultBranch: string; +} + +/** One person or organization in a search result. */ +export interface SearchUserItem { + login: string; + avatarUrl: string | null; + htmlUrl: string; + /** "User" or "Organization". */ + type: string; +} + +/** One code hit. GitHub's code search returns the FILE, plus optional + * text-match fragments when the text-match media type is requested. */ +/** + * One matching fragment of a code-search hit, plus WHERE in it the query + * matched. + * + * The offsets were being thrown away, so a code search — whose whole job is + * "find me this string" — rendered three lines of code with nothing marking + * the string. GitHub sends them; we simply did not carry them. + */ +export interface SearchCodeFragment { + text: string; + /** [start, end) character offsets into `text`, from GitHub's text_matches. */ + ranges: Array<[number, number]>; +} + +export interface SearchCodeItem { + name: string; + path: string; + repoFullName: string; + htmlUrl: string; + /** Matching line fragments, when GitHub returned them. */ + fragments: SearchCodeFragment[]; +} + +/** One page of results, plus the honesty the UI needs to render it. */ +export interface SearchPage<T> { + items: T[]; + /** What GitHub says matched — can exceed what's reachable (1000 cap). */ + totalCount: number; + /** GitHub gave up early and the results are partial. */ + incomplete: boolean; + /** True when another page exists AND is within the 1000-result ceiling. */ + hasMore: boolean; + /** Set instead of items when the local rate budget is spent. */ + limited?: { retryInMs: number }; +} + +export type SearchSort = "best" | "stars" | "updated"; + +/** One repository copy on this machine (Settings → Repositories manager). */ +export interface LocalCopy { + /** Absolute repo root. */ + root: string; + /** Folder name — the display label. */ + name: string; + /** "owner/repo" from the origin remote, when it's a GitHub remote. */ + origin?: string; + /** Sits inside the configured clone folder (so GitStudio may delete it). */ + managed: boolean; + /** Present in the recent-repositories list. */ + recent: boolean; + /** The repo currently open in the app. */ + current: boolean; + /** The folder is gone (a recent someone deleted outside GitStudio). */ + missing: boolean; +} + +/** The app-wide preferences (Settings → Repositories card). */ +export interface AppSettingsView { + /** Effective absolute default clone parent. */ + cloneDir: string; + /** "~/GitStudio"-style rendering for UI copy. */ + cloneDirDisplay: string; + cloneDirIsDefault: boolean; + askWhereEveryTime: boolean; +} + /** Connection state for the GitHub-backed views. */ export interface GitHubStatus { connected: boolean; @@ -738,15 +1163,29 @@ export interface CloneResult { /** Absolute path of the cloned repo on success. */ root?: string; message?: string; + /** Machine-readable failure mode (the dialog focuses the right field). */ + code?: "dest-exists" | "bad-name"; } /** A commit in a PR's Commits tab. */ export interface PrCommitInfo { sha: string; shortSha: string; + /** The subject — the message's first line. */ message: string; + /** The rest of the message, "" when there is none. */ + body?: string; + /** The author's display name as git recorded it. */ author: string; + /** The GitHub account, when the commit matched one — for the avatar. */ + login?: string; + avatarUrl?: string; + /** ISO-8601. */ date: string; + /** GitHub verified the signature. */ + verified?: boolean; + /** More than one parent. */ + isMerge?: boolean; } /** A timeline entry in a PR's Conversation tab (a comment or a review). */ @@ -771,13 +1210,31 @@ export interface CheckRun { /** A GitHub Actions workflow run for the Actions tab. */ export interface WorkflowRun { id: number; + /** The user-facing "#42" (NOT the internal id). */ + runNumber: number; + runAttempt: number; + /** The workflow's name ("Desktop CI"). */ name: string; + /** The run's own title (commit subject / PR title). */ + displayTitle: string; status: string; conclusion: string; branch: string; + headSha: string; event: string; createdAt: string; + updatedAt: string; + /** When execution actually began (createdAt→this = queue time). */ + runStartedAt: string; htmlUrl: string; + /** WHO: the run's actor, and the re-runner when different. */ + actor: GitHubUser | null; + triggeringActor: GitHubUser | null; + workflowId: number; + workflowPath: string; + headCommitMessage: string; + headCommitAuthor: string; + pullRequests: { number: number }[]; } // ── Interactive rebase (the Rebase view) ──────────────────────────────────── @@ -790,11 +1247,32 @@ export interface RebaseCommitInfo { subject: string; /** Humanized author time, e.g. "3h ago". */ rel: string; + /** + * The commit's FULL message — subject, blank line, body, trailers. + * + * A reword seeded its textarea from the subject alone, so choosing Reword and + * changing nothing still committed the subject and deleted the explanation, + * the `Fixes #N`, the `Signed-off-by` and every `Co-Authored-By` under it — + * reporting "Rebase complete." + */ + body?: string; + /** + * Local branches whose tip IS this commit (excluding the one being rebased). + * + * A rewrite gives every commit a new sha, so a branch left pointing at an old + * one is not "untouched" — it is stranded on a parallel line nothing + * references any more. The plan builder can carry them along with + * `update-ref`; it needs to be told which branches those are. + */ + branches?: string[]; } /** Everything the Rebase view needs to render a plan. */ export interface RebasePlanState { ok: boolean; + /** The repo's own `rebase.updateRefs`, so the "carry other branches" toggle + * starts where the user's git already stands rather than at our guess. */ + updateRefs?: boolean; /** Why the plan couldn't be loaded (ok === false). */ message?: string; /** The base the rebase runs onto, exclusive (or "--root"). */ @@ -805,6 +1283,26 @@ export interface RebasePlanState { baseCommit?: { shortSha: string; subject: string }; /** True when a rebase is already mid-flight (conflict or `edit` stop). */ inProgress: boolean; + /** + * HEAD's sha when this plan was built, echoed back on apply. + * + * A plan is a promise about a specific branch tip. Commit from a terminal + * while the workspace is open and the rows no longer describe the range: + * `apply` re-walks it, finds the new commit missing from the rows, treats it + * as one of the below-the-cap tail and appends it — and appending is + * newest-last, so the reversal into git's todo made the commit you just wrote + * the FIRST pick, moving it to the bottom of the branch's history. Reported + * as success, with nothing on screen ever mentioning it. + */ + headSha?: string; + /** + * How many commits `apply()` will replay — the whole selection, not the page + * shown. Above the display cap the two differ, and both the cap banner and + * the confirm dialog quoted the page: "Showing the newest 200 … the older + * ones are kept as-is" and "This rewrites 200 commits" on a range of 260, all + * 260 of which are replayed and get new IDs the moment the base has moved. + */ + replayCount?: number; } export type RebaseAction = "pick" | "reword" | "edit" | "squash" | "fixup" | "drop"; @@ -815,11 +1313,21 @@ export interface RebaseApplyRow { subject: string; /** New message for a `reword` row. */ message?: string; + /** Branches tipped at this commit — see RebaseCommitInfo.branches. */ + branches?: string[]; } export interface RebaseApplyRequest { base: string; rows: RebaseApplyRow[]; + /** The `headSha` the plan was built against. When it no longer matches, the + * rows describe a range that has moved and applying them rewrites history + * the user never saw. Omitted only by callers that built the rows this tick. */ + headSha?: string; + /** Carry other local branches through the rewrite. Omitted = follow the + * repo's own `rebase.updateRefs`, so GitStudio does what the user's git + * would do rather than silently doing something else. */ + updateRefs?: boolean; } /** Wire form of the runner's RebaseOutcome. */ @@ -847,6 +1355,14 @@ export interface IpcChannels { "head:get": [void, HeadInfo | undefined]; "status": [void, ChangedFile[]]; "commit:details": [string, CommitDetailsPayload | undefined]; + /** + * Which local branches contain this commit — "did this come from the branch + * I am on, or was it merged in from somewhere else". + * + * A commit page that shows the change but never says where it lives leaves + * the reader unable to answer the first question they have about it. + */ + "commit:branches": [string, CommitBranches]; "commit:rowStats": [string[], RowStat[]]; "diff:files": [void, ChangedFile[]]; "file:diff": [{ path: string; sha?: string }, FileDiff | undefined]; @@ -902,6 +1418,9 @@ export interface IpcChannels { "branch:push": [{ name: string }, CommitActionResult]; // ── Branch management ── "branches:list": [void, BranchInfo[]]; + /** Recent commits reachable from ONE ref (branch / remote / tag / stash sha) — + * feeds the peek cards so any ref is browsable without loading the graph. */ + "ref:log": [{ ref: string; maxCount?: number }, CompareCommit[]]; "branch:create": [{ name: string; checkout?: boolean }, CommitActionResult]; "branch:delete": [{ name: string; force?: boolean }, CommitActionResult]; /** Fast-forward a local branch straight from its upstream WITHOUT checking @@ -909,7 +1428,18 @@ export interface IpcChannels { "branch:pullFf": [{ name: string }, CommitActionResult]; // ── Compare (base…head) ── "compare:refs": [{ base: string; head: string; mode?: CompareMode }, CompareResult | undefined]; - "compare:fileDiff": [{ base: string; head: string; path: string; mode?: CompareMode }, FileDiff | undefined]; + /** + * One file's two sides between two revisions. + * + * `leftPath` exists for RENAMES: the file did not exist under `path` on the + * base side, so asking for it there returns nothing and a 12-line edit + * rendered as a brand-new file with its entire history thrown away. Callers + * that know the old name send it. + */ + "compare:fileDiff": [ + { base: string; head: string; path: string; leftPath?: string; mode?: CompareMode }, + FileDiff | undefined, + ]; // ── Code browser (GitHub-style file tree at HEAD) ── "repo:tree": [{ path: string }, TreeEntry[]]; "repo:file": [{ path: string }, RepoFile | undefined]; @@ -924,8 +1454,50 @@ export interface IpcChannels { // Settings: git identity + local SSH keys. "git:identity": [void, GitIdentity]; "git:setIdentity": [GitIdentity, CommitActionResult]; + /** Write text to the system clipboard via the MAIN process. The renderer's + * navigator.clipboard needs focus + a user gesture; this path never does — + * it's the fallback that makes auto-copies (device-flow code) reliable. */ + "clipboard:write": [string, void]; + // ── App settings (Settings → Repositories) ── + "settings:get": [void, AppSettingsView]; + /** Patch settings; `cloneDir: null` resets to the built-in default. */ + "settings:update": [{ cloneDir?: string | null; askWhereEveryTime?: boolean }, AppSettingsView]; + /** Native picker for the default clone folder; persists on choice. */ + "settings:pickCloneDir": [void, AppSettingsView | undefined]; + // ── Accounts (Explore profile pages) ── + "users:repos": [string, OrgRepo[]]; + "users:orgs": [string, OrgInfo[]]; + // ── Remote repository browsing (Explore entity pages) ── + /** Branches of any repo — the Explore ref switcher. */ + "ghrepo:branches": [string, GhRepoBranch[]]; + /** Every blob path at a ref, for go-to-file. */ + "ghrepo:paths": [{ fullName: string; ref?: string }, GhRepoPaths]; + // ── Global GitHub search (Explore) ── + "search:repos": [{ query: string; sort?: SearchSort; page?: number }, SearchPage<SearchRepoItem>]; + "search:users": [{ query: string; kind: "users" | "orgs"; page?: number }, SearchPage<SearchUserItem>]; + "search:code": [{ query: string; page?: number }, SearchPage<SearchCodeItem>]; + // ── Local repository copies (Settings → Repositories manager) ── + /** Every clone GitStudio knows about: the clone folder ∪ recents. */ + "repos:local": [void, LocalCopy[]]; + /** Reveal a root in Finder/Explorer. */ + "repos:reveal": [string, boolean]; + /** Forget a root from the recent list (never touches disk). */ + "repos:removeRecent": [string, LocalCopy[]]; + /** Move a managed clone to the trash. Refuses anything outside the clone + * folder, and the repo that's currently open. */ + "repos:trash": [string, CommitActionResult]; + // ── App info + updates ── + "app:info": [void, { version: string; platform: string }]; + /** Poll the release feed now (the Settings "Check for updates" button). */ + "update:check": [void, UpdateCheckResult]; + /** Start the user-confirmed download; completion arrives as update:ready. */ + "update:download": [void, { ok: boolean; message?: string }]; + /** Apply a ready update: restart into it, or open the macOS installer. */ + "update:install": [void, { ok: boolean; message?: string }]; "ssh:keys": [void, SshKey[]]; - "pr:list": [void, PullRequest[]]; + /** `state` mirrors GitHub's open|closed|all. Merged PRs come back under + * `closed` (they carry `mergedAt`), so the renderer narrows those locally. */ + "pr:list": [{ state?: "open" | "closed" | "all" } | void, PullRequest[]]; "pr:detail": [number, PrDetail | undefined]; "pr:checkout": [number, CommitActionResult]; "pr:merge": [{ number: number; method: MergeMethod }, CommitActionResult]; @@ -943,7 +1515,7 @@ export interface IpcChannels { "pr:branches": [void, BranchRef[]]; "pr:reviewers": [void, RepoCollaborator[]]; // Actions control. - "actions:runs": [void, WorkflowRun[]]; + "actions:runs": [ActionsRunsFilter | undefined, WorkflowRun[]]; "actions:runDetail": [number, WorkflowRunDetail | undefined]; "actions:workflows": [void, WorkflowInfo[]]; "actions:dispatchInputs": [number, WorkflowDispatchInput[]]; @@ -954,13 +1526,19 @@ export interface IpcChannels { // Issues CRUD. "issue:list": [{ state?: "open" | "closed" | "all" }, IssueInfo[]]; "issue:detail": [number, IssueDetail | undefined]; - "issue:create": [{ title: string; body?: string }, { ok: boolean; number?: number; message?: string }]; + "issue:create": [ + { title: string; body?: string; labels?: string[]; assignees?: string[]; milestone?: number }, + { ok: boolean; number?: number; message?: string }, + ]; "issue:comment": [{ number: number; body: string }, CommitActionResult]; "issue:setState": [{ number: number; state: "open" | "closed" }, CommitActionResult]; "issue:edit": [{ number: number; title?: string; body?: string }, CommitActionResult]; "issue:labels": [void, RepoLabel[]]; "issue:setLabels": [{ number: number; labels: string[] }, CommitActionResult]; "issue:setAssignees": [{ number: number; assignees: string[] }, CommitActionResult]; + /** Everything in the current repo that involves the signed-in user (search + * API, @me qualifiers): review requests, assignments, own PRs, mentions. */ + "github:myWork": [void, MyWorkItem[]]; // Read-only fetch of an issue/PR from ANY repo (used to open notifications for // OTHER repositories in-app instead of bouncing to github.com). "github:externalItem": [ @@ -976,9 +1554,26 @@ export interface IpcChannels { "release:list": [void, ReleaseInfo[]]; "release:detail": [number, ReleaseInfo | undefined]; "release:tags": [void, TagInfo[]]; - "release:create": [ReleaseInput, CommitActionResult]; + /** `id` is the created release, so the composer can land ON it rather than + * on a list where you have to go and find what you just published. */ + "release:create": [ReleaseInput, CommitActionResult & { id?: number }]; + /** + * GitHub's own release notes, written from the merged pull requests between + * two tags — the "Generate release notes" button on its composer. + * + * Without it the app asks someone to hand-write what GitHub will produce in + * a second, which is most of why the composer felt like a worse place to + * write a release than the website. + */ + "release:generateNotes": [ + { tagName: string; targetCommitish?: string; previousTagName?: string }, + GeneratedNotes, + ]; "release:update": [ReleaseInput, CommitActionResult]; "release:delete": [number, CommitActionResult]; + /** Pick local files (native dialog in MAIN) and upload them as assets. */ + "release:uploadAssets": [{ id: number }, CommitActionResult]; + "release:deleteAsset": [number, CommitActionResult]; // Notifications. "notifications:list": [{ all?: boolean; participating?: boolean }, NotificationThread[]]; /** Unread count for the top-bar badge. AMBIENT: never unlocks the stored @@ -992,6 +1587,32 @@ export interface IpcChannels { "orgs:repos": [string, OrgRepo[]]; "orgs:teams": [string, OrgTeam[]]; "orgs:members": [string, OrgMember[]]; + /** Full record for one repo ("owner/repo") — the org-repo peek's body. */ + "orgs:repoDetail": [string, OrgRepoDetail]; + /** A team's members — the team peek's drill-in list. */ + "orgs:teamMembers": [{ org: string; slug: string }, OrgMember[]]; + /** A user's public profile — the member peek's body. */ + "github:userInfo": [string, GhUserInfo]; + // ── Remote repo browsing (look inside ANY GitHub repo without cloning) ── + /** `ref` is optional everywhere: omitted means the default branch, which is + * what every existing caller already meant. */ + "ghrepo:tree": [{ fullName: string; path: string; ref?: string }, GhRepoEntry[]]; + "ghrepo:file": [{ fullName: string; path: string; ref?: string }, GhRepoFile]; + "ghrepo:readme": [{ fullName: string; ref?: string } | string, { name: string; text: string } | undefined]; + /** Open "owner/repo" as a NORMAL repo: reuse any existing local clone, else + * clone into `dest` (or the configured default folder), then open. Success + * flips the whole app to that repo via repo:changed. `code` makes failure + * modes machine-readable — no more matching on message text. */ + "ghrepo:open": [ + { fullName: string; dest?: string; name?: string }, + { + ok: boolean; + root?: string; + cloned?: boolean; + message?: string; + code?: "collision" | "clone-failed" | "open-failed" | "bad-name"; + }, + ]; // Gists. "gist:list": [void, GistInfo[]]; "gist:detail": [string, GistInfo | undefined]; @@ -1004,7 +1625,7 @@ export interface IpcChannels { "terminal:resize": [{ id: string; cols: number; rows: number }, void]; "terminal:kill": [{ id: string }, void]; // Clone / browse repos. Clone progress streams via the clone:progress event. - "clone:pickDir": [void, string | undefined]; + "clone:pickDir": [{ defaultPath?: string } | void, string | undefined]; "clone:start": [CloneRequest, CloneResult]; "github:repos": [{ search?: string } | void, GhRepoBrief[]]; // ── AI / Agent / MCP (optional, off until a model connection is configured) ── @@ -1066,8 +1687,23 @@ export interface IpcChannels { "rebase:abort": [void, CommitActionResult]; "rebase:continue": [void, CommitActionResult]; "rebase:skip": [void, CommitActionResult]; + "cherryPick:abort": [void, CommitActionResult]; + "cherryPick:continue": [void, CommitActionResult]; + "revert:abort": [void, CommitActionResult]; + "revert:continue": [void, CommitActionResult]; + "cherryPick:skip": [void, CommitActionResult]; + "revert:skip": [void, CommitActionResult]; + "am:abort": [void, CommitActionResult]; + "am:skip": [void, CommitActionResult]; + "am:continue": [void, CommitActionResult]; // ── Tag creation (the Branches view's "Create tag here…") ── "tag:create": [{ name: string; ref?: string; message?: string }, CommitActionResult]; + /** `git tag -d` — LOCAL only. A tag already pushed survives on the remote, + * and the UI has to say so rather than implying the tag is gone. */ + "tag:delete": [string, CommitActionResult]; + /** Publish ONE tag. Pushing every tag at once is a different, much larger + * action and must be asked for on its own. */ + "tag:push": [{ name: string; remote?: string }, CommitActionResult]; // ── PR review depth: per-file diffs + inline threads + metadata ── "pr:fileDiff": [{ number: number; path: string }, FileDiff | undefined]; "pr:reviewThreads": [number, PrReviewThread[]]; @@ -1095,7 +1731,10 @@ export interface IpcChannels { "label:delete": [string, CommitActionResult]; // ── Actions depth: logs + artifacts + secrets/variables ── "actions:jobLog": [{ jobId: number }, string]; - "actions:runLog": [{ runId: number }, string]; + /** Incremental tail: refetch + slice from `offset` (see LogDelta). */ + "actions:jobLogChunk": [{ jobId: number; offset: number }, LogDelta]; + /** Save one job's full log to ~/Downloads. */ + "actions:saveLog": [{ jobId: number; name: string }, CommitActionResult]; "actions:artifacts": [number, ArtifactInfo[]]; "actions:downloadArtifact": [{ id: number; name: string }, CommitActionResult]; "actions:secrets": [void, RepoSecretInfo[]]; @@ -1118,6 +1757,9 @@ export type IpcResponse<C extends IpcChannel> = IpcChannels[C][1]; export interface IpcEvents { /** The active repo changed (opened/closed) — the renderer reloads. */ "repo:changed": RepoInfo | undefined; + /** The recent-repositories list changed (forgotten or trashed elsewhere in + * the app) — the repo switcher and the manager both re-render off this. */ + "repo:recentChanged": RepoInfo[]; /** * Something changed on disk in the open repo — a file edited outside the app, * or a git command run in another terminal (issue #17). Already debounced in @@ -1133,7 +1775,16 @@ export interface IpcEvents { /** A message from the main process to show in-app (never a native alert). */ "app:notice": { kind: "info" | "warn" | "error"; message: string }; /** A menu item asks the renderer to do something it owns. */ - "menu:command": { command: "openRepo" | "refresh" | "closeRepo" | "toggleTerminal" | "cloneRepo" }; + "menu:command": { + command: + | "openRepo" + | "refresh" + | "closeRepo" + | "toggleTerminal" + | "cloneRepo" + | "toggleSidebar" + | "palette"; + }; /** A chunk of PTY output for a terminal session. */ "terminal:data": TerminalData; /** A PTY session ended. */ @@ -1148,6 +1799,39 @@ export interface IpcEvents { "ai:agentEvent": AgentEventWire; /** The agent wants the user to approve a write/destructive action before it runs. */ "ai:confirmRequest": AgentConfirmRequest; + /** A newer app version exists — the renderer asks the user before anything + * downloads (background polls announce a version at most once per session). */ + "update:available": UpdateAvailable; + /** Download progress for a user-confirmed update, in whole percent. */ + "update:progress": { percent: number }; + /** The confirmed update is downloaded and ready to apply. */ + "update:ready": UpdateReady; +} + +// ── App updates (poll → confirm → pull → apply) ─────────────────────────────── + +export interface UpdateAvailable { + /** The newer version waiting on the release feed. */ + version: string; + /** The version currently running. */ + current: string; +} +export interface UpdateReady { + version: string; + /** How update:install applies it: "restart" relaunches into the new version + * (electron-updater); "installer" opens the downloaded macOS DMG. */ + kind: "restart" | "installer"; + /** For "installer": where the download landed (~/Downloads). */ + path?: string; +} +export interface UpdateCheckResult { + status: "uptodate" | "available" | "downloading" | "ready" | "disabled" | "error"; + /** The version currently running. */ + current: string; + /** For available/downloading/ready: the newer version in question. */ + version?: string; + /** For error/disabled: why. */ + message?: string; } export type IpcEvent = keyof IpcEvents; @@ -1169,8 +1853,59 @@ export interface GitOpState { rebasing: boolean; cherryPicking: boolean; reverting: boolean; + /** + * A `git am` is stopped mid-series. + * + * It shares `rebase-apply/` with a rebase on the apply backend, so telling + * the two apart is necessary — but telling them apart is not enough. Reported + * as a rebase, its banner offered two buttons git refuses; reported as + * NOTHING, the app showed an ordinary dirty tree with a live Commit button, + * and committing strands the rest of the series and replaces the patch + * author with you. An operation the app can see has to be an operation the + * app names. + */ + amApplying: boolean; /** Number of currently-conflicted paths. */ conflicts: number; + /** + * The stopped operation has nothing left to commit. + * + * Cherry-picking or reverting something already on the branch stops with + * CHERRY_PICK_HEAD set and ZERO unmerged files — and so does a conflict the + * user resolved by keeping HEAD's side. Both look "resolved" to a conflict + * count, so the banner said "resolve and continue" over an empty file list + * and left Continue enabled; git then refused with "The previous cherry-pick + * is now empty" and the app raised it as an error toast, and a crash report. + * Skip (or Abort) is the way out, and neither was on screen. + */ + nothingToCommit: boolean; + /** + * ONE name for what is in progress — decided in the main process, not + * re-derived from the booleans above. + * + * The renderer's own precedence put `merging` first, and + * `rebase --rebase-merges` stopping on a `merge` step leaves MERGE_HEAD *and* + * `rebase-merge/`: the banner called it a merge, and its Abort ran + * `git merge --abort`, discarding a hand resolution and leaving the rebase + * running underneath. + */ + kind: "merge" | "rebase" | "cherry-pick" | "revert" | "am" | null; + /** Whether `<kind>:continue` can succeed right now. */ + canContinue: boolean; + /** Whether `<kind>:skip` is offered — and safe. There is no `merge --skip`, + * and `rebase --skip` HARD-RESETS, so it is offered only where git itself + * names it as the way out. */ + canSkip: boolean; +} + +/** Where a commit sits in the branch graph — see `commit:branches`. */ +export interface CommitBranches { + /** Local branches containing it, HEAD's own first when present. */ + branches: string[]; + /** True when the branch HEAD is on contains it. */ + onCurrent: boolean; + /** The branch HEAD is on, so the page can phrase it ("also on main"). */ + current?: string; } // ── GitHub depth wire types (PR threads, milestones, actions) ─────────────────── diff --git a/apps/desktop/test/actionsMaps.test.ts b/apps/desktop/test/actionsMaps.test.ts new file mode 100644 index 0000000..0ae0359 --- /dev/null +++ b/apps/desktop/test/actionsMaps.test.ts @@ -0,0 +1,109 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mapRun, mapJob, mapUser } from "../src/main/github/maps"; + +test("mapRun keeps the full run identity (number, attempt, actors, commit)", () => { + const run = mapRun({ + id: 18234567890, + run_number: 42, + run_attempt: 2, + name: "Desktop CI", + display_title: "fix: stream job logs with backpressure", + status: "completed", + conclusion: "success", + head_branch: "fix/log-stream", + head_sha: "a1b2c3d4e5f6a7b8", + event: "pull_request", + created_at: "2026-08-25T10:00:00Z", + updated_at: "2026-08-25T10:08:30Z", + run_started_at: "2026-08-25T10:00:40Z", + html_url: "https://github.com/o/r/actions/runs/18234567890", + actor: { login: "s-ohta", avatar_url: "https://a/1" }, + triggering_actor: { login: "anton", avatar_url: "https://a/2" }, + workflow_id: 77, + path: ".github/workflows/desktop.yml", + head_commit: { message: "fix: stream job logs\n\nlong body", author: { name: "S. Ohta" } }, + pull_requests: [{ number: 104 }], + }); + assert.equal(run.id, 18234567890); + assert.equal(run.runNumber, 42); + assert.equal(run.runAttempt, 2); + assert.equal(run.name, "Desktop CI"); + assert.equal(run.displayTitle, "fix: stream job logs with backpressure"); + assert.equal(run.headSha, "a1b2c3d4e5f6a7b8"); + assert.equal(run.runStartedAt, "2026-08-25T10:00:40Z"); + assert.equal(run.actor?.login, "s-ohta"); + assert.equal(run.triggeringActor?.login, "anton"); + assert.equal(run.workflowId, 77); + assert.equal(run.workflowPath, ".github/workflows/desktop.yml"); + assert.equal(run.headCommitMessage.split("\n")[0], "fix: stream job logs"); + assert.equal(run.headCommitAuthor, "S. Ohta"); + assert.deepEqual(run.pullRequests, [{ number: 104 }]); +}); + +test("mapRun degrades sparse payloads to concrete defaults", () => { + const run = mapRun({ id: 1 }); + assert.equal(run.runNumber, 0); + assert.equal(run.runAttempt, 1); + assert.equal(run.name, "(run)"); + assert.equal(run.displayTitle, "(run)"); + assert.equal(run.actor, null); + assert.equal(run.triggeringActor, null); + assert.equal(run.headCommitMessage, ""); + assert.deepEqual(run.pullRequests, []); +}); + +test("mapRun display title falls back across name/display_title symmetrically", () => { + assert.equal(mapRun({ id: 1, name: "CI" }).displayTitle, "CI"); + assert.equal(mapRun({ id: 1, display_title: "the change" }).name, "the change"); +}); + +test("mapJob keeps runner + timing depth (queue latency, step timestamps)", () => { + const job = mapJob({ + id: 9, + run_id: 18234567890, + run_attempt: 2, + name: "build (macos-latest)", + status: "completed", + conclusion: "success", + created_at: "2026-08-25T10:00:00Z", + started_at: "2026-08-25T10:00:42Z", + completed_at: "2026-08-25T10:05:00Z", + runner_name: "GitHub Actions 12", + runner_group_name: "Default", + labels: ["macos-latest"], + workflow_name: "Desktop CI", + head_branch: "main", + steps: [ + { + name: "Checkout", + status: "completed", + conclusion: "success", + number: 1, + started_at: "2026-08-25T10:00:42Z", + completed_at: "2026-08-25T10:00:50Z", + }, + ], + }); + assert.equal(job.runId, 18234567890); + assert.equal(job.runAttempt, 2); + assert.equal(job.createdAt, "2026-08-25T10:00:00Z"); + assert.equal(job.runnerName, "GitHub Actions 12"); + assert.deepEqual(job.labels, ["macos-latest"]); + assert.equal(job.workflowName, "Desktop CI"); + assert.equal(job.steps[0].startedAt, "2026-08-25T10:00:42Z"); + assert.equal(job.steps[0].completedAt, "2026-08-25T10:00:50Z"); +}); + +test("mapJob null runner fields (queued jobs) become empty strings", () => { + const job = mapJob({ id: 1, runner_name: null, runner_group_name: null, steps: [{ started_at: null, completed_at: null }] }); + assert.equal(job.runnerName, ""); + assert.equal(job.runnerGroupName, ""); + assert.equal(job.steps[0].startedAt, ""); +}); + +test("mapUser maps to null for absent users", () => { + assert.equal(mapUser(null), null); + assert.equal(mapUser(undefined), null); + assert.deepEqual(mapUser({ login: "x" }), { login: "x", avatarUrl: null }); +}); diff --git a/apps/desktop/test/agentApprovals.test.ts b/apps/desktop/test/agentApprovals.test.ts new file mode 100644 index 0000000..f806a3a --- /dev/null +++ b/apps/desktop/test/agentApprovals.test.ts @@ -0,0 +1,67 @@ +// What a human reads before approving an agent's write. +// +// This is the last gate before an automated actor does something to the +// repository, and for the three destructive tools it used to read WEAKER than +// the app's own confirm dialogs for the very same operations: "Reset (hard) to +// abc123." asked you to approve, in git's vocabulary, the destruction of every +// uncommitted change you had — while discarding one file by hand spelled out +// that it could not be undone. +// +// These assertions are about CONSEQUENCE, not wording: each destructive case +// must say what is lost and that it cannot be recovered. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { summarizeArgs } from "../src/main/aiBridge"; + +/** Only the fields `summarizeArgs` reads. */ +const tool = (name: string, title = name): Parameters<typeof summarizeArgs>[0] => + ({ name, title, mode: "destructive" }) as unknown as Parameters<typeof summarizeArgs>[0]; + +test("a hard reset says the working tree is destroyed", () => { + const s = summarizeArgs(tool("git_reset"), { mode: "hard", ref: "HEAD~3" }); + assert.match(s, /HEAD~3/, "it names the target"); + assert.match(s, /uncommitted/i, "and what is at stake"); + assert.match(s, /destroyed|lost/i, "in plain words"); + assert.match(s, /can'?t be undone|cannot be undone/i, "and that it is final"); +}); + +test("the softer resets do NOT claim work is destroyed", () => { + // Over-warning is its own failure: a dialog that cries wolf on a soft reset + // is one people learn to click through on a hard one. + for (const mode of ["soft", "mixed"]) { + const s = summarizeArgs(tool("git_reset"), { mode, ref: "HEAD~1" }); + assert.ok(!/destroyed/i.test(s), `${mode} does not say destroyed`); + assert.match(s, /undone/i, `${mode} says what actually happens`); + } +}); + +test("a discard says the files may be deleted outright", () => { + const s = summarizeArgs(tool("git_discard"), { paths: ["a.ts", "b.ts"] }); + assert.match(s, /a\.ts/, "it names the files"); + assert.match(s, /can'?t be undone/i, "says it is final"); + assert.match(s, /deleted|delete/i, "and warns that untracked files go from disk"); +}); + +test("a forced branch delete says what a force costs", () => { + const plain = summarizeArgs(tool("git_delete_branch"), { name: "feature/x" }); + const forced = summarizeArgs(tool("git_delete_branch"), { name: "feature/x", force: true }); + assert.match(plain, /feature\/x/); + assert.ok(!/merged/i.test(plain), "an ordinary delete does not warn about unmerged work"); + assert.match(forced, /not merged|unmerged/i, "a forced one does"); +}); + +test("the non-destructive tools stay short", () => { + // These are approved many times in a session; padding them with consequences + // they do not have is how the destructive ones stop being read. + const s = summarizeArgs(tool("git_stage"), { paths: ["a.ts"] }); + assert.match(s, /a\.ts/); + assert.ok(s.length < 80, `stage stays a single line (${s.length} chars)`); + assert.ok(!/undone|destroy|lost/i.test(s), "and makes no dire claims"); +}); + +test("an unknown tool still says something a human can read", () => { + const s = summarizeArgs(tool("git_future_thing", "Do a future thing"), { a: 1 }); + assert.match(s, /Do a future thing/, "it leads with the tool's title"); + assert.ok(s.length > 0); +}); diff --git a/apps/desktop/test/appSettings.test.ts b/apps/desktop/test/appSettings.test.ts new file mode 100644 index 0000000..3af3c99 --- /dev/null +++ b/apps/desktop/test/appSettings.test.ts @@ -0,0 +1,84 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { AppSettings } from "../src/main/appSettings"; + +// The settings store behind Settings → Repositories: defaults, persistence, +// the null-reset contract, and the "~"-shortened display path. + +const HOME = "/Users/someone"; +const DEF = join(HOME, "GitStudio"); + +function dir(): string { + return mkdtempSync(join(tmpdir(), "gitstudio-settings-")); +} + +test("fresh store serves the defaults", async () => { + const s = await AppSettings.load(dir(), { defaultCloneDir: DEF, home: HOME }); + assert.equal(s.effectiveCloneDir(), DEF); + assert.equal(s.askWhereEveryTime(), false); + const v = s.view(); + assert.equal(v.cloneDir, DEF); + assert.equal(v.cloneDirDisplay, "~/GitStudio"); + assert.equal(v.cloneDirIsDefault, true); +}); + +test("update persists and reloads", async () => { + const d = dir(); + const s = await AppSettings.load(d, { defaultCloneDir: DEF, home: HOME }); + await s.update({ cloneDir: "/Volumes/Work/src", askWhereEveryTime: true }); + const s2 = await AppSettings.load(d, { defaultCloneDir: DEF, home: HOME }); + assert.equal(s2.effectiveCloneDir(), "/Volumes/Work/src"); + assert.equal(s2.askWhereEveryTime(), true); + // Outside home → shown verbatim, flagged non-default. + const v = s2.view(); + assert.equal(v.cloneDirDisplay, "/Volumes/Work/src"); + assert.equal(v.cloneDirIsDefault, false); +}); + +test("cloneDir: null resets to the default", async () => { + const d = dir(); + const s = await AppSettings.load(d, { defaultCloneDir: DEF, home: HOME }); + await s.update({ cloneDir: "/elsewhere" }); + const v = await s.update({ cloneDir: null }); + assert.equal(v.cloneDir, DEF); + assert.equal(v.cloneDirIsDefault, true); + // And the reset survives a reload. + const s2 = await AppSettings.load(d, { defaultCloneDir: DEF, home: HOME }); + assert.equal(s2.effectiveCloneDir(), DEF); +}); + +test("a home-relative custom dir displays with ~", async () => { + const s = await AppSettings.load(dir(), { defaultCloneDir: DEF, home: HOME }); + const v = await s.update({ cloneDir: join(HOME, "Code") }); + assert.equal(v.cloneDirDisplay, "~/Code"); +}); + +test("malformed JSON on disk starts fresh instead of crashing", async () => { + const d = dir(); + writeFileSync(join(d, "app-settings.json"), "{not json", "utf8"); + const s = await AppSettings.load(d, { defaultCloneDir: DEF, home: HOME }); + assert.equal(s.effectiveCloneDir(), DEF); +}); + +test("junk-typed fields in the file are ignored", async () => { + const d = dir(); + writeFileSync( + join(d, "app-settings.json"), + JSON.stringify({ cloneDir: 42, askWhereEveryTime: "yes" }), + "utf8", + ); + const s = await AppSettings.load(d, { defaultCloneDir: DEF, home: HOME }); + assert.equal(s.effectiveCloneDir(), DEF); + assert.equal(s.askWhereEveryTime(), false); +}); + +test("the file on disk is the two persisted fields, nothing else", async () => { + const d = dir(); + const s = await AppSettings.load(d, { defaultCloneDir: DEF, home: HOME }); + await s.update({ cloneDir: "/x", askWhereEveryTime: true }); + const raw = JSON.parse(readFileSync(join(d, "app-settings.json"), "utf8")); + assert.deepEqual(raw, { cloneDir: "/x", askWhereEveryTime: true }); +}); diff --git a/apps/desktop/test/autoUpdate.test.ts b/apps/desktop/test/autoUpdate.test.ts new file mode 100644 index 0000000..a0911da --- /dev/null +++ b/apps/desktop/test/autoUpdate.test.ts @@ -0,0 +1,57 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + compareVersions, + latestDesktopRelease, + latestDesktopVersion, + pickMacAsset, +} from "../src/main/autoUpdate"; + +// Pure release-feed logic behind the poll→confirm→pull updater. The impure +// parts (electron-updater, fetch, the state machine) stay thin around these. + +test("compareVersions orders dotted versions numerically", () => { + assert.ok(compareVersions("1.6.0", "1.5.9") > 0); + assert.ok(compareVersions("1.5.1", "1.5.10") < 0); + assert.equal(compareVersions("2.0", "2.0.0"), 0); + assert.ok(compareVersions("10.0.0", "9.9.9") > 0); +}); + +test("latestDesktopRelease picks the newest app-v* and ignores ext/draft/prerelease", () => { + const releases = [ + { tag_name: "ext-v1.11.1" }, // the extension's tags are not ours + { tag_name: "app-v1.4.0" }, + { tag_name: "app-v1.6.0", draft: true }, // unpublished + { tag_name: "app-v1.5.2-beta", prerelease: true }, + { tag_name: "app-v1.5.1", assets: [{ name: "GitStudio-1.5.1-arm64.dmg" }] }, + ]; + const hit = latestDesktopRelease(releases); + assert.equal(hit?.version, "1.5.1"); + assert.equal(hit?.release.assets?.[0]?.name, "GitStudio-1.5.1-arm64.dmg"); + assert.equal(latestDesktopVersion(releases), "1.5.1"); +}); + +test("latestDesktopRelease is undefined with no desktop tags or a bad payload", () => { + assert.equal(latestDesktopRelease([{ tag_name: "ext-v1.0.0" }]), undefined); + assert.equal(latestDesktopRelease([]), undefined); + assert.equal(latestDesktopRelease(undefined as unknown as []), undefined); +}); + +test("pickMacAsset prefers this arch's dmg, falls back to its zip", () => { + const assets = [ + { name: "GitStudio-1.6.0-x64.dmg", browser_download_url: "https://dl/x64.dmg", size: 9 }, + { name: "GitStudio-1.6.0-arm64.zip", browser_download_url: "https://dl/arm64.zip", size: 7 }, + { name: "GitStudio-1.6.0-arm64.dmg", browser_download_url: "https://dl/arm64.dmg", size: 8 }, + { name: "GitStudio-Setup-1.6.0.exe", browser_download_url: "https://dl/win.exe", size: 6 }, + ]; + assert.deepEqual(pickMacAsset(assets, "arm64"), { + name: "GitStudio-1.6.0-arm64.dmg", + url: "https://dl/arm64.dmg", + size: 8, + }); + // No arm64 dmg → its zip (never another arch's installer). + const noDmg = assets.filter((a) => a.name !== "GitStudio-1.6.0-arm64.dmg"); + assert.equal(pickMacAsset(noDmg, "arm64")?.name, "GitStudio-1.6.0-arm64.zip"); + assert.equal(pickMacAsset(assets, "x64")?.name, "GitStudio-1.6.0-x64.dmg"); + assert.equal(pickMacAsset([{ name: "notes.txt" }], "arm64"), undefined); +}); diff --git a/apps/desktop/test/benignErrors.test.ts b/apps/desktop/test/benignErrors.test.ts new file mode 100644 index 0000000..c8d3d03 --- /dev/null +++ b/apps/desktop/test/benignErrors.test.ts @@ -0,0 +1,36 @@ +// What the crash reporter is allowed to swallow. +// +// The diff is computed in Monaco's editor web worker. `isBenignError` used to +// suppress EVERYTHING sourced from that file, which is why a worker that was +// cold, crashed, or answering for a disposed model presented to the user as +// "the two diff views sometimes don't show the diffs" with no error anywhere — +// not a toast, not a report, nothing. The worker's ordinary chatter (inlay +// hints, link detection) is still noise; a failure to compute a diff is a +// failure of a feature and must not be filtered out with it. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { isBenignError } from "../src/renderer/benignErrors"; + +test("the worker's ordinary noise stays suppressed", () => { + assert.equal(isBenignError("Script error."), true); + assert.equal(isBenignError("Missing requestHandler or method: inlayHints"), true); + assert.equal(isBenignError("ResizeObserver loop completed with undelivered notifications."), true); + assert.equal(isBenignError("Canceled"), true); + assert.equal(isBenignError("something odd", "file:///app/editor.worker.js"), true); + assert.equal(isBenignError("something odd", "file:///app/editor.worker.a1b2c3.js"), true); +}); + +test("a failure to compute a DIFF is never benign, whatever produced it", () => { + assert.equal(isBenignError("computeDiff failed"), false); + assert.equal(isBenignError("Error in diff computation"), false); + assert.equal(isBenignError("DiffComputer threw"), false); + // Even when it arrives wearing the worker's return address — which is + // precisely the case the blanket filter was hiding. + assert.equal(isBenignError("computeDiff failed", "file:///app/editor.worker.js"), false); +}); + +test("real application errors are still reported", () => { + assert.equal(isBenignError("Cannot read properties of undefined (reading '0')"), false); + assert.equal(isBenignError("TypeError: x is not a function", "file:///app/renderer.js"), false); +}); diff --git a/apps/desktop/test/binaryConflict.test.ts b/apps/desktop/test/binaryConflict.test.ts new file mode 100644 index 0000000..40489a6 --- /dev/null +++ b/apps/desktop/test/binaryConflict.test.ts @@ -0,0 +1,151 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { writeFileSync, readFileSync, mkdtempSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { tmpdir } from "node:os"; +import { RepoStore } from "../src/main/repoStore"; +import { GitBridge } from "../src/main/gitBridge"; +import { removeTempRepo } from "./tmpRepo"; + +/** + * Resolving a conflicted BINARY file. + * + * "Take ours" / "Take theirs" is the only resolution the app offers for a + * binary conflict — there is no meaningful three-pane merge for a PNG. It read + * the chosen side with `git show :N:path`, took the STDOUT AS A STRING, and + * wrote it back as UTF-8. `GitProcess.run` decodes stdout with + * `Buffer.concat(...).toString("utf8")`, which is lossy for every byte that is + * not valid UTF-8: each becomes U+FFFD, three bytes. So the one available + * resolution destroyed the asset, staged the wreckage, and reported success. + * + * Measured on a real 512×512 PNG before the fix: 36,078 bytes in, 67,288 out, + * header `efbfbd504e470d0a` instead of `89504e470d0a1a0a`, and `file(1)` went + * from "PNG image data" to "data". + * + * git can write the bytes itself. It never decodes them. + */ +const md5 = (b: Buffer): string => createHash("md5").update(b).digest("hex"); + +/** A byte string that is NOT valid UTF-8 — a real PNG header plus high bytes. */ +function binary(seed: number): Buffer { + const head = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const body = Buffer.alloc(512); + for (let i = 0; i < body.length; i++) body[i] = (i * 7 + seed) & 0xff; + return Buffer.concat([head, body]); +} + +function conflictedBinaryRepo(): { root: string; ours: Buffer; theirs: Buffer } { + const root = mkdtempSync(`${tmpdir()}/gs-binconf-`); + const git = (...a: string[]): string => execFileSync("git", a, { cwd: root }).toString(); + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + + writeFileSync(`${root}/logo.png`, binary(1)); + git("add", "-A"); + git("commit", "-qm", "base"); + + git("checkout", "-qb", "incoming"); + const theirs = binary(200); + writeFileSync(`${root}/logo.png`, theirs); + git("commit", "-qam", "theirs"); + + git("checkout", "-q", "-"); + const ours = binary(90); + writeFileSync(`${root}/logo.png`, ours); + git("commit", "-qam", "ours"); + + try { + git("merge", "incoming"); + } catch { + /* expected: this conflicts */ + } + return { root, ours, theirs }; +} + +test("taking THEIRS on a binary conflict writes their exact bytes", async () => { + const { root, theirs } = conflictedBinaryRepo(); + try { + const repos = new RepoStore([]); + await repos.open(root); + const bridge = new GitBridge(repos); + + const r = await bridge.conflictTakeSide({ path: "logo.png", side: "theirs" }); + assert.equal(r.ok, true, `the resolution succeeds (${r.message ?? ""})`); + + const onDisk = readFileSync(`${root}/logo.png`); + assert.equal(md5(onDisk), md5(theirs), "byte-for-byte the incoming version"); + assert.equal(onDisk.length, theirs.length, "and the same size — no UTF-8 expansion"); + assert.deepEqual( + [...onDisk.subarray(0, 8)], + [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], + "the PNG header survives intact", + ); + + // And what was STAGED, which is what a commit would ship. + const staged = execFileSync("git", ["show", ":logo.png"], { + cwd: root, + maxBuffer: 1 << 20, + encoding: "buffer", + }) as unknown as Buffer; + assert.equal(md5(staged), md5(theirs), "the staged blob is their file, not a mangled copy"); + } finally { + removeTempRepo(root); + } +}); + +test("taking OURS on a binary conflict writes our exact bytes", async () => { + const { root, ours } = conflictedBinaryRepo(); + try { + const repos = new RepoStore([]); + await repos.open(root); + const bridge = new GitBridge(repos); + const r = await bridge.conflictTakeSide({ path: "logo.png", side: "ours" }); + assert.equal(r.ok, true, `the resolution succeeds (${r.message ?? ""})`); + assert.equal(md5(readFileSync(`${root}/logo.png`)), md5(ours), "byte-for-byte our version"); + } finally { + removeTempRepo(root); + } +}); + +/** Text conflicts — the common case — must be completely unaffected. */ +test("a text conflict still resolves to the chosen side", async () => { + const root = mkdtempSync(`${tmpdir()}/gs-textconf-`); + try { + const git = (...a: string[]): string => execFileSync("git", a, { cwd: root }).toString(); + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + writeFileSync(`${root}/f.txt`, "base\n"); + git("add", "-A"); + git("commit", "-qm", "base"); + git("checkout", "-qb", "incoming"); + writeFileSync(`${root}/f.txt`, "theirs\n"); + git("commit", "-qam", "theirs"); + git("checkout", "-q", "-"); + writeFileSync(`${root}/f.txt`, "ours\n"); + git("commit", "-qam", "ours"); + try { + git("merge", "incoming"); + } catch { + /* expected */ + } + + const repos = new RepoStore([]); + await repos.open(root); + const bridge = new GitBridge(repos); + const r = await bridge.conflictTakeSide({ path: "f.txt", side: "theirs" }); + assert.equal(r.ok, true, r.message ?? ""); + assert.equal(readFileSync(`${root}/f.txt`, "utf8"), "theirs\n"); + assert.match( + execFileSync("git", ["status", "--porcelain"], { cwd: root }).toString(), + /^M {2}f\.txt/m, + "and it is staged as resolved", + ); + } finally { + removeTempRepo(root); + } +}); diff --git a/apps/desktop/test/branchPush.test.ts b/apps/desktop/test/branchPush.test.ts new file mode 100644 index 0000000..46aeeda --- /dev/null +++ b/apps/desktop/test/branchPush.test.ts @@ -0,0 +1,220 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { writeFileSync, mkdtempSync } from "node:fs"; +import { removeTempRepo } from "./tmpRepo"; +import { tmpdir } from "node:os"; +import { RepoStore } from "../src/main/repoStore"; +import { GitBridge } from "../src/main/gitBridge"; + +/** + * Push, from the Branches view, on a branch whose upstream is named differently. + * + * `git branch -m` KEEPS the tracking config: the renamed branch still tracks + * the old remote name. The bridge pushed `git push <remote> <localName>`, using + * only the remote half of the upstream and throwing the remote-side name away — + * so Push created a SECOND remote branch under the new name and left the tracked + * one untouched. Confirmed against real git before the fix: + * + * * [new branch] feature-local-rename -> feature-local-rename + * + * The commits were pushed somewhere nobody was looking, and the ahead count + * never cleared, because the branch still tracked a ref that had not moved. + */ +function repoWithRemote(): { + work: string; + remote: string; + git: (...a: string[]) => string; + cleanup: () => void; +} { + const root = mkdtempSync(`${tmpdir()}/gs-push-`); + const remote = `${root}/remote.git`; + const work = `${root}/work`; + execFileSync("git", ["init", "-q", "--bare", remote]); + execFileSync("git", ["init", "-q", work]); + const git = (...a: string[]): string => execFileSync("git", a, { cwd: work }).toString(); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); // no background gc racing the cleanup + writeFileSync(`${work}/a.txt`, "a\n"); + git("add", "."); + git("commit", "-qm", "init"); + git("remote", "add", "origin", remote); + return { work, remote, git, cleanup: () => removeTempRepo(root) }; +} + +const heads = (remote: string): string[] => + execFileSync("git", ["ls-remote", "--heads", remote]) + .toString() + .trim() + .split("\n") + .filter(Boolean) + .map((l) => l.split("\t")[1]); + +test("pushing a renamed branch updates the branch it tracks, not a new one", async () => { + const { work, remote, git, cleanup } = repoWithRemote(); + try { + git("checkout", "-qb", "feature"); + writeFileSync(`${work}/b.txt`, "b\n"); + git("add", "."); + git("commit", "-qm", "b"); + git("push", "-q", "-u", "origin", "feature"); + + // The rename keeps `branch.feature-local-rename.merge = refs/heads/feature`. + git("branch", "-m", "feature", "feature-local-rename"); + writeFileSync(`${work}/c.txt`, "c\n"); + git("add", "."); + git("commit", "-qm", "c"); + + // Push it from somewhere else, exactly as the Branches view does — this is + // a branch you are not standing on. + git("checkout", "-q", "master"); + + const repos = new RepoStore([]); + await repos.open(work); + const bridge = new GitBridge(repos); + const r = await bridge.branchPush("feature-local-rename"); + assert.equal(r.ok, true, `push succeeds (${r.message ?? ""})`); + + assert.deepEqual( + heads(remote), + ["refs/heads/feature"], + "no second remote branch is invented under the local name", + ); + const pushed = execFileSync("git", ["log", "-1", "--format=%s", "refs/heads/feature"], { + cwd: remote, + }) + .toString() + .trim(); + assert.equal(pushed, "c", "the tracked branch actually received the new commit"); + } finally { + cleanup(); + } +}); + +/** The ordinary case must keep working: same name both sides, nothing clever. */ +test("pushing an ordinary tracked branch still pushes it", async () => { + const { work, remote, git, cleanup } = repoWithRemote(); + try { + git("checkout", "-qb", "topic"); + writeFileSync(`${work}/t.txt`, "t\n"); + git("add", "."); + git("commit", "-qm", "t1"); + git("push", "-q", "-u", "origin", "topic"); + writeFileSync(`${work}/t2.txt`, "t\n"); + git("add", "."); + git("commit", "-qm", "t2"); + git("checkout", "-q", "master"); + + const repos = new RepoStore([]); + await repos.open(work); + const bridge = new GitBridge(repos); + const r = await bridge.branchPush("topic"); + assert.equal(r.ok, true, `push succeeds (${r.message ?? ""})`); + assert.equal( + execFileSync("git", ["log", "-1", "--format=%s", "refs/heads/topic"], { cwd: remote }) + .toString() + .trim(), + "t2", + ); + } finally { + cleanup(); + } +}); + +/** + * A branch whose name is also a tag's. + * + * `git push <remote> <name>` resolves the bare name against refs/heads AND + * refs/tags, so git refuses: "error: src refspec release matches more than + * one". Confirmed against real git. The HEAD push path had always qualified its + * refspec for exactly this reason; the Branches view's path did not. + */ +test("pushing a branch whose name is also a tag still works", async () => { + const { work, remote, git, cleanup } = repoWithRemote(); + try { + git("checkout", "-qb", "release"); + writeFileSync(`${work}/r.txt`, "r\n"); + git("add", "."); + git("commit", "-qm", "r1"); + git("push", "-q", "-u", "origin", "release"); + git("tag", "release"); // a TAG sharing the branch's name — legal in git + writeFileSync(`${work}/r2.txt`, "r\n"); + git("add", "."); + git("commit", "-qm", "r2"); + git("checkout", "-q", "master"); + + const repos = new RepoStore([]); + await repos.open(work); + const bridge = new GitBridge(repos); + const r = await bridge.branchPush("release"); + assert.equal(r.ok, true, `push succeeds despite the tag (${r.message ?? ""})`); + assert.equal( + execFileSync("git", ["log", "-1", "--format=%s", "refs/heads/release"], { cwd: remote }) + .toString() + .trim(), + "r2", + "and it pushed the BRANCH, not the tag", + ); + } finally { + cleanup(); + } +}); + +/** + * Publishing a branch that shares a tag's name. + * + * The publish path has no upstream to resolve, so it fell through to a BARE + * name — matched against refs/heads and refs/tags alike, which git refuses: + * "error: src refspec v2 matches more than one". Verified against real git. + */ +test("publishing a branch whose name is also a tag works", async () => { + const { work, remote, git, cleanup } = repoWithRemote(); + try { + git("checkout", "-qb", "v2"); + writeFileSync(`${work}/v.txt`, "v\n"); + git("add", "."); + git("commit", "-qm", "v"); + git("tag", "v2"); // the collision + git("checkout", "-q", "master"); + + const repos = new RepoStore([]); + await repos.open(work); + const r = await new GitBridge(repos).branchPush("v2"); + assert.equal(r.ok, true, `publish succeeds despite the tag (${r.message ?? ""})`); + assert.ok(heads(remote).includes("refs/heads/v2"), "the BRANCH reached the remote"); + assert.equal( + git("config", "--get", "branch.v2.merge").trim(), + "refs/heads/v2", + "and tracking is still set up", + ); + } finally { + cleanup(); + } +}); + +/** An unpublished branch still publishes and starts tracking. */ +test("pushing an unpublished branch publishes it and sets upstream", async () => { + const { work, remote, git, cleanup } = repoWithRemote(); + try { + git("checkout", "-qb", "brand-new"); + writeFileSync(`${work}/n.txt`, "n\n"); + git("add", "."); + git("commit", "-qm", "n"); + git("checkout", "-q", "master"); + + const repos = new RepoStore([]); + await repos.open(work); + const bridge = new GitBridge(repos); + const r = await bridge.branchPush("brand-new"); + assert.equal(r.ok, true, `publish succeeds (${r.message ?? ""})`); + assert.ok(heads(remote).includes("refs/heads/brand-new"), "it reached the remote"); + assert.equal( + git("config", "--get", "branch.brand-new.merge").trim(), + "refs/heads/brand-new", + "and it now tracks what it published to", + ); + } finally { + cleanup(); + } +}); diff --git a/apps/desktop/test/branchSweep.test.ts b/apps/desktop/test/branchSweep.test.ts new file mode 100644 index 0000000..3602d2a --- /dev/null +++ b/apps/desktop/test/branchSweep.test.ts @@ -0,0 +1,111 @@ +// Which branches "Delete N finished…" is allowed to touch. +// +// `merged` on a BranchInfo is not a git flag — it is computed as `ahead === 0` +// measured with `%(ahead-behind:<default>)` against the DEFAULT BRANCH. Which +// makes the default branch zero commits ahead of itself, and therefore merged, +// and therefore finished. Standing on any feature branch, the sweep listed +// `main` by name in its confirm alongside the genuinely finished branches and +// offered to delete it. +// +// The rule this pins: the sweep set is every local branch that is merged into +// the default branch or whose upstream is gone, MINUS the branch you are on and +// MINUS the default branch itself. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import type { BranchInfo } from "../src/shared/ipc"; + +/** + * The predicate as the view applies it. Kept beside the source assertion below + * so a change to one without the other fails rather than drifting. + */ +function finishedSet(locals: BranchInfo[], defaultBranch?: string): string[] { + return locals + .filter((b) => !b.current && b.name !== defaultBranch && (b.merged || b.gone)) + .map((b) => b.name); +} + +const b = (name: string, o: Partial<BranchInfo> = {}): BranchInfo => ({ + name, + current: false, + ahead: 0, + behind: 0, + ...o, +}); + +test("the default branch is never finished, however merged it looks", () => { + // main measured against main: zero ahead, which is the definition `merged` + // uses. This is the case that was live. + const locals = [ + b("main", { merged: true }), + b("feature/done", { merged: true }), + b("wip", { current: true }), + ]; + assert.deepEqual(finishedSet(locals, "main"), ["feature/done"]); +}); + +test("the branch you are standing on is never finished", () => { + // git refuses to delete the checked-out branch anyway, so offering it puts a + // guaranteed failure in the middle of a sequential bulk delete. + const locals = [b("release/1.2", { current: true, merged: true }), b("old", { merged: true })]; + assert.deepEqual(finishedSet(locals, "main"), ["old"]); +}); + +test("a gone upstream counts as finished, which is what a merged PR leaves", () => { + // GitHub deletes the head branch when a pull request merges, so the local + // copy is left tracking nothing — and a squash merge means it does not read + // as merged either. Without `gone` the sweep would miss the common case. + const locals = [b("fix/login", { gone: true }), b("still-going", {})]; + assert.deepEqual(finishedSet(locals, "main"), ["fix/login"]); +}); + +test("with no default branch known, nothing is protected by name — but the current branch still is", () => { + // `defaultBranch` is undefined in a repo with no origin/HEAD and a detached + // HEAD. Every merged branch is then a candidate, which is correct: there is + // no default branch in the set to protect. + const locals = [b("a", { merged: true }), b("b", { gone: true }), b("c", { current: true, merged: true })]; + assert.deepEqual(finishedSet(locals, undefined), ["a", "b"]); +}); + +test("an unmerged branch with a live upstream is left alone", () => { + const locals = [b("feature/wip", { upstream: "origin/feature/wip", ahead: 3 })]; + assert.deepEqual(finishedSet(locals, "main"), []); +}); + +// ── and the same rule, where it is actually enforced ──────────────────────── + +const here = dirname(fileURLToPath(import.meta.url)); +const renderer = readFileSync(join(here, "..", "src", "renderer", "renderer.ts"), "utf8"); + +test("the view's filter excludes the default branch", () => { + assert.match( + renderer, + /\.filter\(\(b\) => !b\.current && b\.name !== defaultBranch && \(b\.merged \|\| b\.gone\)\)/, + "the sweep's candidate list no longer excludes the default branch by name", + ); +}); + +test("and it is computed downstream of the search and the facets", () => { + // Computed above them, the button read "Delete 6 finished…" beside a list the + // reader had narrowed to one, offering to delete five branches not on screen + // — while the age counts inches away were deliberately measured after the + // same filters. Two controls in one bar disagreeing about what the list is. + const at = renderer.indexOf("const finished = locals"); + assert.ok(at > 0, "the sweep's candidate list moved or was renamed"); + const block = renderer.slice(at, at + 400); + assert.match(block, /\.filter\(\(b\) => hit\(/, "the sweep ignores the search"); + assert.match(block, /\.filter\(\(b\) => bar\.passes\(b\)\)/, "the sweep ignores the facets"); +}); + +test("and the sweep itself refuses them a second time", () => { + // A bulk delete does not get to trust a filter written elsewhere. + const body = renderer.slice(renderer.indexOf("private async sweepFinishedBranches")); + assert.match( + body.slice(0, 1200), + /finished = finished\.filter\(\(b\) => !b\.current && b\.name !== defaultBranch\)/, + "sweepFinishedBranches deletes whatever it is handed", + ); +}); diff --git a/apps/desktop/test/brokenRepo.test.ts b/apps/desktop/test/brokenRepo.test.ts new file mode 100644 index 0000000..145a4ef --- /dev/null +++ b/apps/desktop/test/brokenRepo.test.ts @@ -0,0 +1,93 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { writeFileSync, mkdtempSync } from "node:fs"; +import { removeTempRepo } from "./tmpRepo"; +import { tmpdir } from "node:os"; +import { RepoStore } from "../src/main/repoStore"; +import { GitBridge } from "../src/main/gitBridge"; +import { isExpectedError } from "../src/main/expectedError"; + +/** + * A repository git refuses to read. + * + * `mustSucceed` exists so a failing `git status` can no longer be laundered + * into "working tree clean" — the most dangerous sentence this app can print. + * But it threw a plain `Error`, and every IPC handler files a plain throw as a + * crash report. A corrupt index, a held `index.lock`, wrong permissions or the + * folder moving are all conditions the USER is in, not defects in the app — + * and the status read runs on a filesystem watcher, so one stuck lock would + * have filed a report per tick. + * + * It must still THROW (the renderer's error state depends on it) and still + * carry git's own words. It just must not read as a crash. + */ +test("a repo git cannot read fails loudly, but as an expected condition", async () => { + const root = mkdtempSync(`${tmpdir()}/gs-broken-`); + try { + const git = (...a: string[]): string => execFileSync("git", a, { cwd: root }).toString(); + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); // no background gc racing the cleanup + writeFileSync(`${root}/a.txt`, "a\n"); + git("add", "."); + git("commit", "-qm", "one"); + + // Corrupt the index — exactly the reported failure. + writeFileSync(`${root}/.git/index`, "this is not an index\n"); + + const repos = new RepoStore([]); + await repos.open(root); + const bridge = new GitBridge(repos); + + let thrown: unknown; + try { + await bridge.status(); + } catch (e) { + thrown = e; + } + assert.ok(thrown, "it throws rather than reporting an empty, clean tree"); + assert.ok( + isExpectedError(thrown), + "and it is an EXPECTED condition, so it is never filed as a crash", + ); + assert.match( + String((thrown as Error).message), + /working tree/i, + "while still saying what failed", + ); + } finally { + removeTempRepo(root); + } +}); + +/** The healthy path must be untouched by all of this. */ +test("a healthy repo still reads its working tree", async () => { + const root = mkdtempSync(`${tmpdir()}/gs-healthy-`); + try { + const git = (...a: string[]): string => execFileSync("git", a, { cwd: root }).toString(); + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); // no background gc racing the cleanup + writeFileSync(`${root}/a.txt`, "a\n"); + git("add", "."); + git("commit", "-qm", "one"); + writeFileSync(`${root}/a.txt`, "a2\n"); + writeFileSync(`${root}/b.txt`, "b\n"); + + const repos = new RepoStore([]); + await repos.open(root); + const files = await new GitBridge(repos).status(); + assert.deepEqual( + files.map((f) => [f.status, f.path]).sort(), + [ + ["?", "b.txt"], + ["M", "a.txt"], + ].sort(), + ); + } finally { + removeTempRepo(root); + } +}); diff --git a/apps/desktop/test/cache.test.ts b/apps/desktop/test/cache.test.ts new file mode 100644 index 0000000..e1b85b5 --- /dev/null +++ b/apps/desktop/test/cache.test.ts @@ -0,0 +1,233 @@ +// The stale-while-revalidate cache that sits under every list in the app. +// +// It is the one module where a subtle mistake is invisible in the UI until it +// isn't: nothing looks wrong, the list just quietly stops updating, or shows an +// error it will never recover from. The interesting cases all involve something +// happening to the cache WHILE a request is in flight — a mutation busting a +// prefix, a repo switch, a second reader arriving, a rejection. +// +// `cache.ts` reads `window.gitstudio` at import time, so the stub has to be +// installed before the dynamic import below. + +import { test, before, beforeEach } from "node:test"; +import assert from "node:assert/strict"; + +/** One pending host call we can settle by hand. */ +interface Call { + channel: string; + payload: unknown; + resolve: (v: unknown) => void; + reject: (e: unknown) => void; + promise: Promise<unknown>; +} + +let calls: Call[] = []; + +(globalThis as unknown as { window: unknown }).window = { + gitstudio: { + invoke(channel: string, payload: unknown) { + let resolve!: (v: unknown) => void; + let reject!: (e: unknown) => void; + const promise = new Promise<unknown>((res, rej) => { + resolve = res; + reject = rej; + }); + calls.push({ channel, payload, resolve, reject, promise }); + return promise; + }, + on() { + return () => {}; + }, + }, +}; + +// Loaded in `before` rather than at the top level: the file compiles to CJS +// under tsx, where top-level await is unavailable — and the stub above has to +// be installed first either way. +interface CacheModule { + peek: (c: string, p?: unknown, maxAge?: number) => unknown; + gget: (c: string, p?: unknown, ttl?: number) => Promise<unknown>; + bust: (prefix?: string) => void; + prime: (c: string, p: unknown, v: unknown) => void; + setCacheScope: (root: string | undefined) => void; + cacheScope: () => string; +} +let peek!: CacheModule["peek"]; +let gget!: CacheModule["gget"]; +let bust!: CacheModule["bust"]; +let prime!: CacheModule["prime"]; +let setCacheScope!: CacheModule["setCacheScope"]; +let cacheScope!: CacheModule["cacheScope"]; + +before(async () => { + const m = (await import("../src/renderer/cache")) as unknown as CacheModule; + ({ peek, gget, bust, prime, setCacheScope, cacheScope } = m); +}); + +/** Let the microtask queue drain so `.then` handlers on settled calls run. */ +const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 0)); + +/** Swallow a rejection we deliberately caused, without failing the test. */ +const quiet = <T>(p: Promise<T>): Promise<T | undefined> => p.catch(() => undefined); + +beforeEach(() => { + calls = []; + bust(); + setCacheScope(undefined); +}); + +test("a warm value inside the TTL is served without touching the host", async () => { + const first = gget("issue:list", { state: "open" }); + calls[0].resolve(["a"]); + assert.deepEqual(await first, ["a"]); + assert.equal(calls.length, 1); + + assert.deepEqual(await gget("issue:list", { state: "open" }), ["a"]); + assert.equal(calls.length, 1, "second read inside the TTL must not re-invoke"); +}); + +test("two concurrent readers share one request", async () => { + const a = gget("issue:list", undefined); + const b = gget("issue:list", undefined); + assert.equal(calls.length, 1, "the second caller joins the in-flight request"); + calls[0].resolve(["x"]); + assert.deepEqual(await a, ["x"]); + assert.deepEqual(await b, ["x"]); +}); + +test("the payload is part of the key", async () => { + void gget("issue:list", { state: "open" }); + void gget("issue:list", { state: "closed" }); + assert.equal(calls.length, 2, "different payloads are different entries"); +}); + +test("a repo switch drops everything, in-flight answers included", async () => { + setCacheScope("/repos/a"); + const p = gget("branches:list", undefined); + setCacheScope("/repos/b"); + calls[0].resolve(["main-of-a"]); + await p; + assert.equal(peek("branches:list", undefined), undefined, "repo A's answer must not land in repo B"); +}); + +// ── the in-flight/bust interaction — where the real bug lived ──────────────── + +test("a prefix bust for a DIFFERENT channel must not pin an in-flight channel forever", async () => { + // Staging a file calls bust("status") + bust("diff"). If a GitHub list was + // loading at that moment, its entry kept an in-flight marker that nothing + // ever cleared — and `gget` short-circuits on that marker BEFORE it checks + // the TTL, so the list was pinned to that one answer for the rest of the + // session. Refresh did nothing. + const first = gget("issue:list", undefined); + bust("status"); + calls[0].resolve(["stale"]); + assert.deepEqual(await first, ["stale"]); + await flush(); + + const second = gget("issue:list", undefined); + assert.equal(calls.length, 2, "the next read must actually re-invoke the host"); + calls[1].resolve(["fresh"]); + assert.deepEqual(await second, ["fresh"]); +}); + +test("a superseded answer is not published as if it were fresh", async () => { + const first = gget("issue:list", undefined); + bust("status"); + calls[0].resolve(["stale"]); + await first; + await flush(); + // It was fetched before the invalidation, so it must not be readable as a + // fresh cached value. + assert.equal(peek("issue:list", undefined, 1000), undefined); +}); + +test("a rejection that lands while superseded does not become permanent", async () => { + const first = quiet(gget("issue:list", undefined)); + bust("status"); + calls[0].reject(new Error("network")); + await first; + await flush(); + + const second = gget("issue:list", undefined); + assert.equal(calls.length, 2, "a failed read must be retryable"); + calls[1].resolve(["recovered"]); + assert.deepEqual(await second, ["recovered"]); +}); + +test("a rejection keeps the last good value readable, but stale", async () => { + const warm = gget("issue:list", undefined); + calls[0].resolve(["good"]); + await warm; + + // A NEGATIVE ttl is the unambiguous "refetch regardless": with ttl 0 and no + // time elapsed, `now - at <= 0` still counts as fresh. + const retry = quiet(gget("issue:list", undefined, -1)); + assert.equal(calls.length, 2); + calls[1].reject(new Error("offline")); + await retry; + await flush(); + + assert.deepEqual(peek("issue:list", undefined), ["good"], "the last-known-good survives a failure"); + const third = gget("issue:list", undefined, -1); + assert.equal(calls.length, 3, "and the next read still retries"); + calls[2].resolve(["back"]); + assert.deepEqual(await third, ["back"]); +}); + +test("a value primed while a read is in flight is not clobbered by that read", async () => { + // prime() seeds from a push event, which is newer than a read that started + // earlier. The late answer must not overwrite it. + const p = gget("branches:list", undefined); + prime("branches:list", undefined, ["from-event"]); + calls[0].resolve(["from-older-read"]); + await p; + await flush(); + assert.deepEqual(peek("branches:list", undefined), ["from-event"]); +}); + +// ── bust() semantics ───────────────────────────────────────────────────────── + +test("bust with a prefix clears only channels that start with it", async () => { + const a = gget("status:get", undefined); + calls[0].resolve("dirty"); + await a; + const b = gget("issue:list", undefined); + calls[1].resolve(["i"]); + await b; + + bust("status"); + assert.equal(peek("status:get", undefined), undefined, "status was busted"); + assert.deepEqual(peek("issue:list", undefined), ["i"], "issues was not"); +}); + +test("bust with no prefix clears everything", async () => { + const a = gget("status:get", undefined); + calls[0].resolve("dirty"); + await a; + bust(); + assert.equal(peek("status:get", undefined), undefined); +}); + +test("peek respects a max age", async () => { + const a = gget("status:get", undefined); + calls[0].resolve("dirty"); + await a; + assert.equal(peek("status:get", undefined, 10_000), "dirty"); + assert.equal(peek("status:get", undefined, -1), undefined, "nothing is younger than a negative age"); +}); + +/** + * `cacheScope()` is what keys the things that must be per-repo but are not + * cache entries — an unsent comment draft, above all. Those lived in a + * `Map<number, string>`, so a reply half-written on issue #31 in one repository + * was pre-filled into issue #31's composer in the next one you opened, ready to + * send to strangers. Low numbers collide across repos constantly. + */ +test("the cache scope is readable, and tracks the active repo", () => { + setCacheScope("/repos/alpha"); + assert.equal(cacheScope(), "/repos/alpha"); + setCacheScope("/repos/beta"); + assert.equal(cacheScope(), "/repos/beta", "so a key built from it changes with the repo"); + setCacheScope(undefined); + assert.equal(cacheScope(), "", "and is empty, not undefined, with no repo open"); +}); diff --git a/apps/desktop/test/checkoutRequest.test.ts b/apps/desktop/test/checkoutRequest.test.ts new file mode 100644 index 0000000..595210a --- /dev/null +++ b/apps/desktop/test/checkoutRequest.test.ts @@ -0,0 +1,132 @@ +// What a "check out this branch" click actually sends. +// +// The app has one action that DETACHES (`checkout`, of a commit) and one that +// ATTACHES (`checkout-ref`, of a branch, with the kind deciding how). Issues +// #12/#19 were about offering the first where the second was meant. The item +// builders were fixed and tested — `refMenuItems.test.ts` — but the request +// each click builds was not, and two of the three builders were wrong: +// +// - the graph's context menu stored the ref on the menu item and never read +// it back, so `name` arrived undefined and the main process refused every +// branch checkout as an unsafe ref; +// - the Branches list and the ref page sent `action: "checkout"` with a ref +// NAME in `sha`, so `git checkout origin/foo` detached HEAD onto the +// remote-tracking ref — no branch, no upstream, the next commit landing +// where nothing points at it — under a toast reading "Checked out foo." +// +// tsc could not catch either: `sha` is a string, and both call sites cast the +// payload (`as never` / `as Parameters<…>`) to satisfy the union. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { CommitContextMenu } from "../src/renderer/contextMenu"; +import type { CommitActionRequest } from "../src/shared/ipc"; + +const here = dirname(fileURLToPath(import.meta.url)); +const src = (p: string): string => readFileSync(join(here, "..", "src", p), "utf8"); + +// ── the graph's context menu ──────────────────────────────────────────────── +// It is a DOM class, but `dispatch` is reachable through a click on the row it +// renders, and jsdom is not needed for what this asserts: the request shape. + +function menuRequestFor(ref: { name: string; kind: "head" | "remote" | "tag" }): CommitActionRequest { + let got: CommitActionRequest | undefined; + const menu = new CommitContextMenu((req) => { + got = req; + }); + // `dispatch` is private to the class, not to the module; reaching it directly + // keeps this test free of a DOM. The alternative — rendering the menu and + // clicking — tests jsdom, not the payload. + const dispatch = (menu as unknown as { + dispatch(item: unknown, sha: string): Promise<void>; + }).dispatch.bind(menu); + void dispatch({ label: `Checkout ${ref.name}`, action: "checkout-ref", ref, refItem: true }, "abc1234"); + assert.ok(got, "the menu resolved nothing at all"); + return got; +} + +test("the graph's branch checkout carries the ref, not just the commit", () => { + const req = menuRequestFor({ name: "feature/login", kind: "head" }); + assert.equal(req.action, "checkout-ref"); + // The main process reads `name`, and refuses the request outright without it. + assert.equal(req.name, "feature/login"); + assert.equal(req.refKind, "head"); +}); + +test("the graph's remote checkout says it is remote", () => { + // This is the whole point of the kind: `planRemoteCheckout` creates a local + // tracking branch, and only runs when the request admits the ref is remote. + const req = menuRequestFor({ name: "origin/fix/login", kind: "remote" }); + assert.equal(req.refKind, "remote"); + assert.equal(req.name, "origin/fix/login"); +}); + +test("the graph's tag checkout still detaches, deliberately", () => { + const req = menuRequestFor({ name: "v1.2.0", kind: "tag" }); + assert.equal(req.refKind, "tag"); +}); + +test("a commit action with no ref is unchanged", () => { + // The other nine items on that menu are about the COMMIT, and adding a name + // to them would make `branch`/`tag` prompt for one and then ignore it. + let got: CommitActionRequest | undefined; + const menu = new CommitContextMenu((req) => { + got = req; + }); + const dispatch = (menu as unknown as { + dispatch(item: unknown, sha: string): Promise<void>; + }).dispatch.bind(menu); + void dispatch({ label: "Revert", action: "revert" }, "abc1234"); + assert.equal(got?.action, "revert"); + assert.equal(got?.name, undefined); + assert.equal(got?.refKind, undefined); +}); + +// ── the two renderer call sites ───────────────────────────────────────────── +// `App.checkoutRef` and the ref page's `checkout` build their requests inline, +// inside classes that import the whole renderer and cannot be loaded here. What +// can be asserted is the thing that was actually wrong: neither may reach for +// the detaching action, and both must pass the kind through. + +test("no checkout in the renderer uses the detaching action on a ref name", () => { + for (const f of ["renderer/renderer.ts", "renderer/views/refDetail.ts"]) { + const text = src(f); + // `action: "checkout"` — the detaching one — must not appear at all in + // these two files; every checkout they offer is of a named ref. + assert.equal( + /action:\s*"checkout"/.test(text), + false, + `${f} still sends the detaching checkout action for a named ref`, + ); + assert.ok( + /action:\s*"checkout-ref"/.test(text), + `${f} no longer sends a ref checkout at all — did the call site move?`, + ); + } +}); + +test("the ref checkouts pass a kind through", () => { + for (const f of ["renderer/renderer.ts", "renderer/views/refDetail.ts"]) { + assert.ok( + /refKind/.test(src(f)), + `${f} sends checkout-ref without a kind, so every remote branch detaches`, + ); + } +}); + +test("a remote branch row asks for the remote treatment", () => { + // The Branches list decides the kind from whether you already have the local + // branch: yours attaches by name, theirs has to be created. + const text = src("renderer/renderer.ts"); + assert.match( + text, + /checkoutRef\(mine \? short : r\.name, primary, mine \? "head" : "remote"\)/, + "the remote row no longer distinguishes a local branch from a remote one", + ); + // And the branch switcher's remote section, which promises "as a local + // branch" in its own tooltip. + assert.match(text, /checkoutRef\(b\.name, undefined, "remote"\)/); +}); diff --git a/apps/desktop/test/cloneName.test.ts b/apps/desktop/test/cloneName.test.ts new file mode 100644 index 0000000..e664890 --- /dev/null +++ b/apps/desktop/test/cloneName.test.ts @@ -0,0 +1,39 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { deriveNameFromUrl, validateTargetName } from "../src/shared/cloneName"; + +// The shared clone-target-name helpers: the derived-from-URL default and the +// live validation both the clone dialog and the destination sheet run. + +test("derives the repo name from HTTPS URLs", () => { + assert.equal(deriveNameFromUrl("https://github.com/acme/widgets.git"), "widgets"); + assert.equal(deriveNameFromUrl("https://github.com/acme/widgets"), "widgets"); + assert.equal(deriveNameFromUrl("https://github.com/acme/widgets/"), "widgets"); +}); + +test("derives the repo name from SSH URLs", () => { + assert.equal(deriveNameFromUrl("git@github.com:acme/widgets.git"), "widgets"); +}); + +test("empty / unusable URLs derive nothing", () => { + assert.equal(deriveNameFromUrl(""), undefined); + assert.equal(deriveNameFromUrl(" "), undefined); +}); + +test("an empty override is fine — it means 'use the derived name'", () => { + assert.equal(validateTargetName(""), null); + assert.equal(validateTargetName(" "), null); +}); + +test("a normal name passes", () => { + assert.equal(validateTargetName("my-repo"), null); + assert.equal(validateTargetName("Repo_2.x"), null); +}); + +test("dash-leading, separators, and dot names are refused with reasons", () => { + assert.match(validateTargetName("-rf")!, /dash/); + assert.match(validateTargetName("a/b")!, /separator/); + assert.match(validateTargetName("a\\b")!, /separator/); + assert.match(validateTargetName(".")!, /usable/); + assert.match(validateTargetName("..")!, /usable/); +}); diff --git a/apps/desktop/test/composerPayloads.test.ts b/apps/desktop/test/composerPayloads.test.ts new file mode 100644 index 0000000..9e91a43 --- /dev/null +++ b/apps/desktop/test/composerPayloads.test.ts @@ -0,0 +1,66 @@ +// What the two composers actually SEND. +// +// Both surfaces were rebuilt from modals into routed pages this round, and both +// gained fields the modal could never ask for: "Set as the latest release", and +// labels/assignees/milestone on a new issue. Each of those fields has a rule +// about ABSENCE that is invisible in the UI and destructive when wrong — GitHub +// reads a missing `make_latest` as "you decide" and an explicit `[]` as "clear +// these", so "the author did not choose" and "the author chose nothing" are not +// the same request. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { releaseBody } from "../src/main/github/releases"; +import { newIssueBody } from "../src/main/github/issues"; + +test("a release that never asked about Latest does not answer", () => { + const body = releaseBody({ tagName: "v1.0.0" }, { forCreate: true }); + assert.equal("make_latest" in body, false); +}); + +test("Set as the latest release is sent as GitHub's string, both ways", () => { + assert.equal(releaseBody({ tagName: "v1", makeLatest: true }, { forCreate: true }).make_latest, "true"); + assert.equal(releaseBody({ tagName: "v1", makeLatest: false }, { forCreate: true }).make_latest, "false"); +}); + +test("a new release with no title falls back to its tag", () => { + assert.equal(releaseBody({ tagName: "v2.1.0", name: "" }, { forCreate: true }).name, "v2.1.0"); +}); + +test("an EDIT that empties the title clears it rather than restoring the tag", () => { + // The other direction of the same rule: on an update, "" is a statement. + assert.equal(releaseBody({ id: 5, tagName: "v2.1.0", name: "" }, { forCreate: false }).name, ""); +}); + +test("an empty target is omitted so GitHub uses the default branch", () => { + // Sent as "", GitHub errors instead of defaulting. + assert.equal(releaseBody({ tagName: "v1", targetCommitish: "" }, { forCreate: true }).target_commitish, undefined); +}); + +test("draft and prerelease default to false rather than undefined", () => { + const b = releaseBody({ tagName: "v1" }, { forCreate: true }); + assert.equal(b.draft, false); + assert.equal(b.prerelease, false); +}); + +test("a new issue carries the labels its author chose, in one request", () => { + const b = newIssueBody({ title: "Broken", labels: ["bug", "ui"], assignees: ["antonarnaudov"] }); + assert.deepEqual(b.labels, ["bug", "ui"]); + assert.deepEqual(b.assignees, ["antonarnaudov"]); +}); + +test("choosing nothing omits the field instead of sending an empty array", () => { + const b = newIssueBody({ title: "Broken", labels: [], assignees: [] }); + assert.equal("labels" in b, false); + assert.equal("assignees" in b, false); + assert.equal("milestone" in b, false); +}); + +test("milestone 0 would still be sent — absence is the only omission", () => { + // `!req.milestone` would drop a legitimate 0; the rule is `undefined`. + assert.equal(newIssueBody({ title: "x", milestone: 0 }).milestone, 0); +}); + +test("a body is always present, so an issue with none is not sent as undefined", () => { + assert.equal(newIssueBody({ title: "x" }).body, ""); +}); diff --git a/apps/desktop/test/conflictKinds.test.ts b/apps/desktop/test/conflictKinds.test.ts new file mode 100644 index 0000000..a98b77b --- /dev/null +++ b/apps/desktop/test/conflictKinds.test.ts @@ -0,0 +1,312 @@ +// Not every conflict is a content conflict. +// +// The conflict model reported three sides of text and nothing else, so the +// renderer opened the three-pane merge editor for every conflicted file: +// +// - a BINARY conflict got a line-by-line merge of whatever the bytes decoded +// to — two walls of U+FFFD, or two empty panes; +// - a MODIFY/DELETE conflict (one side edited the file, the other removed it) +// got an ordinary content merge with one deliberately blank pane, and +// nothing anywhere said the file had been deleted on that side. A blank pane +// is exactly what a side that EMPTIED the file looks like, so the two states +// were indistinguishable. +// +// And Discard on a conflicted row ran `git checkout -- <path>`, which refuses +// an unmerged path outright, so a destructive-sounding confirm was followed by +// raw git stderr. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { writeFileSync, readFileSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { RepoStore } from "../src/main/repoStore"; +import { GitBridge } from "../src/main/gitBridge"; +import { removeTempRepo } from "./tmpRepo"; + +/** A repo left mid-merge. `seed` writes the file on each side. */ +function conflicted( + name: string, + base: Buffer | string, + ours: Buffer | string, + theirs: Buffer | string | null, +): { root: string; git: (...a: string[]) => string } { + const root = mkdtempSync(join(tmpdir(), "gs-conflict-kind-")); + const git = (...a: string[]): string => { + try { + return execFileSync("git", a, { cwd: root, encoding: "utf8" }); + } catch (e) { + return String((e as { stdout?: Buffer }).stdout ?? ""); + } + }; + execFileSync("git", ["-c", "init.defaultBranch=main", "init", root]); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + writeFileSync(join(root, name), base); + git("add", "-A"); + git("commit", "-qm", "base"); + + git("checkout", "-q", "-b", "side"); + if (theirs === null) rmSync(join(root, name)); + else writeFileSync(join(root, name), theirs); + git("add", "-A"); + git("commit", "-qm", "theirs"); + + git("checkout", "-q", "main"); + writeFileSync(join(root, name), ours); + git("commit", "-qam", "ours"); + git("merge", "side"); + return { root, git }; +} + +async function bridge(root: string): Promise<GitBridge> { + const repos = new RepoStore([]); + await repos.open(root); + return new GitBridge(repos); +} + +test("a modify/delete conflict says WHICH side has no file", async () => { + const { root, git } = conflicted("f.txt", "base\n", "edited on main\n", null); + try { + assert.notEqual(git("ls-files", "-u", "--", "f.txt").trim(), "", "the fixture is conflicted"); + + const b = await bridge(root); + const m = await b.conflictModel("f.txt"); + assert.ok(m, "there is a model"); + // "theirs" is the side branch, which deleted it. The index holds stage 2 + // and no stage 3 — which is the only way to tell this apart from a side + // that emptied the file, since both give an empty string. + assert.equal(m!.missingSide, "theirs", "the deleted side is named"); + assert.notEqual(m!.binary, true, "and it is not mistaken for a binary"); + } finally { + removeTempRepo(root); + } +}); + +test("the other direction is named the other way round", async () => { + // Deleted on main (ours), edited on the side branch (theirs). + const root = mkdtempSync(join(tmpdir(), "gs-conflict-md2-")); + const git = (...a: string[]): string => { + try { + return execFileSync("git", a, { cwd: root, encoding: "utf8" }); + } catch (e) { + return String((e as { stdout?: Buffer }).stdout ?? ""); + } + }; + try { + execFileSync("git", ["-c", "init.defaultBranch=main", "init", root]); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + writeFileSync(join(root, "f.txt"), "base\n"); + git("add", "-A"); + git("commit", "-qm", "base"); + git("checkout", "-q", "-b", "side"); + writeFileSync(join(root, "f.txt"), "edited on the side\n"); + git("commit", "-qam", "theirs"); + git("checkout", "-q", "main"); + rmSync(join(root, "f.txt")); + git("add", "-A"); + git("commit", "-qm", "deleted here"); + git("merge", "side"); + + const b = await bridge(root); + const m = await b.conflictModel("f.txt"); + assert.equal(m?.missingSide, "ours", "the side that deleted it is the one named"); + } finally { + removeTempRepo(root); + } +}); + +test("a conflicted binary is reported as binary", async () => { + const nul = (tag: number): Buffer => + Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00]), Buffer.alloc(64, tag)]); + const { root } = conflicted("art.png", nul(1), nul(2), nul(3)); + try { + const b = await bridge(root); + const m = await b.conflictModel("art.png"); + assert.ok(m, "there is a model"); + assert.equal(m!.binary, true, "so the renderer can refuse the text merge"); + } finally { + removeTempRepo(root); + } +}); + +test("an ordinary content conflict is neither", async () => { + // The two flags must not fire on the case the merge editor is FOR. + const { root } = conflicted("f.txt", "base\n", "ours\n", "theirs\n"); + try { + const b = await bridge(root); + const m = await b.conflictModel("f.txt"); + assert.ok(m, "there is a model"); + assert.notEqual(m!.binary, true); + assert.equal(m!.missingSide, undefined); + assert.ok(m!.ours.length > 0 && m!.theirs.length > 0, "both sides have text to merge"); + } finally { + removeTempRepo(root); + } +}); + +test("discarding a conflicted file restores the conflict instead of failing", async () => { + const { root, git } = conflicted("f.txt", "base\n", "ours\n", "theirs\n"); + try { + const b = await bridge(root); + // Resolve it by hand, the way someone working through a merge would. + writeFileSync(join(root, "f.txt"), "my careful reconciliation\n"); + + const r = await b.discard("f.txt"); + assert.equal(r.ok, true, `discard succeeds (${r.message ?? ""})`); + // `git checkout -- <path>` refuses an unmerged path: "error: path 'f.txt' + // is unmerged". `--merge` puts the conflict back, which is what discarding + // your changes means while a merge is in progress. + const back = readFileSync(join(root, "f.txt"), "utf8"); + assert.match(back, /^<{7} /m, "the conflict is back in the file"); + assert.notEqual( + git("ls-files", "-u", "--", "f.txt").trim(), + "", + "and git still considers the path unmerged", + ); + } finally { + removeTempRepo(root); + } +}); + +test("discarding an ordinary file still just reverts it", async () => { + const root = mkdtempSync(join(tmpdir(), "gs-discard-plain-")); + const git = (...a: string[]): string => execFileSync("git", a, { cwd: root, encoding: "utf8" }); + try { + execFileSync("git", ["-c", "init.defaultBranch=main", "init", root]); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + writeFileSync(join(root, "a.txt"), "one\n"); + git("add", "-A"); + git("commit", "-qm", "a"); + writeFileSync(join(root, "a.txt"), "two\n"); + + const b = await bridge(root); + assert.equal((await b.discard("a.txt")).ok, true); + assert.equal(readFileSync(join(root, "a.txt"), "utf8"), "one\n"); + } finally { + removeTempRepo(root); + } +}); + +test("a conflicted file over the read cap refuses the text merge", async () => { + // `result` is the text the merge editor seeds its result pane with, and the + // text "Mark resolved" writes back to the file. `readWorking` caps at 512KB, + // so on a larger file resolving would have written the first 512KB over the + // whole thing and staged that as the answer — deleting the rest silently, + // under a toast reading "Resolved and staged." + const CAP = 512 * 1024; + // Each side comfortably over the cap on its own, so the conflicted working + // file — which holds both — is far past it. + const big = (tag: string): string => `${tag} a line of ordinary text\n`.repeat(24_000); + const { root } = conflicted("big.txt", big("base"), big("ours"), big("theirs")); + try { + const b = await bridge(root); + const m = await b.conflictModel("big.txt"); + assert.ok(m, "there is a model"); + assert.ok(m!.result.length <= CAP + 4096, "the working copy really was capped"); + assert.equal(m!.truncated, true, "and the model says so, so the panel can refuse"); + } finally { + removeTempRepo(root); + } +}); + +test("a both-sides-deleted conflict is its own state, not a modify/delete", async () => { + // Git's DD: the path is listed with stage 1 and NEITHER 2 nor 3. Folded into + // `missingSide` it was drawn as "changed on one side, deleted on the other" + // and offered a "Take <side>" button for a side that has nothing to take — + // which `conflictTakeSide` then refuses, correctly, contradicting the panel. + const root = mkdtempSync(join(tmpdir(), "gs-conflict-dd-")); + const git = (...a: string[]): string => { + try { + return execFileSync("git", a, { cwd: root, encoding: "utf8" }); + } catch (e) { + return String((e as { stdout?: Buffer }).stdout ?? ""); + } + }; + try { + execFileSync("git", ["-c", "init.defaultBranch=main", "init", root]); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + writeFileSync(join(root, "f.txt"), "base\n"); + writeFileSync(join(root, "keep.txt"), "so the merge has something to do\n"); + git("add", "-A"); + git("commit", "-qm", "base"); + + // Deleted on one side, deleted AND the file replaced on the other — the + // rename-vs-delete shape that leaves DD. + git("checkout", "-q", "-b", "side"); + execFileSync("git", ["rm", "-q", "f.txt"], { cwd: root }); + writeFileSync(join(root, "keep.txt"), "side\n"); + git("commit", "-qam", "deleted on the side"); + git("checkout", "-q", "main"); + execFileSync("git", ["rm", "-q", "f.txt"], { cwd: root }); + writeFileSync(join(root, "keep.txt"), "main\n"); + git("commit", "-qam", "deleted here too"); + git("merge", "side"); + + const stages = git("ls-files", "-u", "--", "f.txt").trim(); + if (!stages) return; // git resolved it without a conflict — nothing to assert + + const repos = new RepoStore([]); + await repos.open(root); + const b = new GitBridge(repos); + const m = await b.conflictModel("f.txt"); + assert.ok(m, "there is a model"); + assert.equal(m!.bothDeleted, true, "it is reported as deleted on both sides"); + assert.equal(m!.missingSide, undefined, "and NOT as one side missing"); + } finally { + removeTempRepo(root); + } +}); + +test("an added-on-one-side conflict has no common version behind it", async () => { + // Git's UA / AU. There is no base, so nothing was deleted — the modify/delete + // story ("deleted in X") describes a deletion that never happened, about a + // file with no history to have been deleted from. + const root = mkdtempSync(join(tmpdir(), "gs-conflict-ua-")); + const git = (...a: string[]): string => { + try { + return execFileSync("git", a, { cwd: root, encoding: "utf8" }); + } catch (e) { + return String((e as { stdout?: Buffer }).stdout ?? ""); + } + }; + try { + execFileSync("git", ["-c", "init.defaultBranch=main", "init", root]); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + writeFileSync(join(root, "keep.txt"), "base\n"); + git("add", "-A"); + git("commit", "-qm", "base"); + + // `new.txt` exists on ONE side only, and never existed before. + git("checkout", "-q", "-b", "side"); + writeFileSync(join(root, "new.txt"), "from the side\n"); + writeFileSync(join(root, "keep.txt"), "side\n"); + git("add", "-A"); + git("commit", "-qm", "added on the side"); + git("checkout", "-q", "main"); + writeFileSync(join(root, "keep.txt"), "main\n"); + git("commit", "-qam", "changed here"); + git("merge", "side"); + + const repos = new RepoStore([]); + await repos.open(root); + const b = new GitBridge(repos); + const m = await b.conflictModel("new.txt"); + if (!m) return; // no conflict on that path in this git version + // The decisive property: no common ancestor. The renderer branches on it + // to stop telling a deletion story about a file that was only ever added. + assert.equal(m.hasBase, false, "there is no version behind either side"); + } finally { + removeTempRepo(root); + } +}); diff --git a/apps/desktop/test/conflictResolve.test.ts b/apps/desktop/test/conflictResolve.test.ts new file mode 100644 index 0000000..084f35d --- /dev/null +++ b/apps/desktop/test/conflictResolve.test.ts @@ -0,0 +1,120 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { writeFileSync, mkdtempSync, existsSync, mkdirSync } from "node:fs"; +import { removeTempRepo } from "./tmpRepo"; +import { tmpdir } from "node:os"; +import { RepoStore } from "../src/main/repoStore"; +import { GitBridge } from "../src/main/gitBridge"; + +/** + * Resolving a modify/delete conflict — one side edited a file, the other removed + * it. + * + * Such a conflict has only TWO index stages: the base, and whichever side kept + * the file. `conflictTakeSide` unconditionally ran `git show :2:` / `:3:` and + * wrote stdout, so choosing the side that DELETED the file asked git for a stage + * that does not exist — and the user got a raw `fatal: path ... does not exist` + * for pressing a button the app itself had offered. There was no way to resolve + * one of git's seven conflict kinds at all. + * + * An absent stage is not an error. It is that side's answer: delete the file. + */ +function conflictRepo(): { root: string; git: (...a: string[]) => string } { + const root = mkdtempSync(`${tmpdir()}/gs-md-`); + const git = (...a: string[]): string => + execFileSync("git", a, { cwd: root, encoding: "utf8" }); + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); // no background gc racing the cleanup + mkdirSync(`${root}/app`); + writeFileSync(`${root}/app/keep.py`, "print('base')\n"); + writeFileSync(`${root}/app/drop.py`, "print('base')\n"); + writeFileSync(`${root}/app/both.py`, "print('base')\n"); + git("add", "-A"); + git("commit", "-qm", "base"); + + // side branch: delete keep.py, edit drop.py, edit both.py + git("checkout", "-qb", "side"); + execFileSync("git", ["rm", "-q", "app/keep.py"], { cwd: root }); + writeFileSync(`${root}/app/drop.py`, "print('side')\n"); + writeFileSync(`${root}/app/both.py`, "print('side')\n"); + git("add", "-A"); + git("commit", "-qm", "side"); + + // main: edit keep.py, delete drop.py, edit both.py — a UD, a DU and a UU. + git("checkout", "-q", "master"); + writeFileSync(`${root}/app/keep.py`, "print('main')\n"); + execFileSync("git", ["rm", "-q", "app/drop.py"], { cwd: root }); + writeFileSync(`${root}/app/both.py`, "print('main')\n"); + git("add", "-A"); + git("commit", "-qm", "main"); + try { + git("merge", "side"); + } catch { + /* conflicts are the point */ + } + return { root, git }; +} + +test("taking the side that DELETED the file deletes it", async () => { + const { root, git } = conflictRepo(); + try { + const repos = new RepoStore([]); + await repos.open(root); + const bridge = new GitBridge(repos); + + // keep.py: we modified, they deleted. "Take theirs" means remove it. + const theirs = await bridge.conflictTakeSide({ path: "app/keep.py", side: "theirs" }); + assert.equal(theirs.ok, true, `take-theirs succeeds (${theirs.message ?? ""})`); + assert.equal(existsSync(`${root}/app/keep.py`), false, "the file is gone from disk"); + + // drop.py: we deleted, they modified. "Take ours" means remove it. + const ours = await bridge.conflictTakeSide({ path: "app/drop.py", side: "ours" }); + assert.equal(ours.ok, true, `take-ours succeeds (${ours.message ?? ""})`); + assert.equal(existsSync(`${root}/app/drop.py`), false, "and so is this one"); + + const status = git("status", "--porcelain=v1"); + assert.ok(!/^U|^.U/m.test(status.replace(/^.. app\/both\.py$/m, "")), "both are resolved"); + } finally { + removeTempRepo(root); + } +}); + +test("taking the side that KEPT the file keeps it", async () => { + const { root } = conflictRepo(); + try { + const repos = new RepoStore([]); + await repos.open(root); + const bridge = new GitBridge(repos); + + const keepOurs = await bridge.conflictTakeSide({ path: "app/keep.py", side: "ours" }); + assert.equal(keepOurs.ok, true, `keeping our version succeeds (${keepOurs.message ?? ""})`); + assert.ok(existsSync(`${root}/app/keep.py`), "our edited file survives"); + + const keepTheirs = await bridge.conflictTakeSide({ path: "app/drop.py", side: "theirs" }); + assert.equal(keepTheirs.ok, true, `keeping their version succeeds (${keepTheirs.message ?? ""})`); + assert.ok(existsSync(`${root}/app/drop.py`), "their edited file is restored"); + } finally { + removeTempRepo(root); + } +}); + +test("an ordinary content conflict still resolves to the chosen side", async () => { + const { root, git } = conflictRepo(); + try { + const repos = new RepoStore([]); + await repos.open(root); + const bridge = new GitBridge(repos); + const res = await bridge.conflictTakeSide({ path: "app/both.py", side: "theirs" }); + assert.equal(res.ok, true, `take-theirs succeeds (${res.message ?? ""})`); + assert.match( + git("show", ":app/both.py"), + /side/, + "the staged content is the side that was chosen", + ); + } finally { + removeTempRepo(root); + } +}); diff --git a/apps/desktop/test/conflictSideLabels.test.ts b/apps/desktop/test/conflictSideLabels.test.ts new file mode 100644 index 0000000..fa99402 --- /dev/null +++ b/apps/desktop/test/conflictSideLabels.test.ts @@ -0,0 +1,204 @@ +// Which side is YOURS depends on the operation. +// +// Git's index stage 2 is "ours" and stage 3 is "theirs", and the conflict UI +// takes a side by asking for one of those stages. But which of your work each +// stage holds is INVERTED during a rebase: +// +// merge / cherry-pick / revert ours = HEAD, your branch +// theirs = the change being brought in +// rebase ours = the UPSTREAM you are replaying onto +// theirs = YOUR commit being replayed +// +// The labels were hardcoded to the merge reading — "Current Change (ours)" / +// "Incoming Change (theirs)" — so in a rebase the button offering "your +// version" handed you the branch you were rebasing onto and threw away the +// commit being replayed, with the tooltip and the success toast both agreeing +// it had done the opposite. That is unrecoverable work loss behind a label that +// says the opposite of what it does. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { writeFileSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { sideLabels } from "../src/main/gitBridge"; + +const env = { ...process.env, GIT_OPTIONAL_LOCKS: "0" }; + +test("a merge names the sides for a merge", () => { + const l = sideLabels("merge"); + assert.match(l.oursLabel, /your branch/i); + assert.match(l.theirsLabel, /incoming/i); + assert.ok(!/upstream/i.test(l.oursLabel), "a merge's ours is not an upstream"); +}); + +test("a rebase names them the other way round", () => { + const l = sideLabels("rebase"); + assert.match(l.oursLabel, /upstream/i, "stage 2 in a rebase is what you are replaying ONTO"); + assert.match(l.theirsLabel, /your commit/i, "stage 3 is the commit of yours being replayed"); + // The decisive property: the two operations must not describe stage 2 the + // same way, because it does not hold the same thing. + assert.notEqual(l.oursLabel, sideLabels("merge").oursLabel); +}); + +test("cherry-pick and revert read like a merge, because they are", () => { + for (const kind of ["cherry-pick", "revert"] as const) { + assert.equal(sideLabels(kind).oursLabel, sideLabels("merge").oursLabel, kind); + } +}); + +test("git agrees about cherry-pick and revert too", () => { + // The claim above was an assertion about `sideLabels` agreeing with itself, + // which proves nothing about git. `am` was wrong for exactly that reason — + // it was grouped by assumption and never checked. So: real conflicts, real + // stages, for both remaining operations. + const root = mkdtempSync(join(tmpdir(), "gs-pick-sides-")); + const git = (...a: string[]): string => { + try { + return execFileSync("git", a, { cwd: root, encoding: "utf8", env }); + } catch (e) { + return String((e as { stdout?: Buffer }).stdout ?? ""); + } + }; + execFileSync("git", ["-c", "init.defaultBranch=main", "init", root], { env }); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + writeFileSync(join(root, "f.txt"), "base\n"); + git("add", "-A"); + git("commit", "-qm", "base"); + + // A commit on a side branch, to be picked onto main. + git("checkout", "-q", "-b", "side"); + writeFileSync(join(root, "f.txt"), "PICKED\n"); + git("commit", "-qam", "the pick"); + const picked = git("rev-parse", "HEAD").trim(); + + git("checkout", "-q", "main"); + writeFileSync(join(root, "f.txt"), "MINE\n"); + git("commit", "-qam", "mine"); + + git("cherry-pick", picked); // conflicts + assert.equal(git("show", ":2:f.txt").trim(), "MINE", "cherry-pick: stage 2 is YOUR branch"); + assert.equal(git("show", ":3:f.txt").trim(), "PICKED", "and stage 3 is the commit picked"); + git("cherry-pick", "--abort"); + + // And a revert: undoing an earlier commit against a since-changed file. + writeFileSync(join(root, "g.txt"), "one\n"); + git("add", "-A"); + git("commit", "-qm", "add g"); + const target = git("rev-parse", "HEAD").trim(); + writeFileSync(join(root, "g.txt"), "two\n"); + git("commit", "-qam", "change g"); + git("revert", "--no-edit", target); // conflicts + assert.equal(git("show", ":2:g.txt").trim(), "two", "revert: stage 2 is YOUR branch"); + + // Which is what the labels say for both. + for (const kind of ["cherry-pick", "revert"] as const) { + assert.match(sideLabels(kind).oursLabel, /your branch/i, kind); + assert.match(sideLabels(kind).theirsLabel, /incoming/i, kind); + } +}); + +test("`git am` reads like a merge, NOT like a rebase", () => { + // The distinction that matters, and the one this function originally got + // wrong by lumping the two together: a rebase checks the upstream out and + // replays onto it, so stage 2 is the upstream; `git am` applies a patch onto + // the branch you are standing on, so stage 2 is YOURS. + const l = sideLabels("am"); + assert.match(l.oursLabel, /your branch/i, "stage 2 during an am is your own branch"); + assert.match(l.theirsLabel, /patch/i, "and stage 3 is the patch being applied"); + assert.ok(!/rebasing onto/i.test(l.oursLabel), "it must not borrow the rebase wording"); + assert.notEqual(l.oursLabel, sideLabels("rebase").oursLabel); +}); + +test("git really does NOT invert the sides during an am", () => { + // The premise, against real git — the same proof the rebase case gets, since + // the whole bug was assuming these two behaved alike. + const root = mkdtempSync(join(tmpdir(), "gs-am-sides-")); + const patches = join(root, "patches"); + const git = (...a: string[]): string => { + try { + return execFileSync("git", a, { cwd: root, encoding: "utf8", env }); + } catch (e) { + return String((e as { stdout?: Buffer }).stdout ?? ""); + } + }; + execFileSync("git", ["-c", "init.defaultBranch=main", "init", root], { env }); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + writeFileSync(join(root, "f.txt"), "base\n"); + git("add", "-A"); + git("commit", "-qm", "base"); + + // A patch that will conflict, exported from a side branch. + git("checkout", "-q", "-b", "side"); + writeFileSync(join(root, "f.txt"), "FROM-THE-PATCH\n"); + git("commit", "-qam", "patch commit"); + git("format-patch", "-q", "-1", "-o", patches); + + git("checkout", "-q", "main"); + writeFileSync(join(root, "f.txt"), "MY-BRANCH\n"); + git("commit", "-qam", "mine"); + git("am", "--3way", join(patches, "0001-patch-commit.patch")); + + assert.equal(git("show", ":2:f.txt").trim(), "MY-BRANCH", 'during an am, stage 2 is YOUR branch'); + assert.equal( + git("show", ":3:f.txt").trim(), + "FROM-THE-PATCH", + "and stage 3 is the incoming patch", + ); + + const l = sideLabels("am"); + assert.match(l.oursLabel, /your branch/i); + assert.match(l.theirsLabel, /patch/i); +}); + +test("no operation still yields usable labels", () => { + const l = sideLabels(null); + assert.ok(l.oursLabel.length > 0 && l.theirsLabel.length > 0); +}); + +test("git really does invert the sides during a rebase", () => { + // The premise, against real git — because the whole fix rests on it and a + // stale belief here would make the labels confidently wrong in the other + // direction. + const root = mkdtempSync(join(tmpdir(), "gs-rebase-sides-")); + const git = (...a: string[]): string => { + try { + return execFileSync("git", a, { cwd: root, encoding: "utf8", env }); + } catch (e) { + return String((e as { stdout?: Buffer }).stdout ?? ""); + } + }; + execFileSync("git", ["-c", "init.defaultBranch=main", "init", root], { env }); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + writeFileSync(join(root, "f.txt"), "base\n"); + git("add", "-A"); + git("commit", "-qm", "base"); + + // main gains UPSTREAM; the feature branch gains MINE. + git("checkout", "-q", "-b", "feature"); + writeFileSync(join(root, "f.txt"), "MINE\n"); + git("commit", "-qam", "mine"); + git("checkout", "-q", "main"); + writeFileSync(join(root, "f.txt"), "UPSTREAM\n"); + git("commit", "-qam", "upstream"); + + git("checkout", "-q", "feature"); + git("rebase", "main"); // conflicts + + const stage2 = git("show", ":2:f.txt").trim(); + const stage3 = git("show", ":3:f.txt").trim(); + assert.equal(stage2, "UPSTREAM", 'during a rebase, stage 2 ("ours") is the upstream'); + assert.equal(stage3, "MINE", 'and stage 3 ("theirs") is the commit being replayed'); + + // And the labels agree with what git actually holds. + const l = sideLabels("rebase"); + assert.match(l.oursLabel, /upstream/i); + assert.match(l.theirsLabel, /your commit/i); +}); diff --git a/apps/desktop/test/conflictStatus.test.ts b/apps/desktop/test/conflictStatus.test.ts new file mode 100644 index 0000000..1ca225f --- /dev/null +++ b/apps/desktop/test/conflictStatus.test.ts @@ -0,0 +1,108 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parsePorcelainStatus } from "../src/main/gitBridge"; + +/** + * Porcelain v1's two status columns normally mean index-half and worktree-half. + * For an UNMERGED path they mean something else entirely: the two SIDES of the + * merge. The parser read them as halves regardless, and every downstream symptom + * followed from that one mistake — + * + * - one conflicted file produced TWO rows, so 7 conflicts listed as 13; + * - the phantom "staged" row carried an Unstage button, which destroys the + * merge stages and loses both sides of the conflict; + * - `UD` rendered a `D` badge on a file plainly sitting on disk; + * - nothing downstream could tell a conflict from an edit, because nothing in + * the model said which was which. + * + * The seven unmerged codes are git's own (git-status(1), "Short Format"): + * DD, AU, UD, UA, DU, AA, UU. + */ +test("every unmerged code is one row, marked as a conflict", () => { + const z = (...entries: string[]): string => entries.join("\0") + "\0"; + const cases: Array<[string, string]> = [ + ["DD", "both deleted"], + ["AU", "added by us"], + ["UD", "deleted by them"], + ["UA", "added by them"], + ["DU", "deleted by us"], + ["AA", "both added"], + ["UU", "both modified"], + ]; + for (const [code, what] of cases) { + const rows = parsePorcelainStatus(z(`${code} app/thing.py`)); + assert.equal(rows.length, 1, `${code} (${what}) is ONE row, not two`); + assert.equal(rows[0].path, "app/thing.py"); + assert.equal(rows[0].conflicted, true, `${code} is marked conflicted`); + assert.equal(rows[0].conflictKind, code, `${code} keeps its kind`); + assert.equal( + rows[0].staged, + false, + `${code} is never reported as staged — the Unstage button that came with ` + + `that claim destroys the merge stages`, + ); + assert.equal(rows[0].status, "U", `${code} reports U, not a letter that contradicts git`); + } +}); + +/** The ordinary two-half reading must survive, or this fix breaks normal work. */ +test("ordinary index/worktree halves are still read as halves", () => { + const z = (...entries: string[]): string => entries.join("\0") + "\0"; + + // Staged edit plus a NEWER unstaged edit to the same file: two real rows. + const mm = parsePorcelainStatus(z("MM src/a.ts")); + assert.equal(mm.length, 2, "MM is genuinely two halves"); + assert.deepEqual( + mm.map((f) => f.staged), + [true, false], + ); + assert.ok(!mm.some((f) => f.conflicted), "and neither half is a conflict"); + + // Untracked: one unstaged row. Discard DELETES these, which is why the + // confirmation has to be able to tell them apart. + const untracked = parsePorcelainStatus(z("?? new.txt")); + assert.equal(untracked.length, 1); + assert.equal(untracked[0].status, "?"); + assert.equal(untracked[0].staged, false); + assert.ok(!untracked[0].conflicted); + + // A plain staged add, and a plain unstaged modification. + assert.deepEqual( + parsePorcelainStatus(z("A added.ts")).map((f) => [f.status, f.staged, !!f.conflicted]), + [["A", true, false]], + ); + assert.deepEqual( + parsePorcelainStatus(z(" M edited.ts")).map((f) => [f.status, f.staged, !!f.conflicted]), + [["M", false, false]], + ); +}); + +/** + * The real matrix, verbatim from `git status --porcelain=v1` after merging the + * two branches of the merge-conflict-tests repo. Seven paths, seven rows. + */ +test("the real conflict matrix maps one row per path", () => { + const raw = + [ + "UU README.md", + "UU app/calculator.py", + "UD app/greeting.py", + "DU app/legacy.py", + "AA app/new_feature.py", + "UU app/settings.py", + "UU app/version.py", + ].join("\0") + "\0"; + const rows = parsePorcelainStatus(raw); + assert.equal(rows.length, 7, "seven conflicts are seven rows"); + assert.equal(new Set(rows.map((f) => f.path)).size, 7, "no path appears twice"); + assert.ok( + rows.every((f) => f.conflicted), + "and every one of them is marked as a conflict", + ); + assert.deepEqual( + rows.filter((f) => f.conflictKind === "UD" || f.conflictKind === "DU").map((f) => f.path), + ["app/greeting.py", "app/legacy.py"], + "modify/delete conflicts are identifiable — they carry no markers to detect, " + + "so 'Stage all' has to hold them back on kind alone", + ); +}); diff --git a/apps/desktop/test/conflictWriteSafety.test.ts b/apps/desktop/test/conflictWriteSafety.test.ts new file mode 100644 index 0000000..57359ce --- /dev/null +++ b/apps/desktop/test/conflictWriteSafety.test.ts @@ -0,0 +1,373 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { writeFileSync, readFileSync, mkdtempSync, symlinkSync, lstatSync, mkdirSync, existsSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { tmpdir } from "node:os"; +import { RepoStore } from "../src/main/repoStore"; +import { GitBridge } from "../src/main/gitBridge"; +import { removeTempRepo } from "./tmpRepo"; + +/** + * The merge view's primary button, "Mark resolved", sends `conflict:resolve` + * with the editable pane's text. It is offered for EVERY conflicted file — the + * view has no symlink or binary gate — and it wrote that text with + * `writeFile(abs, content, "utf8")`. + * + * Two files are destroyed by that, and both reported "Resolved and staged.": + * + * · A symlink. `writeFile` FOLLOWS it, so the app opened the link's target and + * overwrote it — a file that may be nowhere near the repository — while the + * link git actually tracks kept its old value. Nothing the user typed + * entered the repo, and the resolution git recorded was "keep ours". + * + * · A binary. The content reached the renderer through a JS string, so every + * byte that is not valid UTF-8 came back as U+FFFD: measured, a 4,508-byte + * PNG became 4,565 with its header `89504e47` rewritten to `efbfbd504e47`. + * + * `conflictTakeSide` was fixed for exactly this, forty lines above, and its + * comment claimed take-ours/take-theirs was "the only resolution the app offers + * for a binary conflict". It was not. + */ +const md5 = (b: Buffer): string => createHash("md5").update(b).digest("hex"); + +function repo(name: string): { root: string; git: (...a: string[]) => string } { + const root = mkdtempSync(`${tmpdir()}/gs-cw-${name}-`); + const git = (...a: string[]): string => + execFileSync("git", a, { cwd: root, stdio: ["ignore", "pipe", "pipe"] }).toString(); + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + return { root, git }; +} + +/** Conflict `name` between two branches, seeding each side with `write`. */ +function conflict(root: string, git: (...a: string[]) => string, name: string, write: (side: string) => void): void { + write("base"); + git("add", "-A"); + git("commit", "-qm", "base"); + const main = git("rev-parse", "--abbrev-ref", "HEAD").trim(); + git("checkout", "-qb", "side"); + write("theirs"); + git("add", "-A"); + git("commit", "-qm", "theirs"); + git("checkout", "-q", main); + write("ours"); + git("add", "-A"); + git("commit", "-qm", "ours"); + try { + execFileSync("git", ["merge", "side"], { cwd: root, stdio: "ignore" }); + } catch { + /* the conflict is the point */ + } +} + +test("marking a conflicted symlink resolved does not write through it", async () => { + const { root, git } = repo("link"); + const outside = mkdtempSync(`${tmpdir()}/gs-cw-outside-`); + try { + const secret = `${outside}/secret.txt`; + writeFileSync(secret, "IMPORTANT USER FILE — MUST NOT BE TOUCHED\n"); + conflict(root, git, "link", (side) => { + try { + execFileSync("rm", ["-f", `${root}/link`]); + } catch { + /* first pass */ + } + symlinkSync(side === "ours" ? secret : `${outside}/other-${side}.txt`, `${root}/link`); + }); + + const repos = new RepoStore([]); + await repos.open(root); + const b = new GitBridge(repos); + assert.deepEqual(await b.conflictList(), ["link"], "the view lists it as conflicted"); + + const r = await b.conflictResolve({ path: "link", content: "PWNED BY CONFLICT RESOLVE\n" }); + + assert.equal(r.ok, false, "saving text into a symlink is refused"); + assert.equal(r.expected, true, "as a condition, not a crash to report"); + assert.match(r.message ?? "", /symbolic link/i, "and says what it is"); + assert.match(r.message ?? "", /Take ours or Take theirs/, "and names a way through that works"); + + assert.equal( + readFileSync(secret, "utf8"), + "IMPORTANT USER FILE — MUST NOT BE TOUCHED\n", + "the file outside the repository is untouched", + ); + assert.equal(lstatSync(`${root}/link`).isSymbolicLink(), true, "and the link is still a link"); + } finally { + removeTempRepo(root); + removeTempRepo(outside); + } +}); + +test("marking a conflicted binary resolved does not corrupt it", async () => { + const { root, git } = repo("bin"); + try { + const png = (seed: number): Buffer => { + const head = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const body = Buffer.alloc(512); + for (let i = 0; i < body.length; i++) body[i] = (i * 7 + seed) & 0xff; + return Buffer.concat([head, body]); + }; + conflict(root, git, "logo.png", (side) => { + writeFileSync(`${root}/logo.png`, png(side === "ours" ? 1 : side === "theirs" ? 2 : 0)); + }); + + const before = readFileSync(`${root}/logo.png`); + const repos = new RepoStore([]); + await repos.open(root); + const b = new GitBridge(repos); + + // What the unedited "Mark resolved" button sends: the model's own text. + const r = await b.conflictResolve({ path: "logo.png", content: before.toString("utf8") }); + + assert.equal(r.ok, false, "saving a binary as text is refused"); + assert.match(r.message ?? "", /UTF-8/, "and says why"); + assert.equal( + md5(readFileSync(`${root}/logo.png`)), + md5(before), + "the file is byte-for-byte what it was", + ); + // `git diff --cached --name-only` lists an UNMERGED path regardless, so it + // cannot answer "was anything staged". The stages themselves can: while + // they are there, nothing has been resolved. + assert.notEqual(git("ls-files", "-u", "--", "logo.png").trim(), "", "and it is still conflicted, not resolved"); + + // The escape hatch still works, and git moves the bytes. + const taken = await b.conflictTakeSide({ path: "logo.png", side: "theirs" }); + assert.equal(taken.ok, true, "Take theirs resolves it"); + assert.equal( + readFileSync(`${root}/logo.png`).subarray(0, 8).toString("hex"), + "89504e470d0a1a0a", + "with an intact PNG header", + ); + } finally { + removeTempRepo(root); + } +}); + +test("ordinary text conflicts still resolve", async () => { + const { root, git } = repo("text"); + try { + conflict(root, git, "f.txt", (side) => writeFileSync(`${root}/f.txt`, `${side}\n`)); + + const repos = new RepoStore([]); + await repos.open(root); + const b = new GitBridge(repos); + const r = await b.conflictResolve({ path: "f.txt", content: "ours and theirs\n" }); + + assert.equal(r.ok, true, "the guard must not refuse the ordinary case"); + assert.equal(readFileSync(`${root}/f.txt`, "utf8"), "ours and theirs\n"); + assert.deepEqual(await b.conflictList(), [], "and the conflict is gone"); + } finally { + removeTempRepo(root); + } +}); + +/** + * The lexical containment check cannot see a symlinked PARENT: `resolve(root, + * "dir/x.txt")` stays under the root as a string while `dir` points anywhere. + * The write lands before `git add` gets a chance to refuse. + */ +test("a path whose parent directory is a symlink out of the repo is refused", async () => { + const { root, git } = repo("parent"); + const outside = mkdtempSync(`${tmpdir()}/gs-cw-parentout-`); + try { + mkdirSync(`${outside}/real`); + writeFileSync(`${outside}/real/x.txt`, "OUTSIDE\n"); + writeFileSync(`${root}/f.txt`, "base\n"); + git("add", "-A"); + git("commit", "-qm", "base"); + symlinkSync(`${outside}/real`, `${root}/dir`); + + const repos = new RepoStore([]); + await repos.open(root); + const r = await new GitBridge(repos).conflictResolve({ path: "dir/x.txt", content: "PWNED\n" }); + + assert.equal(r.ok, false, "refused"); + assert.equal(readFileSync(`${outside}/real/x.txt`, "utf8"), "OUTSIDE\n", "and nothing was written"); + } finally { + removeTempRepo(root); + removeTempRepo(outside); + } +}); + +/** + * Non-ASCII filenames. + * + * `ls-files` without `-z` honours `core.quotePath`, which defaults to true, so + * it C-quotes every path outside ASCII: `"caf\303\251.txt"`. The renderer sends + * the RAW path — it comes from `status --porcelain=v2 -z` — so an exact + * comparison against the quoted form never matched, "no stages found" fell into + * the branch that runs `git rm`, and Take ours DELETED the file and staged the + * deletion while reporting "Took your version." + * + * Measured before the fix: of six files conflicting in one merge, the four with + * non-ASCII names were destroyed and the two ASCII ones resolved correctly. + */ +test("a conflicted file with a non-ASCII name resolves, and is not deleted", async () => { + const names = ["plain.txt", "café.txt", "emoji🎉.md", "日本語.md", "sub dir/ünï.ts"]; + const { root, git } = repo("unicode"); + try { + execFileSync("mkdir", ["-p", `${root}/sub dir`]); + const writeAll = (side: string): void => { + for (const n of names) writeFileSync(`${root}/${n}`, `${side} ${n}\n`); + }; + writeAll("base"); + git("add", "-A"); + git("commit", "-qm", "base"); + const main = git("rev-parse", "--abbrev-ref", "HEAD").trim(); + git("checkout", "-qb", "side"); + writeAll("theirs"); + git("add", "-A"); + git("commit", "-qm", "theirs"); + git("checkout", "-q", main); + writeAll("ours"); + git("add", "-A"); + git("commit", "-qm", "ours"); + try { + execFileSync("git", ["merge", "side"], { cwd: root, stdio: "ignore" }); + } catch { + /* the conflict is the point */ + } + + const repos = new RepoStore([]); + await repos.open(root); + const b = new GitBridge(repos); + assert.deepEqual([...(await b.conflictList())].sort(), [...names].sort(), "all six are conflicted"); + + for (const n of names) { + const r = await b.conflictTakeSide({ path: n, side: "ours" }); + assert.equal(r.ok, true, `${n}: Take ours succeeds — ${r.message ?? ""}`); + assert.equal( + readFileSync(`${root}/${n}`, "utf8"), + `ours ${n}\n`, + `${n}: the file is still there, with OUR side in it`, + ); + } + assert.deepEqual(await b.conflictList(), [], "and every conflict is resolved"); + assert.equal( + git("diff", "--cached", "--name-only", "--diff-filter=D").trim(), + "", + "with nothing staged as a deletion", + ); + } finally { + removeTempRepo(root); + } +}); + +/** + * And when the listing cannot be read at all, the answer is a refusal — not a + * deletion. `!present.has(stage)` could not tell "the other side deleted it" + * from "I did not find this path", and answered both by running `git rm`. That + * made destruction the default outcome of not understanding the input, which is + * precisely how the C-quoting bug above destroyed four files. + */ +test("a path the conflict listing does not mention is refused, not deleted", async () => { + const { root, git } = repo("unknown"); + try { + conflict(root, git, "f.txt", (side) => writeFileSync(`${root}/f.txt`, `${side}\n`)); + writeFileSync(`${root}/bystander.txt`, "not conflicted at all\n"); + + const repos = new RepoStore([]); + await repos.open(root); + const r = await new GitBridge(repos).conflictTakeSide({ path: "bystander.txt", side: "theirs" }); + + assert.equal(r.ok, false, "refused"); + assert.equal(r.expected, true, "as a condition, not a crash to report"); + assert.equal( + readFileSync(`${root}/bystander.txt`, "utf8"), + "not conflicted at all\n", + "and the file is still there", + ); + } finally { + removeTempRepo(root); + } +}); + +/** + * A file that is no longer conflicted. + * + * The probe that decides between write and delete used to sit inside + * `if (stdout.trim())`, so an EMPTY listing — the conflict was resolved by a + * watcher tick, another window, or a terminal — skipped it entirely and fell + * through to a precondition-free `git checkout --ours/--theirs`, which happily + * overwrites a file with no conflict left and reports "Took your version." + */ +test("taking a side on a file that is no longer conflicted changes nothing", async () => { + const { root, git } = repo("resolved"); + try { + conflict(root, git, "f.txt", (side) => writeFileSync(`${root}/f.txt`, `${side}\n`)); + + const repos = new RepoStore([]); + await repos.open(root); + const b = new GitBridge(repos); + + // Resolved out from under the view, the way a terminal or a second window + // would do it. + writeFileSync(`${root}/f.txt`, "resolved elsewhere\n"); + git("add", "f.txt"); + assert.deepEqual(await b.conflictList(), [], "nothing is conflicted any more"); + + const r = await b.conflictTakeSide({ path: "f.txt", side: "theirs" }); + assert.equal(r.ok, false, "so taking a side is refused"); + assert.equal(r.expected, true, "as a condition, not a crash"); + assert.match(r.message ?? "", /no longer conflicted/i, "and says so"); + assert.equal( + readFileSync(`${root}/f.txt`, "utf8"), + "resolved elsewhere\n", + "with the resolution someone else made left alone", + ); + } finally { + removeTempRepo(root); + } +}); + +/** + * A both-deleted (DD) conflict. + * + * Neither side still has the file, so there is nothing to merge and nothing to + * write back. "Mark resolved" wrote the editor buffer anyway, RESURRECTING the + * file as a staged addition nobody asked for — and Discard afterwards reported + * success having changed nothing, because the file is not in HEAD to restore. + */ +test("a both-deleted conflict cannot be resolved by writing text into it", async () => { + const { root, git } = repo("dd"); + try { + // A rename/rename: both sides move the same file somewhere different, so + // the ORIGINAL path is left with only a stage 1 — git's `DD`, "both + // deleted". (Two plain deletions of one file merge cleanly and never + // produce an unmerged entry at all.) + writeFileSync(`${root}/doomed.txt`, "contents\n"); + git("add", "-A"); + git("commit", "-qm", "base"); + const main = git("rev-parse", "--abbrev-ref", "HEAD").trim(); + git("checkout", "-qb", "side"); + git("mv", "doomed.txt", "theirs.txt"); + git("commit", "-qm", "they rename it"); + git("checkout", "-q", main); + git("mv", "doomed.txt", "ours.txt"); + git("commit", "-qm", "we rename it elsewhere"); + try { + execFileSync("git", ["merge", "side"], { cwd: root, stdio: "ignore" }); + } catch { + /* the conflict is the point */ + } + + const repos = new RepoStore([]); + await repos.open(root); + const b = new GitBridge(repos); + const listed = await b.conflictList(); + assert.ok(listed.includes("doomed.txt"), `the original path is listed as conflicted (${listed.join(", ")})`); + + const r = await b.conflictResolve({ path: "doomed.txt", content: "resurrected!\n" }); + assert.equal(r.ok, false, "writing text into it is refused"); + assert.equal(r.expected, true, "as a condition"); + assert.match(r.message ?? "", /both sides deleted/i, "and says what happened"); + assert.equal(existsSync(`${root}/doomed.txt`), false, "the file is not resurrected"); + } finally { + removeTempRepo(root); + } +}); diff --git a/apps/desktop/test/cssTokens.test.ts b/apps/desktop/test/cssTokens.test.ts new file mode 100644 index 0000000..c0bbac6 --- /dev/null +++ b/apps/desktop/test/cssTokens.test.ts @@ -0,0 +1,76 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CSS = readFileSync(resolve(HERE, "../src/renderer/styles/app.css"), "utf8"); + +/** + * A `var(--name)` naming a token nothing declares is invisible: the property + * simply does not apply, and the element silently keeps whatever it inherited. + * + * This is not hypothetical. The staging checkbox — the entire staging model in + * that mode — asked for `accent-color: var(--accent)` when the token is + * `--gs-accent`, so the most-clicked control in the app rendered as a stock + * macOS blue tick in a purple app. The hunk rows beside it reached for + * `var(--hover)`, `var(--border)`, `var(--fg)` and `var(--fg-muted)`, none of + * which this stylesheet has ever declared, so they had no hover feedback and no + * left rule at all. Every one of those looked like a design choice. + * + * A fallback (`var(--x, red)`) is a deliberate opt-out and is allowed; a bare + * reference to a name nobody declares is the bug. + */ +test("every CSS variable is declared, or carries a fallback", () => { + // Strip comments first: this file documents the bug above by quoting the + // broken declaration, and a comment is not a use. + const css = CSS.replace(/\/\*[\s\S]*?\*\//g, ""); + const declared = new Set([...css.matchAll(/(--[a-zA-Z0-9-]+)\s*:/g)].map((m) => m[1])); + const bare = new Set( + [...css.matchAll(/var\(\s*(--[a-zA-Z0-9-]+)\s*\)/g)].map((m) => m[1]), + ); + const missing = [...bare].filter((name) => !declared.has(name)).sort(); + assert.deepEqual( + missing, + [], + `these tokens are used with no declaration and no fallback: ${missing.join(", ")}`, + ); +}); + +/** + * The shared packages speak a VS Code vocabulary the desktop has to supply. + * + * `packages/webview-ui/src/styles/tokens.css` derives its whole palette from + * `--vscode-*` names — in the extension the editor provides them; in the desktop + * nobody does. Undeclared, each resolves to `var(undefined)`, which is invalid + * at computed-value time: the property does not apply and the element silently + * keeps whatever it inherited. `--gs-amber` is + * `var(--vscode-gitDecoration-modifiedResourceForeground, var(--vscode-charts-yellow))`, + * and with neither declared a tag chip in the Commits graph rendered as bare + * body text in the light theme — no ink, no pill, nothing in the source saying + * why. + * + * This asserts the desktop declares every name the shared file consumes, so the + * next one added upstream fails here instead of quietly rendering as nothing. + */ +test("the desktop declares every --vscode-* the shared tokens consume", () => { + const shared = readFileSync( + resolve(HERE, "../../../packages/webview-ui/src/styles/tokens.css"), + "utf8", + ).replace(/\/\*[\s\S]*?\*\//g, ""); + const css = CSS.replace(/\/\*[\s\S]*?\*\//g, ""); + const consumed = new Set( + [...shared.matchAll(/var\(\s*(--vscode-[a-zA-Z0-9-]+)/g)].map((m) => m[1]), + ); + assert.ok(consumed.size > 10, `the shared file consumes VS Code names (${consumed.size})`); + const declared = new Set( + [...css.matchAll(/(--vscode-[a-zA-Z0-9-]+)\s*:/g)].map((m) => m[1]), + ); + const missing = [...consumed].filter((t) => !declared.has(t)).sort(); + assert.deepEqual( + missing, + [], + `the desktop never declares these, so they render as nothing: ${missing.join(", ")}`, + ); +}); diff --git a/apps/desktop/test/dashNamedPath.test.ts b/apps/desktop/test/dashNamedPath.test.ts new file mode 100644 index 0000000..f97506c --- /dev/null +++ b/apps/desktop/test/dashNamedPath.test.ts @@ -0,0 +1,159 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { writeFileSync, readFileSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { RepoStore } from "../src/main/repoStore"; +import { GitBridge, safePath, safeArg } from "../src/main/gitBridge"; +import { removeTempRepo } from "./tmpRepo"; + +/** + * A file whose name starts with "-" is an ordinary file. `-fix.patch`, + * `--generated/schema.json`, `-README` — none of them are unusual, and the + * conflict view listed them like any other. + * + * Every button it offered then refused them. The path guard was `safeArg`, the + * one written for REFS, which rejects a leading dash because a ref reaching git + * as a bare positional would be read as an option. A path never does: every + * call here passes it after `--`, where git has already stopped reading + * options. So the app listed a conflict and then answered "That value isn't a + * valid git reference" to every attempt to resolve it, with no other way out. + */ +test("a path guard accepts what a ref guard must not", () => { + assert.equal(safeArg("-fix.patch"), false, "a REF may not lead with a dash — git would read it as an option"); + assert.equal(safePath("-fix.patch"), true, "a PATH may: it is passed after `--`"); + assert.equal(safePath("--generated/schema.json"), true); + assert.equal(safePath(""), false, "empty is still refused"); + assert.equal(safePath("a\0b"), false, "and a NUL, which no filename can contain"); + assert.equal(safePath(undefined), false); +}); + +function conflicted(name: string): string { + const root = mkdtempSync(`${tmpdir()}/gs-dash-`); + const git = (...a: string[]): string => + execFileSync("git", a, { cwd: root, stdio: ["ignore", "pipe", "pipe"] }).toString(); + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + writeFileSync(`${root}/${name}`, "base\n"); + git("add", "-A"); + git("commit", "-qm", "base"); + const main = git("rev-parse", "--abbrev-ref", "HEAD").trim(); + git("checkout", "-qb", "side"); + writeFileSync(`${root}/${name}`, "theirs\n"); + git("commit", "-qam", "theirs"); + git("checkout", "-q", main); + writeFileSync(`${root}/${name}`, "ours\n"); + git("commit", "-qam", "ours"); + try { + execFileSync("git", ["merge", "side"], { cwd: root, stdio: "ignore" }); + } catch { + /* the conflict is the point */ + } + return root; +} + +test("a conflicted file whose name starts with a dash can be resolved", async () => { + const name = "-fix.patch"; + const root = conflicted(name); + try { + const repos = new RepoStore([]); + await repos.open(root); + const b = new GitBridge(repos); + + assert.deepEqual(await b.conflictList(), [name], "the view lists it"); + + const taken = await b.conflictTakeSide({ path: name, side: "theirs" }); + assert.equal(taken.ok, true, "and taking a side works, instead of 'not a valid git reference'"); + assert.equal(readFileSync(`${root}/${name}`, "utf8"), "theirs\n", "with the chosen side on disk"); + } finally { + removeTempRepo(root); + } +}); + +test("a merged result can be written back to a dash-named file", async () => { + const name = "-fix.patch"; + const root = conflicted(name); + try { + const repos = new RepoStore([]); + await repos.open(root); + const b = new GitBridge(repos); + + const r = await b.conflictResolve({ path: name, content: "ours\ntheirs\n" }); + assert.equal(r.ok, true, "the three-pane resolution saves"); + assert.equal(readFileSync(`${root}/${name}`, "utf8"), "ours\ntheirs\n"); + assert.deepEqual(await b.conflictList(), [], "and the conflict is gone"); + } finally { + removeTempRepo(root); + } +}); + +test("a path that escapes the repository is still refused", async () => { + const root = conflicted("-fix.patch"); + try { + const repos = new RepoStore([]); + await repos.open(root); + const r = await new GitBridge(repos).conflictResolve({ path: "../escaped.txt", content: "x" }); + assert.equal(r.ok, false, "loosening the guard for dashes must not loosen containment"); + assert.match(r.message ?? "", /escapes the repository/i); + } finally { + removeTempRepo(root); + } +}); + +/** + * `conflictTakeSide` probes the index to learn which sides of a conflict exist, + * because that decides whether "Take theirs" WRITES a file or DELETES one. It + * asked with a pathspec, and a pathspec is glob-capable — a filename like + * `[id].tsx`, ordinary in every Next.js and SvelteKit app, is a character class + * if it is ever read as one. + * + * Measured on git 2.49 it matches literally in all three pathspec modes, so + * this is coverage of a real surface rather than a reproduction of a live bug. + * The probe now compares paths itself and does not depend on git's + * literal-vs-glob precedence, which is steerable from the environment. + */ +test("a modify/delete conflict on a filename that could be read as a glob", async () => { + const name = "[id].tsx"; + const root = mkdtempSync(`${tmpdir()}/gs-glob-`); + try { + const git = (...a: string[]): string => + execFileSync("git", a, { cwd: root, stdio: ["ignore", "pipe", "pipe"] }).toString(); + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + writeFileSync(`${root}/${name}`, "base\n"); + writeFileSync(`${root}/i`, "an innocent bystander\n"); + git("add", "-A"); + git("commit", "-qm", "base"); + const main = git("rev-parse", "--abbrev-ref", "HEAD").trim(); + + // theirs DELETES it; ours edits it — a modify/delete, where the probe's + // answer decides whether Take theirs removes the file or errors out. + git("checkout", "-qb", "side"); + git("rm", "-q", "--", name); + git("commit", "-qm", "theirs deletes it"); + git("checkout", "-q", main); + writeFileSync(`${root}/${name}`, "ours\n"); + git("commit", "-qam", "ours edits it"); + try { + execFileSync("git", ["merge", "side"], { cwd: root, stdio: "ignore" }); + } catch { + /* the conflict is the point */ + } + + const repos = new RepoStore([]); + await repos.open(root); + const b = new GitBridge(repos); + assert.deepEqual(await b.conflictList(), [name], "the view lists it"); + + const r = await b.conflictTakeSide({ path: name, side: "theirs" }); + assert.equal(r.ok, true, `Take theirs works — ${r.message ?? ""}`); + assert.deepEqual(await b.conflictList(), [], "the conflict is resolved"); + assert.equal(readFileSync(`${root}/i`, "utf8"), "an innocent bystander\n", "and the sibling is untouched"); + } finally { + removeTempRepo(root); + } +}); diff --git a/apps/desktop/test/defaultBranch.test.ts b/apps/desktop/test/defaultBranch.test.ts new file mode 100644 index 0000000..e46b1d7 --- /dev/null +++ b/apps/desktop/test/defaultBranch.test.ts @@ -0,0 +1,150 @@ +// Which branch the app thinks is the default one — against real git. +// +// This single value decides `merged` on every branch (`%(ahead-behind:<it>)` +// with ahead === 0), which decides the "Merged" pill, the Merged facet, and +// what "Delete N finished…" offers to delete. It was read with +// +// git symbolic-ref --short refs/remotes/origin/HEAD +// +// which fails outright in a clone whose remote is not called origin — +// `git clone -o upstream`, a `git remote rename`, a fork workflow. The fallback +// underneath it is the CURRENTLY CHECKED OUT branch, so in such a clone every +// ancestor of HEAD read as merged, and a bulk delete was measured against a ref +// nobody chose. +// +// Driven through the bridge's own public read (`branches:list` sets `merged` +// from it) so the test exercises the shipping path, not a copy of the parsing. + +import { test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { GitContext } from "@gitstudio/git-service/index"; +import { GitBridge } from "../src/main/gitBridge"; +import type { RepoStore } from "../src/main/repoStore"; +import { removeTempRepo } from "./tmpRepo"; + +let origin: string; +let clone: string; + +const env = { ...process.env, GIT_OPTIONAL_LOCKS: "0" }; +const run = (cwd: string, ...a: string[]): string => + execFileSync("git", a, { cwd, encoding: "utf8", env }); + +/** A repo with `main` and a `feature` branch already merged into it. */ +function makeOrigin(): string { + const dir = mkdtempSync(join(tmpdir(), "gitstudio-defbranch-src-")); + execFileSync("git", ["-c", "init.defaultBranch=main", "init", dir], { env }); + run(dir, "config", "user.email", "dev@example.com"); + run(dir, "config", "user.name", "Dev"); + run(dir, "config", "gc.auto", "0"); + writeFileSync(join(dir, "f.txt"), "one\n"); + run(dir, "add", "."); + run(dir, "commit", "-m", "first"); + return dir; +} + +/** Clone it with the remote named `remoteName`, and check out a feature branch + * so the default branch is present WITHOUT being the current one — the only + * state in which any of this is observable. */ +function makeClone(remoteName: string): string { + const dir = mkdtempSync(join(tmpdir(), "gitstudio-defbranch-")); + execFileSync("git", ["clone", "-o", remoteName, "-q", origin, dir], { env }); + run(dir, "config", "user.email", "dev@example.com"); + run(dir, "config", "user.name", "Dev"); + run(dir, "config", "gc.auto", "0"); + run(dir, "checkout", "-q", "-b", "work"); + writeFileSync(join(dir, "g.txt"), "work\n"); + run(dir, "add", "."); + run(dir, "commit", "-m", "work in progress"); + return dir; +} + +const bridgeFor = (dir: string): GitBridge => { + const ctx = new GitContext({ root: dir }); + return new GitBridge({ getContext: () => ctx } as unknown as RepoStore); +}; + +beforeEach(() => { + origin = makeOrigin(); +}); + +afterEach(() => { + removeTempRepo(clone); + removeTempRepo(origin); + clone = ""; +}); + +test("a normal clone: main is the default branch and is not 'merged'", async () => { + clone = makeClone("origin"); + const branches = await bridgeFor(clone).branchesList(); + const main = branches.find((b) => b.name === "main"); + assert.ok(main, `main is missing from ${branches.map((b) => b.name).join(", ")}`); + // main measured against main is zero ahead, which is what `merged` reads — + // the flag is set, and the VIEW is what must exclude the default branch. The + // point of this test is the next one: that the name matches so it can. + assert.equal(main.merged, true); + const work = branches.find((b) => b.name === "work"); + assert.equal(work?.merged, false, "a branch with a commit of its own is not merged"); +}); + +test("a clone whose remote is not called origin still finds the default branch", async () => { + // `git clone -o upstream`. The old read asked refs/remotes/origin/HEAD, which + // does not exist here, and fell through to the CURRENT branch — so `work` + // became "the default branch" and every commit already in `work` counted as + // merged. + clone = makeClone("upstream"); + assert.match( + run(clone, "for-each-ref", "--format=%(refname:short)|%(symref:short)", "refs/remotes/*/HEAD").trim(), + /^upstream\|upstream\/main$/, + "the fixture is not the fork clone this test is about", + ); + const branches = await bridgeFor(clone).branchesList(); + const names = branches.map((b) => b.name).sort(); + assert.deepEqual(names, ["main", "work"]); + + // The decisive assertion: `work` — the branch you are standing on, one commit + // ahead of main — must not read as merged. It did, because divergence was + // measured against `work` itself. + const work = branches.find((b) => b.name === "work"); + assert.equal(work?.merged, false, "the current branch was measured against itself"); + assert.equal(work?.aheadDefault, 1, "one commit ahead of the real default branch"); + + // And main is found by the name a local branch actually has: "main", not + // "upstream/main", which is what stripping a literal "origin/" left behind. + // + // Verified against real git in this exact clone: + // main ahead-behind=0 0 + // work ahead-behind=1 0 + const main = branches.find((b) => b.name === "main"); + assert.equal(main?.aheadDefault, 0, "main is zero ahead of itself"); + assert.equal(main?.behindDefault, 0, "and zero behind itself"); +}); + +test("a renamed remote is the same case, and is the common one", async () => { + // The workflow people actually run: clone, then rename origin to upstream and + // add your own fork as origin later. + clone = makeClone("origin"); + run(clone, "remote", "rename", "origin", "upstream"); + const branches = await bridgeFor(clone).branchesList(); + const work = branches.find((b) => b.name === "work"); + assert.equal(work?.merged, false); + assert.equal(work?.aheadDefault, 1); +}); + +test("no remote at all falls back to the checked-out branch, without throwing", async () => { + // A local-only repo has no refs/remotes/*/HEAD to read. There is no default + // branch to find, and the list must still come back. + clone = mkdtempSync(join(tmpdir(), "gitstudio-defbranch-local-")); + execFileSync("git", ["-c", "init.defaultBranch=main", "init", clone], { env }); + run(clone, "config", "user.email", "dev@example.com"); + run(clone, "config", "user.name", "Dev"); + run(clone, "config", "gc.auto", "0"); + writeFileSync(join(clone, "f.txt"), "one\n"); + run(clone, "add", "."); + run(clone, "commit", "-m", "first"); + const branches = await bridgeFor(clone).branchesList(); + assert.deepEqual(branches.map((b) => b.name), ["main"]); +}); diff --git a/apps/desktop/test/destructiveGuards.test.ts b/apps/desktop/test/destructiveGuards.test.ts new file mode 100644 index 0000000..0296a97 --- /dev/null +++ b/apps/desktop/test/destructiveGuards.test.ts @@ -0,0 +1,110 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readdir, readFile } from "node:fs/promises"; +import { join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Every destructive control the renderer offers must be hard to fire twice. + * + * `serialize()` in the main process QUEUES a second call rather than dropping + * it — its own comment names "a double-clicked Stage" as the thing it exists + * for — so a button that stays enabled through its round trip really does run + * the command twice. Measured on the banner's new "Skip this patch": two clicks + * discarded two patches from a series the app cannot replay, with the only + * feedback an INFO toast that looked identical between them. + * + * A census, not an engine test, for the same reason the arg-guard census + * exists: the mechanisms are all present and correct, and the defect is a call + * site that does not use one. + * + * Two shapes count as a guard, and they defend different things: + * + * · A CONFIRM DIALOG. A second click lands on the dialog's scrim, not on the + * button, so the double-click race cannot happen at all. + * · DISABLING IN FLIGHT — `disabled = true`, an `is-busy` class, or + * `runBusy`. Necessary where there is no dialog to absorb the second press. + * + * A call site with neither must be listed in REVIEWED with the reason it is + * safe. "It's only one click" is not a reason. + */ +const ROOT = fileURLToPath(new URL("../src/renderer", import.meta.url)); + +/** + * Channels whose second run destroys something the user cannot get back, or + * silently repeats an action against a DIFFERENT object (a positional stash + * index, the next patch in a series). + */ +const DESTRUCTIVE = + /"(stash:drop|branch:delete|branch:deleteRemote|gist:delete|release:delete|release:deleteAsset|actions:deleteSecret|actions:deleteVariable|ai:removeConnection|rebase:abort|merge:abort|cherryPick:abort|revert:abort|am:abort|rebase:skip|cherryPick:skip|revert:skip|am:skip|git:discard|discard:all|reset:hard|commit:reset)"/; + +/** Anything in the enclosing lines that makes a second press harmless. */ +const GUARD = /confirmDialog|confirmDanger|requireTyped|disabled = true|is-busy|runBusy|refreshInPlace/; + +/** Call sites reviewed and found safe without either guard. */ +const REVIEWED: Record<string, string> = { + // The Assistant's own tool-call path is driven by the model, not by a button + // a person can double-click. + "assistant.ts": "not user-triggered — the model calls these, one at a time", +}; + +async function tsFiles(dir: string): Promise<string[]> { + const out: string[] = []; + for (const e of await readdir(dir, { withFileTypes: true })) { + const p = join(dir, e.name); + if (e.isDirectory()) out.push(...(await tsFiles(p))); + else if (e.name.endsWith(".ts")) out.push(p); + } + return out; +} + +/** How many lines above an invoke a guard may sit and still be guarding it. */ +const WINDOW = 18; + +test("every destructive control is confirm-gated or disabled in flight", async () => { + const unguarded: string[] = []; + for (const file of await tsFiles(ROOT)) { + const rel = relative(ROOT, file); + if (REVIEWED[rel]) continue; + const lines = (await readFile(file, "utf8")).split("\n"); + lines.forEach((line, i) => { + if (!DESTRUCTIVE.test(line) || !/invoke\(/.test(line)) return; + // The guard usually sits above the call — inside the `.then()` of a + // confirm, or on the button that triggered it. + const near = lines.slice(Math.max(0, i - WINDOW), i + 3).join("\n"); + if (GUARD.test(near)) return; + unguarded.push(`${rel}:${i + 1} ${line.trim().slice(0, 90)}`); + }); + } + assert.deepEqual( + unguarded, + [], + "these destructive controls can be fired twice — `serialize()` queues the second call rather than " + + "dropping it, so the command really does run again. Add a confirm dialog or disable the control " + + "for the round trip, or add the file to REVIEWED with the reason it is safe:\n" + + unguarded.join("\n"), + ); +}); + +test("the census actually sees the controls it claims to check", async () => { + // A census that matches nothing passes forever. This is the arg-guard + // suite's own guard against itself, for the same reason. + let seen = 0; + for (const file of await tsFiles(ROOT)) { + const text = await readFile(file, "utf8"); + for (const line of text.split("\n")) { + if (DESTRUCTIVE.test(line) && /invoke\(/.test(line)) seen++; + } + } + assert.ok( + seen >= 8, + `expected to find the destructive call sites, found ${seen} — has the invoke style changed?`, + ); +}); + +test("the reviewed list has not gone stale", async () => { + const files = (await tsFiles(ROOT)).map((f) => relative(ROOT, f)); + for (const name of Object.keys(REVIEWED)) { + assert.ok(files.includes(name), `${name} is in REVIEWED but no longer exists`); + } +}); diff --git a/apps/desktop/test/diffSides.test.ts b/apps/desktop/test/diffSides.test.ts new file mode 100644 index 0000000..8f69957 --- /dev/null +++ b/apps/desktop/test/diffSides.test.ts @@ -0,0 +1,246 @@ +// Both sides of a diff must be read the same way. +// +// The Changes view builds its FileDiff from two different readers: the working +// tree through `readWorking`, and HEAD through a raw content read. The working +// side was capped at 512KB and classified as binary/text; the HEAD side was +// neither. Two consequences, both of which render as a confident, wrong diff: +// +// - a file LARGER than the cap that nobody has touched comes back with a full +// left side and a capped right side, so every line past the cap is a +// deletion. The app shows you the entire tail of a large file being removed +// by a change you did not make. +// - a file that is binary in HEAD is decoded as UTF-8 into the left pane. +// +// And in `readWorking` itself the size test ran ABOVE the binary tests, so +// anything binary and over the cap — a video, a PNG, a compiled bundle — never +// reached them: its first 512KB were decoded as text and mounted in an editor +// under a note reading "showing the first part of it". + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { writeFileSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { RepoStore } from "../src/main/repoStore"; +import { GitBridge } from "../src/main/gitBridge"; +import { removeTempRepo } from "./tmpRepo"; + +const CAP = 512 * 1024; + +function repo(): { root: string; git: (...a: string[]) => string } { + const root = mkdtempSync(join(tmpdir(), "gs-diff-sides-")); + const git = (...a: string[]): string => + execFileSync("git", a, { cwd: root, encoding: "utf8" }); + execFileSync("git", ["-c", "init.defaultBranch=main", "init", root]); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + return { root, git }; +} + +async function bridge(root: string): Promise<GitBridge> { + const repos = new RepoStore([]); + await repos.open(root); + return new GitBridge(repos); +} + +test("a file over the cap does not render its tail as a deletion", async () => { + const { root, git } = repo(); + try { + // Comfortably over the cap, and identical in HEAD and on disk apart from + // one edit near the very top — so every difference past the cap is + // manufactured, not real. + const big = "a line of perfectly ordinary text\n".repeat(30_000); + assert.ok(big.length > CAP, "the fixture must actually exceed the cap"); + writeFileSync(join(root, "big.txt"), big); + git("add", "-A"); + git("commit", "-qm", "big"); + writeFileSync(join(root, "big.txt"), `EDITED\n${big}`); + + const b = await bridge(root); + const d = await b.fileDiff({ path: "big.txt" }); + + assert.equal(d.truncated, true, "the reader says it only read part of the file"); + // The decisive property: both sides stop at the same place. With HEAD + // uncapped, leftText was the whole 1MB and rightText was 512KB, so the + // editor drew ~15,000 deleted lines for an edit of one. + assert.ok( + Math.abs(d.leftText.length - d.rightText.length) < 4096, + `both sides are read to the same length (left ${d.leftText.length}, right ${d.rightText.length})`, + ); + assert.ok(d.leftText.length <= CAP + 4096, "and the left side really was capped"); + } finally { + removeTempRepo(root); + } +}); + +test("a binary file bigger than the cap is binary, not truncated", async () => { + const { root, git } = repo(); + try { + // A NUL in the first bytes, then padding well past the cap. `git` needs it + // committed so the diff has a left side too. + const bin = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x1a, 0x0a]), + Buffer.alloc(CAP + 50_000, 0xd8), + ]); + writeFileSync(join(root, "art.png"), bin); + git("add", "-A"); + git("commit", "-qm", "art"); + writeFileSync(join(root, "art.png"), Buffer.concat([bin, Buffer.alloc(64, 0x7f)])); + + const b = await bridge(root); + const d = await b.fileDiff({ path: "art.png" }); + + assert.equal(d.binary, true, "it is reported as binary"); + assert.equal(d.rightText, "", "and no decoded bytes are handed to the editor"); + // Not "too large to show in full" — that note invites the reader to trust + // the part they can see, and there is no part they can see. + assert.notEqual(d.truncated, true, "a binary is not a truncated text file"); + } finally { + removeTempRepo(root); + } +}); + +test("a file that is binary in HEAD is not decoded into the left pane", async () => { + const { root, git } = repo(); + try { + writeFileSync(join(root, "f.dat"), Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe, 0x00])); + git("add", "-A"); + git("commit", "-qm", "binary"); + // Replaced with ordinary text: the working side classifies clean, so only + // the HEAD side can catch this. + writeFileSync(join(root, "f.dat"), "now it is text\n"); + + const b = await bridge(root); + const d = await b.fileDiff({ path: "f.dat" }); + + assert.equal(d.binary, true, "the HEAD side's kind counts too"); + assert.equal(d.leftText, "", "and its bytes are not decoded into the pane"); + } finally { + removeTempRepo(root); + } +}); + +test("an ordinary small edit is still an ordinary diff", async () => { + // The guard above must not make every diff claim to be capped or binary. + const { root, git } = repo(); + try { + writeFileSync(join(root, "a.txt"), "one\ntwo\n"); + git("add", "-A"); + git("commit", "-qm", "a"); + writeFileSync(join(root, "a.txt"), "one\ntwo edited\n"); + + const b = await bridge(root); + const d = await b.fileDiff({ path: "a.txt" }); + + assert.notEqual(d.binary, true); + assert.notEqual(d.truncated, true); + assert.equal(d.leftText, "one\ntwo\n"); + assert.equal(d.rightText, "one\ntwo edited\n"); + } finally { + removeTempRepo(root); + } +}); + +test("both sides are capped by the same ruler", async () => { + // `showAt` cut a JS STRING at FILE_CAP_BYTES — which counts UTF-16 code + // units — while `readWorking` cut a Buffer at that many actual BYTES. On any + // file that is not pure ASCII the two sides of one diff were therefore cut at + // different points in the file, and the gap between those points rendered as + // a change in a region nobody had touched. + const { root, git } = repo(); + try { + // Three bytes per character, so the two rulers disagree by a factor of ~3. + const line = "日本語のテキストが一行ずつ並んでいます\n"; + const big = line.repeat(20_000); + // Over the cap in BYTES and under it in CODE UNITS — the sharpest form of + // the mismatch. The old `showAt` measured code units, so it did not + // truncate at all, while `readWorking` measured bytes and did: the left + // pane held the whole file and the right one stopped a third of the way in, + // so two thirds of an untouched file rendered as deleted lines. + assert.ok(Buffer.byteLength(big, "utf8") > CAP, "the fixture exceeds the cap in bytes"); + assert.ok(big.length < CAP, "and does NOT exceed it in code units"); + writeFileSync(join(root, "jp.txt"), big); + git("add", "-A"); + git("commit", "-qm", "jp"); + // One edit at the very top; everything past it is identical. + writeFileSync(join(root, "jp.txt"), `EDITED\n${big}`); + + const b = await bridge(root); + const d = await b.fileDiff({ path: "jp.txt" }); + assert.equal(d.truncated, true); + // The decisive property: cut at the same byte offset, the two sides differ + // only by the line that was actually added. + assert.ok( + Math.abs(Buffer.byteLength(d.leftText, "utf8") - Buffer.byteLength(d.rightText, "utf8")) < 64, + `both sides stop at the same BYTE (left ${Buffer.byteLength(d.leftText, "utf8")}, right ${Buffer.byteLength(d.rightText, "utf8")})`, + ); + } finally { + removeTempRepo(root); + } +}); + +test("an added binary and a deleted one are told apart", async () => { + // Both sides of a binary FileDiff are empty by construction — the producer + // refuses to decode it — so nothing downstream could tell an added image from + // a deleted one, and all three cases read "Its contents changed." + const png = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00]), Buffer.alloc(32, 7)]); + const { root, git } = repo(); + try { + writeFileSync(join(root, "keep.txt"), "so the repo has a commit\n"); + git("add", "-A"); + git("commit", "-qm", "base"); + + // ADDED: on disk, not in HEAD. + writeFileSync(join(root, "new.png"), png); + const b = await bridge(root); + const added = await b.fileDiff({ path: "new.png" }); + assert.equal(added.binary, true); + assert.equal(added.onlySide, "added", "an added binary says so"); + + // DELETED: in HEAD, not on disk. + writeFileSync(join(root, "old.png"), png); + git("add", "-A"); + git("commit", "-qm", "add old.png"); + execFileSync("git", ["rm", "-q", "old.png"], { cwd: root }); + const removed = await b.fileDiff({ path: "old.png" }); + assert.equal(removed.onlySide, "deleted", "a deleted binary says so"); + + // CHANGED: on both sides — neither label applies. + writeFileSync(join(root, "same.png"), png); + git("add", "-A"); + git("commit", "-qm", "add same.png"); + writeFileSync(join(root, "same.png"), Buffer.concat([png, Buffer.alloc(8, 9)])); + const edited = await b.fileDiff({ path: "same.png" }); + assert.equal(edited.binary, true); + assert.equal(edited.onlySide, undefined, "an edited binary claims neither"); + } finally { + removeTempRepo(root); + } +}); + +test("a deleted EMPTY tracked file is not called a new file", async () => { + // Two different things arrive with both sides empty and the path gone from + // disk: a file added to the index and then removed (git's `AD`), and a + // tracked file that was empty in HEAD and has now been deleted. Only the + // first is "staged as a new file" — telling someone the second about a file + // they committed weeks ago says their commit never happened. + const { root, git } = repo(); + try { + writeFileSync(join(root, "empty.txt"), ""); + git("add", "-A"); + git("commit", "-qm", "add an empty file"); + execFileSync("git", ["rm", "-q", "empty.txt"], { cwd: root }); + + const b = await bridge(root); + const d = await b.fileDiff({ path: "empty.txt" }); + assert.equal(d.leftText, "", "HEAD's copy was empty"); + assert.equal(d.rightText, "", "and it is not on disk"); + assert.equal(d.deleted, true, "the producer says it is gone"); + // The flag that tells the two apart. + assert.equal(d.onlySide, "deleted", "and that it EXISTED before, so it is a deletion"); + } finally { + removeTempRepo(root); + } +}); diff --git a/apps/desktop/test/exploreRoutes.test.ts b/apps/desktop/test/exploreRoutes.test.ts new file mode 100644 index 0000000..a60cffa --- /dev/null +++ b/apps/desktop/test/exploreRoutes.test.ts @@ -0,0 +1,99 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + parseAccountTarget, + parseExploreTarget, + parseRepoRoute, + repoRouteId, + searchTargetId, +} from "../src/renderer/exploreRoutes"; + +// Explore states are `target.id` micro-paths so ⌘[ walks the trail without +// widening SectionTarget. That makes the parser load-bearing for navigation: +// a wrong parse silently strands the user on the wrong page. + +test("a bare repo target parses to the root at the default branch", () => { + assert.deepEqual(parseRepoRoute("repo/acme/widgets"), { + fullName: "acme/widgets", + kind: "tree", + ref: undefined, + path: "", + }); +}); + +test("a tree target carries the ref and path", () => { + assert.deepEqual(parseRepoRoute("repo/acme/widgets/tree/main/src/renderer"), { + fullName: "acme/widgets", + kind: "tree", + ref: "main", + path: "src/renderer", + }); +}); + +test("a blob target is distinguished from a tree", () => { + const r = parseRepoRoute("repo/acme/widgets/blob/main/src/index.ts"); + assert.equal(r?.kind, "blob"); + assert.equal(r?.path, "src/index.ts"); +}); + +test("the HEAD sentinel parses back to 'the default branch', never a pinned ref", () => { + // Walking into a file from the root writes HEAD; if that came back as a real + // ref, the switcher would relabel itself and pin the branch behind the user. + const r = parseRepoRoute("repo/acme/widgets/blob/HEAD/README.md"); + assert.equal(r?.ref, undefined); + assert.equal(r?.path, "README.md"); +}); + +test("a ref containing a slash survives the round trip", () => { + const id = repoRouteId({ fullName: "acme/widgets", ref: "release/1.2", path: "src/a.ts", kind: "blob" }); + const r = parseRepoRoute(id); + assert.equal(r?.ref, "release/1.2"); + assert.equal(r?.path, "src/a.ts"); + assert.equal(r?.kind, "blob"); +}); + +test("round-tripping the root produces the bare form", () => { + assert.equal(repoRouteId({ fullName: "acme/widgets" }), "repo/acme/widgets"); +}); + +test("non-repo ids are ignored rather than throwing", () => { + assert.equal(parseRepoRoute(undefined), undefined); + assert.equal(parseRepoRoute("q/repos/git"), undefined); + assert.equal(parseRepoRoute("user/anton"), undefined); + assert.equal(parseRepoRoute("repo/onlyowner"), undefined); +}); + +// ── accounts ───────────────────────────────────────────────────────────────── + +test("user and org targets both resolve to a login", () => { + assert.deepEqual(parseAccountTarget("user/anton"), { login: "anton" }); + assert.deepEqual(parseAccountTarget("org/GitStudioHQ"), { login: "GitStudioHQ" }); +}); + +test("an account target with extra path segments is not an account page", () => { + assert.equal(parseAccountTarget("user/anton/repos"), undefined); + assert.equal(parseAccountTarget("repo/acme/widgets"), undefined); + assert.equal(parseAccountTarget(undefined), undefined); +}); + +// ── searches ───────────────────────────────────────────────────────────────── + +test("a search target round-trips, tab and all", () => { + const id = searchTargetId("code", "createLogPane"); + assert.deepEqual(parseExploreTarget(id), { tab: "code", query: "createLogPane" }); +}); + +test("a query containing slashes and spaces survives", () => { + const id = searchTargetId("repos", "org:acme path:src/renderer"); + assert.deepEqual(parseExploreTarget(id), { tab: "repos", query: "org:acme path:src/renderer" }); +}); + +test("an unknown tab is not a search target", () => { + assert.equal(parseExploreTarget("q/wat/hello"), undefined); + assert.equal(parseExploreTarget("repo/acme/widgets"), undefined); + assert.equal(parseExploreTarget(undefined), undefined); +}); + +test("an empty query still parses (the page shows its start state)", () => { + assert.deepEqual(parseExploreTarget("q/repos/"), { tab: "repos", query: "" }); +}); diff --git a/apps/desktop/test/facets.test.ts b/apps/desktop/test/facets.test.ts new file mode 100644 index 0000000..39614c2 --- /dev/null +++ b/apps/desktop/test/facets.test.ts @@ -0,0 +1,131 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + facetActiveCount, + facetPasses, + facetServerValues, + harvestValues, + type FacetSpec, +} from "../src/renderer/facetModel"; + +// facetBar itself is DOM-bound (verified in the headless harness); the pure +// pieces it stands on are pinned here — harvesting is what decides which +// options a menu can even offer. + +interface Row { + label?: string; + labels?: string[]; + author?: string | null; +} + +test("harvests distinct scalar values, sorted", () => { + const rows: Row[] = [{ author: "zoe" }, { author: "amy" }, { author: "zoe" }]; + assert.deepEqual( + harvestValues<Row>((r) => r.author)(rows).map((o) => o.value), + ["amy", "zoe"], + ); +}); + +test("harvests from array-valued fields (labels)", () => { + const rows: Row[] = [{ labels: ["bug", "ux"] }, { labels: ["ux"] }, {}]; + assert.deepEqual( + harvestValues<Row>((r) => r.labels)(rows).map((o) => o.value), + ["bug", "ux"], + ); +}); + +test("null, undefined and empty strings are skipped, never offered as options", () => { + const rows: Row[] = [{ author: null }, { author: "" }, { author: undefined }, { author: "amy" }]; + assert.deepEqual( + harvestValues<Row>((r) => r.author)(rows).map((o) => o.value), + ["amy"], + ); +}); + +test("an empty list harvests nothing (no phantom options)", () => { + assert.deepEqual(harvestValues<Row>((r) => r.author)([]), []); +}); + +test("an array containing empty entries drops only those entries", () => { + const rows: Row[] = [{ labels: ["", "bug"] }]; + assert.deepEqual( + harvestValues<Row>((r) => r.labels)(rows).map((o) => o.value), + ["bug"], + ); +}); + +// ── the client/server split ────────────────────────────────────────────────── +// +// The rule that matters: a facet WITHOUT a predicate is the server's job, and +// must never also filter locally — doing both would hide rows the server +// already excluded and quietly under-report. + +const SPECS: FacetSpec<Row>[] = [ + { + key: "author", + label: "Author", + icon: "account", + predicate: (r, v) => r.author === v, + }, + // No predicate ⇒ server-side. + { key: "branch", label: "Branch", icon: "git-branch" }, +]; + +test("a client facet filters locally", () => { + assert.equal(facetPasses(SPECS, { author: "amy" }, { author: "amy" }), true); + assert.equal(facetPasses(SPECS, { author: "amy" }, { author: "zoe" }), false); +}); + +test("a SERVER facet never filters locally, whatever its value", () => { + assert.equal(facetPasses(SPECS, { branch: "main" }, { author: "zoe" }), true); +}); + +test("client and server facets combine without the server one hiding rows", () => { + const state = { author: "amy", branch: "main" }; + assert.equal(facetPasses(SPECS, state, { author: "amy" }), true); + assert.equal(facetPasses(SPECS, state, { author: "zoe" }), false); +}); + +test("serverValues returns ONLY predicate-less facets", () => { + assert.deepEqual(facetServerValues(SPECS, { author: "amy", branch: "main" }), { branch: "main" }); +}); + +test("serverValues is empty when only client facets are set", () => { + assert.deepEqual(facetServerValues(SPECS, { author: "amy" }), {}); +}); + +test("activeCount counts both kinds — it drives the Clear button", () => { + assert.equal(facetActiveCount(SPECS, {}), 0); + assert.equal(facetActiveCount(SPECS, { author: "amy" }), 1); + assert.equal(facetActiveCount(SPECS, { author: "amy", branch: "main" }), 2); +}); + +test("an unset facet passes everything", () => { + assert.equal(facetPasses(SPECS, {}, { author: "anyone" }), true); +}); + +// ── humanized option labels ────────────────────────────────────────────────── +// +// A facet menu that lists raw API values ("subscribed", "PullRequest") beside +// rows that render humanized ones ("watching", "PR") never matches what the +// reader is looking at. The label mapper is what keeps the two in step. + +test("without a mapper the option label is the raw value", () => { + const rows: Row[] = [{ author: "review_requested" }]; + const opts = harvestValues<Row>((r) => r.author)(rows); + assert.deepEqual(opts, [{ value: "review_requested", label: undefined }]); +}); + +test("a mapper labels the option while the VALUE stays the API value", () => { + const rows: Row[] = [{ author: "review_requested" }]; + const [opt] = harvestValues<Row>((r) => r.author, (v) => v.replace(/_/g, " "))(rows); + assert.equal(opt.value, "review_requested", "the predicate still matches on the raw value"); + assert.equal(opt.label, "review requested"); +}); + +test("options sort by the LABEL, which is the order the reader sees", () => { + const rows: Row[] = [{ author: "zeta" }, { author: "alpha" }]; + const labels = { zeta: "Aardvark", alpha: "Zebra" } as Record<string, string>; + const opts = harvestValues<Row>((r) => r.author, (v) => labels[v])(rows); + assert.deepEqual(opts.map((o) => o.label), ["Aardvark", "Zebra"]); +}); diff --git a/apps/desktop/test/ghRepoOpen.test.ts b/apps/desktop/test/ghRepoOpen.test.ts new file mode 100644 index 0000000..f231503 --- /dev/null +++ b/apps/desktop/test/ghRepoOpen.test.ts @@ -0,0 +1,138 @@ +import { test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { openGitHubRepo } from "../src/main/ghRepoOpen"; +import type { RepoStore } from "../src/main/repoStore"; +import { removeTempRepo } from "./tmpRepo"; + +// The find-existing-clone half of "open owner/repo as a normal repo": an +// already-cloned repo (recents or the managed folder) must open INSTANTLY and +// never re-clone; a folder-name collision with a DIFFERENT project must +// refuse with a clear message instead of opening the wrong repo. + +let managed: string; +let repoA: string; +const opened: string[] = []; + +/** A RepoStore stand-in: records opens, reports repoA as a recent. */ +const store = { + recentRepos: () => [{ root: repoA, name: "gitstudio" }], + open: async (root: string) => { + opened.push(root); + return { root, name: root.split("/").pop()! }; + }, +} as unknown as RepoStore; + +function makeRepo(dir: string, origin?: string): void { + execFileSync("git", ["-c", "init.defaultBranch=main", "init", dir], { + env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, + }); + if (origin) { + execFileSync("git", ["-C", dir, "remote", "add", "origin", origin]); + } +} + +beforeEach(() => { + opened.length = 0; + managed = mkdtempSync(join(tmpdir(), "gitstudio-managed-")); + repoA = mkdtempSync(join(tmpdir(), "gitstudio-clonea-")); + makeRepo(repoA, "git@github.com:GitStudioHQ/gitstudio.git"); +}); + +afterEach(() => { + removeTempRepo(managed); + removeTempRepo(repoA); +}); + +test("a matching recent clone opens instantly, no clone", async () => { + const r = await openGitHubRepo("GitStudioHQ/gitstudio", store, () => {}, managed); + assert.equal(r.ok, true); + assert.equal(r.cloned, false); + assert.equal(r.root, repoA); + assert.deepEqual(opened, [repoA]); +}); + +test("matching is case-insensitive on owner/repo", async () => { + const r = await openGitHubRepo("gitstudiohq/GITSTUDIO", store, () => {}, managed); + assert.equal(r.ok, true); + assert.equal(r.root, repoA); +}); + +test("a managed-folder clone is found when recents don't have it", async () => { + const inManaged = join(managed, "other"); + makeRepo(inManaged, "https://github.com/acme/other.git"); + const r = await openGitHubRepo("acme/other", store, () => {}, managed); + assert.equal(r.ok, true); + assert.equal(r.cloned, false); + assert.equal(r.root, inManaged); +}); + +test("a recent repo with a DIFFERENT remote is skipped, never opened as an impostor", async () => { + // Both candidate folder names are taken by unrelated projects → the flow + // must refuse (message names the collision) rather than clone over them or + // open the wrong repo. This also proves the wrong-remote recents skip. + mkdirSync(join(managed, "elsewhere")); + mkdirSync(join(managed, "acme-elsewhere")); + const r = await openGitHubRepo("acme/elsewhere", store, () => {}, managed); + assert.equal(r.ok, false); + assert.match(r.message ?? "", /already exists/); + assert.deepEqual(opened, []); +}); + +// ── E1: destination control + structured codes ─────────────────────────────── + +test("a collision reports code 'collision'", async () => { + mkdirSync(join(managed, "elsewhere")); + mkdirSync(join(managed, "acme-elsewhere")); + const r = await openGitHubRepo("acme/elsewhere", store, () => {}, managed); + assert.equal(r.ok, false); + assert.equal(r.code, "collision"); +}); + +test("a garbage name reports code 'bad-name'", async () => { + const r = await openGitHubRepo("nonsense", store, () => {}, managed); + assert.equal(r.ok, false); + assert.equal(r.code, "bad-name"); +}); + +test("an explicit dest overrides the managed folder for discovery-miss clones", async () => { + // The clone itself will fail (no network in tests) — what matters is that + // the attempt happened in `dest`, proven by the code being clone-failed + // (the dest dir was created + no collision) rather than collision. + const dest = mkdtempSync(join(tmpdir(), "gitstudio-dest-")); + try { + // Occupy BOTH default candidate names in the managed dir: with dest + // honored, neither matters. + mkdirSync(join(managed, "elsewhere")); + mkdirSync(join(managed, "acme-elsewhere")); + const r = await openGitHubRepo( + "acme/elsewhere", + store, + () => {}, + managed, + dest, + ); + assert.equal(r.ok, false); + assert.equal(r.code, "clone-failed"); + } finally { + removeTempRepo(dest); + } +}); + +test("an explicit name override collides only on ITSELF (no owner-repo fallback)", async () => { + mkdirSync(join(managed, "mydir")); + const r = await openGitHubRepo( + "acme/elsewhere", + store, + () => {}, + managed, + undefined, + "mydir", + ); + assert.equal(r.ok, false); + assert.equal(r.code, "collision"); + assert.match(r.message ?? "", /mydir/); +}); diff --git a/apps/desktop/test/gitBridgeArgGuards.test.ts b/apps/desktop/test/gitBridgeArgGuards.test.ts new file mode 100644 index 0000000..d3644b1 --- /dev/null +++ b/apps/desktop/test/gitBridgeArgGuards.test.ts @@ -0,0 +1,151 @@ +// A structural guard over gitBridge.ts, in the same spirit as the stylesheet +// tests: it reads the source and asserts a property no behavioural test can. +// +// Every mutation on this bridge takes strings straight from the renderer and +// hands them to git. Git reads any argument beginning with "-" as an OPTION, so +// a ref named `--upload-pack=…` is not a ref at all. `safeArg` exists for this +// and is applied in two dozen places — but "applied in two dozen places" is not +// a property, it is a habit, and habits are what a new handler skips. +// +// So: every method here that returns a CommitActionResult must either guard its +// arguments, or appear below with a reason it does not need to. Adding a +// handler without doing one or the other fails this test. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SRC = readFileSync(join(HERE, "..", "src", "main", "gitBridge.ts"), "utf8"); + +/** + * Methods that pass no renderer string to git in a position git could read as + * an option. Each entry names WHY, so a future reader can re-check the claim + * instead of trusting the list. + */ +const REVIEWED: Record<string, string> = { + // Paths, always passed after a `--` separator by the git-service providers + // (`git add -- <rel>`, `git reset -q HEAD -- <rel>`), where a leading dash is + // a pathspec rather than a flag. + stage: "path goes after `--`", + unstage: "path goes after `--`", + discard: "path goes after `--`", + hunksStage: "path goes after `--`", + stageLines: "path goes after `--`", + // Free text passed as the VALUE of a flag (`-m <message>`), never as its own + // positional. + commit: "message is the value of -m", + stashSave: "message is the value of -m", + // No renderer-supplied string at all. + stageAll: "no arguments", + unstageAll: "no arguments", + syncFetch: "boolean options only", + syncPull: "no arguments", + syncPush: "boolean options only", + mergeAbort: "no arguments", + mergeContinue: "no arguments", + amAbort: "no arguments", + amSkip: "no arguments", + amContinue: "no arguments", + cherryPickAbort: "no arguments", + cherryPickSkip: "no arguments", + revertSkip: "no arguments", + cherryPickContinue: "no arguments", + revertAbort: "no arguments", + revertContinue: "no arguments", + rebaseAbort: "no arguments", + rebaseContinue: "no arguments", + rebaseSkip: "no arguments", +}; + +/** + * Every `name(args): Promise<CommitActionResult>` method and its body. + * + * Parsed by counting parentheses and braces rather than with one regex: several + * signatures here span multiple lines, and a lazy `[\s\S]*?` between the name + * and the return type happily runs THROUGH the next method to find one. + */ +function mutations(): Array<{ name: string; body: string }> { + const out: Array<{ name: string; body: string }> = []; + const head = /^ {2}(?:async )?(\w+)\(/gm; + let m: RegExpExecArray | null; + while ((m = head.exec(SRC))) { + // Walk to the closing paren of the parameter list. + let i = m.index + m[0].length; + let depth = 1; + while (i < SRC.length && depth > 0) { + if (SRC[i] === "(") depth++; + else if (SRC[i] === ")") depth--; + i++; + } + const after = SRC.slice(i, i + 60); + if (!/^\s*:\s*Promise<CommitActionResult>/.test(after)) continue; + const brace = SRC.indexOf("{", i); + if (brace < 0) continue; + let j = brace + 1; + depth = 1; + while (j < SRC.length && depth > 0) { + if (SRC[j] === "{") depth++; + else if (SRC[j] === "}") depth--; + j++; + } + out.push({ name: m[1], body: SRC.slice(brace + 1, j) }); + } + return out; +} + +/** The two shapes of guard used in this file. */ +function guards(body: string): boolean { + // safePath is the pathspec form: it allows a leading dash (legal after `--`) + // and refuses the two things that actually break a path — empty, and a NUL. + return body.includes("safeArg(") || body.includes("safePath(") || body.includes('startsWith("-")'); +} + +test("the bridge exposes the mutations we think it does", () => { + const found = mutations(); + assert.ok( + found.length >= 30, + `expected to parse the bridge's mutations, found ${found.length} — has the method signature style changed?`, + ); + assert.ok(found.some((f) => f.name === "branchCreate" || f.name === "createBranch"), + `expected a branch-creating mutation among: ${found.map((f) => f.name).join(", ")}`); +}); + +test("every git mutation either guards its arguments or is listed as not needing to", () => { + const unaccounted = mutations() + .filter((m) => !guards(m.body)) + .filter((m) => !(m.name in REVIEWED)) + .map((m) => m.name); + assert.deepEqual( + unaccounted, + [], + `these mutations pass renderer input to git without a safeArg/leading-dash guard, ` + + `and are not in the reviewed list. Either guard them, or add them to REVIEWED ` + + `with the reason they are safe: ${unaccounted.join(", ")}`, + ); +}); + +test("the reviewed list has not gone stale", () => { + // An entry that has since GROWN a guard, or that no longer exists, is a + // comment claiming something untrue. + const byName = new Map(mutations().map((m) => [m.name, m])); + for (const name of Object.keys(REVIEWED)) { + const m = byName.get(name); + assert.ok(m, `REVIEWED lists "${name}", which is no longer a mutation on the bridge`); + assert.equal( + guards(m!.body), + false, + `"${name}" now guards its arguments — remove it from REVIEWED`, + ); + } +}); + +test("safeArg rejects exactly what git would read as an option", () => { + // Re-stating the contract the guards above rely on, so a change to safeArg + // that widened it would surface here rather than silently in production. + const src = SRC.slice(SRC.indexOf("export function safeArg")); + assert.match(src, /!v\.startsWith\("-"\)/, "safeArg must still reject a leading dash"); + assert.match(src, /v\.length > 0/, "and the empty string"); +}); diff --git a/apps/desktop/test/githubPaging.test.ts b/apps/desktop/test/githubPaging.test.ts new file mode 100644 index 0000000..ed1736f --- /dev/null +++ b/apps/desktop/test/githubPaging.test.ts @@ -0,0 +1,45 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { nextPagePath } from "../src/main/githubPaging"; + +const API = "https://api.github.com"; + +test("parses rel=next into an API-relative path", () => { + const link = + '<https://api.github.com/repos/o/r/issues?state=open&page=2>; rel="next", ' + + '<https://api.github.com/repos/o/r/issues?state=open&page=9>; rel="last"'; + assert.equal(nextPagePath(link, API), "/repos/o/r/issues?state=open&page=2"); +}); + +test("last page (no rel=next) returns undefined", () => { + const link = '<https://api.github.com/repos/o/r/issues?page=8>; rel="prev", ' + + '<https://api.github.com/repos/o/r/issues?page=1>; rel="first"'; + assert.equal(nextPagePath(link, API), undefined); +}); + +test("missing / empty header returns undefined", () => { + assert.equal(nextPagePath(null, API), undefined); + assert.equal(nextPagePath(undefined, API), undefined); + assert.equal(nextPagePath("", API), undefined); +}); + +test("rel=next appearing after other params in the same segment still matches", () => { + const link = '<https://api.github.com/notifications?page=2>; per_page="50"; rel="next"'; + assert.equal(nextPagePath(link, API), "/notifications?page=2"); +}); + +test("order of relations does not matter", () => { + const link = + '<https://api.github.com/x?page=1>; rel="first", <https://api.github.com/x?page=3>; rel="next"'; + assert.equal(nextPagePath(link, API), "/x?page=3"); +}); + +test("a rel=next on a foreign host is refused", () => { + const link = '<https://evil.example.com/steal?page=2>; rel="next"'; + assert.equal(nextPagePath(link, API), undefined); +}); + +test("a relative rel=next path is passed through", () => { + const link = '</repos/o/r/issues?page=2>; rel="next"'; + assert.equal(nextPagePath(link, API), "/repos/o/r/issues?page=2"); +}); diff --git a/apps/desktop/test/githubStatus.test.ts b/apps/desktop/test/githubStatus.test.ts new file mode 100644 index 0000000..8306e95 --- /dev/null +++ b/apps/desktop/test/githubStatus.test.ts @@ -0,0 +1,51 @@ +// Who the app thinks you are. +// +// The owner reported the top bar showing a "Sign in" button while the Settings +// page, in the same window, showed his account signed in via OAuth Device Flow. +// The cause was one expression: `connected: !!this.login`, evaluated right +// after a `currentLogin()` call that swallows its own failures and returns +// undefined. A rate limit, a captive portal, a slow second at GitHub — and a +// signed-in user was told to sign in. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { githubStatus } from "../src/main/githubStatus"; + +test("a loaded token is connected, even before we know the login", () => { + const s = githubStatus({ hasToken: true, hasStoredToken: true }); + assert.equal(s.connected, true); + assert.equal(s.login, undefined); +}); + +test("a failed login lookup does not sign you out", () => { + // `currentLogin()` answers undefined on ANY failure. That is a missing name, + // not a missing account. + const s = githubStatus({ hasToken: true, hasStoredToken: true, login: undefined }); + assert.equal(s.connected, true); +}); + +test("a token on disk that has not been decrypted is still connected", () => { + // Asking here would raise the OS keychain prompt on every launch, so the name + // fills in after the first real request — the account exists either way. + const s = githubStatus({ hasToken: false, hasStoredToken: true }); + assert.equal(s.connected, true); + assert.equal(s.login, undefined); +}); + +test("the login rides along once something has told us", () => { + const s = githubStatus({ hasToken: true, hasStoredToken: true, login: "antonarnaudov" }); + assert.equal(s.connected, true); + assert.equal(s.login, "antonarnaudov"); +}); + +test("no token anywhere is the only signed-out state", () => { + const s = githubStatus({ hasToken: false, hasStoredToken: false }); + assert.equal(s.connected, false); + assert.equal(s.login, undefined); +}); + +test("the repo rides along in every state, connected or not", () => { + const repo = { owner: "GitStudioHQ", repo: "gitstudio" }; + assert.deepEqual(githubStatus({ hasToken: false, hasStoredToken: false, repo }).repo, repo); + assert.deepEqual(githubStatus({ hasToken: true, hasStoredToken: true, repo }).repo, repo); +}); diff --git a/apps/desktop/test/graphAdapterCore.test.ts b/apps/desktop/test/graphAdapterCore.test.ts index b85023f..7318b9b 100644 --- a/apps/desktop/test/graphAdapterCore.test.ts +++ b/apps/desktop/test/graphAdapterCore.test.ts @@ -54,10 +54,18 @@ test("nextGraphMessage produces graphAppend for later pages", () => { }); test("parseNameStatus handles modifications and renames", () => { - const out = parseNameStatus("M\tsrc/a.ts\nR100\told.ts\tnew.ts\nA\tb.ts\n"); + // -z form: NUL-separated records, a status then its path, and for R/C the + // source path then the destination. Every caller passes -z now, because + // without it git C-quotes any non-ASCII path into an octal escape string + // that is neither displayable nor usable as a pathspec. See nameStatus.test. + const out = parseNameStatus("M\0src/a.ts\0R100\0old.ts\0new.ts\0A\0b.ts\0"); assert.deepEqual(out, [ { path: "src/a.ts", status: "M" }, - { path: "new.ts", status: "R" }, + // The SOURCE path is kept, not just consumed. The base side of a rename + // holds the file under its old name, so a diff asked for `new.ts` on both + // sides comes back empty on the left and renders a twelve-line edit as a + // brand-new file with no history — "the diff doesn't show" over a rename. + { path: "new.ts", status: "R", oldPath: "old.ts" }, { path: "b.ts", status: "A" }, ]); }); diff --git a/apps/desktop/test/graphLoadRace.test.ts b/apps/desktop/test/graphLoadRace.test.ts index b9bf70e..663a46e 100644 --- a/apps/desktop/test/graphLoadRace.test.ts +++ b/apps/desktop/test/graphLoadRace.test.ts @@ -39,6 +39,7 @@ beforeEach(() => { }); git("config", "user.email", "dev@example.com"); git("config", "user.name", "Dev"); + git("config", "gc.auto", "0"); // no background gc racing the cleanup for (let i = 0; i < 12; i++) { writeFileSync(join(repo, `f${i}.txt`), `${i}\n`); git("add", "."); diff --git a/apps/desktop/test/hunkIpc.test.ts b/apps/desktop/test/hunkIpc.test.ts index 0c295f1..5a36f4c 100644 --- a/apps/desktop/test/hunkIpc.test.ts +++ b/apps/desktop/test/hunkIpc.test.ts @@ -27,6 +27,7 @@ beforeEach(() => { }); git("config", "user.email", "dev@example.com"); git("config", "user.name", "Dev"); + git("config", "gc.auto", "0"); // no background gc racing the cleanup writeFileSync(join(repo, "f.txt"), BASE); git("add", "."); git("commit", "-m", "base"); diff --git a/apps/desktop/test/identityIpc.test.ts b/apps/desktop/test/identityIpc.test.ts new file mode 100644 index 0000000..be38847 --- /dev/null +++ b/apps/desktop/test/identityIpc.test.ts @@ -0,0 +1,96 @@ +import { test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { GitContext } from "@gitstudio/git-service/index"; +import { GitBridge } from "../src/main/gitBridge"; +import type { RepoStore } from "../src/main/repoStore"; +import { removeTempRepo } from "./tmpRepo"; + +// git:setIdentity / git:identity — the Settings "Git Identity" card's backend. +// `git config` exits NON-ZERO without throwing, so the write path must check +// the exit code: it used to report "updated ✓" while writing nothing (e.g. a +// read-only ~/.gitconfig), which reads as "updating my identity is broken". + +let repo: string; +let cfgDir: string; +let cfg: string; +let ctx: GitContext; +let bridge: GitBridge; +const savedEnv: Record<string, string | undefined> = {}; + +beforeEach(() => { + repo = mkdtempSync(join(tmpdir(), "gitstudio-ident-")); + cfgDir = mkdtempSync(join(tmpdir(), "gitstudio-identcfg-")); + cfg = join(cfgDir, "gitconfig"); + writeFileSync(cfg, ""); + // Point git's --global scope at our scratch file (GitProcess inherits env). + savedEnv.GIT_CONFIG_GLOBAL = process.env.GIT_CONFIG_GLOBAL; + process.env.GIT_CONFIG_GLOBAL = cfg; + execFileSync("git", ["-c", "init.defaultBranch=main", "init", repo], { + env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, + }); + ctx = new GitContext({ root: repo }); + bridge = new GitBridge({ getContext: () => ctx } as unknown as RepoStore); +}); + +afterEach(() => { + if (savedEnv.GIT_CONFIG_GLOBAL === undefined) delete process.env.GIT_CONFIG_GLOBAL; + else process.env.GIT_CONFIG_GLOBAL = savedEnv.GIT_CONFIG_GLOBAL; + chmodSync(cfgDir, 0o755); // un-readonly so cleanup can delete it + ctx?.dispose?.(); + removeTempRepo(repo); + removeTempRepo(cfgDir); +}); + +test("setGitIdentity writes both values and gitIdentity reads them back", async () => { + const r = await bridge.setGitIdentity({ name: "Test User", email: "t@example.com" }); + assert.equal(r.ok, true); + const written = readFileSync(cfg, "utf8"); + assert.match(written, /name = Test User/); + assert.match(written, /email = t@example\.com/); + assert.deepEqual(await bridge.gitIdentity(), { name: "Test User", email: "t@example.com" }); +}); + +test("a failing git config write reports the failure instead of success", async () => { + // git config rewrites via a lock file + rename, so the DIRECTORY must be + // read-only to make the write fail (a read-only file alone doesn't). + chmodSync(cfgDir, 0o555); + const r = await bridge.setGitIdentity({ name: "Someone Else", email: "" }); + assert.equal(r.ok, false, "a non-zero git exit must not report ok"); + assert.ok(r.message && r.message.length > 0, "the git stderr should be surfaced"); +}); + +test("saving with both fields empty is rejected, not silently 'updated'", async () => { + const r = await bridge.setGitIdentity({ name: "", email: " " }); + assert.equal(r.ok, false); +}); + +/** + * The card is a pair of fields over a pair of git settings, and git will not + * record a commit without both. Clearing one and pressing Save used to write + * only the other, leave the cleared setting exactly as it was, and report + * "Identity updated" — so the value on screen and the value in ~/.gitconfig + * disagreed, with the app insisting it had done what was asked. + */ +test("clearing one field is refused, and leaves the stored identity alone", async () => { + await bridge.setGitIdentity({ name: "Test User", email: "t@example.com" }); + + const cleared = await bridge.setGitIdentity({ name: "", email: "t@example.com" }); + assert.equal(cleared.ok, false, "a half-filled identity is not saved"); + assert.equal(cleared.changed, false); + assert.match(cleared.message ?? "", /both a name and an email/i, "and says why"); + assert.match(cleared.message ?? "", /nothing has been changed/i, "and what it did instead"); + + assert.deepEqual( + await bridge.gitIdentity(), + { name: "Test User", email: "t@example.com" }, + "the stored identity is untouched — which is what the message promised", + ); + + const other = await bridge.setGitIdentity({ name: "Test User", email: " " }); + assert.equal(other.ok, false, "whitespace is empty too"); + assert.match(other.message ?? "", /Add an email/i, "naming the field that is missing"); +}); diff --git a/apps/desktop/test/initials.test.ts b/apps/desktop/test/initials.test.ts new file mode 100644 index 0000000..eb56d3e --- /dev/null +++ b/apps/desktop/test/initials.test.ts @@ -0,0 +1,61 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +// `ui.ts` reaches `bridge.ts`, which reads `window.gitstudio` at module load. +// A bare object is enough — nothing here calls the bridge. +(globalThis as unknown as { window?: unknown }).window ??= { + gitstudio: { invoke: () => Promise.resolve(undefined), on: () => () => {} }, + addEventListener: () => {}, + matchMedia: () => ({ matches: false, addEventListener: () => {} }), +}; +const ui = (): Promise<{ initials: (n: string) => string }> => import("../src/renderer/ui"); + +/** + * Avatar-fallback initials. + * + * The single-part branch stripped everything outside `[A-Za-z0-9]` — which + * deletes every Cyrillic, Greek, CJK, Arabic and Hebrew character there is. So + * a contributor named "Пётр" or "田中" got a "?" tile sitting next to their own + * correctly-spelled name in the same row: the app rendering their name fine and + * claiming, an inch away, that it could not read it. + * + * The multi-part branch took `[0]`, a UTF-16 unit, so an astral first character + * produced half a surrogate pair — a tofu box. + */ +test("non-Latin names get their own initials, not a question mark", async () => { + const { initials } = await ui(); + assert.equal(initials("Пётр"), "ПЁ", "Cyrillic"); + assert.equal(initials("田中"), "田中", "CJK"); + assert.equal(initials("محمد"), "مح", "Arabic"); + assert.equal(initials("Ελένη"), "ΕΛ", "Greek"); + assert.equal(initials("Пётр Иванов"), "ПИ", "two Cyrillic parts"); +}); + +test("astral characters are never cut in half", async () => { + const { initials } = await ui(); + // Outside the BMP: one code point, two UTF-16 units. `slice(0, 2)` used to + // return exactly one lone surrogate here. + const name = "𝒜𝒷"; + const out = initials(name); + assert.ok( + ![...out].some((ch) => { + const c = ch.codePointAt(0) ?? 0; + return c >= 0xd800 && c <= 0xdfff; + }), + `no lone surrogate in ${JSON.stringify(out)}`, + ); +}); + +test("the login cases that drove this still hold", async () => { + const { initials } = await ui(); + // "s-ohta" is one whitespace-part; the first two characters were "s-", so the + // tile rendered punctuation. Dashes, dots and underscores are word breaks. + assert.equal(initials("s-ohta"), "SO"); + assert.equal(initials("ada.lovelace"), "AL"); + assert.equal(initials("grace_hopper"), "GH"); + assert.equal(initials("Ada Lovelace"), "AL"); + assert.equal(initials("mono"), "MO"); + assert.equal(initials(""), "?"); + assert.equal(initials(" "), "?"); + assert.equal(initials("-"), "?", "punctuation alone has no initials"); +}); diff --git a/apps/desktop/test/itemMaps.test.ts b/apps/desktop/test/itemMaps.test.ts new file mode 100644 index 0000000..12eee41 --- /dev/null +++ b/apps/desktop/test/itemMaps.test.ts @@ -0,0 +1,162 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + mapComment, + mapIssue, + mapPull, + mapReactions, + type RawIssue, + type RawPull, +} from "../src/main/github/maps"; + +// The PR/Issue/comment mappers, now the single home for both former copies. +// These assert the METADATA the UI newly depends on — and the absent-field +// defaults, since GitHub omits far more than its docs admit. + +function rawPull(over: Partial<RawPull> = {}): RawPull { + return { + number: 31, + title: "Add the thing", + body: "why", + state: "closed", + html_url: "https://github.com/acme/w/pull/31", + user: { login: "author", avatar_url: "https://a/1" }, + created_at: "2026-08-01T10:00:00Z", + updated_at: "2026-08-05T10:00:00Z", + head: { ref: "feat", sha: "aaa", repo: { full_name: "acme/w" } }, + base: { ref: "main", sha: "bbb", repo: { full_name: "acme/w" } }, + ...over, + }; +} + +test("a merged PR carries who merged it, when it closed, and its counts", () => { + const p = mapPull( + rawPull({ + merged_at: "2026-08-05T09:00:00Z", + closed_at: "2026-08-05T09:00:00Z", + merged_by: { login: "maintainer", avatar_url: "https://a/2" }, + review_comments: 4, + commits: 7, + author_association: "CONTRIBUTOR", + }), + ); + assert.equal(p.mergedAt, "2026-08-05T09:00:00Z"); + assert.equal(p.closedAt, "2026-08-05T09:00:00Z"); + assert.equal(p.mergedBy?.login, "maintainer"); + assert.equal(p.reviewComments, 4); + assert.equal(p.commits, 7); + assert.equal(p.authorAssociation, "CONTRIBUTOR"); +}); + +test("requested reviewers map to users, nulls dropped", () => { + const p = mapPull( + rawPull({ requested_reviewers: [{ login: "a" }, { login: "b", avatar_url: "https://a/b" }] }), + ); + assert.deepEqual(p.requestedReviewers?.map((u) => u.login), ["a", "b"]); + assert.equal(p.requestedReviewers?.[0].avatarUrl, null); +}); + +test("a fork PR reports its head repo; a same-repo PR reports null", () => { + assert.equal(mapPull(rawPull()).headRepoFullName, null); + const forked = mapPull( + rawPull({ head: { ref: "feat", sha: "aaa", repo: { full_name: "someone/w" } } }), + ); + assert.equal(forked.headRepoFullName, "someone/w"); +}); + +test("a PR with no optional metadata still maps to concrete defaults", () => { + const p = mapPull(rawPull()); + assert.equal(p.mergedAt, null); + assert.equal(p.closedAt, null); + assert.equal(p.mergedBy, null); + assert.deepEqual(p.assignees, []); + assert.deepEqual(p.requestedReviewers, []); + assert.equal(p.milestone, null); + assert.equal(p.reactions, undefined); +}); + +// ── issues ─────────────────────────────────────────────────────────────────── + +function rawIssue(over: Partial<RawIssue> = {}): RawIssue { + return { + number: 12, + title: "Broken", + body: null, + state: "closed", + html_url: "https://github.com/acme/w/issues/12", + user: { login: "reporter" }, + created_at: "2026-08-01T10:00:00Z", + updated_at: "2026-08-02T10:00:00Z", + comments: 2, + ...over, + }; +} + +test("a closed issue carries WHY it closed and who closed it", () => { + const i = mapIssue( + rawIssue({ + state_reason: "not_planned", + closed_at: "2026-08-02T10:00:00Z", + closed_by: { login: "triager" }, + }), + ); + assert.equal(i.stateReason, "not_planned"); + assert.equal(i.closedAt, "2026-08-02T10:00:00Z"); + assert.equal(i.closedBy?.login, "triager"); +}); + +test("an open issue has null closure fields, not undefined", () => { + const i = mapIssue(rawIssue({ state: "open" })); + assert.equal(i.stateReason, null); + assert.equal(i.closedAt, null); + assert.equal(i.closedBy, null); +}); + +test("string labels (the search API's shorthand) still map", () => { + const i = mapIssue(rawIssue({ labels: ["bug", { name: "p1", color: "ff0000" }] })); + assert.deepEqual(i.labels, [ + { name: "bug", color: "888888" }, + { name: "p1", color: "ff0000" }, + ]); +}); + +// ── comments ───────────────────────────────────────────────────────────────── + +test("a comment edited after posting keeps both timestamps", () => { + const c = mapComment({ + id: 5, + user: { login: "x" }, + body: "hi", + created_at: "2026-08-01T10:00:00Z", + updated_at: "2026-08-01T11:00:00Z", + author_association: "MEMBER", + }); + assert.equal(c.createdAt, "2026-08-01T10:00:00Z"); + assert.equal(c.updatedAt, "2026-08-01T11:00:00Z"); + assert.equal(c.authorAssociation, "MEMBER"); +}); + +test("a comment with a null body maps to an empty string, never null", () => { + const c = mapComment({ id: 6, user: null, body: null, created_at: "2026-08-01T10:00:00Z" }); + assert.equal(c.body, ""); + assert.equal(c.author, null); +}); + +// ── reactions ──────────────────────────────────────────────────────────────── + +test("reactions map only when someone actually reacted", () => { + assert.equal(mapReactions(undefined), undefined); + assert.equal(mapReactions(null), undefined); + assert.equal(mapReactions({ total_count: 0, "+1": 0 }), undefined); + assert.deepEqual(mapReactions({ total_count: 3, "+1": 2, heart: 1 }), { + total: 3, + plusOne: 2, + minusOne: 0, + laugh: 0, + hooray: 0, + confused: 0, + heart: 1, + rocket: 0, + eyes: 0, + }); +}); diff --git a/apps/desktop/test/localRepos.test.ts b/apps/desktop/test/localRepos.test.ts new file mode 100644 index 0000000..042ac85 --- /dev/null +++ b/apps/desktop/test/localRepos.test.ts @@ -0,0 +1,236 @@ +import { test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + LocalRepoScanner, + SCAN_TTL_MS, + isInside, + samePath, + scanLocalCopies, + trashRefusal, + trashRefusalResolved, +} from "../src/main/localRepos"; +import { removeTempRepo } from "./tmpRepo"; + +// The Settings → Repositories manager's data layer: what's on this machine, +// which copies GitStudio may delete, and the cache that keeps a re-render from +// shelling out to git dozens of times. + +let cloneDir: string; +let outside: string; + +function makeRepo(dir: string, origin?: string): string { + execFileSync("git", ["-c", "init.defaultBranch=main", "init", dir], { + env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, + stdio: "ignore", + }); + if (origin) execFileSync("git", ["-C", dir, "remote", "add", "origin", origin]); + return dir; +} + +beforeEach(() => { + cloneDir = mkdtempSync(join(tmpdir(), "gitstudio-clonedir-")); + outside = mkdtempSync(join(tmpdir(), "gitstudio-outside-")); +}); + +afterEach(() => { + removeTempRepo(cloneDir); + removeTempRepo(outside); +}); + +test("scans the clone folder and reads each origin", async () => { + makeRepo(join(cloneDir, "widgets"), "https://github.com/acme/widgets.git"); + makeRepo(join(cloneDir, "gizmos"), "git@github.com:acme/gizmos.git"); + const copies = await scanLocalCopies({ cloneDir, recents: [] }); + assert.deepEqual( + copies.map((c) => [c.name, c.origin, c.managed, c.recent]), + [ + ["gizmos", "acme/gizmos", true, false], + ["widgets", "acme/widgets", true, false], + ], + ); +}); + +test("non-repo folders and dotfiles in the clone folder are ignored", async () => { + mkdirSync(join(cloneDir, "just-a-folder")); + mkdirSync(join(cloneDir, ".hidden")); + writeFileSync(join(cloneDir, "notes.txt"), "hi"); + makeRepo(join(cloneDir, "real"), "https://github.com/acme/real.git"); + const copies = await scanLocalCopies({ cloneDir, recents: [] }); + assert.deepEqual(copies.map((c) => c.name), ["real"]); +}); + +test("recents outside the clone folder are listed but not managed", async () => { + const far = makeRepo(join(outside, "faraway"), "https://github.com/acme/faraway.git"); + const copies = await scanLocalCopies({ cloneDir, recents: [far] }); + assert.equal(copies.length, 1); + assert.equal(copies[0].origin, "acme/faraway"); + assert.equal(copies[0].managed, false); + assert.equal(copies[0].recent, true); +}); + +test("a repo that is BOTH a recent and in the clone folder appears once, with both flags", async () => { + const r = makeRepo(join(cloneDir, "both"), "https://github.com/acme/both.git"); + const copies = await scanLocalCopies({ cloneDir, recents: [r] }); + assert.equal(copies.length, 1); + assert.equal(copies[0].managed, true); + assert.equal(copies[0].recent, true); +}); + +test("a recent whose folder is gone is reported as missing, never dropped", async () => { + const ghost = join(outside, "deleted-elsewhere"); + const copies = await scanLocalCopies({ cloneDir, recents: [ghost] }); + assert.equal(copies.length, 1); + assert.equal(copies[0].missing, true); + assert.equal(copies[0].origin, undefined); +}); + +test("missing copies sort last", async () => { + makeRepo(join(cloneDir, "alive"), "https://github.com/acme/alive.git"); + const copies = await scanLocalCopies({ + cloneDir, + recents: [join(outside, "aaa-gone")], + }); + assert.deepEqual(copies.map((c) => c.missing), [false, true]); +}); + +test("the open repo is flagged current", async () => { + const r = makeRepo(join(cloneDir, "here"), "https://github.com/acme/here.git"); + const copies = await scanLocalCopies({ cloneDir, recents: [], current: r }); + assert.equal(copies[0].current, true); +}); + +test("a repo with no origin still lists, without an origin", async () => { + makeRepo(join(cloneDir, "local-only")); + const copies = await scanLocalCopies({ cloneDir, recents: [] }); + assert.equal(copies[0].name, "local-only"); + assert.equal(copies[0].origin, undefined); +}); + +test("a non-GitHub origin lists without an owner/repo chip", async () => { + makeRepo(join(cloneDir, "gitlabbed"), "https://gitlab.com/acme/thing.git"); + const copies = await scanLocalCopies({ cloneDir, recents: [] }); + assert.equal(copies[0].origin, undefined); +}); + +test("a missing clone folder is not an error — recents still list", async () => { + const r = makeRepo(join(outside, "kept"), "https://github.com/acme/kept.git"); + const copies = await scanLocalCopies({ + cloneDir: join(cloneDir, "does-not-exist"), + recents: [r], + }); + assert.deepEqual(copies.map((c) => c.name), ["kept"]); +}); + +// ── cache ──────────────────────────────────────────────────────────────────── + +test("the scanner caches within the TTL and re-scans after it", async () => { + let now = 1_000; + const scanner = new LocalRepoScanner(() => now); + makeRepo(join(cloneDir, "one"), "https://github.com/acme/one.git"); + const first = await scanner.scan({ cloneDir, recents: [] }); + assert.deepEqual(first.map((c) => c.name), ["one"]); + + // A second repo appears on disk — inside the TTL the cache hides it. + makeRepo(join(cloneDir, "two"), "https://github.com/acme/two.git"); + now += SCAN_TTL_MS - 1; + assert.deepEqual((await scanner.scan({ cloneDir, recents: [] })).map((c) => c.name), ["one"]); + + now += 2; + assert.deepEqual( + (await scanner.scan({ cloneDir, recents: [] })).map((c) => c.name), + ["one", "two"], + ); +}); + +test("invalidate() drops the cache immediately", async () => { + let now = 1_000; + const scanner = new LocalRepoScanner(() => now); + makeRepo(join(cloneDir, "one"), "https://github.com/acme/one.git"); + await scanner.scan({ cloneDir, recents: [] }); + makeRepo(join(cloneDir, "two"), "https://github.com/acme/two.git"); + scanner.invalidate(); + assert.equal((await scanner.scan({ cloneDir, recents: [] })).length, 2); +}); + +test("a different input re-scans even inside the TTL", async () => { + const scanner = new LocalRepoScanner(() => 1_000); + makeRepo(join(cloneDir, "one"), "https://github.com/acme/one.git"); + const far = makeRepo(join(outside, "far"), "https://github.com/acme/far.git"); + assert.equal((await scanner.scan({ cloneDir, recents: [] })).length, 1); + assert.equal((await scanner.scan({ cloneDir, recents: [far] })).length, 2); +}); + +// ── the delete rule ────────────────────────────────────────────────────────── + +test("isInside is boundary-correct (a sibling prefix is NOT inside)", () => { + assert.equal(isInside("/a/b", "/a/b/c"), true); + assert.equal(isInside("/a/b", "/a/b"), true); + assert.equal(isInside("/a/b", "/a/bc"), false); + assert.equal(isInside("/a/b", "/a"), false); +}); + +test("trashing a managed clone is allowed", () => { + assert.equal(trashRefusal(join(cloneDir, "widgets"), { cloneDir }), null); +}); + +test("trashing refuses anything outside the clone folder", () => { + const why = trashRefusal(join(outside, "precious"), { cloneDir }); + assert.match(why ?? "", /inside your clone folder/); +}); + +test("trashing refuses the clone folder itself", () => { + assert.match(trashRefusal(cloneDir, { cloneDir }) ?? "", /clone folder itself/); +}); + +test("trashing refuses the repo that is currently open", () => { + const root = join(cloneDir, "open-one"); + assert.match(trashRefusal(root, { cloneDir, current: root }) ?? "", /open right now/); +}); + +test("a '..' path can't escape the clone folder", () => { + const escape = join(cloneDir, "..", "elsewhere"); + assert.match(trashRefusal(escape, { cloneDir }) ?? "", /inside your clone folder/); +}); + +// The resolved form is what main.ts actually calls — it must agree with the +// pure rule AND refuse to delete things that aren't repositories. + +test("the resolved rule allows a real managed clone", async () => { + const r = makeRepo(join(cloneDir, "widgets"), "https://github.com/acme/widgets.git"); + assert.equal(await trashRefusalResolved(r, { cloneDir }), null); +}); + +test("the resolved rule refuses a plain folder inside the clone folder", async () => { + const plain = join(cloneDir, "not-a-repo"); + mkdirSync(plain); + assert.match((await trashRefusalResolved(plain, { cloneDir })) ?? "", /isn't a git repository/); +}); + +test("the resolved rule refuses a path that doesn't exist", async () => { + assert.match( + (await trashRefusalResolved(join(cloneDir, "ghost"), { cloneDir })) ?? "", + /isn't a git repository/, + ); +}); + +test("the resolved rule refuses a repo OUTSIDE the clone folder", async () => { + const far = makeRepo(join(outside, "far"), "https://github.com/acme/far.git"); + assert.match((await trashRefusalResolved(far, { cloneDir })) ?? "", /inside your clone folder/); +}); + +test("the resolved rule refuses a symlink inside the clone folder pointing out of it", async () => { + const far = makeRepo(join(outside, "target"), "https://github.com/acme/target.git"); + const link = join(cloneDir, "sneaky"); + symlinkSync(far, link); + assert.match((await trashRefusalResolved(link, { cloneDir })) ?? "", /inside your clone folder/); +}); + +test("samePath compares resolved paths, not strings", () => { + assert.equal(samePath("/a/b", "/a/./b"), true); + assert.equal(samePath("/a/b", "/a/b/"), true); + assert.equal(samePath("/a/b", "/a/c"), false); +}); diff --git a/apps/desktop/test/logModel.test.ts b/apps/desktop/test/logModel.test.ts new file mode 100644 index 0000000..fa1410d --- /dev/null +++ b/apps/desktop/test/logModel.test.ts @@ -0,0 +1,160 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + appendLog, + emptyLogDoc, + finishLog, + parseAnsi, + parseLog, + stripAnsi, +} from "../src/renderer/logModel"; + +const TS = "2026-08-25T10:00:42.1234567Z "; + +test("parseLog strips timestamps and classifies workflow commands", () => { + const doc = parseLog( + `${TS}##[group]Run npm ci\n${TS}npm output line\n${TS}##[endgroup]\n${TS}##[error]exit code 1\n`, + ); + // `##[endgroup]` carries no payload, so giving it a line of its own rendered + // a blank NUMBERED row for every group — output the raw log does not + // contain. It closes its group without occupying a line. + assert.equal(doc.lines.length, 3); + assert.equal(doc.lines[0].kind, "group"); + assert.equal(doc.lines[0].text, "Run npm ci"); + assert.equal(doc.lines[0].ts, TS.trim()); + assert.equal(doc.lines[1].kind, "plain"); + assert.equal(doc.lines[1].text, "npm output line"); + assert.equal(doc.lines[2].kind, "error"); + assert.equal( + doc.lines.some((l) => l.kind === "endgroup"), + false, + "no endgroup line is emitted", + ); + // The group ends at its last REAL line, so collapsing it hides exactly the + // lines that belong to it. + assert.deepEqual(doc.groups, [{ start: 0, end: 1 }]); +}); + +test("unbalanced groups: an unclosed group stays open; stray endgroup is tolerated", () => { + const doc = parseLog(`##[group]outer\nline\n##[endgroup]\n##[endgroup]\n##[group]tail\nmore\n`); + assert.deepEqual(doc.lines.map((l) => l.text), ["outer", "line", "tail", "more"]); + assert.deepEqual(doc.groups, [ + { start: 0, end: 1 }, + { start: 2, end: -1 }, + ]); +}); + +test("an empty group closes on its own header rather than before it", () => { + const doc = parseLog(`##[group]nothing inside\n##[endgroup]\nafter\n`); + assert.deepEqual(doc.groups, [{ start: 0, end: 0 }]); + assert.deepEqual(doc.lines.map((l) => l.text), ["nothing inside", "after"]); +}); + +test("appendLog re-parses a line split across two deltas", () => { + const doc = emptyLogDoc(); + appendLog(doc, `${TS}##[warn`); + assert.equal(doc.lines.length, 0); + assert.equal(doc.danglingTail.length > 0, true); + appendLog(doc, "ing]slow step\nnext\n"); + assert.equal(doc.lines.length, 2); + assert.equal(doc.lines[0].kind, "warning"); + assert.equal(doc.lines[0].text, "slow step"); + assert.equal(doc.lines[1].text, "next"); +}); + +test("finishLog flushes a trailing partial line", () => { + const doc = appendLog(emptyLogDoc(), "no newline at end"); + assert.equal(doc.lines.length, 0); + finishLog(doc); + assert.equal(doc.lines.length, 1); + assert.equal(doc.lines[0].text, "no newline at end"); + assert.equal(doc.danglingTail, ""); +}); + +test("parseAnsi: basic colors, bold, reset, adjacent-span merging", () => { + const spans = parseAnsi("red boldred plain"); + assert.deepEqual( + spans.map((s) => [s.text, s.cls]), + [ + ["red", "log-fg-1"], + [" boldred", "log-fg-1 log-b"], + [" plain", ""], + ], + ); +}); + +test("parseAnsi: bright colors and 256-color mapping to the 16 palette", () => { + const bright = parseAnsi("green"); + assert.equal(bright[0].cls, "log-fg-10"); + const gray = parseAnsi("light-gray"); + assert.equal(gray[0].cls, "log-fg-15"); + const red256 = parseAnsi("red"); + assert.match(red256[0].cls, /log-fg-(1|9)/); +}); + +test("parseAnsi strips non-SGR escapes (cursor moves, OSC)", () => { + const spans = parseAnsi("ab]0;titlec"); + assert.equal(spans.map((s) => s.text).join(""), "abc"); +}); + +test("stripAnsi yields plain searchable text", () => { + assert.equal(stripAnsi("fail: done"), "fail: done"); +}); + +test("truecolor maps to a nearby palette slot", () => { + const spans = parseAnsi("red"); + assert.match(spans[0].cls, /log-fg-9/); +}); + +// ── carriage returns ───────────────────────────────────────────────────────── +// +// Every CI tool that draws a progress bar rewrites one logical line in place +// with `\r` and terminates it with a single `\n`. Keeping the raw text meant +// the pane rendered every intermediate state at once, run together, because a +// `\r` paints as nothing in HTML. + +const CR = String.fromCharCode(13); + +test("a progress line shows its final state, not all of them at once", () => { + const doc = parseLog( + `Downloading 0%${CR}Downloading 25%${CR}Downloading 60%${CR}Downloading 100%\nDone\n`, + ); + assert.equal(doc.lines.length, 2); + assert.equal(doc.lines[0].text, "Downloading 100%"); + assert.equal(doc.lines[1].text, "Done"); +}); + +test("a carriage return overwrites rather than truncating", () => { + // A terminal returns the cursor to column 0 and paints over; a short redraw + // leaves the tail of the longer line behind. + const doc = parseLog(`abcdef${CR}xy\n`); + assert.equal(doc.lines[0].text, "xycdef"); +}); + +test("a CRLF log does not leave a stray return on every line", () => { + const doc = parseLog("alpha\r\nbeta\r\n"); + assert.deepEqual( + doc.lines.map((l) => l.text), + ["alpha", "beta"], + ); +}); + +test("the timestamp survives a redraw on the same line", () => { + // GitHub stamps once per newline, so the stamp sits before the first segment. + // Applying the overwrite to the whole raw line would let a later segment + // paint over the timestamp. + const doc = parseLog(`2026-08-25T10:00:42.1234567Z step 0%${CR}step 99%\n`); + assert.equal(doc.lines[0].ts, "2026-08-25T10:00:42.1234567Z"); + assert.equal(doc.lines[0].text, "step 99%"); +}); + +test("a workflow command still classifies after a redraw", () => { + const doc = parseLog(`junk${CR}##[error]Process completed with exit code 1.\n`); + assert.equal(doc.lines[0].kind, "error"); + assert.equal(doc.lines[0].text, "Process completed with exit code 1."); +}); + +test("text with no carriage return is returned untouched", () => { + const doc = parseLog("plain line\n"); + assert.equal(doc.lines[0].text, "plain line"); +}); diff --git a/apps/desktop/test/logTail.test.ts b/apps/desktop/test/logTail.test.ts new file mode 100644 index 0000000..25c9d3a --- /dev/null +++ b/apps/desktop/test/logTail.test.ts @@ -0,0 +1,49 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { sliceLogDelta } from "../src/main/github/logTail"; + +test("first fetch (offset 0) returns the whole text as an append", () => { + const d = sliceLogDelta("line1\nline2\n", 0); + assert.equal(d.text, "line1\nline2\n"); + assert.equal(d.totalLength, 12); + assert.equal(d.reset, false); + assert.equal(d.truncated, false); +}); + +test("subsequent fetch appends only the unseen remainder", () => { + const d = sliceLogDelta("line1\nline2\nline3\n", 12); + assert.equal(d.text, "line3\n"); + assert.equal(d.reset, false); +}); + +test("unchanged log yields an empty append", () => { + const d = sliceLogDelta("abc", 3); + assert.equal(d.text, ""); + assert.equal(d.totalLength, 3); + assert.equal(d.reset, false); +}); + +test("a shrunken log (re-run attempt) resets", () => { + const d = sliceLogDelta("new run\n", 500); + assert.equal(d.reset, true); + assert.equal(d.text, "new run\n"); + assert.equal(d.truncated, false); +}); + +test("a log over the cap resets to a truncated tail window on a line boundary", () => { + const line = "x".repeat(99) + "\n"; // 100 chars per line + const full = line.repeat(50); // 5000 chars + const d = sliceLogDelta(full, 0, 1000); + assert.equal(d.reset, true); + assert.equal(d.truncated, true); + assert.ok(d.text.length <= 1000); + assert.ok(d.text.startsWith("x")); // starts at a line boundary, not mid-line + assert.equal(d.totalLength, 5000); + assert.ok(full.endsWith(d.text)); +}); + +test("exact-boundary append (remainder == cap) stays an append", () => { + const d = sliceLogDelta("a".repeat(10), 5, 5); + assert.equal(d.reset, false); + assert.equal(d.text, "aaaaa"); +}); diff --git a/apps/desktop/test/mergeReport.test.ts b/apps/desktop/test/mergeReport.test.ts new file mode 100644 index 0000000..25547c2 --- /dev/null +++ b/apps/desktop/test/mergeReport.test.ts @@ -0,0 +1,104 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { writeFileSync, mkdtempSync } from "node:fs"; +import { removeTempRepo } from "./tmpRepo"; +import { tmpdir } from "node:os"; +import { RepoStore } from "../src/main/repoStore"; +import { GitBridge } from "../src/main/gitBridge"; + +/** + * What the app says when a merge conflicts. + * + * `git merge` writes its whole report to STDOUT and leaves stderr empty: + * + * Auto-merging f.txt + * CONFLICT (content): Merge conflict in f.txt + * Automatic merge failed; fix conflicts and then commit the result. + * + * `BranchOpResult` carried only `stderr`, so all of that was dropped and the + * app answered "The operation failed." — while the working tree was sitting + * mid-merge with conflict markers in it. That message describes neither what + * happened nor what to do, and reads like the merge did not run at all. + */ +function conflictRepo(): { root: string; git: (...a: string[]) => string } { + const root = mkdtempSync(`${tmpdir()}/gs-merge-`); + const git = (...a: string[]): string => execFileSync("git", a, { cwd: root }).toString(); + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); // no background gc racing the cleanup + writeFileSync(`${root}/f.txt`, "one\n"); + git("add", "."); + git("commit", "-qm", "base"); + git("checkout", "-qb", "other"); + writeFileSync(`${root}/f.txt`, "other\n"); + git("commit", "-qam", "other"); + git("checkout", "-q", "-"); + writeFileSync(`${root}/f.txt`, "main\n"); + git("commit", "-qam", "main"); + return { root, git }; +} + +test("a conflicted merge reports git's own conflict text, not 'The operation failed'", async () => { + const { root } = conflictRepo(); + try { + const repos = new RepoStore([]); + await repos.open(root); + const bridge = new GitBridge(repos); + const r = await bridge.branchMerge({ name: "other" }); + + assert.equal(r.ok, false, "the merge did not succeed"); + const msg = r.message ?? ""; + assert.notEqual(msg, "The operation failed.", "the placeholder must be gone"); + assert.match(msg, /CONFLICT/i, `it names the conflict (got: ${msg})`); + assert.match(msg, /f\.txt/, "and the file it is in"); + } finally { + removeTempRepo(root); + } +}); + +test("a merge that cannot start still reports git's refusal", async () => { + const { root, git } = conflictRepo(); + try { + // Dirty the tree so git refuses outright — a different failure, and one + // whose text lives on stderr rather than stdout. + writeFileSync(`${root}/f.txt`, "uncommitted edit\n"); + + const repos = new RepoStore([]); + await repos.open(root); + const bridge = new GitBridge(repos); + const r = await bridge.branchMerge({ name: "other" }); + assert.equal(r.ok, false); + assert.notEqual(r.message ?? "", "The operation failed.", "neither channel is dropped"); + assert.ok((r.message ?? "").length > 10, `git's own words survive (got: ${r.message})`); + } finally { + removeTempRepo(root); + } +}); + +test("a clean merge still just succeeds", async () => { + const root = mkdtempSync(`${tmpdir()}/gs-merge-ok-`); + try { + const git = (...a: string[]): string => execFileSync("git", a, { cwd: root }).toString(); + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + writeFileSync(`${root}/a.txt`, "a\n"); + git("add", "."); + git("commit", "-qm", "base"); + git("checkout", "-qb", "side"); + writeFileSync(`${root}/b.txt`, "b\n"); + git("add", "."); + git("commit", "-qm", "side"); + git("checkout", "-q", "-"); + + const repos = new RepoStore([]); + await repos.open(root); + const bridge = new GitBridge(repos); + const r = await bridge.branchMerge({ name: "side" }); + assert.equal(r.ok, true, `a clean merge succeeds (${r.message ?? ""})`); + } finally { + removeTempRepo(root); + } +}); diff --git a/apps/desktop/test/nameStatus.test.ts b/apps/desktop/test/nameStatus.test.ts new file mode 100644 index 0000000..0377336 --- /dev/null +++ b/apps/desktop/test/nameStatus.test.ts @@ -0,0 +1,105 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { writeFileSync, mkdtempSync } from "node:fs"; +import { removeTempRepo } from "./tmpRepo"; +import { tmpdir } from "node:os"; +import { parseNameStatus } from "../src/main/gitBridge"; + +/** + * Non-ASCII paths in a commit's file list. + * + * `git diff/show --name-status` C-QUOTES any path outside ASCII by default, so + * "café.txt" comes out as the literal 17-character string `"caf\303\251.txt"` + * — quotes and octal escapes included. That string was what the Commits view + * printed, and what every subsequent `-- <path>` was handed, so the file's diff + * came back empty: the path did not exist. Every commit touching an accented, + * Cyrillic or CJK filename was affected. + * + * -z removes the quoting entirely, and handles the paths a `quotepath=false` + * would still break on (tabs and newlines in filenames are legal). + */ +function repo(): { root: string; git: (...a: string[]) => string } { + const root = mkdtempSync(`${tmpdir()}/gs-namestatus-`); + const git = (...a: string[]): string => execFileSync("git", a, { cwd: root }).toString(); + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); // no background gc racing the cleanup + return { root, git }; +} + +test("non-ASCII paths survive, verbatim, from real git", () => { + const { root, git } = repo(); + try { + const names = ["café.txt", "日本語.md", "Ünïcødé dir/файл.rs"]; + execFileSync("mkdir", ["-p", `${root}/Ünïcødé dir`]); + for (const n of names) writeFileSync(`${root}/${n}`, "x\n"); + git("add", "-A"); + git("commit", "-qm", "one"); + + const out = git("show", "--name-status", "-M", "-z", "--format=", "HEAD"); + const files = parseNameStatus(out); + assert.deepEqual( + files.map((f) => f.path).sort(), + [...names].sort(), + "the paths are the real ones, not git's C-quoted octal form", + ); + assert.ok( + files.every((f) => f.status === "A"), + "and each carries its status", + ); + for (const f of files) { + assert.ok(!f.path.includes("\\"), `no escape sequences left in ${f.path}`); + assert.ok(!f.path.startsWith('"'), `no wrapping quotes left in ${f.path}`); + } + } finally { + removeTempRepo(root); + } +}); + +test("a rename reports the destination, not the source", () => { + const { root, git } = repo(); + try { + writeFileSync(`${root}/old-name.txt`, "the quick brown fox\n".repeat(20)); + git("add", "-A"); + git("commit", "-qm", "one"); + git("mv", "old-name.txt", "nouveau-nom.txt"); + git("commit", "-qm", "two"); + + const files = parseNameStatus(git("show", "--name-status", "-M", "-z", "--format=", "HEAD")); + assert.equal(files.length, 1, "one rename is one row"); + assert.equal(files[0].status, "R"); + assert.equal(files[0].path, "nouveau-nom.txt", "the destination — the path that exists now"); + } finally { + removeTempRepo(root); + } +}); + +test("ordinary ASCII commits parse exactly as before", () => { + const { root, git } = repo(); + try { + writeFileSync(`${root}/a.txt`, "a\n"); + writeFileSync(`${root}/b.txt`, "b\n"); + git("add", "-A"); + git("commit", "-qm", "one"); + writeFileSync(`${root}/a.txt`, "a2\n"); + execFileSync("rm", [`${root}/b.txt`]); + writeFileSync(`${root}/c.txt`, "c\n"); + git("add", "-A"); + git("commit", "-qm", "two"); + + const files = parseNameStatus(git("diff", "--name-status", "-M", "-z", "HEAD~1..HEAD")); + assert.deepEqual( + files.map((f) => [f.status, f.path]).sort(), + [ + ["A", "c.txt"], + ["D", "b.txt"], + ["M", "a.txt"], + ], + "add / delete / modify all still land", + ); + } finally { + removeTempRepo(root); + } +}); diff --git a/apps/desktop/test/notificationSubject.test.ts b/apps/desktop/test/notificationSubject.test.ts new file mode 100644 index 0000000..706a1a5 --- /dev/null +++ b/apps/desktop/test/notificationSubject.test.ts @@ -0,0 +1,142 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + mapNotification, + subjectHtmlUrl, + subjectRef, + type RawNotification, +} from "../src/main/github/maps"; + +// The Inbox can only open a notification IN-APP if it knows what the thread is +// about. GitHub's subject carries no number and no html_url — only an API url +// whose tail is the number (or the sha). Every form it emits is pinned here. + +test("a pull-request subject yields kind + number", () => { + assert.deepEqual( + subjectRef("PullRequest", "https://api.github.com/repos/acme/widgets/pulls/42"), + { kind: "pull", number: 42 }, + ); +}); + +test("an issue subject yields kind + number", () => { + assert.deepEqual( + subjectRef("Issue", "https://api.github.com/repos/acme/widgets/issues/7"), + { kind: "issue", number: 7 }, + ); +}); + +test("a release subject yields kind + number (the release ID)", () => { + assert.deepEqual( + subjectRef("Release", "https://api.github.com/repos/acme/widgets/releases/98765"), + { kind: "release", number: 98765 }, + ); +}); + +test("a commit subject yields the sha", () => { + assert.deepEqual( + subjectRef("Commit", "https://api.github.com/repos/acme/widgets/commits/9f8e7d6c5b4a3928"), + { kind: "commit", sha: "9f8e7d6c5b4a3928" }, + ); +}); + +test("a query string or fragment after the number doesn't break parsing", () => { + assert.deepEqual( + subjectRef("Issue", "https://api.github.com/repos/acme/widgets/issues/7?foo=1"), + { kind: "issue", number: 7 }, + ); +}); + +test("a repo whose NAME contains digits doesn't confuse the parse", () => { + assert.deepEqual( + subjectRef("Issue", "https://api.github.com/repos/acme/repo123/issues/5"), + { kind: "issue", number: 5 }, + ); +}); + +test("a null url falls back to the declared subject type", () => { + assert.deepEqual(subjectRef("Discussion", null), { kind: "discussion" }); + assert.deepEqual(subjectRef("Release", undefined), { kind: "release" }); + assert.deepEqual(subjectRef("PullRequest", ""), { kind: "pull" }); +}); + +test("an unknown subject type is 'other', never a crash", () => { + assert.deepEqual(subjectRef("CheckSuite", null), { kind: "other" }); + assert.deepEqual(subjectRef(undefined, undefined), { kind: "other" }); +}); + +// ── html urls ──────────────────────────────────────────────────────────────── + +function notif(over: Partial<RawNotification> = {}): RawNotification { + return { + id: "1", + unread: true, + reason: "mention", + updated_at: "2026-08-20T10:00:00Z", + subject: { title: "Something", type: "Issue", url: "https://api.github.com/repos/acme/w/issues/3" }, + repository: { full_name: "acme/w", html_url: "https://github.com/acme/w" }, + ...over, + }; +} + +test("issue/PR/release subjects get exact web urls", () => { + assert.equal(subjectHtmlUrl(notif()), "https://github.com/acme/w/issues/3"); + assert.equal( + subjectHtmlUrl( + notif({ subject: { type: "PullRequest", url: "https://api.github.com/repos/acme/w/pulls/9" } }), + ), + "https://github.com/acme/w/pull/9", + ); + assert.equal( + subjectHtmlUrl( + notif({ subject: { type: "Release", url: "https://api.github.com/repos/acme/w/releases/12" } }), + ), + "https://github.com/acme/w/releases/12", + ); +}); + +test("a commit subject gets a /commit/<sha> url instead of the bare repo", () => { + assert.equal( + subjectHtmlUrl( + notif({ subject: { type: "Commit", url: "https://api.github.com/repos/acme/w/commits/abc1234" } }), + ), + "https://github.com/acme/w/commit/abc1234", + ); +}); + +test("an unparseable subject falls back to the repository", () => { + assert.equal( + subjectHtmlUrl(notif({ subject: { type: "Discussion", url: null } })), + "https://github.com/acme/w", + ); +}); + +// ── the mapper ─────────────────────────────────────────────────────────────── + +test("mapNotification carries the parsed subject + last-read through", () => { + const t = mapNotification( + notif({ + last_read_at: "2026-08-19T09:00:00Z", + subject: { title: "Fix it", type: "PullRequest", url: "https://api.github.com/repos/acme/w/pulls/31" }, + }), + ); + assert.equal(t.subjectKind, "pull"); + assert.equal(t.subjectNumber, 31); + assert.equal(t.subjectSha, undefined); + assert.equal(t.lastReadAt, "2026-08-19T09:00:00Z"); + assert.equal(t.htmlUrl, "https://github.com/acme/w/pull/31"); +}); + +test("mapNotification tolerates a missing subject and repository", () => { + const t = mapNotification({ + id: "9", + unread: false, + reason: "subscribed", + updated_at: "", + subject: null, + repository: null, + }); + assert.equal(t.title, "(untitled)"); + assert.equal(t.repo, ""); + assert.equal(t.subjectKind, "other"); + assert.equal(t.htmlUrl, ""); +}); diff --git a/apps/desktop/test/opMatrix.test.ts b/apps/desktop/test/opMatrix.test.ts new file mode 100644 index 0000000..1b0b835 --- /dev/null +++ b/apps/desktop/test/opMatrix.test.ts @@ -0,0 +1,462 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { writeFileSync, readFileSync, mkdtempSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { RepoStore } from "../src/main/repoStore"; +import { GitBridge } from "../src/main/gitBridge"; +import { removeTempRepo } from "./tmpRepo"; + +/** + * THE TABLE. + * + * Every mid-operation state the Changes banner can show, crossed with the shape + * the working tree is in, asserting three things per cell: what the banner + * NAMES the operation, which of its buttons can act, and — where it matters — + * what the repository looks like after the button is pressed. + * + * This exists because three consecutive fix waves each shipped a defect with + * the same signature: a change correct about the state it was aimed at and + * wrong about a neighbouring one nobody re-tested. Each of those would have + * been one red cell here. + * + * · A rebase paused at `edit` — which the user ASKED for — was reported as + * "nothing left to commit" and its primary button became `rebase --skip`, + * which hard-resets the working tree. + * · Fixing that by dropping rebase from the predicate entirely left an + * apply-backend rebase with NO way to finish: `--continue` is refused on an + * emptied patch, and `--skip`, which git itself names, had no button left. + * · `rebase --rebase-merges` stopping on a `merge` step leaves MERGE_HEAD and + * `rebase-merge/` at once; "merging first" named it a merge, so Abort ran + * `git merge --abort` — discarding a hand resolution and leaving the rebase + * running underneath it. + * + * A cell asserts CAPABILITY (`canContinue` / `canSkip`), not a button label, so + * it holds whatever the banner chooses to render. The renderer reads the same + * two fields; it no longer re-derives them, which is the other half of why this + * class of defect kept recurring. + */ + +interface Repo { + root: string; + git: (...a: string[]) => string; + tryGit: (...a: string[]) => void; + bridge: () => Promise<GitBridge>; +} + +function repo(name: string): Repo { + const root = mkdtempSync(`${tmpdir()}/gs-matrix-${name}-`); + const git = (...a: string[]): string => + execFileSync("git", a, { cwd: root, stdio: ["ignore", "pipe", "pipe"] }).toString(); + const tryGit = (...a: string[]): void => { + try { + execFileSync("git", a, { cwd: root, stdio: "ignore" }); + } catch { + /* the stop is the point */ + } + }; + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + return { + root, + git, + tryGit, + bridge: async () => { + const repos = new RepoStore([]); + await repos.open(root); + return new GitBridge(repos); + }, + }; +} + +/** base → (main: "main side") + (side: "their side"), conflicting on f.txt. */ +function diverge(r: Repo): { main: string; sideSha: string } { + writeFileSync(`${r.root}/f.txt`, "base\n"); + r.git("add", "-A"); + r.git("commit", "-qm", "base"); + const main = r.git("rev-parse", "--abbrev-ref", "HEAD").trim(); + r.git("checkout", "-qb", "side"); + writeFileSync(`${r.root}/f.txt`, "their side\n"); + r.git("commit", "-qam", "their change"); + writeFileSync(`${r.root}/g.txt`, "second\n"); + r.git("add", "-A"); + r.git("commit", "-qm", "their second change"); + const sideSha = r.git("rev-parse", "HEAD~1").trim(); + r.git("checkout", "-q", main); + writeFileSync(`${r.root}/f.txt`, "main side\n"); + r.git("commit", "-qam", "our change"); + return { main, sideSha }; +} + +/** Resolve every conflict by keeping OUR side, and stage it. */ +function resolveKeepingOurs(r: Repo): void { + writeFileSync(`${r.root}/f.txt`, "main side\n"); + r.git("add", "f.txt"); +} + +/** Resolve with something genuinely new, so there IS a commit to record. */ +function resolveWithNewContent(r: Repo): void { + writeFileSync(`${r.root}/f.txt`, "a real resolution\n"); + r.git("add", "f.txt"); +} + +// ── The table ──────────────────────────────────────────────────────────────── + +test("merge: conflicted, then resolved", async () => { + const r = repo("merge"); + try { + diverge(r); + r.tryGit("merge", "side"); + const b = await r.bridge(); + + let st = await b.opState(); + assert.equal(st.kind, "merge", "named a merge"); + assert.equal(st.conflicts, 1); + assert.equal(st.canContinue, false, "cannot continue while conflicted"); + assert.equal(st.canSkip, false, "there is no `git merge --skip`"); + + // Resolved to exactly OUR side: git still allows an empty merge commit, so + // Continue is right here and Skip must not appear. + resolveKeepingOurs(r); + st = await b.opState(); + assert.equal(st.canContinue, true, "an empty merge commit is legal — Continue finishes it"); + assert.equal(st.canSkip, false, "and Skip is still not a thing git offers"); + assert.equal((await b.mergeContinue()).ok, true, "and it works"); + assert.equal((await b.opState()).kind, null, "the merge is over"); + } finally { + removeTempRepo(r.root); + } +}); + +test("rebase, merge backend: conflicted, then resolved to our own side", async () => { + const r = repo("rebasemerge"); + try { + const { main } = diverge(r); + r.git("checkout", "-q", "side"); + r.tryGit("rebase", main); + const b = await r.bridge(); + + let st = await b.opState(); + assert.equal(st.kind, "rebase", "named a rebase"); + assert.equal(st.canContinue, false, "not while conflicted"); + assert.equal(st.canSkip, false, "the merge backend's --continue drops an emptied commit itself"); + + // Resolved to the base's side: the commit is now empty. + resolveKeepingOurs(r); + st = await b.opState(); + assert.equal(st.nothingToCommit, true, "there is nothing left to record"); + assert.equal(st.canContinue, true, "but --continue handles that on this backend"); + assert.equal(st.canSkip, false, "so no hard-resetting Skip is offered"); + assert.equal((await b.rebaseContinue()).ok, true, "and Continue really does finish it"); + assert.equal((await b.opState()).kind, null, "the rebase is over"); + assert.equal(existsSync(`${r.root}/g.txt`), true, "with the rest of the branch replayed"); + } finally { + removeTempRepo(r.root); + } +}); + +test("rebase, APPLY backend: an emptied patch can still be finished", async () => { + const r = repo("rebaseapply"); + try { + const { main } = diverge(r); + r.git("checkout", "-q", "side"); + // The backend a `rebase.backend = apply` config, `--whitespace=`, `-C<n>` + // or a plain `git rebase --apply` selects — and `git pull --rebase` honours. + r.tryGit("rebase", "--apply", main); + const b = await r.bridge(); + + let st = await b.opState(); + assert.equal(st.kind, "rebase", "still a rebase, not an `am`"); + assert.equal(st.canContinue, false, "not while conflicted"); + + resolveKeepingOurs(r); + st = await b.opState(); + assert.equal(st.nothingToCommit, true, "the patch is now empty"); + assert.equal( + st.canContinue, + false, + "and THIS backend refuses --continue — offering it is a button that can never work", + ); + assert.equal( + st.canSkip, + true, + "so Skip is offered, which is what git's own advice names: without it the rebase has no way to finish", + ); + + assert.equal((await b.rebaseSkip()).ok, true, "and Skip finishes it"); + assert.equal((await b.opState()).kind, null, "the rebase is over"); + assert.equal(existsSync(`${r.root}/g.txt`), true, "with the rest of the branch replayed"); + } finally { + removeTempRepo(r.root); + } +}); + +test("rebase paused at an `edit` stop: Continue only, never Skip", async () => { + const r = repo("editstop"); + try { + writeFileSync(`${r.root}/f.txt`, "base\n"); + r.git("add", "-A"); + r.git("commit", "-qm", "base"); + r.git("branch", "trunk"); + writeFileSync(`${r.root}/a.txt`, "a\n"); + r.git("add", "-A"); + r.git("commit", "-qm", "the commit to edit"); + + const seq = `${r.root}/seq.sh`; + writeFileSync(seq, '#!/bin/sh\nsed -i.bak "s/^pick /edit /" "$1"\n'); + execFileSync("chmod", ["+x", seq]); + execFileSync("git", ["rebase", "-i", "trunk"], { + cwd: r.root, + env: { ...process.env, GIT_SEQUENCE_EDITOR: seq, GIT_EDITOR: "true" }, + stdio: "ignore", + }); + + const b = await r.bridge(); + const st = await b.opState(); + assert.equal(st.kind, "rebase"); + assert.equal(st.conflicts, 0, "nothing is conflicted — the pause was deliberate"); + assert.equal(st.nothingToCommit, true, "and the index matches HEAD, exactly like an empty patch"); + assert.equal(st.canContinue, true, "Continue is the way on"); + assert.equal( + st.canSkip, + false, + "and Skip must NOT be offered: `rebase --skip` hard-resets the amend this pause exists to make", + ); + } finally { + removeTempRepo(r.root); + } +}); + +test("a rebase stopped inside a merge step is a rebase, not a merge", async () => { + const r = repo("rebasemerges"); + try { + writeFileSync(`${r.root}/f.txt`, "base\n"); + r.git("add", "-A"); + r.git("commit", "-qm", "base"); + const main = r.git("rev-parse", "--abbrev-ref", "HEAD").trim(); + r.git("branch", "trunk"); + + // A topic merged into the branch, so --rebase-merges has a merge to replay. + r.git("checkout", "-qb", "topic"); + writeFileSync(`${r.root}/f.txt`, "topic\n"); + r.git("commit", "-qam", "topic change"); + r.git("checkout", "-q", main); + writeFileSync(`${r.root}/f.txt`, "mainline\n"); + r.git("commit", "-qam", "mainline change"); + r.tryGit("merge", "topic"); + if (existsSync(`${r.root}/.git/MERGE_HEAD`)) { + writeFileSync(`${r.root}/f.txt`, "merged by hand\n"); + r.git("add", "f.txt"); + r.git("commit", "-qm", "merge topic"); + } + // Move trunk forward so replaying conflicts. + r.git("checkout", "-q", "trunk"); + writeFileSync(`${r.root}/f.txt`, "trunk moved\n"); + r.git("commit", "-qam", "trunk moves"); + r.git("checkout", "-q", main); + r.tryGit("rebase", "--rebase-merges", "trunk"); + + const b = await r.bridge(); + const st = await b.opState(); + if (st.kind === null) return; // git resolved it without stopping — nothing to assert + assert.equal( + st.kind, + "rebase", + `a stop inside a rebase is a rebase even when MERGE_HEAD is set (merging=${st.merging}, rebasing=${st.rebasing}). ` + + "Naming it a merge points Abort at `git merge --abort`, which discards the resolution and leaves the rebase running.", + ); + } finally { + removeTempRepo(r.root); + } +}); + +test("cherry-pick: conflicted, empty, and resolved", async () => { + const r = repo("pick"); + try { + const { sideSha } = diverge(r); + r.tryGit("cherry-pick", sideSha); + const b = await r.bridge(); + + let st = await b.opState(); + assert.equal(st.kind, "cherry-pick"); + assert.equal(st.canContinue, false, "not while conflicted"); + assert.equal(st.canSkip, true, "but Skip is always available to the sequencer"); + + // Resolved to our own side — the pick is now empty and git refuses it. + resolveKeepingOurs(r); + st = await b.opState(); + assert.equal(st.nothingToCommit, true); + assert.equal(st.canContinue, false, "an empty pick cannot be continued"); + assert.equal(st.canSkip, true, "Skip is the way out, and git names it"); + assert.equal((await b.cherryPickContinue()).expected, true, "and the refusal is a condition, not a crash"); + assert.equal((await b.cherryPickSkip()).ok, true, "Skip works"); + assert.equal((await b.opState()).kind, null, "the pick is over"); + } finally { + removeTempRepo(r.root); + } +}); + +test("cherry-pick resolved with real content can be continued", async () => { + const r = repo("pickreal"); + try { + const { sideSha } = diverge(r); + r.tryGit("cherry-pick", sideSha); + const b = await r.bridge(); + resolveWithNewContent(r); + + const st = await b.opState(); + assert.equal(st.nothingToCommit, false, "there IS something to record"); + assert.equal(st.canContinue, true, "so Continue is offered"); + assert.equal((await b.cherryPickContinue()).ok, true, "and it works"); + assert.equal((await b.opState()).kind, null); + } finally { + removeTempRepo(r.root); + } +}); + +test("revert: conflicted, then resolved", async () => { + const r = repo("revert"); + try { + writeFileSync(`${r.root}/f.txt`, "one\n"); + r.git("add", "-A"); + r.git("commit", "-qm", "one"); + writeFileSync(`${r.root}/f.txt`, "two\n"); + r.git("commit", "-qam", "two"); + const two = r.git("rev-parse", "HEAD").trim(); + writeFileSync(`${r.root}/f.txt`, "three\n"); + r.git("commit", "-qam", "three"); + r.tryGit("revert", "--no-edit", two); + const b = await r.bridge(); + + let st = await b.opState(); + assert.equal(st.kind, "revert"); + assert.equal(st.canContinue, false, "not while conflicted"); + assert.equal(st.canSkip, true); + + writeFileSync(`${r.root}/f.txt`, "reverted properly\n"); + r.git("add", "f.txt"); + st = await b.opState(); + assert.equal(st.canContinue, true); + assert.equal((await b.revertContinue()).ok, true); + assert.equal((await b.opState()).kind, null); + } finally { + removeTempRepo(r.root); + } +}); + +test("am: a patch that will not apply is named, and can be skipped", async () => { + const r = repo("am"); + try { + writeFileSync(`${r.root}/f.txt`, "base\n"); + r.git("add", "-A"); + r.git("commit", "-qm", "base"); + const main = r.git("rev-parse", "--abbrev-ref", "HEAD").trim(); + r.git("checkout", "-qb", "series"); + writeFileSync(`${r.root}/f.txt`, "from patch one\n"); + r.git("commit", "-qam", "patch one"); + writeFileSync(`${r.root}/g.txt`, "second\n"); + r.git("add", "-A"); + r.git("commit", "-qm", "patch two"); + writeFileSync(`${r.root}/series.patch`, r.git("format-patch", "-2", "--stdout")); + r.git("checkout", "-q", main); + writeFileSync(`${r.root}/f.txt`, "diverged\n"); + r.git("commit", "-qam", "diverged"); + r.tryGit("am", "series.patch"); + + const b = await r.bridge(); + const st = await b.opState(); + assert.equal(st.kind, "am", "an am is named as itself, never as a rebase"); + assert.equal(st.rebasing, false, "and the rebase verbs, which git refuses here, are not offered"); + assert.equal(st.canSkip, true, "git's own advice on this screen is `am --skip`"); + + assert.equal((await b.amSkip()).ok, true, "and it works"); + assert.equal(existsSync(`${r.root}/g.txt`), true, "the rest of the series still applied"); + assert.equal((await b.opState()).kind, null, "the session is over"); + } finally { + removeTempRepo(r.root); + } +}); + +test("a clean repo is in no operation at all", async () => { + const r = repo("clean"); + try { + writeFileSync(`${r.root}/f.txt`, "base\n"); + r.git("add", "-A"); + r.git("commit", "-qm", "base"); + const st = await (await r.bridge()).opState(); + assert.equal(st.kind, null, "no banner"); + assert.equal(st.canContinue, false); + assert.equal(st.canSkip, false); + assert.equal(st.nothingToCommit, false, "and nothing claims otherwise"); + } finally { + removeTempRepo(r.root); + } +}); + +/** + * The one cell that is about a WRITE rather than a state: Skip is offered only + * where git names it, because `git rebase --skip` hard-resets the working tree + * — it reverts unrelated unstaged edits, which no other skip does. + */ +test("rebase --skip is destructive, which is why it is never the default way on", async () => { + const r = repo("skipdestroys"); + try { + const { main } = diverge(r); + r.git("checkout", "-q", "side"); + r.tryGit("rebase", "--apply", main); + resolveKeepingOurs(r); + // An unrelated edit, sitting in the working tree. + writeFileSync(`${r.root}/unrelated.txt`, "work in progress\n"); + r.git("add", "unrelated.txt"); + r.git("commit", "-qm", "unrelated"); + writeFileSync(`${r.root}/unrelated.txt`, "EDITED, not staged\n"); + + const b = await r.bridge(); + assert.equal((await b.opState()).canSkip, true, "Skip is the only way on here"); + await b.rebaseSkip(); + + assert.notEqual( + readFileSync(`${r.root}/unrelated.txt`, "utf8"), + "EDITED, not staged\n", + "rebase --skip really does hard-reset the tree — which is why the banner asks before running it", + ); + } finally { + removeTempRepo(r.root); + } +}); + +/** + * "Uncheck all" is not `git merge --quit`. + * + * `unstageAll` was one line — `git reset`, no pathspec — and a bare `git reset` + * clears MERGE_HEAD. So unchecking everything mid-merge silently ENDED the + * merge: the next Commit recorded a one-parent commit carrying the merged + * content, with no second parent, and `git merge --abort` afterwards answers + * "There is no merge to abort". Its neighbour `stageAll` goes to real trouble + * over conflicts; this one had no conflict check and no in-progress check. + */ +test("unstaging everything mid-merge leaves the merge in progress", async () => { + const r = repo("unstageall"); + try { + diverge(r); + r.tryGit("merge", "side"); + const b = await r.bridge(); + assert.equal((await b.opState()).kind, "merge", "a merge is in progress"); + + resolveWithNewContent(r); + assert.equal((await b.unstageAll()).ok, true, "unchecking everything succeeds"); + + const st = await b.opState(); + assert.equal(st.kind, "merge", "and the merge is STILL in progress"); + assert.equal(existsSync(`${r.root}/.git/MERGE_HEAD`), true, "MERGE_HEAD survives"); + assert.equal( + r.git("diff", "--cached", "--name-only").trim(), + "", + "with the index emptied, which is what the control claims to do", + ); + } finally { + removeTempRepo(r.root); + } +}); diff --git a/apps/desktop/test/opState.test.ts b/apps/desktop/test/opState.test.ts new file mode 100644 index 0000000..ed7efb3 --- /dev/null +++ b/apps/desktop/test/opState.test.ts @@ -0,0 +1,519 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { writeFileSync, readFileSync, mkdtempSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { RepoStore } from "../src/main/repoStore"; +import { GitBridge } from "../src/main/gitBridge"; +import { removeTempRepo } from "./tmpRepo"; + +/** + * The Changes banner names the operation you are in the middle of and offers + * the two ways out. Both halves were wrong for three of the four operations. + * + * `opState` decided "rebasing" from the mere presence of `.git/rebase-apply`. + * That directory belongs to `git am` just as much as to a rebase on the apply + * backend — git tells them apart by a marker file INSIDE it, `applying` for am + * and `rebasing` for a rebase. So a conflicted `git am` beside the app showed + * "rebase in progress", and both of its buttons ran `git rebase`, which refuses. + */ +function repo(name: string): { root: string; git: (...a: string[]) => string } { + const root = mkdtempSync(`${tmpdir()}/gs-op-${name}-`); + const git = (...a: string[]): string => + execFileSync("git", a, { cwd: root, stdio: ["ignore", "pipe", "pipe"] }).toString(); + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + return { root, git }; +} + +/** Run a command that is EXPECTED to fail (a conflict), swallowing the throw. */ +function tryGit(root: string, ...a: string[]): void { + try { + execFileSync("git", a, { cwd: root, stdio: "ignore" }); + } catch { + /* the conflict is the point */ + } +} + +test("a conflicted `git am` is not reported as a rebase", async () => { + const { root, git } = repo("am"); + try { + writeFileSync(`${root}/f.txt`, "base\n"); + git("add", "-A"); + git("commit", "-qm", "base"); + + // A patch built on a DIFFERENT base than the branch it is applied to, so + // applying it conflicts and leaves `git am` stopped. + git("checkout", "-qb", "side"); + writeFileSync(`${root}/f.txt`, "from the patch\n"); + git("commit", "-qam", "patch side"); + const patch = git("format-patch", "-1", "--stdout"); + writeFileSync(`${root}/p.patch`, patch); + + git("checkout", "-q", "master"); + writeFileSync(`${root}/f.txt`, "from the branch\n"); + git("commit", "-qam", "branch side"); + tryGit(root, "am", "p.patch"); + + assert.ok(existsSync(`${root}/.git/rebase-apply`), "git am does use rebase-apply"); + assert.ok(existsSync(`${root}/.git/rebase-apply/applying`), "and marks it `applying`"); + + const repos = new RepoStore([]); + await repos.open(root); + const st = await new GitBridge(repos).opState(); + + assert.equal( + st.rebasing, + false, + "an `am` is not a rebase — the banner's Abort/Continue would run `git rebase` and be refused", + ); + // Telling them apart is only half of it. Reported as NOTHING, the app shows + // an ordinary dirty tree with a live Commit button — and committing strands + // the rest of the series and replaces the patch author with you. + assert.equal(st.amApplying, true, "it is named as what it is, so the banner can render"); + } finally { + removeTempRepo(root); + } +}); + +test("a real rebase on the apply backend still reports as a rebase", async () => { + const { root, git } = repo("apply"); + try { + writeFileSync(`${root}/f.txt`, "base\n"); + git("add", "-A"); + git("commit", "-qm", "base"); + git("checkout", "-qb", "side"); + writeFileSync(`${root}/f.txt`, "side\n"); + git("commit", "-qam", "side"); + git("checkout", "-q", "master"); + writeFileSync(`${root}/f.txt`, "main\n"); + git("commit", "-qam", "main"); + git("checkout", "-q", "side"); + // --apply forces the backend that shares its directory with `git am`. + tryGit(root, "rebase", "--apply", "master"); + + assert.ok(existsSync(`${root}/.git/rebase-apply`), "the apply backend is in use"); + assert.ok(!existsSync(`${root}/.git/rebase-apply/applying`), "with no `applying` marker"); + + const repos = new RepoStore([]); + await repos.open(root); + const st = await new GitBridge(repos).opState(); + + assert.equal(st.rebasing, true, "this one really is a rebase"); + } finally { + removeTempRepo(root); + } +}); + +/** + * The banner said "cherry-pick in progress" and then aborted it with + * `git merge --abort`, which fails outright — MERGE_HEAD does not exist during + * a cherry-pick. The one control offering a way out did nothing at all. + */ +test("a stopped cherry-pick aborts itself, not a merge", async () => { + const { root, git } = repo("pick"); + try { + writeFileSync(`${root}/f.txt`, "base\n"); + git("add", "-A"); + git("commit", "-qm", "base"); + git("checkout", "-qb", "side"); + writeFileSync(`${root}/f.txt`, "side\n"); + git("commit", "-qam", "side"); + const sideSha = git("rev-parse", "HEAD").trim(); + git("checkout", "-q", "master"); + writeFileSync(`${root}/f.txt`, "main\n"); + git("commit", "-qam", "main"); + const before = git("rev-parse", "HEAD").trim(); + tryGit(root, "cherry-pick", sideSha); + + const repos = new RepoStore([]); + await repos.open(root); + const b = new GitBridge(repos); + + const st = await b.opState(); + assert.equal(st.cherryPicking, true, "the banner names a cherry-pick"); + assert.equal(st.merging, false, "and it is not a merge"); + + const wrong = await b.mergeAbort(); + assert.equal(wrong.ok, false, "`git merge --abort` cannot end a cherry-pick"); + assert.ok(existsSync(`${root}/.git/CHERRY_PICK_HEAD`), "so it is still in progress"); + + const right = await b.cherryPickAbort(); + assert.equal(right.ok, true, "its own abort ends it"); + assert.ok(!existsSync(`${root}/.git/CHERRY_PICK_HEAD`), "the state is gone"); + assert.equal(git("rev-parse", "HEAD").trim(), before, "and HEAD is back where it started"); + } finally { + removeTempRepo(root); + } +}); + +test("a stopped revert aborts itself, not a merge", async () => { + const { root, git } = repo("revert"); + try { + writeFileSync(`${root}/f.txt`, "one\n"); + git("add", "-A"); + git("commit", "-qm", "one"); + writeFileSync(`${root}/f.txt`, "two\n"); + git("commit", "-qam", "two"); + const two = git("rev-parse", "HEAD").trim(); + writeFileSync(`${root}/f.txt`, "three\n"); + git("commit", "-qam", "three"); + const before = git("rev-parse", "HEAD").trim(); + tryGit(root, "revert", "--no-edit", two); + + const repos = new RepoStore([]); + await repos.open(root); + const b = new GitBridge(repos); + + const st = await b.opState(); + assert.equal(st.reverting, true, "the banner names a revert"); + + const wrong = await b.mergeAbort(); + assert.equal(wrong.ok, false, "`git merge --abort` cannot end a revert"); + assert.ok(existsSync(`${root}/.git/REVERT_HEAD`), "so it is still in progress"); + + const right = await b.revertAbort(); + assert.equal(right.ok, true, "its own abort ends it"); + assert.ok(!existsSync(`${root}/.git/REVERT_HEAD`), "the state is gone"); + assert.equal(git("rev-parse", "HEAD").trim(), before, "and HEAD is back where it started"); + } finally { + removeTempRepo(root); + } +}); + +/** + * Continue, too. `--no-edit` is what keeps it from stopping in an editor the + * app cannot show — verified against real git for both verbs, because + * `--continue` refuses several flags and a rejected one would leave the user + * exactly where the wrong-abort left them. + */ +test("a resolved cherry-pick and revert are finished by their own continue", async () => { + for (const kind of ["cherry-pick", "revert"] as const) { + const { root, git } = repo(kind); + try { + writeFileSync(`${root}/f.txt`, "one\n"); + git("add", "-A"); + git("commit", "-qm", "one"); + writeFileSync(`${root}/f.txt`, "two\n"); + git("commit", "-qam", "two"); + const two = git("rev-parse", "HEAD").trim(); + writeFileSync(`${root}/f.txt`, "three\n"); + git("commit", "-qam", "three"); + + if (kind === "revert") { + tryGit(root, "revert", "--no-edit", two); + } else { + // A cherry-pick needs a commit from somewhere else to conflict with. + git("checkout", "-qb", "side", two); + writeFileSync(`${root}/f.txt`, "side\n"); + git("commit", "-qam", "side"); + const side = git("rev-parse", "HEAD").trim(); + git("checkout", "-q", "-"); + tryGit(root, "cherry-pick", side); + } + // Resolve, the way the conflict view would. + writeFileSync(`${root}/f.txt`, "resolved\n"); + git("add", "f.txt"); + + const repos = new RepoStore([]); + await repos.open(root); + const b = new GitBridge(repos); + const r = kind === "revert" ? await b.revertContinue() : await b.cherryPickContinue(); + + assert.equal(r.ok, true, `${kind}: continue commits the resolution — ${r.message ?? ""}`); + assert.ok(!existsSync(`${root}/.git/${kind === "revert" ? "REVERT_HEAD" : "CHERRY_PICK_HEAD"}`), + `${kind}: and the operation is over`); + assert.equal(git("show", "-s", "--format=%s", "HEAD").trim().length > 0, true); + } finally { + removeTempRepo(root); + } + } +}); + +/** + * The half that makes the banner worth having: a plain commit must not be + * allowed to derail a part-applied series. + * + * Measured before the guard: staging the resolution and pressing Commit + * succeeded, HEAD carried the user's message and authorship instead of the + * patch's, `rebase-apply/` was still on disk, and the second patch was never + * applied — with nothing on screen to say any of that had happened. + */ +test("a plain commit cannot derail a part-applied patch series", async () => { + const { root, git } = repo("amcommit"); + try { + writeFileSync(`${root}/f.txt`, "base\n"); + git("add", "-A"); + git("commit", "-qm", "base"); + const main = git("rev-parse", "--abbrev-ref", "HEAD").trim(); + + git("checkout", "-qb", "series"); + writeFileSync(`${root}/f.txt`, "from the patch\n"); + git("commit", "-qam", "patch one"); + writeFileSync(`${root}/g.txt`, "second\n"); + git("add", "-A"); + git("commit", "-qm", "patch two"); + writeFileSync(`${root}/series.patch`, git("format-patch", "-2", "--stdout")); + + git("checkout", "-q", main); + writeFileSync(`${root}/f.txt`, "from the branch\n"); + git("commit", "-qam", "diverged"); + tryGit(root, "am", "series.patch"); + + const repos = new RepoStore([]); + await repos.open(root); + const b = new GitBridge(repos); + assert.equal((await b.opState()).amApplying, true, "the series is stopped"); + + // Resolve, exactly as the conflict view would. + writeFileSync(`${root}/f.txt`, "resolved\n"); + await b.stage("f.txt"); + + const bad = await b.commit({ message: "my own message" }); + assert.equal(bad.ok, false, "Commit is refused"); + assert.equal(bad.expected, true, "as a condition, not a crash to report"); + assert.match(bad.message ?? "", /git am/i, "and says which operation is in the way"); + assert.notEqual( + git("show", "-s", "--format=%s", "HEAD").trim(), + "my own message", + "nothing was committed", + ); + + // Continue is the way through, and it keeps the patch's own metadata. + const good = await b.amContinue(); + assert.equal(good.ok, true, good.message ?? ""); + assert.equal( + git("show", "-s", "--format=%s", "HEAD").trim(), + "patch two", + "the whole series applied, not just the conflicted patch", + ); + assert.equal(existsSync(`${root}/g.txt`), true, "including the patch that had not been reached"); + assert.equal((await b.opState()).amApplying, false, "and the session is over"); + } finally { + removeTempRepo(root); + } +}); + +/** Abandoning a series says so when git declines to rewind a moved HEAD. */ +test("abandoning a patch series reports it when git does not rewind", async () => { + const { root, git } = repo("amabort"); + try { + writeFileSync(`${root}/f.txt`, "base\n"); + git("add", "-A"); + git("commit", "-qm", "base"); + const main = git("rev-parse", "--abbrev-ref", "HEAD").trim(); + git("checkout", "-qb", "series"); + writeFileSync(`${root}/f.txt`, "patch\n"); + git("commit", "-qam", "patch one"); + writeFileSync(`${root}/series.patch`, git("format-patch", "-1", "--stdout")); + git("checkout", "-q", main); + writeFileSync(`${root}/f.txt`, "diverged\n"); + git("commit", "-qam", "diverged"); + const before = git("rev-parse", "HEAD").trim(); + tryGit(root, "am", "series.patch"); + + const repos = new RepoStore([]); + await repos.open(root); + const b = new GitBridge(repos); + + const r = await b.amAbort(); + assert.equal(r.ok, true, r.message ?? ""); + assert.equal((await b.opState()).amApplying, false, "the session is gone"); + assert.equal(git("rev-parse", "HEAD").trim(), before, "and HEAD is where the series started"); + } finally { + removeTempRepo(root); + } +}); + +/** + * Cherry-picking or reverting something that is already on the branch is the + * commonest way to get those wrong, and it does NOT leave a conflict: git stops + * with CHERRY_PICK_HEAD set and zero unmerged files. + * + * So the banner read "cherry-pick in progress — resolve and continue" over an + * empty file list, with Continue enabled; pressing it got git's refusal ("The + * previous cherry-pick is now empty") as a red error toast AND a filed crash + * report, and pointed at `git cherry-pick --skip`, which the app did not offer. + */ +test("an already-applied pick reports nothing to commit, and skip is what works", async () => { + const { root, git } = repo("emptypick"); + try { + writeFileSync(`${root}/f.txt`, "one\n"); + git("add", "-A"); + git("commit", "-qm", "one"); + writeFileSync(`${root}/f.txt`, "two\n"); + git("commit", "-qam", "two"); + const head = git("rev-parse", "HEAD").trim(); + // Pick the commit that is already the tip: legal, and immediately empty. + tryGit(root, "cherry-pick", head); + + const repos = new RepoStore([]); + await repos.open(root); + const b = new GitBridge(repos); + + const st = await b.opState(); + assert.equal(st.cherryPicking, true, "the pick is stopped"); + assert.equal(st.conflicts, 0, "with nothing conflicted — which is why a conflict count cannot see it"); + assert.equal(st.nothingToCommit, true, "so the banner is told there is nothing to continue with"); + + // The button the banner used to enable. + const cont = await b.cherryPickContinue(); + assert.equal(cont.ok, false, "Continue cannot succeed here"); + assert.equal(cont.expected, true, "and it is a condition, not a crash to report"); + + const skipped = await b.cherryPickSkip(); + assert.equal(skipped.ok, true, `Skip is the way out — ${skipped.message ?? ""}`); + assert.equal((await b.opState()).cherryPicking, false, "and the pick is over"); + assert.equal(git("rev-parse", "HEAD").trim(), head, "with history untouched"); + } finally { + removeTempRepo(root); + } +}); + +test("a genuine conflict still reports something to commit", async () => { + const { root, git } = repo("realconf"); + try { + writeFileSync(`${root}/f.txt`, "base\n"); + git("add", "-A"); + git("commit", "-qm", "base"); + git("checkout", "-qb", "side"); + writeFileSync(`${root}/f.txt`, "side\n"); + git("commit", "-qam", "side"); + const side = git("rev-parse", "HEAD").trim(); + git("checkout", "-q", "-"); + writeFileSync(`${root}/f.txt`, "main\n"); + git("commit", "-qam", "main"); + tryGit(root, "cherry-pick", side); + + const repos = new RepoStore([]); + await repos.open(root); + const b = new GitBridge(repos); + assert.equal((await b.opState()).conflicts, 1, "this one really is conflicted"); + + writeFileSync(`${root}/f.txt`, "resolved differently\n"); + git("add", "f.txt"); + const st = await b.opState(); + assert.equal(st.conflicts, 0, "resolved"); + assert.equal(st.nothingToCommit, false, "and there IS something to commit — Continue is right here"); + assert.equal((await b.cherryPickContinue()).ok, true, "and it works"); + } finally { + removeTempRepo(root); + } +}); + +/** + * A rebase paused at `edit` is NOT "nothing left to commit". + * + * The user asked for that pause — the Rebase view's own hint for the action is + * "Pause here so you can amend the commit". git has already applied the commit, + * so the index matches HEAD and nothing is conflicted, which is byte-identical + * to an empty cherry-pick from a conflict count's point of view. + * + * Reading it that way put a lie in the banner ("nothing left to commit, this + * one is already on the branch") and relabelled the single forward button + * **Skip** — `git rebase --skip`, which HARD-RESETS the working tree. The amend + * you paused to make is discarded, and in git's own split-a-commit flow + * (`edit`, then `git reset HEAD^`) the commit being split goes with it. No + * confirm dialog, and a green "Done." + */ +test("a rebase paused at an edit stop is never reported as nothing to commit", async () => { + const { root, git } = repo("editstop"); + try { + writeFileSync(`${root}/f.txt`, "base\n"); + git("add", "-A"); + git("commit", "-qm", "base"); + git("branch", "trunk"); + writeFileSync(`${root}/a.txt`, "a\n"); + git("add", "-A"); + git("commit", "-qm", "the commit to edit"); + + // `edit` on the only commit — the exact stop the Rebase view produces. + const seq = `${root}/seq.sh`; + writeFileSync(seq, '#!/bin/sh\nsed -i.bak "s/^pick /edit /" "$1"\n'); + execFileSync("chmod", ["+x", seq]); + execFileSync("git", ["rebase", "-i", "trunk"], { + cwd: root, + env: { ...process.env, GIT_SEQUENCE_EDITOR: seq, GIT_EDITOR: "true" }, + stdio: "ignore", + }); + + const repos = new RepoStore([]); + await repos.open(root); + const b = new GitBridge(repos); + const st = await b.opState(); + + assert.equal(st.rebasing, true, "the rebase is paused"); + assert.equal(st.conflicts, 0, "with nothing conflicted — which is why a conflict count cannot see it"); + // `nothingToCommit` is now the raw fact ("the index equals HEAD"), which IS + // true here — it is true at every deliberate pause. The decision moved to + // `canSkip`, where it can be made per operation and per backend, because + // making it from the raw fact alone is what put a hard-resetting Skip on + // this screen in the first place. The full cell lives in opMatrix.test.ts. + assert.equal(st.nothingToCommit, true, "the index does equal HEAD — that fact is not the decision"); + assert.equal( + st.canSkip, + false, + "and the decision is right: no hard-resetting Skip at a pause the user asked for", + ); + assert.equal(st.canContinue, true, "Continue is the way on"); + } finally { + removeTempRepo(root); + } +}); + +/** + * A patch that simply will not apply. + * + * The banner offered Continue — which git refuses, because there is nothing + * staged to record — and Abandon, which throws the whole series away. git's own + * advice on that screen is `git am --skip`, and it was offered nowhere. The + * refusal was also classified as a crash rather than a condition, so pressing + * the one enabled button filed a report each time. + */ +test("a patch that will not apply can be skipped, and the refusal is not a crash", async () => { + const { root, git } = repo("amskip"); + try { + writeFileSync(`${root}/f.txt`, "base\n"); + git("add", "-A"); + git("commit", "-qm", "base"); + const main = git("rev-parse", "--abbrev-ref", "HEAD").trim(); + + git("checkout", "-qb", "series"); + writeFileSync(`${root}/f.txt`, "from patch one\n"); + git("commit", "-qam", "patch one"); + writeFileSync(`${root}/g.txt`, "second\n"); + git("add", "-A"); + git("commit", "-qm", "patch two"); + writeFileSync(`${root}/series.patch`, git("format-patch", "-2", "--stdout")); + + git("checkout", "-q", main); + writeFileSync(`${root}/f.txt`, "diverged\n"); + git("commit", "-qam", "diverged"); + tryGit(root, "am", "series.patch"); + + const repos = new RepoStore([]); + await repos.open(root); + const b = new GitBridge(repos); + assert.equal((await b.opState()).amApplying, true, "the series is stopped"); + + // Continue with nothing resolved: git refuses, and that is a CONDITION. + const cont = await b.amContinue(); + assert.equal(cont.ok, false, "Continue cannot succeed on an unapplied patch"); + assert.equal(cont.expected, true, "and it is not reported as a crash"); + assert.ok((cont.message ?? "").length > 0, "with something the user can read"); + + const skipped = await b.amSkip(); + assert.equal(skipped.ok, true, `Skip drops that patch — ${skipped.message ?? ""}`); + assert.equal(existsSync(`${root}/g.txt`), true, "and the REST of the series still applied"); + assert.equal((await b.opState()).amApplying, false, "with the session finished"); + assert.equal( + readFileSync(`${root}/f.txt`, "utf8"), + "diverged\n", + "the skipped patch left the file as it was", + ); + } finally { + removeTempRepo(root); + } +}); diff --git a/apps/desktop/test/overlays.test.ts b/apps/desktop/test/overlays.test.ts new file mode 100644 index 0000000..ba70323 --- /dev/null +++ b/apps/desktop/test/overlays.test.ts @@ -0,0 +1,217 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { dismissLayers, openLayerCount, registerLayer, holdBackground } from "../src/renderer/overlays"; + +// Menus, modals, peeks and popovers all mount on document.body, so a view swap +// can't take them with it. The registry is what lets one call at the route +// change close whatever happens to be open — so its contract matters. + +test("registered layers are disposed on dismiss", () => { + const closed: string[] = []; + registerLayer(() => closed.push("a")); + registerLayer(() => closed.push("b")); + assert.equal(openLayerCount(), 2); + dismissLayers(); + assert.deepEqual(closed.sort(), ["a", "b"]); + assert.equal(openLayerCount(), 0); +}); + +test("newest closes first — a modal opened from a menu goes before the menu", () => { + const order: string[] = []; + registerLayer(() => order.push("menu")); + registerLayer(() => order.push("modal")); + dismissLayers(); + assert.deepEqual(order, ["modal", "menu"]); +}); + +test("a layer that closes itself releases and is not disposed twice", () => { + let disposals = 0; + const h = registerLayer(() => disposals++); + h.release(); + assert.equal(openLayerCount(), 0); + dismissLayers(); + assert.equal(disposals, 0, "released layers must not be disposed"); +}); + +test("dismissing while empty is a no-op", () => { + dismissLayers(); + assert.equal(openLayerCount(), 0); +}); + +test("a dispose that releases itself does not skip its neighbours", () => { + // Real openers call release() from inside their own close path, which mutates + // the registry mid-iteration — walking the live array would skip entries. + const closed: string[] = []; + const handles: Array<{ release: () => void }> = []; + for (const name of ["a", "b", "c"]) { + const h = registerLayer(() => { + closed.push(name); + h.release(); + }); + handles.push(h); + } + dismissLayers(); + assert.deepEqual(closed.sort(), ["a", "b", "c"]); + assert.equal(openLayerCount(), 0); +}); + +test("one throwing layer does not strand the others open", () => { + const closed: string[] = []; + registerLayer(() => closed.push("first")); + registerLayer(() => { + throw new Error("boom"); + }); + registerLayer(() => closed.push("last")); + dismissLayers(); + assert.deepEqual(closed.sort(), ["first", "last"]); + assert.equal(openLayerCount(), 0); +}); + +/** + * `inert` on the app shell has to be REFCOUNTED, because two open surfaces + * holding it at once is the ordinary case: a peek or a drawer opens a dialog. + * + * `holdBackground` used to skip anything already `inert`, so the inner surface + * recorded nothing — right only when the inner one closes first. It does not + * always: a route change disposes layers newest-first, the dialog vetoes (that + * is what `hasUnsavedWork` is for), the drawer beneath it does not, and the + * drawer's release stripped `inert` off the shell the surviving dialog still + * needed. What was left was a dialog claiming `aria-modal="true"` over a fully + * tab-reachable app — and activating anything back there routed the whole + * window behind a dialog the user could still see. + */ +interface FakeEl { + id: string; + attrs: Set<string>; + setAttribute(n: string, v: string): void; + removeAttribute(n: string): void; + hasAttribute(n: string): boolean; + contains(o: unknown): boolean; + matches(sel: string): boolean; +} + +function fakeEl(id: string): FakeEl { + const attrs = new Set<string>(); + return { + id, + attrs, + setAttribute: (n) => void attrs.add(n), + removeAttribute: (n) => void attrs.delete(n), + hasAttribute: (n) => attrs.has(n), + contains: (o) => o === undefined, + matches: () => false, + }; +} + +function withFakeBody<T>(children: FakeEl[], fn: () => T): T { + const g = globalThis as unknown as { document?: unknown }; + const prev = g.document; + g.document = { body: { children } }; + try { + return fn(); + } finally { + if (prev === undefined) delete g.document; + else g.document = prev; + } +} + +test("two surfaces holding the app back: the first to close does not un-hold it", () => { + const shell = fakeEl("app-shell"); + const drawerScrim = fakeEl("drawer"); + const dialogCard = fakeEl("dialog"); + + const releaseDrawer = withFakeBody([shell, drawerScrim, dialogCard], () => + holdBackground(drawerScrim as unknown as HTMLElement), + ); + assert.equal(shell.hasAttribute("inert"), true, "the drawer holds the shell back"); + + const releaseDialog = withFakeBody([shell, drawerScrim, dialogCard], () => + holdBackground(dialogCard as unknown as HTMLElement), + ); + assert.equal(shell.hasAttribute("inert"), true, "and so does the dialog opened from it"); + + // The route change disposes newest-first: the dialog vetoes and survives, the + // drawer beneath it does not. + releaseDrawer(); + assert.equal( + shell.hasAttribute("inert"), + true, + "the shell stays held — a dialog is still open over it", + ); + + releaseDialog(); + assert.equal(shell.hasAttribute("inert"), false, "and is released when the last surface goes"); +}); + +test("a live region is never held back, however many surfaces are open", () => { + const shell = fakeEl("app-shell"); + const toasts = fakeEl("toast-stack"); + const card = fakeEl("dialog"); + + const r1 = withFakeBody([shell, toasts, card], () => holdBackground(card as unknown as HTMLElement)); + assert.equal(toasts.hasAttribute("inert"), false, "toasts stay announceable and clickable"); + assert.equal(shell.hasAttribute("inert"), true); + r1(); + assert.equal(shell.hasAttribute("inert"), false); +}); + +test("releasing twice does not un-hold a surface that is still open", () => { + const shell = fakeEl("app-shell"); + const a = fakeEl("a"); + const b = fakeEl("b"); + const relA = withFakeBody([shell, a, b], () => holdBackground(a as unknown as HTMLElement)); + const relB = withFakeBody([shell, a, b], () => holdBackground(b as unknown as HTMLElement)); + relA(); + relA(); // idempotent — a double release must not decrement twice + assert.equal(shell.hasAttribute("inert"), true, "b is still holding it"); + relB(); + assert.equal(shell.hasAttribute("inert"), false); +}); + +/** + * A layer that DECLINES to close stays in the registry. + * + * Not every dispose closes: a dialog with unsaved work vetoes a route change, + * which is what `hasUnsavedWork` is for. `dismissLayers` cleared the array + * regardless, so that dialog was on screen and absent from the registry — and + * every predicate built on the registry then lied about it. `isTop()` false for + * the layer that IS the top one, so its Escape was dead; `openLayerCount()` + * zero with it open, so the page's own ← navigated out from under it. + */ +test("a layer that refuses to close is still in the registry afterwards", () => { + let aOpen = true; + let bOpen = true; + const a = registerLayer(() => { aOpen = false; a.release(); }, "surface", () => aOpen); + const b = registerLayer(() => { /* vetoes */ }, "modal", () => bOpen); + + assert.equal(openLayerCount(), 2); + assert.equal(b.isTop(), true, "the modal is on top before the sweep"); + + dismissLayers(); + + assert.equal(aOpen, false, "the layer that could close, did"); + assert.equal(openLayerCount(), 1, "and the one that refused is still counted"); + assert.equal(b.isTop(), true, "so it still owns Escape"); + + bOpen = false; + b.release(); + assert.equal(openLayerCount(), 0); +}); + +test("survivors keep their original order, ahead of anything opened during the sweep", () => { + let firstOpen = true; + let secondOpen = true; + const first = registerLayer(() => { /* vetoes */ }, "surface", () => firstOpen); + const second = registerLayer(() => { /* vetoes */ }, "modal", () => secondOpen); + + dismissLayers(); + + assert.equal(openLayerCount(), 2, "both refused, both kept"); + assert.equal(second.isTop(), true, "and the NEWER one is still the top layer"); + assert.equal(first.isTop(), false); + + firstOpen = false; + secondOpen = false; + first.release(); + second.release(); +}); diff --git a/apps/desktop/test/parseGitHubRemote.test.ts b/apps/desktop/test/parseGitHubRemote.test.ts new file mode 100644 index 0000000..7324a6c --- /dev/null +++ b/apps/desktop/test/parseGitHubRemote.test.ts @@ -0,0 +1,36 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseGitHubRemote } from "../src/main/githubBridge"; + +// The remote-URL parser behind "is this a GitHub repo?". The old regex refused +// SSH host aliases (the standard multi-account setup), mis-parsed ssh:// URLs +// with ports (owner="22" → every API call 404'd), accepted non-GitHub hosts +// containing "github.com", and choked on trailing slashes. Each case below is +// one of those real setups. + +const CASES: Array<[string, { owner: string; repo: string } | undefined]> = [ + ["https://github.com/GitStudioHQ/gitstudio.git", { owner: "GitStudioHQ", repo: "gitstudio" }], + ["https://github.com/GitStudioHQ/gitstudio", { owner: "GitStudioHQ", repo: "gitstudio" }], + ["https://github.com/org/repo/", { owner: "org", repo: "repo" }], + ["git@github.com:org/repo.git", { owner: "org", repo: "repo" }], + // SSH host aliases — the multi-account ~/.ssh/config pattern. + ["git@github.com-work:org/repo.git", { owner: "org", repo: "repo" }], + ["git@github.com-personal:me/dotfiles.git", { owner: "me", repo: "dotfiles" }], + // ssh:// with an explicit port — used to parse owner as "22". + ["ssh://git@github.com:22/org/repo.git", { owner: "org", repo: "repo" }], + ["ssh://git@github.com/org/repo.git", { owner: "org", repo: "repo" }], + ["git://github.com/org/repo.git", { owner: "org", repo: "repo" }], + // Not GitHub — must all be rejected. + ["https://evilnotgithub.com/a/b", undefined], + ["https://github.mycorp.com/org/repo.git", undefined], + ["git@gitlab.com:org/repo.git", undefined], + ["", undefined], + ["https://github.com/onlyowner", undefined], + ["https://github.com/o/r/extra", undefined], +]; + +test("parseGitHubRemote handles every real-world remote URL form", () => { + for (const [url, expected] of CASES) { + assert.deepEqual(parseGitHubRemote(url), expected, `for ${JSON.stringify(url)}`); + } +}); diff --git a/apps/desktop/test/parseTrack.test.ts b/apps/desktop/test/parseTrack.test.ts new file mode 100644 index 0000000..289e1a8 --- /dev/null +++ b/apps/desktop/test/parseTrack.test.ts @@ -0,0 +1,36 @@ +// What git's `%(upstream:track)` field actually says. +// +// `[gone]` used to be dropped on the floor, so a branch whose upstream had been +// deleted came back as `{ ahead: 0, behind: 0 }` — byte-identical to perfectly +// in sync. That is the most common state in this project's own workflow, since +// GitHub deletes the head branch when a pull request merges, and it is the +// clearest signal that a branch is finished and safe to delete. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseTrack } from "../src/main/gitBridge"; + +test("a branch in sync is zero, zero, and not gone", () => { + assert.deepEqual(parseTrack(""), { ahead: 0, behind: 0, gone: false }); +}); + +test("ahead and behind are read independently", () => { + assert.deepEqual(parseTrack("[ahead 2]"), { ahead: 2, behind: 0, gone: false }); + assert.deepEqual(parseTrack("[behind 5]"), { ahead: 0, behind: 5, gone: false }); + assert.deepEqual(parseTrack("[ahead 2, behind 1]"), { ahead: 2, behind: 1, gone: false }); +}); + +test("a deleted upstream is GONE, not in sync", () => { + const t = parseTrack("[gone]"); + assert.equal(t.gone, true); + // And it must not masquerade as agreement: zero/zero is the same shape a + // fully-synced branch has, which is precisely why this flag exists. + assert.equal(t.ahead, 0); + assert.equal(t.behind, 0); +}); + +test("a word merely containing 'gone' is not a gone upstream", () => { + // The field is git's, but a branch named e.g. `feat/dragonet` reaching this + // parser through some other path must not read as gone. + assert.equal(parseTrack("[ahead 1] dragonet").gone, false); +}); diff --git a/apps/desktop/test/readFailuresSurface.test.ts b/apps/desktop/test/readFailuresSurface.test.ts new file mode 100644 index 0000000..fed7021 --- /dev/null +++ b/apps/desktop/test/readFailuresSurface.test.ts @@ -0,0 +1,67 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +/** + * A failed read must not be laundered into an empty result. + * + * `.catch(() => [])` on a GitHub read turns a rate limit, a dropped connection + * or a 500 into "This PR has no commits yet." — beside a rail reading 14. The + * app states something false with complete confidence and offers no way to + * retry, and the renderer's errorState-with-Retry branches, which were already + * written, can never run. + * + * WHAT THIS TEST DOES AND DOES NOT PROVE. It is a census of the source, not a + * behavioural test: `GitHubBridge` constructs its own `GitHubClient`, so there + * is no seam to inject a failing one without a refactor. The harness check + * `a-failed-read-says-so-instead-of-showing-nothing` covers the other half — + * that the RENDERER handles a rejection, shows it, and offers Retry — by + * failing the channel at the shim. Neither one alone covers the whole path, and + * saying so is the point: the harness replaces the main process entirely, so no + * harness check can ever exercise this file. + * + * The rule: a read whose result the UI renders as a LIST either propagates its + * failure, or carries a note saying why an empty array is the honest answer. + */ +const BRIDGE = fileURLToPath(new URL("../src/main/githubBridge.ts", import.meta.url)); + +/** + * `.catch(() => [])`, `.catch(() => {})`, and the parenthesised object form + * `.catch(() => ({}))` — which is how an arrow returns an object literal, and + * the form a first pass at this regex missed. + */ +const SWALLOW = /\.catch\(\s*\(\s*\)\s*=>\s*\(?\s*(\[\]|\{\s*\})\s*\)?\s*\)/; + +/** An opt-out, for the cases where empty really is the truthful answer. */ +const REVIEWED = /read-failure-reviewed:/; + +test("no GitHub read hides its failure behind an empty list", async () => { + const src = await readFile(BRIDGE, "utf8"); + const lines = src.split("\n"); + const bad: string[] = []; + lines.forEach((line, i) => { + if (!SWALLOW.test(line)) return; + // The note may sit on the line itself or in the comment block above it. + const near = lines.slice(Math.max(0, i - 8), i + 1).join("\n"); + if (REVIEWED.test(near)) return; + bad.push(`githubBridge.ts:${i + 1} ${line.trim().slice(0, 96)}`); + }); + assert.deepEqual( + bad, + [], + "these reads turn a failure into an empty result, so the UI reports 'there are none' about " + + "a request that never answered. Either let it reject — the error states already exist — or " + + "add a `read-failure-reviewed:` note saying why empty is truthful here:\n" + + bad.join("\n"), + ); +}); + +test("the census can see the pattern it is looking for", async () => { + // A census that matches nothing passes forever. + assert.equal(SWALLOW.test("return this.client.listPrCommits(o, r, n).catch(() => []);"), true); + assert.equal(SWALLOW.test("x.catch(() => ({}))"), true); + assert.equal(SWALLOW.test("x.catch((e) => report(e))"), false, "a handler that DOES something is fine"); + const src = await readFile(BRIDGE, "utf8"); + assert.ok(src.includes(".catch("), "the file still uses .catch somewhere — the shape is current"); +}); diff --git a/apps/desktop/test/rebaseAlreadyApplied.test.ts b/apps/desktop/test/rebaseAlreadyApplied.test.ts new file mode 100644 index 0000000..8a08d0d --- /dev/null +++ b/apps/desktop/test/rebaseAlreadyApplied.test.ts @@ -0,0 +1,140 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { writeFileSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { RepoStore } from "../src/main/repoStore"; +import { RebaseBridge } from "../src/main/rebaseBridge"; +import { removeTempRepo } from "./tmpRepo"; + +/** + * The plan selects its commits the way git's own sequencer does — + * `base...HEAD --cherry-pick --right-only` — which DROPS commits whose patch is + * already on the base. That is correct, and it is why the plan no longer lists + * a commit that a running rebase would skip and pause on. + * + * Dropping them silently is the problem. When it empties the range the view + * printed "No commits between <base> and <branch>. Pick a different base to + * reach further back." — a false statement (the commits exist, and a nearer + * base finds fewer, not more) about the ordinary case of a branch already + * merged upstream. + */ +function repo(name: string): { root: string; git: (...a: string[]) => string } { + const root = mkdtempSync(`${tmpdir()}/gs-cp-${name}-`); + const git = (...a: string[]): string => + execFileSync("git", a, { cwd: root, stdio: ["ignore", "pipe", "pipe"] }).toString(); + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + return { root, git }; +} + +async function plan(root: string): Promise<{ commits: unknown[]; message?: string }> { + const repos = new RepoStore([]); + await repos.open(root); + const r = await new RebaseBridge(repos).load({ base: "upstream" }); + assert.equal(r.ok, true, "the plan loads"); + return r as { commits: unknown[]; message?: string }; +} + +test("a range whose every commit is already upstream says so, instead of claiming it is empty", async () => { + const { root, git } = repo("all"); + try { + writeFileSync(`${root}/f.txt`, "base\n"); + git("add", "-A"); + git("commit", "-qm", "base"); + git("branch", "upstream"); + + // Two commits on the branch… + writeFileSync(`${root}/a.txt`, "a\n"); + git("add", "-A"); + git("commit", "-qm", "add a"); + writeFileSync(`${root}/b.txt`, "b\n"); + git("add", "-A"); + git("commit", "-qm", "add b"); + const a = git("rev-parse", "HEAD~1").trim(); + const b = git("rev-parse", "HEAD").trim(); + + // …both of which land on upstream by a different route, so their SHAs + // differ and only their patch-ids match. This is what a merge-by-rebase or + // a maintainer's cherry-pick leaves behind. + // + // The unrelated commit first is load-bearing: cherry-picking onto the SAME + // parent, in the same second, with the same author, tree and message + // reproduces the input commit byte for byte — git hands back the identical + // sha and `upstream` merely fast-forwards onto the branch, testing nothing. + const branch = git("rev-parse", "--abbrev-ref", "HEAD").trim(); + git("checkout", "-q", "upstream"); + writeFileSync(`${root}/u.txt`, "u\n"); + git("add", "-A"); + git("commit", "-qm", "unrelated upstream work"); + git("cherry-pick", a, b); + git("checkout", "-q", branch); + + const p = await plan(root); + assert.equal(p.commits.length, 0, "the plan is empty, as git's own todo would be"); + assert.match( + p.message ?? "", + /already on the base/i, + "and the view is given the reason, so it does not print 'No commits between …'", + ); + assert.match(p.message ?? "", /^2 commits/, "counted, not hand-waved"); + } finally { + removeTempRepo(root); + } +}); + +test("a partly-upstream range lists the rest and counts what it dropped", async () => { + const { root, git } = repo("some"); + try { + writeFileSync(`${root}/f.txt`, "base\n"); + git("add", "-A"); + git("commit", "-qm", "base"); + git("branch", "upstream"); + + writeFileSync(`${root}/a.txt`, "a\n"); + git("add", "-A"); + git("commit", "-qm", "add a"); + const a = git("rev-parse", "HEAD").trim(); + writeFileSync(`${root}/c.txt`, "c\n"); + git("add", "-A"); + git("commit", "-qm", "add c"); + + const branch = git("rev-parse", "--abbrev-ref", "HEAD").trim(); + git("checkout", "-q", "upstream"); + writeFileSync(`${root}/u.txt`, "u\n"); + git("add", "-A"); + git("commit", "-qm", "unrelated upstream work"); + git("cherry-pick", a); + git("checkout", "-q", branch); + + const p = await plan(root); + assert.equal(p.commits.length, 1, "the one commit not yet upstream is listed"); + assert.match(p.message ?? "", /^One commit in this range isn't listed/, "the other is accounted for"); + } finally { + removeTempRepo(root); + } +}); + +test("a range with nothing dropped carries no note about it", async () => { + const { root, git } = repo("none"); + try { + writeFileSync(`${root}/f.txt`, "base\n"); + git("add", "-A"); + git("commit", "-qm", "base"); + git("branch", "upstream"); + writeFileSync(`${root}/a.txt`, "a\n"); + git("add", "-A"); + git("commit", "-qm", "add a"); + + const p = await plan(root); + assert.equal(p.commits.length, 1); + assert.ok( + !/already on the base/i.test(p.message ?? ""), + "the note appears only when something was actually dropped", + ); + } finally { + removeTempRepo(root); + } +}); diff --git a/apps/desktop/test/rebaseUpdateRefs.test.ts b/apps/desktop/test/rebaseUpdateRefs.test.ts new file mode 100644 index 0000000..6b974ea --- /dev/null +++ b/apps/desktop/test/rebaseUpdateRefs.test.ts @@ -0,0 +1,798 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { writeFileSync, mkdtempSync, chmodSync, existsSync } from "node:fs"; +import { removeTempRepo } from "./tmpRepo"; +import { tmpdir } from "node:os"; +import { RepoStore } from "../src/main/repoStore"; +import { RebaseBridge } from "../src/main/rebaseBridge"; + +/** + * Branches sitting inside a rewritten range. + * + * A rebase gives every commit a new sha. A branch pointing at one of the OLD + * ones is not "left untouched" by that — it is left pointing at a commit that + * is no longer in this branch's history, on a parallel line nothing references. + * git solves this with `update-ref` lines in the todo (`rebase.updateRefs`), + * and the shared plan builder has always been able to emit them — the desktop + * just never populated the branch list or asked for them, so a stack of + * branches was silently orphaned by any rebase that touched their commits. + */ +function stackRepo(): { root: string; git: (...a: string[]) => string } { + const root = mkdtempSync(`${tmpdir()}/gs-updrefs-`); + const git = (...a: string[]): string => execFileSync("git", a, { cwd: root }).toString(); + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); // no background gc racing the cleanup + const commit = (n: string): void => { + writeFileSync(`${root}/${n}.txt`, `${n}\n`); + git("add", "-A"); + git("commit", "-qm", n); + }; + commit("base"); + git("branch", "trunk"); // the rebase base + commit("one"); + git("branch", "stacked-a"); // sits ON "one" + commit("two"); + git("branch", "stacked-b"); // sits ON "two" + commit("three"); + return { root, git }; +} + +const shaOf = (root: string, ref: string): string => + execFileSync("git", ["rev-parse", ref], { cwd: root }).toString().trim(); + +/** Is `ref` an ancestor of HEAD — i.e. still part of this branch's history? */ +const inHistory = (root: string, ref: string): boolean => { + try { + execFileSync("git", ["merge-base", "--is-ancestor", ref, "HEAD"], { cwd: root }); + return true; + } catch { + return false; + } +}; + +test("the plan reports which branches sit on which commits", async () => { + const { root } = stackRepo(); + try { + const repos = new RepoStore([]); + await repos.open(root); + const bridge = new RebaseBridge(repos); + const plan = await bridge.load({ base: "trunk" }); + assert.equal(plan.ok, true, plan.message ?? ""); + + const bySubject = new Map(plan.commits.map((c) => [c.subject, c.branches ?? []])); + assert.deepEqual(bySubject.get("one"), ["stacked-a"]); + assert.deepEqual(bySubject.get("two"), ["stacked-b"]); + assert.deepEqual(bySubject.get("three"), [], "the tip carries no other branch"); + } finally { + removeTempRepo(root); + } +}); + +test("the branch being rebased is never listed — git moves that one itself", async () => { + const { root, git } = stackRepo(); + try { + const current = git("symbolic-ref", "--short", "HEAD").trim(); + const repos = new RepoStore([]); + await repos.open(root); + const plan = await new RebaseBridge(repos).load({ base: "trunk" }); + assert.ok( + !plan.commits.some((c) => (c.branches ?? []).includes(current)), + `"${current}" must not appear — naming it in an update-ref fights the rebase for it`, + ); + } finally { + removeTempRepo(root); + } +}); + +test("with updateRefs on, a reorder carries the stacked branches with it", async () => { + const { root } = stackRepo(); + try { + const repos = new RepoStore([]); + await repos.open(root); + const bridge = new RebaseBridge(repos); + const plan = await bridge.load({ base: "trunk" }); + const before = { a: shaOf(root, "stacked-a"), b: shaOf(root, "stacked-b") }; + + // Reword the oldest commit — enough to give everything above it a new sha. + const rows = plan.commits.map((c) => ({ + action: (c.subject === "one" ? "reword" : "pick") as "reword" | "pick", + sha: c.sha, + subject: c.subject, + message: c.subject === "one" ? "one (reworded)" : undefined, + branches: c.branches, + })); + + const out = await bridge.apply({ base: "trunk", rows, updateRefs: true }); + assert.equal(out.status, "done", out.message ?? ""); + + assert.notEqual(shaOf(root, "stacked-a"), before.a, "stacked-a moved onto the rewrite"); + assert.ok(inHistory(root, "stacked-a"), "and is still in this branch's history"); + assert.ok(inHistory(root, "stacked-b"), "so is stacked-b"); + } finally { + removeTempRepo(root); + } +}); + +test("with updateRefs off, nothing the user did not name is rewritten", async () => { + const { root } = stackRepo(); + try { + const repos = new RepoStore([]); + await repos.open(root); + const bridge = new RebaseBridge(repos); + const plan = await bridge.load({ base: "trunk" }); + // FULL ref on both sides of this measurement: a bare "stacked-a" now + // resolves to the TAG (git prefers refs/tags over refs/heads for an + // ambiguous name), which of course never moves — the first version of this + // test measured the tag and blamed the fix. + const before = shaOf(root, "refs/heads/stacked-a"); + + const rows = plan.commits.map((c) => ({ + action: (c.subject === "one" ? "reword" : "pick") as "reword" | "pick", + sha: c.sha, + subject: c.subject, + message: c.subject === "one" ? "one (reworded)" : undefined, + branches: c.branches, + })); + const out = await bridge.apply({ base: "trunk", rows, updateRefs: false }); + assert.equal(out.status, "done", out.message ?? ""); + assert.equal( + shaOf(root, "stacked-a"), + before, + "opting out means opting out — refs the user did not name stay put", + ); + } finally { + removeTempRepo(root); + } +}); + +test("the repo's own rebase.updateRefs is what the view starts from", async () => { + const { root, git } = stackRepo(); + try { + git("config", "rebase.updateRefs", "true"); + const repos = new RepoStore([]); + await repos.open(root); + const plan = await new RebaseBridge(repos).load({ base: "trunk" }); + assert.equal(plan.updateRefs, true, "the app follows the user's git, not its own guess"); + } finally { + removeTempRepo(root); + } +}); + +/** + * A range longer than the display cap. + * + * `loadCommits` stops at MAX_PLAN_COMMITS so a huge range does not render + * thousands of rows — a DISPLAY limit, and the note said exactly that + * ("Showing the first 200 commits; pick a nearer base to narrow it"). But + * `apply` then built the todo from those rows and ran it over the WHOLE range, + * and in an interactive rebase the todo IS the plan: a commit in the range and + * not in the todo is dropped. So rebasing 205 commits DELETED the 5 oldest and + * reported "done". + * + * Measured on a real repo before the fix: BEFORE=205 AFTER=200 LOST=5. + */ +test("a range longer than the display cap loses nothing", async () => { + const root = mkdtempSync(`${tmpdir()}/gs-rebasecap-`); + try { + const git = (...a: string[]): string => execFileSync("git", a, { cwd: root }).toString(); + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("commit", "-q", "--allow-empty", "-m", "base"); + git("branch", "trunk"); + // Comfortably past the 200-row cap. + for (let i = 1; i <= 205; i++) git("commit", "-q", "--allow-empty", "-m", `c${i}`); + + const subjects = (): string[] => + git("log", "--format=%s", "trunk..HEAD").trim().split("\n").filter(Boolean); + const before = subjects(); + assert.equal(before.length, 205); + + const repos = new RepoStore([]); + await repos.open(root); + const bridge = new RebaseBridge(repos); + const plan = await bridge.load({ base: "trunk" }); + assert.ok(plan.commits.length < before.length, "the plan is capped, as designed"); + + const rows = plan.commits.map((c) => ({ + action: "pick" as const, + sha: c.sha, + subject: c.subject, + })); + const out = await bridge.apply({ base: "trunk", rows }); + assert.equal(out.status, "done", out.message ?? ""); + + const after = subjects(); + assert.equal( + after.length, + before.length, + `no commit is dropped (lost ${before.length - after.length})`, + ); + assert.equal(after[after.length - 1], "c1", "including the oldest, which the plan never showed"); + assert.deepEqual( + before.filter((x) => !after.includes(x)), + [], + "and every subject survives by name", + ); + } finally { + removeTempRepo(root); + } +}); + +/** + * A branch pointing BELOW the display cap. + * + * The commits past the 200-row cap ride along as picks, which is what stops the + * rebase deleting them (above). But they rode along as BARE picks: the branch + * map was built inside `loadCommits`, so only the commits shown on screen ever + * carried their branches. A branch pointing at commit #3 of 205 got no + * `update-ref` line, and the rebase left it on a parallel line no longer in the + * rewritten history — the exact orphaning `--update-refs` exists to prevent, + * happening to the commits the user had the least chance of noticing. + */ +test("a branch below the display cap is carried across the rewrite too", async () => { + const root = mkdtempSync(`${tmpdir()}/gs-capbranch-`); + try { + const git = (...a: string[]): string => execFileSync("git", a, { cwd: root }).toString(); + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + git("commit", "-q", "--allow-empty", "-m", "base"); + git("branch", "trunk"); + // Real content per commit, not `--allow-empty`: every empty commit has the + // same (empty) patch-id, so once the base moves, `--cherry-pick` reads all + // 205 as duplicates of the one empty commit on the other side and drops + // them — "Nothing to rebase" on a range that plainly has 205 commits. + for (let i = 1; i <= 205; i++) { + writeFileSync(`${root}/c${i}.txt`, `c${i}\n`); + git("add", "-A"); + git("commit", "-qm", `c${i}`); + // Deep in the range, far past what the plan will display. + if (i === 3) git("branch", "deep"); + } + const deepBefore = git("rev-parse", "deep").trim(); + // Move the base forward, so the rebase genuinely REWRITES every commit in + // the range. Replayed onto an unchanged base, git fast-forwards the + // untouched prefix and the shas below the edit do not move at all — a real + // outcome, but not the one that orphans a branch. + const branch = git("rev-parse", "--abbrev-ref", "HEAD").trim(); + git("checkout", "-q", "trunk"); + writeFileSync(`${root}/upstream.txt`, "upstream\n"); + git("add", "-A"); + git("commit", "-qm", "upstream moved"); + git("checkout", "-q", branch); + + const repos = new RepoStore([]); + await repos.open(root); + const bridge = new RebaseBridge(repos); + const plan = await bridge.load({ base: "trunk" }); + assert.ok( + !plan.commits.some((c) => c.sha === deepBefore), + "the branch's commit is below the cap — it is not on screen at all", + ); + + const rows = plan.commits.map((c) => ({ action: "pick" as const, sha: c.sha, subject: c.subject })); + const out = await bridge.apply({ base: "trunk", rows, updateRefs: true }); + assert.equal(out.status, "done", out.message ?? ""); + + const deepAfter = git("rev-parse", "deep").trim(); + assert.notEqual(deepAfter, deepBefore, "the branch moved with the rewrite"); + const contains = git("branch", "--contains", deepAfter).trim(); + assert.ok( + contains.split("\n").some((l) => l.replace(/^\*?\s*/, "") === branch), + `the branch is still in the rewritten history, not orphaned beside it (${contains})`, + ); + } finally { + removeTempRepo(root); + } +}); + +/** + * A plan applied to a branch that has moved since it was built. + * + * `apply` re-walks the range and treats anything not among the rows as part of + * the below-the-cap tail, appending it — and appending is newest-last, so the + * reversal into git's todo made the appended commit the FIRST pick. Open the + * Rebase view, commit from a terminal, press Start rebase: the commit you had + * just written was moved to the BOTTOM of the branch's history, with "Rebase + * complete." and no mention of it anywhere. + */ +test("a plan is refused once the branch has moved under it", async () => { + const root = mkdtempSync(`${tmpdir()}/gs-stale-`); + try { + const git = (...a: string[]): string => execFileSync("git", a, { cwd: root }).toString(); + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + writeFileSync(`${root}/f.txt`, "base\n"); + git("add", "-A"); + git("commit", "-qm", "base"); + git("branch", "trunk"); + for (const n of ["A", "B", "C"]) { + writeFileSync(`${root}/${n}.txt`, `${n}\n`); + git("add", "-A"); + git("commit", "-qm", n); + } + + const repos = new RepoStore([]); + await repos.open(root); + const bridge = new RebaseBridge(repos); + const plan = await bridge.load({ base: "trunk" }); + assert.deepEqual(plan.commits.map((c) => c.subject), ["C", "B", "A"], "newest first"); + assert.ok(plan.headSha, "the plan records the tip it describes"); + + // …and now something commits, outside the app. + writeFileSync(`${root}/D.txt`, "D\n"); + git("add", "-A"); + git("commit", "-qm", "D-NEW"); + + const rows = plan.commits.map((c) => ({ action: "pick" as const, sha: c.sha, subject: c.subject })); + const out = await bridge.apply({ base: "trunk", rows, headSha: plan.headSha }); + + assert.equal(out.status, "failed", "the stale plan is refused"); + assert.match(out.message ?? "", /branch has moved/i, "and says why"); + assert.deepEqual( + git("log", "--format=%s", "trunk..HEAD").trim().split("\n"), + ["D-NEW", "C", "B", "A"], + "history is exactly as it was — D-NEW is still the newest commit", + ); + } finally { + removeTempRepo(root); + } +}); + +/** + * What the plan SAYS it will rewrite. + * + * Above the display cap the rows on screen are not the commits the rebase acts + * on: the ones below the cap are replayed too — that is what stops them being + * deleted — and a replay gives every one of them a new id as soon as the base + * has moved. The banner said they were "kept as-is" and the confirm dialog said + * "This rewrites 200 commits" on a range of 260. Both understated the blast + * radius of the one irreversible button in the view. + */ +test("the plan reports every commit it will replay, not just the page shown", async () => { + const root = mkdtempSync(`${tmpdir()}/gs-replaycount-`); + try { + const git = (...a: string[]): string => execFileSync("git", a, { cwd: root }).toString(); + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + git("commit", "-q", "--allow-empty", "-m", "base"); + git("branch", "trunk"); + for (let i = 1; i <= 205; i++) { + writeFileSync(`${root}/c${i}.txt`, `c${i}\n`); + git("add", "-A"); + git("commit", "-qm", `c${i}`); + } + + const repos = new RepoStore([]); + await repos.open(root); + const plan = await new RebaseBridge(repos).load({ base: "trunk" }); + + assert.equal(plan.commits.length, 200, "the page is capped, as designed"); + assert.equal(plan.replayCount, 205, "and the plan says how many will actually be rewritten"); + assert.match( + plan.message ?? "", + /still rewritten and get new IDs/i, + "the banner says what happens to the ones it is not showing", + ); + assert.ok( + !/kept as-is/i.test(plan.message ?? ""), + "and no longer says they are kept as-is, which was the opposite of true", + ); + } finally { + removeTempRepo(root); + } +}); + +/** + * A branch sitting on a commit that a later row SQUASHES or FIXUPS into. + * + * `update-ref` was emitted on the line after its own pick, and git records the + * ref the moment it reaches that line. A squash or fixup below then AMENDS the + * commit the branch was just pointed at — so the branch ends up on a commit + * that is no longer in the rewritten history, which is precisely the orphaning + * `--update-refs` exists to prevent. git's own `--autosquash` emits the + * update-ref AFTER the fold. + * + * `drop` is transparent to that scan: `pick c2 / update-ref / drop c3 / + * fixup c4` orphans just the same, because the fixup still folds into c2. + * + * This ships in BOTH products through the shared plan builder. + */ +test("a branch on a squashed commit moves with the fold, not before it", async () => { + for (const shape of ["fixup", "drop-then-fixup"] as const) { + const root = mkdtempSync(`${tmpdir()}/gs-fold-${shape}-`); + try { + const git = (...a: string[]): string => execFileSync("git", a, { cwd: root }).toString(); + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + git("commit", "-q", "--allow-empty", "-m", "base"); + git("branch", "trunk"); + for (const n of ["c1", "c2", "c3", "c4"]) { + writeFileSync(`${root}/${n}.txt`, `${n}\n`); + git("add", "-A"); + git("commit", "-qm", n); + } + const branch = git("rev-parse", "--abbrev-ref", "HEAD").trim(); + // `feature` sits on c2 — the commit the fold below rewrites. + git("branch", "feature", git("rev-parse", "HEAD~2").trim()); + const before = git("rev-parse", "feature").trim(); + + const repos = new RepoStore([]); + await repos.open(root); + const bridge = new RebaseBridge(repos); + const plan = await bridge.load({ base: "trunk" }); + const bySubject = (x: string): { sha: string; subject: string } => { + const c = plan.commits.find((k) => k.subject === x); + assert.ok(c, `${x} is in the plan`); + return c; + }; + // Display order is newest first, so: c4, c3, c2, c1. + const rows = [ + { action: "fixup" as const, ...bySubject("c4") }, + { + action: (shape === "drop-then-fixup" ? "drop" : "fixup") as "drop" | "fixup", + ...bySubject("c3"), + }, + { action: "pick" as const, ...bySubject("c2"), branches: ["feature"] }, + { action: "pick" as const, ...bySubject("c1") }, + ]; + const out = await bridge.apply({ base: "trunk", rows, updateRefs: true, headSha: plan.headSha }); + assert.equal(out.status, "done", `${shape}: ${out.message ?? ""}`); + + const after = git("rev-parse", "feature").trim(); + assert.notEqual(after, before, `${shape}: the branch moved with the rewrite`); + // The assertion that matters: is it still IN this branch's history? + const contains = git("branch", "--contains", after).trim(); + assert.ok( + contains.split("\n").some((l) => l.replace(/^\*?\s*/, "") === branch), + `${shape}: feature is still in the rewritten history, not orphaned beside it (${contains})`, + ); + } finally { + removeTempRepo(root); + } + } +}); + +/** + * A branch whose name is also a tag's. + * + * `%(refname:short)` returns the shortest UNAMBIGUOUS name, so a branch + * colliding with a tag comes back as `heads/stacked-a`. That went straight into + * `update-ref refs/heads/heads/stacked-a`: a junk branch appeared, the rebase + * reported success, and the user's REAL branch was left on a parallel line no + * longer in the rebased history — the exact orphaning this feature prevents. + * Branch+tag pairs (v1.2, release, stable) are routine. + */ +test("a branch colliding with a tag is carried by its real name", async () => { + const { root, git } = stackRepo(); + try { + git("tag", "stacked-a"); // a TAG sharing the branch's name — legal in git + + const repos = new RepoStore([]); + await repos.open(root); + const bridge = new RebaseBridge(repos); + const plan = await bridge.load({ base: "trunk" }); + + const named = plan.commits.flatMap((c) => c.branches ?? []); + assert.ok( + !named.some((n) => n.includes("/")), + `no ref path leaks into a branch NAME (got: ${named.join(", ")})`, + ); + assert.ok(named.includes("stacked-a"), "the real branch is named"); + + const before = shaOf(root, "stacked-a"); + const rows = plan.commits.map((c) => ({ + action: (c.subject === "one" ? "reword" : "pick") as "reword" | "pick", + sha: c.sha, + subject: c.subject, + message: c.subject === "one" ? "one (reworded)" : undefined, + branches: c.branches, + })); + const out = await bridge.apply({ base: "trunk", rows, updateRefs: true }); + assert.equal(out.status, "done", out.message ?? ""); + + const heads = git("for-each-ref", "--format=%(refname)", "refs/heads").trim().split("\n"); + assert.ok( + !heads.some((h) => h.startsWith("refs/heads/heads/")), + `no junk branch is created (${heads.join(", ")})`, + ); + assert.notEqual( + shaOf(root, "refs/heads/stacked-a"), + before, + "the real branch moved with the rewrite", + ); + assert.ok( + inHistory(root, "refs/heads/stacked-a"), + "and is in the rebased history, not orphaned", + ); + } finally { + removeTempRepo(root); + } +}); + +/** + * A branch checked out in ANOTHER worktree. + * + * git's own `--update-refs` deliberately refuses to move one, writing a comment + * into the todo instead: "# Ref refs/heads/x checked out at <path>". Moving it + * anyway leaves that worktree's HEAD on a rewritten commit while its index and + * working tree stay at the old one — `git status` there then reports staged + * changes nobody made. The app ships a Worktrees feature, so this is a normal + * setup for its users, and the checkbox never said the branch was elsewhere. + */ +test("a branch checked out in another worktree is left alone", async () => { + const { root, git } = stackRepo(); + const linked = `${root}-wt`; + try { + git("worktree", "add", "-q", linked, "stacked-a"); + + const repos = new RepoStore([]); + await repos.open(root); + const plan = await new RebaseBridge(repos).load({ base: "trunk" }); + const named = plan.commits.flatMap((c) => c.branches ?? []); + assert.ok( + !named.includes("stacked-a"), + `a branch checked out elsewhere is never offered (got: ${named.join(", ")})`, + ); + assert.ok(named.includes("stacked-b"), "while ordinary branches still are"); + } finally { + try { + git("worktree", "remove", "--force", linked); + } catch { + /* best effort */ + } + removeTempRepo(linked); + removeTempRepo(root); + } +}); + +/** + * A merge commit inside the range. + * + * `git log <base>..HEAD` lists merges; `git rebase -i` does not — its sequencer + * builds the todo from `rev-list --reverse --topo-order --no-merges`, and its + * parser refuses `pick <merge>` outright. So the plan contained a row git would + * never accept, and applying it left the repo DETACHED AT THE BASE, mid-rebase, + * with a clean tree and no conflict to resolve; Continue re-ran the same failing + * todo and only Abort escaped. A feature branch with main merged into it is the + * ordinary shape of this. + * + * Measured before the fix: + * {"status":"stopped","reason":"unknown", + * "message":"error: 'pick' does not accept merge commits"} + */ +function mergeRangeRepo(): { root: string; git: (...a: string[]) => string } { + const root = mkdtempSync(`${tmpdir()}/gs-mergerange-`); + const git = (...a: string[]): string => execFileSync("git", a, { cwd: root }).toString(); + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + const commit = (n: string): void => { + writeFileSync(`${root}/${n}.txt`, `${n}\n`); + git("add", "-A"); + git("commit", "-qm", n); + }; + commit("m1"); + git("branch", "trunk"); + git("checkout", "-qb", "feature"); + commit("f1"); + // main moves on, and is merged INTO the feature branch — the ordinary shape. + git("checkout", "-q", "trunk"); + commit("m2"); + git("checkout", "-q", "feature"); + git("merge", "-q", "--no-ff", "-m", "Merge branch 'trunk' into feature", "trunk"); + commit("f2"); + return { root, git }; +} + +test("a merge inside the range does not wedge the repo", async () => { + const { root, git } = mergeRangeRepo(); + try { + const repos = new RepoStore([]); + await repos.open(root); + const bridge = new RebaseBridge(repos); + const plan = await bridge.load({ base: "trunk" }); + assert.equal(plan.ok, true, plan.message ?? ""); + + // The plan is what git would replay: no merges in it. + for (const c of plan.commits) { + const parents = git("rev-list", "--parents", "-n", "1", c.sha).trim().split(/\s+/); + assert.ok(parents.length <= 2, `no merge in the plan (${c.subject} has ${parents.length - 1} parents)`); + } + // …and it SAYS the merge was left out, rather than silently dropping it. + assert.match(plan.message ?? "", /merge/i, "the omission is disclosed"); + + const rows = plan.commits.map((c) => ({ + action: "pick" as const, + sha: c.sha, + subject: c.subject, + branches: c.branches, + })); + const out = await bridge.apply({ base: "trunk", rows }); + assert.equal(out.status, "done", `the rebase completes (${out.message ?? ""})`); + + // Not detached, no rebase in progress, and the work is still there. + assert.equal(git("symbolic-ref", "--short", "HEAD").trim(), "feature", "still on the branch"); + const subjects = git("log", "--format=%s", "HEAD").trim().split("\n"); + for (const s of ["f1", "f2", "m2", "m1"]) { + assert.ok(subjects.includes(s), `${s} survives the rebase`); + } + } finally { + removeTempRepo(root); + } +}); + +/** + * The plan must be the todo git itself would generate. + * + * `git log`'s default order is reverse-chronological, which is NOT the order + * `git rebase -i` replays in — its sequencer uses `--topo-order`. On a range + * whose two lines interleave by date the two disagree, and the plan then + * promised a replay order git would not have chosen: + * + * git log --no-merges A, C, B -> todo: pick B, pick C, pick A + * git log --no-merges --topo C, B, A -> todo: pick A, pick B, pick C + * git rebase -i's OWN todo -> pick A, pick B, pick C + * + * (An earlier version of this test asserted "no parent before its child" in the + * default order. That can never fail: git's walk only puts a parent on the + * frontier once a child has been emitted. The real invariant is agreement with + * git, and that IS checkable — so check it.) + */ +function interleavedRepo(): { root: string; git: (...a: string[]) => string } { + const root = mkdtempSync(`${tmpdir()}/gs-interleave-`); + const git = (...a: string[]): string => execFileSync("git", a, { cwd: root }).toString(); + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + const at = (n: string, when: string): void => { + writeFileSync(`${root}/${n}.txt`, `${n}\n`); + execFileSync("git", ["add", "-A"], { cwd: root }); + execFileSync("git", ["commit", "-qm", n], { + cwd: root, + env: { ...process.env, GIT_AUTHOR_DATE: when, GIT_COMMITTER_DATE: when }, + }); + }; + at("base", "2024-01-01T00:00:00Z"); + git("branch", "trunk"); + git("checkout", "-qb", "side"); + at("B", "2024-01-09T00:00:00Z"); + at("C", "2024-01-02T00:00:00Z"); + git("checkout", "-q", "trunk"); + git("checkout", "-qb", "feature"); + at("A", "2024-01-03T00:00:00Z"); + git("merge", "-q", "--no-ff", "-m", "merge", "side"); + return { root, git }; +} + +/** + * git's own todo for `rebase -i <base>`, captured WITHOUT running it. + * + * The sequence editor must exit NON-ZERO: a plain `GIT_SEQUENCE_EDITOR=cat` + * returns 0, so git takes the todo as approved and executes the whole rebase — + * which silently rewrote the branch this is supposed to be measuring, and the + * comparison below then compared a linearized repo against itself and passed + * no matter what. Exiting 1 makes git abort with the repository untouched. + */ +function nativeTodo(root: string, base: string): string[] { + const editor = mkdtempSync(`${tmpdir()}/gs-seq-`) + "/seq.sh"; + writeFileSync(editor, '#!/bin/sh\ncat "$1"\nexit 1\n'); + chmodSync(editor, 0o755); + let out = ""; + try { + execFileSync("git", ["rebase", "-i", base], { + cwd: root, + env: { ...process.env, GIT_SEQUENCE_EDITOR: editor }, + encoding: "utf8", + }); + } catch (e) { + out = String((e as { stdout?: string }).stdout ?? ""); + } + assert.equal( + execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: root }).toString().trim(), + "feature", + "capturing git's todo must not run the rebase", + ); + return out + .split("\n") + .filter((l) => l.startsWith("pick ")) + .map((l) => l.split(/\s+/)[2]); +} + +test("the plan replays in the order git itself would", async () => { + const { root } = interleavedRepo(); + try { + const native = nativeTodo(root, "trunk"); + assert.deepEqual(native, ["A", "B", "C"], "git's own todo, for reference"); + + const repos = new RepoStore([]); + await repos.open(root); + const plan = await new RebaseBridge(repos).load({ base: "trunk" }); + // The plan is newest-first on screen; `apply` reverses it to make the todo. + const ours = plan.commits.map((c) => c.subject).reverse(); + assert.deepEqual(ours, native, "our todo is git's todo"); + } finally { + removeTempRepo(root); + } +}); + +/** + * A commit whose patch is already on the base. + * + * git's sequencer builds the todo with `--cherry-mark --right-only` over + * `upstream...HEAD`, which DROPS commits already applied upstream — a backport, + * a cherry-pick that travelled both ways, a commit someone else merged. Listing + * them made git skip the commit and PAUSE: + * + * warning: skipped previously applied commit 4b20fb3 + * + * leaving the repo mid-rebase with a clean tree and a card telling the user to + * resolve conflicts that do not exist — the same wedge a merge in the range + * produced, for the same reason: a plan git will not execute as written. + */ +test("a patch already on the base is not in the plan, and does not wedge the rebase", async () => { + const root = mkdtempSync(`${tmpdir()}/gs-cherrydup-`); + try { + const git = (...a: string[]): string => execFileSync("git", a, { cwd: root }).toString(); + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + writeFileSync(`${root}/m.txt`, "m\n"); + git("add", "-A"); + git("commit", "-qm", "m1"); + git("branch", "trunk"); + + git("checkout", "-qb", "feature"); + writeFileSync(`${root}/dup.txt`, "dup\n"); + git("add", "-A"); + git("commit", "-qm", "dup"); + writeFileSync(`${root}/keep.txt`, "keep\n"); + git("add", "-A"); + git("commit", "-qm", "keeper"); + + // The SAME patch lands on trunk independently. + git("checkout", "-q", "trunk"); + git("cherry-pick", "-x", "feature~1"); + git("checkout", "-q", "feature"); + + const repos = new RepoStore([]); + await repos.open(root); + const bridge = new RebaseBridge(repos); + const plan = await bridge.load({ base: "trunk" }); + + assert.deepEqual( + plan.commits.map((c) => c.subject), + ["keeper"], + "the already-applied commit is not offered — git's own todo does not list it", + ); + + const out = await bridge.apply({ + base: "trunk", + rows: plan.commits.map((c) => ({ action: "pick" as const, sha: c.sha, subject: c.subject })), + }); + assert.equal(out.status, "done", `the rebase completes (${out.message ?? ""})`); + assert.ok( + !existsSync(`${root}/.git/rebase-merge`), + "and leaves no rebase in progress behind it", + ); + const log = git("log", "--format=%s", "HEAD").trim().split("\n"); + assert.ok(log.includes("keeper"), "the real work survives"); + assert.ok(log.includes("dup"), "and the duplicated patch is still there, from trunk"); + } finally { + removeTempRepo(root); + } +}); diff --git a/apps/desktop/test/refLog.test.ts b/apps/desktop/test/refLog.test.ts new file mode 100644 index 0000000..c791525 --- /dev/null +++ b/apps/desktop/test/refLog.test.ts @@ -0,0 +1,74 @@ +import { test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { GitContext } from "@gitstudio/git-service/index"; +import { GitBridge } from "../src/main/gitBridge"; +import type { RepoStore } from "../src/main/repoStore"; +import { removeTempRepo } from "./tmpRepo"; + +// ref:log — the per-ref history behind the peek cards (branch / remote / tag +// popups). Driven through the same bridge method the IPC channel calls. + +let repo: string; +let ctx: GitContext; +let bridge: GitBridge; + +const git = (...a: string[]): string => + execFileSync("git", a, { cwd: repo, encoding: "utf8", env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" } }); + +beforeEach(() => { + repo = mkdtempSync(join(tmpdir(), "gitstudio-reflog-")); + execFileSync("git", ["-c", "init.defaultBranch=main", "init", repo], { + env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, + }); + git("config", "user.email", "dev@example.com"); + git("config", "user.name", "Dev"); + writeFileSync(join(repo, "f.txt"), "one\n"); + git("add", "."); + git("commit", "-m", "first"); + writeFileSync(join(repo, "f.txt"), "two\n"); + git("commit", "-am", "second"); + git("branch", "side"); + writeFileSync(join(repo, "f.txt"), "three\n"); + git("commit", "-am", "third (main only)"); + git("tag", "v1"); + ctx = new GitContext({ root: repo }); + bridge = new GitBridge({ getContext: () => ctx } as unknown as RepoStore); +}); + +afterEach(() => { + ctx?.dispose?.(); + removeTempRepo(repo); +}); + +test("ref:log lists a branch's commits newest-first with the peek's row shape", async () => { + const log = await bridge.refLog({ ref: "main" }); + assert.equal(log.length, 3); + assert.equal(log[0].subject, "third (main only)"); + assert.equal(log[2].subject, "first"); + assert.equal(log[0].shortSha, log[0].sha.slice(0, 7)); + assert.equal(log[0].author, "Dev"); + assert.ok(log[0].date > 0); +}); + +test("ref:log scopes to the named ref, not HEAD", async () => { + const log = await bridge.refLog({ ref: "side" }); + assert.deepEqual( + log.map((c) => c.subject), + ["second", "first"], + ); +}); + +test("ref:log resolves tags and honors maxCount", async () => { + const log = await bridge.refLog({ ref: "v1", maxCount: 1 }); + assert.equal(log.length, 1); + assert.equal(log[0].subject, "third (main only)"); +}); + +test("ref:log returns empty for an unknown ref and refuses a flag-shaped one", async () => { + assert.deepEqual(await bridge.refLog({ ref: "does-not-exist" }), []); + assert.deepEqual(await bridge.refLog({ ref: "--all" }), []); +}); diff --git a/apps/desktop/test/repoStore.test.ts b/apps/desktop/test/repoStore.test.ts new file mode 100644 index 0000000..7ad1f30 --- /dev/null +++ b/apps/desktop/test/repoStore.test.ts @@ -0,0 +1,67 @@ +// The recents list — small, but it is the app's memory of where your work is, +// and it is persisted as plain JSON that survives upgrades and can be edited by +// hand. So the rules that matter are the ones about paths that are the SAME +// repo spelled differently. +// +// Only the pure list math is exercised here; the RepoStore class itself owns a +// GitContext and a git adapter, which a unit test has no business constructing. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { promoteRecentList, sameRoot } from "../src/main/repoStore"; + +test("two spellings of one path are the same repo", () => { + assert.equal(sameRoot("/x/repo", "/x/repo/"), true, "a trailing slash is not a different repo"); + assert.equal(sameRoot("/x/repo", "/x/./repo"), true); + assert.equal(sameRoot("/x/a/../repo", "/x/repo"), true); + assert.equal(sameRoot("/x/repo", "/x/repo2"), false); + assert.equal(sameRoot("/x/repo", "/y/repo"), false); +}); + +test("opening a repo puts it first", () => { + assert.deepEqual(promoteRecentList(["/b", "/c"], "/a"), ["/a", "/b", "/c"]); +}); + +test("re-opening a repo moves it to the front rather than duplicating it", () => { + assert.deepEqual(promoteRecentList(["/a", "/b", "/c"], "/c"), ["/c", "/a", "/b"]); +}); + +test("a differently spelled path does not become a second entry", () => { + // The persisted list held "/x/repo/"; discovery hands back "/x/repo". Raw + // string equality listed the same repo twice, and the list is short enough + // that two of the same thing pushes a real repo off the end. + assert.deepEqual(promoteRecentList(["/x/repo/", "/y/other"], "/x/repo"), [ + "/x/repo", + "/y/other", + ]); + assert.deepEqual(promoteRecentList(["/x/./repo", "/y/other"], "/x/repo"), [ + "/x/repo", + "/y/other", + ]); +}); + +test("the freshly opened spelling wins, so an odd entry heals", () => { + assert.deepEqual(promoteRecentList(["/x/repo/"], "/x/repo")[0], "/x/repo"); +}); + +test("the list is capped, oldest dropped", () => { + const many = Array.from({ length: 12 }, (_, i) => `/r${i}`); + const next = promoteRecentList(many, "/new", 12); + assert.equal(next.length, 12); + assert.equal(next[0], "/new"); + assert.equal(next.includes("/r11"), false, "the oldest fell off the end"); + assert.equal(next.includes("/r10"), true); +}); + +test("promoting an entry already in a full list does not shrink it", () => { + const many = Array.from({ length: 12 }, (_, i) => `/r${i}`); + const next = promoteRecentList(many, "/r11", 12); + assert.equal(next.length, 12, "it moved, it did not push anything off"); + assert.equal(next[0], "/r11"); +}); + +test("promoting is pure — the input list is not mutated", () => { + const before = ["/a", "/b"]; + promoteRecentList(before, "/b"); + assert.deepEqual(before, ["/a", "/b"]); +}); diff --git a/apps/desktop/test/repoWatcher.test.ts b/apps/desktop/test/repoWatcher.test.ts index efec638..59c4647 100644 --- a/apps/desktop/test/repoWatcher.test.ts +++ b/apps/desktop/test/repoWatcher.test.ts @@ -1,6 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { removeTempRepo } from "./tmpRepo"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { RepoWatcher, shouldRefreshFor, DEBOUNCE_MS } from "../src/main/repoWatcher"; @@ -170,7 +171,7 @@ test("a working-tree edit fires, and is NOT reported as a git-dir change", async assert.equal(info.gitDir, false, "a file edit must not drag a graph reload with it"); } finally { w.dispose(); - rmSync(dir, { recursive: true, force: true }); + removeTempRepo(dir); } }); @@ -190,7 +191,7 @@ test("a .git change IS reported as one, so history gets re-read", async () => { assert.equal(info.gitDir, true); } finally { w.dispose(); - rmSync(dir, { recursive: true, force: true }); + removeTempRepo(dir); } }); @@ -216,7 +217,7 @@ test("a burst of writes collapses into ONE refresh", async () => { assert.ok(calls >= 1 && calls <= 2, `fifty writes produced ${calls} refreshes`); } finally { w.dispose(); - rmSync(dir, { recursive: true, force: true }); + removeTempRepo(dir); } }); @@ -248,7 +249,7 @@ test("ignored churn produces NO refresh at all", async () => { assert.ok(calls <= 1, `an npm install must not wake the app up repeatedly (got ${calls})`); } finally { w.dispose(); - rmSync(dir, { recursive: true, force: true }); + removeTempRepo(dir); } }); @@ -262,7 +263,7 @@ test("dispose() stops it, so a closed repo cannot keep firing", async () => { writeFileSync(join(dir, "after.txt"), "x\n"); await new Promise((r) => setTimeout(r, 800)); assert.equal(calls, 0); - rmSync(dir, { recursive: true, force: true }); + removeTempRepo(dir); }); test("a missing directory degrades instead of throwing", () => { diff --git a/apps/desktop/test/rewordAcrossStop.test.ts b/apps/desktop/test/rewordAcrossStop.test.ts new file mode 100644 index 0000000..195f49b --- /dev/null +++ b/apps/desktop/test/rewordAcrossStop.test.ts @@ -0,0 +1,578 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { writeFileSync, mkdtempSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { RepoStore } from "../src/main/repoStore"; +import { RebaseBridge } from "../src/main/rebaseBridge"; +import { GitBridge } from "../src/main/gitBridge"; +import { removeTempRepo } from "./tmpRepo"; + +/** + * Reword messages across a rebase that STOPS. + * + * The messages were handed to the run through a queue file in the run's TEMP + * DIR, popped by COUNTING editor invocations. A conflict ends that process, and + * `rebase --continue` then ran with `GIT_EDITOR=true` — a no-op — so every + * reword after the stop point committed with its ORIGINAL message while the app + * reported success: a green "Rebase continued." and a reloaded plan, with the + * text the user typed gone and nothing said. + * + * Counting was the second half of the problem. `--continue` opens the editor for + * the commit that stopped WHATEVER its verb, so a conflicted `pick` consumed the + * next reword's message — putting it on a commit nobody reworded and shifting + * every later one. The queue is keyed by SHA now, which also makes a queue left + * behind by some other path inert: a foreign rebase's shas are not in it. + */ +function conflictingStack(): { root: string; git: (...a: string[]) => string } { + const root = mkdtempSync(`${tmpdir()}/gs-rewordstop-`); + const git = (...a: string[]): string => execFileSync("git", a, { cwd: root }).toString(); + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + + writeFileSync(`${root}/shared.txt`, "base\n"); + writeFileSync(`${root}/other.txt`, "o\n"); + git("add", "-A"); + git("commit", "-qm", "m1"); + git("branch", "trunk"); + + // trunk moves shared.txt, so replaying t2 onto it conflicts. + git("checkout", "-qb", "feature"); + writeFileSync(`${root}/f1.txt`, "f1\n"); + git("add", "-A"); + git("commit", "-qm", "t1"); + writeFileSync(`${root}/shared.txt`, "feature side\n"); + git("commit", "-qam", "t2"); + writeFileSync(`${root}/f3.txt`, "f3\n"); + git("add", "-A"); + git("commit", "-qm", "t3"); + writeFileSync(`${root}/f4.txt`, "f4\n"); + git("add", "-A"); + git("commit", "-qm", "t4"); + + git("checkout", "-q", "trunk"); + writeFileSync(`${root}/shared.txt`, "trunk side\n"); + git("commit", "-qam", "m2"); + git("checkout", "-q", "feature"); + return { root, git }; +} + +/** + * Is a reword queue present anywhere for this repo? + * + * There are two homes on purpose: a STAGING copy in `.git` while the run is + * starting (git has not created its state directory yet), and the real one + * inside `.git/rebase-merge/` once the rebase has paused — where git owns its + * lifetime and deletes it with the rebase, however the rebase ends. + */ +function queuePresent(root: string): { staging: boolean; inRebase: boolean } { + return { + staging: existsSync(`${root}/.git/gitstudio-reword-queue.json`), + inRebase: existsSync(`${root}/.git/rebase-merge/gitstudio-reword-queue.json`), + }; +} + +test("rewords queued after the stop point still land, on their own commits", async () => { + const { root, git } = conflictingStack(); + try { + const repos = new RepoStore([]); + await repos.open(root); + const rebase = new RebaseBridge(repos); + const bridge = new GitBridge(repos); + + const plan = await rebase.load({ base: "trunk" }); + assert.equal(plan.ok, true, plan.message ?? ""); + + // Reword t1, t3 and t4 — t2 is the one that will conflict. + const rows = plan.commits.map((c) => ({ + action: (c.subject === "t2" ? "pick" : "reword") as "pick" | "reword", + sha: c.sha, + subject: c.subject, + message: c.subject === "t2" ? undefined : `R-${c.subject.toUpperCase()}`, + branches: c.branches, + })); + + const out = await rebase.apply({ base: "trunk", rows }); + assert.equal(out.status, "stopped", `it stops on t2's conflict (${out.status})`); + + // Resolve exactly as the user would, then continue. + writeFileSync(`${root}/shared.txt`, "resolved\n"); + git("add", "shared.txt"); + const cont = await bridge.rebaseContinue(); + assert.equal(cont.ok, true, `continue succeeds (${cont.message ?? ""})`); + + const subjects = git("log", "--format=%s", "HEAD").trim().split("\n"); + assert.deepEqual( + subjects, + ["R-T4", "R-T3", "t2", "R-T1", "m2", "m1"], + "every reword landed on ITS OWN commit, and the conflicted pick kept its message", + ); + } finally { + removeTempRepo(root); + } +}); + +/** A queue left behind must be inert — it can only ever match its own shas. */ +test("a leftover queue cannot rename someone else's commit", async () => { + const { root, git } = conflictingStack(); + try { + const repos = new RepoStore([]); + await repos.open(root); + const rebase = new RebaseBridge(repos); + + const plan = await rebase.load({ base: "trunk" }); + const rows = plan.commits.map((c) => ({ + action: (c.subject === "t2" ? "pick" : "reword") as "pick" | "reword", + sha: c.sha, + subject: c.subject, + message: c.subject === "t2" ? undefined : `QUEUED-${c.subject}`, + })); + const out = await rebase.apply({ base: "trunk", rows }); + assert.equal(out.status, "stopped"); + + // It survives the stop — inside git's own state directory, which is what + // gives it exactly the rebase's lifetime. + const q = queuePresent(root); + assert.ok(q.inRebase, "the queue survives the stop, in git's rebase state dir"); + assert.ok(!q.staging, "and the staging copy is not left lying in .git"); + + // Walk away from the rebase entirely, the way a terminal `git rebase --abort` + // outside the app would, leaving the queue behind. + git("rebase", "--abort"); + + // Now run an UNRELATED rebase with a reword of our own, by hand. + git("checkout", "-q", "-b", "elsewhere", "trunk"); + writeFileSync(`${root}/e.txt`, "e\n"); + git("add", "-A"); + git("commit", "-qm", "mine"); + const before = git("log", "-1", "--format=%s", "HEAD").trim(); + execFileSync("git", ["rebase", "trunk"], { cwd: root }); + assert.equal( + git("log", "-1", "--format=%s", "HEAD").trim(), + before, + "a stale queue never touches a commit it does not name", + ); + } finally { + removeTempRepo(root); + } +}); + +/** A clean run still installs every message, and leaves nothing behind. */ +test("a rebase with no stop still rewords, and cleans up after itself", async () => { + const { root, git } = conflictingStack(); + try { + git("checkout", "-q", "feature"); + const repos = new RepoStore([]); + await repos.open(root); + const rebase = new RebaseBridge(repos); + // Rebase onto the branch's own base so nothing conflicts. + const plan = await rebase.load({ base: "HEAD~2" }); + const rows = plan.commits.map((c) => ({ + action: "reword" as const, + sha: c.sha, + subject: c.subject, + message: `W-${c.subject}`, + })); + const out = await rebase.apply({ base: "HEAD~2", rows }); + assert.equal(out.status, "done", out.message ?? ""); + const top = git("log", "-2", "--format=%s", "HEAD").trim().split("\n"); + assert.deepEqual(top, ["W-t4", "W-t3"], "both messages installed"); + const q2 = queuePresent(root); + assert.ok(!q2.staging && !q2.inRebase, "and the queue is gone once the rebase ended"); + } finally { + removeTempRepo(root); + } +}); + +/** + * The abort path must forget the queue. + * + * Keying by sha makes a stale queue inert against a FOREIGN rebase — but + * `git rebase --abort` restores the ORIGINAL shas, so a queue abandoned by an + * abort matches perfectly the next time that branch is rebased. An abandoned + * draft then renamed a commit in a rebase nobody asked to reword, and the app + * said "Rebase continued." + * + * Measured before the fix: FINAL log "ABANDONED-DRAFT | m2 | m1". + */ +test("aborting forgets the messages that were abandoned with it", async () => { + const { root, git } = conflictingStack(); + try { + const repos = new RepoStore([]); + await repos.open(root); + const rebase = new RebaseBridge(repos); + const bridge = new GitBridge(repos); + + const plan = await rebase.load({ base: "trunk" }); + const rows = plan.commits.map((c) => ({ + action: (c.subject === "t2" ? "reword" : "pick") as "reword" | "pick", + sha: c.sha, + subject: c.subject, + message: c.subject === "t2" ? "ABANDONED-DRAFT" : undefined, + })); + assert.equal((await rebase.apply({ base: "trunk", rows })).status, "stopped"); + + // The user changes their mind and aborts — discarding that draft. + const ab = await bridge.rebaseAbort(); + assert.equal(ab.ok, true, `abort succeeds (${ab.message ?? ""})`); + const after = queuePresent(root); + assert.ok( + !after.staging && !after.inRebase, + "the abandoned message is gone with the plan that carried it", + ); + + // Now an ORDINARY rebase of the same branch, with no reword asked for. + const again = await bridge.branchRebase({ onto: "trunk" }); + assert.equal(again.ok, false, "it conflicts, as before"); + writeFileSync(`${root}/shared.txt`, "resolved\n"); + git("add", "shared.txt"); + const cont = await bridge.rebaseContinue(); + assert.equal(cont.ok, true, `continue succeeds (${cont.message ?? ""})`); + + assert.ok( + !git("log", "--format=%s", "HEAD").includes("ABANDONED-DRAFT"), + "and no commit wears a message the user threw away", + ); + } finally { + removeTempRepo(root); + } +}); + +/** + * An `edit` stop is a PAUSE, not an ending. + * + * `git rebase -i` exits 0 when it stops at an `edit` row — the user asked for + * that pause — and treating exit 0 as finished toasted "Rebase complete." over + * a detached, mid-rebase repo AND deleted the reword queue, so every reword + * below the `edit` row then committed with its original message. + */ +test("an edit stop is reported as a pause, and keeps the rewords below it", async () => { + const root = mkdtempSync(`${tmpdir()}/gs-editstop-`); + try { + const git = (...a: string[]): string => execFileSync("git", a, { cwd: root }).toString(); + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + for (const n of ["m1", "c1", "c2", "c3"]) { + writeFileSync(`${root}/${n}.txt`, `${n}\n`); + git("add", "-A"); + git("commit", "-qm", n); + if (n === "m1") git("branch", "trunk"); + } + + const repos = new RepoStore([]); + await repos.open(root); + const rebase = new RebaseBridge(repos); + const bridge = new GitBridge(repos); + + const plan = await rebase.load({ base: "trunk" }); + // Pause on c1 (the oldest), reword c3 (the newest) — below the stop in the + // todo, so it is only reached after the user continues. + const rows = plan.commits.map((c) => ({ + action: (c.subject === "c1" ? "edit" : c.subject === "c3" ? "reword" : "pick") as + | "edit" + | "reword" + | "pick", + sha: c.sha, + subject: c.subject, + message: c.subject === "c3" ? "NEW-C3" : undefined, + })); + + const out = await rebase.apply({ base: "trunk", rows }); + assert.equal(out.status, "stopped", `an edit row PAUSES the rebase (got ${out.status})`); + assert.ok( + queuePresent(root).inRebase, + "and the messages queued below it are still there", + ); + + const cont = await bridge.rebaseContinue(); + assert.equal(cont.ok, true, `continue finishes it (${cont.message ?? ""})`); + assert.equal( + git("log", "-1", "--format=%s", "HEAD").trim(), + "NEW-C3", + "the reword below the pause still landed", + ); + } finally { + removeTempRepo(root); + } +}); + +/** + * An abort made OUTSIDE the app. + * + * This is the case the first fence got wrong, and it is worth spelling out + * because the fence LOOKED right. The queue was stamped with the rebase's + * identity — `rebase-merge/onto` plus `orig-head` — and a resume compared the + * stamp before installing anything. But `git rebase --abort` RESTORES those + * values, so rebasing the same branch onto the same base again produced a + * byte-identical stamp, the comparison passed, and an abandoned draft renamed a + * commit in a rebase nobody asked to reword. Measured with the stamp in place: + * + * FINAL log: ABANDONED-DRAFT | m2 | m1 + * + * The queue now lives inside git's own `rebase-merge/`, so git deletes it with + * the directory — whoever aborts, from wherever. The location IS the fence, and + * nothing in this codebase has to describe a lifetime it does not control. + */ +test("a rebase aborted outside the app takes its messages with it", async () => { + const { root, git } = conflictingStack(); + try { + const repos = new RepoStore([]); + await repos.open(root); + const rebase = new RebaseBridge(repos); + const bridge = new GitBridge(repos); + + const plan = await rebase.load({ base: "trunk" }); + const rows = plan.commits.map((c) => ({ + action: (c.subject === "t2" ? "reword" : "pick") as "reword" | "pick", + sha: c.sha, + subject: c.subject, + message: c.subject === "t2" ? "ABANDONED-DRAFT" : undefined, + })); + assert.equal((await rebase.apply({ base: "trunk", rows })).status, "stopped"); + + // A terminal, the extension's own abort command, `git rebase --quit` — + // anything that does not go through this app. + git("rebase", "--abort"); + const left = queuePresent(root); + assert.ok(!left.staging && !left.inRebase, "git took the queue with its own state"); + + // The same branch, the same base, so the OLD stamp would have matched. + const again = await bridge.branchRebase({ onto: "trunk" }); + assert.equal(again.ok, false, "it conflicts on the same commit, as before"); + writeFileSync(`${root}/shared.txt`, "resolved\n"); + git("add", "shared.txt"); + assert.equal((await bridge.rebaseContinue()).ok, true); + + assert.match( + git("log", "--format=%s", "HEAD"), + /MY-REAL-MESSAGE|t2/, + "the commit kept its own message", + ); + assert.ok( + !git("log", "--format=%s", "HEAD").includes("ABANDONED-DRAFT"), + "and not one the user had thrown away", + ); + } finally { + removeTempRepo(root); + } +}); + +/** + * A second plan while one is already paused. + * + * git refuses the run — "It seems that there is already a rebase-merge + * directory" — but only AFTER the new plan's queue has been written, and the + * pause path then handed that queue to the rebase already in flight. So a plan + * git never started still rewrote the message, while the outcome shown read as + * "nothing happened". Measured: FINAL log "SECOND-DRAFT | m2 | m1". + */ +test("a second plan cannot overwrite the messages of the rebase already running", async () => { + const { root, git } = conflictingStack(); + try { + const repos = new RepoStore([]); + await repos.open(root); + const rebase = new RebaseBridge(repos); + const bridge = new GitBridge(repos); + + const plan = await rebase.load({ base: "trunk" }); + const rows = (msg: string) => + plan.commits.map((c) => ({ + action: (c.subject === "t2" ? "pick" : "reword") as "pick" | "reword", + sha: c.sha, + subject: c.subject, + message: c.subject === "t2" ? undefined : msg, + })); + + assert.equal((await rebase.apply({ base: "trunk", rows: rows("FIRST-DRAFT") })).status, "stopped"); + + const second = await rebase.apply({ base: "trunk", rows: rows("SECOND-DRAFT") }); + assert.equal(second.status, "failed", "the second plan is refused outright, not 'paused'"); + assert.match( + second.message ?? "", + /already in progress/i, + "and says so in words the user can act on", + ); + + writeFileSync(`${root}/shared.txt`, "resolved\n"); + git("add", "shared.txt"); + assert.equal((await bridge.rebaseContinue()).ok, true); + const log = git("log", "--format=%s", "HEAD"); + assert.ok(log.includes("FIRST-DRAFT"), "the running rebase kept ITS messages"); + assert.ok(!log.includes("SECOND-DRAFT"), "and the refused plan changed nothing"); + } finally { + removeTempRepo(root); + } +}); + +/** + * An interrupted `git am` is not a rebase. + * + * `git am` uses the SAME `.git/rebase-apply` directory a rebase on the apply + * backend uses; git tells them apart by a marker inside it — `applying` for am, + * `rebasing` for a rebase. Treating the directory alone as proof reported a + * stopped `am` as a paused rebase, so the Rebase view offered Continue, Skip + * and Abort — every one of which runs `git rebase` and is refused — while the + * actual `am` sat there unmentioned. + * + * The prose check this replaced got it right by accident: git says "You are in + * the middle of an am session", which never matched "rebase in progress". + */ +test("an interrupted git am is not reported as a rebase", async () => { + const root = mkdtempSync(`${tmpdir()}/gs-amstate-`); + const patches = mkdtempSync(`${tmpdir()}/gs-ampatch-`); + try { + const git = (...a: string[]): string => execFileSync("git", a, { cwd: root }).toString(); + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + writeFileSync(`${root}/f.txt`, "one\n"); + git("add", "-A"); + git("commit", "-qm", "base"); + + git("checkout", "-qb", "side"); + writeFileSync(`${root}/f.txt`, "two\n"); + git("commit", "-qam", "side change"); + git("format-patch", "-q", "-1", "-o", patches); + + git("checkout", "-q", "-"); + writeFileSync(`${root}/f.txt`, "conflicting\n"); + git("commit", "-qam", "main change"); + + // Conflicts, and leaves an am session open. + try { + execFileSync("git", ["am", `${patches}/0001-side-change.patch`], { cwd: root, stdio: "ignore" }); + } catch { + /* expected */ + } + assert.ok(existsSync(`${root}/.git/rebase-apply/applying`), "an am session is open"); + + const repos = new RepoStore([]); + await repos.open(root); + const plan = await new RebaseBridge(repos).load({}); + assert.equal( + plan.inProgress, + false, + "the app must not offer rebase controls for someone else's am session", + ); + } finally { + try { + execFileSync("git", ["am", "--abort"], { cwd: root, stdio: "ignore" }); + } catch { + /* nothing to abort */ + } + removeTempRepo(patches); + removeTempRepo(root); + } +}); + +/** + * A reword must keep the whole message. + * + * The plan carried only `%s`, so the textarea was seeded with the SUBJECT and + * choosing Reword — even changing nothing — committed the subject alone and + * deleted the explanation, the `Fixes #N`, the `Signed-off-by` and every + * `Co-Authored-By` under it. The app said "Rebase complete." + * + * `%B` has to arrive as a NUL-separated record, not another \x1f field: its + * newlines would parse as extra commits whose "shas" then fail the plan's + * validation and break the view. + */ +test("a reword keeps the body and the trailers", async () => { + const root = mkdtempSync(`${tmpdir()}/gs-rewordbody-`); + try { + const git = (...a: string[]): string => execFileSync("git", a, { cwd: root }).toString(); + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + writeFileSync(`${root}/a.txt`, "a\n"); + git("add", "-A"); + git("commit", "-qm", "base"); + git("branch", "trunk"); + writeFileSync(`${root}/b.txt`, "b\n"); + git("add", "-A"); + execFileSync( + "git", + ["commit", "-q", "-m", "feat: subject\n\nExplanation.\n\nFixes #123\nSigned-off-by: S <s@e>"], + { cwd: root }, + ); + + const repos = new RepoStore([]); + await repos.open(root); + const plan = await new RebaseBridge(repos).load({ base: "trunk" }); + const body = plan.commits[0].body ?? ""; + assert.match(body, /Explanation\./, "the plan carries the body"); + assert.match(body, /Signed-off-by/, "and the trailers"); + assert.match(body, /^feat: subject/, "starting with the subject"); + } finally { + removeTempRepo(root); + } +}); + +/** + * A `#` in a reword message. + * + * The message reaches git through the EDITOR channel, where `--cleanup=default` + * strips every line beginning with `core.commentChar`. A body line like `#123` + * was deleted without a word, and a message STARTING with one became empty — + * which git reads as "abort this commit", wedging the rebase. + * + * `core.commentChar=auto` does not fix it: git chooses when it PREPARES the + * file, from the text that is in it then, and the installer overwrites that + * file afterwards. The character is picked from the messages about to be + * installed instead. git generates its own boilerplate with the same character, + * so a squash group's is still stripped — which is why this cannot be done by + * changing the cleanup MODE. + */ +test("a reword message keeps its hashes, and a squash still loses its boilerplate", async () => { + const root = mkdtempSync(`${tmpdir()}/gs-hash-`); + try { + const git = (...a: string[]): string => execFileSync("git", a, { cwd: root }).toString(); + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + writeFileSync(`${root}/a.txt`, "a\n"); + git("add", "-A"); + git("commit", "-qm", "base"); + git("branch", "trunk"); + for (const n of ["one", "two"]) { + writeFileSync(`${root}/${n}.txt`, `${n}\n`); + git("add", "-A"); + git("commit", "-qm", n); + } + + const repos = new RepoStore([]); + await repos.open(root); + const bridge = new RebaseBridge(repos); + const plan = await bridge.load({ base: "trunk" }); + + const msg = "#urgent: renamed\n\nMentions #456 and:\n#not-a-comment\n\nFixes #123"; + const out = await bridge.apply({ + base: "trunk", + rows: plan.commits.map((c) => ({ + action: (c.subject === "two" ? "squash" : "reword") as "squash" | "reword", + sha: c.sha, + subject: c.subject, + message: c.subject === "one" ? msg : undefined, + })), + }); + assert.equal(out.status, "done", out.message ?? ""); + + const stored = git("log", "-1", "--format=%B", "HEAD"); + assert.ok(stored.includes("#urgent: renamed"), "a leading # survives"); + assert.ok(stored.includes("#not-a-comment"), "and a # line in the body"); + assert.ok(stored.includes("Fixes #123"), "and a trailer mentioning an issue"); + assert.ok( + !stored.includes("This is a combination"), + "while git's own squash boilerplate is still stripped", + ); + } finally { + removeTempRepo(root); + } +}); diff --git a/apps/desktop/test/sanitizerFailsClosed.test.ts b/apps/desktop/test/sanitizerFailsClosed.test.ts new file mode 100644 index 0000000..77da2b2 --- /dev/null +++ b/apps/desktop/test/sanitizerFailsClosed.test.ts @@ -0,0 +1,140 @@ +// sanitizeHtml is the one security boundary in the renderer: every issue body, +// PR description, review comment, release note, gist and README the app shows +// is attacker-controlled text from GitHub, and the renderer it lands in holds +// `window.gitstudio` — the whole IPC surface. A bypass here is not a cosmetic +// bug. +// +// The existing markdown tests check that known-bad CONSTRUCTS are removed. This +// file checks the property that makes the whole class impossible instead: +// +// every `<` in the output begins a tag this sanitizer itself produced. +// +// That property failed. The tag pattern requires a `>` after a balanced run of +// attributes, so a tag with an UNTERMINATED attribute quote — `<img src="x` — +// simply did not match, and unmatched text was passed through verbatim. A +// browser then ran that quote on to the next `"` anywhere in the document, +// which the sanitizer helpfully supplied from a later tag's own `title="…"`, +// and every token after it was parsed as an attribute of the tag that had never +// been attribute-filtered. Confirmed in headless Chrome: the payload below gave +// an <img> a live onerror that fired. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { sanitizeHtml, renderMarkdown } from "../src/renderer/markdown"; + +/** + * Every `<` that is not the start of a well-formed tag. Sanitizer output must + * have none: a `<` the browser will try to parse but that this sanitizer never + * inspected is exactly the hole that was open. + */ +function danglingMarkup(html: string): string[] { + const spans: Array<[number, number]> = []; + const tag = /<\/?[a-zA-Z][a-zA-Z0-9-]*(?:\s+[^<>]*?)?\/?>/g; + let m: RegExpExecArray | null; + while ((m = tag.exec(html))) spans.push([m.index, m.index + m[0].length]); + const loose: string[] = []; + for (let i = 0; i < html.length; i++) { + if (html[i] !== "<") continue; + if (!spans.some(([a, b]) => i >= a && i < b)) loose.push(html.slice(i, i + 48)); + } + return loose; +} + +/** Markup a browser would treat as live but the allowlist never approved. */ +const PAYLOADS = [ + // The proven bypass, both quote flavours. + '<img src="x', + "<img src='x", + '<a href="x onclick=alert(1)>click</a>', + "<a href='x onclick=alert(1)>click</a>", + '<img src="x onerror=alert(1)>', + // Unterminated on a tag that is not even allowlisted. + '<iframe src="x', + '<svg width="1', + // A tag whose name runs into the attribute soup. + "<a href=x onclick=alert(1)>t</a>", + // Backtick and unquoted values. + "<img src=`x` onerror=alert(1)>", + // Split and nested tag names. + "<scr<script>ipt>alert(1)</script>", + "<<img src=x onerror=alert(1)>", + // Newlines and tabs inside the tag. + '<img\nsrc="x\tonerror=alert(1)>', + // A stray `<` in prose must also come out inert. + "a < b and c <3 d", + "5 <6", +]; + +test("the proven bypass no longer produces live markup", () => { + // Two fragments in one body. The first opens a quote the tag pattern cannot + // close; the second supplies the closing quote from the sanitizer's own + // output, putting attacker text into attribute position on the first tag. + const body = + '<img src="x\n\nsome ordinary text\n\n<b title="onerror=alert(document.domain) x">bold</b>'; + const out = sanitizeHtml(body); + assert.ok( + out.startsWith("<img"), + `the unparseable tag must be escaped, got: ${out.slice(0, 60)}`, + ); + assert.deepEqual(danglingMarkup(out), []); + // The legitimate second tag still renders, with its value inert. + assert.match(out, /<b title="onerror=alert\(document\.domain\) x">bold<\/b>/); +}); + +test("the same body through the real renderMarkdown path is inert too", () => { + const out = renderMarkdown( + '<img src="x\n\nsome ordinary text\n\n<b title="onerror=alert(document.domain) x">bold</b>', + ); + assert.deepEqual(danglingMarkup(out), []); +}); + +for (const p of PAYLOADS) { + test(`no dangling markup survives: ${JSON.stringify(p).slice(0, 46)}`, () => { + const out = sanitizeHtml(p); + assert.deepEqual( + danglingMarkup(out), + [], + `sanitizer left markup a browser would parse: ${JSON.stringify(out).slice(0, 160)}`, + ); + }); +} + +test("a body cannot forge the tag sentinel to smuggle markup", () => { + // Allowlisted tags are parked behind U+E001 during the escape pass. If a body + // could supply its own sentinel it could name a slot and have arbitrary text + // restored as markup — so the sentinel is stripped from the input first. + const S = "\uE001"; + const forged = `${S}0${S}<b>x</b>`; + const out = sanitizeHtml(forged); + assert.equal(out.includes(S), false, "sentinels must not survive into output"); + assert.match(out, /<b>x<\/b>/, "and real markup still renders"); + assert.deepEqual(danglingMarkup(out), []); + assert.equal(renderMarkdown(`${S}0${S}plain`).includes(S), false); +}); + +test("ordinary markup is untouched by the escape pass", () => { + const out = sanitizeHtml( + '<p>hello <b>world</b> and <a href="https://example.com">a link</a></p><ul><li>x</li></ul>', + ); + assert.match(out, /<p>/); + assert.match(out, /<b>world<\/b>/); + assert.match(out, /<a href="https:\/\/example\.com" target="_blank" rel="noopener noreferrer nofollow">/); + assert.match(out, /<li>x<\/li>/); + assert.deepEqual(danglingMarkup(out), []); +}); + +test("prose that merely looks like markup reads as prose", () => { + assert.equal(sanitizeHtml("a < b"), "a < b"); + assert.equal(sanitizeHtml("i <3 you"), "i <3 you"); + // …and a void tag still self-closes rather than being escaped. + assert.match(sanitizeHtml("<br>"), /<br \/>/); +}); + +test("a disallowed tag is dropped, not escaped into visible source", () => { + // Escaping an <iframe> would render the literal text "<iframe src=...>" into + // the comment, which is its own kind of wrong. Well-formed disallowed tags + // are removed; only UNPARSEABLE ones become text. + const out = sanitizeHtml('<iframe src="https://e.com"></iframe>after'); + assert.equal(out.includes("iframe"), false); + assert.match(out, /after/); +}); diff --git a/apps/desktop/test/searchDebounce.test.ts b/apps/desktop/test/searchDebounce.test.ts new file mode 100644 index 0000000..2ee9c0d --- /dev/null +++ b/apps/desktop/test/searchDebounce.test.ts @@ -0,0 +1,117 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createSearchScheduler } from "../src/renderer/searchDebounce"; + +// A fake clock: timers only fire when the test says so, so debounce and +// out-of-order answers are deterministic instead of racy. +function fakeTimers() { + let next = 1; + const pending = new Map<number, () => void>(); + return { + setTimer: (fn: () => void, _ms: number) => { + const id = next++; + pending.set(id, fn); + return id; + }, + clearTimer: (id: number) => { + pending.delete(id); + }, + /** Fire everything currently scheduled. */ + flush: () => { + const fns = [...pending.values()]; + pending.clear(); + for (const fn of fns) fn(); + }, + count: () => pending.size, + }; +} + +test("typing repeatedly issues ONE search, not one per keystroke", () => { + const t = fakeTimers(); + const ran: string[] = []; + const s = createSearchScheduler((q) => ran.push(q), { ...t }); + s.queue("gi"); + s.queue("git"); + s.queue("gitst"); + s.queue("gitstudio"); + assert.equal(t.count(), 1, "only the last keystroke has a live timer"); + t.flush(); + assert.deepEqual(ran, ["gitstudio"]); +}); + +test("queries under the minimum never search", () => { + const t = fakeTimers(); + const ran: string[] = []; + const s = createSearchScheduler((q) => ran.push(q), { ...t, minChars: 3 }); + assert.equal(s.queue("g"), undefined); + assert.equal(s.queue("gi"), undefined); + t.flush(); + assert.deepEqual(ran, []); + assert.notEqual(s.queue("git"), undefined); + t.flush(); + assert.deepEqual(ran, ["git"]); +}); + +test("a stale answer is not current — the whole point of generations", () => { + const t = fakeTimers(); + const gens: number[] = []; + const s = createSearchScheduler((_q, gen) => gens.push(gen), { ...t }); + const first = s.queue("react"); + t.flush(); + const second = s.queue("reactive"); + t.flush(); + assert.equal(s.isCurrent(second!), true); + assert.equal(s.isCurrent(first!), false, "the older query's results must be dropped"); + assert.deepEqual(gens.length, 2); +}); + +test("typing back below the minimum invalidates a search already in flight", () => { + const t = fakeTimers(); + const s = createSearchScheduler(() => {}, { ...t, minChars: 3 }); + const gen = s.queue("gitstudio")!; + t.flush(); + assert.equal(s.isCurrent(gen), true); + s.queue("gi"); // too short to run, but it MUST invalidate + assert.equal(s.isCurrent(gen), false); +}); + +test("re-typing the same query doesn't spend another request", () => { + const t = fakeTimers(); + const ran: string[] = []; + const s = createSearchScheduler((q) => ran.push(q), { ...t }); + s.queue("git"); + t.flush(); + assert.equal(s.queue("git"), undefined); + t.flush(); + assert.deepEqual(ran, ["git"]); +}); + +test("cancel() drops a pending search", () => { + const t = fakeTimers(); + const ran: string[] = []; + const s = createSearchScheduler((q) => ran.push(q), { ...t }); + s.queue("gitstudio"); + s.cancel(); + t.flush(); + assert.deepEqual(ran, []); +}); + +test("whitespace is trimmed before both the length test and the dedupe", () => { + const t = fakeTimers(); + const ran: string[] = []; + const s = createSearchScheduler((q) => ran.push(q), { ...t, minChars: 3 }); + assert.equal(s.queue(" g "), undefined); + s.queue(" git "); + t.flush(); + assert.deepEqual(ran, ["git"]); + assert.equal(s.queue("git"), undefined, "same query after trimming"); +}); + +test("lastQuery reports what actually ran, not what was typed", () => { + const t = fakeTimers(); + const s = createSearchScheduler(() => {}, { ...t }); + s.queue("react"); + assert.equal(s.lastQuery(), "", "nothing has run yet"); + t.flush(); + assert.equal(s.lastQuery(), "react"); +}); diff --git a/apps/desktop/test/searchGuard.test.ts b/apps/desktop/test/searchGuard.test.ts new file mode 100644 index 0000000..88a2d61 --- /dev/null +++ b/apps/desktop/test/searchGuard.test.ts @@ -0,0 +1,66 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { SearchGuard, SEARCH_LIMITS } from "../src/main/github/searchGuard"; + +// The budget that keeps Explore from earning a surprise 403. The guard must +// refuse BEFORE spending, and must say how long to wait — a plain "no" would +// leave the UI with nothing honest to show. + +test("core and code have separate budgets", () => { + let now = 0; + const g = new SearchGuard(() => now); + // Spend the entire code budget… + for (let i = 0; i < SEARCH_LIMITS.code; i++) g.take("code"); + assert.equal(g.take("code").ok, false); + // …core is untouched. + assert.equal(g.take("core").ok, true); +}); + +test("a spent budget reports how long to wait, not just failure", () => { + let now = 1_000_000; + const g = new SearchGuard(() => now); + for (let i = 0; i < SEARCH_LIMITS.core; i++) g.take("core"); + const r = g.take("core"); + assert.equal(r.ok, false); + if (!r.ok) { + assert.ok(r.retryInMs > 0, "must carry a wait"); + assert.ok(r.retryInMs <= 61_000, `wait should be within a window, got ${r.retryInMs}`); + } +}); + +test("the budget refills as the window slides", () => { + let now = 0; + const g = new SearchGuard(() => now); + while (g.take("code").ok) { + /* drain */ + } + assert.equal(g.take("code").ok, false); + now += 60_001; // every spend has aged out + assert.equal(g.take("code").ok, true); +}); + +test("remaining() reports what's actually left and never goes negative", () => { + let now = 0; + const g = new SearchGuard(() => now); + const start = g.remaining("core"); + assert.ok(start > 0 && start < SEARCH_LIMITS.core, "reserve keeps headroom"); + g.take("core"); + assert.equal(g.remaining("core"), start - 1); + while (g.take("core").ok) { + /* drain */ + } + assert.equal(g.remaining("core"), 0); +}); + +test("a partial window refills partially, not all at once", () => { + let now = 0; + const g = new SearchGuard(() => now); + g.take("code"); // spent at t=0 + now = 30_000; + while (g.take("code").ok) { + /* drain the rest at t=30s */ + } + now = 60_001; // only the t=0 spend has aged out + assert.equal(g.take("code").ok, true); + assert.equal(g.take("code").ok, false); +}); diff --git a/apps/desktop/test/searchQuery.test.ts b/apps/desktop/test/searchQuery.test.ts new file mode 100644 index 0000000..91242e2 --- /dev/null +++ b/apps/desktop/test/searchQuery.test.ts @@ -0,0 +1,82 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + beyondCeiling, + codeSearchPath, + normalizeQuery, + reachableCount, + repoSearchPath, + repoSortParams, + userQuery, + userSearchPath, + SEARCH_PER_PAGE, + SEARCH_RESULT_CEILING, +} from "../src/main/github/searchQuery"; + +// Every URL Explore can ask GitHub for. A wrong search query doesn't error — +// it returns results, just not the right ones — so the shapes are pinned here. + +test("queries are trimmed and internally collapsed", () => { + assert.equal(normalizeQuery(" react hooks "), "react hooks"); + assert.equal(normalizeQuery("\n\tone\t two \n"), "one two"); + assert.equal(normalizeQuery(" "), ""); +}); + +test("a user search appends the type qualifier that separates the tabs", () => { + assert.equal(userQuery("anton", "users"), "anton type:user"); + assert.equal(userQuery("gitstudio", "orgs"), "gitstudio type:org"); +}); + +test("a user-supplied type: qualifier is respected, not doubled", () => { + assert.equal(userQuery("anton type:org", "users"), "anton type:org"); + assert.equal(userQuery("anton TYPE:org", "users"), "anton TYPE:org"); +}); + +test("'best' sends no sort — that IS GitHub's own ranking", () => { + assert.deepEqual(repoSortParams("best"), {}); + assert.deepEqual(repoSortParams("stars"), { sort: "stars", order: "desc" }); + assert.deepEqual(repoSortParams("updated"), { sort: "updated", order: "desc" }); +}); + +test("repo search path carries query, per_page, page and sort", () => { + const p = repoSearchPath("git client", "stars", 2); + assert.match(p, /^\/search\/repositories\?/); + assert.match(p, /q=git\+client/); + assert.match(p, new RegExp(`per_page=${SEARCH_PER_PAGE}`)); + assert.match(p, /page=2/); + assert.match(p, /sort=stars/); + assert.match(p, /order=desc/); +}); + +test("a best-sorted repo search sends no sort param at all", () => { + const p = repoSearchPath("git client", "best"); + assert.equal(/[?&]sort=/.test(p), false); +}); + +test("qualifiers and special characters survive encoding", () => { + const p = repoSearchPath("stars:>1000 language:TypeScript", "best"); + assert.match(p, /q=stars%3A%3E1000\+language%3ATypeScript/); +}); + +test("user and code search paths are well-formed", () => { + assert.match(userSearchPath("anton", "orgs", 3), /^\/search\/users\?.*type%3Aorg.*page=3/); + assert.match(codeSearchPath("addEventListener", 1), /^\/search\/code\?q=addEventListener/); +}); + +// ── the 1000-result ceiling ────────────────────────────────────────────────── + +test("pages within the ceiling are allowed", () => { + assert.equal(beyondCeiling(1), false); + // page 33 ends at 990 — the last fully reachable page. + assert.equal(beyondCeiling(33), false); +}); + +test("the first page past 1000 results is refused locally (GitHub 422s)", () => { + assert.equal(beyondCeiling(34), true); + assert.equal(beyondCeiling(100), true); +}); + +test("reachableCount never promises more than GitHub will serve", () => { + assert.equal(reachableCount(12), 12); + assert.equal(reachableCount(50_000), SEARCH_RESULT_CEILING); +}); diff --git a/apps/desktop/test/stageConflictMarkers.test.ts b/apps/desktop/test/stageConflictMarkers.test.ts new file mode 100644 index 0000000..7603ce7 --- /dev/null +++ b/apps/desktop/test/stageConflictMarkers.test.ts @@ -0,0 +1,235 @@ +// Staging a conflicted file is how you tell git the conflict is resolved. +// +// `git add` on an unmerged path clears its stage entries and marks it settled. +// So adding a file that still contains `<<<<<<<` does not "stage a broken file" +// — it declares the conflict RESOLVED with the markers in it, and the next +// commit carries them into the tree, where they compile as garbage and read, in +// the history, as a deliberate change. +// +// `stageAll()` has refused this since it was written, with a long comment about +// why. `stage(path)` did not — and per-file Stage is the button people actually +// press while working through a conflict. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { writeFileSync, readFileSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { RepoStore } from "../src/main/repoStore"; +import { GitBridge } from "../src/main/gitBridge"; +import { removeTempRepo } from "./tmpRepo"; + +/** A repo left mid-merge with one genuinely conflicted file. */ +function conflicted(): { root: string; git: (...a: string[]) => string } { + const root = mkdtempSync(`${tmpdir()}/gs-conflict-stage-`); + const git = (...a: string[]): string => { + try { + return execFileSync("git", a, { cwd: root }).toString(); + } catch (e) { + // `merge` exits non-zero ON a conflict, which is the state we want. + return String((e as { stdout?: Buffer }).stdout ?? ""); + } + }; + git("init", "-q", "-b", "main"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + writeFileSync(`${root}/f.txt`, "base\n"); + git("add", "-A"); + git("commit", "-qm", "base"); + git("checkout", "-q", "-b", "side"); + writeFileSync(`${root}/f.txt`, "theirs\n"); + git("commit", "-qam", "theirs"); + git("checkout", "-q", "main"); + writeFileSync(`${root}/f.txt`, "ours\n"); + git("commit", "-qam", "ours"); + git("merge", "side"); + return { root, git }; +} + +test("a file still carrying conflict markers cannot be staged one file at a time", async () => { + const { root, git } = conflicted(); + try { + const onDisk = readFileSync(`${root}/f.txt`, "utf8"); + assert.match(onDisk, /^<{7} /m, "the fixture is not actually conflicted"); + + const repos = new RepoStore([]); + await repos.open(root); + const b = new GitBridge(repos); + + const r = await b.stage("f.txt"); + assert.equal(r.ok, false, "staging is refused"); + assert.match(r.message ?? "", /conflict markers/i, "and says why"); + assert.equal(r.expected, true, "it is a condition, not a crash to report"); + + // The decisive check: git must still consider the path UNMERGED. If the add + // had gone through, `ls-files -u` would be empty and the conflict would read + // as settled. + assert.notEqual( + git("ls-files", "-u", "--", "f.txt").trim(), + "", + "the path is still unmerged — the conflict was not marked resolved", + ); + } finally { + removeTempRepo(root); + } +}); + +test("resolving it first makes the very same call succeed", async () => { + // The guard must not make a conflicted file unstageable forever — that is the + // failure mode stageAll's comment warns about: "someone who resolved a + // conflict properly in another editor would find that file could never be + // staged and Continue disabled forever". + const { root, git } = conflicted(); + try { + const repos = new RepoStore([]); + await repos.open(root); + const b = new GitBridge(repos); + + assert.equal((await b.stage("f.txt")).ok, false); + writeFileSync(`${root}/f.txt`, "ours and theirs, reconciled\n"); + const r = await b.stage("f.txt"); + assert.equal(r.ok, true, "a resolved file stages normally"); + assert.equal(git("ls-files", "-u", "--", "f.txt").trim(), "", "and the conflict is settled"); + } finally { + removeTempRepo(root); + } +}); + +test("a conflicted file cannot be staged in PARTS either", async () => { + // `git add` on an unmerged path settles the whole conflict, so ticking one + // hunk of a conflicted file marked it resolved with every other hunk's + // markers still in it. Both partial-staging routes meet in `lineStageable`. + const { root, git } = conflicted(); + try { + const repos = new RepoStore([]); + await repos.open(root); + const b = new GitBridge(repos); + + const lines = await b.stageLines({ path: "f.txt", lines: [1] }); + assert.equal(lines.ok, false, "line staging is refused"); + assert.match(lines.message ?? "", /conflicted/i, "and says why"); + assert.equal(lines.expected, true, "it is a condition, not a crash"); + + const hunk = await b.hunksStage({ path: "f.txt", index: 0 }); + assert.equal(hunk.ok, false, "hunk staging is refused too"); + + assert.notEqual( + git("ls-files", "-u", "--", "f.txt").trim(), + "", + "the path is still unmerged — nothing declared the conflict settled", + ); + } finally { + removeTempRepo(root); + } +}); + +test("an ordinary file is unaffected", async () => { + // The guard reads the working file on every stage; a normal edit must not be + // slowed down or refused by it. + const root = mkdtempSync(`${tmpdir()}/gs-conflict-plain-`); + const git = (...a: string[]): string => execFileSync("git", a, { cwd: root }).toString(); + try { + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + writeFileSync(`${root}/f.txt`, "one\n"); + git("add", "-A"); + git("commit", "-qm", "base"); + writeFileSync(`${root}/f.txt`, "two\n"); + + const repos = new RepoStore([]); + await repos.open(root); + const b = new GitBridge(repos); + assert.equal((await b.stage("f.txt")).ok, true); + assert.equal(git("diff", "--cached", "--name-only").trim(), "f.txt"); + } finally { + removeTempRepo(root); + } +}); + +test("a file that merely mentions the markers in its text is judged by both ends", async () => { + // `hasConflictMarkers` requires BOTH a `<<<<<<< ` and a `>>>>>>> ` line, so + // documentation about conflicts — this project has some — still stages. + const root = mkdtempSync(`${tmpdir()}/gs-conflict-doc-`); + const git = (...a: string[]): string => execFileSync("git", a, { cwd: root }).toString(); + try { + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + writeFileSync(`${root}/README.md`, "A conflict opens with a line of seven `<` characters.\n"); + git("add", "-A"); + git("commit", "-qm", "base"); + writeFileSync( + `${root}/README.md`, + "A conflict opens with a line of seven `<` characters, like this:\n\n<<<<<<< HEAD\n", + ); + + const repos = new RepoStore([]); + await repos.open(root); + const b = new GitBridge(repos); + assert.equal( + (await b.stage("README.md")).ok, + true, + "an opening marker alone is not a conflict", + ); + } finally { + removeTempRepo(root); + } +}); + +test("a conflicted BINARY is held back by Stage all too", async () => { + // The whole guard is "no markers means somebody resolved it" — and a binary + // cannot contain markers, any more than a modify/delete can. So the one kind + // of conflict the app itself refuses to open a text merge for was the one + // kind "Stage all" waved straight through, declaring it resolved with + // whichever side happened to be sitting in the worktree. + const root = mkdtempSync(`${tmpdir()}/gs-conflict-bin-`); + const git = (...a: string[]): string => { + try { + return execFileSync("git", a, { cwd: root }).toString(); + } catch (e) { + return String((e as { stdout?: Buffer }).stdout ?? ""); + } + }; + try { + const nul = (tag: number): Buffer => + Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00]), Buffer.alloc(48, tag)]); + git("init", "-q", "-b", "main"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + writeFileSync(`${root}/art.png`, nul(1)); + git("add", "-A"); + git("commit", "-qm", "base"); + git("checkout", "-q", "-b", "side"); + writeFileSync(`${root}/art.png`, nul(2)); + git("commit", "-qam", "theirs"); + git("checkout", "-q", "main"); + writeFileSync(`${root}/art.png`, nul(3)); + git("commit", "-qam", "ours"); + git("merge", "side"); + + assert.notEqual(git("ls-files", "-u", "--", "art.png").trim(), "", "the fixture is conflicted"); + + const repos = new RepoStore([]); + await repos.open(root); + const b = new GitBridge(repos); + const r = await b.stageAll(); + + // The decisive check: git must still consider the path UNMERGED. If the + // add had gone through, the conflict would read as settled and Continue + // would light up over a file nobody chose a side for. + assert.notEqual( + git("ls-files", "-u", "--", "art.png").trim(), + "", + "the binary conflict is still unmerged", + ); + assert.equal(r.ok, false, "and Stage all says it could not take everything"); + assert.match(r.message ?? "", /art\.png/, "naming the file that needs a decision"); + } finally { + removeTempRepo(root); + } +}); diff --git a/apps/desktop/test/stageLines.test.ts b/apps/desktop/test/stageLines.test.ts new file mode 100644 index 0000000..59223c9 --- /dev/null +++ b/apps/desktop/test/stageLines.test.ts @@ -0,0 +1,267 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { writeFileSync, mkdtempSync, chmodSync } from "node:fs"; +import { removeTempRepo } from "./tmpRepo"; +import { tmpdir } from "node:os"; +import { RepoStore } from "../src/main/repoStore"; +import { GitBridge } from "../src/main/gitBridge"; + +/** + * Line-level staging against a real repository. + * + * The selection the renderer sends is numbered in the pane the user clicked, and + * that pane always shows `original` — the INDEX. Staging happened to work + * because there `modified` is the working tree, whose numbering usually agrees. + * Unstaging did not: there `modified` is HEAD, so any staged edit that inserts + * or deletes lines shifts every later line, and the selection then names one + * line in the index and a different one in HEAD. + * + * The visible symptom was "Nothing to apply in the selection." for a line + * plainly sitting on screen. + */ +function repo(): { root: string; git: (...a: string[]) => string } { + const root = mkdtempSync(`${tmpdir()}/gs-stagelines-`); + const git = (...a: string[]): string => execFileSync("git", a, { cwd: root }).toString(); + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); // no background gc racing the cleanup + return { root, git }; +} + +test("unstaging a line works when a staged insertion has shifted the numbering", async () => { + const { root, git } = repo(); + try { + const base = Array.from({ length: 20 }, (_, i) => `X${i + 1}`); + writeFileSync(`${root}/u.txt`, base.join("\n") + "\n"); + git("add", "u.txt"); + git("commit", "-qm", "base"); + + // Stage TWO things: five inserted lines near the top, and an edit far below + // it. The insertion pushes the edit five lines down in the index, so index + // numbering and HEAD numbering no longer agree — which is the whole point. + const staged: string[] = []; + for (let i = 1; i <= 20; i++) { + if (i === 3) for (const n of ["NEW-A", "NEW-B", "NEW-C", "NEW-D", "NEW-E"]) staged.push(n); + staged.push(i === 15 ? "X15-EDIT" : `X${i}`); + } + writeFileSync(`${root}/u.txt`, staged.join("\n") + "\n"); + git("add", "u.txt"); + + // The user clicks "X15-EDIT" where it appears in the index pane. + const indexLines = git("show", ":u.txt").split("\n"); + const clicked = indexLines.indexOf("X15-EDIT") + 1; + assert.equal(clicked, 20, "the staged edit sits five lines lower than in HEAD"); + + const repos = new RepoStore([]); + await repos.open(root); + const bridge = new GitBridge(repos); + const res = await bridge.stageLines({ path: "u.txt", lines: [clicked], reverse: true }); + assert.equal(res.ok, true, `unstaging that line succeeds (${res.message ?? ""})`); + + const after = git("show", ":u.txt").split("\n"); + assert.ok(after.includes("X15"), "the selected change is rolled back to HEAD"); + assert.ok(!after.includes("X15-EDIT"), "and is no longer staged"); + assert.ok( + after.includes("NEW-A") && after.includes("NEW-E"), + "while the OTHER staged change is untouched — only what was selected moves", + ); + } finally { + removeTempRepo(root); + } +}); + +test("staging a line still applies the working-tree change it names", async () => { + const { root, git } = repo(); + try { + writeFileSync(`${root}/s.txt`, ["a", "b", "c", "d"].join("\n") + "\n"); + git("add", "s.txt"); + git("commit", "-qm", "base"); + // Two unstaged edits; stage only the second. + writeFileSync(`${root}/s.txt`, ["a-EDIT", "b", "c-EDIT", "d"].join("\n") + "\n"); + + const repos = new RepoStore([]); + await repos.open(root); + const bridge = new GitBridge(repos); + const res = await bridge.stageLines({ path: "s.txt", lines: [3] }); + assert.equal(res.ok, true, `staging line 3 succeeds (${res.message ?? ""})`); + + const staged = git("show", ":s.txt").split("\n"); + assert.equal(staged[2], "c-EDIT", "the selected line is staged"); + assert.equal(staged[0], "a", "and the unselected one is not"); + } finally { + removeTempRepo(root); + } +}); + +/** + * The mode a new file is staged with. + * + * `indexMode` returned whatever the index already recorded, and "100644" when + * there was no index entry at all — which is exactly the brand-new-file case. So + * staging a new executable script through LINE or HUNK staging recorded it + * non-executable, and the commit shipped a script that will not run. The + * ordinary Stage button was never affected: `git add` reads the working tree. + */ +test("a new executable script keeps its bit through line staging", async (t) => { + if (process.platform === "win32") return t.skip("core.fileMode is off on Windows"); + const { root, git } = repo(); + try { + writeFileSync(`${root}/seed`, "x\n"); + git("add", "seed"); + git("commit", "-qm", "seed"); + + writeFileSync(`${root}/run.sh`, "#!/bin/sh\necho hello\n"); + chmodSync(`${root}/run.sh`, 0o755); + + const repos = new RepoStore([]); + await repos.open(root); + const bridge = new GitBridge(repos); + const res = await bridge.stageLines({ path: "run.sh", lines: [1, 2] }); + assert.equal(res.ok, true, `staging the new script succeeds (${res.message ?? ""})`); + + const entry = git("ls-files", "-s", "--", "run.sh").trim(); + assert.match(entry, /^100755 /, `it is staged executable, not 100644 (${entry})`); + } finally { + removeTempRepo(root); + } +}); + +/** A plain (non-executable) new file must not gain a bit it never had. */ +test("line staging does not invent an executable bit", async () => { + const { root, git } = repo(); + try { + writeFileSync(`${root}/seed`, "x\n"); + git("add", "seed"); + git("commit", "-qm", "seed"); + writeFileSync(`${root}/notes.md`, "# notes\nbody\n"); + + const repos = new RepoStore([]); + await repos.open(root); + const bridge = new GitBridge(repos); + await bridge.stageLines({ path: "notes.md", lines: [1, 2] }); + + const entry = git("ls-files", "-s", "--", "notes.md").trim(); + assert.match(entry, /^100644 /, `an ordinary file stays 100644 (${entry})`); + } finally { + removeTempRepo(root); + } +}); + +/** + * The coordinate space the selection actually arrives in. + * + * `fileDiff` builds EVERY working-tree diff as HEAD vs WORKING, whatever the + * file's stage state, and `getSelectedLines` reads the RIGHT editor — so the + * renderer always sends WORKING-tree line numbers. The unstage path matched + * them against INDEX coordinates, and the comment in the source asserted the + * opposite ("that pane always shows `original` — the index"). + * + * The test above cannot catch it: it stages the file and writes nothing + * afterwards, so the working tree is byte-identical to the index and the two + * coordinate spaces coincide. It exercises the index↔HEAD shift, which is the + * axis the old code got right. The broken axis is index↔WORKING, and the shape + * that reaches it is a file that is staged AND dirty — git's `MM`. + * + * Measured on the shipping code before the fix: clicking "X5-STAGED" left it + * staged and silently rolled back "X15-STAGED", returning ok:true. + */ +test("unstaging uses the WORKING line numbers the diff pane actually shows", async () => { + const { root, git } = repo(); + try { + const base = Array.from({ length: 20 }, (_, i) => `X${i + 1}`); + writeFileSync(`${root}/f.txt`, base.join("\n") + "\n"); + git("add", "f.txt"); + git("commit", "-qm", "base"); + + // Two STAGED edits. + const staged = base.map((l, i) => (i === 4 ? "X5-STAGED" : i === 14 ? "X15-STAGED" : l)); + writeFileSync(`${root}/f.txt`, staged.join("\n") + "\n"); + git("add", "f.txt"); + + // …then ten UNSTAGED lines pushed in above them, so working numbering runs + // ten ahead of index numbering. + const working = [...Array.from({ length: 10 }, (_, i) => `NEW-${i + 1}`), ...staged]; + writeFileSync(`${root}/f.txt`, working.join("\n") + "\n"); + assert.match( + git("status", "--porcelain", "--", "f.txt"), + /^MM /, + "the file is staged AND dirty — the shape that reaches the broken axis", + ); + + const indexLines = git("show", ":f.txt").split("\n"); + const workingLines = working; + assert.equal(indexLines.indexOf("X5-STAGED") + 1, 5, "X5-STAGED is index line 5"); + assert.equal( + workingLines.indexOf("X5-STAGED") + 1, + 15, + "…and WORKING line 15, which is the number the diff pane shows", + ); + + const repos = new RepoStore([]); + await repos.open(root); + const bridge = new GitBridge(repos); + // The user clicks "X5-STAGED" where it appears on screen: working line 15. + const res = await bridge.stageLines({ path: "f.txt", lines: [15], reverse: true }); + assert.equal(res.ok, true, `unstaging it succeeds (${res.message ?? ""})`); + + const after = git("show", ":f.txt"); + assert.ok(!after.includes("X5-STAGED"), "the change they CLICKED is unstaged"); + assert.ok( + after.includes("X15-STAGED"), + "and the one they did not click is untouched — this rolled back the wrong change", + ); + } finally { + removeTempRepo(root); + } +}); + +/** The other symptom of the same cause: a refusal for a line that is right there. */ +test("unstaging a lone staged change below an unstaged insertion is not refused", async () => { + const { root, git } = repo(); + try { + const base = Array.from({ length: 20 }, (_, i) => `X${i + 1}`); + writeFileSync(`${root}/f.txt`, base.join("\n") + "\n"); + git("add", "f.txt"); + git("commit", "-qm", "base"); + + const staged = base.map((l, i) => (i === 14 ? "X15-STAGED" : l)); + writeFileSync(`${root}/f.txt`, staged.join("\n") + "\n"); + git("add", "f.txt"); + const working = ["NEW-1", "NEW-2", "NEW-3", ...staged]; + writeFileSync(`${root}/f.txt`, working.join("\n") + "\n"); + + const repos = new RepoStore([]); + await repos.open(root); + const bridge = new GitBridge(repos); + // On screen it is line 18; in the index it is line 15. + const res = await bridge.stageLines({ path: "f.txt", lines: [18], reverse: true }); + assert.equal(res.ok, true, `it is not refused (${res.message ?? ""})`); + assert.ok(!git("show", ":f.txt").includes("X15-STAGED"), "and it is actually unstaged"); + } finally { + removeTempRepo(root); + } +}); + +/** A line that exists ONLY in the working tree has no staged change under it. */ +test("a purely-unstaged line carries no staged change to unstage", async () => { + const { root, git } = repo(); + try { + writeFileSync(`${root}/f.txt`, "a\nb\nc\n"); + git("add", "f.txt"); + git("commit", "-qm", "base"); + writeFileSync(`${root}/f.txt`, "a\nB2\nc\n"); + git("add", "f.txt"); + writeFileSync(`${root}/f.txt`, "a\nB2\nc\nNEW\n"); + + const repos = new RepoStore([]); + await repos.open(root); + const bridge = new GitBridge(repos); + const res = await bridge.stageLines({ path: "f.txt", lines: [4], reverse: true }); + assert.equal(res.ok, false, "there is nothing staged there to unstage"); + assert.equal(git("show", ":f.txt"), "a\nB2\nc\n", "and nothing else is touched"); + } finally { + removeTempRepo(root); + } +}); diff --git a/apps/desktop/test/stagingSafety.test.ts b/apps/desktop/test/stagingSafety.test.ts new file mode 100644 index 0000000..adf9f21 --- /dev/null +++ b/apps/desktop/test/stagingSafety.test.ts @@ -0,0 +1,182 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { writeFileSync, readFileSync, mkdtempSync, symlinkSync, unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { RepoStore } from "../src/main/repoStore"; +import { GitBridge } from "../src/main/gitBridge"; +import { removeTempRepo } from "./tmpRepo"; + +/** + * Line and hunk staging round-trip a file through a JavaScript string: the two + * sides are decoded as UTF-8, the selected changes applied to the string, and + * the result hashed back with `hash-object`. Three kinds of file do not survive + * that trip, and all three used to be destroyed silently, with ok:true. + */ +function repo(name: string): { root: string; git: (...a: string[]) => string } { + const root = mkdtempSync(`${tmpdir()}/gs-safety-${name}-`); + const git = (...a: string[]): string => execFileSync("git", a, { cwd: root }).toString(); + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + return { root, git }; +} + +/** + * A PNG staged line by line came back 29 bytes in, 42 out, its header + * `efbfbd504e47` instead of `89504e47` — every non-UTF-8 byte replaced by + * U+FFFD — and the app said "Staged selected lines." + */ +test("a binary file cannot be staged line by line, and is not offered hunks", async () => { + const { root, git } = repo("bin"); + try { + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xd8, 0xc3, 0x28, 0x0a]); + writeFileSync(`${root}/img.png`, png); + git("add", "-A"); + git("commit", "-qm", "base"); + writeFileSync(`${root}/img.png`, Buffer.concat([png, Buffer.from("ABC\n")])); + + const repos = new RepoStore([]); + await repos.open(root); + const b = new GitBridge(repos); + + const sl = await b.stageLines({ path: "img.png", lines: [1, 2] }); + assert.equal(sl.ok, false, "line staging is refused"); + assert.match(sl.message ?? "", /whole/i, "and says to stage it whole"); + assert.equal(sl.expected, true, "it is a condition, not a crash to report"); + + assert.equal((await b.hunksList("img.png")).length, 0, "no hunks are offered either"); + const hs = await b.hunksStage({ path: "img.png", index: 0 }); + assert.equal(hs.ok, false, "and staging one is refused"); + + assert.equal(git("diff", "--cached", "--name-only").trim(), "", "the index is untouched"); + } finally { + removeTempRepo(root); + } +}); + +/** Latin-1 with no NUL byte looks like ordinary text — and is destroyed too. */ +test("a non-UTF-8 text file is refused, not silently rewritten", async () => { + const { root, git } = repo("latin1"); + try { + const latin1 = Buffer.from("line1\ncaf\xe9\nline3\n", "latin1"); + writeFileSync(`${root}/notes.txt`, latin1); + git("add", "-A"); + git("commit", "-qm", "base"); + writeFileSync(`${root}/notes.txt`, Buffer.concat([latin1, Buffer.from("line4\n")])); + + const repos = new RepoStore([]); + await repos.open(root); + const r = await new GitBridge(repos).stageLines({ path: "notes.txt", lines: [4] }); + assert.equal(r.ok, false, "refused"); + assert.equal( + Buffer.compare(readFileSync(`${root}/notes.txt`).subarray(0, latin1.length), latin1), + 0, + "and the bytes on disk are untouched", + ); + } finally { + removeTempRepo(root); + } +}); + +/** An ordinary large text file must STILL stage — the guard is about bytes, not size. */ +test("a big text file still stages line by line", async () => { + const { root, git } = repo("big"); + try { + const big = Array.from({ length: 25_000 }, (_, i) => `L${i + 1}`).join("\n") + "\n"; + writeFileSync(`${root}/big.txt`, big); + git("add", "-A"); + git("commit", "-qm", "base"); + writeFileSync(`${root}/big.txt`, big.replace("L11\n", "L11-EDIT\n")); + + const repos = new RepoStore([]); + await repos.open(root); + const r = await new GitBridge(repos).stageLines({ path: "big.txt", lines: [11] }); + assert.equal(r.ok, true, `it stages (${r.message ?? ""})`); + assert.ok(git("show", ":big.txt").includes("L11-EDIT"), "and the line is in the index"); + } finally { + removeTempRepo(root); + } +}); + +/** + * `readFile` FOLLOWS a symlink, so the diff showed the pointed-at file's + * contents and staging wrote those contents in as the link's new target — + * mode 120000, a permanently dangling link, committed and cloned that way. + */ +test("a symlink diffs as its target, and cannot be staged in parts", async () => { + const { root, git } = repo("link"); + try { + writeFileSync(`${root}/target.txt`, "T\n"); + writeFileSync(`${root}/other.txt`, "OTHER-1\nOTHER-2\n"); + symlinkSync("target.txt", `${root}/link`); + git("add", "-A"); + git("commit", "-qm", "base"); + unlinkSync(`${root}/link`); + symlinkSync("other.txt", `${root}/link`); + + const repos = new RepoStore([]); + await repos.open(root); + const b = new GitBridge(repos); + + const d = await b.fileDiff({ path: "link" }); + assert.equal(d?.leftText, "target.txt", "the left pane is the OLD link target"); + assert.equal(d?.rightText, "other.txt", "the right pane is the NEW one"); + assert.ok( + !(d?.rightText ?? "").includes("OTHER-1"), + "not the contents of the file it points at", + ); + + const sl = await b.stageLines({ path: "link", lines: [1] }); + assert.equal(sl.ok, false, "and partial staging is refused"); + assert.match(sl.message ?? "", /symbolic link/i, "saying why"); + } finally { + removeTempRepo(root); + } +}); + +/** + * A staged RENAME means HEAD has only the OLD name, so `git show HEAD:<new>` + * failed and the HEAD side read as "". The file then looked like one giant + * insertion, and unstaging a single line rolled the WHOLE thing back to + * nothing: the empty blob in the index, the rename collapsed into add+delete, + * and a 0-byte file committed — ok:true at every step. + */ +test("unstaging one line of a staged rename keeps the file and the rename", async () => { + const { root, git } = repo("rename"); + try { + const lines = Array.from({ length: 12 }, (_, i) => `L${i + 1}`).join("\n") + "\n"; + writeFileSync(`${root}/util.ts`, lines); + git("add", "-A"); + git("commit", "-qm", "base"); + git("mv", "util.ts", "helpers.ts"); + writeFileSync(`${root}/helpers.ts`, lines.replace("L10", "L10-FIXED")); + git("add", "-A"); + assert.match(git("status", "--porcelain=v1"), /^R {2}util\.ts -> helpers\.ts/m, "a staged rename"); + + const repos = new RepoStore([]); + await repos.open(root); + const b = new GitBridge(repos); + + const d = await b.fileDiff({ path: "helpers.ts" }); + assert.ok( + (d?.leftText ?? "").includes("L1"), + "the left pane reads HEAD under the name HEAD knows — not empty", + ); + + const un = await b.stageLines({ path: "helpers.ts", lines: [10], reverse: true }); + assert.equal(un.ok, true, `unstaging that line succeeds (${un.message ?? ""})`); + + const idx = git("show", ":helpers.ts"); + assert.ok(idx.includes("L1"), "the file is still in the index, with its content"); + assert.ok(!idx.includes("L10-FIXED"), "and only the selected line was rolled back"); + assert.match( + git("status", "--porcelain=v1"), + /util\.ts -> helpers\.ts/, + "the rename survives — it did not collapse into an add plus a delete", + ); + } finally { + removeTempRepo(root); + } +}); diff --git a/apps/desktop/test/stylesheet.test.ts b/apps/desktop/test/stylesheet.test.ts new file mode 100644 index 0000000..7d80376 --- /dev/null +++ b/apps/desktop/test/stylesheet.test.ts @@ -0,0 +1,179 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Structural guards on app.css. Both failures below have actually happened in +// this repo, and both are SILENT: the stylesheet still parses, it just stops +// meaning what it says. + +const CSS = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), "../src/renderer/styles/app.css"), + "utf8", +); + +/** Strip comments the way a CSS parser does: the FIRST closing pair wins. */ +function stripComments(css: string): { code: string; comments: string[] } { + const comments: string[] = []; + let out = ""; + for (let i = 0; i < css.length; i++) { + if (css[i] === "/" && css[i + 1] === "*") { + const end = css.indexOf("*/", i + 2); + if (end === -1) { + comments.push(css.slice(i)); + break; + } + comments.push(css.slice(i, end + 2)); + // Preserve newlines so line numbers stay meaningful. + out += css.slice(i, end + 2).replace(/[^\n]/g, " "); + i = end + 1; + continue; + } + out += css[i]; + } + return { code: out, comments }; +} + +test("no comment closes early on a star-slash inside a selector glob", () => { + // A comment containing a glob like ".gh-checks-<star>/" terminates AT the + // glob rather than at its intended end, and CSS error + // recovery then eats the next live declaration. This silently killed --sp-1, + // .sec-list's padding and .gh-head-tools' margin app-wide. + const { comments } = stripComments(CSS); + const offenders: string[] = []; + let idx = 0; + for (const c of comments) { + idx = CSS.indexOf(c, idx); + const after = CSS[idx + c.length]; + // A comment that ends immediately before a letter, dot or dash is the + // signature of a selector glob having closed it by accident. + if (after && /[A-Za-z.\-]/.test(after)) { + const line = CSS.slice(0, idx).split("\n").length; + offenders.push(`line ${line}: …${CSS.slice(idx + c.length - 24, idx + c.length + 16)}…`); + } + idx += c.length; + } + assert.deepEqual(offenders, [], `comment(s) closed into a selector:\n${offenders.join("\n")}`); +}); + +test("braces balance — an unclosed rule swallows every rule after it", () => { + const { code } = stripComments(CSS); + let depth = 0; + let strayLine = 0; + let line = 1; + for (let i = 0; i < code.length; i++) { + const ch = code[i]; + if (ch === "\n") line++; + else if (ch === "{") depth++; + else if (ch === "}") { + depth--; + if (depth < 0 && !strayLine) strayLine = line; + } + } + assert.equal(strayLine, 0, `stray closing brace at line ${strayLine}`); + assert.equal(depth, 0, `${depth} unclosed rule block(s) — everything after the first is dead`); +}); + +test("no selector is left without a declaration block", () => { + // `.foo {` immediately followed by another selector line means the block's + // body was lost in an edit — which is how .dc-createpr came to swallow every + // rule after it. At-rules (@media, @supports, @keyframes) legitimately + // contain selectors, so only rule blocks are checked. + const { code } = stripComments(CSS); + const lines = code.split("\n"); + const bad: number[] = []; + for (let i = 0; i < lines.length - 1; i++) { + const open = lines[i].trim(); + if (!open.endsWith("{") || open.startsWith("@")) continue; + const next = lines[i + 1].trim(); + // A declaration has a colon before any brace; a selector does not. + const looksLikeSelector = /^[.#][\w-]/.test(next) && !next.includes(":") && !next.includes("}"); + if (looksLikeSelector) bad.push(i + 2); + } + assert.deepEqual(bad, [], `selector directly inside a rule block at line(s) ${bad.join(", ")}`); +}); + +// The guards must FAIL on the real defects — a guard that cannot fail is a +// guard that proves nothing. These run against synthetic stylesheets. + +function offenders(css: string): { earlyClose: number; depth: number } { + const { comments } = stripComments(css); + let idx = 0; + let earlyClose = 0; + for (const c of comments) { + idx = css.indexOf(c, idx); + const after = css[idx + c.length]; + if (after && /[A-Za-z.\-]/.test(after)) earlyClose++; + idx += c.length; + } + let depth = 0; + for (const ch of stripComments(css).code) { + if (ch === "{") depth++; + else if (ch === "}") depth--; + } + return { earlyClose, depth }; +} + +test("the early-close guard catches the real defect", () => { + const glob = "*" + "/"; + const broken = `:root {\n /* scale — the sec-${glob}det- pages use it. */\n --sp-1: 4px;\n}\n`; + assert.equal(offenders(broken).earlyClose, 1, "should flag the glob-terminated comment"); + const fine = `:root {\n /* scale — the sec- and det- pages use it. */\n --sp-1: 4px;\n}\n`; + assert.equal(offenders(fine).earlyClose, 0, "should not flag a clean comment"); +}); + +test("the brace guard catches an unclosed rule", () => { + assert.equal(offenders(".a {\n color: red;\n}\n").depth, 0); + assert.equal(offenders(".a {\n.b { color: red; }\n").depth, 1, "should report one unclosed block"); +}); + +/** + * A comment must never sit BETWEEN a selector and its block, or between two + * selectors in a list. + * + * CSS ignores comments, so this: + * + * .cmp-seg-btn.active /* why the badge is accented *\/ + * .cmp-seg-count { background: accent; } + * + * does not mean "here is why .cmp-seg-btn.active .cmp-seg-count is accented". + * It parses as ONE descendant selector, `.cmp-seg-btn.active .cmp-seg-count`, + * and whatever the author meant the qualifier to do is silently gone. Measured + * on the shipping build: every Compare badge took the accent, so the active + * segment was indistinguishable from the inactive one. + * + * This is the FOURTH time a comment beside a selector has eaten a declaration + * in this file, each time presenting as "looks broken, source looks fine". The + * fix for a defect that recurs is to make its shape impossible. + * + * The rule: a comment ends a line, or it starts one. It never sits in the + * middle of a selector. + */ +test("no comment splits a selector from its block", () => { + const lines = CSS.split("\n"); + const bad: string[] = []; + lines.forEach((line, i) => { + const start = line.indexOf("/*"); + if (start < 0) return; + const before = line.slice(0, start).trim(); + // Nothing before the comment: it is a leading comment, which is fine. + if (!before) return; + // A complete declaration or a closed block before it is fine too — + // `color: red; /* why */` and `} /* end of section */`. + if (/[;{}]$/.test(before)) return; + // What is left is a SELECTOR fragment with a comment after it. Legal only + // when the comment closes and the block opens on this same line. + const after = line.slice(start); + if (/\*\/\s*\{/.test(after)) return; + bad.push(`app.css:${i + 1} ${line.trim().slice(0, 96)}`); + }); + assert.deepEqual( + bad, + [], + "a comment between a selector and its block is invisible to CSS — the selector joins the " + + "next one as a DESCENDANT and the qualifier is silently lost. Put the comment on its own " + + "line above the rule:\n" + + bad.join("\n"), + ); +}); diff --git a/apps/desktop/test/textFit.test.ts b/apps/desktop/test/textFit.test.ts new file mode 100644 index 0000000..0059d77 --- /dev/null +++ b/apps/desktop/test/textFit.test.ts @@ -0,0 +1,31 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +/** + * `"a\nb\n".split("\n")` is `["a", "b", ""]`, and every code viewer in the app + * numbered that trailing empty string as a real line — so a 5-line file showed + * six numbers and a line reference was off by one against the editor the reader + * would go on to open. A POSIX text file ends in a newline: this was every file, + * not an edge case. + */ +test("fileLines counts the lines a reader would count", async () => { + const { fileLines } = await import("../src/renderer/textFit.ts"); + assert.deepEqual(fileLines("a\nb\n"), ["a", "b"], "the trailing newline ends the last line"); + assert.deepEqual(fileLines("a\nb"), ["a", "b"], "a file with no trailing newline is unchanged"); + assert.deepEqual(fileLines("a\n\n"), ["a", ""], "a genuinely blank final line survives"); + assert.deepEqual(fileLines(""), [""], "an empty file is one empty line, not zero"); + assert.deepEqual(fileLines("\n"), [""], "a file that is only a newline is one empty line"); + assert.deepEqual(fileLines("only"), ["only"], "a single line with no newline"); + assert.deepEqual(fileLines("a\nb\n\n\n"), ["a", "b", "", ""], "only ONE trailing empty is dropped"); +}); + +/** The other half of the same helper file: the plural nobody should ship. */ +test("plural never renders the placeholder", async () => { + const { plural } = await import("../src/renderer/textFit.ts"); + assert.equal(plural(1, "commit"), "1 commit"); + assert.equal(plural(0, "commit"), "0 commits"); + assert.equal(plural(2, "commit"), "2 commits"); + assert.equal(plural(1200, "download"), `${(1200).toLocaleString()} downloads`, "grouped"); + assert.equal(plural(1, "entry", "entries"), "1 entry"); + assert.equal(plural(3, "entry", "entries"), "3 entries"); +}); diff --git a/apps/desktop/test/tmp-audit.ts b/apps/desktop/test/tmp-audit.ts new file mode 100644 index 0000000..8284213 --- /dev/null +++ b/apps/desktop/test/tmp-audit.ts @@ -0,0 +1,64 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { GitContext } from "@gitstudio/git-service/index"; +import { GitBridge } from "../src/main/gitBridge"; +import type { RepoStore } from "../src/main/repoStore"; + +async function main() { + const repo = mkdtempSync(join(tmpdir(), "aud-repo-")); + const cfgDir = mkdtempSync(join(tmpdir(), "aud-cfg-")); + const cfg = join(cfgDir, "gitconfig"); + writeFileSync(cfg, ""); + process.env.GIT_CONFIG_GLOBAL = cfg; + execFileSync("git", ["-c", "init.defaultBranch=main", "init", repo]); + const ctx = new GitContext({ root: repo }); + const bridge = new GitBridge({ getContext: () => ctx } as unknown as RepoStore); + + console.log("1) save name+email:", JSON.stringify( + await bridge.setGitIdentity({ name: "Old Name", email: "old@work-corp.com" }))); + console.log(" gitconfig:", JSON.stringify(readFileSync(cfg, "utf8"))); + + console.log("\n2) user CLEARS email, presses Save:"); + const r2 = await bridge.setGitIdentity({ name: "Old Name", email: "" }); + console.log(" returned:", JSON.stringify(r2), "-> renderer toasts:", + r2.ok ? '"Git identity updated." (success)' : "error"); + console.log(" gitconfig:", JSON.stringify(readFileSync(cfg, "utf8"))); + console.log(" reopened Settings reads:", JSON.stringify(await bridge.gitIdentity())); + + // Does a real commit still carry the old address? + writeFileSync(join(repo, "a.txt"), "x"); + execFileSync("git", ["add", "a.txt"], { cwd: repo }); + execFileSync("git", ["commit", "-m", "after clearing email"], { cwd: repo }); + console.log(" commit author:", execFileSync("git", + ["log", "-1", "--format=%an <%ae>"], { cwd: repo, encoding: "utf8" }).trim()); + + console.log("\n3) same for a cleared NAME:"); + const r3 = await bridge.setGitIdentity({ name: "", email: "old@work-corp.com" }); + console.log(" returned:", JSON.stringify(r3)); + console.log(" gitconfig:", JSON.stringify(readFileSync(cfg, "utf8"))); + + // --- Now evaluate the PROPOSED FIX: --unset --- + console.log("\n4) proposed fix probe: what does `--unset` do when key is absent?"); + const un1 = await ctx.process.run(["config", "--global", "--unset", "user.nosuchkey"]); + console.log(" unset absent key -> code:", un1.code, "stderr:", JSON.stringify(un1.stderr.trim())); + + console.log("\n5) proposed fix probe: identity fully unset -> can the user still commit?"); + await ctx.process.run(["config", "--global", "--unset", "user.email"]); + await ctx.process.run(["config", "--global", "--unset", "user.name"]); + console.log(" gitconfig:", JSON.stringify(readFileSync(cfg, "utf8"))); + writeFileSync(join(repo, "b.txt"), "y"); + execFileSync("git", ["add", "b.txt"], { cwd: repo }); + try { + execFileSync("git", ["commit", "-m", "after unset"], + { cwd: repo, encoding: "utf8", env: { ...process.env, EMAIL: undefined as never } }); + console.log(" commit SUCCEEDED, author:", execFileSync("git", + ["log", "-1", "--format=%an <%ae>"], { cwd: repo, encoding: "utf8" }).trim()); + } catch (e: unknown) { + const err = e as { stderr?: string; status?: number }; + console.log(" commit FAILED code", err.status, ":", String(err.stderr).trim().split("\n").slice(0,3).join(" | ")); + } + ctx?.dispose?.(); +} +void main(); diff --git a/apps/desktop/test/tmpRepo.ts b/apps/desktop/test/tmpRepo.ts index ece2add..678e2f3 100644 --- a/apps/desktop/test/tmpRepo.ts +++ b/apps/desktop/test/tmpRepo.ts @@ -10,6 +10,14 @@ import { rmSync } from "node:fs"; * accumulator self-consistent" — with a cleanup error rather than an assertion, * about once in ten runs. A temp directory under the OS temp dir that survives * a few seconds longer costs nothing; the OS reclaims it. + * + * git is not the only writer. On this machine something outside this repo drops + * `.git/ai/working_logs/` into every repository shortly after it is created, so + * a `.git` that was empty when the test finished is not empty a beat later. + * That is why the tests also set `gc.auto 0` and why this swallows rather than + * merely retries: any test creating a repo must reach for THIS, not its own + * `rmSync`, or the suite goes red about one run in three in a different test + * each time — which teaches you to ignore it. */ export function removeTempRepo(dir: string | undefined): void { if (!dir) return; diff --git a/apps/desktop/test/truncate.test.ts b/apps/desktop/test/truncate.test.ts new file mode 100644 index 0000000..5508468 --- /dev/null +++ b/apps/desktop/test/truncate.test.ts @@ -0,0 +1,33 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { middleTruncate } from "../src/renderer/textFit"; + +// CSS ellipsis cuts from the right, which on a filesystem path removes the only +// part that identifies it: two clones of the same repo under different parents +// both render as ".../Developer/GitStu…". Paths are shortened from the middle. + +test("a short path is returned untouched", () => { + assert.equal(middleTruncate("/Users/anton/code", 44), "/Users/anton/code"); +}); + +test("a long path keeps BOTH ends", () => { + const p = "/Users/anton/Developer/GitStudioHQ/gitstudio/apps/desktop"; + const out = middleTruncate(p, 30); + assert.ok(out.length <= 30, `got ${out.length}: ${out}`); + assert.ok(out.startsWith("/Users"), out); + assert.ok(out.endsWith("desktop"), out); + assert.ok(out.includes("…"), out); +}); + +test("two clones that differ only at the end stay distinguishable", () => { + const a = middleTruncate("/Users/anton/Developer/GitStudioHQ/gitstudio", 32); + const b = middleTruncate("/Users/anton/Developer/GitStudioHQ/gistudio.dev", 32); + assert.notEqual(a, b, "right-truncation collapsed these into the same string"); +}); + +test("the result never exceeds the limit", () => { + for (const n of [12, 20, 33, 64]) { + const out = middleTruncate("/a/very/long/path/that/keeps/going/and/going/forever", n); + assert.ok(out.length <= n, `max ${n}, got ${out.length}`); + } +}); diff --git a/apps/extension/src/ai/aiCommands.ts b/apps/extension/src/ai/aiCommands.ts index 4b76db4..b79bd56 100644 --- a/apps/extension/src/ai/aiCommands.ts +++ b/apps/extension/src/ai/aiCommands.ts @@ -928,8 +928,17 @@ function resultHtml(nonce: string, title: string, subtitle: string): string { copyText(text).then(function (ok) { flash(b, ok, kind); }); }); - function esc(s) { return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); } - function safeUrl(u) { return /^(https?:|mailto:)/i.test(u) ? u : "#"; } + // Escapes the QUOTE too, like every other esc() in this extension. Without + // it a markdown link URL walks straight out of the href it is written + // into: [t](https://a"onmouseover="alert(1)) rendered as + // <a href="https://a"onmouseover="alert(1" ...> + // i.e. a live event handler on the anchor. The webview CSP has no + // 'unsafe-inline' so it would not have run, but the markup should never + // have been built in the first place. + function esc(s) { return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'"); } + // A prefix check says what a URL STARTS with, not what it contains — a + // quote anywhere in it ends the attribute early, so reject those outright. + function safeUrl(u) { return /^(https?:|mailto:)/i.test(u) && !/["'<>]/.test(u) ? u : "#"; } function inline(s) { var codes = []; diff --git a/apps/extension/src/graph/graphPanel.ts b/apps/extension/src/graph/graphPanel.ts index 9a0c505..b7e37ef 100644 --- a/apps/extension/src/graph/graphPanel.ts +++ b/apps/extension/src/graph/graphPanel.ts @@ -605,7 +605,7 @@ export class CommitGraphPanel { runRebasePlan(active.root, { base: chain.base ?? "--root", todo: built.todo, - rewordMessages: built.rewordMessages, + rewords: built.rewords, }); const outcome = ledger ? await ledger.runWithUndo(active, `Reorder ${order.length} commits`, run) diff --git a/apps/extension/src/rebase/rebaseWorkspacePanel.ts b/apps/extension/src/rebase/rebaseWorkspacePanel.ts index 4296e3c..65b4145 100644 --- a/apps/extension/src/rebase/rebaseWorkspacePanel.ts +++ b/apps/extension/src/rebase/rebaseWorkspacePanel.ts @@ -65,8 +65,17 @@ export class RebaseWorkspacePanel { } const commits = await loadCommits(active, base); if (commits.length === 0) { + // Empty for three different reasons, and "no commits from that point" is + // only true for one of them. The selection deliberately omits merges and + // commits already applied upstream, so a branch that is entirely merged, + // or a range made only of merges, empties the plan while plainly having + // commits in it — and the old sentence sent people looking for a nearer + // base, which finds fewer, not more. + const total = await countInRange(active, base); void vscode.window.showInformationMessage( - "GitStudio: no commits to rebase from that point.", + total > 0 + ? `GitStudio: nothing to rebase — all ${total} commit${total === 1 ? "" : "s"} here are either merges or changes already on the base, which a rebase would skip.` + : "GitStudio: no commits to rebase from that point.", ); return; } @@ -169,11 +178,11 @@ export class RebaseWorkspacePanel { this.post({ type: "result", outcome: { status: "failed", message: built.message } }); return; } - const { todo, rewordMessages } = built; + const { todo, rewords } = built; await this.finish(() => this.undo.runWithUndo(active, `Interactive rebase onto ${shortRef(this.base)}`, () => - runRebasePlan(active.root, { base: this.base, todo, rewordMessages }), + runRebasePlan(active.root, { base: this.base, todo, rewords }), ), ); } @@ -315,10 +324,32 @@ async function resolveBase(active: RepoEntry, sha?: string): Promise<string | un } async function loadCommits(active: RepoEntry, base: string): Promise<RebaseCommit[]> { - const range = base === "--root" ? "HEAD" : `${base}..HEAD`; + // The same selection git's own sequencer uses. The desktop learned each of + // these the hard way, and this panel was still building the plans they exist + // to prevent — the todo IS the plan, so anything wrong here is executed. + // + // · THREE dots + `--cherry-pick --right-only`: drops commits whose patch is + // already on the base (a backport, a cherry-pick that went both ways, a + // commit merged upstream by someone else). `base..HEAD` keeps them, git's + // todo does not, and running the plan made git skip one and PAUSE — + // "warning: skipped previously applied commit" — leaving the repo + // mid-rebase with a clean tree and nothing to resolve. + // · `--no-merges`: a rebase FLATTENS merges, and `git rebase -i` refuses + // `pick <merge>` outright. git checks out the base BEFORE parsing the + // todo, so the repo was left detached at the base, mid-rebase, with no + // conflict to resolve and only Abort as a way out. A feature branch with + // main merged into it is the ordinary shape of this. + // · `--topo-order`: reversed, this reproduces git's own todo; the default + // date ordering does not, so the plan promised one replay order and git + // performed another. + const threeDot = base !== "--root"; + const range = threeDot ? `${base}...HEAD` : "HEAD"; const sep = "\x1f"; const r = await active.ctx.process.run([ "log", + ...(threeDot ? ["--cherry-pick", "--right-only"] : []), + "--no-merges", + "--topo-order", // NEWEST FIRST, matching the Commits list (issue #18). git's todo file is the // other way round; buildRebasePlan does that reversal in exactly one place. `--format=%H${sep}%h${sep}%an${sep}%at${sep}%s`, @@ -342,6 +373,15 @@ async function loadCommits(active: RepoEntry, base: string): Promise<RebaseCommi return out; } +/** Every commit in the range, merges and already-applied ones included — the + * number the user can see in the Commits list, so an empty plan can say what + * happened to them. */ +async function countInRange(active: RepoEntry, base: string): Promise<number> { + const range = base === "--root" ? "HEAD" : `${base}..HEAD`; + const r = await active.ctx.process.run(["rev-list", "--count", range]); + return r.code === 0 ? Number(r.stdout.trim()) || 0 : 0; +} + async function currentBranch(active: RepoEntry): Promise<string> { const r = await active.ctx.process.run(["rev-parse", "--abbrev-ref", "HEAD"]); const b = r.stdout.trim(); diff --git a/apps/extension/test/htmlEscapers.test.ts b/apps/extension/test/htmlEscapers.test.ts new file mode 100644 index 0000000..aedeecb --- /dev/null +++ b/apps/extension/test/htmlEscapers.test.ts @@ -0,0 +1,106 @@ +// Every panel in this extension builds HTML by concatenating strings, and each +// one carries its own little `esc()` because most of them live inside a webview +// script that cannot import anything. Five copies of the same three-line +// function is a shape where one copy quietly falls out of step — and one had: +// +// apps/extension/src/ai/aiCommands.ts escaped & < > but not the QUOTE, so a +// markdown link URL walked straight out of the href it was written into: +// [t](https://a"onmouseover="alert(1)) +// became <a href="https://a"onmouseover="alert(1" …> — a live handler on +// the anchor. The webview CSP (script-src with a nonce, no 'unsafe-inline') +// meant it would not have run, but the markup should never have been built. +// +// An escaper that misses `"` is only safe if its output never lands in an +// attribute, and that is not a property anyone can keep true by hand across +// five files. So it is checked here instead. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join, relative } from "node:path"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "src"); + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const p = join(dir, name); + if (statSync(p).isDirectory()) walk(p, out); + else if (p.endsWith(".ts")) out.push(p); + } + return out; +} + +/** The body of every `function esc(...)`, wherever it is declared. */ +function escapers(): Array<{ file: string; body: string; line: number }> { + const found: Array<{ file: string; body: string; line: number }> = []; + for (const file of walk(ROOT)) { + const src = readFileSync(file, "utf8"); + const re = /function\s+esc\s*\(/g; + let m: RegExpExecArray | null; + while ((m = re.exec(src))) { + const brace = src.indexOf("{", m.index + m[0].length); + if (brace < 0) continue; + let depth = 1; + let i = brace + 1; + while (i < src.length && depth > 0) { + if (src[i] === "{") depth++; + else if (src[i] === "}") depth--; + i++; + } + found.push({ + file: relative(ROOT, file), + body: src.slice(brace, i), + line: src.slice(0, m.index).split("\n").length, + }); + } + } + return found; +} + +test("the extension has the html escapers we think it does", () => { + const all = escapers(); + assert.ok( + all.length >= 4, + `expected several esc() definitions, found ${all.length} — has the declaration style changed?`, + ); +}); + +test("every esc() escapes the quote, not just the angle brackets", () => { + const failures = escapers() + .filter((e) => !/"|�*34;/.test(e.body)) + .map((e) => `${e.file}:${e.line}`); + assert.deepEqual( + failures, + [], + `these escapers leave " intact, so anything they escape is unsafe in an ` + + `attribute: ${failures.join(", ")}`, + ); +}); + +test("every esc() escapes the ampersand first", () => { + // `&` has to go first or the escaper double-encodes its own output: + // "<" -> "<" -> "&lt;" if & is replaced after <. + for (const e of escapers()) { + const amp = e.body.search(/&/); + const lt = e.body.search(/</); + if (amp < 0 || lt < 0) continue; + assert.ok( + amp < lt, + `${e.file}:${e.line} replaces & after < — its own output gets re-escaped`, + ); + } +}); + +test("every esc() covers the four characters that matter", () => { + for (const e of escapers()) { + for (const [what, re] of [ + ["&", /&/], + ["<", /</], + [">", />/], + ['"', /"|�*34;/], + ] as Array<[string, RegExp]>) { + assert.match(e.body, re, `${e.file}:${e.line} does not escape ${what}`); + } + } +}); diff --git a/apps/extension/test/rebaseTodoSelection.test.ts b/apps/extension/test/rebaseTodoSelection.test.ts new file mode 100644 index 0000000..7ea78a2 --- /dev/null +++ b/apps/extension/test/rebaseTodoSelection.test.ts @@ -0,0 +1,92 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +/** + * Every surface that builds an interactive-rebase plan must select its commits + * the way git's own sequencer does. In an interactive rebase the todo IS the + * plan, so a wrong selection is not a display bug — it is executed. + * + * Three flags, each of which cost a real wedge before it was found: + * + * · `--no-merges` — a rebase FLATTENS merges, and `git rebase -i` refuses + * `pick <merge>` outright ("error: 'pick' does not accept merge commits"). + * git checks out the base BEFORE parsing the todo, so the repo was left + * detached at the base, mid-rebase, with a clean tree and no conflict to + * resolve. Continue re-ran the same failing todo; only Abort escaped. A + * feature branch with main merged into it is the ordinary shape of this. + * + * · `--cherry-pick --right-only` over a THREE-dot range — drops commits whose + * patch is already on the base. `base..HEAD` keeps them, git's todo does + * not, so the plan listed a commit git would skip; running it paused with + * "warning: skipped previously applied commit" on a clean tree. + * + * · `--topo-order` — reversed, this reproduces git's own todo. The default + * ordering does not, so the plan promised one replay order and git performed + * another. Measured on a range whose lines interleave by date. + * + * The desktop learned all three; the extension's own panel was still using a + * bare `base..HEAD` months later, because nothing tied the two together. This + * test is that tie: a census, not an engine test, because the engine + * (`buildRebasePlan`) was correct the whole time and the callers were not. + */ +const PANEL = fileURLToPath(new URL("../src/rebase/rebaseWorkspacePanel.ts", import.meta.url)); +const DESKTOP = fileURLToPath(new URL("../../desktop/src/main/rebaseBridge.ts", import.meta.url)); + +/** The `ctx.process.run([...])` argument list of a `git log` that feeds a plan. */ +function logInvocations(src: string): string[] { + const out: string[] = []; + const re = /process\.run\(\[\s*"log",/g; + let m: RegExpExecArray | null; + while ((m = re.exec(src))) { + // Walk to the matching close bracket so a multi-line argument list — which + // all of these are — is captured whole. + let i = m.index + m[0].length; + let depth = 1; + while (i < src.length && depth > 0) { + if (src[i] === "[") depth++; + else if (src[i] === "]") depth--; + i++; + } + out.push(src.slice(m.index, i)); + } + return out; +} + +/** A `git log` that builds a rebase todo names a sha and a subject. */ +const BUILDS_A_TODO = /%H/; + +for (const [name, file] of [ + ["the extension's rebase workspace", PANEL], + ["the desktop's rebase bridge", DESKTOP], +] as const) { + test(`${name} selects its commits the way git's sequencer does`, async () => { + const src = await readFile(file, "utf8"); + const calls = logInvocations(src).filter((c) => BUILDS_A_TODO.test(c)); + assert.ok(calls.length > 0, `expected to find a plan-building git log in ${file}`); + + for (const call of calls) { + const head = call.replace(/\s+/g, " ").slice(0, 90); + assert.match(call, /"--no-merges"/, `${head}: must exclude merges — git rebase -i refuses to pick one`); + assert.match(call, /"--topo-order"/, `${head}: must use git's own todo ordering`); + assert.match( + call, + /"--cherry-pick",\s*"--right-only"/, + `${head}: must drop commits already applied to the base, as git's todo does`, + ); + // The range reaches the call as a variable in both files, so the + // three-dot form is asserted on its definition instead. + assert.match(call, /\brange\b/, `${head}: passes a range`); + } + // …and that range is three-dot when it is not `--root`. `--cherry-pick` + // compares the two SIDES of a symmetric difference: given `base..HEAD` it + // has nothing to compare against and silently drops nothing at all, so the + // flag reads as present while doing exactly what its absence did. + assert.match( + src, + /threeDot \? `\$\{base\}\.\.\.HEAD` : "HEAD"/, + "the plan's range must be three-dot for --cherry-pick to mean anything", + ); + }); +} diff --git a/docs/desktop-redesign.md b/docs/desktop-redesign.md new file mode 100644 index 0000000..726bcaa --- /dev/null +++ b/docs/desktop-redesign.md @@ -0,0 +1,362 @@ +# Desktop redesign — the section-page system + +The one spec every GitHub section view converts to. Written 2026-08-24; Issues +and Pull Requests are the reference implementations. When a view diverges from +this document, the view is wrong or this document gets updated — never a silent +third way. + +## Why + +The old sections were master/detail splits (`ghTwoPane`): a ~430px list pane +that truncated every title beside a detail pane that got the leftovers. Both +scrolled independently; neither had room. Actions rendered as an equal-weight +strip of mini-buttons; metadata (labels, assignees, milestone) was scattered +above the timeline. And too many features handed the user to github.com instead +of finishing the job in-app — even where the IPC layer already supported the +action. + +The replacement is the Linear model translated to git/GitHub: + +- **Lists are full-width pages.** Rich single-line rows, real information + density, keyboard-first. +- **Opening an item navigates to a full detail page** in the same view host — + no split, no overlay. `←` / `Esc` / `⌘[` go back. +- **Properties live in a right rail** on the detail page, each one inline- + editable in place. +- **In-app actions are primary; github.com is an escape hatch** (a single + link icon in the detail toolbar), never the way a feature works. +- **Peeks stay** for cross-entity glances (author profile, commit, branch), and + the drill-in stack is unchanged. + +## Navigation contract + +A detail page is a **routed state**, not view-internal state: + +- Open: `nav("issues", { number: 31 })` — routeView records it in the app + history, forces a section rebuild, and the section renders the detail page. +- Back to the list: `nav("issues", { list: true })` — `list` is the explicit + "section root" target that defeats routeView's same-view no-op. +- `⌘[` / `⌘]` and the mouse side buttons therefore walk list ⇄ detail ⇄ other + sections exactly like a browser. A deep link from anywhere (project board, + notification, prose `#123` reference, palette) is the same call. +- `Esc` on a detail page = back to the list — wired by `detailPage()`, and it + stands down while a peek, palette, modal, or text field owns the key. + +Sections keep their loaded data in the SWR cache (`cache.ts` `peek`/`gget`), +so back-to-list repaints instantly from cache and revalidates in the +background. Mutations `bust("<channel prefix>")` then re-render. + +## The list page + +```text +┌ sec-head ──────────────────────────────────────────────────────────────┐ +│ Title [count] [search] [state segment] [facets] [+ New] │ +├────────────────────────────────────────────────────────────────────────┤ +│ ○ #31 Split views make Issues unreadable [ux] [desktop] ⚑2 💬6 5h │ +│ ○ #30 Workflow logs stop streaming [bug] 💬2 9h │ +└────────────────────────────────────────────────────────────────────────┘ +``` + +- Rows are **single-line**, fixed-height (`.sec-row`, 40px): leading state + icon, muted `#number`, strong title (truncates), label chips inline (hide + under overflow), then a right-aligned meta cluster (assignee avatars, + comment count, diffstat for PRs, relative time in tabular figures). +- `↑/↓/Home/End/Enter` via `wireListNav`; `Enter`/click navigates to detail. +- Empty/loading/error states use the existing `emptyState` / `skeletonList` / + `errorState` — full-width. + +## The detail page + +```text +┌ det-topbar ────────────────────────────────────────────────────────────┐ +│ ← Issues #31 [✨] [Edit] [Close] [⋯] [GitHub ↗] │ +├──────────────────────────────────────────────┬─ det-rail ──────────────┤ +│ ● Open Split views make Issues unreadable │ STATE ● Open │ +│ mira-holt opened 5h ago · 6 comments │ AUTHOR ◉ mira-holt │ +│ ┌ timeline card (prose) ┐ │ ASSIGNEES ◉ anton + │ +│ └────────────────────────┘ │ LABELS [ux] [app] + │ +│ ┌ comment ┐ … │ MILESTONE 1.6 — redesign│ +│ [ composer ] [Comment] │ CREATED Aug 24, 18:04 │ +└──────────────────────────────────────────────┴─────────────────────────┘ +``` + +- `det-topbar`: a back button labeled with the section name, the item number, + then the action cluster. One primary action max (Close/Reopen, Merge); + destructive stays red; "Open on GitHub" is a de-emphasized icon at the end. +- `det-main`: measure-capped (~`--measure-read` + padding) content column — + title block, timeline (THE prose system), composer. Never full-bleed text. +- `det-rail`: 264px sticky property rail (`propSection` rows). Every property + is click-to-edit and reuses the section's existing menus/pickers + (`openMenu`, `peoplePickerModal`, milestone menu…). The rail collapses under + 980px into a wrap row above the timeline. +- Sub-tabbed details (PRs: Conversation / Commits / Checks / Files) put the + tab strip at the top of `det-main`; the Files tab widens to the full column + (the rail hides) because diffs deserve the space. + +## Rail IA + +Local: Code · Changes · Commits · Branches · Rebase · Compare +GitHub: **Inbox** · **My Work** · Pull Requests · Issues · Actions · Releases · Projects +Account: Organizations · Gists + +My Work is the workday-first page: review requests, items assigned to you, +your own PRs, and mentions — four `@me` searches, deduped, grouped by what +each item needs from you. + +Inbox is the notifications view promoted to a rail item (the bell popover +stays for a quick glance). Orgs and Gists are account-scoped, not repo-scoped +— they get their own divider group. + +## CSS + +- New classes are `sec-*` (list page) and `det-*` (detail page), defined in + ONE consolidated block in `app.css` ("SECTION PAGES"). No per-view style + injection from TS (the old `ensureIssuesStyles` pattern is banned), no + inline `style.*` layout from views. +- Spacing uses the `--sp-*` scale (4/8/12/16/20/24/32). New work never + hardcodes paddings/gaps off-scale. +- The legacy `.gh-list`/`.gh-detail`/`ghTwoPane` split rules remain only while + unconverted views still use them; each conversion deletes what it orphans. + +## Conversion checklist (per view) + +1. List page on `sectionList()`; rows single-line; facets in the toolbar. +2. Detail page on `detailPage()` with a property rail; every mutation the IPC + layer supports is wired in-app; github.com demoted to the escape-hatch icon. +3. Deep-link target handling (`target.number` etc.) renders the detail page + directly; `{ list: true }` renders the root. +4. Data through `peek`/`gget`; mutations `bust` their prefix. +5. Screenshot list + detail in both themes with the headless harness before + calling it done. + +Status: **all nine sections done** — Issues, Pull Requests, Inbox, Actions, +Releases, Gists, Projects, Orgs (and the ghTwoPane/ghListResizer scaffolding + +its CSS are deleted). Notes per view: Actions gets a Runs | Workflows segment +(dispatch is a modal, secrets stay a modal, run detail is the routed page); +Releases gets a Releases | Tags segment (tags open a peek with Draft-release); +Gists route by `target.id` (string-keyed) and render files through the +highlighted `.ghfile` code block; Projects keeps its full-width board and adds +**drag-and-drop between Status columns** (optimistic, kebab menu = keyboard +path); Orgs keeps its picker + card grid and adds a header filter over the +active sub-tab. Every section reads through the SWR cache. + +## Depth guarantees (added by the autonomous night pass, 2026-08-25) + +- **Paged reads everywhere.** `requestPaged`/`requestPagedKey` follow the + `Link: rel="next"` chain (caps in `githubPaging.PAGE_CAPS`); a list that + arrives at its cap renders `capNotice` — never a silent truncation. +- **Live surfaces.** Actions polls its runs list (12s) and a live run's detail + (8s, signature-diffed, job-expansion preserved); a PR with pending checks + re-fetches every 15s except while the Files tab holds a Monaco diff. +- **Drafts survive.** Issue/PR comment composers keep unsent text per item + across every navigation until posted. +- **Keyboard layer.** `j/k` aliases in every list, `e` archives in Inbox, + `?` opens the cheat sheet, `Esc` walks detail → list. +- **Release assets** upload (native file picker, uploads.github.com) and + delete in-app; the PR review modal offers verdict + body in one surface. + +## Clone destination control (Phase E1, 2026-08-25) + +Where clones land is now a first-class setting, not a hardcoded path. + +- `src/main/appSettings.ts` — userData/app-settings.json store (errorReporter + template, injectable paths, node-tested). Holds `cloneDir` (default + `~/GitStudio`) and `askWhereEveryTime`. IPC: `settings:get` / + `settings:update` (null = reset) / `settings:pickCloneDir`. +- `ghrepo:open` takes `{fullName, dest?, name?}` and fails with structured + `code: collision | clone-failed | open-failed | bad-name`. The renderer + branches on codes — the old `/already exists/i` string-match is gone. +- `clone:start` pre-checks the destination (`code: dest-exists`) and + validates names via shared `src/shared/cloneName.ts` + (`deriveNameFromUrl` + `validateTargetName`, used live by both dialogs). +- `src/renderer/destinationSheet.ts` — the "Where should owner/repo go?" + sheet: destination (prefilled from settings) + folder-name override with + live validation. Opened by "Choose location…" (org repo peeks, remote + browser), by every one-click open when ask-where-every-time is on, and as + the collision-retry path (prefilled `owner-repo` suggestion). +- Clone dialog: destination prefilled from settings (Clone is one paste + away), folder-name override field, coded-failure focus. +- Settings → Repositories card: clone-dir row (Change…/Reset) + ask toggle. +- Harness: `settings:*` fixtures (`ask=1` URL param presets the toggle), + new driver steps `text:<needle>` (click by visible text) and + `type:<text>` (fill the focused input). + +## Local copies manager (Phase E2, 2026-08-26) + +"What do I actually have on this machine?" is now answerable in-app. + +- `src/main/localRepos.ts` — electron-free scanner (injected paths + clock). + `scanLocalCopies()` unions the clone folder's top-level repos with the + recents list, probes each `origin` (8 at a time, 5s timeout), dedupes by + **real** path, and flags each row managed / recent / current / missing. + `LocalRepoScanner` caches 30s; `invalidate()` after any mutation. +- macOS `/var` → `/private/var` is load-bearing: every containment judgment + goes through `realOrResolve()`, which falls back to *the parent's* realpath + so a missing clone is still judged against the same prefix. +- `src/main/githubRemote.ts` — `parseGitHubRemote` split out of githubBridge + (which imports electron) so the scanner and its node tests stay clean. + githubBridge re-exports it, so existing importers keep their seam. +- IPC: `repos:local`, `repos:reveal` (refuses paths not in the scan), + `repos:removeRecent` (resolved-path match — a symlinked recent used to + silently not match), `repos:trash`. Event `repo:recentChanged` repaints the + welcome screen and rebuilds the native Recent Repositories submenu. +- The delete rule lives in ONE pure function (`trashRefusal`) with an async + `trashRefusalResolved` wrapper that main.ts calls: refuses the clone folder + itself, the open repo, anything outside the clone folder, and anything that + isn't a git repo. Refusals are `expected: true` — never crash-reported. +- Settings → Repositories gained "On this machine": origin chip, path, + managed/recent/open/missing badges, per-row Open / Reveal / Copy path / + Forget / Delete clone… The delete uses `confirmDialog({requireTyped})` — + a new, generic typed-confirmation for irreversible actions. +- Repo switcher gained "Manage Repositories…". +- Harness: `repos:*` fixtures + a `scroll:<selector>` driver step. + +## Metadata sweep (Phase A3, 2026-08-26) + +GitHub knows who merged it, why it closed, and who was asked — the app now +says so. + +- `src/main/github/maps.ts` is finally what its header claimed: `mapPull`, + `mapIssue`, `mapComment`, `mapNotification`, `mapReactions` and `subjectRef` + all live there once. The divergent copies in githubClient.ts, github/prs.ts, + github/issues.ts and github/notifications.ts are gone. + `src/main/githubRemote.ts` keeps the electron-free seam. +- New wire fields — PR: closedAt, mergedBy, reviewComments, commits, + requestedReviewers, milestone, authorAssociation, headRepoFullName, + reactions. Issue: closedAt, closedBy, **stateReason**, authorAssociation, + reactions. IssueComment: updatedAt, authorAssociation, reactions. + NotificationThread: lastReadAt, subjectKind, subjectNumber, subjectSha. +- **`subjectRef()`** parses GitHub's subject API url (whose tail IS the number + or the sha). That single function is why Inbox rows for **Releases** and + **Commits** now open in-app — a release detail page and a graph reveal — + instead of bouncing to github.com. The context menu stopped saying "Open on + GitHub" for rows that open in-app. +- Closed-as-not-planned is its OWN state (`issueStateKind()` in ui.ts): gray + circle-slash lead, "Not planned" pill, and a rail section naming who closed + it and when. Same word, different outcome — GitHub parity. +- PR rail: pending reviewers (dashed = asked, not answered), Merged by, + Milestone, and About facts for Commits / Review comments / From fork / + Author is / Merged-or-Closed. PR rows carry a `fork` chip. +- Comments carry an "edited" marker, an association badge (only when it + means something — never a CONTRIBUTOR badge on every comment), and a + read-only reaction strip. +- Tests: `test/notificationSubject.test.ts` (13), `test/itemMaps.test.ts` (10). + +## Universal facets (Phase A4, 2026-08-26) + +Five views had five different ideas of what "filter" means. Now they share one. + +- `src/renderer/facetModel.ts` — the PURE half (DOM-free, node-tested): + `FacetSpec`, `facetPasses`, `facetServerValues`, `facetActiveCount`, + `harvestValues`. **The load-bearing rule: a spec with no `predicate` is + server-side** — it never filters locally, because doing both would hide rows + the server already excluded. +- `views/common.ts` — `facetBar()` builds the buttons/menus on top of that + (harvested / static / async-loaded options, label swatches and avatars as + leading elements, menu search past 8 options, a Clear button that only + appears while something is filtered), plus `segmented()` (extracted from the + two hand-rolled copies) and shared `swatch()`. It re-exports the pure names, + so views have one facet import site. +- Adoption: + - **Actions** — workflow (async-loaded ids) / branch / actor / event / + status, all SERVER-side: narrowing re-fetches with a different filter, + the filter object IS the cache key, and the 12s poll asks the same + question the view is showing. `capNotice(…, "server")` stops telling + people to "search to narrow" a list the server already narrowed. + - **Issues** — the three bespoke facets migrated, plus author and + state-reason (completed vs not planned). + - **PRs** — author / label / base / state (ready · draft · from a fork). + - **Inbox** — type / reason / repo. + - **My Work** — kind / type. +- `facets.sync(items)` runs on every render: the bar is built before the first + fetch lands, and a facet menu that offers nothing is worse than no facet. +- Tests: `test/facets.test.ts` (12). + +## Explore — global GitHub search (Phase E3, 2026-08-26) + +The last big "go to the browser" moment: finding a repo, a person, an org or a +line of code. Now a rail page. + +- `src/main/github/searchQuery.ts` — PURE path builders (node-tested): a wrong + search query never errors, it just returns the wrong results, so every URL + the app can ask for is pinned by tests. Also owns the 1000-result ceiling + (`beyondCeiling`, `reachableCount`) — asking past it earns a 422. +- `src/main/github/searchGuard.ts` — token buckets, 30/min core and 10/min + code, with headroom reserved. Refuses BEFORE spending and returns + `retryInMs`, so the UI can show a countdown instead of an error for a + condition that fixes itself in seconds. +- `src/main/github/search.ts` — one API request per invoke (explicit page of + 30, never an automatic Link-follow): each page is real money from a small + purse, so "Load more" is a user action. Failure modes live in the RESULT: + `limited`, `incomplete`, `hasMore`. +- `GitHubClient.request` gained an `accept` option — code search only returns + match fragments under `text-match+json`. +- `views/explore.ts` — search-first page: hero field, tab strip (Repositories · + People · Organizations · Code), repo sort, and result rows with hover + actions (Open · Choose location… · Clone… · GitHub). **Code searches only on + Enter** — never a keystroke. Explore rows are their own two-line builder + (`secRow` is a single-line `<button>`, which can't hold a description or + nested action buttons). +- Routed via `target.id` micro-paths (`q/<tab>/<query>`, `repo/<owner>/<name>`, + `user/…`, `org/…`) so ⌘[ walks the trail — no SectionTarget change. +- Rail: **Explore heads the Account group** — discovery before inventory. +- Harness: `search:*` fixtures; the `key:` driver step now dispatches at the + FOCUSED element (a real Enter goes to the input, not the window). +- Tests: `test/searchQuery.test.ts` (11), `test/searchGuard.test.ts` (5). + +## Explore entity pages (Phase E4, 2026-08-26) + +The peek browser is a glance; these are places to actually read a repository. + +- `src/renderer/exploreRoutes.ts` — PURE routing vocabulary (node-tested): + `q/<tab>/<query>`, `repo/<owner>/<name>[/(tree|blob)/<ref>/<path>]`, + `user|org/<login>`. A wrong parse doesn't throw, it strands the user on the + wrong page — hence tests. **"HEAD" is a sentinel that parses back to "the + default branch"**: without that, walking into a file silently pinned the ref + and relabelled the switcher. +- `views/exploreRepo.ts` — routed breadcrumbs (every segment navigable, so ⌘[ + walks the trail), a ref switcher, **go-to-file** (whole tree in one + `git/trees?recursive=1`, ranked by the palette's own exported `fuzzyScore`), + directory listings, README + markdown in the prose system with relative + links resolving in-page, code with the Code view's colorizer, and a metadata + rail. Top bar: ref · Go to file · GitHub · **Open in GitStudio ▾** (split + button: Choose location… / Clone…). +- `views/exploreUser.ts` — an account page for a person or an org: profile + rail (company, location, counts, links, orgs) beside their repositories, + each openable here, with a filter. +- Main: `ref?` threaded through ghrepo:tree/file/readme; new `ghrepo:branches` + (paged — a busy repo has hundreds) and `ghrepo:paths` (25k cap, and BOTH its + own and GitHub's truncation are reported: a file search that can't see a + file must say so). New `users:repos` / `users:orgs`; `GhUserInfo` gained + type/following/twitter/email. +- Orgs' "Browse" hover action now opens the full page; "Details" keeps the peek. +- Tests: `test/exploreRoutes.test.ts` (13). + +## Palette search + global wiring (Phase E5, 2026-08-26) + +⌘K stopped being a jump list and became the front door. + +- `src/renderer/searchDebounce.ts` — PURE scheduler (node-tested with fake + timers). Three rules that are each easy to get subtly wrong: debounce (don't + spend a request per keystroke), a minimum length (two characters match + everything), and **generation tokens** — a slow answer to an old query must + be dropped, not rendered over a newer one. Typing back *below* the minimum + invalidates a search already in flight. +- `PaletteProviders` gained `search?: (query) => …` (fires as you type, unlike + `remote`, which fires once at open) and `pinned` groups that skip fuzzy + filtering — a search group's items ARE the answer, so re-filtering them by + the same query would throw away GitHub's own ranking. +- Wired: a pinned "Search GitHub for …" row → Explore, plus the top 3 + repositories and top 3 people, using **Explore's exact gget cache keys** so + opening the full page after previewing costs nothing. Code search is never + called from the palette — 10/min is too small to spend on typing. +- `github:status` was being fetched three times per palette open; now once. +- Entry points into Explore: the topbar affordance ("Search anything…"), + Inbox repo links (the full repo page, not the peek stack), and every person + chip in the app — `memberCard` gained a primary "View full profile". +- Tests: `test/searchDebounce.test.ts` (8). + +--- + +**Wave 2 complete**: A1 · A2 · E1 · E2 · A3 · A4 · E3 · E4 · E5. +269 tests green; both tsconfigs clean; every surface shot in dark and light. diff --git a/packages/engine/src/lineDiff.ts b/packages/engine/src/lineDiff.ts index 920edbc..588f442 100644 --- a/packages/engine/src/lineDiff.ts +++ b/packages/engine/src/lineDiff.ts @@ -24,6 +24,24 @@ const BASE_DIFF_OPTIONS = { computeMoves: false, }; +/** + * Whether a diff run in this mode should ignore leading/trailing whitespace. + * + * Exported because a second implementation depends on the answer: the desktop + * app draws its split view through this module but its unified view through + * Monaco's own diff worker, whose only whitespace knob is the identically named + * `ignoreTrimWhitespace`. When the two derived that flag separately they drifted + * — the same file, the same toggle, one view showing a change and the other + * showing none. They now read it from here. + * + * Note "all" is NOT expressible in Monaco: it additionally normalizes internal + * whitespace runs (below), which no editor option does. Any surface that has to + * agree with Monaco must offer "trailing", not "all". + */ +export function ignoreTrimWhitespaceFor(mode: WhitespaceMode): boolean { + return mode !== "none"; +} + export function splitLines(text: string): string[] { return text.split("\n"); } @@ -53,7 +71,7 @@ export function diffSide( const diffOptions = { ...BASE_DIFF_OPTIONS, - ignoreTrimWhitespace: whitespace !== "none", + ignoreTrimWhitespace: ignoreTrimWhitespaceFor(whitespace), }; const { changes } = linesDiffComputers diff --git a/packages/engine/test/whitespaceRule.test.ts b/packages/engine/test/whitespaceRule.test.ts new file mode 100644 index 0000000..fed019b --- /dev/null +++ b/packages/engine/test/whitespaceRule.test.ts @@ -0,0 +1,49 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { diffSide, ignoreTrimWhitespaceFor, splitLines } from "../src/lineDiff"; + +/** + * The desktop app draws the same file two ways: the split view through + * `diffSide` here, the unified view through Monaco's own diff worker. Both are + * the same vscode-diff computer, so they agree — but only while they are handed + * the same `ignoreTrimWhitespace`. When each derived it from the app's toggle + * separately they drifted, and the two views of one file disagreed about + * whether it had changed at all. + * + * These pin the rule itself, and the behaviour each answer buys. + */ + +test("the rule is off only when whitespace is not being ignored", () => { + assert.equal(ignoreTrimWhitespaceFor("none"), false); + assert.equal(ignoreTrimWhitespaceFor("trailing"), true); + assert.equal(ignoreTrimWhitespaceFor("all"), true); +}); + +const LEFT = splitLines('export function pad(n: number): string {\n return " ".repeat(n);\n}\n'); +const REINDENTED = splitLines('export function pad(n: number): string {\n return " ".repeat(n);\n}\n'); +const TRAILING = splitLines('export function pad(n: number): string {\n return " ".repeat(n);\n} \n'); +const INNER = splitLines('export function pad(n: number): string {\n return " ".repeat(n);\n}\n'); + +test("with whitespace shown, a re-indent is a change", () => { + assert.equal(diffSide(LEFT, REINDENTED, "right", { whitespace: "none" }).length, 1); +}); + +test("with trailing whitespace ignored, a re-indent is not", () => { + assert.equal(diffSide(LEFT, REINDENTED, "right", { whitespace: "trailing" }).length, 0); +}); + +test("nor are trailing spaces", () => { + assert.equal(diffSide(LEFT, TRAILING, "right", { whitespace: "none" }).length, 1); + assert.equal(diffSide(LEFT, TRAILING, "right", { whitespace: "trailing" }).length, 0); +}); + +/** + * The reason the desktop toggle sends "trailing" and never "all": "all" + * collapses runs of whitespace INSIDE a line — git's `-b`, roughly — which no + * Monaco option does. Offering it beside a unified view would recreate the + * disagreement in the other direction: split silent, unified drawing a change. + */ +test("only \"all\" reaches inside a line, which is why Monaco cannot match it", () => { + assert.equal(diffSide(LEFT, INNER, "right", { whitespace: "trailing" }).length, 1); + assert.equal(diffSide(LEFT, INNER, "right", { whitespace: "all" }).length, 0); +}); diff --git a/packages/git-service/src/BranchOps.ts b/packages/git-service/src/BranchOps.ts index 4a62552..cf0264b 100644 --- a/packages/git-service/src/BranchOps.ts +++ b/packages/git-service/src/BranchOps.ts @@ -2,6 +2,17 @@ import type { GitProcess, GitRunOptions } from "./GitProcess"; export interface BranchOpResult { ok: boolean; + /** + * git's stdout. + * + * Not decoration: a conflicted `git merge` writes its ENTIRE report there — + * "CONFLICT (content): Merge conflict in f.txt / Automatic merge failed; fix + * conflicts and then commit the result." — and leaves stderr empty. Dropping + * it meant the app answered a conflicted merge with "The operation failed." + * while the working tree was sitting mid-merge, which describes neither what + * happened nor what to do about it. Verified against real git. + */ + stdout?: string; /** * git's exit code, kept alongside `ok` because the two are not the same * question. `ok` says "did it do the thing"; the code says *how* it did not, @@ -50,7 +61,7 @@ export class BranchOps { args.push(startPoint); } const r = await this.proc.run(args, { signal: opts?.signal }); - return { ok: r.code === 0, code: r.code, stderr: r.stderr }; + return { ok: r.code === 0, code: r.code, stderr: r.stderr, stdout: r.stdout }; } /** `git checkout [--detach] <ref>`. */ @@ -64,7 +75,7 @@ export class BranchOps { } args.push(ref); const r = await this.proc.run(args, { signal: opts?.signal }); - return { ok: r.code === 0, code: r.code, stderr: r.stderr }; + return { ok: r.code === 0, code: r.code, stderr: r.stderr, stdout: r.stdout }; } /** @@ -82,7 +93,7 @@ export class BranchOps { args.push(startPoint); } const r = await this.proc.run(args, { signal: opts?.signal }); - return { ok: r.code === 0, code: r.code, stderr: r.stderr }; + return { ok: r.code === 0, code: r.code, stderr: r.stderr, stdout: r.stdout }; } /** @@ -102,7 +113,7 @@ export class BranchOps { const r = await this.proc.run(["branch", "-m", old, neu], { signal: opts?.signal, }); - return { ok: r.code === 0, code: r.code, stderr: r.stderr }; + return { ok: r.code === 0, code: r.code, stderr: r.stderr, stdout: r.stdout }; } /** @@ -148,7 +159,7 @@ export class BranchOps { const r = await this.proc.run(["branch", flag, name], { signal: opts?.signal, }); - return { ok: r.code === 0, code: r.code, stderr: r.stderr }; + return { ok: r.code === 0, code: r.code, stderr: r.stderr, stdout: r.stdout }; } /** `git merge [--no-ff|--ff-only] <ref>` into the current branch. */ @@ -162,7 +173,7 @@ export class BranchOps { } args.push(ref); const r = await this.proc.run(args, { signal: opts?.signal }); - return { ok: r.code === 0, code: r.code, stderr: r.stderr }; + return { ok: r.code === 0, code: r.code, stderr: r.stderr, stdout: r.stdout }; } /** `git rebase <upstream>` — rebase the current branch onto `upstream`. */ @@ -173,7 +184,7 @@ export class BranchOps { const r = await this.proc.run(["rebase", upstream], { signal: opts?.signal, }); - return { ok: r.code === 0, code: r.code, stderr: r.stderr }; + return { ok: r.code === 0, code: r.code, stderr: r.stderr, stdout: r.stdout }; } /** `git branch --set-upstream-to=<upstream> <branch>`. */ @@ -186,7 +197,7 @@ export class BranchOps { ["branch", `--set-upstream-to=${upstream}`, branch], { signal: opts?.signal }, ); - return { ok: r.code === 0, code: r.code, stderr: r.stderr }; + return { ok: r.code === 0, code: r.code, stderr: r.stderr, stdout: r.stdout }; } /** `git push <remote> --delete <name>` — delete a branch on the remote. */ @@ -198,6 +209,6 @@ export class BranchOps { const r = await this.proc.run(["push", remote, "--delete", name], { signal: opts?.signal, }); - return { ok: r.code === 0, code: r.code, stderr: r.stderr }; + return { ok: r.code === 0, code: r.code, stderr: r.stderr, stdout: r.stdout }; } } diff --git a/packages/git-service/src/GitProcess.ts b/packages/git-service/src/GitProcess.ts index 6a7f2a5..69549f9 100644 --- a/packages/git-service/src/GitProcess.ts +++ b/packages/git-service/src/GitProcess.ts @@ -79,7 +79,10 @@ function makeAbortError(): Error { * This package must never import `vscode`. */ export class GitProcess { - private readonly cwd: string; + /** The repository root every command runs in. Readable so a provider can ask + * the filesystem something git will not answer — e.g. the mode of a file that + * is not in the index yet. */ + readonly cwd: string; private readonly gitPath: string; private readonly maxConcurrent: number; private readonly onRun?: GitRunHook; diff --git a/packages/git-service/src/GitToolHost.ts b/packages/git-service/src/GitToolHost.ts index 93a51f4..741024b 100644 --- a/packages/git-service/src/GitToolHost.ts +++ b/packages/git-service/src/GitToolHost.ts @@ -221,7 +221,9 @@ class GitContextToolHost implements GitToolHost { /* empty */ } let files: ToolStatusFile[] = []; - const r = await this.ctx.process.run(["diff", "--name-status", "-M", `${base}...${head}`]).catch(() => null); + // -z: without it any non-ASCII path arrives C-quoted and octal-escaped, + // and the AI tool then reports a filename that does not exist on disk. + const r = await this.ctx.process.run(["diff", "--name-status", "-M", "-z", `${base}...${head}`]).catch(() => null); if (r && r.code === 0) { files = parseNameStatus(r.stdout); } @@ -384,17 +386,17 @@ function parsePorcelain(stdout: string): ToolStatusFile[] { return out; } -/** Parse `git diff --name-status -M` into tool files (staged=false; just the change). */ +/** Parse `git diff --name-status -M -z` into tool files (staged=false; just the + * change). NUL-separated records, so nothing is quoted or escaped: a status + * token then its path, and two paths for R/C (source, destination). */ function parseNameStatus(stdout: string): ToolStatusFile[] { const out: ToolStatusFile[] = []; - for (const line of stdout.split("\n")) { - if (!line.trim()) { - continue; - } - const cols = line.split("\t"); - const code = cols[0]?.[0] ?? "?"; - // For R/C the destination path is the last column. - const path = cols.length >= 3 ? cols[cols.length - 1] : cols[1] ?? ""; + const tok = stdout.split("\0").filter((t) => t.length > 0); + for (let i = 0; i < tok.length; i++) { + const code = tok[i][0] ?? "?"; + const paths = code === "R" || code === "C" ? 2 : 1; + const path = tok[i + paths]; // the destination — the one that exists now + i += paths; if (path) { out.push({ path, status: code, staged: false }); } diff --git a/packages/git-service/src/RebaseRunner.ts b/packages/git-service/src/RebaseRunner.ts index 8dbce10..1bd5000 100644 --- a/packages/git-service/src/RebaseRunner.ts +++ b/packages/git-service/src/RebaseRunner.ts @@ -23,13 +23,39 @@ export interface RebasePlan { base: string; /** The full `git-rebase-todo` text to install (see engine serializeRebaseTodo). */ todo: string; - /** New commit messages for each `reword` row, in top-to-bottom todo order. */ - rewordMessages: string[]; + /** + * New commit messages, keyed by the commit each belongs to. + * + * REQUIRED, and there is deliberately no positional alternative. A `rewords?` + * that fell back to a bare list left the extension on the old shape while the + * installer had moved to sha lookup — and the shim minted `{sha: ""}`, which + * `startsWith("")` matches for EVERY commit: every reword got the first + * message and commits nobody reworded were renamed. An optional field is how + * a half-done migration hides from the compiler. + */ + rewords: Array<{ sha: string; message: string }>; } export interface RebaseRunOptions { /** The git executable (default "git"). */ gitPath?: string; + /** + * Observer for each git invocation this runner makes, so the host can show + * them wherever it shows its other git commands. + * + * Without it a rebase's commands are invisible to the desktop's Output tab — + * the surface the app itself calls "what the user reads, copies and pastes + * into bug reports". A `rebase --continue` that fails then leaves nothing + * anywhere: git's explanation of which paths still need `git add` reaches a + * one-line toast and is then unrecoverable. + */ + onRun?: (event: { + args: string[]; + durationMs: number; + exitCode: number | null; + failed: boolean; + stderr?: string; + }) => void; /** * The binary used to run the tiny installer scripts. Defaults to the current * process (Electron/extension host) with ELECTRON_RUN_AS_NODE=1. @@ -49,28 +75,299 @@ const SEQ_INSTALLER = `const fs=require("fs");fs.writeFileSync(process.argv[proc // "# This is a combination of N commits.") is accepted as-is; a reword gets the // next queued message. Rewords are 1:1 with editor calls and processed in todo // order, so a simple queue index stays aligned. -const MSG_INSTALLER = `const fs=require("fs");const t=process.argv[process.argv.length-1];const c=fs.readFileSync(t,"utf8"); +/** + * The message installer, run as GIT_EDITOR. + * + * It chooses the message BY SHA, read from git's own `rebase-merge/done` — + * whose last line is the todo command currently executing, written before the + * editor launches. + * + * It used to pop by CALL COUNT from an index sidecar. That is only correct + * while nothing interrupts the run, and two things do: + * + * · A pause. `git rebase --continue` opens the editor for the commit that + * stopped WHATEVER its verb — a conflicted `pick` gets an editor call too — + * so a counter handed it the next reword's text, putting a message on a + * commit nobody reworded and shifting every later one. + * · A queue that outlives its rebase. Keyed by position, a leftover queue + * applies to whatever rebase runs next; keyed by SHA it cannot, because a + * foreign rebase's shas are not in it. That property is what makes it safe + * to persist the queue at all, which is what fixes the pause. + * + * A squash group's combined message (git marks it "# This is a combination of + * N commits.") is left alone, as before. + * + * An entry must carry a REAL key (>= 4 hex chars). `"".startsWith("")` is true + * and so is `anySha.startsWith("")`, so an unkeyed entry matches every commit + * there is — which turned a compatibility shim into a wildcard that renamed + * commits nobody had reworded. + */ +const MSG_INSTALLER = `const fs=require("fs");const path=require("path"); +const t=process.argv[process.argv.length-1];const c=fs.readFileSync(t,"utf8"); if(/^# This is a combination of \\d+ commits/m.test(c))process.exit(0); -try{const q=JSON.parse(fs.readFileSync(process.env.GS_REWORD_QUEUE,"utf8"));const sp=process.env.GS_REWORD_QUEUE+".idx";let i=0;try{i=parseInt(fs.readFileSync(sp,"utf8"),10)||0}catch(_){} -const m=q[i];if(typeof m==="string"&&m.trim())fs.writeFileSync(t,m.endsWith("\\n")?m:m+"\\n");fs.writeFileSync(sp,String(i+1));}catch(_){} +try{ + const q=JSON.parse(fs.readFileSync(process.env.GS_REWORD_QUEUE,"utf8")); + const gd=process.env.GS_GIT_DIR||""; + let sha=""; + for(const d of ["rebase-merge","rebase-apply"]){ + try{ + const done=fs.readFileSync(path.join(gd,d,"done"),"utf8").split("\\n").filter(function(l){return l.trim()}); + const last=done[done.length-1]||""; + const m=/^\\s*(?:[a-z-]+)\\s+([0-9a-fA-F]{4,40})\\b/.exec(last); + if(m){sha=m[1];break;} + }catch(_){} + } + if(sha){ + const hit=q.find(function(e){return e&&typeof e.sha==="string"&&e.sha.length>=4&&(e.sha.startsWith(sha)||sha.startsWith(e.sha));}); + if(hit&&typeof hit.message==="string"&&hit.message.trim()){ + fs.writeFileSync(t,hit.message.endsWith("\\n")?hit.message:hit.message+"\\n"); + } + } +}catch(_){} process.exit(0);`; +/** + * Where the reword queue and its installer live while a rebase is in flight. + * + * Inside `.git`, not a temp dir: the queue has to outlive the `git rebase -i` + * process so that `--continue` after a conflict can still install the messages + * the user typed. Before this they died with that process, and every reword + * after the stop point committed with its ORIGINAL message while the app + * reported success. + * + * `.git` and not os.tmpdir() because it is keyed to the repository by + * construction, it is not shared between repos, and it goes away when the repo + * does. Resolved through git so a worktree or submodule (where `.git` is a + * FILE) lands in the right place. + */ +async function rewordPaths( + root: string, + opts: RebaseRunOptions, +): Promise<{ dir: string; queue: string; installer: string } | undefined> { + const { code, stdout } = await spawnGit( + ["rev-parse", "--absolute-git-dir"], + root, + { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, + opts, + ); + const dir = stdout.trim(); + if (code !== 0 || !dir) return undefined; + return { + dir, + queue: path.join(dir, "gitstudio-reword-queue.json"), + installer: path.join(dir, "gitstudio-reword-msg.js"), + }; +} + +/** + * git's own state directory for the rebase in progress, or undefined. + * + * This is where the queue LIVES once a rebase has paused — and it is the whole + * fence. git creates this directory when a rebase starts and deletes it when + * the rebase ends, however it ends and whoever ends it: `--abort` from a + * terminal, the extension's own abort command, `--quit`, completion. Anything + * inside it has exactly the rebase's lifetime, enforced by git. + * + * The previous attempt STAMPED the queue with `onto` + `orig-head` and compared + * on resume. That does not fence, because an abort RESTORES those values: the + * next rebase of the same branch onto the same base produces a byte-identical + * stamp, so an abandoned draft matched perfectly. Measured, with the stamp in + * place: "FINAL log: ABANDONED-DRAFT | m2 | m1". A lifetime we try to describe + * is a lifetime we get wrong; a lifetime git already manages is free. + */ +function rebaseStateDir(gitDir: string): string | undefined { + const merge = path.join(gitDir, "rebase-merge"); + try { + if (fs.statSync(merge).isDirectory()) return merge; + } catch { + /* not the merge backend */ + } + // `rebase-apply` is NOT only a rebase. `git am` uses the same directory, and + // git tells them apart by a marker file inside it: `applying` for am, + // `rebasing` for a rebase on the apply backend. Treating the directory alone + // as proof reported an interrupted `git am` as a paused rebase, and every + // control the app then offered — Continue, Skip, Abort — runs `git rebase`, + // which refuses. (The prose check this replaced got that right by accident: + // git says "You are in the middle of an am session", which never matched.) + const apply = path.join(gitDir, "rebase-apply"); + try { + if (fs.statSync(apply).isDirectory() && !fs.existsSync(path.join(apply, "applying"))) { + return apply; + } + } catch { + /* not the apply backend either */ + } + return undefined; +} + +/** Where a PAUSED rebase's queue and installer live: inside git's state dir. */ +function pausedPaths( + gitDir: string, +): { dir: string; queue: string; installer: string } | undefined { + const state = rebaseStateDir(gitDir); + if (!state) return undefined; + return { + dir: gitDir, + queue: path.join(state, "gitstudio-reword-queue.json"), + installer: path.join(state, "gitstudio-reword-msg.js"), + }; +} + +/** + * Forget the queue. Safe to call when there is none. + * + * Synchronous on purpose: this runs on the paths that report the rebase + * FINISHED, and "finished" has to mean the queue is already gone — not that a + * callback will get to it. Errors are swallowed; failing to delete scratch + * state is not a result the caller can act on. + */ +function clearRewordQueue(p: { queue: string; installer: string } | undefined): void { + if (!p) return; + for (const f of [p.queue, charPath(p.queue), p.installer]) { + try { + fs.rmSync(f, { force: true }); + } catch { + /* nothing to do about it */ + } + } +} + +/** + * The environment a `--continue` / `--skip` needs so the remaining rewords are + * still installed. Returns the plain env when there is no queue to honour. + */ +async function resumeEnv( + root: string, + opts: RebaseRunOptions, +): Promise<{ + env: NodeJS.ProcessEnv; + paths?: { dir: string; queue: string; installer: string }; + commentChar: string; +}> { + const base: NodeJS.ProcessEnv = { + ...process.env, + GIT_OPTIONAL_LOCKS: "0", + GIT_EDITOR: "true", + GIT_SEQUENCE_EDITOR: "true", + }; + const git = await rewordPaths(root, opts); + // ONLY from inside git's own rebase state directory. That location is the + // fence: git deletes the directory when the rebase ends, however it ends and + // whoever ends it, so a queue there cannot outlive its rebase and cannot be + // seen by the next one. Nothing here has to guess a lifetime. + const paths = git && pausedPaths(git.dir); + if (!paths || !fs.existsSync(paths.queue) || !fs.existsSync(paths.installer)) { + return { env: base, commentChar: "#" }; + } + const exe = opts.nodePath ?? process.execPath; + let commentChar = "#"; + try { + const c = fs.readFileSync(charPath(paths.queue), "utf8").trim(); + if (c.length === 1) commentChar = c; + } catch { + /* an older queue, or none — the default is right for ordinary messages */ + } + return { + paths, + commentChar, + env: { + ...base, + ELECTRON_RUN_AS_NODE: "1", + GIT_EDITOR: `${shQuote(exe)} ${shQuote(paths.installer)}`, + GS_REWORD_QUEUE: paths.queue, + GS_GIT_DIR: paths.dir, + }, + }; +} + +/** + * Config every rebase invocation carries. + * + * `core.commentChar=auto` because the user's reword message goes to git through + * the EDITOR channel, where `--cleanup=default` strips every line that begins + * with the comment character. A body line like `#123` was deleted from the + * stored message without a word, and a message that STARTS with one became + * empty — which git treats as "abort this commit", wedging the rebase. `auto` + * makes git pick a character that begins no line in the message, so nothing of + * the user's is a comment. + * + * NOT `commit.cleanup=whitespace`: MSG_INSTALLER deliberately leaves a squash + * group's combined message alone, and that message is git's own boilerplate, + * which only `cleanup=default` strips. Changing the character moves git's + * boilerplate with it; changing the cleanup mode leaves the boilerplate in the + * commit. + */ +function rebaseConfig(commentChar: string): string[] { + return ["-c", `core.commentChar=${commentChar}`]; +} + +/** Where the chosen comment character is remembered for `--continue`/`--skip`. */ +function charPath(queue: string): string { + return queue + ".commentchar"; +} + +/** + * A comment character that begins no line in any message we are about to + * install. + * + * `auto` is not enough: git chooses when it PREPARES the message file, from the + * text that is in it then — and MSG_INSTALLER overwrites that file afterwards. + * So git decided on `#` from the ORIGINAL message and stripped the user's `#` + * lines from ours. We know every message up front, so choose from those. + * + * Falls back to `#`, which is no worse than not trying. + */ +function pickCommentChar(messages: readonly string[]): string { + const starts = new Set<string>(); + for (const m of messages) { + for (const line of m.split("\n")) { + const c = line.trimStart()[0]; + if (c) starts.add(c); + } + } + for (const c of [";", "@", "!", "$", "%", "^", "&", "*", "+", "=", "~", "|", ":", "?"]) { + if (!starts.has(c)) return c; + } + return "#"; +} + /** Run the composed plan. Resolves with the outcome; never throws for git errors. */ export async function runRebasePlan( root: string, plan: RebasePlan, opts: RebaseRunOptions = {}, ): Promise<RebaseOutcome> { + // Refuse BEFORE writing anything. + // + // git will refuse this run itself ("there is already a rebase-merge + // directory") — but only after we have already written the new plan's reword + // queue, and the pause path then handed that queue to the rebase ALREADY in + // flight, overwriting the messages the user actually typed. Measured: a + // second plan that git never started still renamed the commit — + // "FINAL log: SECOND-DRAFT | m2 | m1" — while the outcome shown was git's + // "It seems that there is already a rebase-merge directory", which reads as + // "nothing happened". + if (await rebaseInProgress(root, { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, opts)) { + return { + status: "failed", + message: "A rebase is already in progress — continue or abort it before starting another.", + }; + } const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gitstudio-rebase-")); const seqJs = path.join(dir, "seq.js"); - const msgJs = path.join(dir, "msg.js"); const todoFile = path.join(dir, "todo"); - const rewordFile = path.join(dir, "reword.json"); + // The reword queue and its installer live in `.git`, NOT here: they have to + // outlive this process so `--continue` after a conflict can still install the + // messages the user typed. See rewordPaths. + const rw = await rewordPaths(root, opts); + const rewords = plan.rewords; + const msgJs = rw?.installer ?? path.join(dir, "msg.js"); + const rewordFile = rw?.queue ?? path.join(dir, "reword.json"); try { fs.writeFileSync(seqJs, SEQ_INSTALLER); fs.writeFileSync(msgJs, MSG_INSTALLER); fs.writeFileSync(todoFile, plan.todo); - fs.writeFileSync(rewordFile, JSON.stringify(plan.rewordMessages ?? [])); + fs.writeFileSync(rewordFile, JSON.stringify(rewords)); const exe = opts.nodePath ?? process.execPath; const env: NodeJS.ProcessEnv = { @@ -85,25 +382,84 @@ export async function runRebasePlan( GIT_EDITOR: `${shQuote(exe)} ${shQuote(msgJs)}`, GS_REBASE_TODO: todoFile, GS_REWORD_QUEUE: rewordFile, + // The installer reads `<git-dir>/rebase-merge/done` to learn WHICH commit + // git is asking about. + GS_GIT_DIR: rw?.dir ?? "", }; - const args = ["rebase", "-i", plan.base]; + // Chosen from the messages this run will install, and remembered for the + // `--continue` that may follow. + const commentChar = pickCommentChar(rewords.map((r) => r.message)); + if (rw) { + try { + fs.writeFileSync(charPath(rewordFile), commentChar); + } catch { + /* the default is still correct for messages with no leading hash */ + } + } + const args = [...rebaseConfig(commentChar), "rebase", "-i", plan.base]; const { code, stderr, stdout } = await spawnGit(args, root, env, opts); + /** + * A pause, not an ending. + * + * Hand the queue to GIT to look after: moved inside `rebase-merge/`, it + * lives exactly as long as the rebase does, and an abort from anywhere — + * a terminal, the extension, `--quit` — takes it with the directory. That + * is the whole fence; there is nothing for us to stamp or compare. + */ + const paused = ( + reason: "conflict" | "edit" | "unknown", + message: string, + ): RebaseOutcome => { + const inRebase = rw && pausedPaths(rw.dir); + if (rw && inRebase) { + try { + fs.renameSync(rw.queue, inRebase.queue); + fs.renameSync(rw.installer, inRebase.installer); + } catch { + // Could not hand it over — then do NOT leave it lying in .git, where + // the next rebase of this branch would find it. + clearRewordQueue(rw); + } + } else { + clearRewordQueue(rw); + } + return { status: "stopped", reason, message }; + }; + if (code === 0) { + // Exit 0 is NOT the same as finished. `git rebase -i` exits 0 when it + // stops at an `edit` row — the user asked for that pause — and taking it + // as "done" toasted "Rebase complete." over a detached, mid-rebase repo + // AND deleted the queue this whole mechanism exists to preserve, so every + // reword below the `edit` row then committed with its original message. + if (await rebaseInProgress(root, env, opts)) { + return paused("edit", "Rebase paused for editing."); + } + clearRewordQueue(rw); return { status: "done" }; } const blob = `${stdout}\n${stderr}`; if (/could not apply|CONFLICT|Merge conflict|needs merge|fix conflicts/i.test(blob)) { - return { status: "stopped", reason: "conflict", message: firstLine(stderr) || "Rebase paused on a conflict." }; - } - if (/Stopped at .*edit|You can amend the commit now/i.test(blob)) { - return { status: "stopped", reason: "edit", message: "Rebase paused for editing." }; + return paused("conflict", firstLine(stderr, stdout) || "Rebase paused on a conflict."); } + // No `Stopped at .*edit` branch here on purpose. + // + // "You can amend the commit now" is the generic hint git prints after ANY + // failed commit during a rebase — a `commit-msg` hook rejecting the + // message, an empty message, a failed GPG sign. Matching it reported every + // one of those as "Rebase paused for editing." and threw away git's own + // explanation, which is the only thing that says what to fix. The guard + // below already answers correctly: it reports a stop only when a rebase is + // genuinely live, and carries git's words when it does. // Still mid-rebase? Treat as a stop the user must resolve rather than a hard fail. if (await rebaseInProgress(root, env, opts)) { - return { status: "stopped", reason: "unknown", message: firstLine(stderr) || "Rebase paused." }; + return paused("unknown", firstLine(stderr, stdout) || "Rebase paused."); } - return { status: "failed", message: firstLine(stderr) || firstLine(stdout) || "Rebase failed." }; + // A hard failure ends the rebase; a STOP does not, and its queue must + // survive for the `--continue` that follows. + clearRewordQueue(rw); + return { status: "failed", message: firstLine(stderr, stdout) || "Rebase failed." }; } finally { fs.rm(dir, { recursive: true, force: true }, () => {}); } @@ -111,22 +467,86 @@ export async function runRebasePlan( /** `git rebase --continue` (after resolving a conflict / finishing an edit). */ export async function continueRebase(root: string, opts: RebaseRunOptions = {}): Promise<RebaseOutcome> { - const env = { ...process.env, GIT_OPTIONAL_LOCKS: "0", GIT_EDITOR: "true", GIT_SEQUENCE_EDITOR: "true" }; - const { code, stderr, stdout } = await spawnGit(["rebase", "--continue"], root, env, opts); + // GIT_EDITOR was "true" here — a no-op — so every reword AFTER the stop point + // committed with its original message, and the app said "Rebase continued." + const { env, paths, commentChar } = await resumeEnv(root, opts); + const { code, stderr, stdout } = await spawnGit([...rebaseConfig(commentChar), "rebase", "--continue"], root, env, opts); if (code === 0) { + // Exit 0 with a rebase still in flight is the next `edit` stop, not the end + // — and clearing the queue there would drop every reword below it. + if (await rebaseInProgress(root, env, opts)) { + return { status: "stopped", reason: "edit", message: "Rebase paused for editing." }; + } + clearRewordQueue(paths); return { status: "done" }; } const blob = `${stdout}\n${stderr}`; if (/could not apply|CONFLICT|needs merge/i.test(blob)) { - return { status: "stopped", reason: "conflict", message: firstLine(stderr) || "Still conflicted." }; + return { status: "stopped", reason: "conflict", message: firstLine(stderr, stdout) || "The rebase is still stopped." }; } if (await rebaseInProgress(root, env, opts)) { - return { status: "stopped", reason: "unknown", message: firstLine(stderr) || "Rebase paused." }; + return { status: "stopped", reason: "unknown", message: firstLine(stderr, stdout) || "Rebase paused." }; } - return { status: "failed", message: firstLine(stderr) || "Continue failed." }; + clearRewordQueue(paths); + return { status: "failed", message: firstLine(stderr, stdout) || "Continue failed." }; +} + +/** + * `git rebase --skip`, honouring any remaining rewords for the same reason + * `--continue` does: skipping one commit does not make the messages queued for + * the ones after it disappear. + */ +export async function skipRebase(root: string, opts: RebaseRunOptions = {}): Promise<RebaseOutcome> { + const { env, paths, commentChar } = await resumeEnv(root, opts); + const { code, stderr, stdout } = await spawnGit([...rebaseConfig(commentChar), "rebase", "--skip"], root, env, opts); + if (code === 0) { + if (await rebaseInProgress(root, env, opts)) { + return { status: "stopped", reason: "edit", message: "Rebase paused for editing." }; + } + clearRewordQueue(paths); + return { status: "done" }; + } + const blob = `${stdout}\n${stderr}`; + if (/could not apply|CONFLICT|needs merge/i.test(blob)) { + return { status: "stopped", reason: "conflict", message: firstLine(stderr, stdout) || "The rebase is still stopped." }; + } + if (await rebaseInProgress(root, env, opts)) { + return { status: "stopped", reason: "unknown", message: firstLine(stderr, stdout) || "Rebase paused." }; + } + clearRewordQueue(paths); + return { status: "failed", message: firstLine(stderr, stdout) || "Skip failed." }; } /** `git rebase --abort`. */ +/** + * Abort, reporting WHY when it fails. + * + * `abortRebaseAt` answers a bare boolean, so the caller had nothing to show but + * a canned "Couldn't abort the rebase." — while git's own explanation (a locked + * index, an unmerged path it will not discard) was thrown away. That is the + * same laundering this codebase has fixed in three other places. + */ +export async function abortRebase( + root: string, + opts: RebaseRunOptions = {}, +): Promise<RebaseOutcome> { + const { code, stderr, stdout } = await spawnGit( + ["rebase", "--abort"], + root, + { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, + opts, + ); + if (code === 0) { + clearRewordQueue(await rewordPaths(root, opts)); + return { status: "done" }; + } + return { + status: "failed", + message: firstLine(stderr, stdout) || "Couldn't abort the rebase.", + }; +} + +/** Boolean form, for callers that only branch on success. */ export async function abortRebaseAt(root: string, opts: RebaseRunOptions = {}): Promise<boolean> { const { code } = await spawnGit( ["rebase", "--abort"], @@ -134,6 +554,12 @@ export async function abortRebaseAt(root: string, opts: RebaseRunOptions = {}): { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, opts, ); + // ONLY on success. A failed abort has changed nothing about the rebase, so it + // must not change the queue either — destroying the messages while the rebase + // is still live is the worst of both. git's own `--abort` removes + // `rebase-merge/` and the queue inside it; this only sweeps up a staging copy + // left by a run that never reached a pause. + if (code === 0) clearRewordQueue(await rewordPaths(root, opts)); return code === 0; } @@ -147,8 +573,22 @@ async function rebaseInProgress( env: NodeJS.ProcessEnv, opts: RebaseRunOptions, ): Promise<boolean> { - const { stdout } = await spawnGit(["status"], root, env, opts); - return /rebase in progress|interactive rebase in progress/i.test(stdout); + // Ask the FILESYSTEM, not git's prose. + // + // This ran `git status` and grepped it for "rebase in progress". git + // translates that sentence — a French git says "rebasage interactif en + // cours", a German one "Interaktives Rebase im Gange" — and the message + // catalogs ship with the standard package. So on any non-English git the + // answer was always `false`, which silently disabled every guard built on it: + // an `edit` stop was reported as a completed rebase, and the reword queue was + // deleted with it. + // + // The state directory is the same fact without the language, and cheaper than + // `git status` on a large working tree. + const { code, stdout } = await spawnGit(["rev-parse", "--absolute-git-dir"], root, env, opts); + const gitDir = stdout.trim(); + if (code !== 0 || !gitDir) return false; + return rebaseStateDir(gitDir) !== undefined; } /** @@ -163,11 +603,17 @@ async function rebaseInProgress( * So: split on CR as well as LF, prefer a line that announces a problem, and * fall back to the first line that is not progress noise. */ -function firstLine(s: string): string { - const lines = (s || "") +function firstLine(...streams: string[]): string { + const lines = streams + .join("\n") .split(/[\r\n]+/) .map((l) => l.trim()) - .filter((l) => l.length > 0); + // `hint:` lines are git's advice ABOUT the problem, and it puts them first: + // taking one gave the user "hint: Resolve all conflicts manually, mark them + // as resolved with" — a sentence cut mid-clause, telling them to fix + // conflicts that in the emptied-patch case do not exist. `Applying:` is + // progress noise for the same reason `Rebasing (n/m)` is. + .filter((l) => l.length > 0 && !/^(hint|Applying):/i.test(l)); const problem = lines.find((l) => /^(error|fatal|warning):|could not|cannot |failed to|CONFLICT/i.test(l), ); @@ -197,6 +643,7 @@ function spawnGit( opts: RebaseRunOptions, ): Promise<{ code: number | null; stdout: string; stderr: string }> { return new Promise((resolve) => { + const startedAt = Date.now(); // stdin is IGNORED, not inherited/piped. A rebase re-signs commits and may // hit a credential helper; with an open stdin git blocks on the prompt // forever and this promise never settles, wedging the whole rebase with no @@ -215,6 +662,17 @@ function spawnGit( } done = true; clearTimeout(timer); + try { + opts.onRun?.({ + args, + durationMs: Date.now() - startedAt, + exitCode: r.code, + failed: r.code !== 0, + stderr: r.code !== 0 ? r.stderr.slice(0, 4000) : undefined, + }); + } catch { + /* an observer must never break the command it is observing */ + } resolve(r); }; // Backstop for anything that still wedges (a pinentry GUI nobody answers, diff --git a/packages/git-service/src/RefProvider.ts b/packages/git-service/src/RefProvider.ts index d008c64..b289364 100644 --- a/packages/git-service/src/RefProvider.ts +++ b/packages/git-service/src/RefProvider.ts @@ -6,14 +6,28 @@ const FIELD_SEP = "\x1f"; // %(*objectname) peels annotated tags to the COMMIT they tag — %(objectname) // alone is the tag object's own sha, which matches no graph row, so annotated // tags would never render a chip anywhere. Empty for everything else. +// +// The last four fields cost nothing — this read already runs — and each buys a +// fact the UI could not previously state: +// committerdate a remote branch or tag row with only a name and a sha cannot +// be told from its neighbours, or sorted by anything useful +// contents:subject what the ref actually points AT +// objecttype "tag" for an ANNOTATED tag; the only thing that separates +// the two kinds, and nothing has ever carried it +// symref:short on refs/remotes/*/HEAD this is the repository's DEFAULT +// branch, free, with no extra process const REF_FORMAT = `--format=%(objectname)${FIELD_SEP}%(refname)${FIELD_SEP}` + `%(refname:short)${FIELD_SEP}%(HEAD)${FIELD_SEP}%(upstream:short)` + - `${FIELD_SEP}%(upstream:track)${FIELD_SEP}%(*objectname)`; + `${FIELD_SEP}%(upstream:track)${FIELD_SEP}%(*objectname)` + + `${FIELD_SEP}%(committerdate:unix)${FIELD_SEP}%(contents:subject)` + + `${FIELD_SEP}%(objecttype)${FIELD_SEP}%(symref:short)`; /** Parses `%(upstream:track)` ("[ahead 2, behind 3]", "[gone]", or "") into * ahead/behind counts. Returns undefined counts when not tracked/clean. */ -function parseTrack(track: string | undefined): { ahead?: number; behind?: number } { +function parseTrack( + track: string | undefined, +): { ahead?: number; behind?: number; gone?: boolean } { if (!track) { return {}; } @@ -22,6 +36,9 @@ function parseTrack(track: string | undefined): { ahead?: number; behind?: numbe return { ...(ahead ? { ahead: Number(ahead[1]) } : {}), ...(behind ? { behind: Number(behind[1]) } : {}), + // `[gone]` — the upstream was deleted. Without it a branch left behind by a + // merged pull request is indistinguishable from one in perfect sync. + ...(/\bgone\b/.test(track) ? { gone: true } : {}), }; } @@ -60,7 +77,7 @@ export class RefProvider { this.proc.run(["stash", "list", STASH_FORMAT]), ]); for (const line of splitLines(branchesAndTags.stdout)) { - const [objectname, refname, short, head, upstream, track, peeled] = + const [objectname, refname, short, head, upstream, track, peeled, date, subject, objectType, symref] = line.split(FIELD_SEP); const type = refTypeFromFullName(refname); if (!type) { @@ -75,15 +92,30 @@ export class RefProvider { sha: peeled || objectname, isCurrent: head === "*", }; + if (Number(date)) { + ref.date = Number(date); + } + if (subject) { + ref.subject = subject; + } + if (objectType) { + ref.objectType = objectType; + } + if (symref) { + ref.symref = symref; + } if (upstream) { ref.upstream = upstream; - const { ahead, behind } = parseTrack(track); + const { ahead, behind, gone } = parseTrack(track); if (ahead !== undefined) { ref.ahead = ahead; } if (behind !== undefined) { ref.behind = behind; } + if (gone) { + ref.gone = true; + } } refs.push(ref); } diff --git a/packages/git-service/src/StagingProvider.ts b/packages/git-service/src/StagingProvider.ts index 405d402..16e8e86 100644 --- a/packages/git-service/src/StagingProvider.ts +++ b/packages/git-service/src/StagingProvider.ts @@ -1,3 +1,5 @@ +import { lstat } from "node:fs/promises"; +import { join } from "node:path"; import type { GitProcess } from "./GitProcess"; export interface StagingOptions { @@ -270,19 +272,50 @@ export class StagingProvider { } /** - * The file mode currently recorded for `rel` in the index (e.g. "100644" or - * "100755"), or "100644" when the path is not yet tracked. Parsed from - * `git ls-files -s -- <rel>`, whose first field is the mode. + * The mode to record for `rel` — asked of the WORKING TREE, not just the index. + * + * This used to return the mode already in the index, and "100644" whenever the + * path had no index entry. Both answers are wrong in the case that matters: + * + * - a brand-new executable script staged through line or hunk staging has no + * index entry, so it was recorded 100644 and the commit shipped a script + * that will not run; + * - `chmod +x` on a TRACKED file returned the old mode, so the bit could + * never be staged at all through these paths. + * + * `core.fileMode=false` (Windows, some network filesystems) means the on-disk + * bit is not to be trusted, and there the recorded mode is the right answer — + * so that case keeps the old behaviour deliberately. */ private async indexMode(rel: string, signal?: AbortSignal): Promise<string> { - const r = await this.proc.run(["ls-files", "-s", "--", rel], { signal }); - if (r.code === 0) { - const match = /^(\d{6})\s/.exec(r.stdout); - if (match) { - return match[1]; - } + const recorded = await this.recordedMode(rel, signal); + if (!(await this.fileModeHonoured(signal))) return recorded ?? "100644"; + try { + const st = await lstat(join(this.proc.cwd, rel)); + if (st.isSymbolicLink()) return "120000"; + // Git records exactly two file modes; the owner-execute bit is the one it + // reads (see git-update-index(1) on --chmod). + return st.mode & 0o100 ? "100755" : "100644"; + } catch { + // Gone, unreadable, or not a plain file — fall back to whatever the index + // already believed rather than inventing a mode. + return recorded ?? "100644"; } - return "100644"; + } + + /** The mode `rel` already carries in the index, or undefined when untracked. */ + private async recordedMode(rel: string, signal?: AbortSignal): Promise<string | undefined> { + const r = await this.proc.run(["ls-files", "-s", "--", rel], { signal }); + if (r.code !== 0) return undefined; + return /^(\d{6})\s/.exec(r.stdout)?.[1]; + } + + /** Does this repo trust the filesystem's executable bit? */ + private async fileModeHonoured(signal?: AbortSignal): Promise<boolean> { + const r = await this.proc.run(["config", "--bool", "core.fileMode"], { signal }); + // Unset (exit 1) means git's default, which is true on POSIX. + if (r.code !== 0) return process.platform !== "win32"; + return r.stdout.trim() !== "false"; } /** The staged (index) version of a file via `git show :<rel>`, or "". */ diff --git a/packages/git-service/src/SyncOps.ts b/packages/git-service/src/SyncOps.ts index 7071481..1c3a9f3 100644 --- a/packages/git-service/src/SyncOps.ts +++ b/packages/git-service/src/SyncOps.ts @@ -154,6 +154,30 @@ export class SyncOps { refspec = `HEAD:refs/heads/${pair.remoteBranch}`; } } + } else if (branch && !setUpstream) { + // A NAMED branch (the Branches view's Push, which pushes a branch you are + // not standing on). This ran `git push <remote> <localName>` — the local + // name on both sides — so after `git branch -m`, which keeps the tracking + // config pointing at the OLD remote name, Push created a second remote + // branch under the new name and left the tracked one untouched. Verified + // against real git: "* [new branch] feature-local-rename". The ahead + // count never cleared either, because the branch still tracked a ref that + // had not moved. + // + // Source is the local branch by full ref (a bare name resolves against + // refs/tags too); destination is the name the upstream actually has. + const pair = await this.upstreamPair(opts?.signal, branch); + if (pair) { + // ALWAYS fully qualified, not only when the names differ. A bare name is + // resolved against refs/heads AND refs/tags, so on a repo where a tag + // shares the branch's name git refuses outright: + // error: src refspec release matches more than one + // The HEAD path above has said this in a comment since it was written; + // the named-branch path qualified only the rename case and inherited + // the bug for every ordinary push. + remote = pair.remote; + refspec = `refs/heads/${pair.local}:refs/heads/${pair.remoteBranch}`; + } } const args = ["push"]; @@ -171,7 +195,14 @@ export class SyncOps { if (refspec) { args.push(refspec); } else if (branch) { - args.push(branch); + // Qualify here too. This is the PUBLISH path — a branch with no + // upstream yet, so `upstreamPair` above found nothing to resolve — and + // a bare name is matched against refs/heads AND refs/tags, so + // publishing a branch that shares a tag's name failed outright with + // "error: src refspec v2 matches more than one". Verified against real + // git, including that `--set-upstream` still tracks correctly with an + // explicit src:dst ("branch 'v2' set up to track 'origin/v2'"). + args.push(`refs/heads/${branch}:refs/heads/${branch}`); } } const r = await this.proc.run(args, { signal: opts?.signal }); @@ -185,15 +216,21 @@ export class SyncOps { */ private async upstreamPair( signal?: AbortSignal, + /** Resolve THIS branch's pair rather than HEAD's. The Branches view pushes + * a branch it is not standing on, and needs the same answer. */ + branch?: string, ): Promise<{ local: string; remote: string; remoteBranch: string } | null> { - const head = await this.proc.run(["symbolic-ref", "--quiet", "HEAD"], { - signal, - }); - const fullRef = head.stdout.trim(); - if (head.code !== 0 || !fullRef.startsWith("refs/heads/")) { - return null; // detached + let local = branch; + if (!local) { + const head = await this.proc.run(["symbolic-ref", "--quiet", "HEAD"], { + signal, + }); + const fullRef = head.stdout.trim(); + if (head.code !== 0 || !fullRef.startsWith("refs/heads/")) { + return null; // detached + } + local = fullRef.slice("refs/heads/".length); } - const local = fullRef.slice("refs/heads/".length); const [remoteR, mergeR] = await Promise.all([ this.proc.run(["config", "--get", `branch.${local}.remote`], { signal }), this.proc.run(["config", "--get", `branch.${local}.merge`], { signal }), diff --git a/packages/git-service/src/rebasePlan.ts b/packages/git-service/src/rebasePlan.ts index 1308dda..f1f4081 100644 --- a/packages/git-service/src/rebasePlan.ts +++ b/packages/git-service/src/rebasePlan.ts @@ -12,9 +12,9 @@ // Two things depend on that and fail quietly if they are left reading the display // order instead: // -// · rewordMessages. RebaseRunner queues these and pops one each time git opens -// the editor, which happens once per `reword` IN TODO ORDER. Built from the -// display array they would land on the wrong commits, with no error. +// · rewords. RebaseRunner looks these up BY SHA when git opens the editor. +// They used to be a bare list popped once per editor call, which is only +// correct while nothing interrupts the run — see `rewords` below. // · squash/fixup meld into the entry BEFORE them in the file. After the flip // that is the row BELOW on screen, which is why the "first commit can't be a // squash" guard has to run against the reversed plan, not the visible top row. @@ -55,7 +55,21 @@ export interface BuildOptions { } export type RebasePlanResult = - | { ok: true; todo: string; rewordMessages: string[] } + | { + ok: true; + todo: string; + /** + * Reword messages keyed by the commit they belong to. + * + * `rewordMessages` below is the same data as a bare list, and a bare list + * is only usable by COUNTING editor invocations — which stops being + * correct the moment a rebase pauses. `git rebase --continue` opens the + * editor for the commit that stopped, whatever its verb, so a conflicted + * `pick` consumed the next reword's text and every later message landed + * one commit early. Keyed by sha there is nothing to count. + */ + rewords: Array<{ sha: string; message: string }>; + } | { ok: false; message: string }; /** Actions we will write into a todo file. Anything else is a caller bug. */ @@ -124,27 +138,62 @@ export function buildRebasePlan( }; } - // An update-ref line means "this branch points HERE", so it must sit - // immediately after its own pick and travel with it through any reorder. - // Detached from its commit the branch lands somewhere arbitrary — proved by - // getting this wrong once: swapping a pick with the update-ref line above it - // moved a branch onto the commit BEFORE the range began. + // An update-ref line means "this branch points HERE", so it must travel with + // its own commit through any reorder — detached from it, the branch lands + // somewhere arbitrary. (Proved by getting it wrong once: swapping a pick with + // the update-ref line above it moved a branch onto the commit BEFORE the + // range began.) + // + // "With its commit" is not "on the next line", though. A squash or fixup + // AMENDS the commit above it, and git records an update-ref the moment it + // reaches that line — so `pick c2 / update-ref refs/heads/feature / fixup c3` + // points the branch at c2 and then rewrites c2, leaving the branch on a + // commit that is no longer in the history. Verified against git 2.49: + // `merge-base --is-ancestor feature HEAD` fails, which is exactly the + // orphaning `--update-refs` exists to prevent. git's own `--autosquash` + // emits `pick c2 / fixup fixup!c2 / update-ref …`, after the fold. + // + // So each row's refs are emitted after the LAST row that folds into it. + // `drop` is transparent when scanning forward — `pick c2 / update-ref / + // drop c3 / fixup c4` still orphans, because the fixup after the dropped row + // folds into c2 all the same. + const FOLDS = new Set(["squash", "fixup"]); + /** The index of the last row that folds into `i`, or `i` itself. */ + const foldEnd = (i: number): number => { + let end = i; + for (let j = i + 1; j < plan.length; j++) { + if (FOLDS.has(plan[j].action)) end = j; + else if (plan[j].action === "drop") continue; + else break; + } + return end; + }; + + // Refs to emit after each row, keyed by the row that ends its fold run. + const refsAfter = new Map<number, string[]>(); + plan.forEach((r, i) => { + // A DROPPED commit is not in the rewritten history at all, so there is + // nothing for its branch to point at; git's own --update-refs writes a + // comment rather than a line, and moving the branch anyway would put it + // somewhere the user never asked for. + if (!opts?.updateRefs || r.action === "drop") return; + const names = (r.branches ?? []).filter(isSafeBranchName); + if (!names.length) return; + const at = foldEnd(i); + refsAfter.set(at, [...(refsAfter.get(at) ?? []), ...names]); + }); + const lines: string[] = []; - for (const r of plan) { + plan.forEach((r, i) => { lines.push(`${r.action} ${r.sha} ${oneLine(r.subject)}`.trimEnd()); - if (!opts?.updateRefs || r.action === "drop") { - continue; - } - for (const branch of r.branches ?? []) { - if (isSafeBranchName(branch)) { - lines.push(`update-ref refs/heads/${branch}`); - } + for (const branch of refsAfter.get(i) ?? []) { + lines.push(`update-ref refs/heads/${branch}`); } - } + }); const todo = lines.join("\n") + "\n"; - const rewordMessages = plan + const rewords = plan .filter((r) => r.action === "reword") - .map((r) => (r.message ?? "").trim() || r.subject); + .map((r) => ({ sha: r.sha, message: (r.message ?? "").trim() || r.subject })); - return { ok: true, todo, rewordMessages }; + return { ok: true, todo, rewords }; } diff --git a/packages/git-service/test/amendPush.test.ts b/packages/git-service/test/amendPush.test.ts index fd9bc55..f4aba94 100644 --- a/packages/git-service/test/amendPush.test.ts +++ b/packages/git-service/test/amendPush.test.ts @@ -1,7 +1,8 @@ import { test, before, after, beforeEach } from "node:test"; import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { removeTempRepo } from "./tmpRepo"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { GitContext } from "../src/GitContext"; @@ -58,7 +59,7 @@ before(() => { identify(seed); commitIn(seed, "file.txt", "base\n", "base"); gitIn(seed, ["push", "origin", "main"]); - rmSync(seed, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + removeTempRepo(seed); clone = mkdtempSync(join(tmpdir(), "gitstudio-am-clone-")); execFileSync("git", ["clone", bare, clone], { env: ENV }); @@ -69,7 +70,7 @@ before(() => { after(() => { ctx?.dispose(); for (const dir of [bare, clone]) { - if (dir) rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + if (dir) removeTempRepo(dir); } }); @@ -149,7 +150,7 @@ test("the lease refuses when someone else pushed — their work survives", async "the colleague's commit must still be on the remote", ); } finally { - rmSync(other, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + removeTempRepo(other); } }); diff --git a/packages/git-service/test/blame.test.ts b/packages/git-service/test/blame.test.ts index 0fefbd4..4fb6a2b 100644 --- a/packages/git-service/test/blame.test.ts +++ b/packages/git-service/test/blame.test.ts @@ -1,7 +1,8 @@ import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { removeTempRepo } from "./tmpRepo"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { GitContext } from "../src/GitContext"; @@ -64,7 +65,7 @@ before(() => { after(() => { ctx?.dispose(); if (repo) { - rmSync(repo, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + removeTempRepo(repo); } }); diff --git a/packages/git-service/test/checkoutRemote.test.ts b/packages/git-service/test/checkoutRemote.test.ts index 20bb259..80393b0 100644 --- a/packages/git-service/test/checkoutRemote.test.ts +++ b/packages/git-service/test/checkoutRemote.test.ts @@ -1,7 +1,8 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { removeTempRepo } from "./tmpRepo"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { localNameFor, planRemoteCheckout } from "../src/checkoutRemote"; @@ -48,7 +49,7 @@ function git(cwd: string, ...args: string[]): { code: number; out: string } { function cleanup(dir: string): void { try { - rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); + removeTempRepo(dir); } catch { /* the OS tmpdir gets swept anyway */ } diff --git a/packages/git-service/test/commitDetails.test.ts b/packages/git-service/test/commitDetails.test.ts index 76c6803..05fea95 100644 --- a/packages/git-service/test/commitDetails.test.ts +++ b/packages/git-service/test/commitDetails.test.ts @@ -1,7 +1,8 @@ import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { removeTempRepo } from "./tmpRepo"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { GitContext } from "../src/GitContext"; @@ -95,7 +96,7 @@ before(() => { after(() => { ctx?.dispose(); if (repo) { - rmSync(repo, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + removeTempRepo(repo); } }); diff --git a/packages/git-service/test/conflict.test.ts b/packages/git-service/test/conflict.test.ts index c941cde..247ebe4 100644 --- a/packages/git-service/test/conflict.test.ts +++ b/packages/git-service/test/conflict.test.ts @@ -1,7 +1,8 @@ import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { removeTempRepo } from "./tmpRepo"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { GitContext } from "../src/GitContext"; @@ -69,7 +70,7 @@ before(() => { after(() => { ctx?.dispose(); if (repo) { - rmSync(repo, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + removeTempRepo(repo); } }); diff --git a/packages/git-service/test/discard.test.ts b/packages/git-service/test/discard.test.ts index 91108f3..be3e3f0 100644 --- a/packages/git-service/test/discard.test.ts +++ b/packages/git-service/test/discard.test.ts @@ -1,7 +1,8 @@ import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync, existsSync } from "node:fs"; +import { mkdtempSync, writeFileSync, existsSync } from "node:fs"; +import { removeTempRepo } from "./tmpRepo"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { GitContext } from "../src/GitContext"; @@ -40,7 +41,7 @@ before(() => { after(() => { ctx?.dispose(); - rmSync(repo, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + removeTempRepo(repo); }); test("chunkPaths: a normal changeset stays a single batch", () => { diff --git a/packages/git-service/test/gitService.test.ts b/packages/git-service/test/gitService.test.ts index 3d9d965..428fba4 100644 --- a/packages/git-service/test/gitService.test.ts +++ b/packages/git-service/test/gitService.test.ts @@ -1,7 +1,8 @@ import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { removeTempRepo } from "./tmpRepo"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { CommitRecord } from "@gitstudio/host-bridge/git"; @@ -89,7 +90,7 @@ before(() => { after(() => { ctx?.dispose(); if (repo) { - rmSync(repo, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + removeTempRepo(repo); } }); diff --git a/packages/git-service/test/gitToolHost.test.ts b/packages/git-service/test/gitToolHost.test.ts index f6158a8..fb30458 100644 --- a/packages/git-service/test/gitToolHost.test.ts +++ b/packages/git-service/test/gitToolHost.test.ts @@ -1,7 +1,8 @@ import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { removeTempRepo } from "./tmpRepo"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { GitContext } from "../src/GitContext"; @@ -37,7 +38,7 @@ before(() => { after(() => { ctx.dispose(); - rmSync(repo, { recursive: true, force: true }); + removeTempRepo(repo); }); test("read tools report repo state", async () => { diff --git a/packages/git-service/test/history.test.ts b/packages/git-service/test/history.test.ts index be48093..2ea147b 100644 --- a/packages/git-service/test/history.test.ts +++ b/packages/git-service/test/history.test.ts @@ -1,7 +1,8 @@ import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync, renameSync } from "node:fs"; +import { mkdtempSync, writeFileSync, renameSync } from "node:fs"; +import { removeTempRepo } from "./tmpRepo"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { GitContext } from "../src/GitContext"; @@ -71,7 +72,7 @@ before(() => { after(() => { ctx?.dispose(); if (repo) { - rmSync(repo, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + removeTempRepo(repo); } }); diff --git a/packages/git-service/test/rebaseChain.test.ts b/packages/git-service/test/rebaseChain.test.ts index a25b131..d4b11a5 100644 --- a/packages/git-service/test/rebaseChain.test.ts +++ b/packages/git-service/test/rebaseChain.test.ts @@ -1,7 +1,8 @@ import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { removeTempRepo } from "./tmpRepo"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { GitContext } from "../src/GitContext"; @@ -44,7 +45,7 @@ before(() => { after(() => { ctx?.dispose(); for (const d of [bare, clone]) { - if (d) rmSync(d, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + if (d) removeTempRepo(d); } }); diff --git a/packages/git-service/test/rebasePlan.test.ts b/packages/git-service/test/rebasePlan.test.ts index b45b507..3bc2cd8 100644 --- a/packages/git-service/test/rebasePlan.test.ts +++ b/packages/git-service/test/rebasePlan.test.ts @@ -37,17 +37,22 @@ test("reword messages follow TODO order, not screen order", () => { row("aaaaaa", "first", "reword", "FIRST edited"), ]); assert.ok(r.ok); + // Keyed by commit, in todo order. The order still matters for reading, but + // it is no longer what SELECTS the message — see `rewords` in rebasePlan.ts. assert.deepEqual( - r.rewordMessages, - ["FIRST edited", "THIRD edited"], - "oldest commit's message must be consumed first", + r.rewords, + [ + { sha: "aaaaaa", message: "FIRST edited" }, + { sha: "cccccc", message: "THIRD edited" }, + ], + "each message is bound to the commit it belongs to, oldest first", ); }); test("a reword with an empty message falls back to its subject", () => { const r = buildRebasePlan([row("aaaaaa", "first", "reword", " ")]); assert.ok(r.ok); - assert.deepEqual(r.rewordMessages, ["first"]); + assert.deepEqual(r.rewords, [{ sha: "aaaaaa", message: "first" }]); }); test("the squash guard applies to the OLDEST commit — the bottom row on screen", () => { diff --git a/packages/git-service/test/rebasePlanUpdateRefs.test.ts b/packages/git-service/test/rebasePlanUpdateRefs.test.ts index 0e2c4d2..96cfb4f 100644 --- a/packages/git-service/test/rebasePlanUpdateRefs.test.ts +++ b/packages/git-service/test/rebasePlanUpdateRefs.test.ts @@ -97,3 +97,64 @@ test("an ordinary branch name still gets through", () => { assert.ok(r.ok); assert.ok(r.todo.includes("update-ref refs/heads/feature/some-work")); }); + +/** + * An update-ref line must sit after the LAST row that folds into its commit. + * + * git records the ref the moment it reaches the line, and a squash or fixup + * below then AMENDS the commit the branch was just pointed at — so the branch + * ends on a commit that is no longer in the rewritten history, which is exactly + * the orphaning `--update-refs` exists to prevent. git's own `--autosquash` + * emits `pick c2 / fixup fixup!c2 / update-ref …`, after the fold. + * + * `drop` is transparent to that scan: a `drop` between the pick and the fixup + * does not stop the fixup folding into the same commit. + */ +test("update-ref follows the fold, not the pick", () => { + const rows = [ + { action: "pick" as const, sha: "aaa1111", subject: "c1" }, + { action: "pick" as const, sha: "bbb2222", subject: "c2", branches: ["feature"] }, + { action: "fixup" as const, sha: "ccc3333", subject: "c3" }, + ]; + // Display order is newest first; buildRebasePlan reverses into git's todo. + const built = buildRebasePlan([...rows].reverse(), { updateRefs: true }); + assert.equal(built.ok, true); + if (!built.ok) return; + const lines = built.todo.trim().split("\n"); + const ref = lines.findIndex((l) => l.startsWith("update-ref")); + const fixup = lines.findIndex((l) => l.startsWith("fixup")); + assert.ok(ref > fixup, `update-ref must come after the fixup it folds into:\n${built.todo}`); +}); + +test("a drop between the pick and its fixup does not detach the update-ref", () => { + const rows = [ + { action: "pick" as const, sha: "bbb2222", subject: "c2", branches: ["feature"] }, + { action: "drop" as const, sha: "ccc3333", subject: "c3" }, + { action: "fixup" as const, sha: "ddd4444", subject: "c4" }, + ]; + const built = buildRebasePlan([...rows].reverse(), { updateRefs: true }); + assert.equal(built.ok, true); + if (!built.ok) return; + const lines = built.todo.trim().split("\n"); + const ref = lines.findIndex((l) => l.startsWith("update-ref")); + const fixup = lines.findIndex((l) => l.startsWith("fixup")); + assert.ok(ref > fixup, `a dropped row must not end the fold run:\n${built.todo}`); +}); + +test("a dropped commit's own branch gets no update-ref at all", () => { + // Alongside a surviving pick — a plan that drops everything is refused on + // its own grounds, which would make this assert nothing. + const built = buildRebasePlan( + [ + { action: "drop" as const, sha: "eee5555", subject: "gone", branches: ["stranded"] }, + { action: "pick" as const, sha: "fff6666", subject: "kept" }, + ], + { updateRefs: true }, + ); + assert.equal(built.ok, true); + if (!built.ok) return; + assert.ok( + !built.todo.includes("update-ref"), + "a commit that is not in the rewritten history has nowhere for its branch to point", + ); +}); diff --git a/packages/git-service/test/rebaseUpdateRefsE2E.test.ts b/packages/git-service/test/rebaseUpdateRefsE2E.test.ts index c73661e..ef04307 100644 --- a/packages/git-service/test/rebaseUpdateRefsE2E.test.ts +++ b/packages/git-service/test/rebaseUpdateRefsE2E.test.ts @@ -1,7 +1,8 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { removeTempRepo } from "./tmpRepo"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildRebasePlan } from "../src/rebasePlan"; @@ -43,7 +44,7 @@ test("end to end: the plan builder's todo really moves the branches", async () = const out = await runRebasePlan(d, { base: "main~3", todo: plan.todo, - rewordMessages: plan.rewordMessages, + rewords: plan.rewords, }); assert.equal(out.status, "done", JSON.stringify(out)); @@ -59,7 +60,7 @@ test("end to end: the plan builder's todo really moves the branches", async () = console.log(" main:", g(["log", "--format=%s", "main"]).split("\n").join(" ")); console.log(" feat-1 and feat-2 both followed onto the rewritten commits"); } finally { - try { rmSync(d, { recursive: true, force: true, maxRetries: 20, retryDelay: 120 }); } + try { removeTempRepo(d); } catch { /* git background processes still holding the dir; the temp dir is disposable */ } } }); diff --git a/packages/git-service/test/renameTracking.test.ts b/packages/git-service/test/renameTracking.test.ts index b16c84b..1f9654f 100644 --- a/packages/git-service/test/renameTracking.test.ts +++ b/packages/git-service/test/renameTracking.test.ts @@ -1,7 +1,8 @@ import { test, before, after, beforeEach } from "node:test"; import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { removeTempRepo } from "./tmpRepo"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { GitContext } from "../src/GitContext"; @@ -55,7 +56,7 @@ before(() => { gitIn(seed, ["config", "commit.gpgsign", "false"]); commitIn(seed, "file.txt", "base\n", "base"); gitIn(seed, ["push", "origin", "main"]); - rmSync(seed, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + removeTempRepo(seed); clone = mkdtempSync(join(tmpdir(), "gitstudio-rn-clone-")); execFileSync("git", ["clone", bare, clone], { env: ENV }); @@ -70,7 +71,7 @@ after(() => { ctx?.dispose(); for (const dir of [bare, clone]) { if (dir) { - rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + removeTempRepo(dir); } } }); diff --git a/packages/git-service/test/reorderE2E.test.ts b/packages/git-service/test/reorderE2E.test.ts index 591c478..1dc4eef 100644 --- a/packages/git-service/test/reorderE2E.test.ts +++ b/packages/git-service/test/reorderE2E.test.ts @@ -1,7 +1,8 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { removeTempRepo } from "./tmpRepo"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { readRewritableChain } from "../src/rebaseChain"; @@ -49,7 +50,7 @@ function repo(): Repo { dispose: () => { ctx.dispose(); try { - rmSync(dir, { recursive: true, force: true, maxRetries: 20, retryDelay: 120 }); + removeTempRepo(dir); } catch { /* git background processes may still hold it; the temp dir is disposable */ } @@ -84,7 +85,7 @@ async function applyReorder( return runRebasePlan(r.dir, { base: chain.base ?? "--root", todo: built.todo, - rewordMessages: built.rewordMessages, + rewords: built.rewords, }); } @@ -148,7 +149,7 @@ test("published commits are untouched — the chain never included them", async "the published commit keeps its identity — it was never rewritten", ); } finally { - rmSync(bare, { recursive: true, force: true, maxRetries: 20, retryDelay: 120 }); + removeTempRepo(bare); } } finally { r.dispose(); @@ -293,7 +294,7 @@ test("naming the branch being rebased in update-ref breaks the whole rebase", as const out = await runRebasePlan(r.dir, { base: chain.base ?? "--root", todo: built.todo, - rewordMessages: built.rewordMessages, + rewords: built.rewords, }); assert.notEqual( out.status, diff --git a/packages/git-service/test/rewordKeying.test.ts b/packages/git-service/test/rewordKeying.test.ts new file mode 100644 index 0000000..cb497b6 --- /dev/null +++ b/packages/git-service/test/rewordKeying.test.ts @@ -0,0 +1,126 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { writeFileSync, mkdtempSync } from "node:fs"; +import { removeTempRepo } from "./tmpRepo"; +import { tmpdir } from "node:os"; +import { buildRebasePlan } from "../src/rebasePlan"; +import { runRebasePlan, continueRebase } from "../src/RebaseRunner"; + +/** + * Reword messages are selected BY COMMIT, and an unkeyed entry matches nothing. + * + * The installer looks a message up by the sha in git's own `rebase-merge/done`. + * A compatibility shim once minted `{ sha: "", message }` for callers still on + * the positional shape — and `anySha.startsWith("")` is true, so slot 0 matched + * EVERY commit: every reword got the first message and commits nobody reworded + * were renamed. Measured on the extension's shape at the time: + * + * desktop (keyed) LOG: RENAMED-C | B | RENAMED-A (correct) + * extension (unkeyed) LOG: RENAMED-A | B | RENAMED-A (wrong) + * + * `RebasePlan.rewords` is required now, so the compiler finds a caller left + * behind — and this pins the other half: an entry without a real key is inert + * rather than universal. + */ +function repo(): { root: string; git: (...a: string[]) => string } { + const root = mkdtempSync(`${tmpdir()}/gs-rewordkey-`); + const git = (...a: string[]): string => execFileSync("git", a, { cwd: root }).toString(); + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + for (const n of ["m1", "A", "B", "C"]) { + writeFileSync(`${root}/${n}.txt`, `${n}\n`); + git("add", "-A"); + git("commit", "-qm", n); + if (n === "m1") git("branch", "trunk"); + } + return { root, git }; +} + +const shas = (root: string): Record<string, string> => { + const out: Record<string, string> = {}; + for (const line of execFileSync("git", ["log", "--format=%H %s", "trunk..HEAD"], { cwd: root }) + .toString() + .trim() + .split("\n")) { + const [sha, ...rest] = line.split(" "); + out[rest.join(" ")] = sha; + } + return out; +}; + +test("two rewords land on their own commits, not both on the first", async () => { + const { root, git } = repo(); + try { + const s = shas(root); + // Display order is newest-first. + const built = buildRebasePlan([ + { sha: s.C, action: "reword", subject: "C", message: "RENAMED-C" }, + { sha: s.B, action: "pick", subject: "B" }, + { sha: s.A, action: "reword", subject: "A", message: "RENAMED-A" }, + ]); + assert.ok(built.ok, built.ok ? "" : built.message); + + const out = await runRebasePlan(root, { base: "trunk", todo: built.todo, rewords: built.rewords }); + assert.equal(out.status, "done", out.status === "done" ? "" : out.message); + assert.deepEqual( + git("log", "--format=%s", "trunk..HEAD").trim().split("\n"), + ["RENAMED-C", "B", "RENAMED-A"], + "each message on its own commit, and B untouched", + ); + } finally { + removeTempRepo(root); + } +}); + +test("an entry with no real key renames nothing — not even a conflicted pick", async () => { + // The editor only runs for a `reword`… and for whatever commit a rebase + // STOPS on, when the user continues. That is the shape the wildcard hit: a + // plain `pick` that conflicted, continued, and came back renamed. + const root = mkdtempSync(`${tmpdir()}/gs-rewordkey2-`); + try { + const git = (...a: string[]): string => execFileSync("git", a, { cwd: root }).toString(); + git("init", "-q"); + git("config", "user.email", "t@t"); + git("config", "user.name", "t"); + git("config", "gc.auto", "0"); + writeFileSync(`${root}/shared.txt`, "base\n"); + git("add", "-A"); + git("commit", "-qm", "m1"); + git("branch", "trunk"); + git("checkout", "-qb", "feature"); + writeFileSync(`${root}/shared.txt`, "feature\n"); + git("commit", "-qam", "MY-REAL-MESSAGE"); + git("checkout", "-q", "trunk"); + writeFileSync(`${root}/shared.txt`, "trunk\n"); + git("commit", "-qam", "m2"); + git("checkout", "-q", "feature"); + + const sha = git("rev-parse", "HEAD").trim(); + const built = buildRebasePlan([{ sha, action: "pick", subject: "MY-REAL-MESSAGE" }]); + assert.ok(built.ok, built.ok ? "" : built.message); + + // The shape the compatibility shim used to produce. + const out = await runRebasePlan(root, { + base: "trunk", + todo: built.todo, + rewords: [{ sha: "", message: "WILDCARD" }], + }); + assert.equal(out.status, "stopped", "it conflicts"); + + writeFileSync(`${root}/shared.txt`, "resolved\n"); + git("add", "shared.txt"); + const cont = await continueRebase(root); + assert.equal(cont.status, "done", cont.status === "done" ? "" : cont.message); + + assert.equal( + git("log", "-1", "--format=%s", "HEAD").trim(), + "MY-REAL-MESSAGE", + "an unkeyed message is inert — the commit keeps its own", + ); + } finally { + removeTempRepo(root); + } +}); diff --git a/packages/git-service/test/snapshot.test.ts b/packages/git-service/test/snapshot.test.ts index 76b4a46..76dcc01 100644 --- a/packages/git-service/test/snapshot.test.ts +++ b/packages/git-service/test/snapshot.test.ts @@ -1,7 +1,8 @@ import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { mkdtempSync, writeFileSync, readFileSync } from "node:fs"; +import { removeTempRepo } from "./tmpRepo"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { GitContext } from "../src/GitContext"; @@ -51,7 +52,7 @@ before(() => { after(() => { ctx?.dispose(); - rmSync(repo, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + removeTempRepo(repo); }); test("capture records HEAD and the current branch on a clean tree", async () => { @@ -117,7 +118,7 @@ test("isPushed is true once a commit is on a remote-tracking branch", async () = const sha = head(); assert.equal(await ctx.snapshot.isPushed(sha), true); } finally { - rmSync(remote, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + removeTempRepo(remote); } }); diff --git a/packages/git-service/test/staging.test.ts b/packages/git-service/test/staging.test.ts index b0a0e51..ae2a82e 100644 --- a/packages/git-service/test/staging.test.ts +++ b/packages/git-service/test/staging.test.ts @@ -1,7 +1,8 @@ import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { mkdtempSync, writeFileSync, readFileSync } from "node:fs"; +import { removeTempRepo } from "./tmpRepo"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { GitContext } from "../src/GitContext"; @@ -60,7 +61,7 @@ before(() => { after(() => { ctx?.dispose(); if (repo) { - rmSync(repo, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + removeTempRepo(repo); } }); @@ -202,6 +203,6 @@ test("unstageFile on an initial commit (no HEAD) falls back to rm --cached", asy freshCtx.dispose(); } } finally { - rmSync(fresh, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + removeTempRepo(fresh); } }); diff --git a/packages/git-service/test/stash.test.ts b/packages/git-service/test/stash.test.ts index bbc6dc4..d85a6f1 100644 --- a/packages/git-service/test/stash.test.ts +++ b/packages/git-service/test/stash.test.ts @@ -1,7 +1,8 @@ import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { mkdtempSync, writeFileSync, readFileSync } from "node:fs"; +import { removeTempRepo } from "./tmpRepo"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { GitContext } from "../src/GitContext"; @@ -46,7 +47,7 @@ before(() => { after(() => { ctx?.dispose(); if (repo) { - rmSync(repo, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + removeTempRepo(repo); } }); diff --git a/packages/git-service/test/status.test.ts b/packages/git-service/test/status.test.ts index 063fb3d..31532cd 100644 --- a/packages/git-service/test/status.test.ts +++ b/packages/git-service/test/status.test.ts @@ -1,7 +1,8 @@ import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { removeTempRepo } from "./tmpRepo"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { GitContext } from "../src/GitContext"; @@ -113,7 +114,7 @@ before(() => { after(() => { ctx.dispose(); - rmSync(repo, { recursive: true, force: true }); + removeTempRepo(repo); }); test("StatusProvider.read on a real repo groups correctly", async () => { diff --git a/packages/git-service/test/sync.test.ts b/packages/git-service/test/sync.test.ts index afe4ffb..0033a4b 100644 --- a/packages/git-service/test/sync.test.ts +++ b/packages/git-service/test/sync.test.ts @@ -1,7 +1,8 @@ import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { removeTempRepo } from "./tmpRepo"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { GitContext } from "../src/GitContext"; @@ -47,7 +48,7 @@ before(() => { gitIn(seed, ["config", "commit.gpgsign", "false"]); commitIn(seed, "file.txt", "base\n", "base"); gitIn(seed, ["push", "origin", "main"]); - rmSync(seed, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + removeTempRepo(seed); // The clone under test. clone = mkdtempSync(join(tmpdir(), "gitstudio-clone-")); @@ -65,7 +66,7 @@ after(() => { ctx?.dispose(); for (const dir of [bare, clone]) { if (dir) { - rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + removeTempRepo(dir); } } }); @@ -105,7 +106,7 @@ test("aheadBehind reports behind after the remote advances", async () => { gitIn(other, ["config", "commit.gpgsign", "false"]); commitIn(other, "remote.txt", "r\n", "remote 1"); gitIn(other, ["push", "origin", "main"]); - rmSync(other, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + removeTempRepo(other); // Fetch so our remote-tracking ref sees the new commit. const fetched = await ctx.sync.fetch({ prune: true }); diff --git a/packages/git-service/test/worktree.test.ts b/packages/git-service/test/worktree.test.ts index 9252517..f852319 100644 --- a/packages/git-service/test/worktree.test.ts +++ b/packages/git-service/test/worktree.test.ts @@ -2,6 +2,7 @@ import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import { mkdtempSync, rmSync, writeFileSync, existsSync } from "node:fs"; +import { removeTempRepo } from "./tmpRepo"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { GitContext } from "../src/GitContext"; @@ -37,7 +38,7 @@ before(() => { after(() => { ctx?.dispose(); if (repo) { - rmSync(repo, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + removeTempRepo(repo); } }); @@ -51,7 +52,7 @@ test("list reports the main worktree", async () => { test("add creates a linked worktree on a new branch, then list shows both", async () => { const wtPath = mkdtempSync(join(tmpdir(), "gitstudio-wt-linked-")); - rmSync(wtPath, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); // git wants a non-existent path + removeTempRepo(wtPath); // git wants a non-existent path const added = await ctx.worktrees.add(wtPath, "feature", { newBranch: true, @@ -83,7 +84,7 @@ test("remove deletes the linked worktree", async () => { assert.equal(after.length, 1); assert.equal(after[0].branch, "main"); - rmSync(linked!.path, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + removeTempRepo(linked!.path); }); test("parseWorktreePorcelain handles bare, detached, locked, and prunable", () => { @@ -134,7 +135,7 @@ test("a differently-named branch from a remote start point gets no upstream unle try { const noTrackPath = mkdtempSync(join(tmpdir(), "gitstudio-wt-notrack-")); paths.push(noTrackPath); - rmSync(noTrackPath, { recursive: true, force: true }); // git wants a fresh path + removeTempRepo(noTrackPath); // git wants a fresh path const added = await ctx.worktrees.add(noTrackPath, "my-experiment", { newBranch: true, startPoint: "refs/remotes/origin/main", @@ -148,7 +149,7 @@ test("a differently-named branch from a remote start point gets no upstream unle // branch — which is why callers must decide noTrack themselves. const trackPath = mkdtempSync(join(tmpdir(), "gitstudio-wt-track-")); paths.push(trackPath); - rmSync(trackPath, { recursive: true, force: true }); + removeTempRepo(trackPath); const control = await ctx.worktrees.add(trackPath, "control-track", { newBranch: true, startPoint: "refs/remotes/origin/main", @@ -161,8 +162,8 @@ test("a differently-named branch from a remote start point gets no upstream unle ); } finally { for (const p of paths) { - rmSync(p, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + removeTempRepo(p); } - rmSync(remoteDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + removeTempRepo(remoteDir); } }); diff --git a/packages/host-bridge/src/git.ts b/packages/host-bridge/src/git.ts index 86061e3..58cd804 100644 --- a/packages/host-bridge/src/git.ts +++ b/packages/host-bridge/src/git.ts @@ -53,6 +53,26 @@ export interface GitRef { ahead?: number; /** Commits behind the upstream (from `%(upstream:track)`), when tracked. */ behind?: number; + /** + * The upstream this ref tracked NO LONGER EXISTS (git's `[gone]`). + * + * Distinct from untracked: `ahead`/`behind` are both absent in either case, + * so without this a branch whose remote was deleted is indistinguishable from + * one in perfect sync. + */ + gone?: boolean; + /** Tip commit date, epoch seconds — every kind of ref has one. */ + date?: number; + /** Tip commit subject. A remote branch or a tag with only a name and a sha + * cannot be told apart from its neighbours at a glance. */ + subject?: string; + /** "tag" for an ANNOTATED tag (it is its own object), "commit" otherwise. + * The one fact that distinguishes the two kinds of tag, and nothing has ever + * carried it. */ + objectType?: string; + /** What a symbolic ref points at — `refs/remotes/origin/HEAD` names the + * repository's DEFAULT branch, for free, on a read that already runs. */ + symref?: string; } export interface RepoHead { diff --git a/packages/host-bridge/src/scrub.ts b/packages/host-bridge/src/scrub.ts index e655a78..23c74f4 100644 --- a/packages/host-bridge/src/scrub.ts +++ b/packages/host-bridge/src/scrub.ts @@ -9,9 +9,10 @@ /** * Remove anything that could identify a user or their work: private keys, home - * dirs, absolute paths (POSIX, Windows, and UNC) INCLUDING the file/project - * names in the tail, remote URLs (creds AND org/repo), SSH remotes, emails, IPs, - * JWTs, cloud/access tokens, and SHAs. + * dirs, absolute paths (POSIX, Windows, and UNC — with or without spaces in + * them) INCLUDING the file/project names in the tail, remote URLs (creds AND + * org/repo), SSH remotes, emails, IPv4 and IPv6 addresses, JWTs, cloud/access + * tokens, and SHAs. * * Order matters — each step assumes the earlier ones already ran: * - private-key blocks are nuked whole, before anything can partially match; @@ -44,8 +45,14 @@ export function scrub(input: string): string { .replace(/[\w.+-]+@[\w-]+\.[\w.-]+/g, "<email>") // POSIX home/user paths: anonymize the user AND redact the tail (file and // project names), keeping any :line:col suffix (the tail stops at ':'). + // + // The tail accepts a BACKSLASH too. `safeHome()` collapses the user's home + // to `~` before this runs, and on Windows that home is followed by `\`, not + // `/` — so a forward-slash-only tail left every Windows crash stack + // reporting `~\Projects\acme-secret\src\billing.ts`: the project and file + // names this function's contract says it removes. .replace( - /(~|\/Users\/[^/\s"':]+|\/home\/[^/\s"':]+)(\/[^\s"':]*)?/g, + /(~|\/Users\/[^/\s"':]+|\/home\/[^/\s"':]+)([/\\][^\s"':]*)?/g, (_m, prefix: string, tail: string | undefined) => { const p = prefix.startsWith("/Users/") ? "/Users/<user>" @@ -58,8 +65,33 @@ export function scrub(input: string): string { // Windows drive paths and UNC paths -> redact whole (keeps :line:col) .replace(/\b[A-Za-z]:\\[^\s"':]+/g, "<path>") .replace(/\\\\[^\s"':]+/g, "<path>") + // Env-var-rooted Windows paths (%USERPROFILE%\Projects\x) — the variable + // name is not identifying, everything after it is. + .replace(/(%[A-Za-z_][A-Za-z0-9_]*%)[/\\][^\s"':]*/g, "$1\\<path>") + // A path can contain SPACES — "C:\\Users\\John Smith\\…", "\\\\FS01\\Team Share\\…", + // "/Users/John Smith/…" — and every pattern above stops at the first one, + // leaving the surname and the whole project path in the report. Redact what + // trails a marker ONLY when it still contains a separator, so a genuine + // sentence ("/Users/bob is not a repository") keeps its words. + .replace(/(<user>|<path>|~)((?: [^\s"':]+)+)/g, (_m, tag: string, rest: string) => + /[/\\]/.test(rest) ? `${tag}/<path>` : `${tag}${rest}`, + ) // IPv4 addresses .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "<ip>") + // IPv6 — the full eight-group form, and the compressed form which must + // actually contain `::`. + // + // Deliberately NOT "two or more colon-separated hex groups": that redacts + // every 01:23:45 timestamp in a log, and — worse here — the `:42:5` line + // and column this function goes out of its way to preserve so a crash stack + // stays locatable. The compressed rule therefore requires a literal `::` + // ahead of it, and refuses to start immediately after a word character, so + // `billing.ts:42:5` is never a candidate in the first place. + .replace(/\b(?:[0-9a-f]{1,4}:){7}[0-9a-f]{1,4}\b/gi, "<ip>") + .replace( + /(?<![\w:])(?=[0-9a-f:]{0,45}::)[0-9a-f]{0,4}(?::[0-9a-f]{0,4}){2,7}(?![\w:])/gi, + "<ip>", + ) // JWTs (always start with the base64 of `{"` -> eyJ) .replace(/\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}/g, "<jwt>") // AWS access key ids @@ -110,6 +142,40 @@ export function scrubGitMessage(input: string): string { ); } +/** + * Redact CREDENTIALS only, keeping everything else readable. + * + * `scrub()` is for crash reports and is deliberately merciless — it removes + * paths, repo names and hosts, which is right when the text is leaving the + * machine and wrong when it is the app's own git-command log. That log is a + * surface the user is invited to read, copy and paste into a bug report, and + * `git remote add origin https://user:ghp_…@github.com/org/repo` puts a token + * straight into it. + * + * So this keeps the command legible and takes out only the secret: the + * password half of a URL's userinfo, and any bare GitHub token. + */ +export function redactCredentials(input: string): string { + if (!input) { + return ""; + } + return ( + input + // scheme://user:secret@host -> scheme://user:***@host. The user half + // stays: it is usually "oauth2" or "x-access-token" and knowing which + // is the point of reading the log at all. + .replace( + /(\b[a-z][a-z0-9+.-]*:\/\/)([^/\s:@"']+):([^/\s@"']+)@/gi, + (_m, scheme: string, user: string) => `${scheme}${user}:***@`, + ) + // scheme://secret@host — userinfo with no colon is itself the token. + .replace(/(\b[a-z][a-z0-9+.-]*:\/\/)([^/\s:@"']+)@/gi, "$1***@") + // Bare GitHub tokens, wherever they appear (argv, stderr, a header echo). + .replace(/\bgh[posur]_[A-Za-z0-9]{16,}/g, "<token>") + .replace(/\bgithub_pat_[A-Za-z0-9_]{20,}/g, "<token>") + ); +} + export function safeHome(): string { const proc = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process; return proc?.env?.HOME || proc?.env?.USERPROFILE || ""; diff --git a/packages/host-bridge/test/scrub.test.ts b/packages/host-bridge/test/scrub.test.ts index 8d8e01b..77815ae 100644 --- a/packages/host-bridge/test/scrub.test.ts +++ b/packages/host-bridge/test/scrub.test.ts @@ -1,7 +1,7 @@ import { strict as assert } from "node:assert"; import { test } from "node:test"; import * as os from "node:os"; -import { randomId, safeShort, scrub, scrubExtra, scrubGitMessage } from "../src/scrub"; +import { randomId, safeShort, scrub, scrubExtra, scrubGitMessage, redactCredentials } from "../src/scrub"; // The scrubber is the last line of defense before an anonymous crash report // leaves a user's machine, so every identifying shape it must catch is pinned @@ -132,3 +132,123 @@ test("scrubGitMessage redacts a conflicted path", () => { test("scrubGitMessage is empty-safe", () => { assert.equal(scrubGitMessage(""), ""); }); + +// ── paths that contain spaces, and the Windows tail ────────────────────────── +// +// The contract above the function says it removes absolute paths "INCLUDING the +// file/project names in the tail". It did not, in the two places real users +// actually live: Windows, and any path with a space in it. + +test("a Windows crash stack from the user's own machine keeps nothing but the line", () => { + // safeHome() collapses the home directory to `~` first, and on Windows what + // follows it is a BACKSLASH. The tail rule only accepted a forward slash, so + // every Windows report shipped the project and file names intact. + const home = process.env.USERPROFILE; + process.env.USERPROFILE = "C:\\Users\\John Smith"; + const prevHome = process.env.HOME; + delete process.env.HOME; + try { + const out = scrub( + "at load (C:\\Users\\John Smith\\Projects\\acme-secret\\src\\billing.ts:42:11)", + ); + assert.equal(out.includes("acme-secret"), false, "the project name must not survive"); + assert.equal(out.includes("billing"), false, "nor the file name"); + assert.equal(out.includes("John"), false, "nor the user"); + assert.match(out, /:42:11/, "but the line and column stay, so the crash is locatable"); + } finally { + if (home === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = home; + if (prevHome !== undefined) process.env.HOME = prevHome; + } +}); + +test("a space in a path does not end the redaction", () => { + for (const input of [ + "at x (C:\\Users\\John Smith\\projects\\acme-secret\\index.ts:12:5)", + "/Users/John Smith/Work/AcmeCorp/secret.ts", + "\\\\CORP-FS01\\Team Share\\acme\\plan.docx", + ]) { + const out = scrub(input); + for (const secret of ["Smith", "acme", "Acme", "AcmeCorp", "secret", "plan"]) { + assert.equal( + out.includes(secret), + false, + `"${secret}" survived scrubbing of ${JSON.stringify(input)} -> ${JSON.stringify(out)}`, + ); + } + } +}); + +test("but a sentence after a path keeps its words", () => { + // The space rule must only swallow a trailing run that is actually a path — + // otherwise diagnostics turn into "<user>/<path>" and say nothing. + const out = scrub("/Users/bob is not a repository"); + assert.match(out, /is not a repository/); +}); + +test("an env-var-rooted Windows path redacts everything after the variable", () => { + const out = scrub("%USERPROFILE%\\Documents\\AcmeSecret"); + assert.equal(out.includes("AcmeSecret"), false); + assert.match(out, /%USERPROFILE%/, "the variable name itself identifies nobody"); +}); + +// ── IPv6 ───────────────────────────────────────────────────────────────────── + +test("IPv6 addresses are redacted, in both forms", () => { + assert.match(scrub("connect to 2001:0db8:85a3:0000:0000:8a2e:0370:7334 failed"), /<ip>/); + assert.match(scrub("bound ::1"), /<ip>/); + assert.match(scrub("host fe80::1 unreachable"), /<ip>/); +}); + +test("IPv6 redaction does not eat timestamps or line:col", () => { + // The loose "colon-separated hex groups" reading of IPv6 destroys both, and + // line:col is the one thing a crash report has to keep. + assert.match(scrub("at 01:23:45 the build failed"), /01:23:45/); + assert.match(scrub("~/x/y.ts:42:5"), /:42:5/); + assert.match(scrub("took 1:30:00"), /1:30:00/); +}); + +// ── redactCredentials: the git-command log ─────────────────────────────────── +// +// A different job from scrub(). That log is shown to the user, so it has to +// stay readable; only the secret comes out. + +test("a password in a remote URL is redacted, the rest of the command survives", () => { + const out = redactCredentials( + "git remote add origin https://oauth2:ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@github.com/Acme/repo.git", + ); + assert.equal(out.includes("ghp_"), false, "the token must not survive"); + assert.match(out, /oauth2:\*\*\*@github\.com/, "but WHICH user, and which host, still read"); + assert.match(out, /Acme\/repo\.git/, "and the repo, or the log says nothing useful"); + assert.match(out, /^git remote add origin/, "and the command itself"); +}); + +test("userinfo with no colon is treated as the secret", () => { + const out = redactCredentials("fetch https://ghp_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB@github.com/x/y"); + assert.equal(out.includes("ghp_"), false); + assert.match(out, /https:\/\/\*\*\*@github\.com\/x\/y/); +}); + +test("a bare token anywhere is redacted", () => { + for (const t of [ + "ghp_CCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", + "gho_DDDDDDDDDDDDDDDDDDDDDDDDDDDDDD", + "github_pat_EEEEEEEEEEEEEEEEEEEEEE", + ]) { + const out = redactCredentials(`remote: bad credentials for ${t}`); + assert.equal(out.includes(t), false, `${t} survived`); + assert.match(out, /<token>/); + } +}); + +test("an ordinary command is left completely alone", () => { + for (const cmd of [ + "git status --porcelain=v1 -z", + "git log --format=%H -n 50 main", + "git remote add origin https://github.com/Acme/repo.git", + "git push origin feature/x", + "git clone git@github.com:Acme/repo.git", + ]) { + assert.equal(redactCredentials(cmd), cmd, `${cmd} was altered`); + } +}); diff --git a/packages/webview-ui/src/diffView.ts b/packages/webview-ui/src/diffView.ts index 0dc4577..b55fcf6 100644 --- a/packages/webview-ui/src/diffView.ts +++ b/packages/webview-ui/src/diffView.ts @@ -668,6 +668,17 @@ export class DiffView { this.right.focus(); } + /** + * Re-measure both editors now. + * + * The ResizeObserver already does this when the CONTAINER changes, but a host + * that resizes on a pointer drag wants the new width in the same frame as the + * drag, not on the observer's next delivery. + */ + public layout(): void { + for (const editor of this.editors) editor.layout(); + } + public dispose(): void { if (this.rediffTimer) { window.clearTimeout(this.rediffTimer); @@ -695,7 +706,28 @@ export class DiffView { this.viewSubs = []; this.zoneIds.clear(); for (const editor of this.editors) { - editor.getModel()?.dispose(); + // `editor.dispose()` ONLY. + // + // These editors were built with `monaco.editor.create(dom, { value, + // language })` — no model passed — so Monaco creates the model itself and + // the STANDALONE EDITOR OWNS IT (`_ownsModel`), disposing it in + // `_postDetachModelCleanup`. Disposing it here first re-entered Monaco's + // emitter (`onWillDispose` → `setModel(null)` → `_postDetachModelCleanup` + // → `dispose()` again) and threw + // `Cannot read properties of undefined (reading '0')` on EVERY teardown — + // every mode toggle, every file switch, every panel dispose — which + // aborted the rest of that emitter's listener delivery, including the + // model service's and the worker sync's unregistration. A diff editor + // whose worker sync was never unregistered is a diff editor whose worker + // can stop answering, which is what "the diff sometimes doesn't show" + // looked like from the outside. + // + // The asymmetry is real and worth stating: the INLINE path in + // desktop/diffPanel.ts calls `monaco.editor.createModel` itself and + // therefore must dispose those models by hand. Ownership follows who + // created the model, not who used it. If this ever switches to + // `create(dom, { model })`, the model becomes ours again and must be + // disposed AFTER `editor.dispose()`, never before. editor.dispose(); } this.editors = []; diff --git a/packages/webview-ui/src/graph/avatar.ts b/packages/webview-ui/src/graph/avatar.ts index afca592..3289be2 100644 --- a/packages/webview-ui/src/graph/avatar.ts +++ b/packages/webview-ui/src/graph/avatar.ts @@ -229,8 +229,12 @@ export function authorInitials(name: string, email: string): string { if (parts.length === 0) { return "?"; } + // Code POINTS, not UTF-16 units — `slice(0, 2)` / `[0]` cut an astral + // character in half and leave a lone surrogate, which paints as a tofu box. if (parts.length === 1) { - return parts[0].slice(0, 2).toUpperCase(); + return [...parts[0]].slice(0, 2).join("").toUpperCase(); } - return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); + const first = [...parts[0]][0] ?? ""; + const last = [...parts[parts.length - 1]][0] ?? ""; + return (first + last).toUpperCase(); } diff --git a/packages/webview-ui/src/graph/commit-graph.ts b/packages/webview-ui/src/graph/commit-graph.ts index 710dd08..b602234 100644 --- a/packages/webview-ui/src/graph/commit-graph.ts +++ b/packages/webview-ui/src/graph/commit-graph.ts @@ -26,6 +26,7 @@ import { fitRefsWidth, wantedRefsWidth, REF_CHIP_GAP, + legibleRefsWidth, } from "./refLayout"; import { Virtualizer, @@ -39,7 +40,7 @@ import type { WireRef, RowStat, } from "@gitstudio/host-bridge/graphProtocol"; -import { renderRowGutterSVG, laneCenterX } from "./gutter"; +import { renderRowGutterSVG, laneCenterX, lastDrawableLane } from "./gutter"; import { paletteForTheme, observeGraphTheme, @@ -49,6 +50,7 @@ import { avatarHue, authorInitials, } from "./avatar"; +import { COLUMN_DROP_TAIL_AT } from "../limits"; // ── Layout constants (the visual contract; tuned to GitLens proportions) ───── const ROW_HEIGHT = 34; @@ -72,8 +74,22 @@ const COMPACT_DROP_DATE_AT = 580; const COMPACT_DROP_AUTHOR_AT = 430; const SUBJECT_MIN_COMPACT_MID = 150; const SUBJECT_MIN_COMPACT_TIGHT = 120; -/** Below this, column mode drops its Date and SHA tracks. */ -const COLUMN_DROP_TAIL_AT = 760; +/** + * Below this host width, column mode drops its Date and SHA tracks. + * + * The threshold is set by what the REFS column needs when the tail comes back, + * not by when the tail itself starts to feel tight. At 760 the tail returned + * ~36px too early: crossing it (a 1296px window) collapsed the branch/tag track + * from 168px to 87px and rendered zero readable characters in it, so WIDENING + * the window made a column narrower and emptied it. That put the dead band over + * exactly the maximised-laptop widths, and nothing on screen said the column had + * been starved — it just looked empty. + * + * Date and SHA are the right things to give up for it: both are still on the + * row's hover tooltip and in the details dock, and both can be turned back on + * from the Columns menu. A branch name has nowhere else to be. + */ + /** `:host([compact]) .content .refs { max-width }` — a share of the MESSAGE track. */ const COMPACT_REFS_SHARE = 0.44; /** The sidebar rule's `.content .refs { max-width }` — a share of the row. */ @@ -180,6 +196,9 @@ export type GraphAction = | { type: "open"; sha: string } | { type: "context"; sha: string; x: number; y: number } | { type: "menuAction"; sha: string; id: string } + /** A ref chip (branch / remote / tag label) was clicked — hosts navigate to + * that ref instead of treating the click as a row selection. */ + | { type: "refClick"; sha: string; name: string; kind: string } | { type: "loadMore" } | { type: "refresh" } | { type: "requestStats"; shas: string[] } @@ -367,6 +386,14 @@ export class CommitGraph extends LitElement { color: var(--vscode-foreground); } .gh-refresh { margin-left: 2px; } + /* A refresh over an existing list keeps the list and says so here instead. */ + .gh-refresh.is-refreshing { opacity: 0.6; cursor: progress; } + .gh-refresh.is-refreshing .codicon { animation: gh-spin 1s linear infinite; } + @keyframes gh-spin { to { transform: rotate(360deg); } } + /* The app's global reduced-motion rule cannot reach into this shadow root. */ + @media (prefers-reduced-motion: reduce) { + .gh-refresh.is-refreshing .codicon { animation: none; } + } /* ── Anchored popover/menu shell (Columns + search scope share it) ────── */ .gh-anchor { position: relative; flex: 0 0 auto; display: inline-flex; } @@ -813,6 +840,33 @@ export class CommitGraph extends LitElement { :host(.col-dragging) .row, :host(.col-dragging) .colhead { user-select: none; } + /* Every cell is PINNED to its own track. + ------------------------------------- + The row is a seven-track grid and its cells used to be placed by source + order alone. Hiding a column sets its track to 0px (correct) and also + takes the cell out of the flow with display:none — at which point + every later cell slides up one track and lands in the wrong column. + Unchecking "Branch / Tag" gave the subject 9px and the changes column + 493px; unchecking "Date" made the SHA column vanish entirely while the + Columns menu still showed SHA as checked, which is a menu lying about + what is on screen. + With an explicit grid-column, removing a cell moves nothing. Compact + mode lays .content out as flex and is unaffected. */ + :host(:not([compact])) .row > .gutter, + :host(:not([compact])) .colhead .ch-graph { grid-column: 1; } + :host(:not([compact])) .row .refs, + :host(:not([compact])) .colhead .ch-refs { grid-column: 2; } + :host(:not([compact])) .row .subject, + :host(:not([compact])) .colhead .ch-subject { grid-column: 3; } + :host(:not([compact])) .row .changes, + :host(:not([compact])) .colhead .ch-changes { grid-column: 4; } + :host(:not([compact])) .row .meta.author, + :host(:not([compact])) .colhead .ch-author { grid-column: 5; } + :host(:not([compact])) .row .meta.date, + :host(:not([compact])) .colhead .ch-date { grid-column: 6; } + :host(:not([compact])) .row .meta.sha, + :host(:not([compact])) .colhead .ch-sha { grid-column: 7; } + /* ── Hidden columns: hide the cells/header (the track is collapsed to 0 on the inline :host style by applyColumnStyles, which outranks any saved width). These rules only remove the now-empty cells + their grip. */ @@ -1122,6 +1176,8 @@ export class CommitGraph extends LitElement { /* The "+N" overflow pill must never shrink or ellipsize — it's the count. */ /* "+2" is a footnote, not a peer of the branch chips — no box, just a quiet count so the eye lands on the actual ref names. */ + .chip[data-ref] { cursor: pointer; } + .chip[data-ref]:hover { filter: brightness(1.18); text-decoration: underline; } .chip-overflow { color: var(--vscode-descriptionForeground); background: transparent; @@ -1189,8 +1245,12 @@ export class CommitGraph extends LitElement { .changes .ch-count { display: inline-flex; align-items: center; + justify-content: flex-end; gap: 3px; - flex: 0 0 auto; + /* A fixed slot so 3, 11 and 17 right-align and every bar starts on the + same x — otherwise the meters step right as the counts get longer and + a column of proportions is no longer comparable at a glance. */ + flex: 0 0 34px; } .changes .ch-count .codicon { font-size: 12px; opacity: 0.75; } /* A slim proportional meter: length ~ size of the change (log scale), @@ -1486,6 +1546,9 @@ export class CommitGraph extends LitElement { private rowStats = new Map<string, RowStat>(); /** Shas whose stats have been requested but not yet returned. */ private pendingStats = new Set<string>(); + /** Shas the host ANSWERED for and had no stats for. Asking again returns the + * same nothing, and re-asking on every repaint is a request storm. */ + private readonly statsUnavailable = new Set<string>(); private loadMoreArmed = true; /** lane color the pointer is hovering, for the focus-dim affordance. */ private focusColor: number | undefined; @@ -1549,6 +1612,20 @@ export class CommitGraph extends LitElement { this.rebuildIndex(); // New page arrived: re-arm the loader so the next near-bottom fires. this.loadMoreArmed = true; + // …and re-run the live search over it. Row indices shift on append, so + // the old match list is stale as well as short. + // + // `searchMatches` is a plain field, and this runs in `updated()` — AFTER + // the render that the new rows triggered. So the header had already been + // painted from the old list, and nothing scheduled another paint: the + // counter stayed a page behind, and a query whose first page had no hits + // went on saying "No results" over rows it had just highlighted. Ask for + // one more render, and only when the answer actually moved. + if (this.searchQuery.trim()) { + const before = `${this.searchMatches.length}/${this.matchIdx}`; + this.rescanMatches(); + if (`${this.searchMatches.length}/${this.matchIdx}` !== before) this.requestUpdate(); + } } // The `.scroller` only exists once we leave the placeholder states, and a // status flip swaps the whole subtree. Lazily (re)bind the virtualizer to @@ -1615,6 +1692,15 @@ export class CommitGraph extends LitElement { for (let i = 0; i < this.rows.length; i++) { this.shaToIndex.set(this.rows[i].sha, i); } + // A selection that survives a row set it is no longer part of is a lie in + // three places at once. Refresh reloads from the FIRST page, so after + // paging deep and selecting something near the bottom, the selected sha was + // simply gone: no `.row.selected` anywhere in the DOM, `selectedSha` still + // set, and `aria-activedescendant` pointing at an id that does not exist — + // which a screen reader announces as a row that is not there. + if (this.selectedSha !== undefined && !this.shaToIndex.has(this.selectedSha)) { + this.selectedSha = undefined; + } } /** Gutter render width: capped columns × pitch + inset + avatar half-width. */ @@ -1629,6 +1715,16 @@ export class CommitGraph extends LitElement { ); } + /** + * The deepest lane whose NODE fits inside `width`. `.gutter` hides its + * overflow, so a node drawn past this is not merely cut in half — it is gone, + * and the commit renders as a text row with nothing in the graph beside it. + * Lanes past this fold onto it, marked. + */ + private maxDrawableColumn(width: number): number { + return lastDrawableLane(width, COL_WIDTH, NODE_INSET, NODE_RADIUS); + } + private applyGutterWidth(): void { // A user-dragged width wins over the lane-count auto-size (reset restores). const w = this.colWidths.graph ?? this.gutterWidth(); @@ -1834,10 +1930,15 @@ export class CommitGraph extends LitElement { // everything snaps into place only on pointerup. The keyboard resize path // has always re-rendered on every nudge; this makes the drag agree. // - // Only `refs` needs it — the gutter derives its width from the lane count, - // never from colWidths.graph. Coalesced to one frame because pointermove - // fires far faster than we can lay out rows. - if (d.id === "refs" && this.resizeRaf === 0) { + // `graph` needs it for the same reason now. That was not true when this was + // written — the gutter derived its width purely from the lane count — but + // the SVG is sized from `colWidths.graph` since deep lanes became + // draggable-to-reveal, so without a re-render the column widens while the + // rows keep their old canvas and the fold does not move. The feature that + // change was made FOR was inert during the drag. + // + // Coalesced to one frame: pointermove fires far faster than we lay out rows. + if ((d.id === "refs" || d.id === "graph") && this.resizeRaf === 0) { this.resizeRaf = requestAnimationFrame(() => { this.resizeRaf = 0; if (this.drag) this.renderRows(); @@ -1972,6 +2073,12 @@ export class CommitGraph extends LitElement { comfort: Math.min(SUBJECT_COMFORT_WIDTH, Math.round(host * 0.42)), min: spec.min, max: spec.max, + // The same floor a manual drag may squeeze the subject to. Refs yield to + // the subject's COMFORT first, then stop at the width where a ref name is + // still readable — rather than collapsing to a 60px track that can show + // only chrome. See fitRefsWidth. + subjectFloor: this.subjectFloor(), + legible: legibleRefsWidth(this.rows), }); this.autoRefs = { n: this.rows.length, host, w: fitted }; return fitted; @@ -2211,14 +2318,24 @@ export class CommitGraph extends LitElement { const total = v.getTotalSize(); sizer.style.height = `${total}px`; - const gutterW = this.gutterWidth(); + // The width the gutter is actually GIVEN — the same expression + // `applyGutterWidth` sets `--gs-gutter-w` from. It used to render at the + // capped auto-size regardless, so dragging the Graph column wider bought + // nothing but blank space: the SVG stayed 16 lanes wide and everything + // past it stayed clipped. + const gutterW = this.colWidths.graph ?? this.gutterWidth(); let lastIndex = -1; let htmlOut = ""; const needStats: string[] = []; for (const item of items) { lastIndex = Math.max(lastIndex, item.index); const row = this.rows[item.index]; - if (row && !this.rowStats.has(row.sha) && !this.pendingStats.has(row.sha)) { + if ( + row && + !this.rowStats.has(row.sha) && + !this.pendingStats.has(row.sha) && + !this.statsUnavailable.has(row.sha) + ) { needStats.push(row.sha); } htmlOut += this.rowHtml(item, gutterW); @@ -2273,6 +2390,33 @@ export class CommitGraph extends LitElement { this.renderRows(); } + /** + * Release a batch of stat requests that produced no answer. + * + * `renderRows` skips any sha still in `pendingStats`, and `setRowStats` only + * clears the ones it was actually GIVEN — so a batch that failed, or came + * back short, left those shas pending forever and their CHANGES cells blank + * for the rest of the session. + * + * The two cases are NOT the same, and treating them alike is a request storm: + * + * • `answered` — the host replied, and simply had nothing for these shas. + * That is a real answer ("no stats for this commit"), so record it and + * never ask again. Re-asking would produce the same nothing, forever. + * • rejected — the host errored. Worth another try, but NOT right now: + * clearing and re-rendering here would ask again immediately, get the + * same error, and clear and re-render again. The next natural repaint + * (a scroll, a resize) asks, which is bounded by the user. + * + * So this never calls `renderRows` itself. That call was the cycle. + */ + failRowStats(shas: readonly string[], answered = true): void { + for (const sha of shas) { + this.pendingStats.delete(sha); + if (answered) this.statsUnavailable.add(sha); + } + } + /** * Everything the author card shows, for one row. * @@ -2368,6 +2512,7 @@ export class CommitGraph extends LitElement { nodeInset: NODE_INSET, palette: this.palette, focusColor: this.focusColor, + maxColumn: this.maxDrawableColumn(gutterW), }, gutterW, ); @@ -2402,7 +2547,11 @@ export class CommitGraph extends LitElement { : `${row.shortSha}: ${row.subject} — ${row.author}, ${relTime(row.authorDate)}`, ); return ( - `<div class="${cls}" role="row" data-sha="${row.sha}" ` + + // The id is what `aria-activedescendant` on the grid points at. Selection + // lived in a class alone, so a screen reader on this grid heard nothing + // as you arrowed through history — every row carries a good aria-label + // and none of them was ever announced. + `<div class="${cls}" role="row" id="gs-row-${row.sha}" data-sha="${row.sha}" ` + (canReorder ? `title="Drag to reorder" ` : "") + (this.chainShas.length > 0 && this.isFirstInert(row.sha) ? `data-inert-why="${esc(stopReason(this.chainStop))}" ` @@ -2495,6 +2644,25 @@ export class CommitGraph extends LitElement { } return; } + // A ref chip is a LINK to that branch/tag, not a row selection — the + // labels used to be purely decorative, which made the graph's richest + // data its least useful. + const chip = target?.closest(".chip[data-ref]") as HTMLElement | null; + if (chip) { + const row = chip.closest(".row") as HTMLElement | null; + const name = chip.dataset.ref; + if (name && row?.dataset.sha) { + e.preventDefault(); + e.stopPropagation(); + this.onAction({ + type: "refClick", + sha: row.dataset.sha, + name, + kind: chip.dataset.kind ?? "head", + }); + return; + } + } const sha = this.rowShaFromEvent(e); if (!sha) { return; @@ -2773,9 +2941,23 @@ export class CommitGraph extends LitElement { this.selectedSha !== undefined ? this.shaToIndex.get(this.selectedSha) : undefined; - if (e.key === "ArrowDown" || e.key === "ArrowUp") { + // j / k alongside the arrows, the way every other list in this app answers + // and the way the app's own shortcut sheet has been promising ("↑ ↓ or + // j k — Move between rows"). This was the one list where the documented + // keys did nothing. Guarded on modifiers so ⌘J and friends still reach the + // window, and skipped while a text field has focus — the search box sits + // inside this component. + const typing = + e.target instanceof HTMLElement && + (e.target.tagName === "INPUT" || + e.target.tagName === "TEXTAREA" || + e.target.isContentEditable); + const plain = !e.metaKey && !e.ctrlKey && !e.altKey; + const down = e.key === "ArrowDown" || (plain && !typing && e.key === "j"); + const up = e.key === "ArrowUp" || (plain && !typing && e.key === "k"); + if (down || up) { e.preventDefault(); - const delta = e.key === "ArrowDown" ? 1 : -1; + const delta = down ? 1 : -1; const base = current ?? (delta > 0 ? -1 : this.rows.length); const next = Math.max(0, Math.min(this.rows.length - 1, base + delta)); this.select(this.rows[next].sha, true); @@ -2806,15 +2988,23 @@ export class CommitGraph extends LitElement { this.renderRows(); } - /** Public: select + center on a sha (e.g. the host revealing a commit). */ - reveal(sha: string): void { + /** + * Public: select + center on a sha (e.g. the host revealing a commit). + * + * Returns whether the row was actually found. The graph only holds the pages + * it has loaded, so a commit further back than that — which is most of the + * history in any real repository — cannot be revealed at all, and the caller + * needs to know rather than assume it worked. + */ + reveal(sha: string): boolean { const idx = this.shaToIndex.get(sha); if (idx === undefined) { - return; + return false; } this.selectedSha = sha; this.virtualizer?.scrollToIndex(idx, { align: "center" }); this.renderRows(); + return true; } // ── Search (highlight + navigate matches across loaded rows) ─────────────── @@ -2845,14 +3035,64 @@ export class CommitGraph extends LitElement { this.renderRows(); } + /** + * Re-scan for a NEW query or scope: highlight and count, but do not travel. + * + * It used to select the first match and scroll to it — on every keystroke. + * Selecting emits `{type: "select"}`, and the host answers by re-fetching the + * commit and replacing the details pane, so typing three characters into the + * search box threw away the diff you were reading, moved the selection three + * times and made three requests, before you had finished the word. + * + * `rescanMatches` right below already spells out the principle for appended + * pages — "that resets to the first match and scrolls there, which on every + * appended page would yank the view out from under someone reading". A + * keystroke is the same event, more often. Enter travels; typing paints. + */ private computeMatches(): void { - const q = this.searchQuery.trim().toLowerCase(); - this.searchMatches = []; - this.matchSet.clear(); + this.scanMatches(); + // -1, not 0: nothing is "the current match" until the reader asks for one, + // and `gotoMatch` maps -1 to the first (or last, going backwards). this.matchIdx = -1; - if (!q) { + } + + /** + * Re-scan the loaded rows for the current query, keeping the user's place. + * + * The graph pages, so the rows a search ran over are only the ones loaded at + * the time it was typed. Nothing re-scanned on append: the match counter + * froze at its first-page value, and every matching commit on every later + * page rendered as a NON-match — dimmed, uncounted, and unreachable with + * next/previous. On a repo of any size that is most of the answer, silently + * missing, while the counter states a total as fact. + * + * Deliberately not `computeMatches`: that resets to the first match and + * scrolls there, which on every appended page would yank the view out from + * under someone reading. The focused match is re-found by SHA instead, so + * "next" continues from where they actually are. + */ + private rescanMatches(): void { + const focused = this.searchMatches[this.matchIdx]; + const focusedSha = focused !== undefined ? this.rows[focused]?.sha : undefined; + this.scanMatches(); + if (!this.searchMatches.length) { + this.matchIdx = -1; return; } + const again = + focusedSha === undefined + ? -1 + : this.searchMatches.findIndex((i) => this.rows[i]?.sha === focusedSha); + this.matchIdx = again >= 0 ? again : 0; + } + + /** The scan itself: rebuild `searchMatches`/`matchSet`, touching nothing else. */ + private scanMatches(): void { + this.searchMatches = []; + this.matchSet.clear(); + this.matchIdx = -1; + const q = this.searchQuery.trim().toLowerCase(); + if (!q) return; const scope = this.searchScope; for (let i = 0; i < this.rows.length; i++) { if (this.rowMatches(this.rows[i], q, scope)) { @@ -2860,10 +3100,6 @@ export class CommitGraph extends LitElement { this.matchSet.add(i); } } - if (this.searchMatches.length) { - this.matchIdx = 0; - this.scrollToMatch(); - } } /** Whether a row matches the lowercased query under the chosen scope. */ @@ -2894,11 +3130,14 @@ export class CommitGraph extends LitElement { } private gotoMatch(delta: number): void { - if (!this.searchMatches.length) { + const n = this.searchMatches.length; + if (!n) { return; } + // From "no match chosen yet", forward means the first and backward the + // last — rather than the modular arithmetic's second-to-last. this.matchIdx = - (this.matchIdx + delta + this.searchMatches.length) % this.searchMatches.length; + this.matchIdx < 0 ? (delta > 0 ? 0 : n - 1) : (this.matchIdx + delta + n) % n; this.scrollToMatch(); } @@ -2929,10 +3168,23 @@ export class CommitGraph extends LitElement { ? "" : `${n.toLocaleString()}${this.hasMore ? "+" : ""} commit${n === 1 ? "" : "s"}`; const q = this.searchQuery.trim(); + // The search only sees the rows that are LOADED. Saying "No results" while + // more history is unread states as fact something we have not looked at — + // and "3/3" implies the search is finished when it is not. The "+" is the + // same honesty the commit count beside it already uses. const results = q ? this.searchMatches.length - ? `${this.matchIdx + 1}/${this.searchMatches.length}` - : "No results" + ? // Before you travel to one, `matchIdx` is -1 — there is no "current" + // match, because typing a query no longer moves you. Rendering that + // as `0/12` reads as a position, and the position it reads as is one + // that cannot exist: every other counter in the app is 1-based, so + // "0 of 12" says the search found nothing while listing twelve. + this.matchIdx < 0 + ? `${this.searchMatches.length.toLocaleString()}${this.hasMore ? "+" : ""} match${this.searchMatches.length === 1 ? "" : "es"}` + : `${this.matchIdx + 1}/${this.searchMatches.length}${this.hasMore ? "+" : ""}` + : this.hasMore + ? `No results in ${n.toLocaleString()} loaded` + : "No results" : ""; return html`<div class="gheader"> <span @@ -2948,7 +3200,7 @@ export class CommitGraph extends LitElement { aria-hidden="true" ></span> <span class="nm" - >${branch || (this.head ? this.head.slice(0, 8) : "detached HEAD")}</span + >${branch || (this.head ? this.head.slice(0, 8) : "no commits yet")}</span > </span> ${count ? html`<span class="gh-count">${count}</span>` : nothing} @@ -2992,8 +3244,9 @@ export class CommitGraph extends LitElement { </span> ${this.columnsControlHtml()} <button - class="gh-iconbtn gh-refresh" - title="Refresh" + class="gh-iconbtn gh-refresh${this.status === "loading" && this.rows.length ? " is-refreshing" : ""}" + title=${this.status === "loading" && this.rows.length ? "Refreshing…" : "Refresh"} + ?disabled=${this.status === "loading" && this.rows.length > 0} @click=${() => this.onAction({ type: "refresh" })} > <span class="codicon codicon-refresh"></span> @@ -3145,6 +3398,8 @@ export class CommitGraph extends LitElement { tabindex="0" role="grid" aria-label="Commit graph" + aria-rowcount=${this.rows.length} + aria-activedescendant=${this.selectedSha ? `gs-row-${this.selectedSha}` : nothing} @click=${this.onClick} @dblclick=${this.onDblClick} @contextmenu=${this.onContextMenu} @@ -3340,7 +3595,8 @@ function chipHtml(ref: WireRef, remotes: string[] = []): string { const also = remotes.length ? ` · also on ${esc(remotes.join(", "))}` : ""; const tip = esc(tipData([{ name: ref.name, kind: ref.kind, remotes }])); const attrs = (cls: string, what: string) => - `class="${cls}" data-more="${tip}" aria-label="${esc(ref.name)} (${what}${also})"`; + `class="${cls}" data-ref="${esc(ref.name)}" data-kind="${ref.kind}" ` + + `data-more="${tip}" role="button" aria-label="${esc(ref.name)} (${what}${also})"`; switch (ref.kind) { case "currentHead": // No leading dot: the filled accent already marks the current branch, and diff --git a/packages/webview-ui/src/graph/gutter.ts b/packages/webview-ui/src/graph/gutter.ts index a5c3147..97c9a9f 100644 --- a/packages/webview-ui/src/graph/gutter.ts +++ b/packages/webview-ui/src/graph/gutter.ts @@ -34,10 +34,54 @@ export interface GutterOptions { curveSpan?: number; /** Lane stroke width override, px (default 1.75). */ strokeWidth?: number; + /** + * Last lane that fits in `width`. Lanes beyond it are FOLDED onto it rather + * than drawn past the edge — the gutter clips its overflow, so an unclamped + * deep lane meant the commit had no node at all: a row of text with nothing + * in the graph, which reads as "this commit isn't in the history". Folded + * nodes are marked (see `foldedNode`) so a stacked lane is never mistaken for + * a real one. Omit for no clamping. + */ + maxColumn?: number; } /** Lane stroke width — thin enough to feel native, thick enough to read. */ const STROKE_WIDTH = 2.1; +/** + * How far right of a node's centre the FOLD marker reaches, in px, measured + * from the node's edge: the chevron starts 3.5px out, is 3.2px wide, and its + * 1.6px stroke adds a further 0.8px. + * + * Exported because the caller has to reserve this space when it decides which + * lane is the last one that fits. Reserving only the node's radius clipped the + * marker away at roughly one gutter width in four — and a folded node with no + * marker is indistinguishable from a real lane, which is worse than the + * clipping this whole mechanism exists to prevent. + */ +export const FOLD_MARKER_REACH = 3.5 + 3.2 + 0.8; + +/** + * The deepest lane whose node AND fold marker fit inside `width`. + * + * Lives here, beside the drawing it constrains, and is derived by asking + * `laneCenterX` itself rather than inverting it arithmetically — that function + * half-pixel-aligns (`round(...) + 0.5`), so a closed-form estimate disagreed + * with the real centre and clipped the marker at about one gutter width in + * four. One implementation, so the renderer and its caller cannot drift. + */ +export function lastDrawableLane( + width: number, + colWidth: number, + inset: number, + nodeRadius: number, +): number { + const reach = nodeRadius + FOLD_MARKER_REACH; + const est = Math.floor((width - inset - colWidth / 2 - reach) / colWidth) + 1; + for (let c = Math.max(0, est); c > 0; c--) { + if (laneCenterX(c, colWidth, inset) + reach <= width) return c; + } + return 0; +} /** Dimmed opacity for unrelated lanes when a lane is focused. */ const DIM_OPACITY = 0.2; @@ -129,7 +173,13 @@ export function renderRowGutterSVG( const { colWidth, rowHeight, nodeRadius, palette, focusColor } = opts; const inset = opts.nodeInset ?? 0; const strokeWidth = opts.strokeWidth ?? STROKE_WIDTH; - const cx = laneCenterX(row.column, colWidth, inset); + // Fold lanes deeper than the gutter can show onto its last one. Everything + // below draws through `lane`, never a raw column, so a deep-fan-out commit + // keeps a node and its edges instead of being clipped into nothing. + const cap = opts.maxColumn; + const lane = (c: number): number => (cap === undefined ? c : Math.min(c, cap)); + const folded = cap !== undefined && row.column > cap; + const cx = laneCenterX(lane(row.column), colWidth, inset); const cy = Math.round(rowHeight / 2) + 0.5; // Draw diagonals (lane shifts / merges) first, then straight verticals on top @@ -144,7 +194,11 @@ export function renderRowGutterSVG( for (const seg of row.segments) { const dim = focusColor !== undefined && seg.color !== focusColor; const opacity = dim ? ` opacity="${DIM_OPACITY}"` : ""; - const d = segmentPath(seg, colWidth, rowHeight, inset, opts.curveSpan, row.column); + const clamped = + cap === undefined || (seg.fromColumn <= cap && seg.toColumn <= cap) + ? seg + : { ...seg, fromColumn: lane(seg.fromColumn), toColumn: lane(seg.toColumn) }; + const d = segmentPath(clamped, colWidth, rowHeight, inset, opts.curveSpan, lane(row.column)); const markup = `<path d="${d}" fill="none" stroke="${color(palette, seg.color)}" ` + `stroke-width="${strokeWidth}" stroke-linecap="round" ` + @@ -185,9 +239,18 @@ export function renderRowGutterSVG( `<circle cx="${cx}" cy="${cy}" r="${nodeRadius}" fill="${nodeColor}"${nodeOpacity}/>`; } + // A folded node sits on a lane that is not really its own, so say so: a small + // outward chevron past the node, in the lane colour. Without it two commits + // on genuinely different lanes look like they share one. + const beyond = !folded + ? "" + : `<path d="M${cx + nodeRadius + 3.5} ${cy - 3.5}l3.2 3.5l-3.2 3.5" ` + + `fill="none" stroke="${nodeColor}" stroke-width="1.6" ` + + `stroke-linecap="round" stroke-linejoin="round"${nodeOpacity}/>`; + return ( `<svg class="gs-gutter-svg" width="${width}" height="${rowHeight}" ` + `viewBox="0 0 ${width} ${rowHeight}" preserveAspectRatio="none" ` + - `aria-hidden="true">${paths}${node}</svg>` + `aria-hidden="true">${paths}${node}${beyond}</svg>` ); } diff --git a/packages/webview-ui/src/graph/refLayout.ts b/packages/webview-ui/src/graph/refLayout.ts index 73a4374..17584b6 100644 --- a/packages/webview-ui/src/graph/refLayout.ts +++ b/packages/webview-ui/src/graph/refLayout.ts @@ -118,6 +118,43 @@ export function fitRefs(entries: ChipEntry[], colW: number): RefFit { * A `host` of 0 (not laid out yet) means there is no budget to reason about, so * the content fit stands until a real measurement arrives. */ +/** + * The narrowest track that can still show a NAME rather than just chrome. + * + * `min` (60) is the track's structural floor and is load-bearing elsewhere: a + * repo with no refs anywhere asks for exactly `min` so the whole width goes to + * the subject instead of reserving an empty column, and the manual drag clamps + * to it. But 60px is less than one chip's own furniture — REFS_PADDING (18) + * leaves 42, while estimateChipWidth's own floor is 44 — so a track at `min` + * mathematically cannot fit a chip, and the "first chip always draws" guard + * rendered a bare icon with a zero-width name beside it. + * + * This floor applies only when the rows actually WANT refs, so the empty-column + * case keeps collapsing to `min`. + */ +/** Chip width past which the legibility floor stops growing: a 40-character + * branch name must not be allowed to demand half the window. */ +const LEGIBLE_CHIP_CAP = 150; + +/** + * The narrowest track in which the busiest row still shows ONE readable chip. + * + * Derived from the same estimator that computes `wanted`, rather than guessed: + * a chip is 16px of icon + 14px of gap + the name + an optional 14px remote + * tail, and the track adds REFS_PADDING around it. A floor short of that draws + * the chip's furniture and none of its name. + */ +export function legibleRefsWidth(rows: readonly { refs?: WireRef[] }[]): number { + let widest = 0; + for (const row of rows) { + if (!row.refs?.length) continue; + for (const entry of foldRefs(row.refs)) { + widest = Math.max(widest, estimateChipWidth(entry, LEGIBLE_CHIP_CAP)); + } + } + return widest === 0 ? 0 : REFS_PADDING + widest; +} + export function fitRefsWidth(opts: { wanted: number; host: number; @@ -125,9 +162,23 @@ export function fitRefsWidth(opts: { comfort: number; min: number; max: number; + /** How far the subject may be squeezed before refs stop yielding to it. */ + subjectFloor?: number; + /** The width at which one ref name is still readable — see legibleRefsWidth. */ + legible?: number; }): number { - const { wanted, host, nonRefs, comfort, min, max } = opts; - const spare = host > 0 ? host - nonRefs - comfort : wanted; + const { wanted, host, nonRefs, comfort, min, max, subjectFloor, legible = 0 } = opts; + if (host <= 0) return Math.min(max, Math.max(min, wanted)); + // Two budgets: `comfort` is what the subject would LIKE, `hard` is what it + // actually needs. Refs yield to comfort first — that is the whole point of + // the clamp — but never past the point where they can show a name, because a + // column of names showing no names is not a smaller column, it is an empty + // one. Between roughly 1300 and 1550px this pinned the track to 60px and + // rendered zero readable characters, and widening the window made it + // NARROWER, so the app looked broken precisely when maximised on a laptop. + const comfortable = host - nonRefs - comfort; + const hard = subjectFloor === undefined ? comfortable : host - nonRefs - subjectFloor; + const spare = Math.max(comfortable, Math.min(hard, legible)); return Math.min(max, Math.max(min, Math.min(wanted, spare))); } diff --git a/packages/webview-ui/src/limits.ts b/packages/webview-ui/src/limits.ts index 53f84e9..935eaae 100644 --- a/packages/webview-ui/src/limits.ts +++ b/packages/webview-ui/src/limits.ts @@ -6,3 +6,16 @@ * `vscode-diff` line diff still runs; only the word-level overlay is skipped. */ export const LARGE_FILE_LINE_THRESHOLD = 20000; + +/** + * Host width at which the commit graph drops its Date and SHA columns. + * + * Shared because TWO packages need to agree on it and did not: the desktop + * app's graph|details resizer clamps the details column so the graph is never + * squeezed past this point — "otherwise columns silently vanish and their + * resize handles go with them" — and it restated the number as a literal, + * beside a comment quoting a third value. The graph then moved its breakpoint + * and nothing connected the two, so the resizer allowed exactly the drag it + * exists to prevent. + */ +export const COLUMN_DROP_TAIL_AT = 860; diff --git a/packages/webview-ui/src/mergeView.ts b/packages/webview-ui/src/mergeView.ts index 7b4c36f..53b9922 100644 --- a/packages/webview-ui/src/mergeView.ts +++ b/packages/webview-ui/src/mergeView.ts @@ -744,7 +744,13 @@ export class MergeView { const lineCount = model.getLineCount(); let range: monaco.Range; let text: string; - if (span.endExclusive > lineCount) { + // An EMPTY result document is always at end-of-file, whatever the span + // says. Monaco reports one line for "", so `endExclusive > lineCount` is + // false for a single-block accept and the non-EOF branch appends a newline + // — writing a trailing blank line the accepted side never had. It is the + // ordinary case for a conflict with no common ancestor (git's AA/UA/AU), + // where the seed is the empty base. + if (span.endExclusive > lineCount || model.getValueLength() === 0) { // Block reaches end-of-file: replace to the end without a trailing newline. range = new monaco.Range( span.start, @@ -1290,7 +1296,28 @@ export class MergeView { this.trackers.clear(); this.blockState.clear(); for (const editor of this.editors) { - editor.getModel()?.dispose(); + // `editor.dispose()` ONLY. + // + // These editors were built with `monaco.editor.create(dom, { value, + // language })` — no model passed — so Monaco creates the model itself and + // the STANDALONE EDITOR OWNS IT (`_ownsModel`), disposing it in + // `_postDetachModelCleanup`. Disposing it here first re-entered Monaco's + // emitter (`onWillDispose` → `setModel(null)` → `_postDetachModelCleanup` + // → `dispose()` again) and threw + // `Cannot read properties of undefined (reading '0')` on EVERY teardown — + // every mode toggle, every file switch, every panel dispose — which + // aborted the rest of that emitter's listener delivery, including the + // model service's and the worker sync's unregistration. A diff editor + // whose worker sync was never unregistered is a diff editor whose worker + // can stop answering, which is what "the diff sometimes doesn't show" + // looked like from the outside. + // + // The asymmetry is real and worth stating: the INLINE path in + // desktop/diffPanel.ts calls `monaco.editor.createModel` itself and + // therefore must dispose those models by hand. Ownership follows who + // created the model, not who used it. If this ever switches to + // `create(dom, { model })`, the model becomes ours again and must be + // disposed AFTER `editor.dispose()`, never before. editor.dispose(); } this.editors = []; diff --git a/packages/webview-ui/src/styles/hostTokens.ts b/packages/webview-ui/src/styles/hostTokens.ts index d7bba04..5a9a8d7 100644 --- a/packages/webview-ui/src/styles/hostTokens.ts +++ b/packages/webview-ui/src/styles/hostTokens.ts @@ -30,7 +30,15 @@ export const hostTokens = css` --gs-hover-strong: color-mix(in srgb, var(--gs-fg) 12%, var(--gs-hover)); --gs-border: color-mix(in srgb, var(--gs-fg) 13%, transparent); --gs-border-soft: color-mix(in srgb, var(--gs-fg) 8%, transparent); - --gs-amber: var(--vscode-gitDecoration-modifiedResourceForeground, var(--vscode-charts-yellow)); + /* charts-yellow FIRST. This read gitDecoration-"modified" first, on the + reasoning that it is the legibility-tuned equivalent — but "modified" is + whatever a theme paints changed FILES, which is blue in most of them + (and in the GitStudio desktop, by construction). So every tag chip in the + graph rendered as the modified-file blue and the tuned amber never + shipped anywhere. charts-yellow is the semantically right slot for an + amber, and the decoration colour stays as the fallback for the rare theme + that omits the charts palette. */ + --gs-amber: var(--vscode-charts-yellow, var(--vscode-gitDecoration-modifiedResourceForeground)); --gs-brand: #7458e8; --gs-brand-hover: #7d61ec; --gs-brand-fg: #ffffff; diff --git a/packages/webview-ui/src/styles/tokens.css b/packages/webview-ui/src/styles/tokens.css index 07baf71..8599a6e 100644 --- a/packages/webview-ui/src/styles/tokens.css +++ b/packages/webview-ui/src/styles/tokens.css @@ -100,7 +100,15 @@ /* Raw charts-yellow as small text fails AA on light themes (≈3:1); the gitDecoration "modified" foreground is the legibility-tuned equivalent and keeps every amber consumer (tag chips, modified "M") readable on both. */ - --gs-amber: var(--vscode-gitDecoration-modifiedResourceForeground, var(--vscode-charts-yellow)); + /* charts-yellow FIRST. This read gitDecoration-"modified" first, on the + reasoning that it is the legibility-tuned equivalent — but "modified" is + whatever a theme paints changed FILES, which is blue in most of them + (and in the GitStudio desktop, by construction). So every tag chip in the + graph rendered as the modified-file blue and the tuned amber never + shipped anywhere. charts-yellow is the semantically right slot for an + amber, and the decoration colour stays as the fallback for the rare theme + that omits the charts palette. */ + --gs-amber: var(--vscode-charts-yellow, var(--vscode-gitDecoration-modifiedResourceForeground)); /* ---- Brand ------------------------------------------------------------ */ /* GitStudio violet. The mark's "one git tone" is #A98CFF (a light line diff --git a/packages/webview-ui/test/gutterFold.test.ts b/packages/webview-ui/test/gutterFold.test.ts new file mode 100644 index 0000000..b7f3ff3 --- /dev/null +++ b/packages/webview-ui/test/gutterFold.test.ts @@ -0,0 +1,122 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import type { WireRow } from "@gitstudio/host-bridge/graphProtocol"; +import { renderRowGutterSVG, laneCenterX, lastDrawableLane } from "../src/graph/gutter"; + +/** + * `.gutter` hides its overflow and the SVG is sized to the (capped) gutter + * width, while lane x came straight from the row's column with no bound. So a + * commit on lane 17 of a busy repo drew its node past the right edge and was + * CLIPPED AWAY: the row rendered its subject, author and date with nothing at + * all in the graph beside it — which reads as "this commit is not in the + * history", the one thing a commit graph exists to answer. + * + * Deep lanes now fold onto the last one that fits, marked with an outward + * chevron so a folded lane is never mistaken for a real one. + */ +const COL = 26; +const INSET = 16; +const R = 5; + +function row(column: number, over: Partial<WireRow> = {}): WireRow { + return { + sha: "a".repeat(40), + column, + color: 1, + isMerge: false, + refs: [], + segments: [], + subject: "s", + author: "a", + authorEmail: "a@b", + ...over, + } as WireRow; +} + +const opts = (maxColumn?: number): Parameters<typeof renderRowGutterSVG>[1] => ({ + colWidth: COL, + rowHeight: 28, + nodeRadius: R, + nodeInset: INSET, + palette: ["#111", "#222", "#333"], + maxColumn, +}); + +/** Every `cx="…"` the markup draws a circle at. */ +function nodeXs(svg: string): number[] { + return [...svg.matchAll(/<circle cx="([\d.]+)"/g)].map((m) => Number(m[1])); +} + +test("a lane deeper than the gutter still gets a node, inside the canvas", () => { + const width = 458; // the 16-column cap + const svg = renderRowGutterSVG(row(24), opts(15), width); + const xs = nodeXs(svg); + assert.ok(xs.length > 0, "the commit has a node at all"); + for (const x of xs) { + assert.ok(x + R <= width, `the node is inside the ${width}px canvas (drawn at ${x})`); + } + assert.equal(xs[0], laneCenterX(15, COL, INSET), "folded onto the last lane that fits"); +}); + +test("a folded node is marked as folded, and an ordinary one is not", () => { + const deep = renderRowGutterSVG(row(24), opts(15), 458); + const near = renderRowGutterSVG(row(3), opts(15), 458); + assert.match(deep, /<path d="M[\d.]+ [\d.]+l3\.2/, "the deep lane carries the beyond-marker"); + assert.ok( + !/l3\.2 3\.5/.test(near), + "a lane that genuinely fits carries no marker — the marker means 'stacked', " + + "so putting it on a real lane would be a lie in the other direction", + ); +}); + +test("segments into and out of a folded lane are folded with it", () => { + const svg = renderRowGutterSVG( + row(20, { segments: [{ fromColumn: 20, toColumn: 22, color: 1 }] }), + opts(15), + 458, + ); + const maxX = Math.max( + ...[...svg.matchAll(/[ML]([\d.]+) /g)].map((m) => Number(m[1])), + ...[...svg.matchAll(/C([\d.]+) [\d.]+ ([\d.]+) /g)].flatMap((m) => [ + Number(m[1]), + Number(m[2]), + ]), + ); + assert.ok(maxX <= 458, `no path control point escapes the canvas (max x ${maxX})`); +}); + +/** + * The fold marker must be INSIDE the canvas at every gutter width. + * + * The first version reserved only the node's radius when picking the last + * drawable lane, so the chevron was clipped away at roughly one width in four — + * and a folded node with no marker is indistinguishable from a real lane, which + * is worse than the clipping the fold exists to prevent. Measured before the + * fix: 26 of 112 sampled widths overflowed, by up to 5.2px. + */ +test("the fold marker is inside the canvas at every gutter width", () => { + // The REAL function the graph uses — not a copy of its arithmetic here. The + // first version of this test reimplemented the walk, so it passed happily + // while the production reach was wrong: it was checking itself. + const lastLane = (width: number): number => lastDrawableLane(width, COL, INSET, R); + + const overflows: string[] = []; + for (let width = 100; width <= 1400; width++) { + const cap = lastLane(width); + const svg = renderRowGutterSVG(row(cap + 9), opts(cap), width); + // Every x the markup draws at — circles and path coordinates alike. + const xs = [ + ...[...svg.matchAll(/<circle cx="([\d.]+)"/g)].map((m) => Number(m[1])), + ...[...svg.matchAll(/[ML]([\d.]+) /g)].map((m) => Number(m[1])), + ]; + // The chevron is relative (`l3.2 3.5`), so account for its full extent. + const right = Math.max(...xs) + 3.2 + 0.8; + if (right > width) overflows.push(`w=${width} lane=${cap} right=${right.toFixed(1)}`); + } + assert.deepEqual(overflows.slice(0, 5), [], `${overflows.length} width(s) overflow`); +}); + +test("without a cap nothing is folded — the rail and any narrow host keep the old geometry", () => { + const svg = renderRowGutterSVG(row(24), opts(undefined), 1200); + assert.equal(nodeXs(svg)[0], laneCenterX(24, COL, INSET), "lane 24 draws at lane 24"); +});