Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -172,8 +172,7 @@ export function createPagesSession(
locateBlock((await open.require()).state, blockId),
search: async (query: string): Promise<string[]> =>
searchPages((await open.require()).state, query),
status: async (): Promise<LogStatus> =>
getLogStatus(await open.require()),
status: async (): Promise<LogStatus> => (await open.require()).status,

/** `after` absent puts the block at the end of the page. */
addBlock: (id: string, title: string, text: string, after?: string) =>
Expand Down
22 changes: 10 additions & 12 deletions app/src/main/features/projects/pages/pages-store/pages-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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
Expand Down Expand Up @@ -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;
}
28 changes: 13 additions & 15 deletions app/src/renderer/App/App.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -12,8 +12,9 @@ import type { Act } from '../ui';
* project that is open.
*/
type Props = {
projects: Pick<ProjectsApi, 'list' | 'current' | 'availability' | 'open' | 'pick'>;
pages: PagesApi;
projects: Pick<ProjectsApi, 'list' | 'current' | 'open' | 'pick'>;
/** Only what the project that is open calls. */
pages: ProjectViewPages;
/** The clock, passed on to the project that is open. */
now: () => Date;
};
Expand All @@ -33,9 +34,6 @@ function humanize(error: unknown): string {

export default function App({ projects: api, pages, now }: Props) {
const [projects, setProjects] = useState<Project[]>([]);
const [availability, setAvailability] = useState<Record<string, Availability>>(
{},
);
const [loading, setLoading] = useState(true);
const [notice, setNotice] = useState<string | null>(null);
/**
Expand All @@ -47,10 +45,14 @@ export default function App({ projects: api, pages, now }: Props) {
const [project, setProject] = useState<Project | null>(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<Project[]> => {
try {
Expand All @@ -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));
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
}) {
Expand Down
11 changes: 9 additions & 2 deletions app/src/renderer/project/PageView/PageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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<PagesApi, 'search' | 'deleteBlock'>;

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. */
Expand Down
6 changes: 6 additions & 0 deletions app/src/renderer/project/PageView/steps/steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>];

/** 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<PagesApi, 'addBlock'>,
Expand Down
26 changes: 21 additions & 5 deletions app/src/renderer/project/ProjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
};
Expand Down
20 changes: 1 addition & 19 deletions app/src/renderer/ui.ts
Original file line number Diff line number Diff line change
@@ -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<Availability, string> = {
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
Expand Down
Loading