From b1e68f69e5dc06fdc910d146b59725b9dcabc68e Mon Sep 17 00:00:00 2001 From: Daniels-Main Date: Sun, 6 Sep 2026 23:43:44 +0200 Subject: [PATCH 1/2] fix: prepare compatible Azure helper downloads and gate releases --- .github/workflows/release.yml | 3 ++ Cargo.lock | 2 +- README.md | 3 +- ROADMAP.md | 8 +++ TASKS.md | 12 +++++ crates/strand-azdo/Cargo.toml | 2 +- crates/strand-tauri/src/azdo_helper.rs | 71 ++++++++++++++++++++++++++ docs/learnings.md | 7 +++ docs/packaging.md | 12 ++++- docs/release-checklist.md | 4 ++ package.json | 3 +- scripts/check-azdo-channel.mjs | 51 ++++++++++++++++++ scripts/check-azdo-channel.test.mjs | 64 +++++++++++++++++++++++ website/docs/settings.md | 4 ++ 14 files changed, 240 insertions(+), 6 deletions(-) create mode 100644 scripts/check-azdo-channel.mjs create mode 100644 scripts/check-azdo-channel.test.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d6baf4c..8714ff5a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -226,6 +226,9 @@ jobs: - name: Verify release tag matches Strand version run: node scripts/check-release-version.mjs + - name: Verify compatible helper downloads are published + run: pnpm release:check-helper + - name: Install Cosign if: matrix.platform == 'ubuntu-22.04' uses: sigstore/cosign-installer@v4.1.2 diff --git a/Cargo.lock b/Cargo.lock index 6249448b..e3fb260c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5996,7 +5996,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "strand-azdo" -version = "1.2.1" +version = "1.3.0" dependencies = [ "base64 0.22.1", "dirs", diff --git a/README.md b/README.md index 550fcc6f..e49db4a3 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,8 @@ the resolved app appearance automatically. independently versioned and updated, signed `strand-azdo` REST helper from a protocol-specific release channel, configured in Settings → Hosting; installation shows an explicit download and verification indicator, and - Retry force-replaces a broken or protocol-incompatible installed helper; + Retry force-replaces a broken or protocol-incompatible installed helper. + Unavailable helper releases are identified in the installation error. PATs live only in the native credential vault, and Windows can use integrated Negotiate/NTLM authentication. Its collection URL automatically matches HTTPS repository remotes and supplies the project/repository coordinates used by the diff --git a/ROADMAP.md b/ROADMAP.md index c7022f4b..24f79e81 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2979,6 +2979,14 @@ unsigned overrides, tampering and failed signers. Native Windows settings, commit/amend and palette tag flows passed; validation and platform limits are recorded in `docs/hooks-identity-signing-validation-2026-09-06.md`. +**Azure helper download repair kick (2026-09-06):** The app advanced to helper +protocol 7 while only protocol 5 was published; Strand 1.5.1's protocol-6 +channel is also missing. Helper 1.3.0 is prepared for protocol 7, installer +404s now identify unavailable or incomplete releases, and desktop release CI +checks the public manifest, signature, and every platform archive before +building. Signed helper publication and a separate protocol-6 backfill remain +pending; protocol compatibility and signature enforcement stay in place. + ## Cross-cutting tracks (run in parallel with all milestones) **Performance audit kick (2026-09-06):** Rechecked `main` at `8e83c8c` on diff --git a/TASKS.md b/TASKS.md index 8c9e3b7c..960b6f56 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2058,6 +2058,18 @@ tree: watch the agent work, review fast, accept or reject safely. promoted to `strand-azdo-protocol-5` plus the legacy `strand-azdo-latest` channel; hosted post-promotion Linux smoke passed and all three manifests matched byte-for-byte (`Release` run `30427932365`; 2026-07-29). +- ◐ Repair missing Azure helper downloads (2026-09-06): protocol 7 has no + published channel; helper 1.3.0 is prepared locally. Desktop release builds + now require the matching public manifest, signature, and all three archives + (`check-azdo-channel.mjs`); installer 404s explain a missing or incomplete + release (`azdo_helper::download`). Signed publication of + `strand-azdo-v1.3.0` / `strand-azdo-protocol-7` and installation verification + remain pending. Local validation: 19 Rust helper/lifecycle/protocol tests, + 9 release-script tests, Rust check, TypeScript check, and release security + policy passed; the built Windows helper reports version 1.3.0 / protocol 7. +- ☐ Backfill the signed protocol-6 helper channel for already-published Strand + 1.5.1 from its compatible source, with a new immutable helper version/tag; + publishing protocol 7 cannot repair protocol-6 clients (2026-09-06). - ☑ DAN-64 helper lifecycle recovery (`azdo_helper::download_and_install` force mode + protocol-only manifest gating, direct vault cleanup, and removable incompatible-binary status; 2026-09-02): Hosting Retry always replaces the diff --git a/crates/strand-azdo/Cargo.toml b/crates/strand-azdo/Cargo.toml index 285185a5..03dab40f 100644 --- a/crates/strand-azdo/Cargo.toml +++ b/crates/strand-azdo/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "strand-azdo" -version = "1.2.1" +version = "1.3.0" edition.workspace = true license.workspace = true description = "Optional Azure DevOps Server REST helper for Strand." diff --git a/crates/strand-tauri/src/azdo_helper.rs b/crates/strand-tauri/src/azdo_helper.rs index 306bb956..3956d48b 100644 --- a/crates/strand-tauri/src/azdo_helper.rs +++ b/crates/strand-tauri/src/azdo_helper.rs @@ -568,6 +568,19 @@ fn download(client: &reqwest::blocking::Client, url: &str, limit: usize) -> Resu .send() .map_err(|error| format!("Could not download strand-azdo: {error}"))?; if !response.status().is_success() { + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Err(if url.ends_with("/strand-azdo-manifest.json") { + format!( + "No compatible strand-azdo helper release was found for this Strand build \ + (protocol {PROTOCOL_VERSION}, 404 Not Found). Try again after the helper release is published." + ) + } else { + format!( + "The strand-azdo helper release for protocol {PROTOCOL_VERSION} is incomplete \ + (404 Not Found). Try again after the helper release is repaired." + ) + }); + } return Err(format!( "Could not download strand-azdo ({})", response.status() @@ -700,6 +713,64 @@ mod tests { ); } + #[test] + fn missing_helper_downloads_explain_the_release_failure() { + use std::net::TcpListener; + + for (file, status, expected) in [ + ( + "strand-azdo-manifest.json", + "404 Not Found", + "Try again after the helper release is published", + ), + ( + "strand-azdo-manifest.json.minisig", + "404 Not Found", + "Try again after the helper release is repaired", + ), + ( + "helper.zip", + "404 Not Found", + "Try again after the helper release is repaired", + ), + ( + "strand-azdo-manifest.json", + "503 Service Unavailable", + "503 Service Unavailable", + ), + ] { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/{file}", listener.local_addr().unwrap()); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut request = [0; 4096]; + stream.read(&mut request).unwrap(); + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ) + .unwrap(); + }); + let client = reqwest::blocking::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + let error = download(&client, &url, 1024).unwrap_err(); + server.join().unwrap(); + assert!(error.contains(expected), "{error}"); + if status.starts_with("404") { + assert!( + error.contains(&format!("protocol {PROTOCOL_VERSION}")), + "{error}" + ); + } + } + } + #[test] fn protocol_mismatch_downloads_the_matching_channel_helper() { let manifest = manifest("1.2.2", PROTOCOL_VERSION); diff --git a/docs/learnings.md b/docs/learnings.md index 63ac78a6..818a874e 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -2589,6 +2589,13 @@ metadata. Sharing an updater fast path with explicit Retry made recovery a no-op; requiring a protocol-compatible helper to remove its own state trapped users after a breaking IPC change. +**Protocol changes require publication before desktop release (2026-09-06).** +Changing the shared protocol makes Install helper request a new release URL. +Desktop tags do not build helpers. Run `release:check-helper` before desktop +packaging and publish a newly versioned helper first if that channel is +missing; keep prior channels available for shipped clients. Never redirect an +unpublished protocol to global latest or weaken verification to hide a 404. + ## Performance evidence must separate status, patches, and paint (2026-09-06) A fast `repo_snapshot` does not establish a fast refresh: the frontend can diff --git a/docs/packaging.md b/docs/packaging.md index 21569fc2..64006972 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -192,6 +192,14 @@ versions are independent from Strand versions. Run `pnpm version:azdo patch` matching `strand-azdo-vX.Y.Z` tag. Normal `vX.Y.Z` Strand releases do not rebuild or renumber the helper. +Before a desktop release, `pnpm release:check-helper` reads its compiled +protocol from the shared contract and checks the public channel manifest, +signature, and all three platform archives. The desktop release jobs require +this check before building installers. Publish a compatible helper first when +the protocol changes; bumping only Strand otherwise leaves Install helper +pointing at a 404. The check covers availability; the desktop installer still +enforces the signature and archive/binary hashes. + The helper tag builds universal macOS, Windows x86_64, and Linux x86_64 `.zip`/`.tar.gz` archives. Each runner executes its built binary's `version --json`; the metadata and manifest jobs fail unless all three binaries @@ -202,8 +210,8 @@ manifest to a draft versioned helper release, publishes it as a prerelease, and then promotes the identical artifacts to `strand-azdo-protocol-N`. Strand constructs that channel from its compiled protocol version, so publishing protocol N+1 cannot break reinstall -for an older Strand release. The current thread-lifecycle contract is protocol -6. Protocol 5 is additionally promoted to the legacy +for an older Strand release. The current deferred-completion contract is protocol +7. Protocol 5 is additionally promoted to the legacy `strand-azdo-latest` channel used by already-published Strand 1.2 clients. A post-promotion Linux smoke job downloads through the protocol channel, executes the published helper, and rechecks its version, protocol, archive/binary hashes, diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 60ad496f..138792f8 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -11,6 +11,7 @@ Run from the repository root on the exact candidate commit: pnpm install --frozen-lockfile pnpm release:check-security pnpm release:test-helper +pnpm release:check-helper pnpm store:check pnpm --filter ./ui exec tsc --noEmit pnpm --filter ./ui exec vitest run @@ -32,6 +33,9 @@ to the matching `strand-azdo-protocol-N` channel. The post-promotion smoke job must download through that channel and match the running Linux binary and archive to the manifest. A normal Strand tag must not renumber or publish the helper. +Before building a desktop release, its exact protocol channel must already +serve the manifest, signature, and all supported platform archives. Publish +the matching helper first; retain older channels for already-shipped apps. ## Publisher and update trust diff --git a/package.json b/package.json index 6881e7e1..3ef7256c 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "store:msix:build": "pwsh -NoProfile -File scripts/build-msix.ps1", "version:set": "node scripts/bump-version.mjs", "version:azdo": "node scripts/bump-azdo-version.mjs", - "release:test-helper": "node --test scripts/azdo-helper-manifest.test.mjs" + "release:check-helper": "node scripts/check-azdo-channel.mjs", + "release:test-helper": "node --test scripts/azdo-helper-manifest.test.mjs scripts/check-azdo-channel.test.mjs" }, "devDependencies": { "@tauri-apps/cli": "^2" diff --git a/scripts/check-azdo-channel.mjs b/scripts/check-azdo-channel.mjs new file mode 100644 index 00000000..12bb40b7 --- /dev/null +++ b/scripts/check-azdo-channel.mjs @@ -0,0 +1,51 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const targets = [ + 'universal-apple-darwin', + 'x86_64-pc-windows-msvc', + 'x86_64-unknown-linux-gnu', +]; + +// Availability gate only; the desktop still verifies the signature and hashes. +export async function checkChannel(protocol, fetchUrl = fetch) { + const channel = `strand-azdo-protocol-${protocol}`; + const base = `https://github.com/danielss-dev/strand/releases/download/${channel}`; + async function request(name, method = 'HEAD') { + const response = await fetchUrl(`${base}/${name}`, { + method, + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) { + throw new Error(`${channel}/${name}: HTTP ${response.status}. Publish or repair the signed helper channel before releasing Strand.`); + } + return response; + } + + const manifest = await (await request('strand-azdo-manifest.json', 'GET')).json(); + if (manifest.schema_version !== 1 || manifest.protocol_version !== protocol + || typeof manifest.strand_version !== 'string' || !manifest.strand_version.trim() + || !Array.isArray(manifest.assets)) { + throw new Error(`${channel}: manifest does not match the required helper protocol`); + } + const names = targets.map((target) => { + const assets = manifest.assets.filter((asset) => asset.target === target); + const extension = target.includes('linux') ? 'tar.gz' : 'zip'; + const name = `strand-azdo-${manifest.strand_version}-${target}.${extension}`; + if (assets.length !== 1 || assets[0].name !== name || !/^[\w.+-]+$/.test(name)) { + throw new Error(`${channel}: missing or invalid helper asset for ${target}`); + } + return name; + }); + await request('strand-azdo-manifest.json.minisig'); + for (const name of names) await request(name); + return channel; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const source = await readFile(new URL('../crates/strand-azdo-protocol/src/lib.rs', import.meta.url), 'utf8'); + const protocol = Number(source.match(/^pub const PROTOCOL_VERSION: u32 = (\d+);/m)?.[1]); + if (!Number.isSafeInteger(protocol) || protocol < 1) throw new Error('Could not read the desktop helper protocol'); + console.log(`Helper channel available: ${await checkChannel(protocol)}`); +} diff --git a/scripts/check-azdo-channel.test.mjs b/scripts/check-azdo-channel.test.mjs new file mode 100644 index 00000000..7e112eaa --- /dev/null +++ b/scripts/check-azdo-channel.test.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { checkChannel } from './check-azdo-channel.mjs'; + +function fixture({ status = {}, protocol = 7, omitTarget } = {}) { + const calls = []; + const manifest = { + schema_version: 1, + strand_version: '1.3.0', + protocol_version: protocol, + assets: ['universal-apple-darwin', 'x86_64-pc-windows-msvc', 'x86_64-unknown-linux-gnu'] + .filter((target) => target !== omitTarget) + .map((target) => ({ + target, + name: `strand-azdo-1.3.0-${target}.${target.includes('linux') ? 'tar.gz' : 'zip'}`, + })), + }; + return { + calls, + fetchUrl: async (url, options) => { + calls.push({ url, method: options.method }); + const name = url.split('/').at(-1); + return new Response(options.method === 'GET' ? JSON.stringify(manifest) : null, { + status: status[name] ?? 200, + }); + }, + }; +} + +test('desktop release requires its public manifest, signature and all three platform archives', async () => { + const { fetchUrl, calls } = fixture(); + assert.equal(await checkChannel(7, fetchUrl), 'strand-azdo-protocol-7'); + assert.equal(calls.length, 5); + assert.ok(calls.every(({ url }) => url.startsWith('https://github.com/danielss-dev/strand/releases/download/strand-azdo-protocol-7/'))); + assert.deepEqual(calls.map(({ method }) => method), ['GET', 'HEAD', 'HEAD', 'HEAD', 'HEAD']); +}); + +test('unpublished protocol fails with a release instruction and never falls back to an older channel', async () => { + const { fetchUrl, calls } = fixture({ status: { 'strand-azdo-manifest.json': 404 } }); + await assert.rejects(checkChannel(7, fetchUrl), /protocol-7.*HTTP 404.*Publish or repair/); + assert.equal(calls.length, 1); +}); + +test('an older protocol manifest cannot satisfy the release gate', async () => { + const { fetchUrl } = fixture({ protocol: 5 }); + await assert.rejects(checkChannel(7, fetchUrl), /does not match/); +}); + +test('a missing platform fails even if the other platforms were published', async () => { + const { fetchUrl } = fixture({ omitTarget: 'x86_64-pc-windows-msvc' }); + await assert.rejects(checkChannel(7, fetchUrl), /missing or invalid.*x86_64-pc-windows-msvc/); +}); + +for (const name of ['strand-azdo-manifest.json.minisig', 'strand-azdo-1.3.0-x86_64-pc-windows-msvc.zip']) { + test(`an incomplete release fails when ${name} is unavailable`, async () => { + const { fetchUrl } = fixture({ status: { [name]: 404 } }); + await assert.rejects(checkChannel(7, fetchUrl), /HTTP 404.*Publish or repair/); + }); +} + +test('server errors fail closed without treating them as a published channel', async () => { + const { fetchUrl } = fixture({ status: { 'strand-azdo-manifest.json': 503 } }); + await assert.rejects(checkChannel(7, fetchUrl), /HTTP 503/); +}); diff --git a/website/docs/settings.md b/website/docs/settings.md index a1a988cd..263f320d 100644 --- a/website/docs/settings.md +++ b/website/docs/settings.md @@ -144,6 +144,10 @@ The status row shows the installed helper and protocol versions; **Retry installation** always downloads and replaces the helper from Strand's current protocol channel, including when the existing binary reports the same version, and installs it only after signature and SHA-256 verification. +If installation says no compatible helper release was found, the download for +this Strand build's protocol is unavailable. Retry after that helper release +is published. An incomplete-release error means its signature or archive is +missing. Removing credentials will not repair either download problem. Disabling keeps profiles and credentials. **Remove helper and credentials** is confirmed separately and removes the binary, profiles, imported certificates, and vault entries even when the installed helper is broken or uses an older From da90ea4ab56383ff4788abdcb9f2e5fcc3ee7ceb Mon Sep 17 00:00:00 2001 From: Daniels-Main Date: Sun, 6 Sep 2026 23:43:45 +0200 Subject: [PATCH 2/2] feat: place Git and review tools in contextual menus --- README.md | 33 +++++--- ROADMAP.md | 11 +++ TASKS.md | 11 +++ docs/learnings.md | 17 ++++ docs/ui-placement-validation-2026-09-06.md | 73 +++++++++++++++++ ui/src/App.tsx | 79 ++++++++++++------ ui/src/components/RepoRail.tsx | 70 +++++----------- ui/src/components/RepoTabs.tsx | 69 +++++----------- ui/src/components/RepositoryFiles.tsx | 19 +++-- ui/src/components/Sidebar.tsx | 52 ++++++++---- ui/src/components/SigningChoice.tsx | 37 +++++++-- ui/src/components/Topbar.tsx | 12 +-- ui/src/lib/hostingCapabilities.test.ts | 12 ++- ui/src/lib/i18n.ts | 1 + ui/src/lib/lfs.ts | 6 +- ui/src/lib/menu.ts | 23 ++++-- ui/src/lib/repositoryTools.test.ts | 24 ++++++ ui/src/lib/repositoryTools.ts | 27 +++++++ ui/src/lib/types.ts | 2 +- ui/src/lib/userActions.ts | 6 +- ui/src/styles/features.css | 42 ++++++++++ ui/src/styles/user-actions.css | 3 +- ui/src/views/AdvancedRefsDialog.tsx | 25 +++--- ui/src/views/BisectDialog.tsx | 26 +++--- ui/src/views/CloneDialog.tsx | 14 ++-- ui/src/views/CloneScopeDialog.tsx | 4 +- ui/src/views/Commits.tsx | 15 ++++ ui/src/views/GitflowDialog.tsx | 12 +-- ui/src/views/HostedReviewTools.tsx | 62 +++++++------- ui/src/views/InterchangeDialog.tsx | 19 ++--- ui/src/views/LfsDialog.tsx | 15 ++-- ui/src/views/LocalChanges.tsx | 18 ++++- ui/src/views/MaintenanceDialog.tsx | 16 ++-- ui/src/views/PullRequestCompletionControl.tsx | 10 +-- ui/src/views/PullRequestDataLoader.tsx | 80 +++++++++++-------- ui/src/views/PullRequestInboxLoader.tsx | 2 +- ui/src/views/PullRequestMergeControl.tsx | 60 ++++++++------ ui/src/views/PullRequests.tsx | 40 +++++++--- ui/src/views/RemoteDialog.tsx | 7 ++ ui/src/views/RemoteReposDialog.tsx | 19 ++--- ui/src/views/RepositorySettingsDialog.tsx | 29 +++++++ ui/src/views/SettingsDialog.tsx | 8 +- ui/src/views/SparseCheckoutDialog.tsx | 24 +++--- ui/src/views/SubmoduleDialog.tsx | 10 +-- ui/src/views/TagDialog.tsx | 11 ++- ui/src/views/UserActionDialog.tsx | 6 +- ui/src/views/settings/GitSection.tsx | 6 -- ui/src/views/settings/IntegrationsSection.tsx | 12 ++- .../views/settings/RemoteProviderSettings.tsx | 60 ++++++++------ ui/src/views/settings/RepositoryIdentity.tsx | 18 ++--- ui/src/views/settings/SigningSettings.tsx | 43 ++++++---- ui/src/views/settings/UserActionsEditor.tsx | 31 ++++--- website/docs/commits-and-history.md | 11 +-- website/docs/everyday-git.md | 62 +++++++------- website/docs/keyboard-and-palette.md | 6 +- website/docs/pull-requests.md | 31 +++---- website/docs/remote-repositories.md | 3 +- website/docs/repositories-and-workspaces.md | 24 +++--- website/docs/settings.md | 57 ++++++++----- 59 files changed, 971 insertions(+), 554 deletions(-) create mode 100644 docs/ui-placement-validation-2026-09-06.md create mode 100644 ui/src/lib/repositoryTools.test.ts create mode 100644 ui/src/lib/repositoryTools.ts create mode 100644 ui/src/views/RepositorySettingsDialog.tsx diff --git a/README.md b/README.md index e49db4a3..a544bb33 100644 --- a/README.md +++ b/README.md @@ -71,17 +71,21 @@ the resolved app appearance automatically. ## Features +- **Repository settings** — identity and signing defaults live in each repository’s + context menu. Commit options holds per-commit signing; successful hook output + goes to Activity history. Tag signing stays under Advanced options. + - **Read-only command line companion** — `strand status/log/diff/review` works without the desktop; `--json` emits a versioned typed payload and `strand schema` describes it. Full-file review context uses the same engine - as the app. Settings → Integrations installs `strand` in + as the app. Settings → Integrations → Command line installs `strand` in your user command directory. `strand PATH` opens and focuses a repository in the existing desktop instance, including paths with spaces. - **More hosting providers** — GitLab merge requests and Bitbucket Cloud pull requests use the review workspace, with paged lists, comments, inline threads and supported review decisions. GitHub Enterprise/custom hosts use their own - CLI authentication scope; select custom adapters in Settings → Hosting. + CLI authentication scope; select a custom provider in Edit remote → Advanced. GitLab merges guard the reviewed head and follow project settings. Bitbucket merge, GitLab request-changes and Bitbucket draft transitions remain provider-site actions. Bitbucket Server is not supported. @@ -91,18 +95,21 @@ the resolved app appearance automatically. Interrupted creation and remote setup can be resumed from the same dialog. - **Personal user actions** — define repository, ref, and working-tree file - commands in Settings → Integrations. Menu and palette entries open an exact + commands in Settings → User actions, with one argument per row. Contextual + Actions menus and palette entries open an exact executable/argument/working-directory preview, with bounded output and cancellation. - **Repository size controls** — clone a chosen branch with optional depth, single-branch fetching, on-demand file contents (`blob:none`), and recursive submodules. Inspect clone scope and download more or full history from the - network menu or palette. Sparse checkout selects cone directories, distinguishes - excluded files from deletions, and preserves external sparse indexes on reads. + Fetch submenu or palette. Files → File actions opens folder selection for + sparse checkout. Excluded files remain distinct from deletions, and reads + preserve external sparse indexes. Selection changes refuse dirty trees and ignored-file removal. - **Git LFS** — repository setup, tracking patterns, object/transfer status, - downloads/uploads and server locks from the sidebar and command palette. + downloads/uploads and server locks from repository menus and the command palette. + File menus prefill a tracking pattern or lock target. Whole-file staging, checkout, discard and hard reset honor LFS filters; history is never migrated. - **Submodule lifecycle** — add, remove, deinitialize, synchronize URLs, and @@ -209,9 +216,10 @@ the resolved app appearance automatically. Explicit GitHub merge queue/auto-merge and Azure auto-complete controls show provider capability, enable/cancel, policy state and queue position separately from a completed merge. - **Review tools** saves a reviewed head, compares exact trees across pushes or - rebases, exports all unresolved feedback with replies, and previews validated - standard suggestions before applying them to a clean local file. + Code’s toolbar saves a reviewed revision and compares changes since the last + review. The PR menu exports unresolved feedback; each suggestion offers a + before/after preview. Section-level controls load more discussion and checks. + Automatic merging and queue controls live in Merge options. Every GitHub or Azure PR can open its exact provider head in a new worktree without changing local refs or `FETCH_HEAD`. Active PRs can be closed from a confirmed overflow action. Closed PRs can be reopened; @@ -289,8 +297,8 @@ the resolved app appearance automatically. - **Advanced Git refs** — inspect and edit Git notes and replacement refs; retarget or re-annotate existing unsigned tags with old/new targets and optional remote publication checks. External edits require a fresh review. -- **Guided bisect** — start from known good/bad revisions, test and mark - good/bad/skip, inspect remaining candidates and the culprit, resume external +- **Guided bisect** — use a commit’s Find regression menu to choose working + and broken revisions; test and mark working/broken/skip, inspect remaining candidates and the culprit, resume external sessions, and reset to the original checkout while protecting test edits. - **Commit graph** — SVG lanes with branch/tag chips, revealable inline stash nodes with non-mutating diff inspection, a @@ -349,7 +357,8 @@ the resolved app appearance automatically. Review findings are structured, path/line-validated, stale-diff guarded, and require explicit acceptance before they become notes; repository files are never changed by an AI review. -- **Read-only SSH repositories** — inspect remote status, history, full-context +- **Read-only SSH repositories** — open from the repository `+` menu or palette + to inspect remote status, history, full-context reviews and bounded file snapshots through system OpenSSH, with watching, connection health, cancellation and reconnect. Requires a manually installed compatible companion on the POSIX host; see the diff --git a/ROADMAP.md b/ROADMAP.md index 24f79e81..03009855 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2979,6 +2979,17 @@ unsigned overrides, tampering and failed signers. Native Windows settings, commit/amend and palette tag flows passed; validation and platform limits are recorded in `docs/hooks-identity-signing-validation-2026-09-06.md`. +**UI placement and readability revision (2026-09-06):** Moved the September 6 +feature controls into their owning repository, file, branch, tag and PR menus. +Repository settings separates identity and signing from global preferences; +User actions has its own settings page and individual argument fields. Code owns +review actions and inline suggestion previews, section footers own paging, and +Merge options owns automatic completion. Advanced clone, signing and setup +settings collapse; long dialogs scroll within the window with visible controls. +This placement supersedes the earlier September 6 surface descriptions above. +Browser interaction checks and validation limits are recorded in +`docs/ui-placement-validation-2026-09-06.md`. + **Azure helper download repair kick (2026-09-06):** The app advanced to helper protocol 7 while only protocol 5 was published; Strand 1.5.1's protocol-6 channel is also missing. Helper 1.3.0 is prepared for protocol 7, installer diff --git a/TASKS.md b/TASKS.md index 960b6f56..0dde6559 100644 --- a/TASKS.md +++ b/TASKS.md @@ -81,6 +81,17 @@ Detailed comparison and sequencing: [`docs/git-client-1.0-audit.md`](./docs/git- --- +## UI placement and readability (2026-09-06) + +- ☑ Move the September 6 feature controls into repository settings, contextual + menus and PR content; simplify labels and help text; verify keyboard access + and browser layouts (`repositoryTools`, `RepositorySettingsDialog`, per-section + PR paging, inline suggestion entry points, Merge options, and + `docs/ui-placement-validation-2026-09-06.md`). +- ☐ Run the revised placements in native Windows WebView2; the isolated launch + was blocked by automatic approval review. Browser checks cover the UI, while + native IPC/signing/SSH/provider behavior needs that final integration pass. + ## strand-core (Rust git engine) ### Git-client feature audit follow-ups (2026-09-06) diff --git a/docs/learnings.md b/docs/learnings.md index 818a874e..bf4be982 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -2771,3 +2771,20 @@ Preserve that distinction in settings displays and scoped editing. LFS guards must run before sparse-index mutation dispatch. Refresh an attached memory-only sparse index through `sparse_read_index`, never `Index::read` from disk; keep one process-tree cancellation helper when composing Git workflows. + +### 2026-09-06 — Place occasional Git tools with their context + +The user rejected the accumulated UI controls in PRs #115–122. Keep the main +work surfaces quiet: put repository identity/signing in Repository settings, +per-commit signing beside Commit, file scope under Files, Git-flow under Branches, +imports/exports under repository menus, and PR actions beside the content they +affect. User actions has a separate settings page with individual argument rows; +do not expose JSON editing for ordinary configuration. Keep advanced setup and +source diagnostics collapsed. Menus must capture their exact repository/ref/file, +including inactive repository tabs, and remain reachable by keyboard and palette. + +Check shared dialog placement against the actual backdrop offset: the current +backdrop starts 96px below the window top. A max-height of 88vh or 100vh minus +64px can clip the footer. Reserve that offset plus bottom space, let the body +scroll, and prevent header/footer and form controls from shrinking. Verify both +1280×800 and 880×600. Readable text alone does not establish a readable layout. diff --git a/docs/ui-placement-validation-2026-09-06.md b/docs/ui-placement-validation-2026-09-06.md new file mode 100644 index 00000000..ea98f709 --- /dev/null +++ b/docs/ui-placement-validation-2026-09-06.md @@ -0,0 +1,73 @@ +# UI placement and readability — September 6, 2026 + +Implemented the approved placement revision for today's feature PRs. PR #114's +performance work remains intact; occasional tools stay lazy and menu-driven. + +## Placement inventory + +| PR | Revised placement | +| --- | --- | +| #115 | Commit signing in Commit options; successful output in Activity history; identity/signing in Repository settings; tag signing under Advanced options and verification in the tag menu. | +| #116 | Git LFS in repository menus, with a selected-file pattern/lock entry point; Submodule actions offers Add, Update all and Manage, with row management submenus. | +| #117 | Clone options collapsed under Advanced options; history download under Fetch; checked-out folder selection in File actions; compact sparse-checkout link. | +| #118 | Saved actions directly in contextual Actions menus; a dedicated User actions settings page with one argument per input row. | +| #119 | Separate Import / Export commands; Git note and working/broken bisect entry points on commits; replacements under Advanced; editing under Edit tag; Git-flow under Branch actions; short resume banners. | +| #120 | Publish in Remote actions and the no-remotes empty state; custom provider selection in Edit URLs → Advanced; sign-in guidance in Hosting. | +| #121 | Review comparison/marking in Code; unresolved export in PR actions; Preview suggestion beside a suggestion; queue/automatic merging in Merge options; paging beside each section/thread and in the inbox footer. | +| #122 | SSH opening in repository + menus and Quick Launch; CLI installation under Integrations → Command line. | + +## Readability and interaction checks + +Reviewed the changed labels, explanatory text, status messages, menus and dialogs. +Replaced raw boolean values, ambiguous identity labels, generic action buttons, +unrelated tool selectors and implementation terminology. Technical Git details +remain where they explain a choice or operation. LFS tracking explicitly says +pattern because its existing backend accepts wildcard patterns, not literal files. + +Used Chromium against Strand's demo entry with the actual React components and +Tauri mock IPC at 1280×800 and 880×600. Screenshots were inspected after animations +settled. QA-only fixtures supplied optional Git/hosting read results without +changing demo source or contacting hosting services. The demo font path was +redirected to its existing font assets in the browser harness. + +Checked: + +- Repository context menus with Shift+F10, submenu labels, Escape, and captured + repository/ref targets; Branch, Remote, Submodule and repository + menus. +- Identity/signing tabs and advanced disclosures; long forms scroll while their + headers/footers remain visible. Fixed clipped settings and signing footers and + compressed Git-flow controls using the actual 96px backdrop offset. +- Clone, sparse checkout, history, patch/bundle import/export, Git notes, + replacements, bisect, Git-flow, LFS, submodule, tag and SSH dialog entry points. +- Custom provider selection only appears when Advanced is expanded. Command + line setup stays collapsed in Integrations. +- User-action editing preserves spaces, metacharacters and blank arguments as + separate values. Saving a fixture action produced exactly the entered array. +- Ctrl+Enter committed only in the in-memory demo repository; successful output + appeared in Activity history, and the commit form cleared its draft. +- PR export menu; Code review controls; exact thread/comment/suggestion preview + request and Before/After fields; separate checks and thread-reply page reads. +- Queue options remain reachable while immediate merging is blocked; queue + position and blockers are readable, and Escape restores Merge options focus. + Queue state also remains inspectable without cancellation permission. +- Code's added actions wrap as whole controls at 880px; the toolbar uses 11px + text and its measured scroll width equals its available width. + +Screenshots and temporary build/test logs are local under +`target/ui-placement-qa/`; they are not source assets. + +## Automated checks and limits + +- Frontend TypeScript check and production build passed. +- 469 tests across 85 files passed, including repository-target regression + checks and queue-control reachability with/without cancellation permission. +- `git diff --check` passed. +- An isolated native executable compiled successfully. Automatic approval review + rejected starting it with the reason “blocked by policy.” The running user + app and its Vite server were left alone. Native WebView2 integration is still + unverified for this revision and is recorded as a follow-up in TASKS.md. +- Browser checks validate placement, wording, layout and frontend interaction. + They do not establish real signing, SSH, provider mutation, filesystem-dialog + or native-menu behavior. Existing backend guards were retained. +- The production build reports existing large-chunk/mixed-import warnings and + pnpm reports the existing deprecated patchedDependencies configuration. diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 65ce460b..2f1be00e 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,3 +1,4 @@ +import { openRepositoryTool, REPOSITORY_TOOL_EVENT, type RepositoryToolRequest } from './lib/repositoryTools'; import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { getVersion } from '@tauri-apps/api/app'; import { listen } from '@tauri-apps/api/event'; @@ -142,6 +143,7 @@ const LfsDialog = lazy(() => import('./views/LfsDialog').then((m) => ({ default: const SubmoduleDialog = lazy(() => import('./views/SubmoduleDialog').then((m) => ({ default: m.SubmoduleDialog }))); const WorkspaceManagerDialog = lazy(() => import('./views/WorkspaceManagerDialog').then((m) => ({ default: m.WorkspaceManagerDialog }))); const PullRequests = lazy(() => import('./views/PullRequests').then((m) => ({ default: m.PullRequests }))); +const RepositorySettingsDialog = lazy(() => import('./views/RepositorySettingsDialog').then(m => ({ default: m.RepositorySettingsDialog }))); const RemoteReposDialog = lazy(() => import('./views/RemoteReposDialog').then((m) => ({ default: m.RemoteReposDialog }))); /** Whole-UI zoom bounds + step for the browser-style Ctrl/⌘ +/− shortcuts. @@ -357,7 +359,13 @@ export function App() { const [repoSwitcherOpen, setRepoSwitcherOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [remoteReposOpen, setRemoteReposOpen] = useState(false); - const remoteHealth = useRemoteRepos((state) => state.health); + const [repositorySettingsPath, setRepositorySettingsPath] = useState(null); + const [repositorySettingsSection, setRepositorySettingsSection] = useState<'identity' | 'signing'>('identity'); + const [lfsFile, setLfsFile] = useState(''); + const [toolActivityPath, setToolActivityPath] = useState(null); + const [interchangeMode, setInterchangeMode] = useState<'patch' | 'bundle' | 'export'>('patch'); + const [toolRevision, setToolRevision] = useState(); + const [bisectRevision, setBisectRevision] = useState<{ revision: string; rating: 'good' | 'bad' } | undefined>(); const [settingsSection, setSettingsSection] = useState('appearance'); const [cloneOpen, setCloneOpen] = useState(false); const [publishRepoOpen, setPublishRepoOpen] = useState(false); @@ -543,9 +551,31 @@ export function App() { setSettingsOpen(true); }, []); useEffect(() => { - const open = () => openSettingsAt('git'); + const open = () => setRepositorySettingsPath(useRepo.getState().activePath); + const ssh = () => setRemoteReposOpen(true); + const actions = () => openSettingsAt('user-actions'); + const tool = (event: Event) => { + const request = (event as CustomEvent).detail; + const { path, tool, revision, rating } = request; + if (tool === 'settings' || tool === 'signing') { setRepositorySettingsSection(tool === 'signing' ? 'signing' : 'identity'); setRepositorySettingsPath(path); } + else if (tool === 'lfs') { setLfsFile(request.file ?? ''); setLfsAction({ repoPath: path, action: request.lfsAction ?? 'environment' }); } + else if (tool === 'history') setCloneScopePath(path); + else if (tool === 'activity') setToolActivityPath(path); + else if (tool === 'gitflow') setGitflowPath(path); + else if (tool === 'bisect') { setBisectRevision(revision && rating ? { revision, rating } : undefined); setBisectPath(path); } + else if (tool === 'notes' || tool === 'replace') { setToolRevision(revision); setAdvancedRefs({ path, mode: tool }); } + else { setInterchangeMode(tool); setInterchangePath(path); } + }; window.addEventListener('strand:open-git-settings', open); - return () => window.removeEventListener('strand:open-git-settings', open); + window.addEventListener('strand:open-ssh', ssh); + window.addEventListener('strand:manage-user-actions', actions); + window.addEventListener(REPOSITORY_TOOL_EVENT, tool); + return () => { + window.removeEventListener('strand:open-git-settings', open); + window.removeEventListener('strand:open-ssh', ssh); + window.removeEventListener('strand:manage-user-actions', actions); + window.removeEventListener(REPOSITORY_TOOL_EVENT, tool); + }; }, [openSettingsAt]); useEffect(() => { @@ -1261,9 +1291,11 @@ export function App() { push: () => { void onPush(); }, openInEditor, openInTerminal, - openInterchange: () => { const path = useRepo.getState().activePath; if (path) setInterchangePath(path); }, + openInterchange: (mode = 'patch') => { const path = useRepo.getState().activePath; if (path) openRepositoryTool({ path, tool: mode }); }, + openRepositorySettings: () => setRepositorySettingsPath(useRepo.getState().activePath), + openLfs: () => { const path = useRepo.getState().activePath; if (path) setLfsAction({ repoPath: path, action: 'environment' }); }, openGitflow: () => { const path = useRepo.getState().activePath; if (path) setGitflowPath(path); }, - openAdvancedRefs: () => { const path = useRepo.getState().activePath; if (path) setAdvancedRefs({ path, mode: 'notes' }); }, + openAdvancedRefs: (mode = 'notes') => { const path = useRepo.getState().activePath; if (path) setAdvancedRefs({ path, mode }); }, openBisect: () => { const path = useRepo.getState().activePath; if (path) setBisectPath(path); }, }; const hasRepo = Boolean(meta) && !remoteReposOpen; @@ -1742,7 +1774,7 @@ export function App() { const base: PaletteAction[] = [ { id: 'open', label: 'Open repository…', group: 'Actions', shortcut: keyHint('open-repo'), run: () => { void openViaDialog(); } }, { id: 'install-cli', label: 'Install strand command…', group: 'Actions', keywords: 'terminal PATH launcher companion', run: () => { setSettingsSection('integrations'); setSettingsOpen(true); } }, - { id: 'ssh-repositories', label: 'Open repository on SSH host…', group: 'Actions', keywords: 'remote daemon read status log diff review', run: () => setRemoteReposOpen(true) }, + { id: 'ssh-repositories', label: 'Open repository over SSH…', group: 'Actions', keywords: 'remote daemon read status log diff review', run: () => setRemoteReposOpen(true) }, { id: 'ssh-reconnect', label: 'Reconnect SSH repository…', group: 'Actions', run: () => setRemoteReposOpen(true) }, { id: 'ssh-disconnect', label: 'Disconnect SSH repository', group: 'Actions', run: () => { void useRemoteRepos.getState().disconnect(); } }, { id: 'init', label: 'Initialize repository…', group: 'Actions', keywords: 'new create git init local repository', run: () => setInitRepoOpen(true) }, @@ -2016,16 +2048,18 @@ export function App() { : []), { id: 'publish-repository', label: 'Publish repository…', group: 'Actions', keywords: 'create hosted github gitlab bitbucket repository account organization visibility initial push', run: () => setPublishRepoOpen(true) }, { id: 'remote-add', label: 'Add remote…', group: 'Actions', keywords: 'remote origin upstream url add', run: () => setRemoteDialog({ kind: 'add' }) }, - { id: 'git-interchange', label: 'Patches, mailboxes & bundles…', group: 'Actions', keywords: 'import export apply index working tree am continue skip abort author bundle verify prerequisites', run: () => { setPaletteOpen(false); setInterchangePath(meta.path); } }, + { id: 'git-interchange', label: 'Apply patch or mailbox…', group: 'Actions', keywords: 'import export apply index working tree am continue skip abort author bundle verify prerequisites', run: () => { setPaletteOpen(false); openRepositoryTool({path:meta.path,tool:'patch'}); } }, + { id: 'git-import-bundle', label: 'Import bundle…', group: 'Actions', keywords: 'import verify git bundle', run: () => openRepositoryTool({path:meta.path,tool:'bundle'}) }, + { id: 'git-export-bundle', label: 'Export bundle…', group: 'Actions', keywords: 'export git bundle history', run: () => openRepositoryTool({path:meta.path,tool:'export'}) }, { id: 'git-notes', label: 'Git notes…', group: 'Actions', keywords: 'advanced refs objects notes replacements tag annotation', run: () => { setPaletteOpen(false); setAdvancedRefs({ path: meta.path, mode: 'notes' }); } }, - { id: 'git-replace', label: 'Replace refs…', group: 'Actions', keywords: 'advanced refs objects notes replacements tag annotation', run: () => { setPaletteOpen(false); setAdvancedRefs({ path: meta.path, mode: 'replace' }); } }, - { id: 'git-retarget', label: 'Retarget tag…', group: 'Actions', keywords: 'advanced refs objects notes replacements tag annotation', run: () => { setPaletteOpen(false); setAdvancedRefs({ path: meta.path, mode: 'retarget' }); } }, - { id: 'git-reannotate', label: 'Re-annotate tag…', group: 'Actions', keywords: 'advanced refs objects notes replacements tag annotation', run: () => { setPaletteOpen(false); setAdvancedRefs({ path: meta.path, mode: 'reannotate' }); } }, + { id: 'git-replace', label: 'Replacement refs…', group: 'Actions', keywords: 'advanced refs objects notes replacements tag annotation', run: () => { setPaletteOpen(false); setAdvancedRefs({ path: meta.path, mode: 'replace' }); } }, + { id: 'git-retarget', label: 'Change tag target…', group: 'Actions', keywords: 'advanced refs objects notes replacements tag annotation', run: () => { setPaletteOpen(false); setAdvancedRefs({ path: meta.path, mode: 'retarget' }); } }, + { id: 'git-reannotate', label: 'Edit tag message…', group: 'Actions', keywords: 'advanced refs objects notes replacements tag annotation', run: () => { setPaletteOpen(false); setAdvancedRefs({ path: meta.path, mode: 'reannotate' }); } }, { id: 'gitflow', label: 'Git-flow workflows…', group: 'Actions', keywords: 'AVH feature release hotfix start finish resume configuration', run: () => { setPaletteOpen(false); setGitflowPath(meta.path); } }, - { id: 'git-bisect', label: 'Guided bisect…', group: 'Actions', keywords: 'good bad skip regression culprit test resume reset', run: () => { setPaletteOpen(false); setBisectPath(meta.path); } }, + { id: 'git-bisect', label: 'Find regression…', group: 'Actions', keywords: 'bisect good bad skip regression culprit test resume reset', run: () => { setPaletteOpen(false); setBisectPath(meta.path); } }, { id: 'clone-scope', label: 'Repository history and downloads…', group: 'Actions', keywords: 'clone shallow partial filter deepen unshallow single branch', run: () => setCloneScopePath(meta.path) }, - { id: 'sparse-checkout', label: 'Sparse checkout…', group: 'Actions', keywords: 'cone directories select inspect change disable excluded sparse index', run: () => setSparsePath(meta.path) }, + { id: 'sparse-checkout', label: 'Choose checked-out folders…', group: 'Actions', keywords: 'sparse checkout cone directories select inspect change disable excluded sparse index', run: () => setSparsePath(meta.path) }, ...SUBMODULE_ACTIONS.map(([action, label]) => ({ id: `submodule-${action}`, label: `Submodules: ${label}…`, group: 'Actions', keywords: 'submodule lifecycle url nested status add remove deinit sync', run: () => { setPaletteOpen(false); setSubmoduleDialog({ repoPath: meta.path, path: '', action }); } } satisfies PaletteAction)), ...LFS_ACTIONS.map(([action, label]) => ({ id: `lfs-${action}`, label: `Git LFS: ${label}…`, group: 'Actions', keywords: 'large file storage lfs objects filters patterns transfers locks', run: () => { setPaletteOpen(false); setLfsAction({ repoPath: meta.path, action }); } } satisfies PaletteAction)), @@ -2101,7 +2135,8 @@ export function App() { base.push( { id: 'settings', label: 'Settings…', group: 'Actions', shortcut: keyHint('settings'), keywords: 'preferences shortcuts keyboard config options', run: () => openSettingsAt('appearance') }, { id: 'keybindings', label: 'Settings: Keyboard shortcuts', group: 'Actions', keywords: 'keyboard shortcuts keybindings rebind configure customize', run: () => openSettingsAt('keyboard') }, - { id: 'settings-git', label: 'Settings: Repository identity and signing', group: 'Actions', keywords: 'author committer name email local override config gpg ssh key sign tags', run: () => openSettingsAt('git') }, + { id: 'settings-git-global', label: 'Settings: Git', group: 'Actions', keywords: 'global identity name email', run: () => openSettingsAt('git') }, + ...(meta ? [{ id: 'settings-git', label: 'Repository settings: Identity and signing', group: 'Actions', keywords: 'author committer name email local override config gpg ssh key sign tags', run: () => setRepositorySettingsPath(meta.path) } satisfies PaletteAction] : []), { id: 'settings-ai', label: 'Settings: AI', group: 'Actions', keywords: 'ai chatgpt codex claude commit message suggest login', run: () => openSettingsAt('ai') }, { id: 'settings-hosting', label: 'Settings: Hosting', group: 'Actions', keywords: 'github enterprise custom host gitlab bitbucket azure provider authentication account', run: () => openSettingsAt('hosting') }, { id: 'settings-plugins', label: 'Settings: Plugins', group: 'Actions', keywords: 'plugins marketplace extensions workbench surfaces install', run: () => openSettingsAt('plugins') }, @@ -2298,8 +2333,6 @@ export function App() {
setRemoteReposOpen(true)} - remoteHealth={remoteHealth} onOpenPalette={() => setPaletteOpen(true)} onFetch={onFetch} onPull={onPull} @@ -2337,7 +2370,6 @@ export function App() { onOpenRecent={openByPath} onClone={() => setCloneOpen(true)} onCloneScope={() => { if (meta) setCloneScopePath(meta.path); }} - onSparseCheckout={() => { if (meta) setSparsePath(meta.path); }} onCustomize={openIconDialog} onManageWorkspaces={() => setWorkspaceManagerOpen(true)} onWorktreeReview={reviewWorktreeTab} @@ -2386,7 +2418,6 @@ export function App() { onMerge={(source, into) => setMergeDialog({ source, into })} onInteractiveRebase={(base, label) => setRebaseDialog({ base, label })} onManageRemote={(mode) => setRemoteDialog(mode)} - onManageLfs={() => { if (meta) setLfsAction({ repoPath: meta.path, action: 'environment' }); }} onManageSubmodules={(path = '', action = 'inspect') => { if (meta) setSubmoduleDialog({ repoPath: meta.path, path, action }); }} onRenameBranch={(name) => setRenameBranchDialog({ name })} onManageBranchNetwork={(mode) => setBranchNetworkDialog(mode)} @@ -2557,10 +2588,12 @@ export function App() { {maintenanceOpen && meta && ( setMaintenanceOpen(false)} onToast={showToast} /> )} - {interchangePath && setInterchangePath(null)} />} + {repositorySettingsPath && { setRepositorySettingsPath(null); setRepositorySettingsSection('identity'); }} />} + {toolActivityPath && setToolActivityPath(null)} onToast={showToast} />} + {interchangePath && { setInterchangePath(null); setInterchangeMode('patch'); }} />} {gitflowPath && setGitflowPath(null)} />} - {advancedRefs && setAdvancedRefs(null)} />} - {bisectPath && setBisectPath(null)} />} + {advancedRefs && { setAdvancedRefs(null); setToolRevision(undefined); }} />} + {bisectPath && { setBisectPath(null); setBisectRevision(undefined); }} />} {userActionRequest && ( { setUserActionRequest(null); openSettingsAt('user-actions'); }} /> )} - {lfsAction && setLfsAction(null)} />} + {lfsAction && { setLfsAction(null); setLfsFile(''); }} />} {submoduleDialog && setSubmoduleDialog(null)} />} {cloneScopePath && status.some((s) => s.kind === 'CONFLICTED'), [status]); if (!operation) return null; - if (operation === 'bisect') return
Bisect in progressTest the selected revision, then rate it.
; - if (operation === 'mailbox') return
Mailbox in progressResolve and stage conflicts, then continue the mailbox.
; + if (operation === 'bisect') return
Bisect in progressTest the selected revision, then rate it.
; + if (operation === 'mailbox') return
Mailbox in progressResolve and stage conflicts, then continue the mailbox.
; const onAbort = async () => { if (busy) return; diff --git a/ui/src/components/RepoRail.tsx b/ui/src/components/RepoRail.tsx index 4b98fe85..d563e14b 100644 --- a/ui/src/components/RepoRail.tsx +++ b/ui/src/components/RepoRail.tsx @@ -2,7 +2,8 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { Icon } from './Icon'; -import { openUserAction } from '../lib/userActions'; +import { ContextMenu, type MenuItem } from './ContextMenu'; +import { repositoryToolMenu } from '../lib/repositoryTools'; import { WorkspaceSwitcher } from './WorkspaceSwitcher'; import { formatBinding } from '../lib/keys'; import { useRepo } from '../stores/repo'; @@ -126,6 +127,13 @@ export function RepoRail({ onOpenRepo, onInitRepo, onOpenRecent, onClone, onCust e.preventDefault(); void closeRepo(t.path); }} + onKeyDown={(e) => { + if (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10')) { + e.preventDefault(); + const rect = e.currentTarget.getBoundingClientRect(); + setMenu({ path: t.path, name: t.meta.name, worktree: linked, x: rect.right, y: rect.top }); + } + }} onContextMenu={(e) => openMenu(e, t)} > {linked ? ( @@ -190,54 +198,15 @@ function RailContextMenu({ onReview: () => void; onMerge: () => void; }) { - const ref = useRef(null); - useOutsideClose([ref], true, onClose); - - // Clamp the menu inside the viewport (it opens at the cursor). - const [pos, setPos] = useState({ top: menu.y, left: menu.x }); - useLayoutEffect(() => { - const el = ref.current; - if (!el) return; - const r = el.getBoundingClientRect(); - const left = Math.min(menu.x, window.innerWidth - r.width - 8); - const top = Math.min(menu.y, window.innerHeight - r.height - 8); - setPos({ top, left }); - }, [menu]); - - return ( -
- {!menu.worktree && ( - - )} - {menu.worktree && ( - <> - - - - )} - - -
- ); + const items: MenuItem[] = [ + ...(menu.worktree ? [ + { label: 'Review vs base', icon: 'eye' as const, onSelect: onReview }, + { label: 'Merge & clean up…', icon: 'branch' as const, onSelect: onMerge }, + ] : [{ label: 'Customize…', icon: 'edit' as const, onSelect: onCustomize }]), + ...repositoryToolMenu(menu.path), + { label: menu.worktree ? 'Close worktree' : 'Close repository', icon: 'x', onSelect: onCloseRepo }, + ]; + return ; } /** @@ -318,6 +287,9 @@ function RailAddButton({ Clone repository… +
diff --git a/ui/src/components/RepoTabs.tsx b/ui/src/components/RepoTabs.tsx index 93bf490e..f2a28bff 100644 --- a/ui/src/components/RepoTabs.tsx +++ b/ui/src/components/RepoTabs.tsx @@ -2,7 +2,8 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { Icon } from './Icon'; -import { openUserAction } from '../lib/userActions'; +import { ContextMenu, type MenuItem } from './ContextMenu'; +import { repositoryToolMenu } from '../lib/repositoryTools'; import { WorkspaceSwitcher } from './WorkspaceSwitcher'; import { useRepo } from '../stores/repo'; import { useRepoIcons } from '../stores/repoIcons'; @@ -177,6 +178,12 @@ export function RepoTabs({ onOpenRepo, onInitRepo, onOpenRecent, onClone, onCust void closeRepo(t.path); }} onKeyDown={(e) => { + if (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10')) { + e.preventDefault(); + const rect = e.currentTarget.getBoundingClientRect(); + setMenu({ path: t.path, worktree: linked, x: rect.left, y: rect.bottom }); + return; + } if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); void closeRepo(t.path); @@ -257,54 +264,15 @@ function TabContextMenu({ onReview: () => void; onMerge: () => void; }) { - const ref = useRef(null); - useOutsideClose([ref], true, onClose); - - // Clamp the menu inside the viewport (it opens at the cursor). - const [pos, setPos] = useState({ top: menu.y, left: menu.x }); - useLayoutEffect(() => { - const el = ref.current; - if (!el) return; - const r = el.getBoundingClientRect(); - const left = Math.min(menu.x, window.innerWidth - r.width - 8); - const top = Math.min(menu.y, window.innerHeight - r.height - 8); - setPos({ top, left }); - }, [menu]); - - return ( -
- {!menu.worktree && ( - - )} - {menu.worktree && ( - <> - - - - )} - - -
- ); + const items: MenuItem[] = [ + ...(menu.worktree ? [ + { label: 'Review vs base', icon: 'eye' as const, onSelect: onReview }, + { label: 'Merge & clean up…', icon: 'branch' as const, onSelect: onMerge }, + ] : [{ label: 'Customize…', icon: 'edit' as const, onSelect: onCustomize }]), + ...repositoryToolMenu(menu.path), + { label: menu.worktree ? 'Close worktree' : 'Close repository', icon: 'x', onSelect: onCloseRepo }, + ]; + return ; } /** @@ -470,6 +438,9 @@ function RepoSwitcherButton({ Clone repository… +
diff --git a/ui/src/components/RepositoryFiles.tsx b/ui/src/components/RepositoryFiles.tsx index b1f24472..acbd538a 100644 --- a/ui/src/components/RepositoryFiles.tsx +++ b/ui/src/components/RepositoryFiles.tsx @@ -1,3 +1,4 @@ +import { openRepositoryTool } from '../lib/repositoryTools'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { userActionMenu } from '../lib/userActions'; @@ -318,7 +319,13 @@ export function RepositoryFiles({ }, ]; if (!selectedCommit && actionPaths.length === 1) { - if (meta && !rowIsDirectory) items.push(userActionMenu({ path: meta.path, target: { kind: 'file', file: rowPath } })); + if (meta && !rowIsDirectory) items.push(userActionMenu({ path: meta.path, target: { kind: 'file', file: rowPath } }), { + label: 'Git LFS', submenu: [ + { label: 'Track a pattern…', onSelect: () => openRepositoryTool({ path: meta.path, tool: 'lfs', lfsAction: 'track', file: rowPath }) }, + { label: 'Lock this file…', onSelect: () => openRepositoryTool({ path: meta.path, tool: 'lfs', lfsAction: 'lock', file: rowPath }) }, + { label: 'View locks for this file…', onSelect: () => openRepositoryTool({ path: meta.path, tool: 'lfs', lfsAction: 'locks', file: rowPath }) }, + ], + }); items.push( { label: 'Open in editor', icon: 'external', onSelect: () => onOpenFileInEditor(rowPath) }, { @@ -462,13 +469,13 @@ export function RepositoryFiles({ ref={fileCreateButtonRef} type="button" className="side-files-create" - title={t('files.createEntry')} - aria-label={t('files.createEntry')} + title="File actions" + aria-label="File actions" aria-haspopup="menu" aria-expanded={createMenu != null} onClick={openFileCreateMenu} > - +
); @@ -484,8 +491,7 @@ export function RepositoryFiles({ )} {!selectedCommit && localTree && filePaths.length === 0 && fileCreateToolbar} {!selectedCommit && displayedTree.some((entry) => entry.excluded) &&
- Sparse-excluded files are omitted. - +
} {selectedCommit && (
@@ -544,6 +550,7 @@ export function RepositoryFiles({ items={[ { label: t('files.newFile'), icon: 'file-plus', onSelect: () => onCreateFileEntry('', false) }, { label: t('files.newFolder'), icon: 'folder-plus', onSelect: () => onCreateFileEntry('', true) }, + { label: 'Choose checked-out folders…', icon: 'folder', onSelect: () => window.dispatchEvent(new Event('strand:open-sparse-checkout')) }, ]} onClose={() => setCreateMenu(null)} /> diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 3fecc365..c0fb2c2d 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -1,3 +1,4 @@ +import { openRepositoryTool } from '../lib/repositoryTools'; import { useEffect, useMemo, useRef, useState } from 'react'; import { ContextMenu, type MenuItem } from './ContextMenu'; import { userActionMenu } from '../lib/userActions'; @@ -59,7 +60,7 @@ interface SectionProps { onToggle: () => void; count?: number; /** Optional trailing action (e.g. "+" to create) shown on the right. */ - action?: { icon: IconName; title: string; onClick: () => void }; + action?: { icon: IconName; title: string; onClick: (event: React.MouseEvent) => void; menu?: boolean }; } function SideSection({ label, collapsed, onToggle, count, action }: SectionProps) { @@ -70,7 +71,7 @@ function SideSection({ label, collapsed, onToggle, count, action }: SectionProps {label} {action && ( - )} @@ -80,7 +81,6 @@ function SideSection({ label, collapsed, onToggle, count, action }: SectionProps } interface SidebarProps { - onManageLfs: () => void; onManageSubmodules: (path?: string, action?: import('../lib/submodules').SubmoduleDialogAction) => void; onOpenWorkbench: () => void; onOpenWorkSurface: () => void; @@ -182,7 +182,7 @@ function sortTree(node: TreeNode, leafCmp: (a: T, b: T) => number): void { // ─── component ────────────────────────────────────────────────────────── -export function Sidebar({ onManageSubmodules, onManageLfs, onOpenWorkbench, onOpenWorkSurface, onOpenRepo, onOpenRecent, onCreateStash, onCreateTag, onEditTag, onVerifyTag, onCreateBranch, onBranchFromStash, onCreateWorktree, onMerge, onInteractiveRebase, onManageRemote, onRenameBranch, onManageBranchNetwork, onPull, onPush, onForcePush, onFetchBranch, onPullBranch, onOpenFileInEditor, onCreateFileEntry, onToast }: SidebarProps) { +export function Sidebar({ onManageSubmodules, onOpenWorkbench, onOpenWorkSurface, onOpenRepo, onOpenRecent, onCreateStash, onCreateTag, onEditTag, onVerifyTag, onCreateBranch, onBranchFromStash, onCreateWorktree, onMerge, onInteractiveRebase, onManageRemote, onRenameBranch, onManageBranchNetwork, onPull, onPush, onForcePush, onFetchBranch, onPullBranch, onOpenFileInEditor, onCreateFileEntry, onToast }: SidebarProps) { const view = useRepo((s) => s.view); const setView = useRepo((s) => s.setView); const selectFile = useRepo((s) => s.selectFile); @@ -753,10 +753,6 @@ export function Sidebar({ onManageSubmodules, onManageLfs, onOpenWorkbench, onOp const tagMenu = (tg: Tag): MenuItem[] => { const items: MenuItem[] = [ - { label: 'Retarget tag…', onSelect: () => onEditTag(tg.name, 'retarget') }, - { label: 'Re-annotate tag…', onSelect: () => onEditTag(tg.name, 'reannotate') }, - - userActionMenu({ path: meta!.path, target: { kind: 'ref', reference: tg.full_name, oid: tg.target } }), { label: 'Checkout', icon: 'branch', onSelect: () => void runBranchOp(() => checkoutCommit(tg.target)) }, { label: 'New branch from here…', icon: 'plus', onSelect: () => onCreateBranch(tg.full_name, tg.name) }, { label: 'New worktree from here…', icon: 'worktree', onSelect: () => onCreateWorktree({ ref: tg.full_name, label: tg.name }) }, @@ -782,6 +778,10 @@ export function Sidebar({ onManageSubmodules, onManageLfs, onOpenWorkbench, onOp { label: 'Copy tag name', icon: 'file', onSelect: () => { void copyToClipboard(tg.name); onToast('Tag name copied'); } }, { label: 'Copy commit SHA', icon: 'file', onSelect: () => { void copyToClipboard(tg.target); onToast('Commit SHA copied'); } }, ); + items.push({ label: 'Edit tag', submenu: [ + { label: 'Change target commit…', onSelect: () => onEditTag(tg.name, 'retarget') }, + { label: 'Edit message…', onSelect: () => onEditTag(tg.name, 'reannotate') }, + ] }, userActionMenu({ path: meta!.path, target: { kind: 'ref', reference: tg.full_name, oid: tg.target } })); items.push({ label: 'Verify tag signature…', icon: 'tag', onSelect: () => onVerifyTag(tg.name) }); items.push({ label: 'Delete tag', icon: 'trash', danger: true, confirm: true, onSelect: () => void runBranchOp(() => deleteTag(tg.name)) }); return items; @@ -819,7 +819,13 @@ export function Sidebar({ onManageSubmodules, onManageLfs, onOpenWorkbench, onOp onSelect: () => onManageSubmodules(sub.path, 'update'), }); items.push({ label: 'Copy path', icon: 'file', onSelect: () => void copyToClipboard(sub.path) }); - items.push({ label: 'Manage / inspect nested modules…', icon: 'submodule', onSelect: () => onManageSubmodules(sub.path) }); + items.push({ label: 'Inspect submodule…', icon: 'submodule', onSelect: () => onManageSubmodules(sub.path) }); + items.push({ label: 'Manage', submenu: [ + { label: 'Sync URL', onSelect: () => onManageSubmodules(sub.path, 'sync') }, + { label: 'Change URL…', onSelect: () => onManageSubmodules(sub.path, 'set-url') }, + { label: 'Deinitialize…', onSelect: () => onManageSubmodules(sub.path, 'deinit') }, + { label: 'Remove submodule…', danger: true, onSelect: () => onManageSubmodules(sub.path, 'remove') }, + ] }); return items; }; @@ -1017,7 +1023,6 @@ export function Sidebar({ onManageSubmodules, onManageLfs, onOpenWorkbench, onOp /> )}
- {meta && } toggle('branches')} count={filtered.branches.length} - action={{ icon: 'plus', title: 'New branch…', onClick: () => onCreateBranch(null, 'HEAD') }} + action={{ icon: 'chev-down', title: 'Branch actions', menu: true, onClick: (event) => { + const rect = event.currentTarget.getBoundingClientRect(); + openMenu(rect.left, rect.bottom, [ + { label: 'New branch…', icon: 'plus', onSelect: () => onCreateBranch(null, 'HEAD') }, + { label: 'Git-flow…', onSelect: () => openRepositoryTool({ path: meta.path, tool: 'gitflow' }) }, + ]); + } }} /> {sections.branches && renderTreeChildren(branchTree, 0, collapsed, toggleCollapsed, renderBranchLeaf, 'branches')} @@ -1121,9 +1132,15 @@ export function Sidebar({ onManageSubmodules, onManageLfs, onOpenWorkbench, onOp collapsed={!sections.remotes} onToggle={() => toggle('remotes')} count={refs.remotes.length} - action={{ icon: 'plus', title: 'Add remote…', onClick: () => onManageRemote({ kind: 'add' }) }} + action={{ icon: 'chev-down', title: 'Remote actions', menu: true, onClick: (event) => { + const rect = event.currentTarget.getBoundingClientRect(); + openMenu(rect.left, rect.bottom, [ + { label: 'Add remote…', icon: 'plus', onSelect: () => onManageRemote({ kind: 'add' }) }, + { label: 'Publish repository…', onSelect: () => window.dispatchEvent(new Event('strand:publish-repository')) }, + ]); + } }} /> - {sections.remotes && window.dispatchEvent(new CustomEvent('strand:publish-repository'))} />} + {sections.remotes && refs.remotes.length === 0 && window.dispatchEvent(new CustomEvent('strand:publish-repository'))} />} {sections.remotes && renderTreeChildren(remoteTree, 0, collapsed, toggleCollapsed, renderRemoteLeaf, 'remotes', { folderIcon: 'remote', @@ -1170,7 +1187,14 @@ export function Sidebar({ onManageSubmodules, onManageLfs, onOpenWorkbench, onOp collapsed={!sections.submods} onToggle={() => toggle('submods')} count={filteredSubmodules.length} - action={{ icon: 'plus', title: 'Manage submodules', onClick: () => onManageSubmodules() }} + action={{ icon: 'chev-down', title: 'Submodule actions', menu: true, onClick: (event) => { + const rect = event.currentTarget.getBoundingClientRect(); + openMenu(rect.left, rect.bottom, [ + { label: 'Add submodule…', icon: 'plus', onSelect: () => onManageSubmodules('', 'add') }, + { label: 'Update all submodules…', disabled: !submodules.length, onSelect: () => onManageSubmodules('', 'update-all') }, + { label: 'Manage submodules…', onSelect: () => onManageSubmodules() }, + ]); + } }} /> {sections.submods && filteredSubmodules.length === 0 && (
No submodules.
diff --git a/ui/src/components/SigningChoice.tsx b/ui/src/components/SigningChoice.tsx index 389feb4d..c2fc34d6 100644 --- a/ui/src/components/SigningChoice.tsx +++ b/ui/src/components/SigningChoice.tsx @@ -1,14 +1,22 @@ +import { Select } from './Select'; +import { openRepositoryTool } from '../lib/repositoryTools'; import { useEffect, useState } from 'react'; import { errMessage, tauri } from '../lib/tauri'; import type { SigningMode, SigningSettings } from '../lib/types'; +import { ContextMenu, type MenuItem } from './ContextMenu'; +import { Icon } from './Icon'; -export function SigningChoice({ path, kind, annotated = false, settingsLink = true, value, disabled, onChange }: { +export function SigningChoice({ path, kind, annotated = false, settingsLink = true, compact = false, extraItems = [], value, disabled, onChange }: { path: string; kind: 'commit' | 'tag'; annotated?: boolean; settingsLink?: boolean; + compact?: boolean; extraItems?: MenuItem[]; value: SigningMode; disabled: boolean; onChange: (value: SigningMode) => void; }) { const [settings, setSettings] = useState(null); const [error, setError] = useState(null); + const [menu, setMenu] = useState<{ x: number; y: number } | null>(null); + const loadSettings = !compact || menu !== null; useEffect(() => { + if (!loadSettings) return; let active = true; let sequence = 0; const load = () => { @@ -21,20 +29,37 @@ export function SigningChoice({ path, kind, annotated = false, settingsLink = tr window.addEventListener('strand:git-config-changed', load); window.addEventListener('focus', load); return () => { active = false; window.removeEventListener('strand:git-config-changed', load); window.removeEventListener('focus', load); }; - }, [path]); + }, [path, loadSettings]); const inherited = settings && (kind === 'commit' ? settings.commit_sign : settings.tag_sign || (annotated && settings.tag_force_annotated)); + if (compact) return <> + + {menu && setMenu(null)} items={[ + { label: `Use Git setting${settings ? ` (${inherited ? 'signed' : 'unsigned'})` : ''}`, icon: value === 'inherit' ? 'check' : undefined, onSelect: () => onChange('inherit') }, + { label: 'Sign this commit', icon: value === 'sign' ? 'check' : undefined, onSelect: () => onChange('sign') }, + { label: 'Do not sign this commit', icon: value === 'unsigned' ? 'check' : undefined, onSelect: () => onChange('unsigned') }, + { label: 'Repository signing settings…', onSelect: () => openRepositoryTool({ path, tool: 'signing' }) }, + ...extraItems, + ...(error ? [{ label: error, disabled: true }] : []), + ]} />} + ; return
{settingsLink && } + onClick={() => openRepositoryTool({ path, tool: 'signing' })}>Signing settings…} {error && {error}}
; } diff --git a/ui/src/components/Topbar.tsx b/ui/src/components/Topbar.tsx index 96ba1c18..611a7846 100644 --- a/ui/src/components/Topbar.tsx +++ b/ui/src/components/Topbar.tsx @@ -11,8 +11,6 @@ import { useRepo } from '../stores/repo'; import type { PullMode, PushMode } from '../lib/types'; interface Props { - onOpenRemote: () => void; - remoteHealth: string; onOpenPalette: () => void; onFetch: (prune?: boolean) => void; onPull: (mode?: PullMode, autostash?: boolean) => void; @@ -45,7 +43,6 @@ interface Props { /** Open the clone dialog (tabs-mode `+` menu). */ onClone: () => void; onCloneScope: () => void; - onSparseCheckout: () => void; /** Open the icon-customization dialog for a repo tab. */ onCustomize: (path: string) => void; /** Open the workspace manager dialog (tabs-mode switcher). */ @@ -60,8 +57,6 @@ interface Props { } export function Topbar({ - onOpenRemote, - remoteHealth, onOpenPalette, onFetch, onPull, @@ -88,7 +83,6 @@ export function Topbar({ onOpenRecent, onClone, onCloneScope, - onSparseCheckout, onCustomize, onManageWorkspaces, onWorktreeReview, @@ -124,6 +118,7 @@ export function Topbar({ }, { label: 'Fetch and prune', onSelect: () => onFetch(true) }, { label: 'Fetch without pruning', onSelect: () => onFetch(false) }, + { label: 'Download more history…', onSelect: onCloneScope }, { label: 'Set repository default', submenu: [ @@ -174,9 +169,7 @@ export function Topbar({ { label: 'Force with lease…', icon: 'arrow-up', danger: true, onSelect: onForcePush }, ], }, - { label: 'Repository history and downloads…', disabled: networkBusy, onSelect: onCloneScope }, - { label: 'Sparse checkout…', disabled: networkBusy, onSelect: onSparseCheckout }, - ], [fetchPrune, networkBusy, onCloneScope, onSparseCheckout, onFetch, onForcePush, onPull, onPush, onPushAllTags, onSetFetchPrune, onSetPullAutostash, onSetPullMode, pullAutostash, pullMode, pullModeLabel]); + ], [fetchPrune, networkBusy, onCloneScope, onFetch, onForcePush, onPull, onPush, onPushAllTags, onSetFetchPrune, onSetPullAutostash, onSetPullMode, pullAutostash, pullMode, pullModeLabel]); const inTauri = isTauri(); // macOS lets the OS draw the traffic lights over our toolbar (`titleBarStyle: @@ -335,7 +328,6 @@ export function Topbar({ {platform === 'mac' ? '⌘K' : 'Ctrl K'} - {showWinControls && }
); diff --git a/ui/src/lib/hostingCapabilities.test.ts b/ui/src/lib/hostingCapabilities.test.ts index 2b0250af..7d9b97ea 100644 --- a/ui/src/lib/hostingCapabilities.test.ts +++ b/ui/src/lib/hostingCapabilities.test.ts @@ -29,9 +29,19 @@ describe('hosted provider capabilities', () => { for (const provider of ['git_hub', 'azure_dev_ops'] as const) { const html = renderToStaticMarkup(createElement(PullRequestMergeControl, { path: '/fixture', provider, pr, disabledReason: '', onMerged: () => {}, onToast: () => {} })); expect(html).toContain('Merge pull request'); - expect(html).toContain('Choose merge strategy'); + expect(html).toContain('Merge options'); } expect(providerName('git_lab')).toBe('GitLab'); expect(providerName('bitbucket')).toBe('Bitbucket Cloud'); }); + it.each([true, false])('keeps queue state reachable when immediate merge is unavailable (can cancel: %s)', (canCancel) => { + const html = renderToStaticMarkup(createElement(PullRequestMergeControl, { + path: '/fixture', provider: 'git_hub', pr: { ...pr, completion: { + kind: 'github_queue', status: 'queued', can_enable: false, can_cancel: canCancel, strategies: [], blockers: [], position: 2, source_commit: pr.source_commit, + } }, disabledReason: 'Waiting for checks', onMerged: () => {}, onToast: () => {}, + })); + expect(html).toContain('In merge queue'); + expect(html).toContain('aria-label="Merge options"'); + expect(html).not.toContain('disabled=""'); + }); }); diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index aa3f4536..fdd4522a 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -247,6 +247,7 @@ export const en = { 'settings.git': 'Git', 'settings.hosting': 'Hosting', 'settings.integrations': 'Integrations', + 'settings.userActions': 'User actions', 'settings.defaultShell': 'Default shell', 'settings.embeddedShell': 'Embedded terminal shell', 'settings.embeddedShellHint': 'Used only by Work terminals. External “Open in terminal” remains in Integrations.', diff --git a/ui/src/lib/lfs.ts b/ui/src/lib/lfs.ts index d4599837..485263e2 100644 --- a/ui/src/lib/lfs.ts +++ b/ui/src/lib/lfs.ts @@ -1,10 +1,10 @@ export const LFS_ACTIONS = [ - ['environment', 'Installation and configuration'], + ['environment', 'Check installation and configuration'], ['install', 'Set up this repository'], - ['patterns', 'Tracked patterns'], + ['patterns', 'Show tracked patterns'], ['track', 'Track a pattern'], ['untrack', 'Stop tracking a pattern'], - ['status', 'Object and transfer status'], + ['status', 'Show file and transfer status'], ['objects', 'List objects and sizes'], ['fetch', 'Download objects'], ['pull', 'Download and check out objects'], diff --git a/ui/src/lib/menu.ts b/ui/src/lib/menu.ts index 0b0f55d6..d3e431f9 100644 --- a/ui/src/lib/menu.ts +++ b/ui/src/lib/menu.ts @@ -48,9 +48,11 @@ export interface MenuHandlers { push(): void; openInEditor(): void; openInTerminal(): void; - openInterchange(): void; + openInterchange(mode?: 'patch' | 'bundle' | 'export'): void; + openRepositorySettings(): void; + openLfs(): void; openBisect(): void; - openAdvancedRefs(): void; + openAdvancedRefs(mode?: 'notes' | 'replace'): void; openGitflow(): void; } @@ -199,10 +201,6 @@ export async function installAppMenu( const repoMenu = await Submenu.new({ text: 'Repository', items: [ - await item({ id: 'git-interchange', text: 'Patches, Mailboxes & Bundles…', enabled: hasRepo, action: () => handlers().openInterchange() }), - await item({ id: 'git-advanced-refs', text: 'Git Notes, Replacements & Tag Editing…', enabled: hasRepo, action: () => handlers().openAdvancedRefs() }), - await item({ id: 'gitflow', text: 'Git-flow Workflows…', enabled: hasRepo, action: () => handlers().openGitflow() }), - await item({ id: 'git-bisect', text: 'Guided Bisect…', enabled: hasRepo, action: () => handlers().openBisect() }), await item({ id: 'sync', text: 'Sync (Fetch + Pull + Push)', @@ -213,6 +211,19 @@ export async function installAppMenu( await item({ id: 'pull', text: 'Pull', cmd: 'pull', enabled: hasRepo, action: () => handlers().pull() }), await item({ id: 'push', text: 'Push', cmd: 'push', enabled: hasRepo, action: () => handlers().push() }), await sep(), + await item({ id: 'repository-settings', text: 'Repository Settings…', enabled: hasRepo, action: () => handlers().openRepositorySettings() }), + await item({ id: 'git-lfs', text: 'Git LFS…', enabled: hasRepo, action: () => handlers().openLfs() }), + await Submenu.new({ text: 'Import / Export', items: [ + await item({ id: 'git-patch', text: 'Apply Patch or Mailbox…', enabled: hasRepo, action: () => handlers().openInterchange('patch') }), + await item({ id: 'git-bundle-import', text: 'Import Bundle…', enabled: hasRepo, action: () => handlers().openInterchange('bundle') }), + await item({ id: 'git-bundle-export', text: 'Export Bundle…', enabled: hasRepo, action: () => handlers().openInterchange('export') }), + ] }), + await Submenu.new({ text: 'Advanced', items: [ + await item({ id: 'git-advanced-refs', text: 'Replacement Refs…', enabled: hasRepo, action: () => handlers().openAdvancedRefs('replace') }), + await item({ id: 'gitflow', text: 'Git-flow…', enabled: hasRepo, action: () => handlers().openGitflow() }), + await item({ id: 'git-bisect', text: 'Find Regression…', enabled: hasRepo, action: () => handlers().openBisect() }), + ] }), + await sep(), await item({ id: 'open-editor', text: 'Open in Editor', diff --git a/ui/src/lib/repositoryTools.test.ts b/ui/src/lib/repositoryTools.test.ts new file mode 100644 index 00000000..c5bcb625 --- /dev/null +++ b/ui/src/lib/repositoryTools.test.ts @@ -0,0 +1,24 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +vi.mock('./userActions', () => ({ userActionMenu: () => ({ label: 'Actions' }) })); +import { openRepositoryTool, repositoryToolMenu } from './repositoryTools'; + +beforeEach(() => { + vi.stubGlobal('window', { dispatchEvent: vi.fn() }); + vi.stubGlobal('CustomEvent', class { constructor(public type: string, public init: unknown) {} }); +}); +describe('repository tool targets', () => { + it('retains the clicked repository for settings and each import/export action', () => { + const menu = repositoryToolMenu('D:/another repository'); + menu[0].onSelect?.(); + expect(window.dispatchEvent).toHaveBeenLastCalledWith(expect.objectContaining({ init: { detail: { path: 'D:/another repository', tool: 'settings' } } })); + const transfers = menu.find(item => item.label === 'Import / Export')!.submenu!; + for (const [index, tool] of ['patch', 'bundle', 'export'].entries()) { + transfers[index].onSelect?.(); + expect(window.dispatchEvent).toHaveBeenLastCalledWith(expect.objectContaining({ init: { detail: { path: 'D:/another repository', tool } } })); + } + }); + it('keeps the chosen commit and bisect role rather than substituting HEAD', () => { + openRepositoryTool({ path: '/repo', tool: 'bisect', revision: 'a'.repeat(40), rating: 'good' }); + expect(window.dispatchEvent).toHaveBeenCalledWith(expect.objectContaining({ init: { detail: { path: '/repo', tool: 'bisect', revision: 'a'.repeat(40), rating: 'good' } } })); + }); +}); diff --git a/ui/src/lib/repositoryTools.ts b/ui/src/lib/repositoryTools.ts new file mode 100644 index 00000000..b22749ce --- /dev/null +++ b/ui/src/lib/repositoryTools.ts @@ -0,0 +1,27 @@ +import type { MenuItem } from '../components/ContextMenu'; +import { userActionMenu } from './userActions'; + +export type RepositoryTool = 'settings' | 'signing' | 'lfs' | 'patch' | 'bundle' | 'export' | 'notes' | 'replace' | 'bisect' | 'gitflow' | 'history' | 'activity'; +export interface RepositoryToolRequest { + path: string; + tool: RepositoryTool; + revision?: string; + rating?: 'good' | 'bad'; + lfsAction?: 'track' | 'lock' | 'locks'; + file?: string; +} +export const REPOSITORY_TOOL_EVENT = 'strand:repository-tool'; +export function openRepositoryTool(request: RepositoryToolRequest) { + window.dispatchEvent(new CustomEvent(REPOSITORY_TOOL_EVENT, { detail: request })); +} +export function repositoryToolMenu(path: string): MenuItem[] { + const item = (label: string, tool: RepositoryTool): MenuItem => ({ label, onSelect: () => openRepositoryTool({ path, tool }) }); + return [ + item('Repository settings…', 'settings'), + item('Git LFS…', 'lfs'), + { label: 'Import / Export', submenu: [item('Apply patch or mailbox…', 'patch'), item('Import bundle…', 'bundle'), item('Export bundle…', 'export')] }, + { label: 'Advanced', submenu: [item('Replacement refs…', 'replace'), item('History and downloads…', 'history')] }, + item('Activity history…', 'activity'), + userActionMenu({ path, target: { kind: 'repository' } }), + ]; +} diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index 4e5412ce..379610e6 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -520,7 +520,7 @@ export interface MaintenanceOutcome { export interface RepoActivityEntry extends MaintenanceOutcome { id: string; - task: MaintenanceTask; + task: MaintenanceTask | 'commit' | 'amend'; started_at: number; } diff --git a/ui/src/lib/userActions.ts b/ui/src/lib/userActions.ts index 8f4065eb..14e3a6d1 100644 --- a/ui/src/lib/userActions.ts +++ b/ui/src/lib/userActions.ts @@ -33,10 +33,10 @@ export function openUserAction(context: ActionContext, actionId?: string) { export function userActionMenu(context: ActionContext): MenuItem { const actions = useSettings.getState().userActions.filter((action) => action.scope === context.target.kind); return { - label: 'User actions…', icon: 'terminal', - ...(actions.length ? { submenu: actions.map((action) => ({ + label: 'Actions', icon: 'terminal', + submenu: [...actions.map((action) => ({ label: action.name, onSelect: () => openUserAction(context, action.id), - })) } : { onSelect: () => openUserAction(context) }), + })), { label: 'Manage user actions…', onSelect: () => window.dispatchEvent(new Event('strand:manage-user-actions')) }], }; } diff --git a/ui/src/styles/features.css b/ui/src/styles/features.css index 7a2389c8..4bd4c2b4 100644 --- a/ui/src/styles/features.css +++ b/ui/src/styles/features.css @@ -4441,6 +4441,7 @@ textarea.clone-input { font: 10.5px/1.45 var(--font-mono); user-select: text; } +.maintenance-entry > pre:first-child { padding-top: 10px; } /* Settings dialog — Appearance / theme picker (reuses the clone-dialog shell). */ .settings-dialog { @@ -9615,3 +9616,44 @@ select.clone-input { .settings-field .settings-hint { overflow-wrap: anywhere; } .tag-verification-output { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 300px; overflow: auto; font-size: var(--type-ui-sm); } + +/* Occasional tools stay in their owning menus and dialogs. */ +.repository-settings-body { padding: 20px; min-height: 0; overflow-y: auto; } +.repository-settings-tabs { display: flex; gap: 8px; margin: 12px 0 20px; } +.settings-disclosure { min-width: 0; } +.settings-disclosure > summary { cursor: pointer; color: var(--text-2); padding-block: 6px; } +.settings-disclosure[open] > summary { margin-bottom: 8px; } +.settings-disclosure .settings-hint, .git-tool-body .stash-blurb { max-width: 75ch; line-height: 1.55; } +.settings-disclosure code, .settings-disclosure pre { overflow-wrap: anywhere; white-space: pre-wrap; } +.cb-options { flex-shrink: 0; gap: 5px; } +.pr-section-loader { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding-block: 6px; font-size: var(--type-ui-sm); } +.pr-section-loader [role="alert"] { color: var(--del); overflow-wrap: anywhere; } +.pr-search-scope { font-size: var(--type-ui-sm); color: var(--text-muted); } +.pr-completion-options { padding: 12px; display: flex; flex-direction: column; gap: 10px; line-height: 1.5; } +.pr-completion-options > span { color: var(--text-muted); font-size: var(--type-ui-sm); } +.pr-completion-options [role="alert"] { color: var(--del); } +.pr-merge-menu { max-height: min(540px, 70vh); overflow-y: auto; } +.pr-merge-menu[hidden] { display: none; } +.user-action-arguments { display: flex; flex-direction: column; gap: 8px; } +.user-action-arguments .clone-input { min-width: 0; flex: 1; } +.remote-repo-dialog > .settings-disclosure { margin-inline: 16px; } + +.git-tool-dialog { max-height: calc(100vh - 112px); } +.git-tool-dialog > .clone-head, .git-tool-dialog > .clone-foot { flex-shrink: 0; } +.git-tool-dialog > .clone-body, .git-tool-dialog > .repository-settings-body { min-height: 0; overflow-y: auto; } + +.maintenance-dialog, .clone-options-dialog, .hosted-review-dialog, .remote-repo-dialog { max-height: calc(100vh - 112px); } +.maintenance-dialog > .clone-head, .maintenance-dialog > .clone-foot, .hosted-review-dialog > .clone-head { flex-shrink: 0; } +.remote-repo-dialog { overflow-y: auto; } +.remote-repo-dialog > * { flex-shrink: 0; } + +.settings-dialog-lg { max-height: calc(100vh - 112px); } +.git-tool-dialog > .clone-body > *, .maintenance-body > * { flex-shrink: 0; } + +.settings-dialog-lg > .clone-head, .settings-dialog-lg > .clone-foot { flex-shrink: 0; } +.settings-nav { overflow-y: auto; } +.settings-nav-item { flex-shrink: 0; } + +.pr-code-overview { flex-wrap: wrap; padding-block: 8px; font-size: var(--type-ui-sm); } +.pr-code-overview > .h-link { flex-shrink: 0; white-space: nowrap; } +.pr-code-overview code { font-size: inherit; } diff --git a/ui/src/styles/user-actions.css b/ui/src/styles/user-actions.css index 77f6556f..23854bd1 100644 --- a/ui/src/styles/user-actions.css +++ b/ui/src/styles/user-actions.css @@ -6,8 +6,7 @@ .user-action-preview { border: 1px solid var(--border); border-radius: 6px; padding: 10px 14px; } .user-action-preview li { white-space: pre-wrap; overflow-wrap: anywhere; padding: 3px; } .user-action-body pre { max-height: 220px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; background: var(--bg-panel); padding: 10px; } -.user-actions-editor { border-top: 1px solid var(--border); padding-top: 16px; gap: 10px; } -.user-actions-editor textarea { width: 100%; resize: vertical; font-family: var(--font-mono); } +.user-actions-editor { gap: 10px; } .user-actions-buttons { display: flex; gap: 8px; } .user-actions-editor .settings-select, .user-action-body .settings-select { width: 100%; } .user-actions-editor h3 { margin: 0; } diff --git a/ui/src/views/AdvancedRefsDialog.tsx b/ui/src/views/AdvancedRefsDialog.tsx index f8a0fae2..8469de65 100644 --- a/ui/src/views/AdvancedRefsDialog.tsx +++ b/ui/src/views/AdvancedRefsDialog.tsx @@ -6,11 +6,11 @@ import { errMessage, tauri } from '../lib/tauri'; import type { AdvancedRefs, GitNote, ReplaceReview, TagEditKind, TagEditReview } from '../lib/advancedRefs'; import { useRepo } from '../stores/repo'; -export function AdvancedRefsDialog({ path, initialMode = 'notes', initialTag = '', onClose }: { path: string; initialMode?: 'notes' | 'replace' | TagEditKind; initialTag?: string; onClose: () => void }) { - const [mode, setMode] = useState(initialMode); +export function AdvancedRefsDialog({ path, initialMode = 'notes', initialTag = '', initialRevision = 'HEAD', onClose }: { path: string; initialMode?: 'notes' | 'replace' | TagEditKind; initialTag?: string; initialRevision?: string; onClose: () => void }) { + const [mode] = useState(initialMode); const [notesRef, setNotesRef] = useState('refs/notes/commits'); const [data, setData] = useState(null); - const [revision, setRevision] = useState('HEAD'); + const [revision, setRevision] = useState(initialRevision); const [note, setNote] = useState(null); const [message, setMessage] = useState(''); const [original, setOriginal] = useState(''); @@ -27,7 +27,7 @@ export function AdvancedRefsDialog({ path, initialMode = 'notes', initialTag = ' const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const [output, setOutput] = useState(''); - const first = useRef(null); + const first = useRef(null); const mounted = useRef(true); const reads = useRef(0); const refresh = useCallback(async () => { @@ -63,18 +63,15 @@ export function AdvancedRefsDialog({ path, initialMode = 'notes', initialTag = ' } function clearTag() { setTagReview(null); setAcknowledged(false); setConfirm(''); } const tags = useRepo((s) => s.refs.tags); - return {busy ? 'Working with Git…' : ''}}>

Repository: {path}. These are Git objects and refs, separate from Strand’s local Review notes.

- {mode === 'notes' && <> - + - - + + {note &&
{note.target.oid}

{note.target.kind} · {note.target.subject}