File links, the file catalog, same-document navigation and the WebMCP open tools use the path scheme; the QA scripts follow.
- commit
- bdd572c1db4a716eb9761a8fd283bdad422b84d6
- parent
- f35f8dff89e66aebe8a44ec0fd7c41a71485a6a1
- author
- Dami <dami.etoile@gmail.com>
| M | scripts/ | 8 | ++++---- |
| M | scripts/ | 8 | +++++--- |
| M | scripts/ | 2 | +- |
| M | scripts/ | 6 | +++--- |
| M | scripts/ | 2 | +- |
| M | scripts/ | 19 | +++++++++++-------- |
| M | scripts/ | 4 | ++-- |
| M | scripts/ | 31 | +++++++++++++++++++++--------- |
| M | tinyclient/ | 2 | +- |
| M | tinyclient/ | 10 | +++++----- |
| M | tinyclient/ | 24 | +++++++++++++----------- |
| M | tinyclient/ | 28 | +++++++++++++++++++-------- |
@@ -131,12 +131,12 @@ const waitUntil = () => { let done; const event = {waitUntil(promise) { done = p
131131 test("push renders the relay's actions on the notification", async () => {132132 const w = worker();133133 const {event, finished} = waitUntil();134- event.data = {json: () => ({title: "tiny", body: "hermes asks: Publish?", url: "https://tiny.example/approvals?id=" + request, tag: "tiny-approvals", badge: 1, actions: [{action: "approve", title: "Approve"}, {action: "deny", title: "Deny"}, {action: "reply", title: "Reply"}, {action: "bad action"}]})};134+ event.data = {json: () => ({title: "tiny", body: "hermes asks: Publish?", url: "https://tiny.example/approvals/" + request, tag: "tiny-approvals", badge: 1, actions: [{action: "approve", title: "Approve"}, {action: "deny", title: "Deny"}, {action: "reply", title: "Reply"}, {action: "bad action"}]})};135135 w.handlers.push(event);136136 await finished();137137 assert.equal(w.shown.length, 1);138138 assert.equal(JSON.stringify(w.shown[0].options.actions), JSON.stringify([{action: "approve", title: "Approve"}, {action: "deny", title: "Deny"}, {action: "reply", title: "Reply"}]));139- assert.equal(w.shown[0].options.data.url, "https://tiny.example/approvals?id=" + request);139+ assert.equal(w.shown[0].options.data.url, "https://tiny.example/approvals/" + request);140140 const plain = worker();141141 const second = waitUntil();142142 second.event.data = {json: () => ({title: "tiny", body: "Replied to you"})};@@ -146,11 +146,11 @@ test("push renders the relay's actions on the notification", async () => {
146146 });147147 148148 test("a notification action opens the answer in the existing window, or a new one", async () => {149- const url = "https://tiny.example/approvals?id=" + request;149+ const url = "https://tiny.example/approvals/" + request;150150 const navigated = [];151151 const open = {url: "https://tiny.example/inbox", navigate: async target => { navigated.push(target); return {focus() { navigated.push("focused"); }}; }};152152 const existing = worker({windows: [open]});153- for (const [action, expected] of [["approve", url + "&answer=approve"], ["deny", url + "&answer=deny"], ["reply", url + "&answer=reply"], ["", url], [undefined, url]]) {153+ for (const [action, expected] of [["approve", url + "?answer=approve"], ["deny", url + "?answer=deny"], ["reply", url + "?answer=reply"], ["", url], [undefined, url]]) {154154 const {event, finished} = waitUntil();155155 event.action = action;156156 event.notification = {close() {}, data: {url}};@@ -31,10 +31,10 @@ function setup({path = "/r/test/files", actor = pubkey} = {}) {
3131 return {context, values, rows};3232 }3333 34-const link = () => `https://relay.test/r/test/file?hash=${hash}#key=${"c".repeat(64)}&iv=${"d".repeat(16)}&name=video.mp4&type=video%2Fmp4`;34+const link = () => `https://relay.test/r/test/file/${hash}#key=${"c".repeat(64)}&iv=${"d".repeat(16)}&name=video.mp4&type=video%2Fmp4`;3535 3636 test("saves encrypted metadata per tenant and account and restores it after reload", () => {37- const first = setup({path: "/r/test/file?hash=" + hash});37+ const first = setup({path: "/r/test/file/" + hash});3838 assert.equal(first.context.tiny.files.catalog.save({hash, link: link(), name: "video.mp4", type: "video/mp4", size: 104857600}, pubkey), true);3939 assert.equal(first.context.tiny.files.catalog.get(hash, pubkey).name, "video.mp4");4040 assert.equal(first.context.tiny.files.catalog.get(hash, "e".repeat(64)), null);@@ -44,7 +44,9 @@ test("saves encrypted metadata per tenant and account and restores it after relo
4444 4545 test("rejects unsafe links, names and cross-tenant links", () => {4646 const {context} = setup();47- assert.equal(context.tiny.files.catalog.save({hash, link: "https://evil.test/r/test/file?hash=" + hash + "#key=x", name: "x"}, pubkey), false);47+ assert.equal(context.tiny.files.catalog.save({hash, link: "https://evil.test/r/test/file/" + hash + "#key=x", name: "x"}, pubkey), false);4848 assert.equal(context.tiny.files.catalog.save({hash, link: link(), name: "../secret"}, pubkey), false);4949 assert.equal(context.tiny.files.catalog.save({hash, link: link().replace("/r/test/", "/r/other/"), name: "x"}, pubkey), false);50+ assert.equal(context.tiny.files.catalog.save({hash, link: link().replace("/file/" + hash, "/file?hash=" + hash), name: "x"}, pubkey), false);51+ assert.equal(context.tiny.files.catalog.save({hash, link: link().replace("/file/" + hash, "/file/" + "f".repeat(64)), name: "x"}, pubkey), false);5052 });@@ -50,7 +50,7 @@ const setup = () => {
5050 document.dispatchEvent = () => {};5151 document.querySelectorAll = () => [];5252 const customElements = {registry: {}, define(name, ctor) { this.registry[name] = ctor; }};53- const location = {href: "https://relay.test/file?hash=cipher", origin: "https://relay.test", pathname: "/file", hash: ""};53+ const location = {href: "https://relay.test/r/test/file/cipher", origin: "https://relay.test", pathname: "/r/test/file/cipher", hash: ""};5454 const window = {document, customElements, location, addEventListener: () => {}, tiny: {util: {}, blossom: {encryption: {}}}};5555 class HTMLElement extends Node {}5656 Object.assign(globalThis, {window, document, customElements, HTMLElement, location, Blob, URL, CustomEvent: class {}});@@ -159,7 +159,7 @@ test("nested folder components upload and decrypt large files without exposing k
159159 160160 test("plain uploads retain names and folder paths within the current library folder", async () => {161161 const {window, FileUpload} = load(true);162- window.location.href = "https://relay.test/files?view=library&path=Projects";162+ window.location.href = "https://relay.test/files?path=Projects";163163 const requests = [];164164 window.tiny.signedFetch = async (target, method, bytes) => requests.push({target, method, bytes});165165 const upload = new FileUpload();@@ -172,7 +172,7 @@ test("plain uploads retain names and folder paths within the current library fol
172172 assert.equal(first.searchParams.get("path"), "Projects/Trip/photos/snow & sun.jpg");173173 assert.equal(new URL(requests[1].target, window.location.href).searchParams.get("path"), "Projects/notes.txt");174174 assert.equal(new TextDecoder().decode(requests[0].bytes), "image");175- window.location.href = "https://relay.test/files?view=sites&path=weather";175+ window.location.href = "https://relay.test/files/sites?path=weather";176176 await upload.storePlain([new File(["file"], "new.txt")]);177177 assert.equal(new URL(requests[2].target, window.location.href).searchParams.get("path"), "new.txt");178178 });@@ -198,7 +198,7 @@ test("encrypted upload never touches the clipboard until Copy is pressed", async
198198 navigator.clipboard.writeText = async () => { copies++; throw Error("denied"); };199199 const {upload, link, uploaded} = await sealedUpload(window, FileUpload, new File(["secret plaintext"], "secret.txt", {type: "text/plain"}));200200 assert.notEqual(new TextDecoder().decode(uploaded()), "secret plaintext");201- assert.match(link, /\/file\?hash=[0-9a-f]{64}#key=.*&iv=/);201+ assert.match(link, /\/file\/[0-9a-f]{64}#key=.*&iv=/);202202 assert.equal(upload.querySelector("[data-share-url]").value, link);203203 assert.equal(upload.querySelector("[data-share]").hidden, false);204204 assert.equal(copies, 0);@@ -22,7 +22,7 @@ async page => {
2222 await page.evaluate(() => { window.__uxMarker = 'preserved'; });2323 await run('repository navigation preserves document and ref filters', async () => {2424 await page.getByRole('link', {name:'ux-edge-repo',exact:true}).click();25- await page.waitForURL('**/repo?**');25+ await page.waitForURL('**/repos/*/*');2626 await clickNav('/code');2727 await page.locator('#ref-select').selectOption('refs/heads/feature/café');2828 await page.locator('#panel form').getByRole('button', {name:'go',exact:true}).click();@@ -17,10 +17,13 @@ const add = (name, path, query = "") => { for (const prefix of prefixes) cases.p
1717 1818 for (const q of ["", "?limit=-1&cursor=%00", `?q=${encodeURIComponent(unusual)}&limit=999999`, "?owner=%00&ref=refs%2Fheads%2Fmain&path=%2F..%2F"])1919 add("search", "/search" + q);20-for (const q of ["", `?owner=${encodeURIComponent(unusual)}&repo=${encodeURIComponent(unusual)}&ref=%00&path=${encodeURIComponent(unusual)}`, "?owner=%00&repo=%00&limit=NaN&cursor=%00"])21- add("repo", "/repo" + q);22-for (const q of ["", `?hash=${encodeURIComponent(unusual)}&limit=-1&cursor=%00`, "?sha=%00&hash=not-a-sha"])23- add("file", "/file" + q);20+const repoPage = (owner, repo, rest = "") => `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}${rest}`;21+for (const path of ["/repos/", repoPage(unusual, unusual), repoPage(unusual, unusual, `/tree/${encodeURIComponent(unusual)}?ref=%00`), repoPage(unusual, unusual, `/file/${encodeURIComponent(unusual)}?ref=%00`), repoPage("\u0000", "\u0000", "/history?limit=NaN&cursor=%00"), repoPage("\u0000", "\u0000", "/commit/%00"), repoPage("\u0000", "\u0000", "/issues/not-an-id"), repoPage("\u0000", "\u0000", "/prs/%00")])22+ add("repo", path);23+for (const path of ["/file/", `/file/${encodeURIComponent(unusual)}?limit=-1&cursor=%00`, "/file/%00", "/file/not-a-sha", "/file?hash=" + "0".repeat(64)])24+ add("file", path);25+for (const path of ["/approvals/", "/approvals/%00", "/approvals/not-an-id", "/approvals?id=" + "0".repeat(64)])26+ add("approval", path);2427 for (const id of ["", "not-an-event", "0".repeat(64), unusual, manifest.notes[0].id]) add("event", "/e/" + encodeURIComponent(id));2528 2629 const results = [];@@ -52,11 +55,11 @@ await validGet("note", `/e/${note.id}`, note.id);
5255 await validGet("article", "/a/ux-long-article", "A long article");5356 await validGet("articles", "/articles", "A long article");5457 await validGet("files", "/files", "Files");55-await validGet("file", `/file?hash=${file.sha256}`, file.sha256);58+await validGet("file", `/file/${file.sha256}`, file.sha256);5659 await validGet("repos", "/repos", manifest.repository.id);57-await validGet("repo home", `/repo?owner=${manifest.owner}&repo=${manifest.repository.id}`, manifest.repository.id);58-await validGet("repo tree", `/repo?owner=${manifest.owner}&repo=${manifest.repository.id}&view=tree&ref=refs%2Fheads%2Fmain`, "README UX fixture.md");59-await validGet("repo file", `/repo?owner=${manifest.owner}&repo=${manifest.repository.id}&view=file&ref=refs%2Fheads%2Fmain&path=${encodeURIComponent("nested dir/Unicode café/emoji 🧪.md")}`, "unicode path content");60+await validGet("repo home", repoPage(manifest.owner, manifest.repository.id), manifest.repository.id);61+await validGet("repo tree", repoPage(manifest.owner, manifest.repository.id, "/tree?ref=refs%2Fheads%2Fmain"), "README UX fixture.md");62+await validGet("repo file", repoPage(manifest.owner, manifest.repository.id, "/file/" + "nested dir/Unicode café/emoji 🧪.md".split("/").map(encodeURIComponent).join("/") + "?ref=refs%2Fheads%2Fmain"), "unicode path content");6063 6164 const report = { schema: 1, fixture: manifest.fixture, relay: base, generatedAt: new Date().toISOString(), cases: cases.length, fuzz: results.length, validCases: valid.length, passed: [...results, ...valid].filter(r => r.ok).length, failed: [...results, ...valid].filter(r => !r.ok).length, results, valid };6265 await mkdir(dirname(out), { recursive: true }); await writeFile(out, JSON.stringify(report, null, 2) + "\n");@@ -116,13 +116,13 @@ test("return targets reject external addresses, tenant changes and sign-in loops
116116 });117117 118118 test("sign-in links carry private fragments only in the browser fragment", async () => {119- const target = "/r/work/file?hash=abc#key=private-key&name=notes.txt";119+ const target = "/r/work/file/abc#key=private-key&name=notes.txt";120120 const {sandbox, documentEvents} = await page({href: "https://tiny.example" + target});121121 const attrs = new Map([["fx-action", "/r/work/signin"]]);122122 const link = {href: "https://tiny.example/r/work/signin", hasAttribute: name => attrs.has(name), setAttribute: (name,value) => attrs.set(name,value)};123123 documentEvents.get("click")({target: {closest: () => link}});124124 const login = new URL(link.href, sandbox.location.href);125- assert.equal(login.searchParams.get("next"), "/r/work/file?hash=abc");125+ assert.equal(login.searchParams.get("next"), "/r/work/file/abc");126126 assert.equal(login.search.includes("private-key"), false);127127 assert.equal(new URLSearchParams(login.hash.slice(1)).get("return"), target);128128 assert.equal(attrs.get("fx-action"), link.href);@@ -97,11 +97,25 @@ test("repository storage reads pass the selected owner and repository", async ()
9797 9898 test("navigation actually opens the selected repository without changing tenant", async () => {9999 const {tools, sandbox} = await browser({path: "/r/work/tools"});100- await tools.get("tiny.open_repository").execute({owner: "a".repeat(64), repo: "notes", path: "hello world.go", view: "file"});100+ const open = tools.get("tiny.open_repository");101+ const owner = "a".repeat(64);102+ await open.execute({owner, repo: "notes", path: "src/hello world.go", view: "file", ref: "refs/heads/main"});101103 const url = new URL(sandbox.opened);102- assert.equal(url.pathname, "/r/work/repo");103- assert.equal(url.searchParams.get("path"), "hello world.go");104- assert.equal(url.searchParams.get("view"), "file");104+ assert.equal(url.pathname, "/r/work/repos/" + owner + "/notes/file/src/hello%20world.go");105+ assert.equal(url.searchParams.get("ref"), "refs/heads/main");106+ const opened = async input => { await open.execute({owner, repo: "my notes", ...input}); return new URL(sandbox.opened).pathname + new URL(sandbox.opened).search; };107+ assert.equal(await opened({}), "/r/work/repos/" + owner + "/my%20notes");108+ assert.equal(await opened({view: "tree"}), "/r/work/repos/" + owner + "/my%20notes/tree");109+ assert.equal(await opened({view: "tree", path: "docs/caf\u00e9"}), "/r/work/repos/" + owner + "/my%20notes/tree/docs/caf%C3%A9");110+ assert.equal(await opened({view: "history"}), "/r/work/repos/" + owner + "/my%20notes/history");111+ assert.equal(await opened({view: "commit", id: "b".repeat(40)}), "/r/work/repos/" + owner + "/my%20notes/commit/" + "b".repeat(40));112+ assert.equal(await opened({view: "activity"}), "/r/work/repos/" + owner + "/my%20notes/activity");113+ assert.equal(await opened({view: "issues"}), "/r/work/repos/" + owner + "/my%20notes/issues");114+ assert.equal(await opened({view: "prs"}), "/r/work/repos/" + owner + "/my%20notes/prs");115+ assert.equal(await opened({view: "issue", id: "c".repeat(64)}), "/r/work/repos/" + owner + "/my%20notes/issues/" + "c".repeat(64));116+ assert.equal(await opened({view: "pr", id: "d".repeat(64)}), "/r/work/repos/" + owner + "/my%20notes/prs/" + "d".repeat(64));117+ await tools.get("tiny.open_file").execute({hash: "e".repeat(64)});118+ assert.equal(new URL(sandbox.opened).pathname, "/r/work/file/" + "e".repeat(64));105119 });106120 107121 test("registration failures stay visible while other tools remain available", async () => {@@ -170,9 +184,8 @@ test("approval tools list, read and open requests for a decision", async () => {
170184 assert.equal(new URL(sandbox.opened).pathname, "/r/work/approvals");171185 await tools.get("tiny.open_approvals").execute({id: "c".repeat(64)});172186 const opened = new URL(sandbox.opened);173- assert.equal(opened.pathname, "/r/work/approvals");174- assert.equal(opened.searchParams.get("id"), "c".repeat(64));175- assert.equal(opened.searchParams.get("answer"), null);187+ assert.equal(opened.pathname, "/r/work/approvals/" + "c".repeat(64));188+ assert.equal(opened.search, "");176189 });177190 178191 test("job tools list and read long tasks through the session", async () => {@@ -331,8 +344,8 @@ test("wiki tools read pages and merge requests and open pages in the tenant", as
331344 assert.equal(new URL(sandbox.opened).pathname, "/r/work/wiki");332345 await open.execute({d: "日本語 Article", merge: "c".repeat(64)});333346 let opened = new URL(sandbox.opened);334- assert.equal(opened.pathname, "/r/work/wiki/" + encodeURIComponent("日本語 Article"));335- assert.equal(opened.searchParams.get("merge"), "c".repeat(64));347+ assert.equal(opened.pathname, "/r/work/wiki/" + encodeURIComponent("日本語 Article") + "/proposals/" + "c".repeat(64));348+ assert.equal(opened.search, "");336349 await open.execute({d: "release-notes-1-4", edit: true, version: "b".repeat(64)});337350 opened = new URL(sandbox.opened);338351 assert.equal(opened.pathname, "/r/work/wiki/release-notes-1-4");@@ -1774,7 +1774,7 @@
17741774 if (root && !(url.pathname === root || url.pathname.startsWith(root + "/"))) return null;17751775 return root ? url.pathname.slice(root.length) || "/" : url.pathname;17761776 };1777- const allowedRoute = path => /^(?:\/(?:inbox|approvals|profile|account|outbox|search|social|articles|private|chat|media|sites|marmot|grasp|terms|signin|connect|tools|repo|repos|file|files|wiki|rooms)?\/?|\/manage(?:\/(?:people|agents|moderation|rules|identity|connect|data|sync|views|health|owner|status))?\/?|\/(?:invite|e|a|wiki|social)\/.+|\/chat\/dm\/[0-9a-f]{64}|\/rooms\/[a-z0-9_-]{1,64}(?:\/thread\/[0-9a-f]{64})?\/?)$/.test(path || "");1777+ const allowedRoute = path => /^(?:\/(?:inbox|approvals|profile|account|outbox|search|social|articles|private|chat|media|sites|marmot|grasp|terms|signin|connect|tools|repo|repos|file|files|wiki|rooms)?\/?|\/manage(?:\/(?:people|agents|moderation|rules|identity|connect|data|sync|views|health|owner|status))?\/?|\/manage\/agents\/[^/]+\/?|\/(?:invite|e|a|wiki|social)\/.+|\/repos\/[^/]+\/[^/]+(?:\/.*)?|\/file\/[0-9a-f]{64}\/?|\/files\/(?:sites|rooms|storage)\/?|\/approvals\/[0-9a-f]{64}\/?|\/chat\/dm\/[0-9a-f]{64}|\/rooms\/[a-z0-9_-]{1,64}(?:\/thread\/[0-9a-f]{64})?\/?)$/.test(path || "");17781778 let navigationSerial = 0, activeAbort;17791779 let renderedURL = location.href;17801780 const streams = new Set();@@ -40,13 +40,14 @@
4040 value.isWellFormed?.() !== false && !/[\u0000/\\]/.test(value);4141 const validPath = value => value === undefined ||4242 (typeof value === "string" && value.length <= MAX_NAME && !/[\u0000\\]/.test(value));43+ // A stored file page is /file/{hash}; the hash is the last path segment.44+ const fileHash = url => (url.pathname.match(/(?:^|\/)file\/([^/]+)$/) || [])[1] || null;4345 const validLink = (value, hash) => {4446 if (typeof value !== "string") return false;4547 try {4648 const url = new URL(value, location.href);47- const expected = new URL(globalThis.tiny?.localPath?.("/file") || "/file", location.href);49+ const expected = new URL((globalThis.tiny?.localPath?.("/file/") || "/file/") + hash, location.href);4850 if (url.origin !== location.origin || url.pathname !== expected.pathname) return false;49- if (url.searchParams.get("hash") !== hash && url.searchParams.get("sha") !== hash) return false;5051 const params = new URLSearchParams(url.hash.slice(1));5152 return Boolean((params.get("key") && params.get("iv")) ||5253 (params.get("enc") === "chk-v1" && params.get("key")) ||@@ -121,14 +122,13 @@
121122 };122123 const restore = pubkey => {123124 const current = new URL(location.href);124- if (!/(?:^|\/)file$/.test(current.pathname)) return null;125- const hash = current.searchParams.get("hash") || current.searchParams.get("sha");125+ const hash = fileHash(current);126126 const fragment = new URLSearchParams(current.hash.slice(1));127127 if (!HASH.test(hash || "") || fragment.get("key") || fragment.get("iv")) return null;128128 const entry = entryFor(hash, pubkey);129129 if (!entry) return null;130130 const saved = new URL(entry.link);131- if (saved.searchParams.get("hash") !== hash && saved.searchParams.get("sha") !== hash) return null;131+ if (fileHash(saved) !== hash) return null;132132 history.replaceState?.(null, "", saved.pathname + saved.search + saved.hash);133133 return entry;134134 };@@ -145,9 +145,14 @@
145145 );146146 }147147 148+ // A stored file page is /file/{hash}; the hash is the last path segment.149+ const fileHash = url => {150+ const segment = (url.pathname.match(/\/file\/([^/]+)\/?$/) || [])[1] || "";151+ try { return decodeURIComponent(segment); } catch { return segment; }152+ };153+148154 const shareURL = (reference, name, type) => {149- const url = new URL(tiny.localPath("/file"), location.href);150- url.searchParams.set("hash", reference.hash);155+ const url = new URL(tiny.localPath("/file/" + reference.hash), location.href);151156 url.hash = new URLSearchParams({152157 manifest: reference.hash,153158 key: hex(reference.key),@@ -304,8 +309,7 @@
304309 this.say("Background upload of " + info.name + " finished.");305310 return tiny.navigate?.(location.href);306311 }307- const link = new URL(tiny.localPath("/file"), location.href);308- link.search = "?hash=" + descriptor.sha256;312+ const link = new URL(tiny.localPath("/file/" + descriptor.sha256), location.href);309313 link.hash = info.fragment;310314 const params = new URLSearchParams(info.fragment);311315 await this.remember(link.href, [{name: info.name, size: info.size || Math.max(0, descriptor.size - 16), type: params.get("type") || "application/octet-stream"}], false);@@ -478,8 +482,8 @@
478482 }479483 }480484 async storePlain(files) {481- const query = new URL(location.href).searchParams;482- const parent = (!query.get("view") || query.get("view") === "library") ? query.get("path") || "" : "";485+ const current = new URL(location.href);486+ const parent = /(?:^|\/)files\/?$/.test(current.pathname) ? current.searchParams.get("path") || "" : "";483487 const total = files.reduce((size, file) => size + file.size, 0);484488 let sent = 0;485489 for (const [index, file] of files.entries()) {@@ -576,8 +580,7 @@
576580 }577581 if (descriptor.sha256 !== hash) throw Error("Relay returned an unexpected hash.");578582 this.pending = null;579- const link = new URL(tiny.localPath("/file"), location.href);580- link.search = "?hash=" + hash;583+ const link = new URL(tiny.localPath("/file/" + hash), location.href);581584 link.hash = fragment;582585 return link.href;583586 }@@ -607,7 +610,7 @@
607610 async remember(link, files, folder) {608611 const url = new URL(link);609612 const params = new URLSearchParams(url.hash.slice(1));610- const hash = url.searchParams.get("hash");613+ const hash = fileHash(url);611614 const name = params.get("name") || files[0].name;612615 const type = params.get("type") || files[0].type || "application/octet-stream";613616 const size = files.reduce((sum, file) => sum + file.size, 0);@@ -764,8 +767,7 @@
764767 const item = element("li");765768 if (link.t === 2 || link.t === 3) {766769 const open = element("a", link.n + "/");767- const url = new URL(tiny.localPath("/file"), location.href);768- url.searchParams.set("hash", hex(link.h));770+ const url = new URL(tiny.localPath("/file/" + hex(link.h)), location.href);769771 const fragment = new URLSearchParams({770772 manifest: hex(link.h),771773 node: String(link.t),@@ -157,30 +157,42 @@
157157 location.assign(url.href);158158 return {opened: url.href};159159 }160+ // Repository pages live at /repos/{owner}/{repo}/{view}...; every segment is161+ // percent-encoded except that "/" inside a repository file path stays a separator.162+ const segment = value => encodeURIComponent(String(value));163+ const filePath = value => String(value).split("/").filter(Boolean).map(segment).join("/");160164 function repoURL(input) {161- const params = new URLSearchParams();162- for (const key of ["owner", "repo", "ref", "path", "view", "id"]) {163- if (input[key] !== undefined) params.set(key, input[key]);165+ let path = "/repos/" + segment(input.owner) + "/" + segment(input.repo);166+ switch (input.view) {167+ case "tree": path += "/tree" + (input.path ? "/" + filePath(input.path) : ""); break;168+ case "file": path += "/file/" + filePath(input.path || ""); break;169+ case "history": path += "/history"; break;170+ case "commit": path += "/commit/" + segment(input.id || ""); break;171+ case "activity": path += "/activity"; break;172+ case "issues": path += "/issues"; break;173+ case "prs": path += "/prs"; break;174+ case "issue": path += "/issues/" + segment(input.id || ""); break;175+ case "pr": path += "/prs/" + segment(input.id || ""); break;164176 }165- return "/repo?" + params;177+ return input.ref !== undefined ? path + "?ref=" + encodeURIComponent(String(input.ref)) : path;166178 }167179 register("tiny.open_repository", "Open a repository in this tab for browsing code, history or activity.",168180 object({...repository, id: hash, view: {type: "string", enum: ["tree", "file", "history", "commit", "activity", "issues", "prs", "issue", "pr"]}}, ["owner", "repo"]), {}, input => open(repoURL(input)));169181 register("tiny.open_file", "Open a stored file by its SHA-256 hash in this tab. For repository source, use tiny.open_repository with view=file.",170- object({hash}, ["hash"]), {}, input => open("/file?hash=" + encodeURIComponent(input.hash)));182+ object({hash}, ["hash"]), {}, input => open("/file/" + encodeURIComponent(input.hash)));171183 register("tiny.open_status", "Open the relay health page in this tab: build versions, service status and browser tool readiness.", object(), {}, () => open("/manage/health"));172184 register("tiny.open_files", "Open the Files page in this tab, where files and folders upload and shared items wait.", object(), {}, () => open("/files"));173185 register("tiny.open_approvals", "Open the Approvals page in this tab, where requests for a decision wait. Pass id to focus one request; the person answers with a signed tap.",174- object({id: hash}), {}, input => open("/approvals" + (input.id ? "?id=" + encodeURIComponent(input.id) : "")));186+ object({id: hash}), {}, input => open("/approvals" + (input.id ? "/" + encodeURIComponent(input.id) : "")));175187 register("tiny.open_wiki_page", "Open a wiki page in this tab. Omit d for the page list. version opens one version by event id; merge opens the compare view for a merge request; edit opens the editor.",176188 object({d: pageName, version: hash, merge: hash, edit: {type: "boolean"}}), {}, input => {177189 if (!input.d) return open("/wiki");178190 const params = new URLSearchParams();179191 if (input.version) params.set("version", input.version);180- if (input.merge) params.set("merge", input.merge);181192 if (input.edit) params.set("edit", "1");182193 const search = params.toString();183- return open("/wiki/" + encodeURIComponent(input.d) + (search ? "?" + search : ""));194+ const page = "/wiki/" + encodeURIComponent(input.d) + (input.merge ? "/proposals/" + encodeURIComponent(input.merge) : "");195+ return open(page + (search ? "?" + search : ""));184196 });185197 register("tiny.open_room", "Open a chat room in this tab, or the rooms list when no id is given.",186198 object({id: room}), {}, input => open(input.id ? "/rooms/" + encodeURIComponent(input.id) : "/rooms"));