Keep the browsed file in the source view route

Browsing the file tree only changed component state, so no file view
was ever a history entry and back left the repository. The path now
travels as a query parameter, since trailing URL segments are already
consumed by the revision.
This commit is contained in:
Rūdolfs Ošiņš
2026-10-06 16:28:14 +02:00
parent 651af122b2
commit 67785a8c30
7 changed files with 103 additions and 17 deletions
+3 -3
View File
@@ -3,12 +3,12 @@
const {
name,
fetchBlob,
onSelect,
active,
indent = 0.5,
}: {
name: string;
fetchBlob: () => Promise<void>;
onSelect: () => void;
active: boolean;
indent?: number;
} = $props();
@@ -41,7 +41,7 @@
class:active
style:padding-left="{indent}rem"
style:padding-right="0.5rem"
onclick={fetchBlob}>
onclick={onSelect}>
<div class="global-flex" style:padding="0.25rem 0">
<div class="icon txt-missing">
<Icon name="document" />
+4 -4
View File
@@ -7,7 +7,7 @@
interface Props {
fetchTree: (path: string) => Promise<Tree>;
fetchBlob: (path: string) => Promise<void>;
onSelect: (path: string) => void;
currentPath: string;
name: string;
prefix: string;
@@ -20,7 +20,7 @@
const {
name,
fetchBlob,
onSelect,
currentPath,
prefix,
isExpanded,
@@ -71,7 +71,7 @@
{#if entry.kind === "tree"}
<FileTreeFolder
{fetchTree}
{fetchBlob}
{onSelect}
name={entry.name}
{currentPath}
{isExpanded}
@@ -81,7 +81,7 @@
{:else if entry.kind === "blob"}
<FileTreeFile
name={entry.name}
fetchBlob={() => fetchBlob(entry.path)}
onSelect={() => onSelect(entry.path)}
active={entry.path === currentPath}
indent={indent + 1.5} />
{/if}
+4 -4
View File
@@ -11,10 +11,10 @@
tree: Tree;
currentPath: string;
fetchTree: (path: string) => Promise<Tree>;
fetchBlob: (path: string) => Promise<void>;
onSelect: (path: string) => void;
}
const { currentPath, tree, fetchTree, fetchBlob }: Props = $props();
const { currentPath, tree, fetchTree, onSelect }: Props = $props();
// Manual expand/collapse overrides, keyed by folder prefix. Held here
// rather than in FileTreeFolder so the state survives the virtualizer
@@ -50,11 +50,11 @@
{isExpanded}
{toggleExpanded}
{fetchTree}
{fetchBlob} />
{onSelect} />
{:else}
<FileTreeFile
name={entry.name}
fetchBlob={() => fetchBlob(entry.path)}
onSelect={() => onSelect(entry.path)}
active={entry.path === currentPath} />
{/if}
{/snippet}
+27 -5
View File
@@ -10,6 +10,7 @@
import { useOverlayScrollbars } from "overlayscrollbars-svelte";
import { invoke, InvokeError } from "@app/lib/invoke";
import * as router from "@app/lib/router";
import type { SidebarData } from "@app/lib/router/definitions";
import { highlight } from "@app/lib/syntax";
@@ -63,15 +64,32 @@
let codeElement: HTMLElement | undefined = $state();
let error: InvokeError | undefined = $state();
// The file that was last opened, so that reloading the same route doesn't
// fetch it again, while switching revisions on the same path does.
let openedFile: string | undefined;
// The shown file lives in the route, so browsing the tree and following
// links inside a document both go through history.
$effect(() => {
if (path) {
void openPath(path);
} else {
if (!path) {
openedFile = undefined;
blob = readme;
currentPath = readme?.path || "";
error = undefined;
return;
}
const file = `${repo.rid}:${oid}:${path}`;
if (file !== openedFile) {
openedFile = file;
void openPath(path);
}
});
function showPath(filePath: string) {
void router.push({ ...baseRoute, path: filePath });
}
function isMarkdownPath(path: string): boolean {
return /\.(md|mkd|markdown)$/i.test(path);
}
@@ -203,7 +221,11 @@
{#if tree.entries.length > 0}
<ScrollArea
style="border-right: 1px solid var(--color-border-subtle); flex: 1; min-height: 0; width: 100%; padding: 0.5rem;">
<TreeComponent {tree} {currentPath} {fetchTree} {fetchBlob} />
<TreeComponent
{tree}
{currentPath}
{fetchTree}
onSelect={showPath} />
</ScrollArea>
{/if}
</div>
@@ -298,7 +320,7 @@
rid={repo.rid}
sha={oid}
path={currentPath}
onNavigate={fetchBlob} />
onNavigate={showPath} />
{:else}
<Markdown content={blob.content} />
{/if}
+2
View File
@@ -34,6 +34,8 @@ export interface RepoHomeRoute {
rid: string;
peer?: string;
revision?: string;
// The file shown in the source view. Trailing URL segments are already
// consumed by `revision`, so this travels as a query parameter.
path?: string;
}
+54 -1
View File
@@ -25,8 +25,13 @@ test("open a relative link in the source view", async ({ page }) => {
const url = page.url();
await readme.getByRole("link", { name: "the notes" }).click();
// The link opens the file instead of navigating the app away.
// The link opens the file in the source view rather than navigating the
// window away, and the file it opened is part of the history.
await expect(page.getByText("Notes from a sibling file.")).toBeVisible();
expect(page.url()).not.toBe(url);
await page.goBack();
await expect(readme.getByText("Alpha section text.")).toBeVisible();
expect(page.url()).toBe(url);
});
@@ -94,6 +99,54 @@ test("bound the reads of recursive includes", async ({ page }) => {
expect(blobReads).toBeLessThanOrEqual(260);
});
test("go back from a file opened in the tree", async ({ page }) => {
await page.goto("/inbox");
await page.getByRole("link", { name: "asciidoc" }).click();
const readme = page.locator(".asciidoc");
await expect(readme.getByText("Alpha section text.")).toBeVisible();
const url = page.url();
await page.getByText("notes.txt", { exact: true }).click();
await expect(page.getByText("Notes from a sibling file.")).toBeVisible();
await expect(page).toHaveURL(/\?path=notes\.txt/);
await page.goBack();
await expect(readme.getByText("Alpha section text.")).toBeVisible();
expect(page.url()).toBe(url);
});
test("show the same path at another revision", async ({ page }) => {
await page.goto("/inbox");
const commit = page.waitForResponse(
response => new URL(response.url()).pathname === "/repo_commit",
);
await page.getByRole("link", { name: "asciidoc" }).click();
const { parents } = (await (await commit).json()) as { parents: string[] };
const rid = page.url().match(/\/repos\/(rad:[^/?]+)/)?.[1];
await page.getByText("notes.txt", { exact: true }).click();
await expect(page.getByText("Notes from a sibling file.")).toBeVisible();
// Follow an in-app link to the same file at the previous commit, so the
// view is reused with only the revision changed.
await page.evaluate(href => {
const anchor = document.createElement("a");
anchor.href = href;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
}, `/repos/${rid}/home/${parents[0]}?path=notes.txt`);
await expect(
page.getByText("An earlier version of the notes."),
).toBeVisible();
await expect(page.getByText("Notes from a sibling file.")).toHaveCount(0);
await page.goBack();
await expect(page.getByText("Notes from a sibling file.")).toBeVisible();
});
test("sanitize untrusted markup in an AsciiDoc readme", async ({ page }) => {
await page.goto("/inbox");
await page.getByRole("link", { name: "asciidoc" }).click();
+9
View File
@@ -597,6 +597,15 @@ export async function createMarkdownFixture(peer: RadiclePeer) {
export async function createAsciidocFixture(peer: RadiclePeer) {
const { repoFolder } = await createRepo(peer, { name: "asciidoc" });
// An older version of a file, so a test can show the same path at two
// revisions.
await Fs.writeFile(
Path.join(repoFolder, "notes.txt"),
"An earlier version of the notes.\n",
);
await peer.git(["add", "."], { cwd: repoFolder });
await peer.git(["commit", "-m", "Add notes"], { cwd: repoFolder });
await Fs.writeFile(
Path.join(repoFolder, "README.adoc"),
[