From bd4b69e2de3767fff2dec2b6469b323633f92de4 Mon Sep 17 00:00:00 2001 From: Sal Rahman Date: Tue, 27 Oct 2026 18:21:01 -0700 Subject: [PATCH] Hand views only what they call, and drop what nothing uses (#118, #119) Views were handed more of the bridge than they used. ProjectView and PageView took all of PagesApi, LinkedReferences took all of it to pass on, and ProjectView declared an availability prop it never read, which App fetched for every project only to pass it. Each view's bridge prop is now what it calls and what the views it renders call: StepsPages beside the steps, PageViewPages as that plus search and deleteBlock (LinkedReferences takes the same, since each cut is a PageView), and ProjectViewPages as PageViewPages plus ProjectView's own ten calls, which is what App takes. ProjectView loses availability, and App stops fetching it. The projects:availability channel and the bridge stay. formatLastOpened and AVAILABILITY_LABEL in ui.ts were imported nowhere, and formatLastOpened was the renderer's one Date.now() outside index.tsx; ui.ts now holds only Act. getLogStatus returned view.status and nothing else: the session reads view.status, and the comment moves onto that field. getPage was already the one kept by #113. No behaviour changes beyond App no longer asking for reachability nothing shows. Co-Authored-By: Claude Opus 5.5 --- app/CLAUDE.md | 2 +- .../pages/pages-session/pages-session.ts | 5 ++-- .../projects/pages/pages-store/pages-store.ts | 22 +++++++-------- app/src/renderer/App/App.tsx | 28 +++++++++---------- .../LinkedReferences/LinkedReferences.tsx | 5 ++-- .../renderer/project/PageView/PageView.tsx | 11 ++++++-- .../renderer/project/PageView/steps/steps.ts | 6 ++++ app/src/renderer/project/ProjectView.tsx | 26 +++++++++++++---- app/src/renderer/ui.ts | 20 +------------ 9 files changed, 66 insertions(+), 59 deletions(-) diff --git a/app/CLAUDE.md b/app/CLAUDE.md index 1e33dd3..2b5332f 100644 --- a/app/CLAUDE.md +++ b/app/CLAUDE.md @@ -48,7 +48,7 @@ Two stores, and they do not swap. SQLite holds the projects registry and config; - A ledger opens through `openOrCreate` in `@pbnotes/ledger`, and that is the one way one comes to exist: a missing or empty folder (dotfiles such as `.DS_Store`, and named extras such as `AGENTS.md`, aside) is made a ledger, and everything else that is not already one is refused and offered nothing, an unmarked one (segments, no marker) included. Never `mkdir` a ledger or write a `LEDGER` any other way, and never add a way to force past a refusal. The marker's signature line is on disk forever: do not change it. The ledger reads every directory at the top as a writer directory. - `seq` detects damage within a device. Ordering across devices is the hybrid logical clock (`hlc`), floored by what has been seen and bounded against a clock set to the wrong year. Never order by `at`; it is for display. - Never truncate, rewrite or repair a file belonging to another device. A half-arrived foreign log stalls and retries; it must never stop a project from opening. See `../packages/ledger/multi-writer.md`, the ledger's spec; what sync does to blocks and pages is `docs/block-sync.md`. -- What events mean is `@pbnotes/pages` (`../packages/pages/`), a workspace package so that other programs reading a folder fold it the same way the app does (#54): the event schema, `NAMESPACE` and `HANDLES`, the fold and its gate (`pagesFold`), the queries over it, which take the fold state alone (`getPage`, `getPages`, `getReferences`, `getPreviews`, `searchPages`, `locateBlock`), one pure builder per event (`blockCreated`, `blockEdited`, `blockDeleted`, `blockIndented`, `blockOutdented`), and the commands that check against the fold, build and dispatch (`addBlock` and the rest). It imports no `node:` module, since the renderer bundles it, and never `@pbnotes/ledger`: the fold takes `{ type, v, payload }` and the commands write through `PagesView.dispatch`. What stays in the app is what belongs to the window, the clock or the log: `getState`, `getLogStatus` and the `status` on the app's `PagesView` (`pages-store.ts`), and the IPC contract in `src/shared/pages.ts`. Reach it by its package name, never a relative path. +- What events mean is `@pbnotes/pages` (`../packages/pages/`), a workspace package so that other programs reading a folder fold it the same way the app does (#54): the event schema, `NAMESPACE` and `HANDLES`, the fold and its gate (`pagesFold`), the queries over it, which take the fold state alone (`getPage`, `getPages`, `getReferences`, `getPreviews`, `searchPages`, `locateBlock`), one pure builder per event (`blockCreated`, `blockEdited`, `blockDeleted`, `blockIndented`, `blockOutdented`), and the commands that check against the fold, build and dispatch (`addBlock` and the rest). It imports no `node:` module, since the renderer bundles it, and never `@pbnotes/ledger`: the fold takes `{ type, v, payload }` and the commands write through `PagesView.dispatch`. What stays in the app is what belongs to the window, the clock or the log: `getState` and the `status` on the app's `PagesView` (`pages-store.ts`), and the IPC contract in `src/shared/pages.ts`. Reach it by its package name, never a relative path. - The renderer reaches main only through `window.pbnotes`; keep new surface behind the context bridge in `src/preload/index.ts`. - Each component should have an associated story in Storybook. - Encapsulate by folder diff --git a/app/src/main/features/projects/pages/pages-session/pages-session.ts b/app/src/main/features/projects/pages/pages-session/pages-session.ts index 457ea2a..6ea0528 100644 --- a/app/src/main/features/projects/pages/pages-session/pages-session.ts +++ b/app/src/main/features/projects/pages/pages-session/pages-session.ts @@ -18,7 +18,7 @@ import { } from "@pbnotes/pages"; import type { PagesState, Place } from "../../../../../shared/pages.ts"; import type { LogStatus } from "../../../../../shared/log.ts"; -import { getLogStatus, getState } from "../pages-store/pages-store.ts"; +import { getState } from "../pages-store/pages-store.ts"; import type { PagesView } from "../pages-store/pages-store.ts"; import { back, go, rezoom } from "../trail/trail.ts"; @@ -172,8 +172,7 @@ export function createPagesSession( locateBlock((await open.require()).state, blockId), search: async (query: string): Promise => searchPages((await open.require()).state, query), - status: async (): Promise => - getLogStatus(await open.require()), + status: async (): Promise => (await open.require()).status, /** `after` absent puts the block at the end of the page. */ addBlock: (id: string, title: string, text: string, after?: string) => diff --git a/app/src/main/features/projects/pages/pages-store/pages-store.ts b/app/src/main/features/projects/pages/pages-store/pages-store.ts index 72d50fd..960eac7 100644 --- a/app/src/main/features/projects/pages/pages-store/pages-store.ts +++ b/app/src/main/features/projects/pages/pages-store/pages-store.ts @@ -2,7 +2,7 @@ * The app's side of pages: what `@pbnotes/pages` does not know because it is * about this app rather than about the log. Which day is today and where the * trail ends belong to the window and the clock; how much of the folder could - * be read belongs to the ledger. + * be read belongs to the ledger, and is the `status` on the view. * * Deliberately free of `electron` imports: callers hand in the view, which * keeps the store runnable under `node --test`. @@ -16,7 +16,15 @@ import type { LogStatus } from "../../../../../shared/log.ts"; * The view `ledgers.ts` hands out: the package's, and how much of the log * could be read, which the package has no business knowing. */ -export type PagesView = Base & { readonly status: LogStatus }; +export type PagesView = Base & { + /** + * How much of the folder this ledger is kept in could be read, and what in + * it was skipped. Nothing here is an error to clear — a session whose log is + * still syncing is a normal state that usually heals itself — but none of it + * may be skipped without the user being able to find out. + */ + readonly status: LogStatus; +}; /** * Everything that shows at the end of `trail`: the one page it ends on, or @@ -61,13 +69,3 @@ export function getState( previews: getPreviews(view.state), }; } - -/** - * How much of the folder this ledger is kept in could be read, and what in it - * was skipped. Nothing here is an error to clear — a session whose log is still - * syncing is a normal state that usually heals itself — but none of it may be - * skipped without the user being able to find out. - */ -export function getLogStatus(view: PagesView): LogStatus { - return view.status; -} diff --git a/app/src/renderer/App/App.tsx b/app/src/renderer/App/App.tsx index d678e3f..c37bfc9 100644 --- a/app/src/renderer/App/App.tsx +++ b/app/src/renderer/App/App.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useState } from 'react'; -import type { PagesApi } from '../../shared/pages'; -import type { Availability, Project, ProjectsApi } from '../../shared/projects'; +import type { Project, ProjectsApi } from '../../shared/projects'; import ProjectPicker from '../ProjectPicker/ProjectPicker'; import ProjectView from '../project/ProjectView'; +import type { ProjectViewPages } from '../project/ProjectView'; import Welcome from '../Welcome/Welcome'; import type { Act } from '../ui'; @@ -12,8 +12,9 @@ import type { Act } from '../ui'; * project that is open. */ type Props = { - projects: Pick; - pages: PagesApi; + projects: Pick; + /** Only what the project that is open calls. */ + pages: ProjectViewPages; /** The clock, passed on to the project that is open. */ now: () => Date; }; @@ -33,9 +34,6 @@ function humanize(error: unknown): string { export default function App({ projects: api, pages, now }: Props) { const [projects, setProjects] = useState([]); - const [availability, setAvailability] = useState>( - {}, - ); const [loading, setLoading] = useState(true); const [notice, setNotice] = useState(null); /** @@ -47,10 +45,14 @@ export default function App({ projects: api, pages, now }: Props) { const [project, setProject] = useState(null); /** - * The view renders from the database immediately; reachability is filled in - * afterwards so a sleeping network share cannot hold up the first paint. - * `loading` clears on both paths — a failed list must not leave the app - * showing "Loading…" with no way out. + * The view renders from the database alone: nothing here waits on the disk, + * so a sleeping network share cannot hold up the first paint. `loading` + * clears on both paths — a failed list must not leave the app showing + * "Loading…" with no way out. + * + * Whether each project can be reached (`projects.availability`) is not read + * here: nothing on screen shows it yet. It comes back with the code that + * draws it. */ const refresh = useCallback(async (): Promise => { try { @@ -60,9 +62,6 @@ export default function App({ projects: api, pages, now }: Props) { setProjects(next); setProject(current); setLoading(false); - setAvailability( - next.length > 0 ? await api.availability(next.map((p) => p.id)) : {}, - ); return next; } catch (error) { setNotice(humanize(error)); @@ -201,7 +200,6 @@ export default function App({ projects: api, pages, now }: Props) { // belong to the project that was showing a moment ago. The key is // all the view is told about which project that is. key={project.id} - availability={availability[project.id] ?? 'available'} act={act} pages={pages} now={now} diff --git a/app/src/renderer/project/PageView/LinkedReferences/LinkedReferences.tsx b/app/src/renderer/project/PageView/LinkedReferences/LinkedReferences.tsx index 44490ad..66932fb 100644 --- a/app/src/renderer/project/PageView/LinkedReferences/LinkedReferences.tsx +++ b/app/src/renderer/project/PageView/LinkedReferences/LinkedReferences.tsx @@ -1,7 +1,7 @@ import type { Page } from '@pbnotes/pages'; -import type { PagesApi } from '../../../../shared/pages'; import type { Act } from '../../../ui'; import PageView from '../PageView'; +import type { PageViewPages } from '../PageView'; /** * What links to the page above, under it: each page that names this one, @@ -17,7 +17,8 @@ export default function LinkedReferences({ references, act, }: { - pages: PagesApi; + /** Handed on whole to each cut, which is a `PageView`. */ + pages: PageViewPages; references: Page[]; act: Act; }) { diff --git a/app/src/renderer/project/PageView/PageView.tsx b/app/src/renderer/project/PageView/PageView.tsx index 52e7dda..2386d10 100644 --- a/app/src/renderer/project/PageView/PageView.tsx +++ b/app/src/renderer/project/PageView/PageView.tsx @@ -6,7 +6,7 @@ import type { Act } from '../../ui'; import { Block, BlockEditor, blockBox, blockFocus } from '../Block/Block'; import LinkedReferences from './LinkedReferences/LinkedReferences'; import { add, edit, indent, outdent, remove } from './steps/steps'; -import type { Step } from './steps/steps'; +import type { Step, StepsPages } from './steps/steps'; import { afterCancel, afterDelete, movable, newBlock } from './editing/editing'; import type { Caret, Then } from './editing/editing'; @@ -18,8 +18,15 @@ import type { Caret, Then } from './editing/editing'; */ type Writing = { after?: string } | null; +/** + * The part of the bridge a page calls: what it calls itself, and what the + * steps it writes through call. A cut under the page is another `PageView`, + * so `LinkedReferences` needs no more than this. + */ +export type PageViewPages = StepsPages & Pick; + type Props = { - pages: PagesApi; + pages: PageViewPages; /** The page as main last sent it. */ page: Page; /** What links here. Left off for a page that is itself shown as one. */ diff --git a/app/src/renderer/project/PageView/steps/steps.ts b/app/src/renderer/project/PageView/steps/steps.ts index 79b7e86..2213146 100644 --- a/app/src/renderer/project/PageView/steps/steps.ts +++ b/app/src/renderer/project/PageView/steps/steps.ts @@ -21,6 +21,12 @@ import type { PagesApi } from '../../../../shared/pages'; /** An event to draw, and the call that asks main to record it. */ export type Step = [PageEvent, () => Promise]; +/** Every call the steps here make, for a view that hands them its bridge. */ +export type StepsPages = Pick< + PagesApi, + 'addBlock' | 'editBlock' | 'deleteBlock' | 'indentBlock' | 'outdentBlock' +>; + /** A new block, named by the caller, at the end of the page or beneath `after`. */ export function add( pages: Pick, diff --git a/app/src/renderer/project/ProjectView.tsx b/app/src/renderer/project/ProjectView.tsx index 5499d95..1c9ae3f 100644 --- a/app/src/renderer/project/ProjectView.tsx +++ b/app/src/renderer/project/ProjectView.tsx @@ -2,12 +2,30 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { locate, pageKey, today } from '@pbnotes/pages'; import type { PagesApi, PagesState } from '../../shared/pages'; -import type { Availability } from '../../shared/projects'; import type { Act } from '../ui'; import { Previews } from './Block/Block'; import PageView from './PageView/PageView'; +import type { PageViewPages } from './PageView/PageView'; import { imageTarget, placeNew } from './placement/placement'; +/** + * The part of the bridge a project calls: what it calls itself, and what each + * page it shows does. + */ +export type ProjectViewPages = PageViewPages & + Pick< + PagesApi, + | 'onChanged' + | 'show' + | 'locate' + | 'go' + | 'back' + | 'rezoom' + | 'addImage' + | 'addBlock' + | 'indentBlock' + | 'blockMenu' + >; /** * A project, in one of two shapes. By default the journal: every day that has @@ -29,11 +47,9 @@ const images = (data: DataTransfer): File[] => [...data.files].filter((file) => file.type.startsWith('image/')); type Props = { - availability: Availability; act: Act; - /** The bridge to main, handed down from `index.tsx`. Passed on whole to - * `PageView`, which takes the same type. */ - pages: PagesApi; + /** The bridge to main, handed down from `index.tsx`, and on to each page. */ + pages: ProjectViewPages; /** The clock, for which day a drop with no page lands on. */ now: () => Date; }; diff --git a/app/src/renderer/ui.ts b/app/src/renderer/ui.ts index e2b28fd..015fdee 100644 --- a/app/src/renderer/ui.ts +++ b/app/src/renderer/ui.ts @@ -1,24 +1,6 @@ /** - * Display helpers shared by the views. Presentation only — anything that has - * to agree with main belongs in `src/shared/projects.ts` instead. + * What the views share about reaching main. */ -import type { Availability } from '../shared/projects'; - -export const AVAILABILITY_LABEL: Record = { - available: '', - missing: 'Not found', - unknown: 'Unreachable', -}; - -export function formatLastOpened(iso: string | null): string { - if (!iso) return 'Never opened'; - const opened = new Date(iso); - const days = Math.floor((Date.now() - opened.getTime()) / 86_400_000); - if (days === 0) return 'Opened today'; - if (days === 1) return 'Opened yesterday'; - if (days < 30) return `Opened ${days} days ago`; - return `Opened ${opened.toLocaleDateString()}`; -} /** * How the views are allowed to reach main: run the call, surface a rejection