feat(web): remove browser upload UI — content enters via local sync only (for now)

Topbar button, ⋯ menu entry, palette action, hidden file input, and the
upload plumbing (upload.ts) are gone; the server upload API stays (devices
and the store proxy depend on it). Spec reworked to seed via API and assert
the affordance is absent. 44/44 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VbiaaVM2ACxeRi8ySG9ybc
This commit is contained in:
Snow Lee
2026-07-19 09:48:17 -07:00
co-authored by Claude Fable 5
parent 40a0b19aa4
commit 51ce477b07
6 changed files with 27 additions and 154 deletions
+11 -8
View File
@@ -113,17 +113,20 @@ test("share mints a public link that serves the file, revoke kills it", async ({
expect(gone.status()).toBe(404);
});
test("upload into the selected folder, then the file opens", async ({ page }) => {
test("no browser upload: content arrives via sync; the tree picks it up", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/notes`);
await page.locator("#upload-btn").waitFor();
await page.setInputFiles('input[type="file"]', {
name: "dropped.md",
mimeType: "text/markdown",
buffer: Buffer.from("# Dropped\n\nUploaded through the browser.\n"),
});
await page.waitForURL(`/${pid}/notes/dropped.md`);
// The upload affordance is gone everywhere — content enters via local sync.
await expect(page.locator("#upload-btn")).toHaveCount(0);
await expect(page.locator('input[type="file"]')).toHaveCount(0);
// A file lands through the device/store path (simulated via the API)…
await page.request.put(
`/api/p/${pid}/upload/content?path=${encodeURIComponent("notes/dropped.md")}`,
{ data: "# Dropped\n\nArrived through sync.\n" },
);
// …and the polling tree shows it; opening renders it.
await page.goto(`/${pid}/notes/dropped.md`);
await expect(page.locator("#content h1")).toHaveText("Dropped");
await expect(page.locator('#tree .row[data-path="notes/dropped.md"]')).toBeVisible();
});
+4 -44
View File
@@ -10,7 +10,6 @@ import type { Project, ServerConfig } from "../api/types";
import { useHeat, useTree } from "../hooks/useBrowse";
import { urlForPath, urlForView, type Route } from "../router";
import { currentNavType, navigate, useLocationPath } from "../nav";
import { uploadFile } from "../upload";
import { copyText } from "../util";
import { toast } from "../toast";
import { AppShell, Icon, Topbar, closeSidebarOnMobile } from "../components/shell";
@@ -138,17 +137,16 @@ export default function Browser(props: {
/* ---- topbar state + actions ---- */
const [meta, setMeta] = useState("");
const [uploadStatus, setUploadStatus] = useState("");
const [share, setShare] = useState<{ url: string; copied: boolean } | null>(null);
const [moreOpen, setMoreOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
const uploadInput = useRef<HTMLInputElement>(null);
const downloadRef = useRef<HTMLAnchorElement>(null);
const panel = props.panel ?? null;
const canShare = !panel && hub && !!project && isFile;
const canHistory = !panel && hub && !!project;
const canUpload = !!config.upload?.enabled && (!hub || !!project);
// Browser upload is deliberately absent (for now): content enters through
// local sync only; the web app is a read/share/history surface.
const canDownload = !panel && isFile;
const canMore = !panel && (isFile || (hub && !!project && isDir));
const downloadURL = apiBase + "download?path=" + encodeURIComponent(path);
@@ -175,32 +173,6 @@ export default function Browser(props: {
openHistory(isDir ? path + "/" : path);
}, [path, isDir, openHistory]);
const uploadNow = useCallback(() => uploadInput.current?.click(), []);
const onUploadPick = async () => {
const input = uploadInput.current!;
const file = input.files?.[0];
input.value = "";
if (!file) return;
// A selected folder receives the upload; a selected file means "next
// to it".
const dir = !path ? "" : isDir ? path : path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
const dest = dir ? dir + "/" + file.name : file.name;
try {
setUploadStatus(`Uploading ${dest}`);
await uploadFile(apiBase, dest, file);
setUploadStatus(`Uploaded ${dest}`);
await qc.invalidateQueries({ queryKey: ["tree", apiBase] });
openPath(dest);
} catch (err) {
setUploadStatus("Upload failed: " + (err as Error).message);
}
};
useEffect(() => {
// Any navigation clears a stale upload status from the meta slot.
setUploadStatus("");
}, [routeKey]);
/* ---- ⌘K palette ---- */
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -223,7 +195,6 @@ export default function Browser(props: {
if (isFile) add("download", "Download: " + path, "action", () => downloadRef.current?.click());
}
if (hub && project) add("hist", "History: whole project", "action", () => openHistory(""));
if (canUpload) add("upload", "Upload a file…", "action", uploadNow);
if (hub) {
for (const p of props.projects || []) {
if (!project || p.id !== project.id) {
@@ -237,7 +208,7 @@ export default function Browser(props: {
for (const d of dirIndex.keys()) add("folder", d, "folder", () => openPath(d));
for (const f of flatFiles) add("doc", f.path, "file", () => openPath(f.path));
return items;
}, [hub, project, path, isFile, canUpload, config.auth?.enabled, dirIndex, flatFiles, props.projects, shareNow, historyNow, uploadNow, openHistory, openPath]);
}, [hub, project, path, isFile, config.auth?.enabled, dirIndex, flatFiles, props.projects, shareNow, historyNow, openHistory, openPath]);
/* ---- "⋯ More" menu (secondary actions on narrow screens) ---- */
useEffect(() => {
@@ -372,7 +343,7 @@ export default function Browser(props: {
const topbar = (
<Topbar
crumb={crumb}
meta={uploadStatus || meta}
meta={meta}
actions={
<>
<button id="search-btn" className="btn ghost" title="Search (⌘K)" onClick={() => setPaletteOpen(true)}>
@@ -388,12 +359,6 @@ export default function Browser(props: {
<Icon name="hist" /> <span className="lbl">History</span>
</button>
)}
{canUpload && (
<button id="upload-btn" className="btn" onClick={uploadNow}>
<Icon name="upload" /> <span className="lbl">Upload</span>
</button>
)}
<input type="file" hidden ref={uploadInput} onChange={onUploadPick} />
{canDownload && (
<a id="download" className="btn" download href={downloadURL} ref={downloadRef}>
<Icon name="download" /> <span className="lbl">Download</span>
@@ -420,11 +385,6 @@ export default function Browser(props: {
History
</button>
)}
{canUpload && (
<button className="more-item" onClick={uploadNow}>
Upload
</button>
)}
{canDownload && (
<button className="more-item" onClick={() => downloadRef.current?.click()}>
Download
-90
View File
@@ -1,90 +0,0 @@
import type { UploadPlan } from "./api/types";
/* The client asks the server how to upload (upload/init): "direct" hands
back a short-lived presigned URL and the bytes go straight to the object
store; "server" means relay the bytes through the bdrive server. */
export async function uploadFile(apiBase: string, dest: string, file: File): Promise<void> {
const buf = await file.arrayBuffer();
const sha = await sha256Hex(buf);
const post = async (url: string, body: unknown) => {
const r = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(await r.text());
return r.json();
};
const req = { path: dest, sha256: sha, size: file.size };
const plan: UploadPlan = await post(apiBase + "upload/init", req);
if (plan.mode === "direct") {
if (!plan.exists) {
// identical content already in the store? skip the PUT
const r = await fetch(plan.url!, {
method: plan.method || "PUT",
headers: plan.headers || {},
body: buf,
});
if (!r.ok) throw new Error("storage upload failed: " + r.status);
}
await post(apiBase + "upload/commit", req);
} else {
const r = await fetch(apiBase + "upload/content?path=" + encodeURIComponent(dest), {
method: "PUT",
body: buf,
});
if (!r.ok) throw new Error(await r.text());
}
}
async function sha256Hex(buf: ArrayBuffer): Promise<string> {
if (crypto.subtle) {
const d = await crypto.subtle.digest("SHA-256", buf);
return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
return sha256Fallback(new Uint8Array(buf)); // plain-http origins have no crypto.subtle
}
/* Minimal SHA-256 (FIPS 180-4) for non-secure contexts. */
function sha256Fallback(bytes: Uint8Array): string {
const K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]);
const H = new Uint32Array([
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
]);
const rr = (x: number, n: number) => (x >>> n) | (x << (32 - n));
const len = bytes.length;
const padded = new Uint8Array(((((len + 8) >> 6) + 1) << 6));
padded.set(bytes);
padded[len] = 0x80;
const dv = new DataView(padded.buffer);
dv.setUint32(padded.length - 8, Math.floor((len * 8) / 0x100000000));
dv.setUint32(padded.length - 4, (len * 8) >>> 0);
const w = new Uint32Array(64);
for (let off = 0; off < padded.length; off += 64) {
for (let i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4);
for (let i = 16; i < 64; i++) {
const s0 = rr(w[i - 15], 7) ^ rr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
const s1 = rr(w[i - 2], 17) ^ rr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
}
let [a, b, c, d, e, f, g, h] = H as unknown as number[];
for (let i = 0; i < 64; i++) {
const S1 = rr(e, 6) ^ rr(e, 11) ^ rr(e, 25);
const t1 = (h + S1 + ((e & f) ^ (~e & g)) + K[i] + w[i]) >>> 0;
const S0 = rr(a, 2) ^ rr(a, 13) ^ rr(a, 22);
const t2 = (S0 + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
h = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0;
}
H[0] += a; H[1] += b; H[2] += c; H[3] += d; H[4] += e; H[5] += f; H[6] += g; H[7] += h;
}
return [...H].map((x) => (x >>> 0).toString(16).padStart(8, "0")).join("");
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>&#128059;</text></svg>">
<script type="module" crossorigin src="/assets/index-lt5n9mM7.js"></script>
<script type="module" crossorigin src="/assets/index-b5pl3IMV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BWf7Qnlg.css">
</head>
<body>