diff --git a/http-client/lib/project.ts b/http-client/lib/project.ts index f7e6eafd8..061b14a9f 100644 --- a/http-client/lib/project.ts +++ b/http-client/lib/project.ts @@ -1,6 +1,7 @@ -import type { Commit, Commits } from "./project/commit.js"; -import type { Embed } from "./project/comment.js"; +import type { ZodSchema } from "zod"; import type { Fetcher, RequestOptions } from "./fetcher.js"; +import type { Embed } from "./project/comment.js"; +import type { Commit, Commits } from "./project/commit.js"; import type { Issue, IssueCreated, @@ -13,27 +14,26 @@ import type { PatchUpdateAction, } from "./project/patch.js"; import type { SuccessResponse } from "./shared.js"; -import type { ZodSchema } from "zod"; -import { successResponseSchema } from "./shared.js"; import { array, boolean, literal, number, + object, optional, record, - object, string, union, z, } from "zod"; +import { successResponseSchema } from "./shared.js"; import { - diffBlobSchema, commitHeaderSchema, commitSchema, commitsSchema, + diffBlobSchema, diffSchema, } from "./project/commit.js"; @@ -44,9 +44,9 @@ import { } from "./project/issue.js"; import { + patchCreatedSchema, patchSchema, patchesSchema, - patchCreatedSchema, } from "./project/patch.js"; const projectSchema = object({ @@ -121,7 +121,7 @@ const treeSchema = object({ export type Remote = z.infer; -const remoteSchema = object({ +export const remoteSchema = object({ id: string(), alias: string().optional(), heads: record(string(), string()), diff --git a/package-lock.json b/package-lock.json index a6118c7e3..70abcc18f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "buffer": "^6.0.3", "compare-versions": "^6.1.0", "dompurify": "^3.0.11", + "fuzzysort": "^3.0.2", "hast-util-to-dom": "^4.0.0", "hast-util-to-html": "^9.0.0", "lodash": "^4.17.21", @@ -2707,6 +2708,11 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fuzzysort": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-3.0.2.tgz", + "integrity": "sha512-ZyahVgxvckB1Qosn7YGWLDJJp2XlyaQ2WmZeI+d0AzW0AMqVYnz5N89G6KAKa6m/LOtv+kzJn4lhDF/yVg11Cg==" + }, "node_modules/get-func-name": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", diff --git a/package.json b/package.json index 40426c158..0f5555c83 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "buffer": "^6.0.3", "compare-versions": "^6.1.0", "dompurify": "^3.0.11", + "fuzzysort": "^3.0.2", "hast-util-to-dom": "^4.0.0", "hast-util-to-html": "^9.0.0", "lodash": "^4.17.21", diff --git a/public/index.css b/public/index.css index 9ab8efbd7..f6101c272 100644 --- a/public/index.css +++ b/public/index.css @@ -96,6 +96,11 @@ pre { height: 100%; background-color: var(--color-fill-ghost); } +.global-flex-item { + display: flex; + gap: 0.5rem; + align-items: center; +} /* Breakpoints diff --git a/src/components/DropdownList/DropdownListItem.svelte b/src/components/DropdownList/DropdownListItem.svelte index a670d74b5..a2a3d0e9e 100644 --- a/src/components/DropdownList/DropdownListItem.svelte +++ b/src/components/DropdownList/DropdownListItem.svelte @@ -2,6 +2,7 @@ export let selected: boolean; export let disabled: boolean = false; export let title: string | undefined = undefined; + export let style: string | undefined = undefined; - import { formatNodeId } from "@app/lib/utils"; + import { formatNodeId, parseNodeId, truncateId } from "@app/lib/utils"; import Avatar from "./Avatar.svelte"; import Id from "./Id.svelte"; export let nodeId: string; export let alias: string | undefined = undefined; + export let subject: string = formatNodeId(nodeId); - +
{#if alias} - {alias} + + {alias} + {:else} - {formatNodeId(nodeId)} + + {formatNodeId(nodeId)} + + + {truncateId(parseNodeId(nodeId)?.pubkey || "")} + {/if}
diff --git a/src/components/Popover.svelte b/src/components/Popover.svelte index b306ddeb1..91d202d91 100644 --- a/src/components/Popover.svelte +++ b/src/components/Popover.svelte @@ -20,7 +20,7 @@ let thisComponent: HTMLDivElement; function clickOutside(ev: MouseEvent | TouchEvent) { - if (!$focused?.contains(ev.target as HTMLDivElement)) { + if ($focused && !ev.composedPath().includes($focused)) { closeFocused(); } } diff --git a/src/views/projects/History.svelte b/src/views/projects/History.svelte index 43981f70f..3a849fca7 100644 --- a/src/views/projects/History.svelte +++ b/src/views/projects/History.svelte @@ -7,7 +7,7 @@ Node, Tree, } from "@http-client"; - import type { Route } from "@app/lib/router"; + import type { ProjectRoute } from "./router"; import { COMMITS_PER_PAGE } from "./router"; import { HttpdClient } from "@http-client"; @@ -26,7 +26,6 @@ export let baseUrl: BaseUrl; export let node: Node; export let commit: string; - export let branches: string[]; export let commitHeaders: CommitHeader[]; export let peer: string | undefined; export let peers: Remote[]; @@ -43,6 +42,11 @@ let loading = false; let allCommitHeaders: CommitHeader[]; + $: baseRoute = { + resource: "project.history", + node: baseUrl, + project: project.id, + } as Extract; $: { allCommitHeaders = commitHeaders; page = 0; @@ -63,28 +67,6 @@ } loading = false; } - - $: peersWithRoute = peers.map(remote => ({ - remote, - selected: remote.id === peer, - route: { - resource: "project.history", - node: baseUrl, - project: project.id, - peer: remote.id, - } as Route, - })); - - $: branchesWithRoute = branches.map(name => ({ - name, - route: { - resource: "project.history", - node: baseUrl, - project: project.id, - peer, - revision: name, - } as Route, - })); - -
- {#if selectedBranch} - - - - - - closeFocused()}> - - -
- {item.name} -
- {#if onCanonical} - - Canonical - - {/if} -
- -
- - closeFocused()}> - - -
- {project.defaultBranch} -
-
- -
-
-
- {/if} - -
- -
diff --git a/src/views/projects/Source/Header.svelte b/src/views/projects/Source/Header.svelte index 81e0d4e4d..6b8c76e84 100644 --- a/src/views/projects/Source/Header.svelte +++ b/src/views/projects/Source/Header.svelte @@ -1,26 +1,29 @@
- {#if peers.length > 0} - - {/if} - - +
diff --git a/src/views/projects/Source/PeerBranchSelector.svelte b/src/views/projects/Source/PeerBranchSelector.svelte new file mode 100644 index 000000000..7f2f25d9f --- /dev/null +++ b/src/views/projects/Source/PeerBranchSelector.svelte @@ -0,0 +1,286 @@ + + + + +
+ + + + + + {#if selectedPeer} + + + + {/if} +
diff --git a/src/views/projects/Source/PeerBranchSelector/Peer.svelte b/src/views/projects/Source/PeerBranchSelector/Peer.svelte new file mode 100644 index 000000000..d9b71df7a --- /dev/null +++ b/src/views/projects/Source/PeerBranchSelector/Peer.svelte @@ -0,0 +1,88 @@ + + + + +
+
+ (expanded = !expanded)}> + + + + {#if peer.remote.delegate} + + + Delegate + + {/if} +
+
+{#if expanded} + {#each Object.entries(peer.remote.heads) as [name, head]} + closeFocused()}> + + replace({ + ...baseRoute, + peer: peer.remote.id, + revision: name, + })} + style={`${subgridStyle} padding-left: 2.3rem; gap: inherit;`}> +
+ + + {name} + +
+
+ + {formatCommit(head)} + +
+
+ + {/each} +{/if} diff --git a/src/views/projects/Source/PeerSelector.svelte b/src/views/projects/Source/PeerSelector.svelte deleted file mode 100644 index a88e41196..000000000 --- a/src/views/projects/Source/PeerSelector.svelte +++ /dev/null @@ -1,134 +0,0 @@ - - - - -
- - - - - - closeFocused()} route={item.route}> - -
- -
- - {item.remote.alias || formatNodeId(item.remote.id)} - - {#if $httpdStore.state !== "stopped" && item.remote.id === $httpdStore.node.id} - - You - - {/if} - {#if item.remote.delegate} - - - Delegate - - {/if} -
- -
-
-
- {#if selectedPeer} - - - - {/if} -
diff --git a/src/views/projects/components/CommitButton.svelte b/src/views/projects/components/CommitButton.svelte index d6b851e3a..22ff66b08 100644 --- a/src/views/projects/components/CommitButton.svelte +++ b/src/views/projects/components/CommitButton.svelte @@ -5,9 +5,12 @@ import Link from "@app/components/Link.svelte"; import { formatCommit } from "@app/lib/utils"; + export let styleMinWidth: string | undefined = undefined; + export let styleWidth: "100%" | undefined = undefined; export let styleRoundBorders: boolean = false; export let projectId: string; export let baseUrl: BaseUrl; + export let hideSummaryOnMobile: boolean = true; export let commit: Commit["commit"]; $: commitShortId = formatCommit(commit.id); @@ -27,6 +30,7 @@ -
+
{commitShortId}
- + {commit.summary}
diff --git a/src/views/projects/router.ts b/src/views/projects/router.ts index 27cf29af9..2c4050650 100644 --- a/src/views/projects/router.ts +++ b/src/views/projects/router.ts @@ -21,15 +21,15 @@ import type { Tree, } from "@http-client"; -import * as Syntax from "@app/lib/syntax"; +import { experimental } from "@app/lib/appearance"; import * as httpd from "@app/lib/httpd"; +import * as Syntax from "@app/lib/syntax"; +import { unreachable } from "@app/lib/utils"; +import { nodePath } from "@app/views/nodes/router"; +import { handleError, unreachableError } from "@app/views/projects/error"; import { HttpdClient } from "@http-client"; import { ResponseError, ResponseParseError } from "@http-client/lib/fetcher"; -import { experimental } from "@app/lib/appearance"; import { get } from "svelte/store"; -import { handleError, unreachableError } from "@app/views/projects/error"; -import { nodePath } from "@app/views/nodes/router"; -import { unreachable } from "@app/lib/utils"; export const COMMITS_PER_PAGE = 30; export const PATCHES_PER_PAGE = 10; @@ -119,7 +119,6 @@ export type ProjectLoadedRoute = project: Project; peers: Remote[]; peer: string | undefined; - branches: string[]; revision: string | undefined; tree: Tree; path: string; @@ -137,7 +136,6 @@ export type ProjectLoadedRoute = project: Project; peers: Remote[]; peer: string | undefined; - branches: string[]; revision: string | undefined; tree: Tree; commitHeaders: CommitHeader[]; @@ -438,7 +436,9 @@ async function loadTreeView( isLocalNodeSeeding(route), ]); - let branchMap: Record; + let branchMap: Record = { + [project.defaultBranch]: project.head, + }; if (route.peer) { const peer = peers.find(peer => peer.id === route.peer); if (!peer) { @@ -449,15 +449,10 @@ async function loadTreeView( } else { branchMap = peer.heads; } - } else { - branchMap = { [project.defaultBranch]: project.head }; } if (route.route) { - const { revision, path } = detectRevision( - route.route, - branchMap || { [project.defaultBranch]: project.head }, - ); + const { revision, path } = detectRevision(route.route, branchMap); route.revision = revision; route.path = path; } @@ -481,7 +476,6 @@ async function loadTreeView( project, peers: peers.filter(remote => Object.keys(remote.heads).length > 0), peer: route.peer, - branches: Object.keys(branchMap), rawPath, revision: route.revision, tree, @@ -587,7 +581,6 @@ async function loadHistoryView( project, peers: peers.filter(remote => Object.keys(remote.heads).length > 0), peer: route.peer, - branches: Object.keys(branchMap || {}), revision: route.revision, tree, commitHeaders, diff --git a/tests/e2e/project.spec.ts b/tests/e2e/project.spec.ts index 25c7bfb62..8c2fc175f 100644 --- a/tests/e2e/project.spec.ts +++ b/tests/e2e/project.spec.ts @@ -12,6 +12,7 @@ import { sourceBrowsingUrl, test, } from "@tests/support/fixtures.js"; +import { changeBranch } from "@tests/support/project"; import { expectUrlPersistsReload } from "@tests/support/router"; test("navigate to project", async ({ page }) => { @@ -288,17 +289,12 @@ test("peer and branch switching", async ({ page }) => { // Alice's peer. { - await page.getByLabel("Change peer").click(); - await page - .getByRole("link", { - name: "alice delegate", - }) - .click(); - await expect(page.getByLabel("Change peer")).toHaveText("alice Delegate"); + await changeBranch("alice", `main ${shortAliceHead}`, page); + await expect(page.getByTitle("Change branch")).toHaveText(/alice/); // Default `main` branch. { - await expect(page.getByTitle("Change branch")).toHaveText("main"); + await expect(page.getByTitle("Change branch")).toHaveText(/main/); await expect( page .getByRole("button", { @@ -315,6 +311,7 @@ test("peer and branch switching", async ({ page }) => { // Feature branch with a slash in the name. { + await changeBranch("alice", "feature/branch", page); await page.getByTitle("Change branch").click(); await page.getByText("feature/branch").click(); @@ -333,8 +330,7 @@ test("peer and branch switching", async ({ page }) => { // Branch without a history or files in it. { - await page.getByTitle("Change branch").click(); - await page.getByText("orphaned-branch").click(); + await changeBranch("alice", "orphaned-branch", page); await expect( page.getByRole("button", { name: "orphaned-branch" }), @@ -356,8 +352,8 @@ test("peer and branch switching", async ({ page }) => { { await page.getByRole("link", { name: "source-browsing" }).nth(1).click(); - await expect(page.getByLabel("Change peer")).not.toContainText("alice"); - await expect(page.getByLabel("Change peer")).not.toContainText("bob"); + await expect(page.getByTitle("Change branch")).not.toContainText("alice"); + await expect(page.getByTitle("Change branch")).not.toContainText("bob"); await expect(page.getByTitle("Change branch")).toBeVisible(); await expect( @@ -372,10 +368,10 @@ test("peer and branch switching", async ({ page }) => { // Bob's peer. { - await page.getByLabel("Change peer").click(); - await page.getByRole("link", { name: "bob" }).click(); - await expect(page.getByLabel("Change peer")).toContainText("bob"); - await expect(page.getByLabel("Change peer")).not.toHaveText("delegate"); + await changeBranch("bob", `main ${shortBobHead}`, page); + await expect( + page.getByRole("button", { name: "avatar bob / main" }), + ).toBeVisible(); // Default `main` branch. { @@ -402,36 +398,22 @@ test("peer and branch switching", async ({ page }) => { test("only one modal can be open at a time", async ({ page }) => { await page.goto(sourceBrowsingUrl); - await page.getByLabel("Change peer").click(); - await page - .getByRole("link", { - name: "alice delegate", - }) - .click(); + await changeBranch("alice", `main ${shortAliceHead}`, page); await page.getByText("Clone").click(); await expect(page.getByText("Code font")).not.toBeVisible(); await expect(page.getByText("Use the Radicle CLI")).toBeVisible(); await expect(page.getByText("bob")).not.toBeVisible(); - await expect(page.getByText("feature/branch")).not.toBeVisible(); await page.getByRole("button", { name: "Settings" }).click(); await expect(page.getByText("Code font")).toBeVisible(); await expect(page.getByText("Use the Radicle CLI")).not.toBeVisible(); await expect(page.getByText("bob")).not.toBeVisible(); - await expect(page.getByText("feature/branch")).not.toBeVisible(); await page.getByTitle("Change branch").click(); await expect(page.getByText("Code font")).not.toBeVisible(); await expect(page.getByText("Use the Radicle CLI")).not.toBeVisible(); - await expect(page.getByText("bob")).not.toBeVisible(); - await expect(page.getByText("feature/branch")).toBeVisible(); - - await page.getByLabel("Change peer").click(); - await expect(page.getByText("Code font")).not.toBeVisible(); - await expect(page.getByText("Use the Radicle CLI")).not.toBeVisible(); await expect(page.getByText("bob")).toBeVisible(); - await expect(page.getByText("feature/branch")).not.toBeVisible(); }); test.describe("browser error handling", () => { diff --git a/tests/e2e/project/commit.spec.ts b/tests/e2e/project/commit.spec.ts index 401d4fa65..ed3f81085 100644 --- a/tests/e2e/project/commit.spec.ts +++ b/tests/e2e/project/commit.spec.ts @@ -1,23 +1,19 @@ import { aliceRemote, bobHead, - bobMainCommitCount, expect, shortBobHead, sourceBrowsingUrl, test, } from "@tests/support/fixtures.js"; +import { changeBranch } from "@tests/support/project"; import sinon from "sinon"; const commitUrl = `${sourceBrowsingUrl}/commits/${bobHead}`; test("navigation from commit list", async ({ page }) => { await page.goto(sourceBrowsingUrl); - await page.getByLabel("Change peer").click(); - await page.getByRole("link", { name: "bob" }).click(); - await page - .getByRole("link", { name: `Commits ${bobMainCommitCount}` }) - .click(); + await changeBranch("bob", `main ${shortBobHead}`, page); await page.getByText("Update readme").first().click(); await expect(page).toHaveURL(commitUrl); diff --git a/tests/e2e/project/commits.spec.ts b/tests/e2e/project/commits.spec.ts index c2953105b..c879099af 100644 --- a/tests/e2e/project/commits.spec.ts +++ b/tests/e2e/project/commits.spec.ts @@ -1,15 +1,15 @@ import { aliceMainCommitCount, aliceMainCommitMessage, - aliceMainHead, bobMainCommitCount, expect, gitOptions, + shortAliceHead, shortBobHead, sourceBrowsingUrl, test, } from "@tests/support/fixtures.js"; -import { createProject } from "@tests/support/project"; +import { changeBranch, createProject } from "@tests/support/project"; import sinon from "sinon"; test("peer and branch switching", async ({ page }) => { @@ -20,14 +20,11 @@ test("peer and branch switching", async ({ page }) => { // Alice's peer. { - await page.getByLabel("Change peer").click(); - await page - .getByRole("link", { - name: "alice delegate", - }) - .click(); + await changeBranch("alice", `main ${shortAliceHead}`, page); - await expect(page.getByLabel("Change peer")).toHaveText("alice Delegate"); + await expect(page.getByTitle("Change branch")).toHaveText( + "alice Delegate / main", + ); await expect(page.getByText("Thursday, November 17, 2022")).toBeVisible(); await expect(page.locator(".list .teaser")).toHaveCount( @@ -36,7 +33,7 @@ test("peer and branch switching", async ({ page }) => { const latestCommit = page.locator(".teaser").first(); await expect(latestCommit).toContainText(aliceMainCommitMessage); - await expect(latestCommit).toContainText(aliceMainHead.substring(0, 7)); + await expect(latestCommit).toContainText(shortAliceHead); const earliestCommit = page.locator(".teaser").last(); await expect(earliestCommit).toContainText( @@ -44,8 +41,7 @@ test("peer and branch switching", async ({ page }) => { ); await expect(earliestCommit).toContainText("36d5bbe"); - await page.getByTitle("Change branch").click(); - await page.getByText("feature/branch").click(); + await changeBranch("alice", "feature/branch", page); await expect( page.getByRole("button", { name: "feature/branch" }), @@ -53,8 +49,7 @@ test("peer and branch switching", async ({ page }) => { await expect(page.getByText("Thursday, November 17, 2022")).toBeVisible(); await expect(page.locator(".list .teaser")).toHaveCount(bobMainCommitCount); - await page.getByTitle("Change branch").click(); - await page.getByText("orphaned-branch").click(); + await changeBranch("alice", "orphaned-branch", page); await expect( page.getByRole("button", { name: "orphaned-branch" }), @@ -65,10 +60,9 @@ test("peer and branch switching", async ({ page }) => { // Bob's peer. { - await page.getByLabel("Change peer").click(); - await page.getByRole("link", { name: "bob" }).click(); + await changeBranch("bob", `main ${shortBobHead}`, page); - await expect(page.getByLabel("Change peer")).toContainText("bob"); + await expect(page.getByTitle("Change branch")).toContainText("bob"); await expect(page.getByText("Wednesday, December 21, 2022")).toBeVisible(); await expect(page.locator(".list").first().locator(".teaser")).toHaveCount( @@ -155,9 +149,8 @@ test("relative timestamps", async ({ page }) => { .getByRole("link", { name: `Commits ${aliceMainCommitCount}` }) .click(); - await page.getByLabel("Change peer").click(); - await page.getByRole("link", { name: "bob" }).click(); - await expect(page.getByLabel("Change peer")).toHaveText("bob"); + await changeBranch("bob", `main ${shortBobHead}`, page); + await expect(page.getByTitle("Change branch")).toHaveText(/bob/); const latestCommit = page.locator(".teaser").first(); await expect(latestCommit).toContainText( `Bob Belcher committed ${shortBobHead} now`, diff --git a/tests/support/project.ts b/tests/support/project.ts index 08d1ad870..6c645c87d 100644 --- a/tests/support/project.ts +++ b/tests/support/project.ts @@ -1,9 +1,16 @@ import type { Locator, Page } from "@playwright/test"; import type { RadiclePeer } from "@tests/support/peerManager"; -import * as Path from "node:path"; import { expect } from "@playwright/test"; import { readFileSync } from "node:fs"; +import * as Path from "node:path"; + +export async function changeBranch(peer: string, branch: string, page: Page) { + await page.getByTitle("Change branch").click(); + const peerLocator = page.getByLabel("peer-item").filter({ hasText: peer }); + await peerLocator.getByTitle("Expand peer").click(); + await page.getByRole("button", { name: branch }).click(); +} // Create a project using the rad CLI. export async function createProject(