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