diff --git a/.github/actions/setup-bun-rust/action.yml b/.github/actions/setup-bun-rust/action.yml index f941f482..fc1e0f19 100644 --- a/.github/actions/setup-bun-rust/action.yml +++ b/.github/actions/setup-bun-rust/action.yml @@ -13,7 +13,7 @@ runs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.9 - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@stable diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 9b0acb4c..8c8ecc11 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -19,7 +19,7 @@ default: image: node:22-bookworm interruptible: true before_script: - - curl -fsSL https://bun.sh/install | bash + - curl -fsSL https://bun.sh/install | bash -s "bun-v1.3.9" - export PATH="$HOME/.bun/bin:$PATH" - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable - export PATH="$CARGO_HOME/bin:$PATH" diff --git a/.size-limit.json b/.size-limit.json index 9d050c78..20024115 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -1,8 +1,8 @@ [ { - "name": "shell renderer JS bundle (gz) — per docs/shell/13-frontend-stack.md performance budgets; bumped 270→290 KB on 2026-05-28 for 12.1 (FormatJS ICU runtime, ~12.6 KB); 290→292 KB on 2026-06-01 for the new-vault onboarding pass (welcome redesign + Join/Devices first-run entry + theme-provider rework, 282.6→290.5 KB gz); 292→350 KB on 2026-06-01 for 8.8 (@react-fancy-menus/core menu runtime swap-in, +~52 KB for the menu runtime + @floating-ui/react-dom + @dnd-kit/* — dnd-kit is the bulk and only pulled for sortable rows no current surface uses; revisit if the dep ships a sortable-free entry). Replaces the bespoke cheatsheet/launcher/icon-context/anchored-menu DOM that was deleted", + "name": "shell renderer JS bundle (gz) — per docs/shell/13-frontend-stack.md performance budgets; bumped 270→290 KB on 2026-05-28 for 12.1 (FormatJS ICU runtime, ~12.6 KB); 290→292 KB on 2026-06-01 for the new-vault onboarding pass (welcome redesign + Join/Devices first-run entry + theme-provider rework, 282.6→290.5 KB gz); 292→350 KB on 2026-06-01 for 8.8 (@react-fancy-menus/core menu runtime swap-in, +~52 KB for the menu runtime + @floating-ui/react-dom + @dnd-kit/* — dnd-kit is the bulk and only pulled for sortable rows no current surface uses; revisit if the dep ships a sortable-free entry). Replaces the bespoke cheatsheet/launcher/icon-context/anchored-menu DOM that was deleted. Bumped 350→385 KB on 2026-10-19 for the 0.15.0 market-demand bucket (main measured 369.3 KB, branch 375.4 KB gz); accepted by the owner as a re-baseline, NOT a fix: the renderer slimming rung is owed", "path": "packages/shell/out/renderer/assets/index-*.js", - "limit": "350 KB", + "limit": "385 KB", "gzip": true }, { @@ -12,9 +12,9 @@ "gzip": true }, { - "name": "shell main process bundle (raw) — bumped 100→110 KB on 2026-05-23 for 10.2 (HPKE + member-wraps + device-x25519) + 10.3a (sync envelope codec/seal/pipeline/relay-port) + help/changelog + defaults-catalog scheme/extension expansion; bumped 110→210 KB on 2026-06-11 — the limit drifted ~86 KB under-budget across the sync stack (10.3b–10.9) + 9.12.3 (ListSource service-side resolution moved the predicate/source evaluators into main) + Mailbox-5 (the `mail` service + Gmail transport glue), measured 196 KB raw. This is a deliberate re-baseline, NOT a fix: a dedicated main-process trim pass is owed at G3 (split workers out / lazy-load the sync + connector subsystems). Revisit there. Bumped 210→214 KB for the Files storage-inventory surface (storage-inventory + gather + wallpaper-entries + the files.listStorageInventory wire, ~2.8 KB); folds into the same G3 trim. Bumped 214→220 KB on 2026-06-14 for the Storage-view owner-stamping surface (listAssetOwners + ownerByAsset entityId/entityType stamping so Storage rows open in Preview), measured 218.1 KB raw; same G3 trim. Bumped 220→245 KB on 2026-06-16 for the IE-1/IE-2 import-export spine (`packages/shell/src/main/{bundle,import}/` — tar/container codec, canonical-json, vault export/import + the shared import engine), measured 238.9 KB raw; same G3 trim (the import/export subsystem is a lazy-load candidate alongside sync + connectors). Bumped 245→305 KB on 2026-06-23 — accumulated drift across the always-on sync stack (10.12 live-sync engine, 10.13 selective-sync, 10.14 restore producer+consumer + RestoreEngine), the app-lifecycle catalog spine (14.29–14.34: CatalogClient / InstallEngine / UpdateEngine / `.brainstorm` bundle codec), SYNC-4b gated admission, and the cross-app DnD broker services (selection / dnd / ghost overlay), measured 296.5 KB; NOT a fix — the same G3 main-process trim is owed (sync + connector + import-export + catalog are all lazy-load candidates).", + "name": "shell main process bundle (raw) — bumped 100→110 KB on 2026-05-23 for 10.2 (HPKE + member-wraps + device-x25519) + 10.3a (sync envelope codec/seal/pipeline/relay-port) + help/changelog + defaults-catalog scheme/extension expansion; bumped 110→210 KB on 2026-06-11 — the limit drifted ~86 KB under-budget across the sync stack (10.3b–10.9) + 9.12.3 (ListSource service-side resolution moved the predicate/source evaluators into main) + Mailbox-5 (the `mail` service + Gmail transport glue), measured 196 KB raw. This is a deliberate re-baseline, NOT a fix: a dedicated main-process trim pass is owed at G3 (split workers out / lazy-load the sync + connector subsystems). Revisit there. Bumped 210→214 KB for the Files storage-inventory surface (storage-inventory + gather + wallpaper-entries + the files.listStorageInventory wire, ~2.8 KB); folds into the same G3 trim. Bumped 214→220 KB on 2026-06-14 for the Storage-view owner-stamping surface (listAssetOwners + ownerByAsset entityId/entityType stamping so Storage rows open in Preview), measured 218.1 KB raw; same G3 trim. Bumped 220→245 KB on 2026-06-16 for the IE-1/IE-2 import-export spine (`packages/shell/src/main/{bundle,import}/` — tar/container codec, canonical-json, vault export/import + the shared import engine), measured 238.9 KB raw; same G3 trim (the import/export subsystem is a lazy-load candidate alongside sync + connectors). Bumped 245→305 KB on 2026-06-23 — accumulated drift across the always-on sync stack (10.12 live-sync engine, 10.13 selective-sync, 10.14 restore producer+consumer + RestoreEngine), the app-lifecycle catalog spine (14.29–14.34: CatalogClient / InstallEngine / UpdateEngine / `.brainstorm` bundle codec), SYNC-4b gated admission, and the cross-app DnD broker services (selection / dnd / ghost overlay), measured 296.5 KB; NOT a fix — the same G3 main-process trim is owed (sync + connector + import-export + catalog are all lazy-load candidates). Bumped 305→365 KB on 2026-10-19 for the 0.15.0 market-demand bucket (version history, inverse relations, split view, backups, AI routing gates; main measured 330.6 KB, branch 355.7 KB, brotli — the \"raw\" label is wrong, size-limit compresses by default); accepted by the owner as a re-baseline, same G3 trim owed.", "path": "packages/shell/out/main/index.js", - "limit": "305 KB" + "limit": "365 KB" }, { "name": "shell preload bundle (raw)", diff --git a/apps/agent/manifest.json b/apps/agent/manifest.json index 2915c2d9..7f2f27bb 100644 --- a/apps/agent/manifest.json +++ b/apps/agent/manifest.json @@ -17,8 +17,10 @@ "ai.provider:anthropic", "ai.provider:openai", "ai.provider:glm", + "ai.provider:mistral", "ai.provider:gemini", "ai.provider:solheim", + "ai.provider:*", "search.read", "search.hybrid", "platform.read", diff --git a/apps/agent/src/app.test.tsx b/apps/agent/src/app.test.tsx index 9a0c5db1..cf2c4866 100644 --- a/apps/agent/src/app.test.tsx +++ b/apps/agent/src/app.test.tsx @@ -460,3 +460,33 @@ describe("AgentApp — app-icon attention badge (7.14)", () => { expect(handle.container.querySelector('[data-testid="agent-escalation"]')).not.toBeNull(); }); }); + +describe("AgentApp — auto-hide sidebar (7.15)", () => { + afterEach(() => { + document.documentElement.removeAttribute("data-bs-autohide-sidebars"); + window.localStorage.removeItem("agent:sidebar-open"); + }); + + const sidebar = () => handle?.container.querySelector("#agent-sidebar") as HTMLElement; + + it("without the preference, a collapsed sidebar is plainly hidden", async () => { + window.localStorage.setItem("agent:sidebar-open", "false"); + installShell([]); + handle = await renderInto(); + await flush(); + expect(sidebar().hasAttribute("inert")).toBe(true); + expect(sidebar().hasAttribute("data-bs-autohide")).toBe(false); + expect(handle?.container.querySelector(".bs-autohide-edge")).toBeNull(); + }); + + it("with the preference on, a collapsed sidebar becomes a hidden overlay with an edge hot-zone", async () => { + document.documentElement.setAttribute("data-bs-autohide-sidebars", "on"); + window.localStorage.setItem("agent:sidebar-open", "false"); + installShell([]); + handle = await renderInto(); + await flush(); + expect(sidebar().getAttribute("data-bs-autohide")).toBe("hidden"); + expect(sidebar().hasAttribute("inert")).toBe(true); + expect(handle?.container.querySelector(".bs-autohide-edge")).not.toBeNull(); + }); +}); diff --git a/apps/agent/src/app.tsx b/apps/agent/src/app.tsx index ad2237be..0b6f2e3b 100644 --- a/apps/agent/src/app.tsx +++ b/apps/agent/src/app.tsx @@ -18,6 +18,7 @@ import { AiContentPartKind, type AiImagePart, type AiProvenance, + type AiProviderInfo, AttachmentKind, COLLECTION_TYPE_URL, CONVERSATION_TYPE_URL, @@ -63,7 +64,7 @@ import { type ObjectMenuRuntime, ObjectMenuTrigger, } from "@brainstorm-os/sdk/object-menu"; -import { PanelSide, PanelToggleButton } from "@brainstorm-os/sdk/panel-toggle"; +import { PanelSide, PanelToggleButton, useAutoHidePanel } from "@brainstorm-os/sdk/panel-toggle"; import { friendlyTypeName } from "@brainstorm-os/sdk/system-entities"; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; import type { ReactElement } from "react"; @@ -96,6 +97,7 @@ import { estimateTurnTokens, grantCapability, grantsCover, + modelForRequest, providerForRequest, resolveProvider, } from "./logic/conversation-settings"; @@ -155,6 +157,7 @@ import { buildVaultDataContextBlock } from "./logic/vault-data-context"; import { buildWorkspaceContextBlock, joinContextBlocks } from "./logic/workspace-context"; import { MemoryPopover } from "./memory-popover"; import { ProposalTray } from "./proposal-tray"; +import { RunChangesPanel } from "./run-changes-panel"; import { getBrainstorm } from "./runtime"; import { SaveAsAutomationPopover } from "./save-as-automation-popover"; import { TurnTimeline } from "./turn-timeline"; @@ -460,7 +463,23 @@ export function AgentApp(): ReactElement { () => providerForRequest(resolveProvider(appCaps ?? [], str(activeConv?.properties.provider))), [appCaps, activeConv], ); - const conversationModel = str(activeConv?.properties.model) || undefined; + const conversationModel = modelForRequest(str(activeConv?.properties.model)); + + // Agent-14 — the shell's routable providers (incl. user-added custom + // endpoints), already filtered to this app's grants. `null` = an older shell + // without `ai.providers`; the picker then derives providers from caps. + const [aiProviders, setAiProviders] = useState(null); + useEffect(() => { + if (!settingsOpen || !aiSvc?.providers) return; + let live = true; + aiSvc + .providers() + .then((list) => live && setAiProviders(list)) + .catch(() => live && setAiProviders(null)); + return () => { + live = false; + }; + }, [aiSvc, settingsOpen]); // The conversation's token budget + spend so far (Agent-5). const tokenBudget = activeConv ? posNum(activeConv.properties.tokenBudget) : undefined; @@ -525,6 +544,7 @@ export function AgentApp(): ReactElement { return next; }); }, []); + const sidebarAutoHide = useAutoHidePanel({ side: PanelSide.Left, collapsed: !sidebarOpen }); // The composer is a CompactEditor (rich draft) — the latest payload rides in // a ref (reading it doesn't need a render); `draftEmpty` drives the Send // button's enabled state. @@ -1598,7 +1618,13 @@ export function AgentApp(): ReactElement {
- + {sidebarAutoHide.edgeProps &&
}
+ { + if (filterBarShown && hasActiveFilters(store.filters)) store.clearFilters(); + setFilterOpen(!filterBarShown); + }} + />
+ {filterBarShown ? ( + + ) : null} + {store.duplicateGroups && store.duplicateGroups.length > 0 ? ( +

+ {plural( + store.duplicateGroups.length, + "brainstorm.files.duplicates.summary.one", + "brainstorm.files.duplicates.summary.other", + { + size: formatBytes( + store.duplicateGroups.reduce((sum, group) => sum + group.reclaimableBytes, 0), + ), + }, + )} +

+ ) : null} + { + it("groups by content hash and keeps only groups of two or more", () => { + const groups = findDuplicateGroups([ + file("a2", "aaa", 100, 2), + file("a1", "aaa", 100, 1), + file("solo", "bbb", 999, 1), + file("nohash", undefined, 100, 1), + ]); + expect(groups).toHaveLength(1); + expect(groups[0]?.hash).toBe("aaa"); + expect(groups[0]?.entities.map((e) => e.id)).toEqual(["a1", "a2"]); + expect(groups[0]?.reclaimableBytes).toBe(100); + }); + + it("orders groups by reclaimable bytes, biggest first", () => { + const groups = findDuplicateGroups([ + file("s1", "small", 10, 1), + file("s2", "small", 10, 2), + file("s3", "small", 10, 3), + file("b1", "big", 1000, 1), + file("b2", "big", 1000, 2), + ]); + expect(groups.map((g) => g.hash)).toEqual(["big", "small"]); + expect(groups[1]?.reclaimableBytes).toBe(20); + }); + + it("ignores deleted rows and non-file entities", () => { + const deleted = { ...file("d", "aaa", 5, 1), deletedAt: 9 }; + const folderWithHash: Entity = { + ...file("f", "aaa", 5, 1), + type: FOLDER_TYPE, + }; + expect(findDuplicateGroups([file("a", "aaa", 5, 1), deleted, folderWithHash])).toEqual([]); + }); +}); diff --git a/apps/files/src/logic/duplicates.ts b/apps/files/src/logic/duplicates.ts new file mode 100644 index 00000000..d42d7b6d --- /dev/null +++ b/apps/files/src/logic/duplicates.ts @@ -0,0 +1,47 @@ +/** + * Duplicate detection (9.8.16). Every stored upload carries the content + * hash `files.import` computed over its bytes (persisted as `hash`), so two + * File rows with the same hash ARE the same bytes, whatever they are named + * or wherever they live. Grouping is exact — no fuzzy name/size heuristics. + */ + +import { type Entity, FILE_TYPE, readContentHash, readSize } from "../types/entity"; + +export type DuplicateGroup = { + hash: string; + /** Oldest first — the earliest import reads as "the original". */ + entities: Entity[]; + /** Size of one copy. */ + size: number; + /** Bytes the extra copies occupy (`size × (copies − 1)`). */ + reclaimableBytes: number; +}; + +/** Group live File rows by content hash, keeping only groups with ≥2 rows. + * Groups order by reclaimable bytes (biggest win first), then hash so the + * order is stable across renders. */ +export function findDuplicateGroups(entities: readonly Entity[]): DuplicateGroup[] { + const byHash = new Map(); + for (const entity of entities) { + if (entity.type !== FILE_TYPE || entity.deletedAt !== null) continue; + const hash = readContentHash(entity); + if (hash === null) continue; + const bucket = byHash.get(hash); + if (bucket) bucket.push(entity); + else byHash.set(hash, [entity]); + } + const groups: DuplicateGroup[] = []; + for (const [hash, members] of byHash) { + if (members.length < 2) continue; + members.sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)); + const size = Math.max(...members.map(readSize)); + groups.push({ + hash, + entities: members, + size, + reclaimableBytes: size * (members.length - 1), + }); + } + groups.sort((a, b) => b.reclaimableBytes - a.reclaimableBytes || a.hash.localeCompare(b.hash)); + return groups; +} diff --git a/apps/files/src/logic/filters.test.ts b/apps/files/src/logic/filters.test.ts new file mode 100644 index 00000000..6e92483f --- /dev/null +++ b/apps/files/src/logic/filters.test.ts @@ -0,0 +1,129 @@ +import { RelativeDateRange } from "@brainstorm-os/sdk/predicate-eval"; +import { describe, expect, it } from "vitest"; +import { type Entity, FILE_TYPE, FOLDER_TYPE, NOTE_TYPE } from "../types/entity"; +import { + DEFAULT_FILE_FILTERS, + KindFilter, + SizeFilter, + applyFileFilters, + collectExtensions, + countActiveFilters, + hasActiveFilters, + kindOf, +} from "./filters"; + +const NOW = new Date(2026, 9, 18, 12, 0, 0).getTime(); +const DAY = 24 * 60 * 60 * 1000; + +function file(id: string, name: string, mime: string, size: number, updatedAt = NOW): Entity { + return { + id, + type: FILE_TYPE, + properties: { name, mime, size }, + createdAt: updatedAt, + updatedAt, + deletedAt: null, + }; +} + +function folder(id: string, name: string): Entity { + return { + id, + type: FOLDER_TYPE, + properties: { name, members: [] }, + createdAt: NOW, + updatedAt: NOW, + deletedAt: null, + }; +} + +const photo = file("p", "beach.JPG", "image/jpeg", 3 * 1024 * 1024); +const report = file("r", "report.pdf", "application/pdf", 400 * 1024, NOW - 3 * DAY); +const notes = file("n", "notes.txt", "text/plain", 20, NOW - 40 * DAY); +const song = file("s", "song.mp3", "audio/mpeg", 50 * 1024 * 1024); +const backup = file("b", "backup.zip", "application/zip", 200 * 1024 * 1024); +const blob = file("x", "mystery", "application/octet-stream", 10); +const docs = folder("f", "Docs"); +const rows = [photo, report, notes, song, backup, blob, docs]; + +describe("kindOf", () => { + it("buckets files by MIME family", () => { + expect(kindOf(photo)).toBe(KindFilter.Image); + expect(kindOf(report)).toBe(KindFilter.Document); + expect(kindOf(notes)).toBe(KindFilter.Document); + expect(kindOf(song)).toBe(KindFilter.Audio); + expect(kindOf(backup)).toBe(KindFilter.Archive); + expect(kindOf(blob)).toBe(KindFilter.Other); + expect(kindOf(docs)).toBe(KindFilter.Folder); + }); + + it("reads Notes as documents and other typed objects as Other", () => { + expect(kindOf({ ...docs, type: NOTE_TYPE })).toBe(KindFilter.Document); + expect(kindOf({ ...docs, type: "brainstorm/Person/v1" })).toBe(KindFilter.Other); + }); +}); + +describe("applyFileFilters", () => { + it("is an identity copy when nothing is active", () => { + const out = applyFileFilters(rows, DEFAULT_FILE_FILTERS, NOW); + expect(out).toEqual(rows); + expect(out).not.toBe(rows); + expect(hasActiveFilters(DEFAULT_FILE_FILTERS)).toBe(false); + }); + + it("filters by kind", () => { + const out = applyFileFilters(rows, { ...DEFAULT_FILE_FILTERS, kind: KindFilter.Document }, NOW); + expect(out.map((e) => e.id)).toEqual(["r", "n"]); + }); + + it("filters by size band and drops sizeless rows (folders)", () => { + const medium = applyFileFilters(rows, { ...DEFAULT_FILE_FILTERS, size: SizeFilter.Medium }, NOW); + expect(medium.map((e) => e.id)).toEqual(["p"]); + const tiny = applyFileFilters(rows, { ...DEFAULT_FILE_FILTERS, size: SizeFilter.Tiny }, NOW); + expect(tiny.map((e) => e.id)).toEqual(["n", "x"]); + const huge = applyFileFilters(rows, { ...DEFAULT_FILE_FILTERS, size: SizeFilter.Huge }, NOW); + expect(huge.map((e) => e.id)).toEqual(["b"]); + }); + + it("filters by extension case-insensitively", () => { + const out = applyFileFilters(rows, { ...DEFAULT_FILE_FILTERS, extension: "jpg" }, NOW); + expect(out.map((e) => e.id)).toEqual(["p"]); + }); + + it("filters by a live-rolling modified range", () => { + const week = applyFileFilters( + rows, + { ...DEFAULT_FILE_FILTERS, modified: RelativeDateRange.Last7Days }, + NOW, + ); + expect(week.map((e) => e.id)).not.toContain("n"); + expect(week.map((e) => e.id)).toContain("r"); + const today = applyFileFilters( + rows, + { ...DEFAULT_FILE_FILTERS, modified: RelativeDateRange.Today }, + NOW, + ); + expect(today.map((e) => e.id)).not.toContain("r"); + }); + + it("ANDs every active axis", () => { + const filters = { + kind: KindFilter.Document, + size: SizeFilter.Small, + modified: RelativeDateRange.Last30Days, + extension: "pdf", + }; + expect(countActiveFilters(filters)).toBe(4); + expect(applyFileFilters(rows, filters, NOW).map((e) => e.id)).toEqual(["r"]); + }); +}); + +describe("collectExtensions", () => { + it("lists distinct lower-cased extensions A→Z, files only", () => { + expect(collectExtensions(rows, null)).toEqual(["jpg", "mp3", "pdf", "txt", "zip"]); + }); + + it("keeps the selected extension listed even when no row has it", () => { + expect(collectExtensions([photo], "pdf")).toEqual(["jpg", "pdf"]); + }); +}); diff --git a/apps/files/src/logic/filters.ts b/apps/files/src/logic/filters.ts new file mode 100644 index 00000000..b96d9727 --- /dev/null +++ b/apps/files/src/logic/filters.ts @@ -0,0 +1,218 @@ +/** + * Property filters for the folder-contents list (9.8.16 — Files as a + * collection). Pure: the store runs `applyFileFilters` over the already + * sorted/searched rows, so filters compose with sort, group-by, search and + * the Duplicates view instead of each owning a private narrowing pass. + * + * The date axis reuses the SDK's live-rolling `RelativeDateRange` (the same + * resolver the Database filter language evaluates), so "Last 7 days" here + * and in a Database view are one definition. + */ + +import { + RelativeDateRange, + isRelativeDateRange, + resolveRelativeRange, +} from "@brainstorm-os/sdk/predicate-eval"; +import { + type Entity, + FILE_TYPE, + FOLDER_TYPE, + NOTE_TYPE, + readExtension, + readSize, +} from "../types/entity"; + +export enum KindFilter { + Any = "any", + Folder = "folder", + Image = "image", + Document = "document", + Audio = "audio", + Video = "video", + Archive = "archive", + Other = "other", +} + +export const KIND_FILTERS: readonly KindFilter[] = [ + KindFilter.Any, + KindFilter.Folder, + KindFilter.Image, + KindFilter.Document, + KindFilter.Audio, + KindFilter.Video, + KindFilter.Archive, + KindFilter.Other, +]; + +export enum SizeFilter { + Any = "any", + Tiny = "tiny", + Small = "small", + Medium = "medium", + Large = "large", + Huge = "huge", +} + +export const SIZE_FILTERS: readonly SizeFilter[] = [ + SizeFilter.Any, + SizeFilter.Tiny, + SizeFilter.Small, + SizeFilter.Medium, + SizeFilter.Large, + SizeFilter.Huge, +]; + +const KB = 1024; +const MB = 1024 * KB; + +/** Half-open byte bands `[min, max)`; the bands tile the whole range so + * every sized file lands in exactly one. */ +const SIZE_BANDS: Record, { min: number; max: number }> = { + [SizeFilter.Tiny]: { min: 0, max: 100 * KB }, + [SizeFilter.Small]: { min: 100 * KB, max: MB }, + [SizeFilter.Medium]: { min: MB, max: 10 * MB }, + [SizeFilter.Large]: { min: 10 * MB, max: 100 * MB }, + [SizeFilter.Huge]: { min: 100 * MB, max: Number.POSITIVE_INFINITY }, +}; + +/** Only the look-back ranges make sense for "modified" — nothing in the vault + * was modified tomorrow. */ +export const MODIFIED_RANGES: readonly RelativeDateRange[] = [ + RelativeDateRange.Today, + RelativeDateRange.Yesterday, + RelativeDateRange.Last7Days, + RelativeDateRange.Last30Days, + RelativeDateRange.ThisWeek, + RelativeDateRange.ThisMonth, + RelativeDateRange.ThisYear, +]; + +export type FileFilters = { + kind: KindFilter; + size: SizeFilter; + /** `null` = any time. */ + modified: RelativeDateRange | null; + /** Lower-cased, no dot; `null` = any extension. */ + extension: string | null; +}; + +export const DEFAULT_FILE_FILTERS: FileFilters = Object.freeze({ + kind: KindFilter.Any, + size: SizeFilter.Any, + modified: null, + extension: null, +}); + +export function hasActiveFilters(filters: FileFilters): boolean { + return ( + filters.kind !== KindFilter.Any || + filters.size !== SizeFilter.Any || + filters.modified !== null || + filters.extension !== null + ); +} + +export function countActiveFilters(filters: FileFilters): number { + return ( + (filters.kind !== KindFilter.Any ? 1 : 0) + + (filters.size !== SizeFilter.Any ? 1 : 0) + + (filters.modified !== null ? 1 : 0) + + (filters.extension !== null ? 1 : 0) + ); +} + +const DOCUMENT_MIMES = new Set([ + "application/pdf", + "application/json", + "application/xml", + "application/rtf", + "application/msword", + "application/vnd.ms-excel", + "application/vnd.ms-powerpoint", + "application/epub+zip", +]); + +const ARCHIVE_MIMES = new Set([ + "application/zip", + "application/x-tar", + "application/gzip", + "application/x-7z-compressed", + "application/vnd.rar", + "application/x-rar-compressed", + "application/x-bzip2", +]); + +function readMime(entity: Entity): string { + const mime = entity.properties.mime; + return typeof mime === "string" ? mime.toLowerCase() : ""; +} + +/** The kind bucket an entity belongs to. Files bucket by MIME family; + * Notes read as documents; every other typed object is `Other`. */ +export function kindOf(entity: Entity): Exclude { + if (entity.type === FOLDER_TYPE) return KindFilter.Folder; + if (entity.type === NOTE_TYPE) return KindFilter.Document; + if (entity.type !== FILE_TYPE) return KindFilter.Other; + const mime = readMime(entity); + if (mime.startsWith("image/")) return KindFilter.Image; + if (mime.startsWith("audio/")) return KindFilter.Audio; + if (mime.startsWith("video/")) return KindFilter.Video; + if (ARCHIVE_MIMES.has(mime)) return KindFilter.Archive; + if ( + mime.startsWith("text/") || + DOCUMENT_MIMES.has(mime) || + mime.startsWith("application/vnd.openxmlformats-officedocument.") || + mime.startsWith("application/vnd.oasis.opendocument.") + ) { + return KindFilter.Document; + } + return KindFilter.Other; +} + +function matchesSize(entity: Entity, size: SizeFilter): boolean { + if (size === SizeFilter.Any) return true; + if (entity.type !== FILE_TYPE) return false; + const band = SIZE_BANDS[size]; + const bytes = readSize(entity); + return bytes >= band.min && bytes < band.max; +} + +function matchesModified(entity: Entity, range: RelativeDateRange | null, now: number): boolean { + if (range === null) return true; + if (!isRelativeDateRange(range)) return false; + const window = resolveRelativeRange(range, now); + return entity.updatedAt >= window.start && entity.updatedAt < window.end; +} + +export function matchesFilters(entity: Entity, filters: FileFilters, now: number): boolean { + if (filters.kind !== KindFilter.Any && kindOf(entity) !== filters.kind) return false; + if (!matchesSize(entity, filters.size)) return false; + if (filters.extension !== null && readExtension(entity) !== filters.extension) return false; + return matchesModified(entity, filters.modified, now); +} + +/** Order-preserving narrow of `entities` to the rows every active filter + * accepts. Inactive filters return the input as-is (a defensive copy). */ +export function applyFileFilters( + entities: readonly Entity[], + filters: FileFilters, + now: number, +): Entity[] { + if (!hasActiveFilters(filters)) return entities.slice(); + return entities.filter((entity) => matchesFilters(entity, filters, now)); +} + +/** The distinct extensions present among `entities`, A→Z — the options the + * extension filter offers (an extension nobody has is never a choice). A + * currently-selected extension stays listed even when no row carries it, + * so the picker can always show + clear the active choice. */ +export function collectExtensions(entities: readonly Entity[], selected: string | null): string[] { + const seen = new Set(); + for (const entity of entities) { + const ext = readExtension(entity); + if (ext.length > 0) seen.add(ext); + } + if (selected !== null) seen.add(selected); + return [...seen].sort((a, b) => a.localeCompare(b)); +} diff --git a/apps/files/src/logic/group.ts b/apps/files/src/logic/group.ts index 7fd11ce9..cb2e91a5 100644 --- a/apps/files/src/logic/group.ts +++ b/apps/files/src/logic/group.ts @@ -9,7 +9,7 @@ * entirely rather than rendering a single unlabelled group. */ -import { type Entity, FILE_TYPE, FOLDER_TYPE, readName } from "../types/entity"; +import { type Entity, FILE_TYPE, FOLDER_TYPE, readExtension, readName } from "../types/entity"; export enum GroupKey { None = "none", @@ -53,10 +53,8 @@ export type GroupLabels = { function typeBucket(entity: Entity, labels: GroupLabels): { key: string; label: string } { if (entity.type === FOLDER_TYPE) return { key: "folder", label: labels.folders }; if (entity.type === FILE_TYPE) { - const name = readName(entity); - const dot = name.lastIndexOf("."); - const ext = dot > 0 ? name.slice(dot + 1) : ""; - if (ext.length > 0 && ext.length <= 8 && !ext.includes(" ")) { + const ext = readExtension(entity); + if (ext.length > 0) { const upper = ext.toUpperCase(); return { key: `ext:${upper}`, label: upper }; } diff --git a/apps/files/src/logic/list-columns.test.ts b/apps/files/src/logic/list-columns.test.ts index 75c2cea8..489b3612 100644 --- a/apps/files/src/logic/list-columns.test.ts +++ b/apps/files/src/logic/list-columns.test.ts @@ -34,21 +34,14 @@ describe("list columns (9.8.11)", () => { }); describe("listIconTrack", () => { - it("reserves one shared track when ANY row in the folder has an icon", () => { - // The regression: `grid-template-columns` is per-row, so an `auto` - // leading track collapsed to 0 on iconless rows and reserved space on - // icon-bearing ones — two column geometries in one list, with name, - // kind and modified each landing at two different x positions. - expect(listIconTrack(true, 18)).toBe("18px"); - }); - - it("collapses the track when NOTHING in the folder has an icon", () => { - // The no-default-type-icon-fallback rule survives: a uniformly iconless - // folder still slides every name left into the slot. - expect(listIconTrack(false, 18)).toBe("0px"); + it("reserves one fixed track so every row shares a column geometry", () => { + // `grid-template-columns` is per-row: a track that varied with the row + // (or collapsed to 0) put name, kind and modified at different x — and a + // 0 track let the name land in the icon column as a sliver. + expect(listIconTrack(18)).toBe("18px"); }); it("tracks the mode's icon size so density presets stay aligned", () => { - expect(listIconTrack(true, 28)).toBe("28px"); + expect(listIconTrack(28)).toBe("28px"); }); }); diff --git a/apps/files/src/logic/list-columns.ts b/apps/files/src/logic/list-columns.ts index f2334909..c43a543f 100644 --- a/apps/files/src/logic/list-columns.ts +++ b/apps/files/src/logic/list-columns.ts @@ -50,20 +50,14 @@ export const LIST_COLUMN_WIDTH: Record = { }; /** - * The list-mode icon track, decided ONCE for a whole folder. + * The list-mode icon track, decided ONCE for a whole list. * * `grid-template-columns` is a per-ROW declaration — each row is its own grid — - * so an `auto` leading track sizes independently per row. A row renders an - * empty icon span when its entity has no icon (the project-wide - * no-default-type-icon-fallback rule), which collapsed the track to 0 on those - * rows while icon-bearing rows reserved it. The result was two column - * geometries in one list: name, kind and modified each landing at two - * different x positions. - * - * `anyRowHasIcon` therefore has to be computed across the folder, not per row. - * The fallback rule survives: a folder where NOTHING has an icon still yields a - * zero track, so every name slides left into the slot. + * so the leading track must be the same fixed width on every row or name, kind + * and modified land at different x positions. It is never 0: the glyph slot is + * always rendered (`RowGlyph`), and a collapsed track is exactly what let the + * name take the icon column while the kind cell took the name's `1fr` lane. */ -export function listIconTrack(anyRowHasIcon: boolean, iconSizePx: number): string { - return anyRowHasIcon ? `${iconSizePx}px` : "0px"; +export function listIconTrack(iconSizePx: number): string { + return `${iconSizePx}px`; } diff --git a/apps/files/src/logic/property-rows.ts b/apps/files/src/logic/property-rows.ts index 646e7e57..2aa798f1 100644 --- a/apps/files/src/logic/property-rows.ts +++ b/apps/files/src/logic/property-rows.ts @@ -44,6 +44,9 @@ const RENDERED_ELSEWHERE: ReadonlySet = new Set([ "modified", "id", "type", + // Agent-16 — authorship renders as the panel's own "Created by" row. + "agentprovenance", + "createdbyagent", ]); export type PropertyRow = { diff --git a/apps/files/src/logic/upload.test.ts b/apps/files/src/logic/upload.test.ts index 5aa22b50..d1c01a1f 100644 --- a/apps/files/src/logic/upload.test.ts +++ b/apps/files/src/logic/upload.test.ts @@ -1,31 +1,5 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_MIME, collisionName, mimeFromName, sha256Hex, splitName } from "./upload"; - -describe("mimeFromName", () => { - it("maps common extensions case-insensitively", () => { - expect(mimeFromName("report.PDF")).toBe("application/pdf"); - expect(mimeFromName("photo.JPG")).toBe("image/jpeg"); - expect(mimeFromName("a.txt")).toBe("text/plain"); - expect(mimeFromName("a.md")).toBe("text/markdown"); - expect(mimeFromName("a.csv")).toBe("text/csv"); - }); - - it("falls back to application/octet-stream for unknown extensions", () => { - expect(mimeFromName("foo.weirdext")).toBe(DEFAULT_MIME); - }); - - it("returns the default when there is no extension", () => { - expect(mimeFromName("README")).toBe(DEFAULT_MIME); - }); - - it("treats a dotfile (leading dot, nothing else) as extensionless", () => { - expect(mimeFromName(".bashrc")).toBe(DEFAULT_MIME); - }); - - it("returns the default when the name ends with a dot", () => { - expect(mimeFromName("foo.")).toBe(DEFAULT_MIME); - }); -}); +import { collisionName, sha256Hex, splitName } from "./upload"; describe("splitName", () => { it("splits stem and dotted extension", () => { diff --git a/apps/files/src/logic/upload.ts b/apps/files/src/logic/upload.ts index 0f830ddb..79f80784 100644 --- a/apps/files/src/logic/upload.ts +++ b/apps/files/src/logic/upload.ts @@ -12,55 +12,6 @@ * which Node ≥18 + vitest's `jsdom` env both provide. */ -const EXTENSION_MIME: Readonly> = { - txt: "text/plain", - md: "text/markdown", - markdown: "text/markdown", - html: "text/html", - htm: "text/html", - css: "text/css", - js: "text/javascript", - mjs: "text/javascript", - ts: "text/typescript", - tsx: "text/typescript", - json: "application/json", - csv: "text/csv", - xml: "application/xml", - yaml: "text/yaml", - yml: "text/yaml", - pdf: "application/pdf", - png: "image/png", - jpg: "image/jpeg", - jpeg: "image/jpeg", - gif: "image/gif", - webp: "image/webp", - avif: "image/avif", - svg: "image/svg+xml", - heic: "image/heic", - mp3: "audio/mpeg", - wav: "audio/wav", - ogg: "audio/ogg", - flac: "audio/flac", - mp4: "video/mp4", - webm: "video/webm", - mov: "video/quicktime", - zip: "application/zip", - tar: "application/x-tar", - gz: "application/gzip", -}; - -export const DEFAULT_MIME = "application/octet-stream"; - -/** Lower-cases the trailing extension and maps it to a MIME type. Returns - * `application/octet-stream` when the extension is missing or unknown — - * the safe default that preserves bytes verbatim. */ -export function mimeFromName(name: string): string { - const dot = name.lastIndexOf("."); - if (dot <= 0 || dot === name.length - 1) return DEFAULT_MIME; - const ext = name.slice(dot + 1).toLowerCase(); - return EXTENSION_MIME[ext] ?? DEFAULT_MIME; -} - /** Split a filename into `stem` + `ext` ("" or ".jpg"-with-dot). A leading * dot is treated as a hidden-file prefix, not an extension — `.bashrc` * has stem `.bashrc` + ext "". */ diff --git a/apps/files/src/main.tsx b/apps/files/src/main.tsx index 95416444..a72a2aed 100644 --- a/apps/files/src/main.tsx +++ b/apps/files/src/main.tsx @@ -1,6 +1,7 @@ import "@brainstorm-os/sdk/app-theme.css"; import { initAnalytics } from "@brainstorm-os/sdk/analytics"; import "@brainstorm-os/editor/editor.css"; +import "@brainstorm-os/sdk/count-badge.css"; import { AppErrorBoundary } from "@brainstorm-os/sdk/error-boundary"; import { mountMenuHost } from "@brainstorm-os/sdk/menus"; import { applyPersistedPanelWidth } from "@brainstorm-os/sdk/resizable"; diff --git a/apps/files/src/store/use-files-store.ts b/apps/files/src/store/use-files-store.ts index 61e620cb..eff916b6 100644 --- a/apps/files/src/store/use-files-store.ts +++ b/apps/files/src/store/use-files-store.ts @@ -26,7 +26,9 @@ import { import { announce } from "@brainstorm-os/sdk/a11y"; import { isEntityLocked, lockRefusesWrite, lockTogglePatch } from "@brainstorm-os/sdk/entity-lock"; import { entityTitleOr } from "@brainstorm-os/sdk/entity-title"; +import { formatBytes } from "@brainstorm-os/sdk/format-bytes"; import { type NavHistory, createNavHistory } from "@brainstorm-os/sdk/nav-history"; +import { importDroppedOsFiles, mimeFromName } from "@brainstorm-os/sdk/os-file-drop"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { plural, t } from "../i18n"; import { type BreadcrumbSegment, deriveBreadcrumbs } from "../logic/breadcrumbs"; @@ -36,7 +38,14 @@ import { openerFromHandlers, unresolvedTypes, } from "../logic/browsable-types"; +import { findDuplicateGroups } from "../logic/duplicates"; import { type EntityLink, partitionLinksForEntity } from "../logic/entity-links"; +import { + DEFAULT_FILE_FILTERS, + type FileFilters, + applyFileFilters, + collectExtensions, +} from "../logic/filters"; import { FolderTree } from "../logic/folder-tree"; import { DEFAULT_GROUP_KEY, type EntityGroup, GroupKey, groupEntities } from "../logic/group"; import { DEFAULT_LIST_COLUMNS, type ListColumn, toggleListColumn } from "../logic/list-columns"; @@ -63,7 +72,7 @@ import { defaultDirectionFor, sortEntities, } from "../logic/sort"; -import { collisionName, mimeFromName, sha256Hex } from "../logic/upload"; +import { collisionName, sha256Hex } from "../logic/upload"; import { type RetainedMembersMap, buildVaultFileTree } from "../logic/vault-tree"; import { applyViewOptionsToAllFolders, @@ -240,6 +249,11 @@ export function useFilesStore() { const [searchQuery, setSearchQuery] = useState(""); const [searchScope, setSearchScope] = useState(SearchScope.ActiveFolder); const [smartFolders, setSmartFolders] = useState([]); + // 9.8.16 — property filters are session state that survives navigation + // (a "show me PDFs" narrowing follows the user folder to folder); + // the Duplicates view is a vault-wide location any folder nav leaves. + const [filters, setFilters] = useState(DEFAULT_FILE_FILTERS); + const [duplicatesActive, setDuplicatesActive] = useState(false); const [sortKey, setSortKeyState] = useState(DEFAULT_SORT_KEY); const [sortDirection, setSortDirection] = useState(DEFAULT_SORT_DIRECTION); const [groupKey, setGroupKeyState] = useState(DEFAULT_GROUP_KEY); @@ -358,24 +372,41 @@ export function useFilesStore() { // triggers `rerender` on every mutation (create/rename/move/delete), // so a fresh read here always reflects the latest snapshot. The list // is row-virtualised downstream, so this stays cheap. - const searching = searchQuery.trim() !== ""; - const sortedRows: Entity[] = searching - ? runSearch({ - tree, - // Vault scope walks from the ROOT folder, not the folder the user - // happens to be in (9.8.9) — a Vault-scoped smart folder re-run - // must see the whole tree. - folderId: searchScope === SearchScope.Vault ? ROOT_FOLDER_ID : nav.current, - query: searchQuery, - scope: searchScope, - }) - : sortEntities(tree.listFolderMembers(nav.current), sortKey, sortDirection); + const searching = !duplicatesActive && searchQuery.trim() !== ""; + const unfilteredRows: Entity[] = duplicatesActive + ? tree.listByType(FILE_TYPE) + : searching + ? runSearch({ + tree, + // Vault scope walks from the ROOT folder, not the folder the user + // happens to be in (9.8.9) — a Vault-scoped smart folder re-run + // must see the whole tree. + folderId: searchScope === SearchScope.Vault ? ROOT_FOLDER_ID : nav.current, + query: searchQuery, + scope: searchScope, + }) + : sortEntities(tree.listFolderMembers(nav.current), sortKey, sortDirection); + const availableExtensions = collectExtensions(unfilteredRows, filters.extension); + const sortedRows = applyFileFilters(unfilteredRows, filters, Date.now()); + // Duplicates group AFTER filtering, so "duplicate PDFs" means ≥2 PDFs + // sharing bytes — a group a filter thins to one row is not a duplicate. + const duplicateGroups = duplicatesActive ? findDuplicateGroups(sortedRows) : null; // Group-by (9.8.11): sections re-order the flat row list (group-major, // sorted within), so selection / keyboard / preview-siblings — which all // consume `visibleRows` order — stay coherent with what's on screen. // Search results are never grouped (relevance order IS the result). - const visibleGroups: EntityGroup[] | null = - !searching && groupKey !== GroupKey.None + const visibleGroups: EntityGroup[] | null = duplicateGroups + ? duplicateGroups.map((group) => ({ + key: `dup:${group.hash}`, + label: plural( + group.entities.length, + "brainstorm.files.duplicates.group.one", + "brainstorm.files.duplicates.group.other", + { size: formatBytes(group.size) }, + ), + entities: group.entities, + })) + : !searching && groupKey !== GroupKey.None ? groupEntities(sortedRows, groupKey, { folders: t("brainstorm.files.group.folders"), noExtension: t("brainstorm.files.group.noExtension"), @@ -428,6 +459,7 @@ export function useFilesStore() { // `` (and back/forward chords) call this after the // controller has already walked the stack, so it must not re-`push`. const applyFolderLocation = useCallback((id: string) => { + setDuplicatesActive(false); setExpandedFolders((s) => new Set(s).add(id)); setSelection(EMPTY_SELECTION); setSearchQuery(""); @@ -436,7 +468,10 @@ export function useFilesStore() { const navigateToFolder = useCallback( (id: string) => { - if (id === navHist.current()) return; + if (id === navHist.current()) { + setDuplicatesActive(false); + return; + } const entity = tree.get(id); if (!entity || entity.type !== FOLDER_TYPE) return; navHist.push(id); @@ -504,6 +539,25 @@ export function useFilesStore() { [tree, navigateToFolder], ); + // ─── Collection view: filters + duplicates (9.8.16) ─────────────────── + + const setFilter = useCallback((patch: Partial) => { + setFilters((current) => ({ ...current, ...patch })); + setSelection(EMPTY_SELECTION); + }, []); + + const clearFilters = useCallback(() => { + setFilters(DEFAULT_FILE_FILTERS); + setSelection(EMPTY_SELECTION); + }, []); + + const showDuplicates = useCallback(() => { + setDuplicatesActive(true); + setSelection(EMPTY_SELECTION); + setSearchQuery(""); + setSearchScope(SearchScope.ActiveFolder); + }, []); + const toggleFolderExpansion = useCallback((id: string) => { setExpandedFolders((s) => { const next = new Set(s); @@ -779,24 +833,25 @@ export function useFilesStore() { return; } const parentId = navRef.current.current; - const createdIds: string[] = []; uploadInFlightRef.current = true; - for (const file of dropped) { - let stored: StoredUpload; - try { - const bytes = new Uint8Array(await file.arrayBuffer()); - const reply = await filesSvc.import({ name: file.name, bytes }); - stored = { - size: reply.size, - hash: reply.contentHash, - assetId: reply.assetId, - assetMime: reply.mime, - }; - } catch (error) { - console.warn(`[files] ${t("brainstorm.files.upload.readFailed", { name: file.name })}`, error); - continue; - } - const id = ingestStoredFile(file.name, stored, parentId); + const imported = await importDroppedOsFiles( + dropped, + { import: filesSvc.import.bind(filesSvc) }, + { + onError: (file, error) => + console.warn( + `[files] ${t("brainstorm.files.upload.readFailed", { name: file.name })}`, + error, + ), + }, + ); + const createdIds: string[] = []; + for (const file of imported) { + const id = ingestStoredFile( + file.name, + { size: file.size, hash: file.hash, assetId: file.assetId, assetMime: file.assetMime }, + parentId, + ); if (id) createdIds.push(id); } finishUpload(parentId, createdIds); @@ -1353,6 +1408,13 @@ export function useFilesStore() { deleteSmartFolderById, renameSmartFolderById, activateSmartFolder, + filters, + setFilter, + clearFilters, + availableExtensions, + duplicatesActive, + duplicateGroups, + showDuplicates, sortKey, sortDirection, setSortKey, diff --git a/apps/files/src/styles-row-grid.test.ts b/apps/files/src/styles-row-grid.test.ts new file mode 100644 index 00000000..60950ef0 --- /dev/null +++ b/apps/files/src/styles-row-grid.test.ts @@ -0,0 +1,57 @@ +/** + * Row-grid contract — the list row's leading icon track is FIXED, and the + * glyph host is never collapsed out of the grid. + * + * `grid-template-columns` is per row. When the first track was `auto` and + * `.content-row__glyph:empty { display: none }` removed the host from the + * grid, the NAME became the first grid item and sized to the icon track + * while the Kind cell took the `1fr` lane — "H.. pdf Today" with the file + * name a sliver (the 0.15.0 release-shot regression, `h-01-after-import`). + * Same posture as `styles-lane-fill.test.ts`: parse `styles.css`, fail on + * a silent regression. + */ + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const CSS_PATH = join(dirname(fileURLToPath(import.meta.url)), "styles.css"); + +function rules(css: string): { selectors: string[]; body: string }[] { + const stripped = css.replace(/\/\*[\s\S]*?\*\//g, ""); + const out: { selectors: string[]; body: string }[] = []; + for (const match of stripped.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { + out.push({ + selectors: (match[1] ?? "").split(",").map((s) => s.trim()), + body: match[2] ?? "", + }); + } + return out; +} + +describe("list row grid keeps a fixed icon track", () => { + const css = readFileSync(CSS_PATH, "utf8"); + const all = rules(css); + + it(".content-row's first grid track is a fixed width, never auto", () => { + const row = all.filter((r) => r.selectors.includes(".content-row")); + const template = row + .map((r) => /grid-template-columns:\s*([^;]+);/.exec(r.body)?.[1]?.trim()) + .find((v): v is string => Boolean(v)); + expect(template, ".content-row must declare grid-template-columns").toBeDefined(); + const first = (template ?? "").split(/\s+/)[0] ?? ""; + expect(first, `first track "${first}" must be fixed (px or a --space token)`).toMatch( + /^(\d+px|var\(--space-[\w-]+\))$/, + ); + }); + + it("never collapses an empty .content-row__glyph out of the grid", () => { + const collapse = all.filter( + (r) => + r.selectors.some((s) => s.includes(".content-row__glyph:empty")) && + /display:\s*none/.test(r.body), + ); + expect(collapse, "an empty glyph host must keep its grid slot").toHaveLength(0); + }); +}); diff --git a/apps/files/src/styles.css b/apps/files/src/styles.css index 1623b492..4d878d7a 100644 --- a/apps/files/src/styles.css +++ b/apps/files/src/styles.css @@ -241,7 +241,7 @@ body.is-resizing .window-resize { will-change: transform; } -body[data-nav-open="false"] .sidebar { +body[data-nav-open="false"] .sidebar:not([data-bs-autohide]) { transform: translateX(-100%); border-inline-end-color: transparent; visibility: hidden; @@ -615,6 +615,42 @@ body[data-nav-open="false"] .sidebar { color: var(--accent); } +.toolbar__filter { + flex-shrink: 0; + gap: var(--space-1); +} + +/* Property-filter row + the Duplicates summary (9.8.16) — a second band + * under the toolbar, same gutter + hairline, so a narrowed list always says + * why. */ +.files-filterbar, +.files-locationbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-2) var(--space-3); + padding: var(--space-1) var(--space-2); + margin: 0; + border-bottom: var(--border-width) solid var(--border); + flex-shrink: 0; +} + +.files-filterbar__field { + display: inline-flex; + align-items: center; + gap: var(--space-1); +} + +.files-filterbar__label, +.files-locationbar { + color: var(--text-dim); + font-size: var(--text-size-sm); +} + +.files-filterbar__clear { + margin-inline-start: auto; +} + /* ── Smart folders (saved searches) ───────────────────────────────────── */ .smart-folders { @@ -755,12 +791,11 @@ body[data-nav-open="false"] .sidebar { .content-row { display: grid; - /* Per [[feedback_no_default_type_icon_fallback]] (project-wide), the - * icon column auto-sizes so it collapses to 0 (gap included) when an - * entity has no own icon — the name then slides left into the slot - * instead of every iconless row staying indented under the fixed 24px - * track that used to be `24px ...`. */ - grid-template-columns: auto minmax(0, 1fr) minmax(0, 160px) 120px; + /* The leading track is FIXED and the glyph host is always rendered: an + * `auto` track + a collapsed empty host let the NAME become the first grid + * item and shrink to the icon column (0.15.0 "file names invisible"). List + * modes override the template inline from the mode's icon size. */ + grid-template-columns: 20px minmax(0, 1fr) minmax(0, 160px) 120px; align-items: center; gap: var(--space-2); padding: var(--space-2); @@ -791,12 +826,6 @@ body[data-nav-open="false"] .sidebar { height: 47px; } -/* Inner host of ``: collapse the empty wrapper too so the - * grid's `auto` column has nothing to size to. */ -.content-row__glyph:empty { - display: none; -} - .content-row:hover { background: var(--hover); } @@ -878,6 +907,9 @@ body[data-nav-open="false"] .sidebar { justify-content: center; width: 20px; height: 20px; + flex-shrink: 0; + overflow: hidden; + border-radius: var(--radius-sm); color: var(--glyph-folder); } @@ -1068,10 +1100,6 @@ body[data-view-mode="grid"] .content-row__glyph { * folder-tint surface its padding ring. */ width: calc(var(--glyph-size, 36px) * 1.25); height: calc(var(--glyph-size, 36px) * 1.25); - /* Explicit display beats the global `.content-row__glyph:empty - * { display: none }` collapse — in tile modes the media slot is part - * of the tile's anatomy (folder tint / thumb / chip), so an empty - * slot keeps its box instead of leaving a name-only "button". */ display: flex; align-items: center; justify-content: center; diff --git a/apps/files/src/types/entity.ts b/apps/files/src/types/entity.ts index fdcca34a..46b1af04 100644 --- a/apps/files/src/types/entity.ts +++ b/apps/files/src/types/entity.ts @@ -104,6 +104,25 @@ export function readSize(entity: Entity): number { return typeof size === "number" ? size : 0; } +/** Lower-cased trailing extension of a File's name ("" for folders, typed + * objects, and names with no plausible extension). Shared by the Type + * group-by bucket and the extension filter so the two agree on what counts. */ +export function readExtension(entity: Entity): string { + if (entity.type !== FILE_TYPE) return ""; + const name = readName(entity); + const dot = name.lastIndexOf("."); + const ext = dot > 0 ? name.slice(dot + 1) : ""; + if (ext.length === 0 || ext.length > 8 || ext.includes(" ")) return ""; + return ext.toLowerCase(); +} + +/** The content hash a stored upload carries (`files.import`'s `contentHash`, + * persisted as `hash`), or null when the row has none. */ +export function readContentHash(entity: Entity): string | null { + const hash = entity.properties.hash; + return typeof hash === "string" && hash.length > 0 ? hash : null; +} + export function readMembers(entity: Entity): string[] { const members = entity.properties.members; if (!Array.isArray(members)) return []; diff --git a/apps/files/src/ui/content-list.tsx b/apps/files/src/ui/content-list.tsx index 38938773..d871434e 100644 --- a/apps/files/src/ui/content-list.tsx +++ b/apps/files/src/ui/content-list.tsx @@ -22,10 +22,12 @@ import { useDropTarget, } from "@brainstorm-os/sdk/object-dnd"; import { ObjectMenuTrigger } from "@brainstorm-os/sdk/object-menu"; +import { useOsFileDrop } from "@brainstorm-os/sdk/os-file-drop"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { t } from "../i18n"; import { dragItemsForEntry } from "../logic/drag-items"; +import { hasActiveFilters } from "../logic/filters"; import { DEFAULT_LIST_COLUMNS, LIST_COLUMN_WIDTH, @@ -39,9 +41,9 @@ import type { FilesStore } from "../store/use-files-store"; import { type Entity, FILE_TYPE, FOLDER_TYPE, readName } from "../types/entity"; import type { BrainstormRuntime } from "../types/runtime"; import { TileSize, ViewMode, isListMode } from "../view-mode"; -import { formatBytes, formatTimeAgo, readEntityIcon, typeLabel } from "./entity-view"; -import { EntityIcon } from "./entity-visuals"; +import { formatBytes, formatTimeAgo, typeLabel } from "./entity-view"; import { filesObjectMenuContext } from "./object-menu-context"; +import { RowGlyph } from "./row-glyph"; const DND_MIME = "application/x-brainstorm-entity"; @@ -128,12 +130,6 @@ function isGridMode(mode: ViewMode): boolean { return mode === ViewMode.Grid || mode === ViewMode.Gallery || mode === ViewMode.IconList; } -/** Tile modes that render the media slot (thumbnail / glyph band). The - * icon-list lanes are compact rows — they keep the plain entity icon. */ -function isTileMediaMode(mode: ViewMode): boolean { - return mode === ViewMode.Grid || mode === ViewMode.Gallery; -} - function rowHeightFor(mode: ViewMode, tileSize: TileSize): number { if (mode === ViewMode.IconList) return ICON_LIST_ROW_HEIGHT; return tileMetrics(mode, tileSize)?.height ?? ROW_HEIGHT; @@ -222,56 +218,18 @@ export function ContentList({ const mode = store.viewMode; const isGrid = isGridMode(mode); - // One icon track for the whole list, not one per row. A row renders a glyph - // when it is a folder or the entity carries its own icon; `EntityIcon` - // renders an EMPTY span otherwise, per the project-wide - // no-default-type-icon-fallback rule. Because `grid-template-columns` is - // per-row, an `auto` first track then collapsed to 0 on exactly those rows - // and reserved space on the others — so name, kind and modified each landed - // at two different x positions in one list. - // - // The rule is preserved: when NOTHING in this folder has an icon the track - // is still 0 and every name slides left into the slot. It is only when the - // folder is mixed that the iconless rows keep the gutter, which is the - // alignment the list view exists for. const iconTrack = useMemo( - () => - listIconTrack( - rows.some((entity) => entity.type === FOLDER_TYPE || readEntityIcon(entity)), - iconSizeFor(mode, store.tileSize), - ), - [rows, mode, store.tileSize], + () => listIconTrack(iconSizeFor(mode, store.tileSize)), + [mode, store.tileSize], ); - // 9.8.15 — Finder/OS drag-in upload. The content area is the drop - // target for EXTERNAL files (`dataTransfer` carries the "Files" type); - // internal entity drags ride DND_MIME on the rows and never enter this - // path. Depth-counted so dragging across child rows doesn't flicker - // the affordance off. - const [osDragDepth, setOsDragDepth] = useState(0); - const isOsFileDrag = (e: React.DragEvent) => - e.dataTransfer.types.includes("Files") && !e.dataTransfer.types.includes(DND_MIME); - const onOsDragEnter = (e: React.DragEvent) => { - if (!isOsFileDrag(e)) return; - e.preventDefault(); - setOsDragDepth((d) => d + 1); - }; - const onOsDragOver = (e: React.DragEvent) => { - if (!isOsFileDrag(e)) return; - e.preventDefault(); - e.dataTransfer.dropEffect = "copy"; - }; - const onOsDragLeave = (e: React.DragEvent) => { - if (!isOsFileDrag(e)) return; - setOsDragDepth((d) => Math.max(0, d - 1)); - }; - const onOsDrop = (e: React.DragEvent) => { - if (!isOsFileDrag(e)) return; - e.preventDefault(); - setOsDragDepth(0); - const files = Array.from(e.dataTransfer.files); - if (files.length > 0) void store.uploadDroppedFiles(files); - }; + // 9.8.15 — Finder/OS drag-in upload, through the shared + // `@brainstorm-os/sdk/os-file-drop` zone (9.8.17): EXTERNAL files only — + // internal row drags ride DND_MIME and are ignored here. + const osDrop = useOsFileDrop({ + onFiles: (files) => void store.uploadDroppedFiles(files), + ignoreTypes: [DND_MIME], + }); // Group-by section headers (9.8.11) — injected as their own virtual rows // in List mode. Grid/Gallery render group-ORDERED tiles without header @@ -398,42 +356,75 @@ export function ContentList({ ); if (rows.length === 0) { - const searching = store.searchQuery.trim() !== ""; + const filtered = hasActiveFilters(store.filters); + const searching = !store.duplicatesActive && store.searchQuery.trim() !== ""; + const empty = store.duplicatesActive + ? { + icon: IconName.Copy, + title: t("brainstorm.files.duplicates.emptyTitle"), + hint: filtered + ? t("brainstorm.files.filter.emptyBody") + : t("brainstorm.files.duplicates.emptyBody"), + } + : searching + ? { + icon: IconName.Search, + title: t("brainstorm.files.empty.searchTitle"), + hint: t("brainstorm.files.empty.searchBody", { query: store.searchQuery }), + } + : filtered + ? { + icon: IconName.Search, + title: t("brainstorm.files.filter.emptyTitle"), + hint: t("brainstorm.files.filter.emptyBody"), + } + : null; return (
0 ? "true" : "false"} - onDragEnter={onOsDragEnter} - onDragOver={onOsDragOver} - onDragLeave={onOsDragLeave} - onDrop={onOsDrop} + data-os-drop={osDrop.isOver ? "true" : "false"} + {...osDrop.dropProps} > - - - {t("brainstorm.files.empty.newFolder")} - - ), - })} - /> + {empty ? ( + + {t("brainstorm.files.filter.clear")} + + ), + } + : {})} + /> + ) : ( + + + {t("brainstorm.files.empty.newFolder")} + + } + /> + )}
); } @@ -448,13 +439,10 @@ export function ContentList({ id="content-list" data-testid="content-list" data-view-mode={store.viewMode} - data-os-drop={osDragDepth > 0 ? "true" : "false"} + data-os-drop={osDrop.isOver ? "true" : "false"} ref={setListRef} aria-label={t("brainstorm.files.contentList.label")} - onDragEnter={onOsDragEnter} - onDragOver={onOsDragOver} - onDragLeave={onOsDragLeave} - onDrop={onOsDrop} + {...osDrop.dropProps} >
void; - /** The icon track for list mode, decided ONCE for the whole list. - * - * `grid-template-columns` is per-ROW — every row is its own grid — so an - * `auto` first track sizes independently per row. `EntityIcon` renders an - * empty span when an entity has no icon (the no-default-fallback rule), so - * those rows collapsed the track to 0 while icon-bearing rows reserved it: - * two column geometries in one list, with name, kind and modified all - * landing at different x. Deciding it once and giving every row the same - * value is what makes the columns line up. */ + /** The list-mode icon track. `grid-template-columns` is per ROW, so it + * is decided once from the mode's icon size and handed to every row — + * one column geometry for the whole list. */ iconTrack: string; /** DND-6 — keyboard twin of the DND-5 drag-out grip. Absent when the * runtime has no save surface (older shells). */ @@ -576,91 +558,6 @@ function columnText(column: ListColumn, entity: Entity): string { return typeof size === "number" ? formatBytes(size) : ""; } -/** Trailing extension of `name`, uppercased for the tile chip ("JPG", - * "PDF"). Hidden-file prefixes and extension-less names yield null (the - * band keeps its plain surface). Capped so "markdown" doesn't blow the - * chip out. */ -function extensionChip(name: string): string | null { - const dot = name.lastIndexOf("."); - if (dot <= 0 || dot === name.length - 1) return null; - const ext = name.slice(dot + 1); - if (ext.length > 5) return null; - return ext.toUpperCase(); -} - -/** - * The tile-mode media slot (grid / gallery): image files render their - * stored blob (`brainstorm://asset/`, decided by the shell's - * preview-safe `assetMime`); anything with its OWN icon renders it; other - * files fall back to an extension chip so a tile is never an anonymous - * blank rectangle. Folders render the folder glyph (the sanctioned - * type-indicator exception to [[feedback_no_default_type_icon_fallback]] — - * a container genuinely needs an indicator) on a neutral surface rather - * than an empty tinted "cover" band. - * - * A non-File/Folder object (the universal browser) with no own icon shows - * its default opener app's squircle (`brainstorm://app-icon/`) — - * the same sanctioned type-identity badge the dashboard pins use, so a - * Task/Note/Bookmark tile reads as *what it is* rather than going blank. - */ -function TileMedia({ - entity, - iconSize, - openerAppId, -}: { - entity: Entity; - iconSize: number; - openerAppId: string | null; -}) { - const icon = readEntityIcon(entity); - const isFolder = entity.type === FOLDER_TYPE; - const isFile = entity.type === FILE_TYPE; - const assetId = entity.properties.assetId; - const assetMime = entity.properties.assetMime; - const hasImage = - typeof assetId === "string" && - assetId !== "" && - typeof assetMime === "string" && - assetMime.startsWith("image/"); - const chip = isFile && !hasImage && !icon ? extensionChip(readName(entity)) : null; - const appIcon = !isFolder && !isFile && !icon && !hasImage ? openerAppId : null; - return ( - - ); -} - function ContentRow({ entity, store, @@ -885,23 +782,12 @@ function ContentRow({ ) : null} - {isTileMediaMode(store.viewMode) ? ( - - ) : isFolder && !readEntityIcon(entity) ? ( - - ) : ( - - )} + {renaming ? ( diff --git a/apps/files/src/ui/filter-bar.test.tsx b/apps/files/src/ui/filter-bar.test.tsx new file mode 100644 index 00000000..06d7b5cb --- /dev/null +++ b/apps/files/src/ui/filter-bar.test.tsx @@ -0,0 +1,75 @@ +// @vitest-environment jsdom +import { type ReactNode, act } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_FILE_FILTERS, KindFilter } from "../logic/filters"; +import { FilterBar, FilterToggleButton } from "./filter-bar"; + +let root: Root | null = null; + +function render(node: ReactNode): HTMLElement { + const el = document.createElement("div"); + document.body.appendChild(el); + root = createRoot(el); + act(() => root?.render(node)); + return el; +} + +afterEach(() => { + act(() => root?.unmount()); + root = null; + document.body.innerHTML = ""; +}); + +describe("FilterToggleButton", () => { + it("reports pressed state and the active-filter count", () => { + const el = render( + {}} + />, + ); + const button = el.querySelector('[data-testid="toolbar-filter"]'); + expect(button?.getAttribute("aria-pressed")).toBe("true"); + expect(button?.querySelector(".bs-count-badge")?.textContent).toBe("2"); + }); + + it("shows no badge when nothing is active", () => { + const el = render( + {}} />, + ); + expect(el.querySelector(".bs-count-badge")).toBeNull(); + }); +}); + +describe("FilterBar", () => { + it("disables Clear until a filter is active, then clears", () => { + const onClear = vi.fn(); + const el = render( + {}} + onClear={onClear} + />, + ); + const clear = el.querySelector('[data-testid="filter-clear"]'); + expect(clear?.disabled).toBe(true); + + act(() => + root?.render( + {}} + onClear={onClear} + />, + ), + ); + expect(clear?.disabled).toBe(false); + act(() => clear?.click()); + expect(onClear).toHaveBeenCalledOnce(); + expect(el.querySelector('[data-testid="filter-extension"]')?.textContent).toContain(".pdf"); + }); +}); diff --git a/apps/files/src/ui/filter-bar.tsx b/apps/files/src/ui/filter-bar.tsx new file mode 100644 index 00000000..35669ffa --- /dev/null +++ b/apps/files/src/ui/filter-bar.tsx @@ -0,0 +1,180 @@ +/** + * The property-filter row (9.8.16): Kind · Size · Modified · Extension, each + * a shared ``, plus Clear. Lives under the toolbar band; the + * toolbar's Filter toggle shows it, and it stays up while any filter is + * active so a narrowed list is never unexplained. + */ + +import { CountBadge, CountBadgeTone } from "@brainstorm-os/sdk/count-badge"; +import type { RelativeDateRange } from "@brainstorm-os/sdk/predicate-eval"; +import { SelectMenu, type SelectMenuOption } from "@brainstorm-os/sdk/select-menu"; +import { type ReactNode, useMemo } from "react"; +import { type TranslationKey, t } from "../i18n"; +import { + type FileFilters, + KIND_FILTERS, + KindFilter, + MODIFIED_RANGES, + SIZE_FILTERS, + SizeFilter, + countActiveFilters, +} from "../logic/filters"; + +/** SelectMenu values are strings; "any" for the nullable axes is the empty + * string, which no real extension or range token can collide with. */ +const ANY = ""; + +const KIND_LABEL_KEY: Record = { + [KindFilter.Any]: "brainstorm.files.filter.any", + [KindFilter.Folder]: "brainstorm.files.filter.kind.folder", + [KindFilter.Image]: "brainstorm.files.filter.kind.image", + [KindFilter.Document]: "brainstorm.files.filter.kind.document", + [KindFilter.Audio]: "brainstorm.files.filter.kind.audio", + [KindFilter.Video]: "brainstorm.files.filter.kind.video", + [KindFilter.Archive]: "brainstorm.files.filter.kind.archive", + [KindFilter.Other]: "brainstorm.files.filter.kind.other", +}; + +const SIZE_LABEL_KEY: Record = { + [SizeFilter.Any]: "brainstorm.files.filter.any", + [SizeFilter.Tiny]: "brainstorm.files.filter.size.tiny", + [SizeFilter.Small]: "brainstorm.files.filter.size.small", + [SizeFilter.Medium]: "brainstorm.files.filter.size.medium", + [SizeFilter.Large]: "brainstorm.files.filter.size.large", + [SizeFilter.Huge]: "brainstorm.files.filter.size.huge", +}; + +function modifiedLabelKey(range: RelativeDateRange): TranslationKey { + return `brainstorm.files.filter.modified.${range}` as TranslationKey; +} + +export function FilterToggleButton({ + open, + filters, + onToggle, +}: { + open: boolean; + filters: FileFilters; + onToggle(): void; +}) { + const active = countActiveFilters(filters); + return ( + + ); +} + +export function FilterBar({ + filters, + extensions, + onChange, + onClear, +}: { + filters: FileFilters; + extensions: readonly string[]; + onChange(patch: Partial): void; + onClear(): void; +}) { + const kindOptions = useMemo[]>( + () => KIND_FILTERS.map((kind) => ({ value: kind, label: t(KIND_LABEL_KEY[kind]) })), + [], + ); + const sizeOptions = useMemo[]>( + () => SIZE_FILTERS.map((size) => ({ value: size, label: t(SIZE_LABEL_KEY[size]) })), + [], + ); + const modifiedOptions = useMemo[]>( + () => [ + { value: ANY, label: t("brainstorm.files.filter.any") }, + ...MODIFIED_RANGES.map((range) => ({ value: range, label: t(modifiedLabelKey(range)) })), + ], + [], + ); + const extensionOptions = useMemo[]>( + () => [ + { value: ANY, label: t("brainstorm.files.filter.any") }, + ...extensions.map((ext) => ({ value: ext, label: `.${ext}` })), + ], + [extensions], + ); + const active = countActiveFilters(filters) > 0; + + return ( +
+ + + data-testid="filter-kind" + ariaLabel={t("brainstorm.files.filter.kind")} + value={filters.kind} + options={kindOptions} + onChange={(kind) => onChange({ kind })} + /> + + + + data-testid="filter-size" + ariaLabel={t("brainstorm.files.filter.size")} + value={filters.size} + options={sizeOptions} + onChange={(size) => onChange({ size })} + /> + + + + data-testid="filter-modified" + ariaLabel={t("brainstorm.files.filter.modified")} + value={filters.modified ?? ANY} + options={modifiedOptions} + onChange={(value) => + onChange({ modified: value === ANY ? null : (value as RelativeDateRange) }) + } + /> + + + + data-testid="filter-extension" + ariaLabel={t("brainstorm.files.filter.extension")} + value={filters.extension ?? ANY} + options={extensionOptions} + onChange={(value) => onChange({ extension: value === ANY ? null : value })} + /> + + +
+ ); +} + +function FilterField({ label, children }: { label: string; children: ReactNode }) { + return ( + + + {children} + + ); +} diff --git a/apps/files/src/ui/inspector.tsx b/apps/files/src/ui/inspector.tsx index 0b123894..98f0ebe0 100644 --- a/apps/files/src/ui/inspector.tsx +++ b/apps/files/src/ui/inspector.tsx @@ -297,7 +297,17 @@ function PropertiesTab({ entity, store }: { entity: Entity; store: FilesStore }) }, ]; - return ; + return ( + + ); } /** The Comments tab — the same shared comments thread every entity app shows, diff --git a/apps/files/src/ui/row-glyph.test.tsx b/apps/files/src/ui/row-glyph.test.tsx new file mode 100644 index 00000000..5ae324dc --- /dev/null +++ b/apps/files/src/ui/row-glyph.test.tsx @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; +import { type Entity, FILE_TYPE, FOLDER_TYPE, NOTE_TYPE } from "../types/entity"; +import { ViewMode } from "../view-mode"; +import { RowGlyph } from "./row-glyph"; + +let root: Root | null = null; + +function render(node: React.ReactNode): HTMLElement { + const el = document.createElement("div"); + document.body.appendChild(el); + root = createRoot(el); + act(() => root?.render(node)); + return el; +} + +afterEach(() => { + act(() => root?.unmount()); + root = null; + document.body.innerHTML = ""; +}); + +function entity(type: string, properties: Record): Entity { + return { id: `e_${type}`, type, properties, createdAt: 1, updatedAt: 1, deletedAt: null }; +} + +/** Every row kind the Files browser can show: the two native kinds, an + * object carrying its own icon, and foreign objects with / without a known + * opener app. None of them may render an EMPTY glyph host — an empty host + * is what let the name slide into the icon track. */ +const ROW_KINDS: ReadonlyArray<[string, Entity, string | null]> = [ + ["folder without an icon", entity(FOLDER_TYPE, { name: "Docs", members: [] }), null], + ["imported pdf", entity(FILE_TYPE, { name: "a.pdf", mime: "application/pdf" }), "preview"], + [ + "imported image", + entity(FILE_TYPE, { name: "a.png", assetId: "as_1", assetMime: "image/png" }), + "preview", + ], + ["note with its own emoji icon", entity(NOTE_TYPE, { name: "n", icon: "emoji:😀" }), "notes"], + ["bookmark with an opener", entity("brainstorm/Bookmark/v1", { name: "b" }), "bookmarks"], + ["event without an opener", entity("brainstorm/Event/v1", { name: "ev" }), null], +]; + +describe("RowGlyph", () => { + for (const mode of [ViewMode.List, ViewMode.IconList, ViewMode.Grid, ViewMode.Gallery]) { + for (const [label, row, opener] of ROW_KINDS) { + it(`${mode}: ${label} renders a non-empty glyph`, () => { + const el = render(); + const host = el.querySelector(".content-row__glyph"); + expect(host, "glyph host missing").not.toBeNull(); + expect(host?.childElementCount, `${label} rendered an empty glyph host`).toBeGreaterThan(0); + }); + } + } +}); diff --git a/apps/files/src/ui/row-glyph.tsx b/apps/files/src/ui/row-glyph.tsx new file mode 100644 index 00000000..d1d05f2d --- /dev/null +++ b/apps/files/src/ui/row-glyph.tsx @@ -0,0 +1,107 @@ +import { Icon, IconName } from "@brainstorm-os/sdk/icon"; +import type { CSSProperties } from "react"; +import { type Entity, FILE_TYPE, FOLDER_TYPE, readName } from "../types/entity"; +import { ViewMode } from "../view-mode"; +import { readEntityIcon } from "./entity-view"; +import { EntityIcon } from "./entity-visuals"; + +/** `data-glyph` on the host — the stylesheet keys the glyph colour off it. */ +export enum RowGlyphKind { + Folder = "folder", + File = "file", + Unknown = "unknown", +} + +/** Trailing extension of `name`, uppercased for the tile chip ("JPG", + * "PDF"). Hidden-file prefixes and extension-less names yield null. Capped + * so "markdown" doesn't blow the chip out. */ +function extensionChip(name: string): string | null { + const dot = name.lastIndexOf("."); + if (dot <= 0 || dot === name.length - 1) return null; + const ext = name.slice(dot + 1); + if (ext.length > 5) return null; + return ext.toUpperCase(); +} + +function isTileMode(mode: ViewMode): boolean { + return mode === ViewMode.Grid || mode === ViewMode.Gallery; +} + +export type RowGlyphProps = { + entity: Entity; + mode: ViewMode; + iconSize: number; + openerAppId: string | null; +}; + +/** + * The row's leading glyph slot, shared by every view mode. It is NEVER + * empty: an empty host is what let the name slide into the grid's icon track + * (the 0.15.0 "file names invisible in List" regression). + * + * Image files render their stored blob (`brainstorm://asset/`, + * decided by the shell's preview-safe `assetMime`); anything with its OWN + * icon renders it; folders render the folder glyph. Other files fall back to + * an extension chip on tiles and the file-kind glyph in the one-lane lists + * (a chip can't fit a 20px slot). A non-File/Folder object (the universal + * browser) with no own icon shows its default opener app's squircle + * (`brainstorm://app-icon/`) — the same sanctioned type-identity + * badge the dashboard pins use — and the generic entity glyph when no opener + * is registered. + */ +export function RowGlyph({ entity, mode, iconSize, openerAppId }: RowGlyphProps) { + const icon = readEntityIcon(entity); + const isFolder = entity.type === FOLDER_TYPE; + const isFile = entity.type === FILE_TYPE; + const assetId = entity.properties.assetId; + const assetMime = entity.properties.assetMime; + const hasImage = + typeof assetId === "string" && + assetId !== "" && + typeof assetMime === "string" && + assetMime.startsWith("image/"); + const chip = + isFile && isTileMode(mode) && !hasImage && !icon ? extensionChip(readName(entity)) : null; + const appIcon = !isFolder && !isFile && !icon && !hasImage ? openerAppId : null; + const kind = isFolder ? RowGlyphKind.Folder : isFile ? RowGlyphKind.File : RowGlyphKind.Unknown; + return ( + + ); +} diff --git a/apps/files/tests/collection-view-store.test.tsx b/apps/files/tests/collection-view-store.test.tsx new file mode 100644 index 00000000..68995e27 --- /dev/null +++ b/apps/files/tests/collection-view-store.test.tsx @@ -0,0 +1,121 @@ +// @vitest-environment jsdom +/** + * 9.8.16 — Files as a collection, at the store level: property filters + * narrow the visible rows (and compose with the folder listing), and the + * vault-wide Duplicates view groups File rows by content hash, showing only + * groups of ≥2 and leaving on any folder navigation. + */ + +import { act } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { KindFilter } from "../src/logic/filters"; +import { useFilesStore } from "../src/store/use-files-store"; +import { ROOT_FOLDER_ID } from "../src/types/entity"; + +type Probe = { store: ReturnType | null }; + +function mount(probe: Probe): Root { + const el = document.createElement("div"); + document.body.appendChild(el); + const root = createRoot(el); + function Harness() { + probe.store = useFilesStore(); + return null; + } + act(() => root.render()); + return root; +} + +const enc = new TextEncoder(); +const BYTES: Record = { + "h-1": enc.encode("same bytes"), + "h-2": enc.encode("same bytes"), + "h-3": enc.encode("different"), +}; + +let probe: Probe; + +beforeEach(() => { + localStorage.clear(); + probe = { store: null }; + (window as unknown as { brainstorm: unknown }).brainstorm = { + app: { id: "io.brainstorm.files", version: "0.1.0", sdkVersion: "1" }, + capabilities: ["files.read"], + services: { + files: { + requestOpen: vi.fn().mockResolvedValue([ + { handleId: "h-1", displayName: "photo.png" }, + { handleId: "h-2", displayName: "photo copy.png" }, + { handleId: "h-3", displayName: "notes.txt" }, + ]), + read: vi.fn(async (h: { handleId: string }) => BYTES[h.handleId] ?? new Uint8Array()), + }, + }, + }; +}); + +afterEach(() => { + (window as unknown as { brainstorm: unknown }).brainstorm = undefined; + document.body.innerHTML = ""; + localStorage.clear(); +}); + +async function mountWithUploads(): Promise { + const root = mount(probe); + await act(async () => { + await probe.store?.uploadFiles(); + }); + return root; +} + +describe("Files collection view (9.8.16)", () => { + it("filters the folder listing by kind and clears back", async () => { + const root = await mountWithUploads(); + expect(probe.store?.visibleRows).toHaveLength(3); + expect(probe.store?.availableExtensions).toEqual(["png", "txt"]); + + act(() => probe.store?.setFilter({ kind: KindFilter.Document })); + expect(probe.store?.visibleRows.map((e) => e.properties.name)).toEqual(["notes.txt"]); + + act(() => probe.store?.setFilter({ extension: "png" })); + expect(probe.store?.visibleRows).toHaveLength(0); + + act(() => probe.store?.clearFilters()); + expect(probe.store?.visibleRows).toHaveLength(3); + act(() => root.unmount()); + }); + + it("shows only content-hash groups of two or more in the Duplicates view", async () => { + const root = await mountWithUploads(); + act(() => probe.store?.showDuplicates()); + + expect(probe.store?.duplicatesActive).toBe(true); + expect(probe.store?.duplicateGroups).toHaveLength(1); + expect(probe.store?.visibleRows.map((e) => e.properties.name).sort()).toEqual([ + "photo copy.png", + "photo.png", + ]); + expect(probe.store?.visibleSections).toHaveLength(1); + expect(probe.store?.visibleSections?.[0]?.label).toMatch(/^2 copies/); + act(() => root.unmount()); + }); + + it("applies filters before grouping duplicates", async () => { + const root = await mountWithUploads(); + act(() => probe.store?.showDuplicates()); + act(() => probe.store?.setFilter({ kind: KindFilter.Document })); + expect(probe.store?.duplicateGroups).toEqual([]); + expect(probe.store?.visibleRows).toHaveLength(0); + act(() => root.unmount()); + }); + + it("leaves the Duplicates view on folder navigation, even to the current folder", async () => { + const root = await mountWithUploads(); + act(() => probe.store?.showDuplicates()); + act(() => probe.store?.navigateToFolder(ROOT_FOLDER_ID)); + expect(probe.store?.duplicatesActive).toBe(false); + expect(probe.store?.visibleRows).toHaveLength(3); + act(() => root.unmount()); + }); +}); diff --git a/apps/form-designer/src/i18n/de.json b/apps/form-designer/src/i18n/de.json index 4876805e..0948636c 100644 --- a/apps/form-designer/src/i18n/de.json +++ b/apps/form-designer/src/i18n/de.json @@ -133,5 +133,11 @@ "chrome.meta": "Erstellt / geändert", "chrome.windowControls": "Fenstersteuerung", "chrome.entityHeader": "Kopfzeile (Symbol + Titel + Aktionen)", - "chrome.tabs": "Tabs" + "chrome.tabs": "Tabs", + "action.applyToType": "Auf Typ anwenden", + "action.applyToTypeHint": "Als Standardlayout für neue {type}-Entitäten festlegen", + "status.applying": "Wird angewendet…", + "status.appliedToType": "Als Standardlayout für {type} angewendet.", + "status.applyInvalid": "Dieses Layout kann nicht angewendet werden — korrigieren Sie es zuerst im Editor.", + "status.applyFailed": "Das Layout konnte nicht angewendet werden." } diff --git a/apps/form-designer/src/i18n/es.json b/apps/form-designer/src/i18n/es.json index fe611f5c..03a94697 100644 --- a/apps/form-designer/src/i18n/es.json +++ b/apps/form-designer/src/i18n/es.json @@ -133,5 +133,11 @@ "chrome.meta": "Creado / modificado", "chrome.windowControls": "Controles de ventana", "chrome.entityHeader": "Encabezado (icono + título + acciones)", - "chrome.tabs": "Pestañas" + "chrome.tabs": "Pestañas", + "action.applyToType": "Aplicar al tipo", + "action.applyToTypeHint": "Usar como diseño predeterminado para las nuevas entidades {type}", + "status.applying": "Aplicando…", + "status.appliedToType": "Aplicado como diseño predeterminado para {type}.", + "status.applyInvalid": "Este diseño no se puede aplicar: corrígelo primero en el editor.", + "status.applyFailed": "No se pudo aplicar el diseño." } diff --git a/apps/form-designer/src/i18n/fr.json b/apps/form-designer/src/i18n/fr.json index 0dde6d5b..e57e3c77 100644 --- a/apps/form-designer/src/i18n/fr.json +++ b/apps/form-designer/src/i18n/fr.json @@ -133,5 +133,11 @@ "chrome.meta": "Créé / modifié", "chrome.windowControls": "Contrôles de fenêtre", "chrome.entityHeader": "En-tête (icône + titre + actions)", - "chrome.tabs": "Onglets" + "chrome.tabs": "Onglets", + "action.applyToType": "Appliquer au type", + "action.applyToTypeHint": "En faire la mise en page par défaut des nouvelles entités {type}", + "status.applying": "Application…", + "status.appliedToType": "Appliqué comme mise en page par défaut pour {type}.", + "status.applyInvalid": "Cette mise en page ne peut pas être appliquée — corrigez-la d'abord dans l'éditeur.", + "status.applyFailed": "Impossible d'appliquer la mise en page." } diff --git a/apps/form-designer/src/i18n/it.json b/apps/form-designer/src/i18n/it.json index 74989ca3..8d411835 100644 --- a/apps/form-designer/src/i18n/it.json +++ b/apps/form-designer/src/i18n/it.json @@ -133,5 +133,11 @@ "chrome.meta": "Creato / modificato", "chrome.windowControls": "Controlli finestra", "chrome.entityHeader": "Intestazione (icona + titolo + azioni)", - "chrome.tabs": "Schede" + "chrome.tabs": "Schede", + "action.applyToType": "Applica al tipo", + "action.applyToTypeHint": "Rendi questo il layout predefinito per le nuove entità {type}", + "status.applying": "Applicazione…", + "status.appliedToType": "Applicato come layout predefinito per {type}.", + "status.applyInvalid": "Questo layout non può essere applicato — correggilo prima nell'editor.", + "status.applyFailed": "Impossibile applicare il layout." } diff --git a/apps/form-designer/src/i18n/pt.json b/apps/form-designer/src/i18n/pt.json index 729dc1db..70c96087 100644 --- a/apps/form-designer/src/i18n/pt.json +++ b/apps/form-designer/src/i18n/pt.json @@ -133,5 +133,11 @@ "chrome.meta": "Criado / modificado", "chrome.windowControls": "Controles da janela", "chrome.entityHeader": "Cabeçalho (ícone + título + ações)", - "chrome.tabs": "Abas" + "chrome.tabs": "Abas", + "action.applyToType": "Aplicar ao tipo", + "action.applyToTypeHint": "Tornar este o layout padrão para novas entidades {type}", + "status.applying": "Aplicando…", + "status.appliedToType": "Aplicado como layout padrão para {type}.", + "status.applyInvalid": "Este layout não pode ser aplicado — corrija-o primeiro no editor.", + "status.applyFailed": "Não foi possível aplicar o layout." } diff --git a/apps/graph/src/blocks/embedded-graph/entry.test.ts b/apps/graph/src/blocks/embedded-graph/entry.test.ts index c19417f6..0431f368 100644 --- a/apps/graph/src/blocks/embedded-graph/entry.test.ts +++ b/apps/graph/src/blocks/embedded-graph/entry.test.ts @@ -19,6 +19,7 @@ function makeCtx(graph: unknown): { const ctx = { entityId: "gr-1", capabilities: () => [], + hostEntityId: () => null, root, graph: (async (messageName: string) => messageName === "getEntity" ? graph : null) as unknown as ( diff --git a/apps/graph/src/react/selection-inspector.tsx b/apps/graph/src/react/selection-inspector.tsx index 7c97ad7e..164f2bbe 100644 --- a/apps/graph/src/react/selection-inspector.tsx +++ b/apps/graph/src/react/selection-inspector.tsx @@ -84,6 +84,7 @@ export function SelectionInspector({ title={t("inspector.label")} rows={rows} entityId={entity.id} + authorship={{ properties: entity.properties }} {...(tabbed ? { hideHeader: true } : {})} /> )} diff --git a/apps/journal/manifest.json b/apps/journal/manifest.json index fea1fe65..91751bdb 100644 --- a/apps/journal/manifest.json +++ b/apps/journal/manifest.json @@ -14,6 +14,9 @@ "entities.read:*", "entities.write:io.brainstorm.journal/Entry/v1", "entities.write:brainstorm/Comment/v1", + "entities.write:brainstorm/Object/v1", + "entities.write:brainstorm/List/v1", + "entities.write:brainstorm/Task/v1", "intents.dispatch:open", "intents.dispatch:compose", "notifications.post", diff --git a/apps/journal/src/app.tsx b/apps/journal/src/app.tsx index 1f1b7bc5..24303d0e 100644 --- a/apps/journal/src/app.tsx +++ b/apps/journal/src/app.tsx @@ -27,6 +27,7 @@ import { type CommentsFocusRequest, RightPanelTab } from "@brainstorm-os/editor" import { useVaultEntities, useYDocDamaged } from "@brainstorm-os/react-yjs"; import { navModeFromEvent, openEntity } from "@brainstorm-os/sdk"; import type { CommentAnchor, Icon } from "@brainstorm-os/sdk-types"; +import { type LinkedEntity, findBacklinks, findOutgoingLinks } from "@brainstorm-os/sdk/backlinks"; import { MiniCalendar, openCalendarPopover } from "@brainstorm-os/sdk/calendar"; import type { MonthGridReactCell } from "@brainstorm-os/sdk/calendar"; import { Checkbox } from "@brainstorm-os/sdk/checkbox"; @@ -50,7 +51,7 @@ import { openAnchoredMenu, } from "@brainstorm-os/sdk/object-menu"; import { readPanelOpen, writePanelOpen } from "@brainstorm-os/sdk/panel-state"; -import { PanelSide, PanelToggleButton } from "@brainstorm-os/sdk/panel-toggle"; +import { PanelSide, PanelToggleButton, useAutoHidePanel } from "@brainstorm-os/sdk/panel-toggle"; import { IconPickerButton } from "@brainstorm-os/sdk/picker-host"; import { PresenceStack, usePresence, useSelf } from "@brainstorm-os/sdk/presence-stack"; import { applyPersistedPanelWidth, attachResizable } from "@brainstorm-os/sdk/resizable"; @@ -68,13 +69,13 @@ import { } from "react"; import { buildJournalDemo } from "./demo/dataset"; import { useJournalT } from "./i18n-hooks"; -import { - type Backlink, - type OutgoingLink, - findBacklinks, - findOutgoingLinks, -} from "./logic/backlinks"; import { shiftByDays } from "./logic/calendar-grid"; +import { + type CaptureWriter, + type FileableCollection, + fileableCollections, +} from "./logic/capture-filing"; +import { captureIntoDay } from "./logic/capture-into-day"; import { HABIT_LABEL_KEY, type HabitId, @@ -85,6 +86,7 @@ import { toggleHabit, } from "./logic/check-in"; import { JournalDayBodyMode, journalDayBodyMode } from "./logic/day-body-mode"; +import { dateMsForEntryId, ensureDayEntry } from "./logic/day-entry"; import { buildJournalDenormalizer } from "./logic/denormalize-entry"; import { groupEntriesByMonth, monthLabelFromKey } from "./logic/entry-overview"; import { type EntryWriteContext, writeEntryPatch } from "./logic/entry-writes"; @@ -99,7 +101,6 @@ import { journalPlural, t } from "./logic/journal-i18n"; import { dateKeyForJournal, journalEntryIdForKey, - journalEntryIdToDateMs, journalNoteTitle, parseJournalDateKey, } from "./logic/journal-keys"; @@ -122,9 +123,11 @@ import { import { parseReminderTime, shouldFireWriteReminder } from "./logic/write-reminder"; import { JOURNAL_ENTRY_TYPE, + NOTE_ENTITY_TYPE, type VaultSnapshot, getJournalRuntime, notesFromSnapshot, + vaultEntityToNoteLike, } from "./runtime"; import { pushEntityIndex, wireEditorIndex } from "./store/editor-index"; import { getYDocResolverApi } from "./store/ydoc-resolver"; @@ -136,10 +139,11 @@ import { type JournalMenuResolver, bindDelegatedObjectMenu, } from "./ui/delegated-object-menu"; -import { applyJournalSuggestion, insertEntityMention } from "./ui/editor-bridge"; +import { appendJournalLine, applyJournalSuggestion, insertEntityMention } from "./ui/editor-bridge"; import type { JournalCommentHooks, JournalDenormalizeFn } from "./ui/entry-editor-mount"; import { attachOverviewKeyboard } from "./ui/overview-keyboard"; import { JournalPropertiesIsland, type JournalPropertiesOptions } from "./ui/properties-panel"; +import { QuickCapture, type QuickCaptureResult } from "./ui/quick-capture"; import { EntityIcon } from "./ui/react/entity-icon"; import { EntryEditorIsland } from "./ui/react/entry-editor-island"; import { openJournalSearch } from "./ui/search-overlay"; @@ -404,9 +408,14 @@ export function JournalApp(): ReactElement { [recordNav], ); + // Loaded entries behind a ref: the boot-time `open` lands on a seeded + // entry (its own id, not `journal-`) only via the list, and the + // handler is bound once. + const mergedEntriesRef = useRef(mergedEntries); + mergedEntriesRef.current = mergedEntries; const focusJournalEntry = useCallback( (entityId: string): boolean => { - const ms = journalEntryIdToDateMs(entityId); + const ms = dateMsForEntryId(entityId, mergedEntriesRef.current); if (ms !== null) { focusTo(new Date(ms)); return true; @@ -461,7 +470,10 @@ export function JournalApp(): ReactElement { ); /** Ensure a journal entry exists for `date`. Returns its id (or null when - * there's no entities service / the create rejected non-idempotently). */ + * there's no entities service / the create rejected non-idempotently). + * The day resolves to ONE entry: the one the list shows, else the one the + * vault already holds (the live snapshot can still be the empty initial + * one when the editor autofocuses on open), else a fresh `journal-`. */ const ensureEntry = useCallback( async (date: Date, init?: { icon?: Icon | null }): Promise => { const dateKey = dateKeyForJournal(date); @@ -469,8 +481,10 @@ export function JournalApp(): ReactElement { if (existing) return existing.noteId; const bs = getJournalRuntime(); - const create = bs?.services?.entities?.create; - if (!create) return null; + const entities = bs?.services?.entities; + const create = entities?.create; + const list = bs?.services?.vaultEntities?.list; + if (!create || !list) return null; const stableId = journalEntryIdForKey(dateKey); const pending = inflightCreateRef.current.get(stableId); @@ -480,33 +494,37 @@ export function JournalApp(): ReactElement { if (init?.icon !== undefined) properties.icon = init.icon; const job = (async (): Promise => { - try { - await create.call(bs?.services?.entities, JOURNAL_ENTRY_TYPE, properties, stableId); - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - if (!/already exists/i.test(msg)) { - console.warn("[journal] entities.create failed:", error); - return null; - } - } - const ts = Date.now(); - const synthetic: JournalEntry = { - noteId: stableId, - icon: init?.icon ?? null, - dateEpochMs: parseJournalDateKey(dateKey) ?? date.getTime(), + const resolved = await ensureDayEntry({ dateKey, - rawTitle: dateKey, - preview: "", - wordCount: 0, - seedBody: null, - values: {}, - mood: null, - habits: [], - createdAt: ts, - updatedAt: ts, - }; - setOptimistic((prev) => new Map(prev).set(dateKey, synthetic)); - return stableId; + existing: null, + stableId, + listRows: async () => + (await list.call(bs?.services?.vaultEntities, { types: [JOURNAL_ENTRY_TYPE] })).entities, + create: async (id) => { + await create.call(entities, JOURNAL_ENTRY_TYPE, properties, id); + }, + }); + if (!resolved) return null; + const ts = Date.now(); + const synthetic: JournalEntry | undefined = resolved.row + ? projectJournalEntries([vaultEntityToNoteLike(resolved.row)])[0] + : { + noteId: resolved.noteId, + icon: init?.icon ?? null, + dateEpochMs: parseJournalDateKey(dateKey) ?? date.getTime(), + dateKey, + rawTitle: dateKey, + preview: "", + wordCount: 0, + seedBody: null, + values: {}, + mood: null, + habits: [], + createdAt: ts, + updatedAt: ts, + }; + if (synthetic) setOptimistic((prev) => new Map(prev).set(dateKey, synthetic)); + return resolved.noteId; })(); inflightCreateRef.current.set(stableId, job); try { @@ -1026,6 +1044,7 @@ export function JournalApp(): ReactElement { return next; }); }, []); + const navAutoHide = useAutoHidePanel({ side: PanelSide.Left, collapsed: !navOpen }); const toggleProps = useCallback(() => { setPropsOpen((open) => { const next = !open; @@ -1168,10 +1187,11 @@ export function JournalApp(): ReactElement { data-props-open={String(propsOpen)} > + {navAutoHide.edgeProps &&
}
void; -}): ReactElement { - return ( - - ); -} - -function OutgoingPanel({ +/** "Linked from" (backlinks) / "Links to" (outgoing) — one panel, two + * headings; both walks resolve to the same `LinkedEntity` row. */ +function LinksPanel({ + heading, links, onOpen, }: { - links: readonly OutgoingLink[]; - onOpen: (link: OutgoingLink, event: React.MouseEvent) => void; + heading: string; + links: readonly LinkedEntity[]; + onOpen: (link: LinkedEntity, event: React.MouseEvent) => void; }): ReactElement { return ( + + {pickerOpen && note && ( setPickerOpen(false)} /> )} @@ -1226,6 +1378,7 @@ export function NotesApp() { if (!runtime) return shell; return ( , + keywords: ["page", "subpage", "sub-page", "turn", "convert", "extract", "move"], + run: ({ editor, blockKeys }) => runTurnIntoPage(editor, blockKeys), +}; + const sharedActions = createStandardBlockActions(editorT); const deleteActionIndex = sharedActions.findIndex((c) => c.id === "block.action.delete"); @@ -471,6 +492,8 @@ const deleteActionIndex = sharedActions.findIndex((c) => c.id === "block.action. * add-property actions slotted before Delete, plus the colour / * highlight swatch rows. */ export const BLOCK_ACTIONS: readonly BlockCommand[] = [ + ...createInsertBlockActions(editorT, BLOCK_COMMANDS), + TURN_INTO_PAGE_ACTION, ...sharedActions.slice(0, deleteActionIndex), COPY_LINK_ACTION, ADD_PROPERTY_ACTION, @@ -478,6 +501,7 @@ export const BLOCK_ACTIONS: readonly BlockCommand[] = [ ...sharedActions.slice(deleteActionIndex), ...SWATCH_COLORS.map((color) => colorAction(ColorTarget.Text, color)), ...SWATCH_COLORS.map((color) => colorAction(ColorTarget.Highlight, color)), + ...createBlockBackgroundActions(editorT), ]; /** Slash-trigger path: the caller's paragraph (the one with `/property`) @@ -636,6 +660,32 @@ function firstBlockText(editor: LexicalEditor, blockKeys: ReadonlySet): return text; } +async function runTurnIntoPage( + editor: LexicalEditor, + blockKeys: ReadonlySet | undefined, +): Promise { + if (!blockKeys || blockKeys.size === 0 || !editor.isEditable()) return; + const entities = getBrainstorm()?.services.entities; + const resolver = getYDocResolverApi(); + if (!entities || !resolver) { + console.warn( + "[notes/turn-into-page] entities / ydoc services unavailable — can't create a page.", + ); + return; + } + try { + await turnBlocksIntoPage(editor, blockKeys, { + create: (type, properties, id) => entities.create(type, properties, id), + resolve: resolver.resolve, + whenLoaded: resolver.whenLoaded, + plant: plantSerializedStateIntoDoc, + newId: newNoteId, + }); + } catch (error) { + console.warn("[notes/turn-into-page] failed — the document is unchanged:", error); + } +} + function insertSubPage(editor: LexicalEditor): void { const entities = getBrainstorm()?.services.entities; if (!entities) { diff --git a/apps/notes/src/editor/context-menu-plugin.tsx b/apps/notes/src/editor/context-menu-plugin.tsx index a6069d2b..935816cf 100644 --- a/apps/notes/src/editor/context-menu-plugin.tsx +++ b/apps/notes/src/editor/context-menu-plugin.tsx @@ -8,20 +8,30 @@ * `contextmenu` is intercepted (preventDefault) so the native menu doesn't * appear; the cursor's `clientX/Y` becomes a 1×1 synthetic rect the menu * anchors against. + * + * A right-click inside a nested editor ("Edit here" transclusion, B11.22) + * belongs to that editor's own instance (`documentId` = the source), never + * the host's. */ -import { openBlockActionMenu, useBlockSelectionStore, useEditorT } from "@brainstorm-os/editor"; +import { + isEventInNestedEditor, + openBlockActionMenu, + useBlockSelectionStore, + useEditorT, +} from "@brainstorm-os/editor"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { $getNearestNodeFromDOMNode, type NodeKey } from "lexical"; import { useEffect } from "react"; import { BLOCK_ACTIONS } from "./commands"; -import { useNoteContext } from "./note-context"; +import { useNoteContextOptional } from "./note-context"; -export function BlockContextMenuPlugin() { +export function BlockContextMenuPlugin({ documentId }: { documentId?: string } = {}) { const [editor] = useLexicalComposerContext(); const selectionStore = useBlockSelectionStore(); const t = useEditorT(); - const { noteId } = useNoteContext(); + const host = useNoteContextOptional(); + const noteId = documentId ?? host?.noteId; useEffect(() => { const root = editor.getRootElement(); @@ -29,6 +39,7 @@ export function BlockContextMenuPlugin() { function onContextMenu(event: MouseEvent) { if (!(event.target instanceof Node)) return; + if (isEventInNestedEditor(event, root)) return; let blockKey: NodeKey | null = null; // `$getNearestNodeFromDOMNode` needs an active *editor* — route // through `editor.read(...)` rather than `editorState.read(...)`. @@ -58,7 +69,7 @@ export function BlockContextMenuPlugin() { onActivate: (command) => { const s = selectionStore.getSnapshot(); const blockKeys = s.selectedKeys.size > 0 ? s.selectedKeys : new Set([targetKey]); - command.run({ editor, blockKeys, documentId: noteId }); + command.run({ editor, blockKeys, ...(noteId ? { documentId: noteId } : {}) }); }, }); } diff --git a/apps/notes/src/editor/editor.tsx b/apps/notes/src/editor/editor.tsx index 0db04de8..0063c7b0 100644 --- a/apps/notes/src/editor/editor.tsx +++ b/apps/notes/src/editor/editor.tsx @@ -78,6 +78,7 @@ import { SlashMenuPlugin, TableColumnResizePlugin, TablesPlugin, + TabsPlugin, TitlePlugin, TogglePlugin, TransclusionTypeaheadPlugin, @@ -116,11 +117,14 @@ import { MediaInspectorPlugin } from "./media-inspector-plugin"; import { NormalizeEmptyDocPlugin } from "./normalize-empty-plugin"; import { NoteContextProvider, type NoteContextValue } from "./note-context"; import { NOTES_ADDITIONAL_NODES } from "./notes-nodes"; +import type { OutlineEntry } from "./outline"; +import { OutlinePlugin } from "./outline-plugin"; import { localPresence } from "./presence"; import { makeNoteBootstrap } from "./seed-title"; import { editorTheme } from "./theme"; import { TOGGLE_HEADING_TRANSFORMER } from "./toggle-heading-transformer"; import { renderTransclusionBody } from "./transclusion-body"; +import { type TransclusionEditHost, TransclusionEditProvider } from "./transclusion-edit-host"; import { TransclusionRenderProvider } from "./transclusion-render-context"; import { UNICODE_SHORTCUT_TRANSFORMERS } from "./typing-shortcuts"; @@ -167,6 +171,11 @@ export type EditorProps = { * snippet/refs). Only ever passed for the open, unlocked target note. */ insertRequest?: InsertAtEndRequest | null; onInsertDone?: (applied: boolean) => void; + /** Lets a transcluded body be edited in place (B11.22). Omit ⇒ every + * transclusion in this document stays read-only. */ + transclusionHost?: TransclusionEditHost | null; + /** Receives the live heading outline (B11.26) for the side panel. */ + onOutlineChange?: (entries: OutlineEntry[]) => void; }; export function Editor({ @@ -182,6 +191,8 @@ export function Editor({ onAnchorDone, insertRequest = null, onInsertDone, + transclusionHost = null, + onOutlineChange, }: EditorProps) { const doc = useYDoc(noteId); const whenLoaded = useYDocLoaded(noteId); @@ -216,115 +227,124 @@ export function Editor({ [doc], ); return ( - - + { - console.error("[notes/editor]", error); - }} > - {/* Forward the WHOLE context object — cherry-picking fields here is + { + console.error("[notes/editor]", error); + }} + > + {/* Forward the WHOLE context object — cherry-picking fields here is what silently dropped the comment wiring (F-163): the app passed onCommentSelection/commentedBlockIds/onCommentBlockClick and this mount forwarded only values+setValue, so the toolbar Comment row, the block highlight and the click-to-thread chip never reached a real shell. */} - - - - - - {onRecoverBlank && ( - + + + + - )} - - - - - - - - - - - - - - - - - - - {onInsertDone && ( - + )} + + + + + + + + + + + + + + + + + + + - )} - - - - - - - - - - - - - - {editorHandleRef && } - - - - - - - - - {/* Unconditional mount — rationale in dev-bench-plugin.tsx header. */} - - - - - + {onInsertDone && ( + + )} + + + + + + + + + + + + + + {editorHandleRef && } + {onOutlineChange && } + + + + + + + + + {/* Unconditional mount — rationale in dev-bench-plugin.tsx header. */} + + + + + + ); } diff --git a/apps/notes/src/editor/format-chords-plugin.test.tsx b/apps/notes/src/editor/format-chords-plugin.test.tsx new file mode 100644 index 00000000..d7dc8287 --- /dev/null +++ b/apps/notes/src/editor/format-chords-plugin.test.tsx @@ -0,0 +1,117 @@ +// @vitest-environment jsdom + +/** + * The turn-into / mark chords answer only in the editor that owns the + * keystroke: the host skips keys typed inside a nested "Edit here" body. + */ + +import { BlockSelectionPlugin, TURN_INTO_COMMAND } from "@brainstorm-os/editor"; +import { LexicalComposer } from "@lexical/react/LexicalComposer"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { COMMAND_PRIORITY_CRITICAL } from "lexical"; +import { act, useEffect } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { FormatChordsPlugin } from "./format-chords-plugin"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const IS_MAC = /Mac/i.test(navigator.platform); + +function Harness({ onTurn }: { onTurn: () => void }) { + const [editor] = useLexicalComposerContext(); + useEffect(() => { + const root = document.getElementById("host-root"); + editor.setRootElement(root); + const off = editor.registerCommand( + TURN_INTO_COMMAND, + () => { + onTurn(); + return true; + }, + COMMAND_PRIORITY_CRITICAL, + ); + return () => { + off(); + editor.setRootElement(null); + }; + }, [editor, onTurn]); + return null; +} + +function chord(target: Element): KeyboardEvent { + const event = new KeyboardEvent("keydown", { + key: "1", + code: "Digit1", + altKey: true, + metaKey: IS_MAC, + ctrlKey: !IS_MAC, + bubbles: true, + cancelable: true, + }); + target.dispatchEvent(event); + return event; +} + +describe("FormatChordsPlugin ownership", () => { + let container: HTMLDivElement; + let root: Root; + let hostRoot: HTMLDivElement; + let nestedRoot: HTMLDivElement; + + beforeEach(() => { + hostRoot = document.createElement("div"); + hostRoot.id = "host-root"; + nestedRoot = document.createElement("div"); + nestedRoot.setAttribute("data-lexical-editor", "true"); + nestedRoot.contentEditable = "true"; + document.body.append(hostRoot); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + hostRoot.remove(); + }); + + async function mount(onTurn: () => void): Promise { + await act(async () => + root.render( + { + throw e; + }, + }} + > + + + + + , + ), + ); + } + + it("turns the host block on a host keystroke", async () => { + const onTurn = vi.fn(); + await mount(onTurn); + const event = chord(hostRoot); + expect(onTurn).toHaveBeenCalledTimes(1); + expect(event.defaultPrevented).toBe(true); + }); + + it("leaves a keystroke from a nested editor to that editor", async () => { + const onTurn = vi.fn(); + await mount(onTurn); + // Appended after mount: binding the host root resets its children. + hostRoot.append(nestedRoot); + const event = chord(nestedRoot); + expect(onTurn).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + }); +}); diff --git a/apps/notes/src/editor/format-chords-plugin.tsx b/apps/notes/src/editor/format-chords-plugin.tsx index d066a30e..291f9fd0 100644 --- a/apps/notes/src/editor/format-chords-plugin.tsx +++ b/apps/notes/src/editor/format-chords-plugin.tsx @@ -12,12 +12,20 @@ * selected we first bridge them to a range selection spanning the set — the * same `selectBlocksAsRange` the gutter action menu uses — so a single chord * strikes / turns-into every selected block at once. + * + * Nested editors ("Edit here", B11.22): the host skips a keystroke that comes + * from an editor nested inside it (`isEventInNestedEditor`) — otherwise + * Mod+Alt+1 typed in the transcluded body would turn the HOST's last-caret + * block into a heading. The nested body mounts its own instance with + * `nested`, which only answers keystrokes from inside its own root (or while + * its own blocks are selected), so each chord reaches exactly one editor. */ import { BlockType, TURN_INTO_COMMAND, formatTextInBlocks, + isEventInNestedEditor, useBlockSelectionStore, } from "@brainstorm-os/editor"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; @@ -42,10 +50,22 @@ const TURN_INTO_CHORDS: ReadonlyArray<[ActionId, BlockType]> = [ [ActionId.TurnIntoCallout, BlockType.Callout], ]; -export function FormatChordsPlugin(): null { +export function FormatChordsPlugin({ nested = false }: { nested?: boolean } = {}): null { const [editor] = useLexicalComposerContext(); const blockSelection = useBlockSelectionStore(); + const ownsEvent = useCallback( + (event: KeyboardEvent): boolean => { + const root = editor.getRootElement(); + if (isEventInNestedEditor(event, root)) return false; + if (!nested) return true; + const target = event.target; + const insideRoot = root !== null && target instanceof Node && root.contains(target); + return insideRoot || blockSelection.getSnapshot().selectedKeys.size > 0; + }, + [editor, blockSelection, nested], + ); + // In block-selection mode there's no Lexical caret, so a mark applies to // every text node in the selected blocks (a bridged element-boundary range // can't be formatted point-to-point). Otherwise the chord keeps its @@ -72,10 +92,11 @@ export function FormatChordsPlugin(): null { ActionId.ToggleStrikeMark, useCallback( (event: KeyboardEvent) => { + if (!ownsEvent(event)) return; event.preventDefault(); applyMark("strikethrough"); }, - [applyMark], + [applyMark, ownsEvent], ), ); @@ -83,10 +104,11 @@ export function FormatChordsPlugin(): null { ActionId.ToggleCodeMark, useCallback( (event: KeyboardEvent) => { + if (!ownsEvent(event)) return; event.preventDefault(); applyMark("code"); }, - [applyMark], + [applyMark, ownsEvent], ), ); @@ -94,6 +116,7 @@ export function FormatChordsPlugin(): null { // `useShortcut` per chord would be ten listeners for one concern). useEffect(() => { function onKeydown(event: KeyboardEvent) { + if (!ownsEvent(event)) return; for (const [id, blockType] of TURN_INTO_CHORDS) { if (matchesActionChord(id, event)) { event.preventDefault(); @@ -108,7 +131,7 @@ export function FormatChordsPlugin(): null { // keyboard-exempt document.addEventListener("keydown", onKeydown, true); return () => document.removeEventListener("keydown", onKeydown, true); - }, [editor, bridgeBlockSelection]); + }, [editor, bridgeBlockSelection, ownsEvent]); return null; } diff --git a/apps/notes/src/editor/link-markup-ops.ts b/apps/notes/src/editor/link-markup-ops.ts index ac2228eb..8248ac08 100644 --- a/apps/notes/src/editor/link-markup-ops.ts +++ b/apps/notes/src/editor/link-markup-ops.ts @@ -22,6 +22,7 @@ * stays one source of truth across the plugin + future tooling. */ +import { TASK_CHECK_ATTR } from "@brainstorm-os/editor"; import { parseBrainstormEntityUri } from "./extract-references"; export const BRAINSTORM_ENTITY_LINK_PREFIX = "brainstorm://entity/"; @@ -170,6 +171,7 @@ export function findMentionFromEvent( root: Element, ): { entityId: string; entityType: string } | null { if (!(target instanceof Element)) return null; + if (target.closest(`[${TASK_CHECK_ATTR}]`)) return null; const el = target.closest("[data-entity-id][data-entity-type]"); if (!el || !root.contains(el)) return null; const entityId = el.getAttribute("data-entity-id") ?? ""; diff --git a/apps/notes/src/editor/nested-list-style.test.ts b/apps/notes/src/editor/nested-list-style.test.ts index 1d3a3078..6a9cfd94 100644 --- a/apps/notes/src/editor/nested-list-style.test.ts +++ b/apps/notes/src/editor/nested-list-style.test.ts @@ -59,3 +59,18 @@ describe("nested list marker styling", () => { ).toMatch(/list-style:\s*lower-roman/); }); }); + +describe("indentation guides (B11.30)", () => { + it("a nested list draws a token-coloured, non-interactive guide", () => { + const body = ruleBody(".notes__list-item--nested > .notes__list::before"); + expect(body).not.toBeNull(); + expect(body).toMatch(/border-inline-start:[^;]*var\(--color-border-subtle\)/); + expect(body).toMatch(/pointer-events:\s*none/); + }); + + it("an open toggle draws the same guide down its body", () => { + const body = ruleBody('.notes__toggle[data-open="true"]::after'); + expect(body).not.toBeNull(); + expect(body).toMatch(/var\(--color-border-subtle\)/); + }); +}); diff --git a/apps/notes/src/editor/notes-nodes.ts b/apps/notes/src/editor/notes-nodes.ts index dbf33cda..c39d710c 100644 --- a/apps/notes/src/editor/notes-nodes.ts +++ b/apps/notes/src/editor/notes-nodes.ts @@ -8,7 +8,16 @@ * quietly strip the bookmark. Pin the set in one place. */ -import { CalloutNode, ColumnNode, ColumnsNode, TitleNode, ToggleNode } from "@brainstorm-os/editor"; +import { + CalloutNode, + ColumnNode, + ColumnsNode, + InlineImageNode, + TabPaneNode, + TabsNode, + TitleNode, + ToggleNode, +} from "@brainstorm-os/editor"; import { CodeNode } from "@lexical/code"; import { AutoLinkNode, LinkNode } from "@lexical/link"; import { ListItemNode, ListNode } from "@lexical/list"; @@ -47,6 +56,7 @@ export const NOTES_ADDITIONAL_NODES: ReadonlyArray> = [ AutoLinkNode, HorizontalRuleNode, ImageBlockNode, + InlineImageNode, VideoBlockNode, PropertyBlockNode, PropertyListBlockNode, @@ -73,5 +83,7 @@ export const NOTES_ADDITIONAL_NODES: ReadonlyArray> = [ InlineTransclusionNode, ColumnsNode, ColumnNode, + TabsNode, + TabPaneNode, TableOfContentsNode, ]; diff --git a/apps/notes/src/editor/outline-plugin.tsx b/apps/notes/src/editor/outline-plugin.tsx new file mode 100644 index 00000000..95ce410a --- /dev/null +++ b/apps/notes/src/editor/outline-plugin.tsx @@ -0,0 +1,37 @@ +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { useEffect } from "react"; +import { $collectOutline, type OutlineEntry, sameOutline } from "./outline"; + +/** Recompute cadence — headings change on typing, but the panel only needs + * to settle, not track every keystroke. */ +const OUTLINE_DEBOUNCE_MS = 120; + +export function OutlinePlugin({ + onChange, +}: { + onChange: (entries: OutlineEntry[]) => void; +}): null { + const [editor] = useLexicalComposerContext(); + useEffect(() => { + let last: OutlineEntry[] = []; + let timer: ReturnType | null = null; + const publish = (): void => { + timer = null; + const next = editor.getEditorState().read($collectOutline); + if (sameOutline(last, next)) return; + last = next; + onChange(next); + }; + publish(); + const unregister = editor.registerUpdateListener(({ dirtyElements, dirtyLeaves }) => { + if (dirtyElements.size === 0 && dirtyLeaves.size === 0) return; + if (timer === null) timer = setTimeout(publish, OUTLINE_DEBOUNCE_MS); + }); + return () => { + unregister(); + if (timer !== null) clearTimeout(timer); + onChange([]); + }; + }, [editor, onChange]); + return null; +} diff --git a/apps/notes/src/editor/outline.test.ts b/apps/notes/src/editor/outline.test.ts new file mode 100644 index 00000000..43ddd4d8 --- /dev/null +++ b/apps/notes/src/editor/outline.test.ts @@ -0,0 +1,99 @@ +// @vitest-environment jsdom +import { createHeadlessEditor } from "@lexical/headless"; +import { $createListItemNode, $createListNode, ListItemNode, ListNode } from "@lexical/list"; +import { $createHeadingNode, $createQuoteNode, HeadingNode, QuoteNode } from "@lexical/rich-text"; +import { $createParagraphNode, $createTextNode, $getRoot, type LexicalEditor } from "lexical"; +import { describe, expect, it } from "vitest"; +import { + $collectOutline, + type OutlineEntry, + activeOutlineIndex, + outlineBaseLevel, + sameOutline, +} from "./outline"; + +function editor(): LexicalEditor { + return createHeadlessEditor({ + namespace: "outline", + nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode], + onError: (e) => { + throw e; + }, + }); +} + +describe("$collectOutline", () => { + it("lists headings in document order with their level, skipping body text", () => { + const e = editor(); + e.update( + () => { + const root = $getRoot(); + root.append( + $createHeadingNode("h1").append($createTextNode("Intro")), + $createParagraphNode().append($createTextNode("body")), + $createHeadingNode("h3").append($createTextNode(" Detail ")), + $createQuoteNode().append($createTextNode("not a heading")), + $createHeadingNode("h2").append($createTextNode("")), + ); + }, + { discrete: true }, + ); + const out = e.getEditorState().read($collectOutline); + expect(out.map((o) => [o.text, o.level])).toEqual([ + ["Intro", 1], + ["Detail", 3], + ["", 2], + ]); + }); + + it("finds a heading nested inside a container block", () => { + const e = editor(); + e.update( + () => { + const list = $createListNode("bullet"); + const item = $createListItemNode(); + list.append(item); + item.append($createTextNode("x")); + $getRoot().append(list, $createHeadingNode("h2").append($createTextNode("After"))); + }, + { discrete: true }, + ); + expect( + e + .getEditorState() + .read($collectOutline) + .map((o) => o.text), + ).toEqual(["After"]); + }); +}); + +describe("outline helpers", () => { + const a: OutlineEntry[] = [ + { key: "1", text: "A", level: 2 }, + { key: "2", text: "B", level: 3 }, + ]; + + it("sameOutline compares key, text and level", () => { + expect( + sameOutline( + a, + a.map((x) => ({ ...x })), + ), + ).toBe(true); + expect(sameOutline(a, [a[0] as OutlineEntry])).toBe(false); + expect(sameOutline(a, [a[0] as OutlineEntry, { key: "2", text: "B!", level: 3 }])).toBe(false); + }); + + it("outlineBaseLevel is the shallowest level present", () => { + expect(outlineBaseLevel(a)).toBe(2); + expect(outlineBaseLevel([])).toBe(1); + }); + + it("activeOutlineIndex picks the last heading at or above the threshold", () => { + expect(activeOutlineIndex([], 0)).toBe(-1); + expect(activeOutlineIndex([100, 300, 500], 50)).toBe(0); + expect(activeOutlineIndex([-200, 40, 500], 50)).toBe(1); + expect(activeOutlineIndex([-400, -200, -10], 50)).toBe(2); + expect(activeOutlineIndex([-400, null, 600], 50)).toBe(0); + }); +}); diff --git a/apps/notes/src/editor/outline.ts b/apps/notes/src/editor/outline.ts new file mode 100644 index 00000000..777c8360 --- /dev/null +++ b/apps/notes/src/editor/outline.ts @@ -0,0 +1,83 @@ +/** + * Document outline (B11.26) — the headings of the open note, in document + * order, for the side Outline panel. Pure readers over the editor state so + * the panel can live outside the composer; `OutlinePlugin` is the one live + * subscription. + */ + +import { $isHeadingNode } from "@lexical/rich-text"; +import { $getRoot, $isElementNode, type LexicalNode, type NodeKey } from "lexical"; + +export type OutlineEntry = { + key: NodeKey; + text: string; + /** 1-based heading depth (`h1` → 1). */ + level: number; +}; + +function headingLevel(tag: string): number { + const n = Number(tag.slice(1)); + return Number.isFinite(n) && n > 0 ? n : 1; +} + +/** Must run inside `editorState.read()` / `editor.read()`. Descends into + * container blocks (toggles, columns, callouts) so a heading nested in one + * is still listed; text leaves are never visited. */ +export function $collectOutline(): OutlineEntry[] { + const out: OutlineEntry[] = []; + const stack: LexicalNode[] = [...$getRoot().getChildren()].reverse(); + while (stack.length > 0) { + const node = stack.pop(); + if (!node) break; + if ($isHeadingNode(node)) { + out.push({ + key: node.getKey(), + text: node.getTextContent().trim(), + level: headingLevel(node.getTag()), + }); + continue; + } + if ($isElementNode(node) && !node.isInline()) { + const children = node.getChildren(); + for (let i = children.length - 1; i >= 0; i--) { + const child = children[i]; + if (child && $isElementNode(child)) stack.push(child); + } + } + } + return out; +} + +export function sameOutline(a: readonly OutlineEntry[], b: readonly OutlineEntry[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + const x = a[i]; + const y = b[i]; + if (!x || !y || x.key !== y.key || x.text !== y.text || x.level !== y.level) return false; + } + return true; +} + +/** Shallowest level present, so a doc that starts at `h2` doesn't render + * every row one indent in. */ +export function outlineBaseLevel(entries: readonly OutlineEntry[]): number { + let min = Number.POSITIVE_INFINITY; + for (const e of entries) if (e.level < min) min = e.level; + return Number.isFinite(min) ? min : 1; +} + +/** The heading the reader is "in": the last one whose top has scrolled to + * (or above) `threshold`. Before the first heading reaches it, the first + * heading is active. `-1` for an empty outline. `tops` are viewport-space + * offsets in document order; `null` marks a heading with no DOM element. */ +export function activeOutlineIndex(tops: readonly (number | null)[], threshold: number): number { + if (tops.length === 0) return -1; + let active = 0; + for (let i = 0; i < tops.length; i++) { + const top = tops[i]; + if (top === null || top === undefined) continue; + if (top <= threshold) active = i; + else break; + } + return active; +} diff --git a/apps/notes/src/editor/transclusion-body.tsx b/apps/notes/src/editor/transclusion-body.tsx index 4170fa90..68595892 100644 --- a/apps/notes/src/editor/transclusion-body.tsx +++ b/apps/notes/src/editor/transclusion-body.tsx @@ -1,41 +1,150 @@ /** - * Nested read-only transclusion body (B6.4b render-half). + * Nested transclusion body (B6.4b render-half, B11.22 edit-in-place). * - * Mounts a real, non-editable `` bound to the target - * entity's replica Y.Doc (resolved through `useYDoc(entityId)`), so the - * transcluded content paints with full fidelity — mentions, embeds, images, - * tables and *nested* transclusions all render through the same node set the - * primary editor uses. The Lexical-free `EditorPreview` was the alternative; - * a live mount is what lets a transcluded sub-tree's own decorator nodes work - * (and the cycle/depth guard already bounds the recursion). + * Mounts a real `` bound to the target entity's replica + * Y.Doc (resolved through `useYDoc(entityId)`), so the transcluded content + * paints with full fidelity — mentions, embeds, images, tables and *nested* + * transclusions all render through the same node set the primary editor uses + * (the cycle/depth guard bounds the recursion). + * + * Read-only by default. "Edit here" switches the body into an explicit edit + * mode: the nested editor becomes editable and mounts the Notes editing + * plugins, so every keystroke writes straight into the SOURCE's Y.Doc (the + * same doc the source's own editor binds), with a visible boundary + the + * source card as its chip. The nested editor carries its own block gutter, + * block selection (with its keyboard chords) and block context menu, scoped + * to the source's blocks: the host gutter yields inside the editing body + * (`gutterOwnsPointer`) and host chords skip keystrokes from it. Guarantees: + * - a locked source, a source Notes hasn't loaded, or a read-only host keeps + * the body read-only (the toggle is disabled with a hint, never hidden); + * a lock that lands mid-edit drops the body back to read-only live; + * - undo is the SOURCE's: the nested `CollaborationPlugin` owns its own + * Yjs `UndoManager` over the source doc, and Lexical marks a nested + * editor's key events handled so the host never also undoes; + * - editing a source from within itself can't happen: such a body is + * cycle-elided before it mounts (`decideTransclusionRender`); + * - the source's denormalised title / snippet / refs follow the edit + * through the host's `onSourceChange` (the open note's autosave path). * * `renderTransclusionBody` is injected into `TransclusionRenderContext` by the * primary editor and re-injected here, so a `TransclusionNode` inside the * mounted body resolves the SAME renderer — without `transclusion-node.tsx` * importing this heavy module (which would cycle through * `NOTES_ADDITIONAL_NODES`). - * - * The provider here wraps the nested editor (it must be an ANCESTOR of the - * `` to reach its decorators — see - * `transclusion-render-context.tsx`) and extends the ancestor chain by this - * target's id, so a deeper transclusion of an ancestor collapses via - * `decideTransclusionRender` instead of recursing forever. */ -import { BrainstormEditor, EditablePlugin } from "@brainstorm-os/editor"; +import { + AutosavePlugin, + BLOCK_MARKDOWN_TRANSFORMERS, + BlockEmbedPickerPlugin, + BlockGutterPlugin, + BlockSelectionPlugin, + BrainstormEditor, + CodeBlockPlugin, + ColumnsPlugin, + EditablePlugin, + EmbedPlugin, + InlineToolbarPlugin, + ListMarkdownShortcutsPlugin, + MentionTypeaheadPlugin, + SlashMenuPlugin, + TablesPlugin, + TabsPlugin, + TitlePlugin, + TogglePlugin, + TransclusionTypeaheadPlugin, + TurnIntoPlugin, + useTransclusionRender, +} from "@brainstorm-os/editor"; import { useUniversalBody, useYDoc, useYDocApplyPending, useYDocLoaded, } from "@brainstorm-os/react-yjs"; +import { TRANSFORMERS } from "@lexical/markdown"; +import { CheckListPlugin } from "@lexical/react/LexicalCheckListPlugin"; +import { HorizontalRulePlugin } from "@lexical/react/LexicalHorizontalRulePlugin"; +import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"; +import { TabIndentationPlugin } from "@lexical/react/LexicalTabIndentationPlugin"; +import type { SerializedEditorState } from "lexical"; +import { useCallback, useMemo, useState } from "react"; +import { t } from "../i18n/t"; +import { BLOCK_ACTIONS, BLOCK_COMMANDS } from "./commands"; +import { BlockContextMenuPlugin } from "./context-menu-plugin"; +import { FormatChordsPlugin } from "./format-chords-plugin"; +import { LinkMarkupPlugin } from "./link-markup-plugin"; import { NOTES_ADDITIONAL_NODES } from "./notes-nodes"; import { editorTheme } from "./theme"; +import { useTransclusionEditHost } from "./transclusion-edit-host"; import { type TransclusionBodyRenderer, TransclusionRenderProvider, } from "./transclusion-render-context"; +const EDIT_TRANSFORMERS = [...BLOCK_MARKDOWN_TRANSFORMERS, ...TRANSFORMERS]; + +export enum TransclusionEditState { + /** Host read-only or the source isn't held by Notes — no toggle at all. */ + Unavailable = "unavailable", + /** Editable in principle, but the source is locked. */ + Locked = "locked", + ReadOnly = "read-only", + Editing = "editing", +} + +/** Pure decision for the body's edit state (unit-tested). */ +export function resolveTransclusionEditState(args: { + hostEditable: boolean; + canEdit: boolean; + locked: boolean; + requested: boolean; +}): TransclusionEditState { + if (!args.hostEditable || !args.canEdit) return TransclusionEditState.Unavailable; + if (args.locked) return TransclusionEditState.Locked; + return args.requested ? TransclusionEditState.Editing : TransclusionEditState.ReadOnly; +} + +function TransclusionEditingPlugins({ + entityId, + onChange, +}: { + entityId: string; + onChange: (state: SerializedEditorState) => void; +}) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + function TransclusionBodyMount({ entityId, chain, @@ -47,28 +156,83 @@ function TransclusionBodyMount({ const whenLoaded = useYDocLoaded(entityId); const applyPending = useYDocApplyPending(entityId); useUniversalBody(doc); + const { editable: hostEditable = false } = useTransclusionRender(); + const host = useTransclusionEditHost(); + const [requested, setRequested] = useState(false); + const state = resolveTransclusionEditState({ + hostEditable, + canEdit: host?.canEdit(entityId) ?? false, + locked: host?.isLocked(entityId) ?? false, + requested, + }); + const editing = state === TransclusionEditState.Editing; + const nestedChain = useMemo(() => [...chain, entityId], [chain, entityId]); + const onSourceChange = useCallback( + (next: SerializedEditorState) => host?.onSourceChange(entityId, next), + [host, entityId], + ); + const toggleLabel = editing + ? t("notes.transclusion.doneEditing") + : t("notes.transclusion.editHere"); + const toggleHint = + state === TransclusionEditState.Locked + ? t("notes.transclusion.lockedHint") + : editing + ? t("notes.transclusion.doneHint") + : t("notes.transclusion.editHint"); return ( - - { - console.error("[notes/transclusion-body]", error); - }} + {state !== TransclusionEditState.Unavailable && ( +
+ {editing && ( + {t("notes.transclusion.editingBadge")} + )} + +
+ )} + - -
-
+ { + console.error("[notes/transclusion-body]", error); + }} + > + + {/* Read-only too: a transcluded tabs block needs its strip. */} + + {editing && } + + +
); } diff --git a/apps/notes/src/editor/transclusion-edit-host.tsx b/apps/notes/src/editor/transclusion-edit-host.tsx new file mode 100644 index 00000000..607ab364 --- /dev/null +++ b/apps/notes/src/editor/transclusion-edit-host.tsx @@ -0,0 +1,28 @@ +/** + * What a transclusion body needs from the Notes app to be edited in place + * (B11.22): whether Notes holds the source (only an object Notes has loaded + * can have its body edited here, so its denormalised title / snippet / refs + * can follow), whether the source is read-only-locked, and where a body edit + * lands for denormalisation. + * + * Provided ABOVE `` (decorators only see providers above the + * composer — see `transclusion-render-context.tsx`). Absent ⇒ every + * transclusion stays read-only. + */ + +import type { SerializedEditorState } from "lexical"; +import { createContext, useContext } from "react"; + +export type TransclusionEditHost = { + canEdit: (entityId: string) => boolean; + isLocked: (entityId: string) => boolean; + onSourceChange: (entityId: string, state: SerializedEditorState) => void; +}; + +const TransclusionEditContext = createContext(null); + +export const TransclusionEditProvider = TransclusionEditContext.Provider; + +export function useTransclusionEditHost(): TransclusionEditHost | null { + return useContext(TransclusionEditContext); +} diff --git a/apps/notes/src/editor/transclusion-edit.test.tsx b/apps/notes/src/editor/transclusion-edit.test.tsx new file mode 100644 index 00000000..2f86d758 --- /dev/null +++ b/apps/notes/src/editor/transclusion-edit.test.tsx @@ -0,0 +1,227 @@ +// @vitest-environment jsdom + +/** + * B11.22 — editing a transclusion in place. The nested body mounts a real + * Yjs-bound `` over the SOURCE's doc; the explicit + * "Edit here" mode flips it editable only when the host is editable, Notes + * holds the source, and the source isn't locked. + */ + +import { INSERT_TABS_COMMAND, isEventInNestedEditor } from "@brainstorm-os/editor"; +import { YDocProvider, createYDocResolver } from "@brainstorm-os/react-yjs"; +import { $getRoot, type LexicalEditor } from "lexical"; +import { type ReactNode, act } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Doc } from "yjs"; +import { + TransclusionEditState, + renderTransclusionBody, + resolveTransclusionEditState, +} from "./transclusion-body"; +import { type TransclusionEditHost, TransclusionEditProvider } from "./transclusion-edit-host"; +import { TransclusionRenderProvider } from "./transclusion-render-context"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const SOURCE = "n_source"; +const HOST = "n_host"; + +describe("resolveTransclusionEditState", () => { + const base = { hostEditable: true, canEdit: true, locked: false, requested: false }; + it("is unavailable under a read-only host or a source Notes doesn't hold", () => { + expect(resolveTransclusionEditState({ ...base, hostEditable: false })).toBe( + TransclusionEditState.Unavailable, + ); + expect(resolveTransclusionEditState({ ...base, canEdit: false, requested: true })).toBe( + TransclusionEditState.Unavailable, + ); + }); + it("is locked (never editing) when the source is locked, even if requested", () => { + expect(resolveTransclusionEditState({ ...base, locked: true, requested: true })).toBe( + TransclusionEditState.Locked, + ); + }); + it("edits only on request", () => { + expect(resolveTransclusionEditState(base)).toBe(TransclusionEditState.ReadOnly); + expect(resolveTransclusionEditState({ ...base, requested: true })).toBe( + TransclusionEditState.Editing, + ); + }); +}); + +describe("isEventInNestedEditor", () => { + it("flags events from a Lexical root nested inside the host root only", () => { + const host = document.createElement("div"); + host.setAttribute("data-lexical-editor", "true"); + const nested = document.createElement("div"); + nested.setAttribute("data-lexical-editor", "true"); + const text = document.createElement("span"); + nested.append(text); + const own = document.createElement("span"); + host.append(nested, own); + const fire = (target: Element) => { + const event = new KeyboardEvent("keydown"); + Object.defineProperty(event, "target", { value: target }); + return event; + }; + expect(isEventInNestedEditor(fire(text), host)).toBe(true); + expect(isEventInNestedEditor(fire(own), host)).toBe(false); + expect(isEventInNestedEditor(fire(text), nested)).toBe(false); + expect(isEventInNestedEditor(fire(document.body), host)).toBe(false); + expect(isEventInNestedEditor(fire(text), null)).toBe(false); + }); +}); + +describe("transclusion body edit mode", () => { + let container: HTMLDivElement; + let root: Root; + let docs: Map; + let resolver: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + docs = new Map(); + resolver = createYDocResolver({ load: async () => null, persist: () => {}, release: () => {} }); + }); + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + resolver.dispose(); + }); + + function host(overrides: Partial = {}): TransclusionEditHost { + return { + canEdit: (id) => id === SOURCE, + isLocked: () => false, + onSourceChange: vi.fn(), + ...overrides, + }; + } + + async function render( + editHost: TransclusionEditHost | null, + hostEditable: boolean, + ): Promise { + const resolve: typeof resolver.resolve = (id) => { + const handle = resolver.resolve(id); + docs.set(id, handle.doc); + return handle; + }; + const tree: ReactNode = ( + + + + {renderTransclusionBody({ entityId: SOURCE, entityType: "", chain: [HOST] })} + + + + ); + await act(async () => root.render(tree)); + await act(async () => { + await Promise.resolve(); + }); + } + + const toggle = () => + container.querySelector(".notes__transclusion-edit-toggle"); + const state = () => + container.querySelector(".notes__transclusion-edit")?.getAttribute("data-edit-state"); + const editableRoot = () => + container.querySelector(".notes__transclusion-body-content")?.getAttribute("contenteditable"); + + it("binds the nested editor to the SOURCE's doc, read-only until asked", async () => { + await render(host(), true); + expect(docs.has(SOURCE)).toBe(true); + expect(docs.has(HOST)).toBe(false); + expect(state()).toBe(TransclusionEditState.ReadOnly); + expect(editableRoot()).toBe("false"); + expect(toggle()?.getAttribute("aria-pressed")).toBe("false"); + }); + + it("'Edit here' makes the body editable with a visible boundary; 'Done' ends it", async () => { + await render(host(), true); + await act(async () => toggle()?.click()); + expect(state()).toBe(TransclusionEditState.Editing); + expect(editableRoot()).toBe("true"); + expect(container.querySelector(".notes__transclusion-edit--active")).not.toBeNull(); + expect(container.querySelector(".notes__transclusion-edit-badge")).not.toBeNull(); + expect(toggle()?.getAttribute("aria-pressed")).toBe("true"); + await act(async () => toggle()?.click()); + expect(editableRoot()).toBe("false"); + }); + + it("handles the Tabs insert command it offers (TabsPlugin is mounted)", async () => { + await render(host(), true); + await act(async () => toggle()?.click()); + const nested = ( + container.querySelector(".notes__transclusion-body-content") as + | (HTMLElement & { __lexicalEditor?: LexicalEditor }) + | null + )?.__lexicalEditor; + expect(nested).toBeDefined(); + let handled = false; + await act(async () => { + nested?.update(() => $getRoot().selectEnd(), { discrete: true }); + handled = nested?.dispatchCommand(INSERT_TABS_COMMAND, 2) ?? false; + }); + expect(handled).toBe(true); + }); + + it("mounts its own block gutter while editing, and drops it on Done", async () => { + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { + cb(0); + return 1; + }); + await render(host(), true); + const gutter = () => document.querySelector(".bs-editor__block-gutter"); + const hover = async () => + act(async () => { + container + .querySelector(".notes__transclusion-body-content") + ?.dispatchEvent(new MouseEvent("mousemove", { clientY: 0, bubbles: true })); + }); + await hover(); + expect(gutter()).toBeNull(); + await act(async () => toggle()?.click()); + await hover(); + expect(gutter()).not.toBeNull(); + await act(async () => toggle()?.click()); + expect(gutter()).toBeNull(); + vi.unstubAllGlobals(); + }); + + it("keeps a locked source read-only, with a disabled toggle that says why", async () => { + await render(host({ isLocked: () => true }), true); + expect(state()).toBe(TransclusionEditState.Locked); + expect(toggle()?.disabled).toBe(true); + expect(toggle()?.title).toMatch(/locked/i); + expect(editableRoot()).toBe("false"); + }); + + it("drops back to read-only when the source is locked mid-edit", async () => { + let locked = false; + const editHost = host({ isLocked: () => locked }); + await render(editHost, true); + await act(async () => toggle()?.click()); + expect(editableRoot()).toBe("true"); + locked = true; + await render({ ...editHost }, true); + expect(state()).toBe(TransclusionEditState.Locked); + expect(editableRoot()).toBe("false"); + }); + + it("offers no edit mode under a read-only host or without an edit host", async () => { + await render(host(), false); + expect(toggle()).toBeNull(); + expect(editableRoot()).toBe("false"); + await render(null, true); + expect(toggle()).toBeNull(); + }); +}); diff --git a/apps/notes/src/editor/turn-into-page.test.ts b/apps/notes/src/editor/turn-into-page.test.ts new file mode 100644 index 00000000..f484b04e --- /dev/null +++ b/apps/notes/src/editor/turn-into-page.test.ts @@ -0,0 +1,233 @@ +// @vitest-environment jsdom +import { $createTitleNode, BASELINE_NODES } from "@brainstorm-os/editor"; +import { createYDocResolver, isUniversalBodyEmpty } from "@brainstorm-os/react-yjs"; +import { $createListItemNode, $createListNode } from "@lexical/list"; +import { + $createParagraphNode, + $createTextNode, + $getNodeByKey, + $getRoot, + $isElementNode, + type LexicalEditor, + type NodeKey, + type SerializedEditorState, + createEditor, +} from "lexical"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Doc } from "yjs"; +import { plantSerializedStateIntoDoc } from "../store/migrate-body"; +import { NOTES_ADDITIONAL_NODES } from "./notes-nodes"; +import { + PAGE_ENTITY_TYPE, + replaceBlocksWithPageRef, + snapshotBlocksAsPage, + turnBlocksIntoPage, +} from "./turn-into-page"; + +function makeEditor(): LexicalEditor { + return createEditor({ + nodes: [...BASELINE_NODES, ...NOTES_ADDITIONAL_NODES], + onError(error) { + throw error; + }, + }); +} + +type Seeded = { title: NodeKey; a: NodeKey; b: NodeKey; c: NodeKey; item: NodeKey }; + +function seed(editor: LexicalEditor): Seeded { + const keys = { title: "", a: "", b: "", c: "", item: "" }; + editor.update( + () => { + const title = $createTitleNode().append($createTextNode("Host")); + const a = $createParagraphNode().append($createTextNode("Plan")); + const b = $createParagraphNode().append($createTextNode("Step one")); + const item = $createListItemNode().append($createTextNode("Bullet")); + const list = $createListNode("bullet").append(item); + const c = $createParagraphNode().append($createTextNode("After")); + $getRoot().clear().append(title, a, b, list, c); + Object.assign(keys, { + title: title.getKey(), + a: a.getKey(), + b: b.getKey(), + c: c.getKey(), + item: item.getKey(), + }); + }, + { discrete: true }, + ); + return keys; +} + +function rootTypes(editor: LexicalEditor): string[] { + return editor.getEditorState().read(() => + $getRoot() + .getChildren() + .map((c) => c.getType()), + ); +} + +function bodyTexts(state: SerializedEditorState): string[] { + return (state.root.children as { type: string; children?: { text?: string }[] }[]).map( + (c) => `${c.type}:${(c.children ?? []).map((x) => x.text ?? "").join("")}`, + ); +} + +describe("snapshotBlocksAsPage", () => { + it("titles the page from the first block and moves the rest into its body", () => { + const editor = makeEditor(); + const k = seed(editor); + const snap = snapshotBlocksAsPage(editor, new Set([k.b, k.a])); + expect(snap?.title).toBe("Plan"); + expect(snap?.keys).toEqual([k.a, k.b]); + if (!snap) return; + expect(bodyTexts(snap.state)).toEqual(["title:Plan", "paragraph:Step one"]); + }); + + it("keeps a list item inside a list in the new body", () => { + const editor = makeEditor(); + const k = seed(editor); + const snap = snapshotBlocksAsPage(editor, new Set([k.item])); + expect(snap?.title).toBe("Bullet"); + if (!snap) return; + expect(bodyTexts(snap.state)[1]?.startsWith("list:")).toBe(true); + }); + + it("refuses a selection that contains the page title", () => { + const editor = makeEditor(); + const k = seed(editor); + expect(snapshotBlocksAsPage(editor, new Set([k.title, k.a]))).toBeNull(); + expect(snapshotBlocksAsPage(editor, new Set())).toBeNull(); + }); +}); + +describe("replaceBlocksWithPageRef", () => { + it("swaps the blocks for one page link in a single update", () => { + const editor = makeEditor(); + const k = seed(editor); + const snap = snapshotBlocksAsPage(editor, new Set([k.a, k.b])); + if (!snap) throw new Error("no snapshot"); + const updates = vi.fn(); + const off = editor.registerUpdateListener(updates); + expect(replaceBlocksWithPageRef(editor, snap, "n_page")).toBe(true); + off(); + expect(updates).toHaveBeenCalledTimes(1); + expect(rootTypes(editor)).toEqual(["title", "page-ref", "list", "paragraph"]); + }); + + it("drops an emptied list and lands the link where the item was", () => { + const editor = makeEditor(); + const k = seed(editor); + const snap = snapshotBlocksAsPage(editor, new Set([k.item])); + if (!snap) throw new Error("no snapshot"); + replaceBlocksWithPageRef(editor, snap, "n_page"); + expect(rootTypes(editor)).toEqual(["title", "paragraph", "paragraph", "page-ref", "paragraph"]); + }); + + it("leaves the document alone when the blocks changed after the snapshot", () => { + const editor = makeEditor(); + const k = seed(editor); + const snap = snapshotBlocksAsPage(editor, new Set([k.a])); + if (!snap) throw new Error("no snapshot"); + editor.update( + () => { + const block = $getNodeByKey(k.a); + if ($isElementNode(block)) block.append($createTextNode(" edited")); + }, + { discrete: true }, + ); + expect(replaceBlocksWithPageRef(editor, snap, "n_page")).toBe(false); + }); +}); + +describe("turnBlocksIntoPage", () => { + let resolver: ReturnType; + let docs: Map; + + beforeEach(() => { + resolver = createYDocResolver({ load: async () => null, persist: () => {}, release: () => {} }); + docs = new Map(); + }); + afterEach(() => resolver.dispose()); + + function deps(create = vi.fn(async () => ({}))) { + return { + create, + resolve: (id: string) => { + const handle = resolver.resolve(id); + docs.set(id, handle.doc); + return handle; + }, + whenLoaded: resolver.whenLoaded, + plant: plantSerializedStateIntoDoc, + newId: () => "n_new", + now: () => 42, + }; + } + + it("creates the note, plants the blocks into its doc, then links it in place", async () => { + const editor = makeEditor(); + const k = seed(editor); + const d = deps(); + const id = await turnBlocksIntoPage(editor, new Set([k.a, k.b]), d); + expect(id).toBe("n_new"); + expect(d.create).toHaveBeenCalledWith( + PAGE_ENTITY_TYPE, + { title: "Plan", createdAt: 42, updatedAt: 42 }, + "n_new", + ); + const doc = docs.get("n_new"); + expect(doc && isUniversalBodyEmpty(doc)).toBe(false); + expect(rootTypes(editor)).toEqual(["title", "page-ref", "list", "paragraph"]); + }); + + it("changes nothing when the create fails", async () => { + const editor = makeEditor(); + const k = seed(editor); + const before = rootTypes(editor); + await expect( + turnBlocksIntoPage( + editor, + new Set([k.a]), + deps(vi.fn(async () => Promise.reject(new Error("no")))), + ), + ).rejects.toThrow("no"); + expect(rootTypes(editor)).toEqual(before); + }); +}); + +describe("turn into page — undo", () => { + it("one Yjs undo step brings the blocks back and drops the link", async () => { + const { createBinding, createUndoManager, syncLexicalUpdateToYjs } = await import("@lexical/yjs"); + const { createLocalProvider } = await import("@brainstorm-os/editor"); + const { Doc: YDoc } = await import("yjs"); + const doc = new YDoc(); + const editor = makeEditor(); + const provider = createLocalProvider(doc); + const binding = createBinding(editor, provider, "t", doc, new Map([["t", doc]])); + editor.registerUpdateListener( + ({ prevEditorState, editorState, dirtyElements, dirtyLeaves, normalizedNodes, tags }) => + syncLexicalUpdateToYjs( + binding, + provider, + prevEditorState, + editorState, + dirtyElements, + dirtyLeaves, + normalizedNodes, + tags, + ), + ); + const undo = createUndoManager(binding, binding.root.getSharedType()); + const k = seed(editor); + undo.stopCapturing(); + const before = JSON.stringify(binding.root.getSharedType().toDelta()); + const snap = snapshotBlocksAsPage(editor, new Set([k.a, k.b])); + if (!snap) throw new Error("no snapshot"); + expect(replaceBlocksWithPageRef(editor, snap, "n_page")).toBe(true); + expect(JSON.stringify(binding.root.getSharedType().toDelta())).not.toBe(before); + undo.undo(); + expect(JSON.stringify(binding.root.getSharedType().toDelta())).toBe(before); + expect(undo.undoStack.length).toBe(1); + }); +}); diff --git a/apps/notes/src/editor/turn-into-page.ts b/apps/notes/src/editor/turn-into-page.ts new file mode 100644 index 00000000..2a956a18 --- /dev/null +++ b/apps/notes/src/editor/turn-into-page.ts @@ -0,0 +1,198 @@ +/** + * "Turn into page" (B11.25) — the selected blocks move into a new Note, and a + * sub-page link (the same `PageRefNode` `/subpage` inserts) takes their place. + * + * Ordering is chosen so content is never lost: + * 1. snapshot the blocks (the block-clipboard serializer — list items keep + * their list, block backgrounds ride along); + * 2. create the Note and plant the snapshot into its Y.Doc (through the + * renderer resolver, like the vault body migration); + * 3. only then swap the blocks for the link, in ONE editor update — so a + * single undo restores the blocks and removes the link together. + * A failure in 1–2 leaves the document untouched. If the blocks were edited + * while the page was being written, the swap is skipped (the page keeps the + * earlier copy and the document keeps the edit) rather than discarding the + * newer text. + * + * The first selected block's text becomes the page title; a lone block (or a + * multi-block selection's first plain block) is consumed as the title rather + * than duplicated into the body. + */ + +import { + TITLE_NODE_TYPE, + parseBrainstormPayload, + serializeBlocksAsJson, +} from "@brainstorm-os/editor"; +import { isUniversalBodyEmpty } from "@brainstorm-os/react-yjs"; +import type { YDocResolver } from "@brainstorm-os/react-yjs"; +import { $isListItemNode, $isListNode } from "@lexical/list"; +import { + $createParagraphNode, + $getNodeByKey, + $getRoot, + $isElementNode, + type LexicalEditor, + type LexicalNode, + type NodeKey, + type SerializedEditorState, + type SerializedLexicalNode, +} from "lexical"; +import type { Doc } from "yjs"; +import { migrateTitleIntoBody } from "./migrate-title"; +import { $createPageRefNode } from "./nodes/page-ref-node"; + +export const PAGE_ENTITY_TYPE = "io.brainstorm.notes/Note/v1"; + +const TITLE_MAX = 200; + +export type TurnIntoPageDeps = { + create: (type: string, properties: Record, id: string) => Promise; + resolve: YDocResolver; + whenLoaded?: (entityId: string) => Promise; + plant: (doc: Doc, state: SerializedEditorState) => void; + newId: () => string; + now?: () => number; +}; + +export type PageSnapshot = { + /** Selected blocks, document order (a list item stays a list item). */ + keys: NodeKey[]; + title: string; + state: SerializedEditorState; + /** The serialized blocks at snapshot time — the swap re-checks it. */ + fingerprint: string; +}; + +function blockOrder(editor: LexicalEditor, keys: ReadonlySet): NodeKey[] { + return editor.getEditorState().read(() => { + const out: NodeKey[] = []; + const walk = (node: LexicalNode) => { + if (keys.has(node.getKey())) { + out.push(node.getKey()); + return; + } + if ($isElementNode(node)) for (const child of node.getChildren()) walk(child); + }; + walk($getRoot()); + return out; + }); +} + +/** Snapshot the selected blocks as a new page's body. `null` for an empty + * selection or one that contains the page title. */ +export function snapshotBlocksAsPage( + editor: LexicalEditor, + keys: ReadonlySet, +): PageSnapshot | null { + const ordered = blockOrder(editor, keys); + const firstKey = ordered[0]; + if (!firstKey) return null; + const { title, consumesFirst, touchesTitle } = editor.getEditorState().read(() => { + const first = $getNodeByKey(firstKey); + const anyTitle = ordered.some((k) => $getNodeByKey(k)?.getType() === TITLE_NODE_TYPE); + return { + title: (first?.getTextContent() ?? "").trim().slice(0, TITLE_MAX), + consumesFirst: + first !== null && first.getParent()?.is($getRoot()) === true && !$isListNode(first), + touchesTitle: anyTitle, + }; + }); + if (touchesTitle) return null; + const fingerprint = serializeBlocksAsJson(editor, new Set(ordered)); + const payload = parseBrainstormPayload(fingerprint); + if (!payload) return null; + const blocks = consumesFirst ? payload.blocks.slice(1) : payload.blocks; + const bare: SerializedEditorState = { + root: { + type: "root", + version: 1, + direction: null, + format: "", + indent: 0, + children: blocks as SerializedLexicalNode[], + }, + } as SerializedEditorState; + const state = migrateTitleIntoBody(bare, title) as SerializedEditorState; + return { keys: ordered, title, state, fingerprint }; +} + +/** Swap the snapshot's blocks for a page link in one update. Returns false + * (and changes nothing) when the blocks moved on since the snapshot. */ +export function replaceBlocksWithPageRef( + editor: LexicalEditor, + snapshot: PageSnapshot, + pageId: string, +): boolean { + const keys = new Set(snapshot.keys); + const live = blockOrder(editor, keys); + if (live.length !== snapshot.keys.length) return false; + if (serializeBlocksAsJson(editor, keys) !== snapshot.fingerprint) return false; + let swapped = false; + editor.update( + () => { + const first = $getNodeByKey(snapshot.keys[0] as NodeKey); + if (!first) return; + const ref = $createPageRefNode(pageId, PAGE_ENTITY_TYPE, snapshot.title); + if ($isListItemNode(first)) { + const previous = first.getPreviousSibling(); + if (previous) previous.insertAfter(ref); + else first.getParentOrThrow().insertBefore(ref); + } else { + first.insertBefore(ref); + } + const touchedLists = new Set(); + for (const key of snapshot.keys) { + const node = $getNodeByKey(key); + if (!node) continue; + const parent = node.getParent(); + if ($isListNode(parent)) touchedLists.add(parent.getKey()); + node.remove(); + } + for (const key of touchedLists) { + const list = $getNodeByKey(key); + if ($isListNode(list) && list.getChildrenSize() === 0) list.remove(); + } + if (!ref.getNextSibling()) ref.insertAfter($createParagraphNode()); + const next = ref.getNextSibling(); + if ($isElementNode(next)) next.selectStart(); + swapped = true; + }, + { discrete: true }, + ); + return swapped; +} + +/** Write a snapshot into a brand-new page's Y.Doc. */ +export async function plantPage( + pageId: string, + snapshot: PageSnapshot, + deps: Pick, +): Promise { + const handle = deps.resolve(pageId); + try { + if (deps.whenLoaded) await deps.whenLoaded(pageId); + if (!isUniversalBodyEmpty(handle.doc)) throw new Error("new page already has a body"); + deps.plant(handle.doc, snapshot.state); + } finally { + handle.release(); + } +} + +/** The whole flow. Resolves to the new page id, or `null` when nothing moved. */ +export async function turnBlocksIntoPage( + editor: LexicalEditor, + keys: ReadonlySet, + deps: TurnIntoPageDeps, +): Promise { + const snapshot = snapshotBlocksAsPage(editor, keys); + if (!snapshot) return null; + const id = deps.newId(); + const now = (deps.now ?? Date.now)(); + await deps.create(PAGE_ENTITY_TYPE, { title: snapshot.title, createdAt: now, updatedAt: now }, id); + await plantPage(id, snapshot, deps); + if (!replaceBlocksWithPageRef(editor, snapshot, id)) { + console.warn("[notes/turn-into-page] blocks changed while the page was written — kept both."); + } + return id; +} diff --git a/apps/notes/src/i18n/de.json b/apps/notes/src/i18n/de.json index 0702e915..bdbd73e5 100644 --- a/apps/notes/src/i18n/de.json +++ b/apps/notes/src/i18n/de.json @@ -336,6 +336,7 @@ "notes.inlinePropertyForm.format.url": "URL", "notes.inlinePropertyForm.format.email": "E-Mail", "notes.inlinePropertyForm.format.phone": "Telefon", + "notes.inlinePropertyForm.format.richText": "Formatierter Text", "notes.inlinePropertyForm.format.currency": "Währung", "notes.inlinePropertyForm.format.percent": "Prozent", "notes.inlinePropertyForm.format.duration": "Dauer", @@ -402,5 +403,50 @@ "notes.coverPicker.libraryEmpty": "Noch keine Titelbilder hochgeladen.", "notes.coverPicker.focalHint": "Ziehen Sie, um den Fokuspunkt zu wählen.", "notes.coverPicker.useCover": "Titelbild verwenden", - "notes.coverPicker.galleryRegion": "Verlaufs- und Farbtitelbilder" + "notes.coverPicker.galleryRegion": "Verlaufs- und Farbtitelbilder", + "notes.transclusion.editHere": "Hier bearbeiten", + "notes.transclusion.doneEditing": "Fertig", + "notes.transclusion.editHint": "Die Quellseite hier bearbeiten – Änderungen werden dort gespeichert", + "notes.transclusion.doneHint": "Bearbeitung der Quellseite beenden", + "notes.transclusion.lockedHint": "Die Quellseite ist gesperrt – entsperre sie, um hier zu bearbeiten", + "notes.transclusion.editingBadge": "Quellseite wird bearbeitet", + "notes.action.turnIntoPage.label": "Seite", + "notes.action.turnIntoPage.description": "Diese Blöcke in eine neue Unterseite verschieben", + "notes.outline.show": "Gliederung anzeigen", + "notes.outline.hide": "Gliederung ausblenden", + "notes.outline.region": "Gliederung", + "notes.outline.list": "Überschriften in dieser Notiz", + "notes.outline.emptyTitle": "Noch keine Überschriften", + "notes.outline.emptyHint": "Füge dieser Notiz Überschriften hinzu, um ihre Gliederung aufzubauen.", + "notes.outline.noNoteTitle": "Keine Notiz geöffnet", + "notes.outline.noNoteHint": "Öffne eine Notiz, um ihre Gliederung zu sehen.", + "notes.history.title": "Verlauf", + "notes.history.empty": "Noch keine früheren Versionen", + "notes.history.emptyHint": "Eine Version wird ein paar Minuten nach dem Ende der Bearbeitung gespeichert.", + "notes.history.loadFailed": "Der Verlauf konnte nicht geladen werden.", + "notes.history.today": "Heute", + "notes.history.tomorrow": "Morgen", + "notes.history.yesterday": "Gestern", + "notes.history.you": "Sie", + "notes.history.cause.idle": "Bearbeitet", + "notes.history.cause.beforeBulk": "Vor einer Massenänderung", + "notes.history.cause.beforeRestore": "Vor einer Wiederherstellung", + "notes.history.cause.agentRun": "Vor einem Agentenlauf", + "notes.history.cause.import": "Vor einem Import", + "notes.history.cause.manual": "Gespeichert", + "notes.history.listOnly": "Sie sehen, wann und von wem diese Notiz geändert wurde, aber nicht ihren früheren Inhalt.", + "notes.history.previewOnly": "Sie können diese Version ansehen, aber nicht wiederherstellen.", + "notes.history.back": "Alle Versionen", + "notes.history.changes": "Eigenschaftsänderungen", + "notes.history.noChanges": "Die Eigenschaften sind dieselben wie jetzt", + "notes.history.body": "Inhalt", + "notes.history.emptyBody": "Diese Version hatte keinen Inhalt.", + "notes.history.change.added": "seitdem hinzugefügt", + "notes.history.change.removed": "seitdem entfernt", + "notes.history.change.changed": "geändert", + "notes.history.restore": "Diese Version wiederherstellen", + "notes.history.restoring": "Wird wiederhergestellt…", + "notes.history.restoreFailed": "Diese Version konnte nicht wiederhergestellt werden.", + "notes.history.restored": "Version wiederhergestellt. Sie können dies im Verlauf rückgängig machen.", + "notes.history.previewFailed": "Diese Version konnte nicht geladen werden." } diff --git a/apps/notes/src/i18n/en.json b/apps/notes/src/i18n/en.json index 19b6d170..6aafb93b 100644 --- a/apps/notes/src/i18n/en.json +++ b/apps/notes/src/i18n/en.json @@ -336,6 +336,7 @@ "notes.inlinePropertyForm.format.url": "URL", "notes.inlinePropertyForm.format.email": "Email", "notes.inlinePropertyForm.format.phone": "Phone", + "notes.inlinePropertyForm.format.richText": "Rich text", "notes.inlinePropertyForm.format.currency": "Currency", "notes.inlinePropertyForm.format.percent": "Percent", "notes.inlinePropertyForm.format.duration": "Duration", @@ -402,5 +403,50 @@ "notes.coverPicker.libraryEmpty": "No covers uploaded yet.", "notes.coverPicker.focalHint": "Drag to choose the focal point.", "notes.coverPicker.useCover": "Use cover", - "notes.coverPicker.galleryRegion": "Gradient and colour covers" + "notes.coverPicker.galleryRegion": "Gradient and colour covers", + "notes.transclusion.editHere": "Edit here", + "notes.transclusion.doneEditing": "Done", + "notes.transclusion.editHint": "Edit the source page here — changes are saved to it", + "notes.transclusion.doneHint": "Stop editing the source page", + "notes.transclusion.lockedHint": "The source page is locked — unlock it to edit here", + "notes.transclusion.editingBadge": "Editing the source page", + "notes.action.turnIntoPage.label": "Page", + "notes.action.turnIntoPage.description": "Move these blocks into a new sub-page", + "notes.history.title": "History", + "notes.history.empty": "No earlier versions yet", + "notes.history.emptyHint": "A version is saved a couple of minutes after you stop editing.", + "notes.history.loadFailed": "Couldn't load the history.", + "notes.history.today": "Today", + "notes.history.tomorrow": "Tomorrow", + "notes.history.yesterday": "Yesterday", + "notes.history.you": "You", + "notes.history.cause.idle": "Edited", + "notes.history.cause.beforeBulk": "Before a bulk change", + "notes.history.cause.beforeRestore": "Before a restore", + "notes.history.cause.agentRun": "Before an agent run", + "notes.history.cause.import": "Before an import", + "notes.history.cause.manual": "Saved", + "notes.history.listOnly": "You can see when this note changed and who changed it, but not its earlier content.", + "notes.history.previewOnly": "You can view this version but can't restore it.", + "notes.history.back": "All versions", + "notes.history.changes": "Property changes", + "notes.history.noChanges": "Properties are the same as now", + "notes.history.body": "Content", + "notes.history.emptyBody": "This version had no content.", + "notes.history.change.added": "added since", + "notes.history.change.removed": "removed since", + "notes.history.change.changed": "changed", + "notes.history.restore": "Restore this version", + "notes.history.restoring": "Restoring…", + "notes.history.restoreFailed": "Couldn't restore this version.", + "notes.history.restored": "Version restored. You can undo it from History.", + "notes.history.previewFailed": "Couldn't load this version.", + "notes.outline.show": "Show outline", + "notes.outline.hide": "Hide outline", + "notes.outline.region": "Outline", + "notes.outline.list": "Headings in this note", + "notes.outline.emptyTitle": "No headings yet", + "notes.outline.emptyHint": "Add headings to this note to build its outline.", + "notes.outline.noNoteTitle": "No note open", + "notes.outline.noNoteHint": "Open a note to see its outline." } diff --git a/apps/notes/src/i18n/es.json b/apps/notes/src/i18n/es.json index 9dc05e27..0d340906 100644 --- a/apps/notes/src/i18n/es.json +++ b/apps/notes/src/i18n/es.json @@ -336,6 +336,7 @@ "notes.inlinePropertyForm.format.url": "URL", "notes.inlinePropertyForm.format.email": "Correo", "notes.inlinePropertyForm.format.phone": "Teléfono", + "notes.inlinePropertyForm.format.richText": "Texto enriquecido", "notes.inlinePropertyForm.format.currency": "Moneda", "notes.inlinePropertyForm.format.percent": "Porcentaje", "notes.inlinePropertyForm.format.duration": "Duración", @@ -402,5 +403,50 @@ "notes.coverPicker.libraryEmpty": "Aún no se han subido portadas.", "notes.coverPicker.focalHint": "Arrastre para elegir el punto focal.", "notes.coverPicker.useCover": "Usar portada", - "notes.coverPicker.galleryRegion": "Portadas de degradados y colores" + "notes.coverPicker.galleryRegion": "Portadas de degradados y colores", + "notes.transclusion.editHere": "Editar aquí", + "notes.transclusion.doneEditing": "Listo", + "notes.transclusion.editHint": "Editar la página de origen aquí: los cambios se guardan en ella", + "notes.transclusion.doneHint": "Dejar de editar la página de origen", + "notes.transclusion.lockedHint": "La página de origen está bloqueada: desbloquéala para editarla aquí", + "notes.transclusion.editingBadge": "Editando la página de origen", + "notes.action.turnIntoPage.label": "Página", + "notes.action.turnIntoPage.description": "Mover estos bloques a una nueva subpágina", + "notes.outline.show": "Mostrar esquema", + "notes.outline.hide": "Ocultar esquema", + "notes.outline.region": "Esquema", + "notes.outline.list": "Encabezados de esta nota", + "notes.outline.emptyTitle": "Aún no hay encabezados", + "notes.outline.emptyHint": "Añade encabezados a esta nota para crear su esquema.", + "notes.outline.noNoteTitle": "Ninguna nota abierta", + "notes.outline.noNoteHint": "Abre una nota para ver su esquema.", + "notes.history.title": "Historial", + "notes.history.empty": "Aún no hay versiones anteriores", + "notes.history.emptyHint": "Se guarda una versión un par de minutos después de que dejes de editar.", + "notes.history.loadFailed": "No se pudo cargar el historial.", + "notes.history.today": "Hoy", + "notes.history.tomorrow": "Mañana", + "notes.history.yesterday": "Ayer", + "notes.history.you": "Tú", + "notes.history.cause.idle": "Editado", + "notes.history.cause.beforeBulk": "Antes de un cambio masivo", + "notes.history.cause.beforeRestore": "Antes de una restauración", + "notes.history.cause.agentRun": "Antes de una ejecución del agente", + "notes.history.cause.import": "Antes de una importación", + "notes.history.cause.manual": "Guardado", + "notes.history.listOnly": "Puedes ver cuándo cambió esta nota y quién la cambió, pero no su contenido anterior.", + "notes.history.previewOnly": "Puedes ver esta versión, pero no restaurarla.", + "notes.history.back": "Todas las versiones", + "notes.history.changes": "Cambios de propiedades", + "notes.history.noChanges": "Las propiedades son las mismas que ahora", + "notes.history.body": "Contenido", + "notes.history.emptyBody": "Esta versión no tenía contenido.", + "notes.history.change.added": "añadido desde entonces", + "notes.history.change.removed": "eliminado desde entonces", + "notes.history.change.changed": "cambiado", + "notes.history.restore": "Restaurar esta versión", + "notes.history.restoring": "Restaurando…", + "notes.history.restoreFailed": "No se pudo restaurar esta versión.", + "notes.history.restored": "Versión restaurada. Puedes deshacerlo desde el historial.", + "notes.history.previewFailed": "No se pudo cargar esta versión." } diff --git a/apps/notes/src/i18n/fr.json b/apps/notes/src/i18n/fr.json index b2793d0d..ee5e2505 100644 --- a/apps/notes/src/i18n/fr.json +++ b/apps/notes/src/i18n/fr.json @@ -336,6 +336,7 @@ "notes.inlinePropertyForm.format.url": "URL", "notes.inlinePropertyForm.format.email": "E-mail", "notes.inlinePropertyForm.format.phone": "Téléphone", + "notes.inlinePropertyForm.format.richText": "Texte enrichi", "notes.inlinePropertyForm.format.currency": "Devise", "notes.inlinePropertyForm.format.percent": "Pourcentage", "notes.inlinePropertyForm.format.duration": "Durée", @@ -402,5 +403,50 @@ "notes.coverPicker.libraryEmpty": "Pas encore de couvertures importées.", "notes.coverPicker.focalHint": "Faites glisser pour choisir le point focal.", "notes.coverPicker.useCover": "Utiliser la couverture", - "notes.coverPicker.galleryRegion": "Couvertures en dégradés et couleurs" + "notes.coverPicker.galleryRegion": "Couvertures en dégradés et couleurs", + "notes.transclusion.editHere": "Modifier ici", + "notes.transclusion.doneEditing": "Terminé", + "notes.transclusion.editHint": "Modifier la page source ici — les modifications y sont enregistrées", + "notes.transclusion.doneHint": "Arrêter de modifier la page source", + "notes.transclusion.lockedHint": "La page source est verrouillée — déverrouillez-la pour la modifier ici", + "notes.transclusion.editingBadge": "Modification de la page source", + "notes.action.turnIntoPage.label": "Page", + "notes.action.turnIntoPage.description": "Déplacer ces blocs dans une nouvelle sous-page", + "notes.outline.show": "Afficher le plan", + "notes.outline.hide": "Masquer le plan", + "notes.outline.region": "Plan", + "notes.outline.list": "Titres de cette note", + "notes.outline.emptyTitle": "Aucun titre pour l’instant", + "notes.outline.emptyHint": "Ajoutez des titres à cette note pour construire son plan.", + "notes.outline.noNoteTitle": "Aucune note ouverte", + "notes.outline.noNoteHint": "Ouvrez une note pour voir son plan.", + "notes.history.title": "Historique", + "notes.history.empty": "Pas encore de versions antérieures", + "notes.history.emptyHint": "Une version est enregistrée quelques minutes après la fin de vos modifications.", + "notes.history.loadFailed": "Impossible de charger l'historique.", + "notes.history.today": "Aujourd'hui", + "notes.history.tomorrow": "Demain", + "notes.history.yesterday": "Hier", + "notes.history.you": "Vous", + "notes.history.cause.idle": "Modifié", + "notes.history.cause.beforeBulk": "Avant une modification groupée", + "notes.history.cause.beforeRestore": "Avant une restauration", + "notes.history.cause.agentRun": "Avant une exécution de l'agent", + "notes.history.cause.import": "Avant une importation", + "notes.history.cause.manual": "Enregistré", + "notes.history.listOnly": "Vous pouvez voir quand et par qui cette note a été modifiée, mais pas son contenu antérieur.", + "notes.history.previewOnly": "Vous pouvez consulter cette version, mais pas la restaurer.", + "notes.history.back": "Toutes les versions", + "notes.history.changes": "Modifications des propriétés", + "notes.history.noChanges": "Les propriétés sont identiques à maintenant", + "notes.history.body": "Contenu", + "notes.history.emptyBody": "Cette version n'avait aucun contenu.", + "notes.history.change.added": "ajouté depuis", + "notes.history.change.removed": "supprimé depuis", + "notes.history.change.changed": "modifié", + "notes.history.restore": "Restaurer cette version", + "notes.history.restoring": "Restauration…", + "notes.history.restoreFailed": "Impossible de restaurer cette version.", + "notes.history.restored": "Version restaurée. Vous pouvez l'annuler depuis l'historique.", + "notes.history.previewFailed": "Impossible de charger cette version." } diff --git a/apps/notes/src/i18n/it.json b/apps/notes/src/i18n/it.json index 57e78575..3f5cb26e 100644 --- a/apps/notes/src/i18n/it.json +++ b/apps/notes/src/i18n/it.json @@ -336,6 +336,7 @@ "notes.inlinePropertyForm.format.url": "URL", "notes.inlinePropertyForm.format.email": "E-mail", "notes.inlinePropertyForm.format.phone": "Telefono", + "notes.inlinePropertyForm.format.richText": "Testo formattato", "notes.inlinePropertyForm.format.currency": "Valuta", "notes.inlinePropertyForm.format.percent": "Percentuale", "notes.inlinePropertyForm.format.duration": "Durata", @@ -402,5 +403,50 @@ "notes.coverPicker.libraryEmpty": "Ancora nessuna copertina caricata.", "notes.coverPicker.focalHint": "Trascini per scegliere il punto focale.", "notes.coverPicker.useCover": "Usa copertina", - "notes.coverPicker.galleryRegion": "Copertine con gradienti e colori" + "notes.coverPicker.galleryRegion": "Copertine con gradienti e colori", + "notes.transclusion.editHere": "Modifica qui", + "notes.transclusion.doneEditing": "Fatto", + "notes.transclusion.editHint": "Modifica qui la pagina di origine: le modifiche vengono salvate lì", + "notes.transclusion.doneHint": "Interrompi la modifica della pagina di origine", + "notes.transclusion.lockedHint": "La pagina di origine è bloccata: sbloccala per modificarla qui", + "notes.transclusion.editingBadge": "Modifica della pagina di origine", + "notes.action.turnIntoPage.label": "Pagina", + "notes.action.turnIntoPage.description": "Sposta questi blocchi in una nuova sottopagina", + "notes.outline.show": "Mostra struttura", + "notes.outline.hide": "Nascondi struttura", + "notes.outline.region": "Struttura", + "notes.outline.list": "Intestazioni di questa nota", + "notes.outline.emptyTitle": "Ancora nessuna intestazione", + "notes.outline.emptyHint": "Aggiungi intestazioni a questa nota per costruirne la struttura.", + "notes.outline.noNoteTitle": "Nessuna nota aperta", + "notes.outline.noNoteHint": "Apri una nota per vederne la struttura.", + "notes.history.title": "Cronologia", + "notes.history.empty": "Ancora nessuna versione precedente", + "notes.history.emptyHint": "Una versione viene salvata un paio di minuti dopo che smetti di modificare.", + "notes.history.loadFailed": "Impossibile caricare la cronologia.", + "notes.history.today": "Oggi", + "notes.history.tomorrow": "Domani", + "notes.history.yesterday": "Ieri", + "notes.history.you": "Tu", + "notes.history.cause.idle": "Modificato", + "notes.history.cause.beforeBulk": "Prima di una modifica in blocco", + "notes.history.cause.beforeRestore": "Prima di un ripristino", + "notes.history.cause.agentRun": "Prima di un'esecuzione dell'agente", + "notes.history.cause.import": "Prima di un'importazione", + "notes.history.cause.manual": "Salvato", + "notes.history.listOnly": "Puoi vedere quando e da chi è stata modificata questa nota, ma non il suo contenuto precedente.", + "notes.history.previewOnly": "Puoi visualizzare questa versione ma non ripristinarla.", + "notes.history.back": "Tutte le versioni", + "notes.history.changes": "Modifiche alle proprietà", + "notes.history.noChanges": "Le proprietà sono le stesse di adesso", + "notes.history.body": "Contenuto", + "notes.history.emptyBody": "Questa versione non aveva contenuto.", + "notes.history.change.added": "aggiunto da allora", + "notes.history.change.removed": "rimosso da allora", + "notes.history.change.changed": "modificato", + "notes.history.restore": "Ripristina questa versione", + "notes.history.restoring": "Ripristino…", + "notes.history.restoreFailed": "Impossibile ripristinare questa versione.", + "notes.history.restored": "Versione ripristinata. Puoi annullare dalla cronologia.", + "notes.history.previewFailed": "Impossibile caricare questa versione." } diff --git a/apps/notes/src/i18n/pt.json b/apps/notes/src/i18n/pt.json index 342b6f86..0762aa4c 100644 --- a/apps/notes/src/i18n/pt.json +++ b/apps/notes/src/i18n/pt.json @@ -336,6 +336,7 @@ "notes.inlinePropertyForm.format.url": "URL", "notes.inlinePropertyForm.format.email": "E-mail", "notes.inlinePropertyForm.format.phone": "Telefone", + "notes.inlinePropertyForm.format.richText": "Texto formatado", "notes.inlinePropertyForm.format.currency": "Moeda", "notes.inlinePropertyForm.format.percent": "Porcentagem", "notes.inlinePropertyForm.format.duration": "Duração", @@ -402,5 +403,50 @@ "notes.coverPicker.libraryEmpty": "Ainda não há capas enviadas.", "notes.coverPicker.focalHint": "Arraste para escolher o ponto focal.", "notes.coverPicker.useCover": "Usar capa", - "notes.coverPicker.galleryRegion": "Capas com gradientes e cores" + "notes.coverPicker.galleryRegion": "Capas com gradientes e cores", + "notes.transclusion.editHere": "Editar aqui", + "notes.transclusion.doneEditing": "Concluído", + "notes.transclusion.editHint": "Editar a página de origem aqui — as alterações são salvas nela", + "notes.transclusion.doneHint": "Parar de editar a página de origem", + "notes.transclusion.lockedHint": "A página de origem está bloqueada — desbloqueie-a para editar aqui", + "notes.transclusion.editingBadge": "Editando a página de origem", + "notes.action.turnIntoPage.label": "Página", + "notes.action.turnIntoPage.description": "Mover estes blocos para uma nova subpágina", + "notes.outline.show": "Mostrar estrutura", + "notes.outline.hide": "Ocultar estrutura", + "notes.outline.region": "Estrutura", + "notes.outline.list": "Títulos desta nota", + "notes.outline.emptyTitle": "Ainda sem títulos", + "notes.outline.emptyHint": "Adicione títulos a esta nota para criar a sua estrutura.", + "notes.outline.noNoteTitle": "Nenhuma nota aberta", + "notes.outline.noNoteHint": "Abra uma nota para ver a sua estrutura.", + "notes.history.title": "Histórico", + "notes.history.empty": "Ainda não há versões anteriores", + "notes.history.emptyHint": "Uma versão é salva alguns minutos depois que você para de editar.", + "notes.history.loadFailed": "Não foi possível carregar o histórico.", + "notes.history.today": "Hoje", + "notes.history.tomorrow": "Amanhã", + "notes.history.yesterday": "Ontem", + "notes.history.you": "Você", + "notes.history.cause.idle": "Editado", + "notes.history.cause.beforeBulk": "Antes de uma alteração em massa", + "notes.history.cause.beforeRestore": "Antes de uma restauração", + "notes.history.cause.agentRun": "Antes de uma execução do agente", + "notes.history.cause.import": "Antes de uma importação", + "notes.history.cause.manual": "Salvo", + "notes.history.listOnly": "Você pode ver quando e por quem esta nota foi alterada, mas não o conteúdo anterior.", + "notes.history.previewOnly": "Você pode ver esta versão, mas não restaurá-la.", + "notes.history.back": "Todas as versões", + "notes.history.changes": "Alterações de propriedades", + "notes.history.noChanges": "As propriedades são as mesmas de agora", + "notes.history.body": "Conteúdo", + "notes.history.emptyBody": "Esta versão não tinha conteúdo.", + "notes.history.change.added": "adicionado desde então", + "notes.history.change.removed": "removido desde então", + "notes.history.change.changed": "alterado", + "notes.history.restore": "Restaurar esta versão", + "notes.history.restoring": "Restaurando…", + "notes.history.restoreFailed": "Não foi possível restaurar esta versão.", + "notes.history.restored": "Versão restaurada. Você pode desfazer pelo Histórico.", + "notes.history.previewFailed": "Não foi possível carregar esta versão." } diff --git a/apps/notes/src/keyboard/action-ids.ts b/apps/notes/src/keyboard/action-ids.ts index c338846f..e4384ae8 100644 --- a/apps/notes/src/keyboard/action-ids.ts +++ b/apps/notes/src/keyboard/action-ids.ts @@ -22,6 +22,8 @@ export enum ActionId { ToggleSidebar = "notes.toggle-sidebar", /** Show/hide the right-hand Properties panel. Bound to `Mod+Shift+\`. */ ToggleProperties = "notes.toggle-properties", + /** Show/hide the side Outline panel (B11.26). Bound to `Mod+Alt+o`. */ + ToggleOutline = "notes.toggle-outline", /** Lock/unlock the open note (page-level read-only). Bound to `Mod+Alt+l` * (OQ-235 (a): `Mod+Shift+L` stays on the shell appearance toggle). */ ToggleNoteLock = "notes.toggle-note-lock", @@ -58,6 +60,9 @@ export enum ActionId { * `Mod+Enter` — conventional escape hatch when Enter alone inserts a * newline inside the code. */ ExitCodeBlock = "notes.exit-code-block", + /** Open the block Insert menu below the caret's block (B11.21). Bound to + * `Mod+Shift+Enter`; the editor's `INSERT_MENU_CHORDS` owns the binding. */ + OpenInsertMenu = "notes.open-insert-menu", /** Close the media inspector popover. Bound to `Escape`. */ CloseMediaInspector = "notes.close-media-inspector", /** Toggle the strikethrough text mark on the selection (B11.6). Lexical diff --git a/apps/notes/src/keyboard/default-chords.ts b/apps/notes/src/keyboard/default-chords.ts index e3dccab5..539b0100 100644 --- a/apps/notes/src/keyboard/default-chords.ts +++ b/apps/notes/src/keyboard/default-chords.ts @@ -22,6 +22,7 @@ export const DEFAULT_CHORDS: Readonly> = { [ActionId.DeleteSelectedBlocks]: ["Backspace", "Delete"], [ActionId.ToggleSidebar]: ["Mod+\\"], [ActionId.ToggleProperties]: ["Mod+Shift+\\"], + [ActionId.ToggleOutline]: ["Mod+Alt+o"], [ActionId.ToggleNoteLock]: ["Mod+Alt+l"], [ActionId.CloseIconPicker]: ["Escape"], [ActionId.CloseActionMenu]: ["Escape"], @@ -41,6 +42,7 @@ export const DEFAULT_CHORDS: Readonly> = { [ActionId.CutSelectedBlocks]: ["Mod+x"], [ActionId.PasteSelectedBlocks]: ["Mod+v"], [ActionId.ExitCodeBlock]: ["Mod+Enter"], + [ActionId.OpenInsertMenu]: ["Mod+Shift+Enter"], [ActionId.CloseMediaInspector]: ["Escape"], [ActionId.OpenLinkMarkup]: ["Mod+k"], [ActionId.ToggleStrikeMark]: ["Mod+Shift+S"], diff --git a/apps/notes/src/main.tsx b/apps/notes/src/main.tsx index 365d3464..c0846ac1 100644 --- a/apps/notes/src/main.tsx +++ b/apps/notes/src/main.tsx @@ -2,11 +2,12 @@ import "@brainstorm-os/sdk/app-theme.css"; import { initAnalytics } from "@brainstorm-os/sdk/analytics"; import "@brainstorm-os/sdk/empty-state.css"; import "@brainstorm-os/sdk/share-dialog.css"; +import "@brainstorm-os/sdk/version-history.css"; import { mountMenuHost } from "@brainstorm-os/sdk/menus"; import { mountSpellcheckMenuFromWindow } from "@brainstorm-os/sdk/spellcheck-menu"; import "@brainstorm-os/editor/editor.css"; import "@brainstorm-os/editor/editor-theme.css"; -import { setEditorHost, setEntityIndexSource } from "@brainstorm-os/editor"; +import { editorEntitiesHost, setEditorHost, setEntityIndexSource } from "@brainstorm-os/editor"; import { YDocProvider } from "@brainstorm-os/react-yjs"; import { BlockRendererRegistryProvider, @@ -82,6 +83,7 @@ function mountFullApp(mountRoot: HTMLElement): void { const storage = runtime?.services.storage; const blocksSvc = runtime?.services.blocks; const bpSvc = runtime?.services.bp; + const notesEntities = editorEntitiesHost(runtime?.services.entities); setEditorHost({ ...(intents ? { @@ -113,6 +115,9 @@ function mountFullApp(mountRoot: HTMLElement): void { } : {}), ...(bpSvc ? { bp: bpSvc } : {}), + // `/task` + the task-mention checkbox (B11.24) create / complete tasks + // under Notes' existing `entities.write:*` grant. + ...(notesEntities ? { entities: notesEntities } : {}), }); const blocksService = runtime?.services.blocks; diff --git a/apps/notes/src/properties/picker-labels.ts b/apps/notes/src/properties/picker-labels.ts index 51ee9798..85ede947 100644 --- a/apps/notes/src/properties/picker-labels.ts +++ b/apps/notes/src/properties/picker-labels.ts @@ -62,6 +62,7 @@ export function useNotesPickerLabels(): Partial { formatUrl: t("notes.inlinePropertyForm.format.url"), formatEmail: t("notes.inlinePropertyForm.format.email"), formatPhone: t("notes.inlinePropertyForm.format.phone"), + formatRichText: t("notes.inlinePropertyForm.format.richText"), formatCurrency: t("notes.inlinePropertyForm.format.currency"), formatPercent: t("notes.inlinePropertyForm.format.percent"), formatDuration: t("notes.inlinePropertyForm.format.duration"), diff --git a/apps/notes/src/store/runtime.ts b/apps/notes/src/store/runtime.ts index 4b3c5822..070f655f 100644 --- a/apps/notes/src/store/runtime.ts +++ b/apps/notes/src/store/runtime.ts @@ -17,6 +17,7 @@ import type { SearchService, SharingService, VaultEntitiesService, + VersionHistoryService, } from "@brainstorm-os/sdk-types"; type StorageValue = unknown; @@ -126,6 +127,8 @@ export type NotesBrainstorm = { * declares the scarce `sharing.share` cap so its object-menu "Share…" * opens the shared ``. */ sharing?: SharingService; + /** 10.16 — version history (older shells omit it). */ + history?: VersionHistoryService; /** Stage 9.3 preview surface — used by the `@`-mention typeahead * to enumerate vault-wide entities for cross-app linking. Goes * away when the full entities service lands; the SDK swaps the diff --git a/apps/notes/src/styles.css b/apps/notes/src/styles.css index 03b8da11..88115b2f 100644 --- a/apps/notes/src/styles.css +++ b/apps/notes/src/styles.css @@ -553,12 +553,18 @@ textarea { min-width: 0; } -.notes[data-nav-open="false"] .notes__nav { +.notes[data-nav-open="false"] .notes__nav:not([data-bs-autohide]) { transform: translateX(-100%); border-color: transparent; visibility: hidden; } +/* Auto-hide (7.15): the hot-zone and the overlay start below the header row. */ +.notes > .bs-autohide-edge, +.notes__nav[data-bs-autohide] { + top: 44px; +} + /* PHYSICAL-AXIS INTENTIONAL: resize handle straddles the nav's right edge geometrically */ .notes__nav-resize { position: absolute; @@ -788,3 +794,149 @@ body.is-resizing .notes__props-resize { min-width: 0; } + +/* B11.22 — in-place transclusion editing. The toggle row sits between the + source card (the chip naming where the content lives) and the body; while + editing, the body's well gains an accent boundary so it is unmistakable + that keystrokes land in ANOTHER page. */ +.notes__transclusion-edit { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.notes__transclusion-edit-bar { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-2); +} + +.notes__transclusion-edit-badge { + font-size: var(--text-size-xs); + color: var(--text-dim); +} + +.notes__transclusion-edit--active .notes__transclusion-body-content { + outline: var(--border-width) solid var(--accent); + outline-offset: var(--space-1); + border-radius: var(--radius-sm); +} + +/* ── Side Outline panel (B11.26) ─────────────────────────────────────── + A second glass overlay on the right edge, docked beside the properties + panel when both are open (never stacked over it). Same GPU-transform + slide as `.notes__props`. */ +.notes__outline { + position: absolute; + top: 44px; + bottom: 0; + inset-inline-end: 0; + width: 240px; + display: flex; + flex-direction: column; + border-inline-start: var(--border-width) solid var(--color-glass-border); + box-shadow: var(--shadow-xl); + overflow: hidden; + z-index: 99; + transition: + transform var(--motion-duration-normal) ease, + visibility var(--motion-duration-normal) ease; + will-change: transform; +} + +/* Docking beside the properties panel is a transform too, so opening or + closing that panel slides the outline on the compositor, not via layout. + PHYSICAL-AXIS INTENTIONAL: the shift follows the screen edge, like the + closed-state slide below. */ +.notes[data-props-open="true"] .notes__outline { + transform: translateX(calc(-1 * var(--notes-props-width, 320px))); +} + +.notes[data-outline-open="false"] .notes__outline { + transform: translateX(100%); + box-shadow: none; + border-color: transparent; + visibility: hidden; +} + +.notes__outline-header { + display: flex; + align-items: center; + flex: none; + height: 44px; + padding: 0 var(--space-3); + border-bottom: var(--border-width) solid var(--color-border-subtle); + color: var(--color-text-secondary); + font-size: var(--text-size-sm); + font-weight: 600; +} + +.notes__outline-empty { + flex: 1 1 0; + min-height: 0; +} + +.notes__outline-list { + flex: 1 1 0; + min-height: 0; + margin: 0; + padding: var(--space-2) 0; + list-style: none; + overflow-y: auto; + outline: none; +} + +.notes__outline-item { + --outline-depth: 0; + outline: none; +} + +.notes__outline-item[data-depth="1"] { + --outline-depth: 1; +} +.notes__outline-item[data-depth="2"] { + --outline-depth: 2; +} +.notes__outline-item[data-depth="3"] { + --outline-depth: 3; +} +.notes__outline-item[data-depth="4"] { + --outline-depth: 4; +} +.notes__outline-item[data-depth="5"] { + --outline-depth: 5; +} + +.notes__outline-link { + display: block; + width: 100%; + padding: var(--space-1) var(--space-3); + padding-inline-start: calc(var(--space-3) + var(--outline-depth) * var(--space-3)); + border: none; + border-inline-start: 2px solid transparent; + background: transparent; + color: var(--color-text-secondary); + font-size: var(--text-size-sm); + text-align: start; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; +} + +.notes__outline-link:hover { + background: var(--hover); + color: var(--color-text-primary); +} + +.notes__outline-item:focus-visible .notes__outline-link { + outline: 2px solid var(--color-focus-ring); + outline-offset: -2px; +} + +.notes__outline-item[aria-current="location"] .notes__outline-link { + border-inline-start-color: var(--accent); + color: var(--color-text-primary); + font-weight: 600; +} diff --git a/apps/notes/src/ui/outline-panel.test.tsx b/apps/notes/src/ui/outline-panel.test.tsx new file mode 100644 index 00000000..a45317ec --- /dev/null +++ b/apps/notes/src/ui/outline-panel.test.tsx @@ -0,0 +1,117 @@ +// @vitest-environment jsdom +import type { LexicalEditor } from "lexical"; +import { act } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OutlineEntry } from "../editor/outline"; +import { OutlinePanel } from "./outline-panel"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const ENTRIES: OutlineEntry[] = [ + { key: "a", text: "Alpha", level: 1 }, + { key: "b", text: "Beta", level: 2 }, + { key: "c", text: "", level: 2 }, +]; + +let root: Root | null = null; +let host: HTMLElement | null = null; + +afterEach(() => { + act(() => root?.unmount()); + host?.remove(); + root = null; + host = null; + vi.unstubAllGlobals(); +}); + +function harness(tops: Record) { + const scrolled: string[] = []; + const els = new Map(); + for (const key of Object.keys(tops)) { + const el = document.createElement("h2"); + el.getBoundingClientRect = () => ({ top: tops[key] ?? 0 }) as DOMRect; + el.scrollIntoView = () => scrolled.push(key); + els.set(key, el); + } + const editor = { getElementByKey: (k: string) => els.get(k) ?? null } as unknown as LexicalEditor; + const scroller = document.createElement("div"); + scroller.getBoundingClientRect = () => ({ top: 0 }) as DOMRect; + const editorRef = { current: editor }; + const scrollContainer = () => scroller; + const mount = (hasNote: boolean, entries: OutlineEntry[]) => { + if (!host) { + host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); + } + act(() => { + root?.render( + , + ); + }); + return host; + }; + return { mount, scroller, scrolled, tops }; +} + +const options = (el: HTMLElement) => [...el.querySelectorAll("[role='option']")]; + +describe("OutlinePanel", () => { + it("shows a no-note empty state, then a no-headings one", () => { + const h = harness({}); + expect(h.mount(false, []).textContent).toContain("No note open"); + expect(h.mount(true, []).textContent).toContain("No headings yet"); + }); + + it("indents by depth relative to the shallowest heading and labels untitled ones", () => { + const el = harness({ a: 0, b: 100, c: 200 }).mount(true, ENTRIES); + const items = options(el); + expect(items.map((i) => i.getAttribute("data-depth"))).toEqual(["0", "1", "1"]); + expect(items[2]?.textContent).toBe("Untitled heading"); + }); + + it("click scrolls the heading into view", () => { + const h = harness({ a: 0, b: 100, c: 200 }); + const el = h.mount(true, ENTRIES); + act(() => options(el)[1]?.querySelector("button")?.click()); + expect(h.scrolled).toEqual(["b"]); + }); + + it("keyboard: ArrowDown moves the cursor and Enter reveals", () => { + const h = harness({ a: 0, b: 100, c: 200 }); + const el = h.mount(true, ENTRIES); + const list = el.querySelector("[role='listbox']"); + expect(list).not.toBeNull(); + const key = (k: string) => + act(() => { + list?.dispatchEvent(new KeyboardEvent("keydown", { key: k, bubbles: true })); + }); + key("ArrowDown"); + key("Enter"); + expect(h.scrolled).toEqual(["b"]); + }); + + it("tracks the heading the reader has scrolled to", () => { + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { + cb(0); + return 1; + }); + const tops = { a: -300, b: 20, c: 400 }; + const h = harness(tops); + const el = h.mount(true, ENTRIES); + const current = () => options(el).findIndex((i) => i.getAttribute("aria-current") === "location"); + expect(current()).toBe(1); + tops.b = -100; + tops.c = 10; + act(() => { + h.scroller.dispatchEvent(new Event("scroll")); + }); + expect(current()).toBe(2); + }); +}); diff --git a/apps/notes/src/ui/outline-panel.tsx b/apps/notes/src/ui/outline-panel.tsx new file mode 100644 index 00000000..9a15f60c --- /dev/null +++ b/apps/notes/src/ui/outline-panel.tsx @@ -0,0 +1,125 @@ +/** + * Side Outline panel (B11.26) — the open note's headings as a navigable + * list. Click / Enter scrolls the heading into view; the heading the reader + * is currently in is tracked off the document scroller and marked + * `aria-current`. Always live: with no note, or a note without headings, it + * renders its own empty state rather than the toggle going dead. + */ + +import { Orientation, useCompositeKeyboard } from "@brainstorm-os/sdk/a11y"; +import { EmptyState, EmptyStateTone } from "@brainstorm-os/sdk/empty-state"; +import { IconName } from "@brainstorm-os/sdk/icon"; +import type { LexicalEditor } from "lexical"; +import { type MutableRefObject, type ReactNode, useCallback, useEffect, useState } from "react"; +import { type OutlineEntry, activeOutlineIndex, outlineBaseLevel } from "../editor/outline"; +import { t } from "../i18n/t"; + +/** How far below the scroller's top edge a heading counts as "reached" — + * roughly one line, so the active row flips as the heading passes under + * the header rather than only once it has scrolled fully out of view. */ +const ACTIVE_THRESHOLD_PX = 48; + +export function OutlinePanel({ + hasNote, + entries, + editorRef, + scrollContainer, +}: { + hasNote: boolean; + entries: readonly OutlineEntry[]; + editorRef: MutableRefObject; + /** The element the document scrolls inside (`.notes__main`). */ + scrollContainer: () => HTMLElement | null; +}): ReactNode { + const [cursor, setCursor] = useState(0); + const [current, setCurrent] = useState(-1); + + useEffect(() => { + const scroller = scrollContainer(); + if (!scroller || entries.length === 0) { + setCurrent(-1); + return; + } + let frame = 0; + const measure = (): void => { + frame = 0; + const editor = editorRef.current; + const top = scroller.getBoundingClientRect().top + ACTIVE_THRESHOLD_PX; + const tops = entries.map((e) => { + const el = editor?.getElementByKey(e.key); + return el ? el.getBoundingClientRect().top : null; + }); + setCurrent(activeOutlineIndex(tops, top)); + }; + const onScroll = (): void => { + if (frame === 0) frame = requestAnimationFrame(measure); + }; + measure(); + scroller.addEventListener("scroll", onScroll, { passive: true }); + return () => { + scroller.removeEventListener("scroll", onScroll); + if (frame !== 0) cancelAnimationFrame(frame); + }; + }, [entries, editorRef, scrollContainer]); + + useEffect(() => { + setCursor((c) => Math.min(c, Math.max(0, entries.length - 1))); + }, [entries.length]); + + const reveal = useCallback( + (index: number) => { + const entry = entries[index]; + const editor = editorRef.current; + if (!entry || !editor) return; + setCursor(index); + editor.getElementByKey(entry.key)?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, + [entries, editorRef], + ); + + const { containerProps, getItemProps } = useCompositeKeyboard({ + orientation: Orientation.Vertical, + count: entries.length, + activeIndex: cursor, + onActiveIndexChange: setCursor, + onActivate: reveal, + wrap: false, + typeahead: (i) => entries[i]?.text ?? "", + }); + + if (!hasNote || entries.length === 0) { + return ( + + ); + } + + const base = outlineBaseLevel(entries); + return ( +
    + {entries.map((entry, index) => ( +
  • + +
  • + ))} +
+ ); +} diff --git a/apps/preview/src/app.test.tsx b/apps/preview/src/app.test.tsx index 64d637eb..faf57daa 100644 --- a/apps/preview/src/app.test.tsx +++ b/apps/preview/src/app.test.tsx @@ -236,3 +236,34 @@ describe("PreviewApp layout contract (F-317)", () => { await unmount(); }); }); + +describe("PreviewApp — auto-hide sidebar (7.15)", () => { + afterEach(() => { + document.documentElement.removeAttribute("data-bs-autohide-sidebars"); + }); + + it("without the preference, collapsing takes the library out of the tab order", async () => { + const { container, unmount } = await renderInto(); + await flush(); + const toggle = container.querySelector('[data-testid="sidebar-toggle"]'); + await act(async () => toggle?.click()); + const sidebar = container.querySelector(".preview__sidebar"); + expect(sidebar?.hasAttribute("inert")).toBe(true); + expect(sidebar?.hasAttribute("data-bs-autohide")).toBe(false); + expect(container.querySelector(".bs-autohide-edge")).toBeNull(); + await unmount(); + }); + + it("with the preference on, a collapsed library becomes a hidden overlay with an edge hot-zone", async () => { + document.documentElement.setAttribute("data-bs-autohide-sidebars", "on"); + const { container, unmount } = await renderInto(); + await flush(); + const toggle = container.querySelector('[data-testid="sidebar-toggle"]'); + await act(async () => toggle?.click()); + const sidebar = container.querySelector(".preview__sidebar"); + expect(sidebar?.getAttribute("data-bs-autohide")).toBe("hidden"); + expect(sidebar?.hasAttribute("inert")).toBe(true); + expect(container.querySelector(".bs-autohide-edge")).not.toBeNull(); + await unmount(); + }); +}); diff --git a/apps/preview/src/app.tsx b/apps/preview/src/app.tsx index 817aad99..81e2dba1 100644 --- a/apps/preview/src/app.tsx +++ b/apps/preview/src/app.tsx @@ -13,16 +13,18 @@ */ import { RightPanelTab } from "@brainstorm-os/editor"; +import { LiveRegion } from "@brainstorm-os/sdk/a11y"; import { EmptyState } from "@brainstorm-os/sdk/empty-state"; import { requestSaveBytes } from "@brainstorm-os/sdk/export-file"; import { IconName } from "@brainstorm-os/sdk/icon"; import { readPanelOpen, writePanelOpen } from "@brainstorm-os/sdk/panel-state"; +import { PanelSide, useAutoHidePanel } from "@brainstorm-os/sdk/panel-toggle"; import { PresenceStack, usePresence, useSelf } from "@brainstorm-os/sdk/presence-stack"; import { type ReactElement, useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { PreviewFile } from "./demo/dataset"; import { type GalleryState, resolveOpenPayload, toPreviewFiles } from "./host/apply-open"; import { chipLabelFor } from "./host/context-label"; -import { indexOfId, jumpTo, step } from "./host/host-state"; +import { indexOfId, jumpTo, removeById, step } from "./host/host-state"; import { type OpenPayload, type PreviewContextSibling, @@ -171,6 +173,16 @@ export function PreviewApp(): ReactElement { [navigateTo], ); + // 9.20.15 — a file moved to the Bin from the inspector leaves the gallery; + // the cursor stays on whatever slid into its slot. + const removeFromGallery = useCallback((id: string): void => { + const next = removeById({ siblings: siblingsRef.current, cursor: cursorRef.current }, id); + if (next.siblings === siblingsRef.current) return; + setPairs(EMPTY_PAIRS); + setSiblings(next.siblings); + setCursor(next.cursor); + }, []); + const toggleInspector = useCallback( () => setInspectorOpen((v) => { @@ -289,6 +301,8 @@ export function PreviewApp(): ReactElement { }; }, [navigateTo, focusById, applyGallery]); + const sidebarAutoHide = useAutoHidePanel({ side: PanelSide.Left, collapsed: !sidebarOpen }); + const chip = useMemo(() => chipLabelFor(context, total), [context, total]); const counterText = total > 0 ? t("counter.position", { index: cursor + 1, total }) : t("counter.empty"); @@ -304,6 +318,7 @@ export function PreviewApp(): ReactElement { return ( <> +
- + + {sidebarAutoHide.edgeProps &&
}
@@ -358,6 +380,7 @@ export function PreviewApp(): ReactElement { activeTab={rightTab} onTabChange={setRightTab} onClose={closeInspector} + onDeleted={removeFromGallery} />
diff --git a/apps/preview/src/host/host-state.test.ts b/apps/preview/src/host/host-state.test.ts index 98ef50cd..b8459ac9 100644 --- a/apps/preview/src/host/host-state.test.ts +++ b/apps/preview/src/host/host-state.test.ts @@ -3,7 +3,7 @@ */ import { describe, expect, it } from "vitest"; import type { PreviewFile } from "../demo/dataset"; -import { activeFile, indexOfId, initState, jumpTo, step } from "./host-state"; +import { activeFile, indexOfId, initState, jumpTo, removeById, step } from "./host-state"; function fakeFile(id: string): PreviewFile { return { @@ -56,4 +56,16 @@ describe("host-state", () => { expect(indexOfId(s, "b")).toBe(1); expect(indexOfId(s, "missing")).toBe(-1); }); + + it("removeById keeps the cursor on the file that slides into the removed slot", () => { + const files = [fakeFile("a"), fakeFile("b"), fakeFile("c")]; + expect(activeFile(removeById(initState(files, 1), "b"))?.id).toBe("c"); + expect(activeFile(removeById(initState(files, 2), "c"))?.id).toBe("b"); + expect(activeFile(removeById(initState(files, 2), "a"))?.id).toBe("c"); + const emptied = removeById(initState([fakeFile("a")], 0), "a"); + expect(emptied.siblings).toHaveLength(0); + expect(activeFile(emptied)).toBeNull(); + const same = initState(files, 0); + expect(removeById(same, "zzz")).toBe(same); + }); }); diff --git a/apps/preview/src/host/host-state.ts b/apps/preview/src/host/host-state.ts index 66271117..f5599bcb 100644 --- a/apps/preview/src/host/host-state.ts +++ b/apps/preview/src/host/host-state.ts @@ -50,6 +50,17 @@ export function indexOfId(state: HostState, id: string): number { return -1; } +/** Drop a sibling (e.g. it was moved to the Bin) and keep the cursor on the + * file that slid into its place — the next one, or the new last one when + * the removed file was last. Unknown ids leave the state unchanged. */ +export function removeById(state: HostState, id: string): HostState { + const index = indexOfId(state, id); + if (index < 0) return state; + const siblings = state.siblings.filter((_, i) => i !== index); + const cursor = state.cursor > index ? state.cursor - 1 : state.cursor; + return { siblings, cursor: clampCursor(cursor, siblings.length) }; +} + function wrap(n: number, len: number): number { if (len <= 0) return 0; return ((n % len) + len) % len; diff --git a/apps/preview/src/i18n/de.json b/apps/preview/src/i18n/de.json index a89853ed..b613e9ea 100644 --- a/apps/preview/src/i18n/de.json +++ b/apps/preview/src/i18n/de.json @@ -76,5 +76,14 @@ "inspector.properties": "Eigenschaften", "inspector.noProperties": "Noch keine Eigenschaften", "inspector.addProperty": "Eigenschaft hinzufügen", - "inspector.removeProperty": "{name} entfernen" + "inspector.removeProperty": "{name} entfernen", + "inspector.caption": "Bildunterschrift", + "inspector.linkedFrom.one": "Von {count} Objekt verlinkt", + "inspector.linkedFrom.other": "Von {count} Objekten verlinkt", + "inspector.noBacklinks": "Noch nichts verlinkt hierher", + "inspector.openInNewTab": "„{name}“ in neuem Tab öffnen", + "inspector.moveToBin": "In den Papierkorb verschieben", + "inspector.lockedHint": "Entsperren Sie diese Datei, um sie zu ändern", + "inspector.deleted": "„{name}“ in den Papierkorb verschoben", + "inspector.deleteFailed": "„{name}“ konnte nicht in den Papierkorb verschoben werden" } diff --git a/apps/preview/src/i18n/en.json b/apps/preview/src/i18n/en.json index d52a27c6..7f26e73d 100644 --- a/apps/preview/src/i18n/en.json +++ b/apps/preview/src/i18n/en.json @@ -76,5 +76,14 @@ "inspector.properties": "Properties", "inspector.noProperties": "No properties yet", "inspector.addProperty": "Add property", - "inspector.removeProperty": "Remove {name}" + "inspector.removeProperty": "Remove {name}", + "inspector.caption": "Caption", + "inspector.linkedFrom.one": "Linked from {count} object", + "inspector.linkedFrom.other": "Linked from {count} objects", + "inspector.noBacklinks": "Nothing links here yet", + "inspector.openInNewTab": "Open “{name}” in a new tab", + "inspector.moveToBin": "Move to Bin", + "inspector.lockedHint": "Unlock this file to change it", + "inspector.deleted": "Moved “{name}” to the Bin", + "inspector.deleteFailed": "Could not move “{name}” to the Bin" } diff --git a/apps/preview/src/i18n/es.json b/apps/preview/src/i18n/es.json index 3a4da523..5d309b2f 100644 --- a/apps/preview/src/i18n/es.json +++ b/apps/preview/src/i18n/es.json @@ -76,5 +76,14 @@ "inspector.properties": "Propiedades", "inspector.noProperties": "Aún no hay propiedades", "inspector.addProperty": "Añadir propiedad", - "inspector.removeProperty": "Eliminar {name}" + "inspector.removeProperty": "Eliminar {name}", + "inspector.caption": "Pie de foto", + "inspector.linkedFrom.one": "Enlazado desde {count} objeto", + "inspector.linkedFrom.other": "Enlazado desde {count} objetos", + "inspector.noBacklinks": "Aún no hay nada enlazado aquí", + "inspector.openInNewTab": "Abrir «{name}» en una pestaña nueva", + "inspector.moveToBin": "Mover a la papelera", + "inspector.lockedHint": "Desbloquea este archivo para cambiarlo", + "inspector.deleted": "Se movió «{name}» a la papelera", + "inspector.deleteFailed": "No se pudo mover «{name}» a la papelera" } diff --git a/apps/preview/src/i18n/fr.json b/apps/preview/src/i18n/fr.json index 9eabe9f2..2d22a6cc 100644 --- a/apps/preview/src/i18n/fr.json +++ b/apps/preview/src/i18n/fr.json @@ -76,5 +76,14 @@ "inspector.properties": "Propriétés", "inspector.noProperties": "Pas encore de propriétés", "inspector.addProperty": "Ajouter une propriété", - "inspector.removeProperty": "Retirer {name}" + "inspector.removeProperty": "Retirer {name}", + "inspector.caption": "Légende", + "inspector.linkedFrom.one": "Lié depuis {count} objet", + "inspector.linkedFrom.other": "Lié depuis {count} objets", + "inspector.noBacklinks": "Rien ne pointe encore ici", + "inspector.openInNewTab": "Ouvrir « {name} » dans un nouvel onglet", + "inspector.moveToBin": "Placer dans la corbeille", + "inspector.lockedHint": "Déverrouillez ce fichier pour le modifier", + "inspector.deleted": "« {name} » placé dans la corbeille", + "inspector.deleteFailed": "Impossible de placer « {name} » dans la corbeille" } diff --git a/apps/preview/src/i18n/it.json b/apps/preview/src/i18n/it.json index 058e5151..58d9781f 100644 --- a/apps/preview/src/i18n/it.json +++ b/apps/preview/src/i18n/it.json @@ -76,5 +76,14 @@ "inspector.properties": "Proprietà", "inspector.noProperties": "Ancora nessuna proprietà", "inspector.addProperty": "Aggiungi proprietà", - "inspector.removeProperty": "Rimuovi {name}" + "inspector.removeProperty": "Rimuovi {name}", + "inspector.caption": "Didascalia", + "inspector.linkedFrom.one": "Collegato da {count} oggetto", + "inspector.linkedFrom.other": "Collegato da {count} oggetti", + "inspector.noBacklinks": "Ancora nulla rimanda qui", + "inspector.openInNewTab": "Apri “{name}” in una nuova scheda", + "inspector.moveToBin": "Sposta nel cestino", + "inspector.lockedHint": "Sblocca questo file per modificarlo", + "inspector.deleted": "“{name}” spostato nel cestino", + "inspector.deleteFailed": "Impossibile spostare “{name}” nel cestino" } diff --git a/apps/preview/src/i18n/pt.json b/apps/preview/src/i18n/pt.json index 0155b8be..4649fd4c 100644 --- a/apps/preview/src/i18n/pt.json +++ b/apps/preview/src/i18n/pt.json @@ -76,5 +76,14 @@ "inspector.properties": "Propriedades", "inspector.noProperties": "Ainda sem propriedades", "inspector.addProperty": "Adicionar propriedade", - "inspector.removeProperty": "Remover {name}" + "inspector.removeProperty": "Remover {name}", + "inspector.caption": "Legenda", + "inspector.linkedFrom.one": "Vinculado a partir de {count} objeto", + "inspector.linkedFrom.other": "Vinculado a partir de {count} objetos", + "inspector.noBacklinks": "Nada aponta para cá ainda", + "inspector.openInNewTab": "Abrir “{name}” em uma nova aba", + "inspector.moveToBin": "Mover para a lixeira", + "inspector.lockedHint": "Desbloqueie este arquivo para alterá-lo", + "inspector.deleted": "“{name}” movido para a lixeira", + "inspector.deleteFailed": "Não foi possível mover “{name}” para a lixeira" } diff --git a/apps/preview/src/logic/entity-to-file.test.ts b/apps/preview/src/logic/entity-to-file.test.ts index 63030012..175f5664 100644 --- a/apps/preview/src/logic/entity-to-file.test.ts +++ b/apps/preview/src/logic/entity-to-file.test.ts @@ -69,4 +69,32 @@ describe("entityToPreviewFile", () => { entityToPreviewFile({ id: "ent_4", properties: { name: "x", assetId: "asset_z" } }), ).toBeNull(); }); + + test("reads Modified from the entity's own updatedAt when the row carries none", () => { + // A freshly imported `File/v1` row has no `properties.updatedAt`; its + // recency lives on the envelope, which is what Files' "Today" reads. + const file = entityToPreviewFile({ + id: "ent_5", + updatedAt: 1_700_000_000_000, + properties: { name: "a.png", assetId: "asset_a", assetMime: "image/png" }, + }); + expect(file?.info.modifiedAt).toBe(1_700_000_000_000); + }); + + test("prefers the envelope updatedAt over a stale property copy", () => { + const file = entityToPreviewFile({ + id: "ent_6", + updatedAt: 2_000, + properties: { name: "a.png", assetId: "asset_a", assetMime: "image/png", updatedAt: 1_000 }, + }); + expect(file?.info.modifiedAt).toBe(2_000); + }); + + test("still reads a property updatedAt when the envelope has none", () => { + const file = entityToPreviewFile({ + id: "ent_7", + properties: { name: "a.png", assetId: "asset_a", assetMime: "image/png", updatedAt: 1_000 }, + }); + expect(file?.info.modifiedAt).toBe(1_000); + }); }); diff --git a/apps/preview/src/logic/entity-to-file.ts b/apps/preview/src/logic/entity-to-file.ts index 2ebeb885..1885d7fc 100644 --- a/apps/preview/src/logic/entity-to-file.ts +++ b/apps/preview/src/logic/entity-to-file.ts @@ -30,6 +30,11 @@ import type { PreviewFile } from "../demo/dataset"; * typed and the only fields that matter are these. */ export type ResolvableEntity = { id?: unknown; + /** The envelope's own modification time. A `File/v1` row from Files + * carries recency HERE, not in `properties` — reading only the property + * showed "Modified —" for every freshly imported file while Files said + * "Today". */ + updatedAt?: unknown; properties?: Record | null; }; @@ -91,7 +96,7 @@ export function entityToPreviewFile( const name = readString(props.name) ?? id; const sizeBytes = readNumberOrNull(props.size); - const modifiedAt = readNumberOrNull(props.updatedAt); + const modifiedAt = readNumberOrNull(entity.updatedAt) ?? readNumberOrNull(props.updatedAt); return { id, diff --git a/apps/preview/src/logic/entity-values.ts b/apps/preview/src/logic/entity-values.ts index 61194623..da8382b7 100644 --- a/apps/preview/src/logic/entity-values.ts +++ b/apps/preview/src/logic/entity-values.ts @@ -46,3 +46,16 @@ export function entityLockedFromSnapshot( ): boolean { return isEntityLocked(liveEntity(snapshot, entityId)); } + +/** The File entity's top-level caption property (9.20.15). */ +export const CAPTION_KEY = "caption"; + +export function entityCaptionFromSnapshot( + snapshot: VaultEntitiesSnapshot, + entityId: string | null, +): string { + const caption = ( + liveEntity(snapshot, entityId)?.properties as Record | undefined + )?.[CAPTION_KEY]; + return typeof caption === "string" ? caption : ""; +} diff --git a/apps/preview/src/styles.css b/apps/preview/src/styles.css index 2854d36e..2922ba7c 100644 --- a/apps/preview/src/styles.css +++ b/apps/preview/src/styles.css @@ -59,6 +59,7 @@ button { grid-template-columns: auto minmax(0, 1fr); grid-template-rows: 1fr; overflow: hidden; + position: relative; /* anchor for the auto-hide sidebar overlay (7.15) */ } /* Main column: the stage/inspector body over the filmstrip — the original @@ -90,7 +91,7 @@ button { overflow: hidden; } -.preview--sidebar-collapsed .preview__sidebar { +.preview--sidebar-collapsed .preview__sidebar:not([data-bs-autohide]) { display: none; } @@ -367,6 +368,71 @@ button { visibility: hidden; } +/* Context sections under the property rows (9.20.15): backlinks, then the + * Move-to-Bin action. Each is its own hairline-separated block in the panel's + * scrolling body. */ +.preview-context { + display: flex; + flex-direction: column; + gap: var(--space-1); + padding-block: var(--space-3); + border-top: var(--border-width) solid var(--border); + flex-shrink: 0; +} + +.preview-context__heading { + margin: 0; + color: var(--text-dim); + font-size: var(--text-size-sm); + font-weight: var(--text-weight-medium); +} + +.preview-context__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-0_5); +} + +.preview-context__link { + display: flex; + align-items: center; + gap: var(--space-2); + width: 100%; + padding: var(--space-1) var(--space-2); + border: 0; + border-radius: var(--radius-md); + background: transparent; + color: var(--text); + font: inherit; + text-align: start; + cursor: pointer; +} + +.preview-context__link:hover, +.preview-context__link:focus-visible { + background: var(--hover); +} + +.preview-context__link-title { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.preview-context__link-glyph { + flex-shrink: 0; + color: var(--text-dim); +} + +.preview-context__bin { + align-self: flex-start; +} + /* ── Filmstrip ──────────────────────────────────────────────────────── */ .preview__filmstrip { diff --git a/apps/preview/src/ui/file-sidebar.tsx b/apps/preview/src/ui/file-sidebar.tsx index 4133f3d8..d3265de7 100644 --- a/apps/preview/src/ui/file-sidebar.tsx +++ b/apps/preview/src/ui/file-sidebar.tsx @@ -14,6 +14,7 @@ import { Orientation, useCompositeKeyboard } from "@brainstorm-os/sdk/a11y"; import { Icon, IconName } from "@brainstorm-os/sdk/icon"; +import type { AutoHidePanelProps } from "@brainstorm-os/sdk/panel-toggle"; import { type ReactElement, useCallback, useMemo, useState } from "react"; import type { PreviewFile } from "../demo/dataset"; import { t } from "../i18n"; @@ -24,10 +25,16 @@ export function FileSidebar({ files, activeId, onOpen, + open = true, + autoHide, }: { files: readonly PreviewFile[]; activeId: string | null; onOpen: (file: PreviewFile) => void; + /** The library is on screen — pinned open, or revealed by auto-hide. */ + open?: boolean; + /** 7.15 — auto-hide overlay wiring from `useAutoHidePanel`. */ + autoHide?: AutoHidePanelProps; }): ReactElement { const [query, setQuery] = useState(""); const visible = useMemo(() => filterPreviewFiles(files, query), [files, query]); @@ -55,7 +62,13 @@ export function FileSidebar({ }); return ( -
+ ); +} + +describe("useAutoHidePanel (7.15)", () => { + let host: HTMLDivElement; + let root: Root; + + beforeEach(() => { + vi.useFakeTimers(); + host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(() => { + act(() => root.unmount()); + host.remove(); + document.documentElement.removeAttribute(AUTO_HIDE_SIDEBARS_ATTRIBUTE); + vi.useRealTimers(); + }); + + const panel = () => host.querySelector('[data-testid="panel"]') as HTMLElement; + const edge = () => host.querySelector('[data-testid="edge"]'); + + it("is inert while the preference is off: no edge, no overlay face", () => { + act(() => root.render()); + expect(edge()).toBeNull(); + expect(panel().hasAttribute("data-bs-autohide")).toBe(false); + expect(panel().getAttribute("aria-hidden")).toBe("true"); + }); + + it("a pinned-open sidebar is untouched even with the preference on", () => { + document.documentElement.setAttribute(AUTO_HIDE_SIDEBARS_ATTRIBUTE, "on"); + act(() => root.render()); + expect(edge()).toBeNull(); + expect(latest?.visible).toBe(true); + }); + + it("collapsed + preference on: hidden overlay stays out of the tab order until edge hover reveals it", () => { + document.documentElement.setAttribute(AUTO_HIDE_SIDEBARS_ATTRIBUTE, "on"); + act(() => root.render()); + expect(panel().getAttribute("data-bs-autohide")).toBe(AutoHideFace.Hidden); + expect(panel().hasAttribute("inert")).toBe(true); + act(() => latest?.edgeProps?.onPointerEnter()); + act(() => vi.advanceTimersByTime(50)); + expect(panel().getAttribute("data-bs-autohide")).toBe(AutoHideFace.Revealed); + expect(panel().hasAttribute("inert")).toBe(false); + expect(panel().getAttribute("aria-hidden")).toBe("false"); + }); + + it("the shortcut path reveals and moves focus in; Escape hides", () => { + document.documentElement.setAttribute(AUTO_HIDE_SIDEBARS_ATTRIBUTE, "on"); + act(() => root.render()); + act(() => latest?.toggle()); + expect(document.activeElement?.textContent).toBe("First"); + act(() => { + panel().dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + }); + expect(panel().getAttribute("data-bs-autohide")).toBe(AutoHideFace.Hidden); + }); + + it("follows the preference live when the shell flips the root attribute", async () => { + act(() => root.render()); + expect(latest?.active).toBe(false); + await act(async () => { + document.documentElement.setAttribute(AUTO_HIDE_SIDEBARS_ATTRIBUTE, "on"); + await Promise.resolve(); + }); + expect(latest?.active).toBe(true); + }); +}); diff --git a/packages/sdk/src/panel-toggle/use-auto-hide-panel.ts b/packages/sdk/src/panel-toggle/use-auto-hide-panel.ts new file mode 100644 index 00000000..31450e76 --- /dev/null +++ b/packages/sdk/src/panel-toggle/use-auto-hide-panel.ts @@ -0,0 +1,179 @@ +/** + * React binding for the auto-hide sidebar mode (7.15) — see `auto-hide.ts`. + * + * An app opts in by spreading `panelProps` on its sidebar element, rendering + * the edge hot-zone from `edgeProps`, driving the sidebar's `inert` / + * `aria-hidden` from `visible`, and calling `toggle` from its sidebar shortcut + * while `active`. Everything is inert while the sidebar is open or the user + * has auto-hide off, so adoption never changes the pinned-open behaviour. + */ + +import { + type FocusEvent, + type RefCallback, + useCallback, + useEffect, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { attachShortcut } from "../shortcut"; +import { + AUTO_HIDE_SIDEBARS_ATTRIBUTE, + AutoHideFace, + createAutoHideController, + readAutoHideSidebarsPreference, +} from "./auto-hide"; +import type { PanelSide } from "./panel-toggle-icon"; + +const HIDE_CHORD = "Escape"; +const FOCUSABLE = + 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable="true"]'; + +function subscribePreference(onChange: () => void): () => void { + if (typeof document === "undefined" || typeof MutationObserver === "undefined") { + return () => undefined; + } + const observer = new MutationObserver(onChange); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: [AUTO_HIDE_SIDEBARS_ATTRIBUTE], + }); + return () => observer.disconnect(); +} + +const readPreference = () => readAutoHideSidebarsPreference(); +const readPreferenceOnServer = () => false; + +/** Live Settings → Appearance → "Auto-hide sidebars" for this renderer. */ +export function useAutoHideSidebarsPreference(): boolean { + return useSyncExternalStore(subscribePreference, readPreference, readPreferenceOnServer); +} + +export type UseAutoHidePanelOptions = { + side: PanelSide; + /** The app's own collapsed state (its panel toggle is off). */ + collapsed: boolean; + /** Override the shell preference (tests, an app-level setting). */ + enabled?: boolean; + revealDelayMs?: number; + hideDelayMs?: number; +}; + +export type AutoHidePanelProps = { + ref?: RefCallback; + "data-bs-autohide"?: AutoHideFace; + "data-bs-side"?: PanelSide; + onPointerEnter?: () => void; + onPointerLeave?: () => void; + onFocus?: () => void; + onBlur?: (event: FocusEvent) => void; +}; + +export type AutoHideEdgeProps = { + className: string; + "data-bs-side": PanelSide; + "aria-hidden": true; + onPointerEnter: () => void; + onPointerLeave: () => void; +}; + +export type AutoHidePanel = { + /** Auto-hide governs the panel right now (on, and the panel is collapsed). */ + active: boolean; + revealed: boolean; + /** The panel's content is on screen — pinned open, or revealed. Drive the + * panel's `inert` / `aria-hidden` from this so a hidden overlay stays out + * of the tab order and the accessibility tree. */ + visible: boolean; + panelProps: AutoHidePanelProps; + /** The edge hot-zone to render beside the panel, or `null` when inactive. */ + edgeProps: AutoHideEdgeProps | null; + /** Reveal (moving focus in) or hide — the app's sidebar shortcut. */ + toggle: () => void; + hide: () => void; +}; + +export function useAutoHidePanel(options: UseAutoHidePanelOptions): AutoHidePanel { + const preference = useAutoHideSidebarsPreference(); + const active = (options.enabled ?? preference) && options.collapsed; + const [revealed, setRevealed] = useState(false); + const panelRef = useRef(null); + const focusOnReveal = useRef(false); + const [controller] = useState(() => + createAutoHideController({ + onChange: setRevealed, + ...(options.revealDelayMs !== undefined ? { revealDelayMs: options.revealDelayMs } : {}), + ...(options.hideDelayMs !== undefined ? { hideDelayMs: options.hideDelayMs } : {}), + }), + ); + + useEffect(() => () => controller.dispose(), [controller]); + + useEffect(() => { + if (!active) controller.hide(); + }, [active, controller]); + + useEffect(() => { + if (!revealed || !focusOnReveal.current) return; + focusOnReveal.current = false; + panelRef.current?.querySelector(FOCUSABLE)?.focus(); + }, [revealed]); + + useEffect(() => { + const panel = panelRef.current; + if (!active || !revealed || !panel) return; + return attachShortcut(panel, HIDE_CHORD, () => controller.hide()); + }, [active, revealed, controller]); + + const setPanel = useCallback>((el) => { + panelRef.current = el; + }, []); + + const toggle = useCallback(() => { + if (!controller.revealed) focusOnReveal.current = true; + controller.toggle(); + }, [controller]); + + const hide = useCallback(() => controller.hide(), [controller]); + + if (!active) { + return { + active: false, + revealed: false, + visible: !options.collapsed, + panelProps: { ref: setPanel }, + edgeProps: null, + toggle, + hide, + }; + } + + return { + active: true, + revealed, + visible: revealed, + panelProps: { + ref: setPanel, + "data-bs-autohide": revealed ? AutoHideFace.Revealed : AutoHideFace.Hidden, + "data-bs-side": options.side, + onPointerEnter: () => controller.panelEnter(), + onPointerLeave: () => controller.panelLeave(), + onFocus: () => controller.focusIn(), + onBlur: (event) => { + const next = event.relatedTarget; + if (next instanceof Node && event.currentTarget.contains(next)) return; + controller.focusOut(); + }, + }, + edgeProps: { + className: "bs-autohide-edge", + "data-bs-side": options.side, + "aria-hidden": true, + onPointerEnter: () => controller.edgeEnter(), + onPointerLeave: () => controller.edgeLeave(), + }, + toggle, + hide, + }; +} diff --git a/packages/sdk/src/picker-host.test.tsx b/packages/sdk/src/picker-host.test.tsx index 2475f12b..f1bd269c 100644 --- a/packages/sdk/src/picker-host.test.tsx +++ b/packages/sdk/src/picker-host.test.tsx @@ -56,6 +56,7 @@ const labels = { formatUrl: "URL", formatEmail: "Email", formatPhone: "Phone", + formatRichText: "Rich text", formatCurrency: "Currency", formatPercent: "Percent", formatDuration: "Duration", @@ -106,6 +107,20 @@ describe("openInlinePropertyForm", () => { } }); + it("builds every text input on the shared .bs-input face (F-511e)", () => { + try { + open(); + const inputs = Array.from( + document.querySelectorAll('.bs-inline-property-form input[type="text"]'), + ); + expect(inputs.length).toBeGreaterThan(0); + for (const input of inputs) + expect(input.classList.contains("bs-input"), input.outerHTML).toBe(true); + } finally { + teardown(); + } + }); + it("clicking the backdrop closes the picker and fires onCancel", () => { const onCancel = vi.fn(); try { diff --git a/packages/sdk/src/picker-host.tsx b/packages/sdk/src/picker-host.tsx index f3235ee5..1d5b2592 100644 --- a/packages/sdk/src/picker-host.tsx +++ b/packages/sdk/src/picker-host.tsx @@ -31,7 +31,7 @@ import { type InlinePropertyFormCommit, type InlinePropertyFormLabels, } from "./inline-property-form"; -import type { RelationTargetType } from "./inline-property-form-logic"; +import type { InverseCandidate, RelationTargetType } from "./inline-property-form-logic"; let container: HTMLElement | null = null; let root: Root | null = null; @@ -293,6 +293,9 @@ export function openInlinePropertyForm(opts: { /** Forwarded to `` — entity types a Relation can * target (surfaces the "Links to" picker). */ relationTargetTypes?: readonly RelationTargetType[]; + /** Forwarded to `` — relations a new Relation can + * pair with (surfaces the "Two-way with" picker). */ + inverseCandidates?: readonly InverseCandidate[]; }): void { ensureInlinePropertyHostStyles(); const r = ensureRoot(); @@ -303,6 +306,7 @@ export function openInlinePropertyForm(opts: { {...(opts.onCancel ? { onCancel: opts.onCancel } : {})} {...(opts.autoFocus !== undefined ? { autoFocus: opts.autoFocus } : {})} {...(opts.relationTargetTypes ? { relationTargetTypes: opts.relationTargetTypes } : {})} + {...(opts.inverseCandidates ? { inverseCandidates: opts.inverseCandidates } : {})} />, ); } @@ -317,6 +321,7 @@ function InlinePropertyHost(props: { onCancel?: () => void; autoFocus?: boolean; relationTargetTypes?: readonly RelationTargetType[]; + inverseCandidates?: readonly InverseCandidate[]; }) { const handleCancel = (): void => { // Capture the caller's hook BEFORE `closePicker()` triggers the @@ -347,6 +352,7 @@ function InlinePropertyHost(props: { onCancel={handleCancel} {...(props.autoFocus !== undefined ? { autoFocus: props.autoFocus } : {})} {...(props.relationTargetTypes ? { relationTargetTypes: props.relationTargetTypes } : {})} + {...(props.inverseCandidates ? { inverseCandidates: props.inverseCandidates } : {})} />
diff --git a/packages/sdk/src/predicate-eval/evaluate-predicate.ts b/packages/sdk/src/predicate-eval/evaluate-predicate.ts index 9b61ed80..26786be7 100644 --- a/packages/sdk/src/predicate-eval/evaluate-predicate.ts +++ b/packages/sdk/src/predicate-eval/evaluate-predicate.ts @@ -15,11 +15,31 @@ * truth-table for both without divergence. */ -import { isPropertyRef } from "@brainstorm-os/sdk-types"; -import type { PropertyPredicate, ScalarValue } from "@brainstorm-os/sdk-types"; +import { isPropertyRef, predicateReferencesHost } from "@brainstorm-os/sdk-types"; +import type { PropertyPredicate, PropertyRef, ScalarValue } from "@brainstorm-os/sdk-types"; import { type EntityRow, readPropertyPath } from "../in-memory-entities"; import { isInRelativeRange, isRelativeDateRange } from "./relative-date"; +/** Ambient inputs a predicate may reference (9.12.24). `hostEntityId` is the + * entity an embedded view renders inside — what a `{ $host: true }` ref + * resolves to. Absent ⇒ every host-relative predicate matches nothing. */ +export type PredicateContext = { + readonly hostEntityId?: string; +}; + +const NO_CONTEXT: PredicateContext = Object.freeze({}); + +const hostRefCache = new WeakMap(); + +function referencesHost(pred: PropertyPredicate): boolean { + let hit = hostRefCache.get(pred); + if (hit === undefined) { + hit = predicateReferencesHost(pred); + hostRefCache.set(pred, hit); + } + return hit; +} + /** * Evaluate a `PropertyPredicate` against a single entity. * @@ -27,42 +47,66 @@ import { isInRelativeRange, isRelativeDateRange } from "./relative-date"; * resolves against a single consistent clock for the whole pass — and so tests * can pin a fixed reference. It defaults to the current time, which is what * makes a "Last 7 days" view filter re-roll every time it recompiles. + * + * `ctx` supplies the host entity a `$host` ref resolves to. A host-relative + * predicate with no host is rejected as a whole (not per leaf), so negation + * can never turn "unknown host" into "every row". */ export function evaluatePredicate( entity: EntityRow, pred: PropertyPredicate, now: number = Date.now(), + ctx: PredicateContext = NO_CONTEXT, ): boolean { - if ("$and" in pred) return pred.$and.every((p) => evaluatePredicate(entity, p, now)); - if ("$or" in pred) return pred.$or.some((p) => evaluatePredicate(entity, p, now)); - if ("$not" in pred) return !evaluatePredicate(entity, pred.$not, now); + if (ctx.hostEntityId === undefined && referencesHost(pred)) return false; + return evaluateNode(entity, pred, now, ctx); +} + +function evaluateNode( + entity: EntityRow, + pred: PropertyPredicate, + now: number, + ctx: PredicateContext, +): boolean { + if ("$and" in pred) return pred.$and.every((p) => evaluateNode(entity, p, now, ctx)); + if ("$or" in pred) return pred.$or.some((p) => evaluateNode(entity, p, now, ctx)); + if ("$not" in pred) return !evaluateNode(entity, pred.$not, now, ctx); if ("$eq" in pred) return everyEntry(pred.$eq, (path, want) => - scalarEq(readScalar(entity, path), eqOperand(entity, want, now)), + scalarEq(readScalar(entity, path), operand(entity, want, now, ctx)), ); if ("$neq" in pred) return everyEntry( pred.$neq, - (path, want) => !scalarEq(readScalar(entity, path), eqOperand(entity, want, now)), + (path, want) => !scalarEq(readScalar(entity, path), operand(entity, want, now, ctx)), ); if ("$contains" in pred) return everyEntry(pred.$contains, (path, want) => - collectionContains(readPropertyPath(entity, path), want), + collectionContains(readPropertyPath(entity, path), containsOperand(entity, want, now, ctx)), ); if ("$notContains" in pred) return everyEntry( pred.$notContains, - (path, want) => !collectionContains(readPropertyPath(entity, path), want), + (path, want) => + !collectionContains(readPropertyPath(entity, path), containsOperand(entity, want, now, ctx)), ); if ("$gt" in pred) - return everyEntry(pred.$gt, (path, rhs) => signMatches(entity, path, rhs, now, (s) => s > 0)); + return everyEntry(pred.$gt, (path, rhs) => + signMatches(entity, path, rhs, now, ctx, (s) => s > 0), + ); if ("$lt" in pred) - return everyEntry(pred.$lt, (path, rhs) => signMatches(entity, path, rhs, now, (s) => s < 0)); + return everyEntry(pred.$lt, (path, rhs) => + signMatches(entity, path, rhs, now, ctx, (s) => s < 0), + ); if ("$gte" in pred) - return everyEntry(pred.$gte, (path, rhs) => signMatches(entity, path, rhs, now, (s) => s >= 0)); + return everyEntry(pred.$gte, (path, rhs) => + signMatches(entity, path, rhs, now, ctx, (s) => s >= 0), + ); if ("$lte" in pred) - return everyEntry(pred.$lte, (path, rhs) => signMatches(entity, path, rhs, now, (s) => s <= 0)); + return everyEntry(pred.$lte, (path, rhs) => + signMatches(entity, path, rhs, now, ctx, (s) => s <= 0), + ); if ("$in" in pred) return everyEntry(pred.$in, (path, list) => { const v = readScalar(entity, path); @@ -132,11 +176,18 @@ function collectionContains(collection: unknown, want: ScalarValue): boolean { return collection.toLowerCase().includes(want.toLowerCase()); } if (Array.isArray(collection)) { - return collection.some((c) => scalarEq(c, want)); + return collection.some((c) => scalarEq(unwrapLabeled(c), want)); } return false; } +/** A multi-valued property stores `{ value, label? }` envelopes (a relation is + * `[{ value: "" }]`); membership compares the envelope's `value`. */ +function unwrapLabeled(item: unknown): unknown { + if (item && typeof item === "object" && "value" in item) return (item as { value: unknown }).value; + return item; +} + function collectAll(value: unknown): unknown[] { if (value === undefined || value === null) return []; if (Array.isArray(value)) return value; @@ -152,14 +203,37 @@ function compare(a: unknown, b: number | string): number { return 0; } -/** Resolve an `$eq`/`$neq` right-hand side (9.12.21): a `PropertyRef` reads - * another property — or the clock (`$now`) — on the same entity; a literal - * passes through unchanged. */ -function eqOperand(entity: EntityRow, operand: unknown, now: number): unknown { - if (isPropertyRef(operand)) { - return "$now" in operand ? now : readScalar(entity, operand.$prop); - } - return operand; +/** Resolve a `PropertyRef` to the value it stands for: another property on + * the same entity (`$prop`), the clock (`$now`), or the host entity's id + * (`$host`) — a literal id, so `x = $host` is exactly `x = ""`. */ +function resolveRef( + entity: EntityRow, + ref: PropertyRef, + now: number, + ctx: PredicateContext, +): unknown { + if ("$now" in ref) return now; + if ("$host" in ref) return ctx.hostEntityId ?? null; + return readScalar(entity, ref.$prop); +} + +/** Resolve an `$eq`/`$neq` right-hand side (9.12.21): a `PropertyRef` resolves + * through {@link resolveRef}; a literal passes through unchanged. */ +function operand(entity: EntityRow, rhs: unknown, now: number, ctx: PredicateContext): unknown { + return isPropertyRef(rhs) ? resolveRef(entity, rhs, now, ctx) : rhs; +} + +/** A `$contains` right-hand side resolved to the scalar the collection is + * searched for. A ref that resolves to a non-scalar never matches. */ +function containsOperand( + entity: EntityRow, + rhs: unknown, + now: number, + ctx: PredicateContext, +): ScalarValue { + const v = operand(entity, rhs, now, ctx); + if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return v; + return null; } /** Coerce a value to an orderable scalar: a property-ui `DateValue` `{at}` @@ -176,11 +250,13 @@ function asComparable(value: unknown): number | string | null { /** Resolve a comparison right-hand side to an orderable value — a literal, * another property (`$prop`), or the clock (`$now`). `null` ⇒ incomparable. */ -function comparand(entity: EntityRow, rhs: unknown, now: number): number | string | null { - if (isPropertyRef(rhs)) { - return "$now" in rhs ? now : asComparable(readScalar(entity, rhs.$prop)); - } - return asComparable(rhs); +function comparand( + entity: EntityRow, + rhs: unknown, + now: number, + ctx: PredicateContext, +): number | string | null { + return asComparable(operand(entity, rhs, now, ctx)); } /** True when both operands are comparable and their ordering satisfies `ok`. A @@ -191,10 +267,11 @@ function signMatches( path: string, rhs: unknown, now: number, + ctx: PredicateContext, ok: (sign: number) => boolean, ): boolean { const left = asComparable(readScalar(entity, path)); - const right = comparand(entity, rhs, now); + const right = comparand(entity, rhs, now, ctx); if (left === null || right === null) return false; return ok(compare(left, right)); } diff --git a/packages/sdk/src/predicate-eval/evaluate-source.ts b/packages/sdk/src/predicate-eval/evaluate-source.ts index 59cbf5be..c9b3f7af 100644 --- a/packages/sdk/src/predicate-eval/evaluate-source.ts +++ b/packages/sdk/src/predicate-eval/evaluate-source.ts @@ -19,21 +19,27 @@ import { } from "@brainstorm-os/sdk-types"; import { effectiveMembers } from "../collections"; import type { InMemoryVault } from "../in-memory-entities"; -import { evaluatePredicate } from "./evaluate-predicate"; +import { type PredicateContext, evaluatePredicate } from "./evaluate-predicate"; -export function evaluateSource(source: ListSource | null, db: InMemoryVault): Set { +/** `ctx` carries the host entity a `$host` ref in a `byFilter` source resolves + * to (9.12.24); without it a host-relative filter contributes no members. */ +export function evaluateSource( + source: ListSource | null, + db: InMemoryVault, + ctx?: PredicateContext, +): Set { if (source === null) return new Set(); switch (source.kind) { case ListSourceKind.ByType: return collectByType(db, source.types); case ListSourceKind.ByFilter: - return collectByFilter(db, source.where); + return collectByFilter(db, source.where, ctx); case ListSourceKind.ByLink: return collectByLink(db, source); case ListSourceKind.ByVocabulary: return collectByVocabulary(db, source.vocabularyId, source.values); case ListSourceKind.Composite: - return collectComposite(db, source); + return collectComposite(db, source, ctx); } } @@ -50,11 +56,13 @@ function collectByType(db: InMemoryVault, types: ReadonlyArray): Set[1], + ctx: PredicateContext | undefined, ): Set { const out = new Set(); + const now = Date.now(); for (const e of db.entities) { if (e.deletedAt !== null) continue; - if (evaluatePredicate(e, where)) out.add(e.id); + if (evaluatePredicate(e, where, now, ctx)) out.add(e.id); } return out; } @@ -129,9 +137,10 @@ function collectByVocabulary( function collectComposite( db: InMemoryVault, source: { op: CompositeOp; sources: ReadonlyArray }, + ctx: PredicateContext | undefined, ): Set { if (source.sources.length === 0) return new Set(); - const childSets = source.sources.map((s) => evaluateSource(s, db)); + const childSets = source.sources.map((s) => evaluateSource(s, db, ctx)); if (source.op === CompositeOp.And) { return intersectAll(childSets); } diff --git a/packages/sdk/src/predicate-eval/host-ref.test.ts b/packages/sdk/src/predicate-eval/host-ref.test.ts new file mode 100644 index 00000000..1eb20441 --- /dev/null +++ b/packages/sdk/src/predicate-eval/host-ref.test.ts @@ -0,0 +1,155 @@ +import { + CompositeOp, + type ListSource, + ListSourceKind, + type PropertyPredicate, + type PropertyRef, + type ScalarValue, + predicateReferencesHost, +} from "@brainstorm-os/sdk-types"; +import { describe, expect, it } from "vitest"; +import type { EntityRow, InMemoryVault } from "../in-memory-entities"; +import { evaluatePredicate } from "./evaluate-predicate"; +import { evaluateSource } from "./evaluate-source"; + +const HOST: { $host: true } = { $host: true }; + +function row(id: string, properties: Record): EntityRow { + return { id, type: "io.test/Book/v1", properties, createdAt: 0, updatedAt: 0, deletedAt: null }; +} + +/** mulberry32 — deterministic, so a failing seed reproduces exactly. fast-check + * is not a workspace dependency; this is the same generate-and-compare shape. */ +function rng(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const IDS = ["ent_a", "ent_b", "ent_c", "ent_d"] as const; +const PATHS = ["author", "editors", "title"] as const; +const LEAF_OPS = ["$eq", "$neq", "$contains", "$notContains", "$gt", "$lte"] as const; + +function pick(r: () => number, xs: readonly T[]): T { + return xs[Math.floor(r() * xs.length)] as T; +} + +function genEntity(r: () => number, i: number): EntityRow { + const props: Record = {}; + if (r() < 0.8) props.author = pick(r, IDS); + if (r() < 0.8) props.editors = IDS.filter(() => r() < 0.4); + if (r() < 0.5) props.title = pick(r, ["x", "ent_a", "y"]); + return row(`row_${i}`, props); +} + +/** A random predicate tree whose leaves compare against `rhs()` — called once + * per leaf so the host-ref and literal trees are built from one shape. */ +function genPredicate( + r: () => number, + depth: number, + rhs: (r: () => number) => ScalarValue | PropertyRef, +): PropertyPredicate { + const roll = r(); + if (depth > 0 && roll < 0.15) return { $not: genPredicate(r, depth - 1, rhs) }; + if (depth > 0 && roll < 0.3) + return { $and: [genPredicate(r, depth - 1, rhs), genPredicate(r, depth - 1, rhs)] }; + if (depth > 0 && roll < 0.45) + return { $or: [genPredicate(r, depth - 1, rhs), genPredicate(r, depth - 1, rhs)] }; + const op = pick(r, LEAF_OPS); + const path = pick(r, PATHS); + return { [op]: { [path]: rhs(r) } } as unknown as PropertyPredicate; +} + +/** Same seed ⇒ same tree; one variant with `$host` leaves, one with the literal. */ +function hostAndLiteral(seed: number, hostId: string): [PropertyPredicate, PropertyPredicate] { + const leaf = + (asHost: boolean) => + (r: () => number): ScalarValue | PropertyRef => { + if (r() < 0.6) return asHost ? HOST : hostId; + return pick(r, IDS); + }; + return [genPredicate(rng(seed), 3, leaf(true)), genPredicate(rng(seed), 3, leaf(false))]; +} + +describe("$host ref (9.12.24) — property tests", () => { + const entities = Array.from({ length: 24 }, (_, i) => genEntity(rng(1000 + i), i)); + + it("with no host, a host-relative predicate never matches — even negated", () => { + let checked = 0; + for (let seed = 1; seed <= 400; seed += 1) { + const [withHost] = hostAndLiteral(seed, "ent_a"); + if (!predicateReferencesHost(withHost)) continue; + for (const e of entities) { + expect(evaluatePredicate(e, withHost, 0)).toBe(false); + expect(evaluatePredicate(e, { $not: withHost }, 0)).toBe(false); + expect(evaluatePredicate(e, withHost, 0, {})).toBe(false); + } + checked += 1; + } + expect(checked).toBeGreaterThan(200); + }); + + it("with a host, `$host` is exactly the literal-id predicate", () => { + for (let seed = 1; seed <= 400; seed += 1) { + const hostId = pick(rng(seed * 7), IDS); + const [withHost, literal] = hostAndLiteral(seed, hostId); + for (const e of entities) { + expect(evaluatePredicate(e, withHost, 0, { hostEntityId: hostId })).toBe( + evaluatePredicate(e, literal, 0), + ); + } + } + }); +}); + +describe("$host ref — examples", () => { + const book = row("b1", { author: "page_1", editors: ["page_1", "page_2"] }); + + it("relation contains this page", () => { + const pred: PropertyPredicate = { $contains: { editors: HOST } }; + expect(evaluatePredicate(book, pred, 0, { hostEntityId: "page_2" })).toBe(true); + expect(evaluatePredicate(book, pred, 0, { hostEntityId: "page_9" })).toBe(false); + expect(evaluatePredicate(book, pred, 0)).toBe(false); + }); + + it("a multi-valued relation's { value } envelopes match by id", () => { + const multi = row("b2", { authors: [{ value: "page_1" }, { value: "page_3" }] }); + const pred: PropertyPredicate = { $contains: { authors: HOST } }; + expect(evaluatePredicate(multi, pred, 0, { hostEntityId: "page_3" })).toBe(true); + expect(evaluatePredicate(multi, pred, 0, { hostEntityId: "page_2" })).toBe(false); + }); + + it("scalar relation is this page", () => { + const pred: PropertyPredicate = { $eq: { author: HOST } }; + expect(evaluatePredicate(book, pred, 0, { hostEntityId: "page_1" })).toBe(true); + expect(evaluatePredicate(book, { $neq: { author: HOST } }, 0)).toBe(false); + }); + + it("a predicate without $host is unaffected by the missing host", () => { + expect(evaluatePredicate(book, { $eq: { author: "page_1" } }, 0)).toBe(true); + }); + + it("threads the host through evaluateSource (byFilter + composite)", () => { + const db: InMemoryVault = { + entities: [book, row("b2", { author: "page_2" })], + links: [], + }; + const byHost: ListSource = { + kind: ListSourceKind.ByFilter, + where: { $eq: { author: HOST } }, + }; + expect([...evaluateSource(byHost, db, { hostEntityId: "page_2" })]).toEqual(["b2"]); + expect(evaluateSource(byHost, db).size).toBe(0); + const composite: ListSource = { + kind: ListSourceKind.Composite, + op: CompositeOp.Or, + sources: [byHost, { kind: ListSourceKind.ByType, types: ["io.test/Book/v1"] }], + }; + expect(evaluateSource(composite, db).size).toBe(2); + }); +}); diff --git a/packages/sdk/src/predicate-eval/index.ts b/packages/sdk/src/predicate-eval/index.ts index 1aef0555..ab6e24ec 100644 --- a/packages/sdk/src/predicate-eval/index.ts +++ b/packages/sdk/src/predicate-eval/index.ts @@ -6,7 +6,7 @@ * membership semantics cannot drift between client and service. */ -export { evaluatePredicate } from "./evaluate-predicate"; +export { type PredicateContext, evaluatePredicate } from "./evaluate-predicate"; export { applyMemberOverrides, byLinkAnchors, diff --git a/packages/sdk/src/presence-stack/index.ts b/packages/sdk/src/presence-stack/index.ts index 055c5b4c..d3c9f60b 100644 --- a/packages/sdk/src/presence-stack/index.ts +++ b/packages/sdk/src/presence-stack/index.ts @@ -1,4 +1,5 @@ export { + PresenceActivity, type PresencePeer, type PresenceStackProps, type PresenceSummary, diff --git a/packages/sdk/src/presence-stack/presence-awareness.test.ts b/packages/sdk/src/presence-stack/presence-awareness.test.ts index 9ed2f740..197a9ab9 100644 --- a/packages/sdk/src/presence-stack/presence-awareness.test.ts +++ b/packages/sdk/src/presence-stack/presence-awareness.test.ts @@ -6,11 +6,30 @@ import { buildLocalPresence, peerFromState, } from "./presence-awareness"; +import { PresenceActivity } from "./presence-stack"; const payload = (id: string, name: string, color = "#2f6df6", avatarRef?: string) => ({ [PRESENCE_STATE_KEY]: { id, name, color, ...(avatarRef ? { avatarRef } : {}) }, }); +describe("presence activity (Chats-4)", () => { + const self = { pubkey: "pk", displayName: "Ada", fingerprint: "fp" }; + + it("round-trips a known activity through the payload", () => { + const built = buildLocalPresence(self, 3, PresenceActivity.Typing); + expect(built.activity).toBe(PresenceActivity.Typing); + expect(peerFromState({ [PRESENCE_STATE_KEY]: built })?.activity).toBe(PresenceActivity.Typing); + }); + + it("omits activity when idle and drops an unknown one from a peer", () => { + expect("activity" in buildLocalPresence(self, 3, null)).toBe(false); + const peer = peerFromState({ + [PRESENCE_STATE_KEY]: { id: "u1", name: "A", color: "#000", activity: "