mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
feat(webapp): say what a conflict copy is, where a reader meets it (BEA-128) (#177)
A concurrent edit is preserved as `<name>.bdrive-conflict-<device>-<utc>` — the guarantee the whole shared-folder promise rests on. Until now that promise appeared in the README, the docs and syncer.go, and nowhere in the hub: a conflict copy was an ordinary row with an alarming name, and a user could only learn what it was by reading the README. conflictName is a pure function of the path, so the frontend recovers the device and the moment from the string alone — no server route, no journal field, no request. lib/conflict.ts holds the parser (anchored suffix, a strictly narrower match of the Go convention; anything malformed is null, never a throw), the listing marks the row, and ConflictBanner explains the file and links the version that kept the original name. History and the Dashboard stay out, per the spec's stated cut. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6f8eb99606
commit
d4fd7eb372
@@ -88,7 +88,7 @@ classDiagram
|
|||||||
|
|
||||||
class components {
|
class components {
|
||||||
FileView FolderListing FileTree
|
FileView FolderListing FileTree
|
||||||
HistoryView HistoryRow HistoryFilters DiffView VersionBanner
|
HistoryView HistoryRow HistoryFilters DiffView VersionBanner ConflictBanner
|
||||||
Insights ShareDialog NewProjectDialog
|
Insights ShareDialog NewProjectDialog
|
||||||
ShareBanner SharesTable AdminTable
|
ShareBanner SharesTable AdminTable
|
||||||
OrgAdmin HubSettings ProjectSettings
|
OrgAdmin HubSettings ProjectSettings
|
||||||
@@ -109,6 +109,7 @@ classDiagram
|
|||||||
+heat.ts orphanPaths (reads whose file left the tree)
|
+heat.ts orphanPaths (reads whose file left the tree)
|
||||||
+heat.ts placeLabels LABEL_MAX (scatter danger-dot labels)
|
+heat.ts placeLabels LABEL_MAX (scatter danger-dot labels)
|
||||||
+heat.ts HOT_READS STALE_DAYS isDanger daysSince agoLabel staleNote
|
+heat.ts HOT_READS STALE_DAYS isDanger daysSince agoLabel staleNote
|
||||||
|
+conflict.ts parseConflict Conflict
|
||||||
+sniff.ts sniffBytes BlobText MAX_BYTES
|
+sniff.ts sniffBytes BlobText MAX_BYTES
|
||||||
+csv.ts parseDelimited Csv CSV_ROWS
|
+csv.ts parseDelimited Csv CSV_ROWS
|
||||||
+mermaid.ts hasMermaid renderMermaid Palette DARK LIGHT
|
+mermaid.ts hasMermaid renderMermaid Palette DARK LIGHT
|
||||||
@@ -116,6 +117,7 @@ classDiagram
|
|||||||
}
|
}
|
||||||
note for lib "mermaid.ts is the one exception to 'pure, no React, unit-tested on node': it needs a DOM and a browser-only library, so its coverage is Playwright. html in → html out, so neither caller can be tempted to patch a live subtree. It imports mermaid only when hasMermaid() says a document has a fence — that gate is what keeps a diagram-free page from downloading any of it — and every failure (unparseable fence, render throw, chunk that never loads) returns the untouched <pre><code> instead of throwing"
|
note for lib "mermaid.ts is the one exception to 'pure, no React, unit-tested on node': it needs a DOM and a browser-only library, so its coverage is Playwright. html in → html out, so neither caller can be tempted to patch a live subtree. It imports mermaid only when hasMermaid() says a document has a fence — that gate is what keeps a diagram-free page from downloading any of it — and every failure (unparseable fence, render throw, chunk that never loads) returns the untouched <pre><code> instead of throwing"
|
||||||
note for lib "pure, no React, unit-tested on node (npm test) — the line diff is ~40 lines, cheaper than auditing a diff package. heat.ts is the one read-count arithmetic: every surface (file header, folder listing, Dashboard bar) totals and splits through it, so they cannot disagree; useBrowse re-exports it. HEAT_DISCLOSURE sits beside that arithmetic for the same reason: a member's own views count toward the number, and four surfaces printing their own copy of that promise is four promises that can drift (BEA-61). The constant is NOT re-exported through useBrowse — surfaces import it straight from lib/heat, and a unit test asserts src/ holds exactly one copy of the sentence. The hot-and-stale VERDICT joined the totals for the same reason (BEA-119): HOT_READS/STALE_DAYS/isDanger were private to Insights.tsx, so the Dashboard was the only screen that could say a doc was hot and unmaintained — the file page and the folder listing showed the ingredients and no verdict. isDanger takes (reads, days) rather than a heat entry because only the Dashboard has a reader lens: it passes its lens-filtered count, the other two pass heatTotal. staleNote returns a STRING (empty when not flagged) so the badge stays pure and survives whichever component owns the meta line"
|
note for lib "pure, no React, unit-tested on node (npm test) — the line diff is ~40 lines, cheaper than auditing a diff package. heat.ts is the one read-count arithmetic: every surface (file header, folder listing, Dashboard bar) totals and splits through it, so they cannot disagree; useBrowse re-exports it. HEAT_DISCLOSURE sits beside that arithmetic for the same reason: a member's own views count toward the number, and four surfaces printing their own copy of that promise is four promises that can drift (BEA-61). The constant is NOT re-exported through useBrowse — surfaces import it straight from lib/heat, and a unit test asserts src/ holds exactly one copy of the sentence. The hot-and-stale VERDICT joined the totals for the same reason (BEA-119): HOT_READS/STALE_DAYS/isDanger were private to Insights.tsx, so the Dashboard was the only screen that could say a doc was hot and unmaintained — the file page and the folder listing showed the ingredients and no verdict. isDanger takes (reads, days) rather than a heat entry because only the Dashboard has a reader lens: it passes its lens-filtered count, the other two pass heatTotal. staleNote returns a STRING (empty when not flagged) so the badge stays pure and survives whichever component owns the meta line"
|
||||||
|
note for lib "conflict.ts recognises a conflict copy from its NAME alone — syncer.conflictName is a pure function of the path, so the device and the moment come out of the string with no server route, no journal field and no request. The regex is an ANCHORED suffix and a strictly narrower match of the Go convention (sanitize's character class, clip's 32), and every mismatch — truncated suffix, impossible date — is null rather than a throw, so a stray filename can never break a listing. Two callers: FolderListing marks the row, ConflictBanner explains the file (BEA-128)"
|
||||||
note for lib "csv.ts parses .csv/.tsv for FileView's table view — ~50 lines against RFC 4180, so no papaparse. It NEVER throws: null means 'not a table' (unterminated quote, no delimiter) and the caller falls back to the plain-text preview, which is why the fallback is a type-level guarantee rather than a try/catch someone can forget"
|
note for lib "csv.ts parses .csv/.tsv for FileView's table view — ~50 lines against RFC 4180, so no papaparse. It NEVER throws: null means 'not a table' (unterminated quote, no delimiter) and the caller falls back to the plain-text preview, which is why the fallback is a type-level guarantee rather than a try/catch someone can forget"
|
||||||
|
|
||||||
ErrorBoundary --> App : wraps the whole tree
|
ErrorBoundary --> App : wraps the whole tree
|
||||||
@@ -125,10 +127,11 @@ classDiagram
|
|||||||
VolumeApp --> Browser
|
VolumeApp --> Browser
|
||||||
HubApp --> router
|
HubApp --> router
|
||||||
Browser --> router
|
Browser --> router
|
||||||
|
Browser --> lib : parseConflict
|
||||||
Browser --> components
|
Browser --> components
|
||||||
HubApp --> components
|
HubApp --> components
|
||||||
components --> nav : linkProps navigate
|
components --> nav : linkProps navigate
|
||||||
components --> lib : diffText groupRuns hotPathSplit placeLabels staleNote isDanger parseDelimited renderMermaid
|
components --> lib : diffText groupRuns hotPathSplit placeLabels staleNote isDanger parseDelimited renderMermaid parseConflict
|
||||||
hooks --> lib : re-exports heat.ts, sniffBytes
|
hooks --> lib : re-exports heat.ts, sniffBytes
|
||||||
shareMermaid --> lib : renderMermaid
|
shareMermaid --> lib : renderMermaid
|
||||||
hooks --> api
|
hooks --> api
|
||||||
|
|||||||
@@ -311,6 +311,16 @@ func seedE2E(t *testing.T, state, prefix, projectID string) {
|
|||||||
User: "alice@x.io", UserName: "Alice",
|
User: "alice@x.io", UserName: "Alice",
|
||||||
Kind: journal.KindDelete, Path: "scratch.md",
|
Kind: journal.KindDelete, Path: "scratch.md",
|
||||||
})
|
})
|
||||||
|
// A conflict copy of guide.md, named exactly the way syncer.conflictName
|
||||||
|
// writes one. The suffix is the whole contract the hub reads a conflict
|
||||||
|
// out of (BEA-128), so the timestamp is a fixed literal: a relative one
|
||||||
|
// would make the banner's text move under the assertion.
|
||||||
|
// Under archive/ on purpose: notes/ is where two other specs pin an exact
|
||||||
|
// file count and measure every row's width on a 360px phone, and a 53-
|
||||||
|
// character filename is not what those are about.
|
||||||
|
put("archive/old-runbook.md.bdrive-conflict-mira-laptop-20260814T060945Z",
|
||||||
|
"# Old runbook\n\nMira's version, written at the same time as the other one.\n", 2*time.Hour)
|
||||||
|
ops[len(ops)-1].Note = "conflict copy of archive/old-runbook.md"
|
||||||
if err := journal.Append(filepath.Join(prefix, "journal", "seed.jsonl"), ops); err != nil {
|
if err := journal.Append(filepath.Join(prefix, "journal", "seed.jsonl"), ops); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { login, wikiId } from "./helpers";
|
||||||
|
|
||||||
|
// BEA-128: a conflict copy is the guarantee the shared-folder promise rests
|
||||||
|
// on, and it used to appear nowhere in the hub. Two surfaces: the listing
|
||||||
|
// row says what the file is, the file page explains it.
|
||||||
|
|
||||||
|
const COPY = "archive/old-runbook.md.bdrive-conflict-mira-laptop-20260814T060945Z";
|
||||||
|
const NAME = COPY.split("/").pop()!;
|
||||||
|
|
||||||
|
test("the file listing flags a conflict copy", async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
const pid = await wikiId(page);
|
||||||
|
await page.goto(`/${pid}/archive`);
|
||||||
|
const row = page.locator(".dl-row").filter({ hasText: NAME });
|
||||||
|
await expect(row.locator(".dl-conflict")).toHaveText("conflict copy");
|
||||||
|
// The label has to travel without hover — touch and screen readers get
|
||||||
|
// neither the title nor the badge's own two words.
|
||||||
|
await expect(row.locator(".dl-conflict")).toHaveAttribute(
|
||||||
|
"aria-label",
|
||||||
|
/concurrent edit from mira-laptop/,
|
||||||
|
);
|
||||||
|
// Ordinary files keep their ordinary row.
|
||||||
|
await expect(
|
||||||
|
page.locator(".dl-row").filter({ hasText: /^old-runbook\.md/ }).first().locator(".dl-conflict"),
|
||||||
|
).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the conflict copy's page explains it and links the other version", async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
const pid = await wikiId(page);
|
||||||
|
await page.goto(`/${pid}/${COPY}`);
|
||||||
|
const banner = page.locator(".vbanner").filter({ hasText: "Conflict copy" });
|
||||||
|
await expect(banner).toContainText("mira-laptop");
|
||||||
|
await expect(banner).toContainText("2026"); // the moment, in local time
|
||||||
|
await expect(banner).toContainText("archive/old-runbook.md");
|
||||||
|
await banner.getByRole("button", { name: /other version/i }).click();
|
||||||
|
await page.waitForURL(`/${pid}/archive/old-runbook.md`);
|
||||||
|
await expect(page.locator("#content")).toContainText("Still read, never maintained");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an ordinary file gets no conflict banner", async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
const pid = await wikiId(page);
|
||||||
|
await page.goto(`/${pid}/archive/old-runbook.md`);
|
||||||
|
await expect(page.locator(".vbanner").filter({ hasText: "Conflict copy" })).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -32,6 +32,8 @@ import { Insights, useInsightsDevices } from "../components/Insights";
|
|||||||
import { HistoryView, historyTitle } from "../components/HistoryView";
|
import { HistoryView, historyTitle } from "../components/HistoryView";
|
||||||
import type { Run } from "../lib/runs";
|
import type { Run } from "../lib/runs";
|
||||||
import { VersionBanner } from "../components/VersionBanner";
|
import { VersionBanner } from "../components/VersionBanner";
|
||||||
|
import { ConflictBanner } from "../components/ConflictBanner";
|
||||||
|
import { parseConflict } from "../lib/conflict";
|
||||||
|
|
||||||
// The hub's six share-time credential rules, in words. Only one caller
|
// The hub's six share-time credential rules, in words. Only one caller
|
||||||
// (shareNow), so it lives here rather than in its own file.
|
// (shareNow), so it lives here rather than in its own file.
|
||||||
@@ -591,6 +593,7 @@ export default function Browser(props: {
|
|||||||
// A PDF page is unreadable squeezed into the 768px reading column.
|
// A PDF page is unreadable squeezed into the 768px reading column.
|
||||||
pageWidth = HTML_EXT.test(path) || PDF_EXT.test(path) ? "wide" : "read";
|
pageWidth = HTML_EXT.test(path) || PDF_EXT.test(path) ? "wide" : "read";
|
||||||
pageClass = "markdown";
|
pageClass = "markdown";
|
||||||
|
const conflict = parseConflict(path);
|
||||||
view = (
|
view = (
|
||||||
<>
|
<>
|
||||||
{version && (
|
{version && (
|
||||||
@@ -601,6 +604,16 @@ export default function Browser(props: {
|
|||||||
onViewCurrent={() => openPath(path)}
|
onViewCurrent={() => openPath(path)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{conflict && (
|
||||||
|
<ConflictBanner
|
||||||
|
conflict={conflict}
|
||||||
|
originalHref={
|
||||||
|
flatFiles.some((f) => f.path === conflict.original)
|
||||||
|
? () => openPath(conflict.original)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<FileView
|
<FileView
|
||||||
apiBase={apiBase}
|
apiBase={apiBase}
|
||||||
path={path}
|
path={path}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { Conflict } from "../lib/conflict";
|
||||||
|
import { Icon } from "./shell";
|
||||||
|
|
||||||
|
/* A conflict copy is an ordinary file with an alarming name, and the README
|
||||||
|
is the only place that ever explained it. This banner is what makes the
|
||||||
|
guarantee visible where a user actually meets the file: two devices edited
|
||||||
|
at once, nothing was dropped, and the other version is one click away.
|
||||||
|
|
||||||
|
Pure presentation over a path the caller already parsed — no request, no
|
||||||
|
server flag; the name carries everything (see lib/conflict.ts). Reuses
|
||||||
|
VersionBanner's .vbanner styles: same shape, same job. */
|
||||||
|
export function ConflictBanner(props: {
|
||||||
|
conflict: Conflict;
|
||||||
|
/* The recovered original only when the project actually holds it:
|
||||||
|
conflictName truncates a long base name before appending the suffix, so
|
||||||
|
the name we recover can be one that never existed. The explanation is
|
||||||
|
the point; the link is the bonus. */
|
||||||
|
originalHref?: () => void;
|
||||||
|
}) {
|
||||||
|
const { conflict, originalHref } = props;
|
||||||
|
const who = conflict.device || "another device";
|
||||||
|
return (
|
||||||
|
<div className="vbanner" role="status">
|
||||||
|
<span className="vb-icon">
|
||||||
|
<Icon name="alert" />
|
||||||
|
</span>
|
||||||
|
<div className="vb-text">
|
||||||
|
<b>Conflict copy — a concurrent edit, preserved</b>
|
||||||
|
<span>
|
||||||
|
{who} edited this file at the same time as someone else on{" "}
|
||||||
|
{conflict.when.toLocaleString()}. Rather than drop either version,
|
||||||
|
beardrive kept that one here.{" "}
|
||||||
|
{originalHref ? (
|
||||||
|
<>The other version lives at <code>{conflict.original}</code></>
|
||||||
|
) : (
|
||||||
|
<>The other version kept the original name.</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{originalHref && (
|
||||||
|
<div className="vb-actions">
|
||||||
|
<button className="ai-btn" onClick={originalHref}>
|
||||||
|
Open the other version
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import type { HeatMap, Node } from "../api/types";
|
import type { HeatMap, Node } from "../api/types";
|
||||||
import { heatFor, heatLevel, heatText, useFolderHistory } from "../hooks/useBrowse";
|
import { heatFor, heatLevel, heatText, useFolderHistory } from "../hooks/useBrowse";
|
||||||
|
import { parseConflict } from "../lib/conflict";
|
||||||
import { HEAT_DISCLOSURE, staleNote } from "../lib/heat";
|
import { HEAT_DISCLOSURE, staleNote } from "../lib/heat";
|
||||||
import { humanSize } from "../util";
|
import { humanSize } from "../util";
|
||||||
import { Icon } from "./shell";
|
import { Icon } from "./shell";
|
||||||
@@ -58,6 +59,7 @@ export function FolderListing(props: {
|
|||||||
}
|
}
|
||||||
const he = heatFor(heatMap, c.path, !!c.dir);
|
const he = heatFor(heatMap, c.path, !!c.dir);
|
||||||
if (he) meta = heatText(he) + (meta ? " · " + meta : "");
|
if (he) meta = heatText(he) + (meta ? " · " + meta : "");
|
||||||
|
const conflict = c.dir ? null : parseConflict(c.path);
|
||||||
// Files only: a folder's heat is a subtree sum and it has no one
|
// Files only: a folder's heat is a subtree sum and it has no one
|
||||||
// mtime to be stale against, which is also why the Dashboard
|
// mtime to be stale against, which is also why the Dashboard
|
||||||
// plots files only (BEA-119).
|
// plots files only (BEA-119).
|
||||||
@@ -81,6 +83,19 @@ export function FolderListing(props: {
|
|||||||
<Icon name={c.dir ? "folder" : "doc"} />
|
<Icon name={c.dir ? "folder" : "doc"} />
|
||||||
</span>
|
</span>
|
||||||
<span className="dl-name">{c.name}</span>
|
<span className="dl-name">{c.name}</span>
|
||||||
|
{conflict && (
|
||||||
|
/* The one thing a strangely-named file needs at a glance:
|
||||||
|
that beardrive put it there on purpose. The page itself
|
||||||
|
explains what it is — same reasoning as the heat dot
|
||||||
|
below, the label has to travel without hover. */
|
||||||
|
<span
|
||||||
|
className="dl-conflict"
|
||||||
|
aria-label={"Conflict copy: a concurrent edit from " + (conflict.device || "another device") + " that beardrive preserved instead of dropping."}
|
||||||
|
title={"A concurrent edit from " + (conflict.device || "another device") + " that beardrive preserved instead of dropping."}
|
||||||
|
>
|
||||||
|
conflict copy
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{stale && (
|
{stale && (
|
||||||
/* Same reasoning as the dot below: the glyph carries a real
|
/* Same reasoning as the dot below: the glyph carries a real
|
||||||
aria-label, because title= needs a hover that touch and
|
aria-label, because title= needs a hover that touch and
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Run with `npm test` (node's built-in runner; node >= 23 strips the types).
|
||||||
|
// Excluded from tsconfig's include — it imports node: builtins, which the
|
||||||
|
// app's DOM-only lib set does not know about.
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { parseConflict } from "./conflict.ts";
|
||||||
|
|
||||||
|
test("parses the original, the device and the moment out of the name", () => {
|
||||||
|
const c = parseConflict("notes/plan.md.bdrive-conflict-laptop-20260814T060945Z");
|
||||||
|
assert.ok(c);
|
||||||
|
assert.equal(c.original, "notes/plan.md");
|
||||||
|
assert.equal(c.device, "laptop");
|
||||||
|
assert.equal(c.when.toISOString(), "2026-08-14T06:09:45.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a device name with dashes still splits on the anchored timestamp", () => {
|
||||||
|
const c = parseConflict("plan.md.bdrive-conflict-mira-laptop-20260814T060945Z");
|
||||||
|
assert.ok(c);
|
||||||
|
assert.equal(c.device, "mira-laptop");
|
||||||
|
assert.equal(c.original, "plan.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the match is an anchored suffix, not a substring", () => {
|
||||||
|
// a folder named after a conflict copy holds ordinary files
|
||||||
|
assert.equal(parseConflict("a.md.bdrive-conflict-x-20260814T060945Z/b.md"), null);
|
||||||
|
assert.equal(parseConflict("plan.md.bdrive-conflict-x-20260814T060945Z.bak"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a malformed or truncated suffix is not a conflict copy", () => {
|
||||||
|
for (const p of [
|
||||||
|
"plan.md",
|
||||||
|
"plan.md.bdrive-conflict",
|
||||||
|
"plan.md.bdrive-conflict-laptop",
|
||||||
|
"plan.md.bdrive-conflict-laptop-20260814",
|
||||||
|
"plan.md.bdrive-conflict-laptop-20260814T0609Z",
|
||||||
|
"plan.md.bdrive-conflict-laptop-2026-08-14T06:09:45Z",
|
||||||
|
"plan.md.bdrive-conflict-way-too-long-a-device-name-to-have-survived-clip-20260814T060945Z",
|
||||||
|
]) {
|
||||||
|
assert.equal(parseConflict(p), null, p);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an impossible date degrades to not-a-conflict rather than a bogus Date", () => {
|
||||||
|
assert.equal(parseConflict("plan.md.bdrive-conflict-laptop-20261314T060945Z"), null);
|
||||||
|
assert.equal(parseConflict("plan.md.bdrive-conflict-laptop-20260230T060945Z"), null);
|
||||||
|
assert.equal(parseConflict("plan.md.bdrive-conflict-laptop-20260814T256945Z"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a conflict copy of a conflict copy resolves to the inner one", () => {
|
||||||
|
const outer = parseConflict(
|
||||||
|
"plan.md.bdrive-conflict-a-20260101T000000Z.bdrive-conflict-b-20260102T000000Z",
|
||||||
|
);
|
||||||
|
assert.ok(outer);
|
||||||
|
assert.equal(outer.device, "b");
|
||||||
|
assert.equal(outer.original, "plan.md.bdrive-conflict-a-20260101T000000Z");
|
||||||
|
const inner = parseConflict(outer.original);
|
||||||
|
assert.ok(inner);
|
||||||
|
assert.equal(inner.original, "plan.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an empty device name (sanitize kept nothing) still parses", () => {
|
||||||
|
const c = parseConflict("plan.md.bdrive-conflict--20260814T060945Z");
|
||||||
|
assert.ok(c);
|
||||||
|
assert.equal(c.device, "");
|
||||||
|
assert.equal(c.original, "plan.md");
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Conflict copies: the one guarantee the shared-folder promise rests on.
|
||||||
|
// When two devices edit the same file between syncs, the loser is preserved
|
||||||
|
// beside the winner as `<name>.bdrive-conflict-<device>-<utc>` — see
|
||||||
|
// conflictName in internal/syncer/syncer.go. That name is a pure function of
|
||||||
|
// the path, so the hub can recognise a conflict copy, and recover the device
|
||||||
|
// and the moment, from the string alone: no server route, no journal field.
|
||||||
|
// Unit-tested in conflict.test.ts (`npm test`).
|
||||||
|
|
||||||
|
export type Conflict = {
|
||||||
|
original: string; // the path the winning version lives at
|
||||||
|
device: string; // the device whose edit was preserved
|
||||||
|
when: Date; // when that edit was made (UTC in the name)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Anchored at the end — a path that merely CONTAINS the string somewhere in
|
||||||
|
// the middle (a folder named after a conflict copy) is an ordinary file.
|
||||||
|
// The character class mirrors syncer.go's sanitize (everything outside
|
||||||
|
// [A-Za-z0-9_-] becomes '-') and the 32 mirrors its clip.
|
||||||
|
const RE = /\.bdrive-conflict-([A-Za-z0-9_-]{0,32})-(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/;
|
||||||
|
|
||||||
|
/* The conflict copy behind a path, or null for an ordinary file. Anything
|
||||||
|
malformed — a truncated suffix, a device name too long, a date that isn't
|
||||||
|
one — is "not a conflict copy", never a throw. */
|
||||||
|
export function parseConflict(path: string): Conflict | null {
|
||||||
|
const m = RE.exec(path);
|
||||||
|
if (!m) return null;
|
||||||
|
const [, device, y, mo, d, h, mi, s] = m;
|
||||||
|
// 20260814T060945Z is not ISO-8601, and new Date() on it is
|
||||||
|
// implementation-defined — build it by field instead, then round-trip to
|
||||||
|
// reject the impossible (month 13, February 30, hour 25).
|
||||||
|
const when = new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +s));
|
||||||
|
if (
|
||||||
|
when.getUTCFullYear() !== +y ||
|
||||||
|
when.getUTCMonth() !== +mo - 1 ||
|
||||||
|
when.getUTCDate() !== +d ||
|
||||||
|
when.getUTCHours() !== +h ||
|
||||||
|
when.getUTCMinutes() !== +mi ||
|
||||||
|
when.getUTCSeconds() !== +s
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { original: path.slice(0, m.index), device, when };
|
||||||
|
}
|
||||||
@@ -633,6 +633,10 @@ a.ai-main:hover { color: var(--accent); }
|
|||||||
}
|
}
|
||||||
.dl-meta { flex: none; font-size: 12px; color: var(--text-faint); font-variant-numeric: tabular-nums; }
|
.dl-meta { flex: none; font-size: 12px; color: var(--text-faint); font-variant-numeric: tabular-nums; }
|
||||||
/* read-heat dot: intensity grows with 30-day reads (heatLevel in app.js) */
|
/* read-heat dot: intensity grows with 30-day reads (heatLevel in app.js) */
|
||||||
|
/* Conflict copy marker in the file listing: a preserved concurrent edit is
|
||||||
|
an ordinary file with an alarming name, so the row says so before the
|
||||||
|
reader has to guess from the suffix. */
|
||||||
|
.dl-conflict { flex: none; font-size: 10.5px; line-height: 1; letter-spacing: .02em; text-transform: uppercase; padding: 3px 6px; border-radius: 999px; border: 1px solid var(--accent-dim); background: var(--glow); color: var(--accent-bright); white-space: nowrap; }
|
||||||
.heatdot { flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
|
.heatdot { flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
|
||||||
.heatdot.lvl1 { opacity: .3; }
|
.heatdot.lvl1 { opacity: .3; }
|
||||||
.heatdot.lvl2 { opacity: .55; }
|
.heatdot.lvl2 { opacity: .55; }
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -5,10 +5,10 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>BearDrive</title>
|
<title>BearDrive</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
|
||||||
<script type="module" crossorigin src="/assets/index-D8UAHgrJ.js"></script>
|
<script type="module" crossorigin src="/assets/index-CZVWnc_F.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/_commonjsHelpers-CqkleIqs.js">
|
<link rel="modulepreload" crossorigin href="/assets/_commonjsHelpers-CqkleIqs.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/mermaid-DQuCJ8Gi.js">
|
<link rel="modulepreload" crossorigin href="/assets/mermaid-DQuCJ8Gi.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-C20TgXSV.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-vCqznddJ.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user