pi-weave 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -1
- package/package.json +2 -1
- package/src/web/client/dist/app.js +88 -124
- package/src/web/client/graph/ForceTuner.tsx +14 -98
- package/src/web/client/graph/Graph.tsx +11 -74
- package/src/web/client/graph/column.model.ts +3 -13
- package/src/web/client/graph/graph.model.ts +30 -62
- package/src/web/client/graph/groups.ts +9 -7
- package/src/web/client/graph/positions.ts +2 -2
- package/src/web/client/graph/renderer.dom.ts +2 -2
- package/src/web/client/graph/renderer.ts +5 -4
- package/src/web/client/graph/tuner.model.ts +6 -180
- package/src/web/client/main.tsx +1 -4
- package/src/web/client/note/Note.tsx +11 -1
- package/src/web/client/shell/Settings.tsx +71 -0
- package/src/web/client/shell/Shell.tsx +61 -24
- package/src/web/client/shell/StatusBar.tsx +1 -5
- package/src/web/client/shell/keys.model.ts +14 -8
- package/src/web/client/shell/shell.model.ts +1 -21
- package/src/web/client/shell/theme.model.ts +26 -96
- package/src/web/client/shell/theme.ts +20 -145
- package/src/web/client/shell/workspace.css.ts +27 -4
- package/src/web/server/backup.ts +59 -0
- package/src/web/server/routes.ts +12 -1
- package/src/web/shared/layout.ts +5 -6
- package/src/web/shared/preferences.ts +76 -0
- package/src/web/shared/themes.ts +92 -0
- package/src/web/shared/workspace.ts +44 -4
|
@@ -1,126 +1,10 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
* of sliders behind a URL flag, and everything about it that is not a DOM node
|
|
8
|
-
* lives here — §10's rule, the same split `Graph.tsx` / `column.model.ts`
|
|
9
|
-
* already uses.
|
|
10
|
-
*
|
|
11
|
-
* ## Why a query flag and not a setting
|
|
12
|
-
*
|
|
13
|
-
* The tuner is a *developer* affordance with a human in the loop; it is not a
|
|
14
|
-
* feature and must not read as one. A URL string is the cheapest gate that is
|
|
15
|
-
* genuinely hidden (no menu entry, no chord to collide with §11 P4's
|
|
16
|
-
* keymap, no persisted state to leak into a normal session) while staying
|
|
17
|
-
* reachable on a running workspace with no rebuild. A build-time `define`
|
|
18
|
-
* would have been more hidden still and was rejected: it breaks the
|
|
19
|
-
* byte-reproducible `build:web:check` contract for a panel that is already
|
|
20
|
-
* unreachable without the flag.
|
|
21
|
-
*
|
|
22
|
-
* ## Tier rules (§2)
|
|
23
|
-
*
|
|
24
|
-
* `src/web/client/**`, but nothing here touches the DOM or npm — the flag
|
|
25
|
-
* arrives as a `location.search` *string*, so this module compiles under the
|
|
26
|
-
* root `tsconfig.json` and its tests are ordinary ones.
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
import type { ForceConstants } from "../../shared/layout";
|
|
30
|
-
import { FORCE_DEFAULTS } from "../../shared/layout";
|
|
31
|
-
|
|
32
|
-
// --- the gate ----------------------------------------------------------------------
|
|
33
|
-
|
|
34
|
-
/** The query parameter that opens the panel. Documented in §15.7. */
|
|
35
|
-
export const SLIDERS_FLAG = "sliders";
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Whether `location.search` asks for the tuner.
|
|
39
|
-
*
|
|
40
|
-
* `?sliders`, `?sliders=1` and `?sliders=true` all open it; `?sliders=0` and
|
|
41
|
-
* `?sliders=false` do not, so a bookmarked URL can carry the parameter in the
|
|
42
|
-
* off position. Anything unparseable is off — a typo must not silently put a
|
|
43
|
-
* debug panel over a user's graph.
|
|
44
|
-
*
|
|
45
|
-
* Hand-parsed rather than via `URLSearchParams`, which is a DOM/Node global
|
|
46
|
-
* this tier's `tsconfig` does not provide.
|
|
47
|
-
*/
|
|
48
|
-
export function slidersFlag(search: string): boolean {
|
|
49
|
-
for (const pair of search.replace(/^\?/, "").split("&")) {
|
|
50
|
-
const eq = pair.indexOf("=");
|
|
51
|
-
const name = eq === -1 ? pair : pair.slice(0, eq);
|
|
52
|
-
if (name !== SLIDERS_FLAG) continue;
|
|
53
|
-
const value = eq === -1 ? "" : pair.slice(eq + 1).toLowerCase();
|
|
54
|
-
return value !== "0" && value !== "false";
|
|
55
|
-
}
|
|
56
|
-
return false;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// --- the sliders --------------------------------------------------------------------
|
|
1
|
+
/** Parsing and formatting for Settings → Interface graph sliders. */
|
|
2
|
+
import { FORCE_DEFAULTS, type ForceConstants } from "../../shared/layout";
|
|
3
|
+
import { FORCE_SLIDERS } from "../../shared/preferences";
|
|
4
|
+
import type { SliderSpec } from "../../shared/preferences";
|
|
5
|
+
export { FORCE_SLIDERS };
|
|
6
|
+
export type { SliderSpec };
|
|
60
7
|
|
|
61
|
-
/** One slider: which constant it drives, and the range it may drive it over. */
|
|
62
|
-
export interface SliderSpec {
|
|
63
|
-
readonly key: keyof ForceConstants;
|
|
64
|
-
readonly label: string;
|
|
65
|
-
/** One line under the label saying what moving it does. */
|
|
66
|
-
readonly hint: string;
|
|
67
|
-
readonly min: number;
|
|
68
|
-
readonly max: number;
|
|
69
|
-
readonly step: number;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* The panel, in the order the forces actually fight each other: cohesion
|
|
74
|
-
* first (what holds a group together), then repulsion (what pushes groups
|
|
75
|
-
* apart), then gravity (what pulls everything back in). Reading top to bottom
|
|
76
|
-
* is reading the tug-of-war.
|
|
77
|
-
*
|
|
78
|
-
* Ranges are generous on purpose — the point of the exercise is that the
|
|
79
|
-
* shipped values are in the wrong region, so a range hugging them would hide
|
|
80
|
-
* the answer. `chargeMax` tops out at 4000, which is past any useful value on
|
|
81
|
-
* a graph that spans a few thousand units; that top of the range is
|
|
82
|
-
* effectively "uncapped".
|
|
83
|
-
*/
|
|
84
|
-
export const FORCE_SLIDERS: readonly SliderSpec[] = [
|
|
85
|
-
{ key: "containsStrength", label: "contains strength", hint: "how hard a parent holds its children — group cohesion", min: 0, max: 1, step: 0.01 },
|
|
86
|
-
{ key: "containsRest", label: "contains rest", hint: "how far children sit from their parent — rosette radius", min: 10, max: 300, step: 5 },
|
|
87
|
-
{ key: "relationStrength", label: "relation strength", hint: "pull of links-to / mentions across groups", min: 0, max: 1, step: 0.01 },
|
|
88
|
-
{ key: "relationDistance", label: "relation distance", hint: "how long those cross-group springs are", min: 20, max: 600, step: 10 },
|
|
89
|
-
{ key: "charge", label: "charge", hint: "node-to-node repulsion — more negative pushes harder", min: -1200, max: 0, step: 10 },
|
|
90
|
-
{ key: "chargeMax", label: "charge range", hint: "distance past which repulsion stops — caps overall spread", min: 100, max: 4000, step: 50 },
|
|
91
|
-
{ key: "center", label: "centre gravity", hint: "pull toward the origin — too much makes one blob", min: 0, max: 0.3, step: 0.005 },
|
|
92
|
-
];
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* The arrangement this whole exercise replaced, kept as a **comparison**.
|
|
96
|
-
*
|
|
97
|
-
* Not a suggestion any more — `FORCE_DEFAULTS` is now the chosen answer, found
|
|
98
|
-
* through these sliders and frozen in `shared/layout.ts`. This is the *before*
|
|
99
|
-
* picture, one button press away, so the next person to open the panel can see
|
|
100
|
-
* in one click what the constants are for rather than reading a changelog.
|
|
101
|
-
*
|
|
102
|
-
* Measured at 300 ticks, worst-case gap between the big branches' bounding
|
|
103
|
-
* boxes (negative = the branches interleave, which is the hairball):
|
|
104
|
-
* `siblingBlobsGraph` **−312** here against **+39** shipped, `repoLikeGraph`
|
|
105
|
-
* **−274** against **+133**.
|
|
106
|
-
*/
|
|
107
|
-
export const HAIRBALL: Readonly<ForceConstants> = {
|
|
108
|
-
containsRest: 90,
|
|
109
|
-
containsStrength: 0.02,
|
|
110
|
-
relationDistance: 170,
|
|
111
|
-
relationStrength: 0.05,
|
|
112
|
-
charge: -50,
|
|
113
|
-
chargeMax: Infinity,
|
|
114
|
-
center: 0.09,
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* A slider's value for `<input type="range">`.
|
|
119
|
-
*
|
|
120
|
-
* `chargeMax` defaults to `Infinity`, which is not a number a range input can
|
|
121
|
-
* hold, so it reads as the top of its range — the position that means "no
|
|
122
|
-
* meaningful cap", which is what `Infinity` is.
|
|
123
|
-
*/
|
|
124
8
|
export function sliderValue(forces: ForceConstants, spec: SliderSpec): number {
|
|
125
9
|
const raw = forces[spec.key];
|
|
126
10
|
if (!Number.isFinite(raw)) return spec.max;
|
|
@@ -147,64 +31,6 @@ export function formatValue(value: number): string {
|
|
|
147
31
|
return Number.isInteger(value) ? String(value) : value.toFixed(3).replace(/0+$/, "").replace(/\.$/, "");
|
|
148
32
|
}
|
|
149
33
|
|
|
150
|
-
// --- handing the numbers back ----------------------------------------------------------
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* The current constants as the literal that belongs in `shared/layout.ts`.
|
|
154
|
-
*
|
|
155
|
-
* The whole point of the tuner is that a human finds the values and they then
|
|
156
|
-
* get *frozen into the source*, so the last step of the loop is transcription
|
|
157
|
-
* — and transcribing seven floats by eye off a panel is exactly where a digit
|
|
158
|
-
* gets dropped. The Copy button emits the block verbatim.
|
|
159
|
-
*/
|
|
160
|
-
export function forcesSnippet(forces: ForceConstants): string {
|
|
161
|
-
const line = (key: keyof ForceConstants): string => ` ${key}: ${Number.isFinite(forces[key]) ? forces[key] : "Infinity"},`;
|
|
162
|
-
return ["export const FORCES: ForceConstants = {", ...FORCE_SLIDERS.map((spec) => line(spec.key)), "};"].join("\n");
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/** True when nothing has been moved off the shipped values. Drives "Reset". */
|
|
166
34
|
export function isDefault(forces: ForceConstants): boolean {
|
|
167
35
|
return FORCE_SLIDERS.every((spec) => Object.is(forces[spec.key], FORCE_DEFAULTS[spec.key]));
|
|
168
36
|
}
|
|
169
|
-
|
|
170
|
-
// --- the group-colour setting (§15.8) ------------------------------------------------
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Where the group-colour choice is remembered.
|
|
174
|
-
*
|
|
175
|
-
* Versioned like the position cache, and stored separately from it: a palette
|
|
176
|
-
* preference must survive the layout invalidation that a force change causes,
|
|
177
|
-
* or tuning the physics would silently reset the user's colours.
|
|
178
|
-
*/
|
|
179
|
-
export const GROUP_COLORS_STORAGE_KEY = "pi-weave.graph.groupColors.v1";
|
|
180
|
-
|
|
181
|
-
/** The two-method storage port, same shape as `PositionStorage`. */
|
|
182
|
-
export interface SettingStorage {
|
|
183
|
-
getItem(key: string): string | null;
|
|
184
|
-
setItem(key: string, value: string): void;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Whether to colour by group. Defaults to **on**.
|
|
189
|
-
*
|
|
190
|
-
* On by default because the forces go to real trouble to separate the groups
|
|
191
|
-
* and leaving them one colour wastes that. A throwing `getItem` (Safari
|
|
192
|
-
* private browsing, partitioned storage) is a default, not an error — the
|
|
193
|
-
* same answer `loadPositions` gives.
|
|
194
|
-
*/
|
|
195
|
-
export function loadGroupColors(storage: SettingStorage): boolean {
|
|
196
|
-
try {
|
|
197
|
-
return storage.getItem(GROUP_COLORS_STORAGE_KEY) !== "off";
|
|
198
|
-
} catch {
|
|
199
|
-
return true;
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** Persist the choice, best-effort. A full or refusing quota is not an error. */
|
|
204
|
-
export function saveGroupColors(storage: SettingStorage, on: boolean): void {
|
|
205
|
-
try {
|
|
206
|
-
storage.setItem(GROUP_COLORS_STORAGE_KEY, on ? "on" : "off");
|
|
207
|
-
} catch {
|
|
208
|
-
// A preference that does not survive a reload still works this session.
|
|
209
|
-
}
|
|
210
|
-
}
|
package/src/web/client/main.tsx
CHANGED
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
import { render } from "preact";
|
|
14
14
|
import { BOOTSTRAP_ELEMENT_ID } from "../shared/wire";
|
|
15
15
|
import { readBootstrap } from "./bootstrap";
|
|
16
|
-
import { slidersFlag } from "./graph/tuner.model";
|
|
17
16
|
import { Shell } from "./shell/Shell";
|
|
18
17
|
import { installTheme } from "./shell/theme";
|
|
19
18
|
import { loadTheme, themeAttr } from "./shell/theme.model";
|
|
@@ -34,9 +33,7 @@ if (host !== null) {
|
|
|
34
33
|
cwd={boot.cwd}
|
|
35
34
|
initialWidth={window.innerWidth}
|
|
36
35
|
platform={navigator.platform}
|
|
37
|
-
|
|
38
|
-
// owns the parsing, so this stays a read and the decision stays tested.
|
|
39
|
-
tuner={slidersFlag(window.location.search)}
|
|
36
|
+
|
|
40
37
|
/>,
|
|
41
38
|
host,
|
|
42
39
|
);
|
|
@@ -84,6 +84,8 @@ const PREVIEW_X = "weave-preview-x";
|
|
|
84
84
|
const PREVIEW_Y = "weave-preview-y";
|
|
85
85
|
|
|
86
86
|
export interface NoteProps {
|
|
87
|
+
spellcheck?: boolean;
|
|
88
|
+
defaultEdit?: boolean;
|
|
87
89
|
note: NotePayload | null;
|
|
88
90
|
loadFailed: boolean;
|
|
89
91
|
graph: GraphPayload | null;
|
|
@@ -233,6 +235,14 @@ export function Note(props: NoteProps) {
|
|
|
233
235
|
}
|
|
234
236
|
};
|
|
235
237
|
|
|
238
|
+
const defaultOpened = useRef<string | null>(null);
|
|
239
|
+
useEffect(() => {
|
|
240
|
+
if (note !== null && defaultOpened.current !== note.slug) {
|
|
241
|
+
defaultOpened.current = note.slug;
|
|
242
|
+
if (props.defaultEdit && drafts.get(note.slug) === null) drafts.open(note.slug, body);
|
|
243
|
+
}
|
|
244
|
+
}, [note?.slug, props.defaultEdit]);
|
|
245
|
+
|
|
236
246
|
const toggleEdit = (): void => {
|
|
237
247
|
if (note === null || drafts.isTaskSaving(note.slug)) return;
|
|
238
248
|
if (drafts.get(note.slug) === null) drafts.open(note.slug, body);
|
|
@@ -343,7 +353,7 @@ export function Note(props: NoteProps) {
|
|
|
343
353
|
<textarea
|
|
344
354
|
class="weave-note-editor"
|
|
345
355
|
aria-label={EDITOR_ARIA_LABEL}
|
|
346
|
-
spellcheck
|
|
356
|
+
spellcheck={props.spellcheck ?? true}
|
|
347
357
|
value={draft.body}
|
|
348
358
|
onInput={(event) => drafts.edit(note.slug, (event.target as HTMLTextAreaElement).value)}
|
|
349
359
|
onKeyDown={(event) => {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useState } from "preact/hooks";
|
|
2
|
+
import type { ComponentChildren } from "preact";
|
|
3
|
+
import type { WorkspaceLayout } from "../../shared/workspace";
|
|
4
|
+
import type { Preferences } from "../../shared/preferences";
|
|
5
|
+
import { ACCENTS, THEMES } from "../../shared/themes";
|
|
6
|
+
import type { AccentChoice, PaletteChoice } from "../../shared/themes";
|
|
7
|
+
import { ForceTuner } from "../graph/ForceTuner";
|
|
8
|
+
import { useFocusTrap } from "./FocusTrap";
|
|
9
|
+
import { keyHelp } from "./keys.model";
|
|
10
|
+
|
|
11
|
+
const CATEGORIES = ["General", "Appearance", "Interface", "Editor", "Files", "Backup"] as const;
|
|
12
|
+
function Row(props: { name: string; hint: string; children: ComponentChildren }) {
|
|
13
|
+
return <label class="weave-setting-row"><span><strong>{props.name}</strong><small>{props.hint}</small></span>{props.children}</label>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function Settings(props: {
|
|
17
|
+
compact: boolean; layout: WorkspaceLayout; info: { version: string; vaultRoot: string }; cwd: string; shortcut: string;
|
|
18
|
+
onChange: (layout: WorkspaceLayout) => void; onRefresh: () => void; onClose: () => void;
|
|
19
|
+
}) {
|
|
20
|
+
const [category, setCategory] = useState<typeof CATEGORIES[number]>("General");
|
|
21
|
+
const trap = useFocusTrap();
|
|
22
|
+
const p = props.layout.preferences;
|
|
23
|
+
const update = (values: Partial<Preferences>): void => props.onChange({ ...props.layout, preferences: { ...p, ...values } });
|
|
24
|
+
return <div class="weave-scrim weave-settings-scrim" onClick={props.onClose}>
|
|
25
|
+
<div class="weave-settings" role="dialog" aria-modal="true" aria-label="Settings" tabIndex={-1}
|
|
26
|
+
ref={trap.ref as { current: HTMLDivElement | null }} onClick={(event) => event.stopPropagation()}
|
|
27
|
+
onKeyDown={(event) => void trap.onKeyDown(event as unknown as KeyboardEvent)}>
|
|
28
|
+
<header><h2>Settings</h2><button type="button" aria-label="Close settings" onClick={props.onClose}>×</button></header>
|
|
29
|
+
<nav aria-label="Settings categories">{props.compact ? <select class="weave-settings-category" aria-label="Settings category" value={category} onChange={(event) => setCategory(event.currentTarget.value as typeof category)}>{CATEGORIES.map((name) => <option key={name} value={name}>{name}</option>)}</select> : CATEGORIES.map((name) => <button type="button" key={name} aria-current={category === name ? "page" : undefined} onClick={() => setCategory(name)}>{name}</button>)}</nav>
|
|
30
|
+
<section aria-label={`${category} settings`}><h3>{category}</h3>
|
|
31
|
+
{category === "General" ? <>
|
|
32
|
+
<p>Pi Weave {props.info.version}</p>
|
|
33
|
+
<dl class="weave-settings-locations"><dt>Vault</dt><dd>{props.info.vaultRoot || "Loading…"}</dd><dt>Repository</dt><dd>{props.cwd}</dd></dl>
|
|
34
|
+
<div class="weave-settings-actions"><button type="button" onClick={props.onRefresh}>Refresh workspace</button><a href="https://github.com/EranYonai/pi-weave#readme" target="_blank" rel="noreferrer">Documentation ↗</a></div>
|
|
35
|
+
<details><summary tabIndex={0}>Keyboard shortcuts</summary>{keyHelp(props.shortcut.slice(0, -1)).map((group) => <div key={group.title}><h4>{group.title}</h4>{group.entries.map((entry) => <div class="weave-key-row" key={entry.combo}><kbd>{entry.combo}</kbd><span>{entry.what}</span></div>)}</div>)}</details>
|
|
36
|
+
</> : null}
|
|
37
|
+
{category === "Appearance" ? <>
|
|
38
|
+
<Row name="Color scheme" hint="System follows your device and uses your chosen light and dark themes."><select aria-label="Color scheme" value={props.layout.theme === "system" ? "system" : THEMES[props.layout.theme].scheme} onChange={(event) => props.onChange({ ...props.layout, theme: event.currentTarget.value === "system" ? "system" : event.currentTarget.value === "light" ? p.lightTheme : p.darkTheme })}><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select></Row>
|
|
39
|
+
{(["light", "dark"] as const).map((scheme) => <Row key={scheme} name={`${scheme === "light" ? "Light" : "Dark"} theme`} hint={`Used in ${scheme} mode and by the ribbon theme button.`}><select aria-label={`${scheme === "light" ? "Light" : "Dark"} theme`} value={p[`${scheme}Theme`]} onChange={(event) => {
|
|
40
|
+
const theme = event.currentTarget.value as PaletteChoice;
|
|
41
|
+
props.onChange({ ...props.layout, preferences: { ...p, [`${scheme}Theme`]: theme }, theme: props.layout.theme !== "system" && THEMES[props.layout.theme].scheme === scheme ? theme : props.layout.theme });
|
|
42
|
+
}}>{Object.entries(THEMES).filter(([, theme]) => theme.scheme === scheme).map(([id, theme]) => <option key={id} value={id}>{theme.name}</option>)}</select></Row>)}
|
|
43
|
+
<Row name="Accent color" hint="Choose independently of the theme. Shades adapt to light and dark backgrounds."><select aria-label="Accent color" value={p.accent} onChange={(event) => update({ accent: event.currentTarget.value as AccentChoice })}><option value="theme">Theme default</option>{Object.entries(ACCENTS).map(([id, accent]) => <option value={id} key={id}>{accent.name}</option>)}</select></Row>
|
|
44
|
+
<Row name="Note font size" hint="Applies to reading and editing notes."><span class="weave-setting-range"><input aria-label="Note font size" type="range" min={12} max={22} step={1} value={p.fontSize} onInput={(event) => update({ fontSize: Number(event.currentTarget.value) })} /><output>{p.fontSize}px</output></span></Row>
|
|
45
|
+
</> : null}
|
|
46
|
+
{category === "Interface" ? <>
|
|
47
|
+
<Row name="Notes sidebar" hint="Show files and recent notes."><input type="checkbox" checked={props.layout.treeVisible} onChange={(event) => props.onChange({ ...props.layout, treeVisible: event.currentTarget.checked })} /></Row>
|
|
48
|
+
<Row name="Context sidebar" hint="Show links, backlinks, and related items."><input type="checkbox" checked={props.layout.contextVisible} onChange={(event) => props.onChange({ ...props.layout, contextVisible: event.currentTarget.checked })} /></Row>
|
|
49
|
+
<Row name="Color graph by group" hint="Give each containment group its own color."><input type="checkbox" checked={p.groupColors} onChange={(event) => update({ groupColors: event.currentTarget.checked })} /></Row>
|
|
50
|
+
<h4>Graph layout</h4><p class="weave-settings-hint">Adjust the graph live. Your choices are saved for this workspace.</p>
|
|
51
|
+
<ForceTuner forces={p.forces} onChange={(forces) => update({ forces })} />
|
|
52
|
+
</> : null}
|
|
53
|
+
{category === "Editor" ? <>
|
|
54
|
+
<Row name="Spellcheck" hint="Use your browser or device’s spellchecker while editing."><input type="checkbox" checked={p.spellcheck} onChange={(event) => update({ spellcheck: event.currentTarget.checked })} /></Row>
|
|
55
|
+
<Row name="Readable line length" hint="Keep note text in a comfortable reading column."><input type="checkbox" checked={p.readable} onChange={(event) => update({ readable: event.currentTarget.checked })} /></Row>
|
|
56
|
+
<Row name="Open notes in editing mode" hint="Newly opened notes start in the editor. Existing drafts are preserved."><input type="checkbox" checked={p.defaultEdit} onChange={(event) => update({ defaultEdit: event.currentTarget.checked })} /></Row>
|
|
57
|
+
</> : null}
|
|
58
|
+
{category === "Files" ? <>
|
|
59
|
+
<Row name="On startup" hint="Choose which tabs appear when you reopen this workspace."><select aria-label="On startup" value={p.startup} onChange={(event) => update({ startup: event.currentTarget.value as Preferences["startup"] })}><option value="restore">Restore open tabs</option><option value="empty">Start with an empty tab</option></select></Row>
|
|
60
|
+
<Row name="Focus new tabs" hint="Switch to notes opened in a new tab, including from the graph."><input type="checkbox" checked={p.focusNewTabs} onChange={(event) => update({ focusNewTabs: event.currentTarget.checked })} /></Row>
|
|
61
|
+
<h4>Deleting files</h4><p>Deleting a note or folder removes it from the vault after confirmation. Download a backup before deleting anything you may need again.</p>
|
|
62
|
+
</> : null}
|
|
63
|
+
{category === "Backup" ? <>
|
|
64
|
+
<h4>Download your vault</h4><p>Save a ZIP containing the vault’s files and folders, including notes, metadata, and attachments. Linked files and folders are included as files.</p>
|
|
65
|
+
<p>Save any unsaved edits first. The download contains files saved on disk. Backups are manual; store the ZIP somewhere safe.</p>
|
|
66
|
+
<a class="weave-settings-download" href="/api/backup" download>Download vault ZIP</a>
|
|
67
|
+
</> : null}
|
|
68
|
+
</section>
|
|
69
|
+
</div>
|
|
70
|
+
</div>;
|
|
71
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Shared browser/desktop workspace: one data source, two optional tab groups. */
|
|
2
|
-
import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
|
|
2
|
+
import { useEffect, useLayoutEffect, useRef, useState, useMemo } from "preact/hooks";
|
|
3
3
|
import type { GraphPayload, NotePayload } from "../../shared/wire";
|
|
4
|
-
import { activePane, activeTab, activateTab, closePane, closeTab, focusDocument, initialLayout, moveTab, navigateTab, openDocument, openGraph, parseWorkspaceLayout, splitPane, tabDropSource, tabSelection } from "../../shared/workspace";
|
|
4
|
+
import { activePane, activeTab, activateTab, closePane, closeTab, focusDocument, initialLayout, reorderTab, moveTab, navigateTab, openDocument, openWithPreferences, restoreWorkspace, openGraph, parseWorkspaceLayout, splitPane, tabDropSource, tabSelection } from "../../shared/workspace";
|
|
5
5
|
import type { Pane, WorkspaceLayout, WorkspaceTab } from "../../shared/workspace";
|
|
6
6
|
import { fetchJson } from "../api.dom";
|
|
7
7
|
import { openNote, saveNote } from "../api";
|
|
@@ -19,15 +19,18 @@ import { startWorkspace, watchNote } from "../workspace";
|
|
|
19
19
|
import type { WorkspaceHandle } from "../workspace";
|
|
20
20
|
import { ContextRail } from "./ContextRail";
|
|
21
21
|
import { deeplinkSelection, formatHash } from "./deeplink.model";
|
|
22
|
+
import { Settings } from "./Settings";
|
|
23
|
+
import type { Preferences } from "../../shared/preferences";
|
|
24
|
+
import { THEMES } from "../../shared/themes";
|
|
22
25
|
import { HelpOverlay } from "./HelpOverlay";
|
|
23
26
|
import { watchKeys } from "./keys";
|
|
24
27
|
import { COLUMN_FOCUS_SELECTORS, TREE_FILTER_SELECTOR, focusSelector, runShellAction } from "./keys.model";
|
|
25
28
|
import { StatusBar } from "./StatusBar";
|
|
26
29
|
import type { OverlayId } from "./shell.model";
|
|
27
30
|
import { NOTE_DRAG_TYPE, noteDropId, TICK_MS, graphClickOpensTab, looksApple, recentEntries, recordVisit, searchHint, searchShortcut, statusBarModel } from "./shell.model";
|
|
28
|
-
import {
|
|
31
|
+
import { toggleTheme, effectiveScheme, loadTheme, saveTheme, themeButton } from "./theme.model";
|
|
29
32
|
|
|
30
|
-
export interface ShellProps { cwd: string; initialWidth: number; platform: string
|
|
33
|
+
export interface ShellProps { cwd: string; initialWidth: number; platform: string }
|
|
31
34
|
|
|
32
35
|
function titleOf(tab: WorkspaceTab, graph: GraphPayload | null): string {
|
|
33
36
|
if (tab.kind === "graph") return "Graph view";
|
|
@@ -46,7 +49,7 @@ function RecentList(props: { visits: readonly string[]; graph: GraphPayload | nu
|
|
|
46
49
|
|
|
47
50
|
/** A tab view can unmount; the shared draft store outlives it. */
|
|
48
51
|
function DocumentView(props: {
|
|
49
|
-
tab: WorkspaceTab; graph: GraphPayload | null; drafts: DraftStore; revision: number;
|
|
52
|
+
preferences: Preferences; tab: WorkspaceTab; graph: GraphPayload | null; drafts: DraftStore; revision: number;
|
|
50
53
|
onSelect: (id: string, newTab?: boolean) => void; onSave: (slug: string, body: string) => Promise<boolean>;
|
|
51
54
|
now: number; onScroll: (value: number) => void; onSearch: () => void; onGraph: () => void;
|
|
52
55
|
}) {
|
|
@@ -87,7 +90,7 @@ function DocumentView(props: {
|
|
|
87
90
|
return <div class="weave-document" ref={element} onScrollCapture={(event) => {
|
|
88
91
|
const target = event.target as HTMLElement;
|
|
89
92
|
if (target.classList.contains("weave-note")) { savedScroll.current = target.scrollTop; props.onScroll(target.scrollTop); }
|
|
90
|
-
}}><Note note={payload?.note ?? null} loadFailed={payload?.failed ?? false} loadVersion={payload?.version ?? 0} graph={props.graph} selectedId={id}
|
|
93
|
+
}}><Note spellcheck={props.preferences.spellcheck} defaultEdit={props.preferences.defaultEdit} note={payload?.note ?? null} loadFailed={payload?.failed ?? false} loadVersion={payload?.version ?? 0} graph={props.graph} selectedId={id}
|
|
91
94
|
onSelect={props.onSelect} onOpen={(noteSlug) => void openNote(fetchJson, noteSlug).then((result) => {
|
|
92
95
|
if (!result.ok) window.alert(result.message); else if (!result.data.opened) window.alert("Could not open the note in an editor.");
|
|
93
96
|
})} onSave={props.onSave} drafts={props.drafts} idPrefix={props.tab.id} now={props.now} /></div>;
|
|
@@ -119,12 +122,14 @@ export function Shell(props: ShellProps) {
|
|
|
119
122
|
const [width, setWidth] = useState(props.initialWidth);
|
|
120
123
|
const [scheme, setScheme] = useState(() => schemeOf(window));
|
|
121
124
|
const [ready, setReady] = useState(false);
|
|
125
|
+
const [info, setInfo] = useState({ version: "", vaultRoot: "" });
|
|
122
126
|
const [persistError, setPersistError] = useState(false);
|
|
123
127
|
const [revision, setRevision] = useState(0);
|
|
124
128
|
const [, setDraftRevision] = useState(0);
|
|
125
129
|
const [graphSelection, setGraphSelection] = useState<string | null>(null);
|
|
126
130
|
const [graphPreviewId, setGraphPreviewId] = useState<string | null>(null);
|
|
127
131
|
const draggedTab = useRef<string | null>(null);
|
|
132
|
+
const [dropTab, setDropTab] = useState<{ paneId: string; before: string | null } | null>(null);
|
|
128
133
|
const [dropPane, setDropPane] = useState<string | null>(null);
|
|
129
134
|
const [recentMode, setRecentMode] = useState(false);
|
|
130
135
|
const [visits, setVisits] = useState<readonly string[]>([]);
|
|
@@ -140,7 +145,9 @@ export function Shell(props: ShellProps) {
|
|
|
140
145
|
const [graphBox, setGraphBox] = useState({ left: 0, top: 0, width: 600, height: 500 });
|
|
141
146
|
const pane = activePane(layout);
|
|
142
147
|
const tab = activeTab(layout);
|
|
143
|
-
const theme = themeButton(layout.theme);
|
|
148
|
+
const theme = themeButton(layout.theme, scheme, layout.preferences);
|
|
149
|
+
const palette = effectiveScheme(layout.theme, scheme, layout.preferences);
|
|
150
|
+
const graphTheme = useMemo(() => ({ theme: palette, accent: layout.preferences.accent }), [palette, layout.preferences.accent]);
|
|
144
151
|
const selectedId = tab.kind === "graph" ? graphSelection : tabSelection(tab);
|
|
145
152
|
const graphPane = layout.panes.find((group) => group.tabs.some((entry) => entry.id === group.activeTab && entry.kind === "graph"));
|
|
146
153
|
const graphVisible = graphPane !== undefined && (width >= 850 || graphPane.id === layout.activePane);
|
|
@@ -157,7 +164,7 @@ export function Shell(props: ShellProps) {
|
|
|
157
164
|
if (mayRemoveDrafts(drafts, live.current.layout, next, () => window.confirm(DISCARD_PROMPT))) setLayout(next);
|
|
158
165
|
};
|
|
159
166
|
const select = (id: string | null, newTab = false, paneId = layout.activePane, keepSidebar = false): void => {
|
|
160
|
-
const next = openDocument(layout, id, { newTab, paneId });
|
|
167
|
+
const next = (id === null ? openDocument : openWithPreferences)(layout, id, { newTab, paneId });
|
|
161
168
|
change(width < 850 && !keepSidebar ? { ...next, treeVisible: false } : next);
|
|
162
169
|
setCompactContext(false);
|
|
163
170
|
};
|
|
@@ -189,13 +196,14 @@ export function Shell(props: ShellProps) {
|
|
|
189
196
|
void (async () => {
|
|
190
197
|
try {
|
|
191
198
|
const response = await fetchJson("/api/workspace-state");
|
|
192
|
-
const value = await response.json() as { viewId?: unknown; layout?: unknown };
|
|
199
|
+
const value = await response.json() as { viewId?: unknown; layout?: unknown; info?: { version: string; vaultRoot: string } };
|
|
193
200
|
if (!current) return;
|
|
194
201
|
if (response.ok && typeof value.viewId === "string") {
|
|
195
202
|
viewId.current = value.viewId;
|
|
203
|
+
if (value.info) setInfo(value.info);
|
|
196
204
|
const restored = parseWorkspaceLayout(value.layout);
|
|
197
205
|
if (restored) {
|
|
198
|
-
setLayout(restored);
|
|
206
|
+
setLayout(restoreWorkspace(restored));
|
|
199
207
|
setVisits(restored.panes.flatMap((pane) => pane.tabs.flatMap((tab) => tab.history)).reduce<readonly string[]>(recordVisit, []));
|
|
200
208
|
}
|
|
201
209
|
} else setPersistError(true);
|
|
@@ -214,11 +222,11 @@ export function Shell(props: ShellProps) {
|
|
|
214
222
|
useEffect(() => {
|
|
215
223
|
if (!ready) return;
|
|
216
224
|
history.replaceState(null, "", formatHash(selectedId));
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
225
|
+
document.documentElement.dataset.weaveTheme = palette;
|
|
226
|
+
document.documentElement.dataset.weaveScheme = THEMES[palette].scheme;
|
|
227
|
+
document.documentElement.dataset.weaveAccent = layout.preferences.accent;
|
|
220
228
|
saveTheme(localStorage, layout.theme);
|
|
221
|
-
}, [ready, selectedId, layout.theme]);
|
|
229
|
+
}, [ready, selectedId, layout.theme, palette, layout.preferences.accent]);
|
|
222
230
|
const pendingSave = useRef(Promise.resolve());
|
|
223
231
|
useEffect(() => {
|
|
224
232
|
if (!ready || !viewId.current) return;
|
|
@@ -265,7 +273,7 @@ export function Shell(props: ShellProps) {
|
|
|
265
273
|
else live.current.select(null);
|
|
266
274
|
},
|
|
267
275
|
closeTab: () => { const current = live.current.layout; change(closeTab(current, current.activePane, activeTab(current).id)); },
|
|
268
|
-
|
|
276
|
+
toggleTheme: () => setLayout((current) => ({ ...current, theme: toggleTheme(current.theme, schemeOf(window), current.preferences) })),
|
|
269
277
|
focusSelector: (selector) => {
|
|
270
278
|
if (selector === COLUMN_FOCUS_SELECTORS.graph) setLayout((current) => ({ ...openGraph(current), ...(window.innerWidth < 850 ? { treeVisible: false } : {}) }));
|
|
271
279
|
else if (selector === COLUMN_FOCUS_SELECTORS.tree || selector === TREE_FILTER_SELECTOR) setLayout((current) => ({ ...current, treeVisible: true }));
|
|
@@ -304,6 +312,7 @@ export function Shell(props: ShellProps) {
|
|
|
304
312
|
if (tabDropSource(layout, paneId, draggedTab.current) === null && !event.dataTransfer?.types.includes(NOTE_DRAG_TYPE)) return;
|
|
305
313
|
event.preventDefault();
|
|
306
314
|
if (event.dataTransfer) event.dataTransfer.dropEffect = draggedTab.current === null ? "copy" : "move";
|
|
315
|
+
setDropTab(null);
|
|
307
316
|
setDropPane(paneId);
|
|
308
317
|
};
|
|
309
318
|
const tabDrop = (paneId: string, event: DragEvent): void => {
|
|
@@ -318,6 +327,20 @@ export function Shell(props: ShellProps) {
|
|
|
318
327
|
});
|
|
319
328
|
draggedTab.current = null;
|
|
320
329
|
setDropPane(null);
|
|
330
|
+
setDropTab(null);
|
|
331
|
+
};
|
|
332
|
+
const reorderDragOver = (pane: Pane, before: string | null, event: DragEvent): void => {
|
|
333
|
+
if (!pane.tabs.some((tab) => tab.id === draggedTab.current)) return;
|
|
334
|
+
event.preventDefault(); event.stopPropagation();
|
|
335
|
+
event.dataTransfer!.dropEffect = "move";
|
|
336
|
+
setDropPane(null); setDropTab({ paneId: pane.id, before });
|
|
337
|
+
};
|
|
338
|
+
const reorderDrop = (pane: Pane, event: DragEvent): void => {
|
|
339
|
+
const id = draggedTab.current;
|
|
340
|
+
if (id === null || !pane.tabs.some((tab) => tab.id === id)) return;
|
|
341
|
+
event.preventDefault(); event.stopPropagation();
|
|
342
|
+
setLayout((current) => reorderTab(current, pane.id, id, dropTab?.paneId === pane.id ? dropTab.before : null));
|
|
343
|
+
draggedTab.current = null; setDropTab(null); setDropPane(null);
|
|
321
344
|
};
|
|
322
345
|
const renderPane = (group: Pane) => {
|
|
323
346
|
const current = group.tabs.find((entry) => entry.id === group.activeTab)!;
|
|
@@ -327,14 +350,27 @@ export function Shell(props: ShellProps) {
|
|
|
327
350
|
onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDropPane(null); }}
|
|
328
351
|
hidden={width < 850 && !isActive} onFocusCapture={() => { if (!isActive) setLayout((value) => ({ ...value, activePane: group.id })); }}
|
|
329
352
|
onPointerDown={() => { if (!isActive) setLayout((value) => ({ ...value, activePane: group.id })); }}>
|
|
330
|
-
<div class="weave-tabs" role="tablist" aria-label="Open documents">
|
|
331
|
-
{group.tabs.map((entry) => <div key={entry.id} class={`weave-tab${entry.id === current.id ? " weave-tab-active" : ""}`}
|
|
353
|
+
<div class="weave-tabs" role="tablist" aria-label="Open documents" onDragOver={(event) => reorderDragOver(group, null, event)} onDrop={(event) => reorderDrop(group, event)}>
|
|
354
|
+
{group.tabs.map((entry) => <div key={entry.id} class={`weave-tab${entry.id === current.id ? " weave-tab-active" : ""}${dropTab?.paneId === group.id && dropTab.before === entry.id ? " weave-tab-drop-before" : ""}${dropTab?.paneId === group.id && dropTab.before === null && entry === group.tabs.at(-1) ? " weave-tab-drop-after" : ""}`}
|
|
355
|
+
onDragOver={(event) => {
|
|
356
|
+
const bounds = event.currentTarget.getBoundingClientRect();
|
|
357
|
+
const before = event.clientX < bounds.left + bounds.width / 2 ? entry.id : group.tabs[group.tabs.indexOf(entry) + 1]?.id ?? null;
|
|
358
|
+
reorderDragOver(group, before, event);
|
|
359
|
+
}} onDrop={(event) => reorderDrop(group, event)}>
|
|
332
360
|
<button type="button" role="tab" title={titleOf(entry, data.graph)} id={`tab-${entry.id}`} aria-selected={entry.id === current.id} aria-controls={`panel-${group.id}`} tabIndex={entry.id === current.id ? 0 : -1}
|
|
333
|
-
draggable
|
|
361
|
+
draggable
|
|
334
362
|
onDragStart={(event) => { draggedTab.current = entry.id; event.dataTransfer!.effectAllowed = "move"; event.dataTransfer!.setData("application/x-weave-tab", entry.id); }}
|
|
335
363
|
onClick={() => setLayout(activateTab(layout, group.id, entry.id))}
|
|
336
364
|
onKeyDown={(event) => {
|
|
337
365
|
const offset = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
|
|
366
|
+
if (offset && event.altKey && !event.ctrlKey && !event.metaKey) {
|
|
367
|
+
event.preventDefault(); event.stopPropagation();
|
|
368
|
+
const index = group.tabs.indexOf(entry);
|
|
369
|
+
const before = offset < 0 ? group.tabs[Math.max(0, index - 1)]!.id : group.tabs[index + 2]?.id ?? null;
|
|
370
|
+
setLayout(reorderTab(layout, group.id, entry.id, before));
|
|
371
|
+
requestAnimationFrame(() => document.getElementById(`tab-${entry.id}`)?.focus());
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
338
374
|
if (!offset && event.key !== "Home" && event.key !== "End") return;
|
|
339
375
|
event.preventDefault();
|
|
340
376
|
const index = event.key === "Home" ? 0 : event.key === "End" ? group.tabs.length - 1 : (group.tabs.indexOf(entry) + offset + group.tabs.length) % group.tabs.length;
|
|
@@ -361,7 +397,7 @@ export function Shell(props: ShellProps) {
|
|
|
361
397
|
</div></details>
|
|
362
398
|
</div>
|
|
363
399
|
<div class="weave-pane-content" role="tabpanel" tabIndex={-1} id={`panel-${group.id}`} aria-labelledby={`tab-${current.id}`} {...(current.kind === "graph" ? { ref: graphSlot } : {})}>
|
|
364
|
-
{current.kind === "document" ? <DocumentView key={`${current.id}:${current.cursor}:${tabSelection(current)}`} tab={current} graph={data.graph} drafts={drafts} revision={revision} now={now}
|
|
400
|
+
{current.kind === "document" ? <DocumentView key={`${current.id}:${current.cursor}:${tabSelection(current)}`} preferences={layout.preferences} tab={current} graph={data.graph} drafts={drafts} revision={revision} now={now}
|
|
365
401
|
onSelect={(id, newTab) => select(id, newTab, group.id)} onSave={save} onScroll={(value) => rememberScroll(current.id, value)}
|
|
366
402
|
onSearch={() => setOverlay("search")} onGraph={showGraph} /> : null}
|
|
367
403
|
</div>
|
|
@@ -369,7 +405,7 @@ export function Shell(props: ShellProps) {
|
|
|
369
405
|
};
|
|
370
406
|
|
|
371
407
|
return <>
|
|
372
|
-
<div class="weave-workbench" ref={root} onDragEnd={() => { draggedTab.current = null; setDropPane(null); }}>
|
|
408
|
+
<div class="weave-workbench" style={{ "--weave-note-size": `${layout.preferences.fontSize}px`, "--weave-reading-width": layout.preferences.readable ? "760px" : "100%" }} ref={root} onDragEnd={() => { draggedTab.current = null; setDropPane(null); setDropTab(null); }}>
|
|
373
409
|
<nav class="weave-ribbon" aria-label="Workspace tools">
|
|
374
410
|
<button type="button" aria-label="Toggle notes sidebar" aria-pressed={layout.treeVisible} title="Notes" onClick={() => setLayout({ ...layout, treeVisible: !layout.treeVisible })}>▤</button>
|
|
375
411
|
<button type="button" aria-label="Open graph view" title="Graph view" onClick={showGraph}>◌</button>
|
|
@@ -379,8 +415,8 @@ export function Shell(props: ShellProps) {
|
|
|
379
415
|
<span class="weave-ribbon-space" />
|
|
380
416
|
<button type="button" aria-label="Toggle context sidebar" aria-pressed={contextVisible} title="Context" onClick={() => width < 1050 ? setCompactContext(!compactContext) : setLayout({ ...layout, contextVisible: !layout.contextVisible })}>☷</button>
|
|
381
417
|
<button type="button" aria-label="Refresh workspace" title="Refresh workspace" onClick={() => { workspace.current?.refresh(); setRevision((value) => value + 1); }}>↻</button>
|
|
382
|
-
<button type="button" aria-label={theme.hint} title={theme.hint} onClick={() => setLayout({ ...layout, theme:
|
|
383
|
-
<button type="button" aria-label="
|
|
418
|
+
<button type="button" aria-label={theme.hint} title={theme.hint} onClick={() => setLayout({ ...layout, theme: toggleTheme(layout.theme, scheme, layout.preferences) })}>{theme.glyph}</button>
|
|
419
|
+
<button type="button" aria-label="Settings" title="Settings" onClick={() => setOverlay("settings")}>⚙</button>
|
|
384
420
|
</nav>
|
|
385
421
|
<div class="weave-sidebar-dock weave-notes-dock" data-open={layout.treeVisible} inert={!layout.treeVisible} aria-hidden={!layout.treeVisible} style={{ "--weave-sidebar-width": `${Math.min(layout.treeWidth, width - 90)}px` }}><aside class="weave-sidebar weave-sidebar-notes" aria-label="Notes sidebar">
|
|
386
422
|
<div class="weave-sidebar-heading"><button type="button" aria-pressed={!recentMode} onClick={() => setRecentMode(false)}>Files</button><button type="button" aria-pressed={recentMode} onClick={() => setRecentMode(true)}>Recent</button><button type="button" aria-label="Hide notes sidebar" onClick={() => { root.current?.querySelector<HTMLButtonElement>('[aria-label="Toggle notes sidebar"]')?.focus(); setLayout({ ...layout, treeVisible: false }); }}>«</button></div>
|
|
@@ -395,15 +431,16 @@ export function Shell(props: ShellProps) {
|
|
|
395
431
|
</main>
|
|
396
432
|
<div class="weave-sidebar-dock weave-context-dock" data-open={contextVisible} inert={!contextVisible} aria-hidden={!contextVisible} style={{ "--weave-sidebar-width": `${layout.contextWidth}px` }}><ResizeHandle label="Resize context sidebar" min={180} max={400} value={layout.contextWidth} onChange={(delta) => setLayout((value) => ({ ...value, contextWidth: Math.max(180, Math.min(400, value.contextWidth - delta)) }))} /><aside class="weave-sidebar weave-sidebar-context" aria-label="Context sidebar"><div class="weave-sidebar-heading"><strong>Context</strong><button type="button" aria-label="Hide context sidebar" onClick={() => { root.current?.querySelector<HTMLButtonElement>('[aria-label="Toggle context sidebar"]')?.focus(); setCompactContext(false); setLayout({ ...layout, contextVisible: false }); }}>»</button></div><ContextRail graph={data.graph} selectedId={selectedId} onSelect={select} /></aside></div>
|
|
397
433
|
{hasGraph ? <div class="weave-graph-host" onDragOver={(event) => { if (graphPane) tabDragOver(graphPane.id, event); }} onDrop={(event) => { if (graphPane) tabDrop(graphPane.id, event); }} onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDropPane(null); }} onPointerDown={() => { if (graphPane) setLayout((current) => ({ ...current, activePane: graphPane.id })); }} onFocusCapture={() => { if (graphPane) setLayout((current) => ({ ...current, activePane: graphPane.id })); }} aria-hidden={!graphVisible} style={{ ...graphBox, visibility: graphVisible ? "visible" : "hidden", pointerEvents: graphVisible ? "auto" : "none" }}>
|
|
398
|
-
<Graph graph={data.graph} selectedId={selectedId} previewId={graphPreviewId} onSelect={selectGraph} onOpen={openGraphNode} renderer={createSigmaRenderer} storage={localStorage} host={window} scheme={
|
|
434
|
+
<Graph graph={data.graph} selectedId={selectedId} previewId={graphPreviewId} onSelect={selectGraph} onOpen={openGraphNode} renderer={createSigmaRenderer} storage={localStorage} host={window} scheme={graphTheme} forces={layout.preferences.forces} groupColors={layout.preferences.groupColors} bootFailed={data.graphFailed} fit={fit} />
|
|
399
435
|
</div> : null}
|
|
400
436
|
</div>
|
|
401
|
-
<div class="weave-footer"><StatusBar model={statusBarModel(props.cwd, selectedId
|
|
437
|
+
<div class="weave-footer"><StatusBar model={statusBarModel(props.cwd, selectedId)} />
|
|
402
438
|
{persistError ? <span role="status">Workspace restoration unavailable</span> : null}
|
|
403
439
|
{drafts.dirtySlugs().length ? <span>{drafts.dirtySlugs().length} unsaved</span> : null}
|
|
404
440
|
{width < 850 && layout.panes.length > 1 ? <button type="button" onClick={() => setLayout({ ...layout, activePane: layout.panes.find((group) => group.id !== pane.id)!.id })}>Switch pane</button> : null}
|
|
405
441
|
</div>
|
|
406
442
|
{overlay === "search" ? <SearchPalette graph={data.graph} onSelect={select} onClose={() => setOverlay(null)} ports={{ fetch: fetchJson }} /> : null}
|
|
443
|
+
{overlay === "settings" ? <Settings compact={width <= 650} layout={layout} info={info} cwd={props.cwd} shortcut={searchShortcut(looksApple(props.platform))} onChange={(next) => { change(next); setCompactContext(next.contextVisible); }} onRefresh={() => { workspace.current?.refresh(); setRevision((value) => value + 1); }} onClose={() => setOverlay(null)} /> : null}
|
|
407
444
|
{overlay === "help" ? <HelpOverlay shortcut={searchShortcut(looksApple(props.platform))} onClose={() => setOverlay(null)} /> : null}
|
|
408
445
|
</>;
|
|
409
446
|
}
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The status bar (weave-workspace §1.2).
|
|
3
3
|
*
|
|
4
|
-
* Working directory
|
|
4
|
+
* Working directory and current selection. The
|
|
5
5
|
* model is built by `statusBarModel`; this renders it.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { StatusBarModel } from "./shell.model";
|
|
9
|
-
import { shortStamp } from "./shell.model";
|
|
10
9
|
|
|
11
10
|
export function StatusBar({ model }: { model: StatusBarModel }) {
|
|
12
11
|
return (
|
|
@@ -17,9 +16,6 @@ export function StatusBar({ model }: { model: StatusBarModel }) {
|
|
|
17
16
|
<span class="weave-status-sel" title="Current item">
|
|
18
17
|
{model.selection}
|
|
19
18
|
</span>
|
|
20
|
-
<span class="weave-status-stamp" title="data as of">
|
|
21
|
-
{shortStamp(model.stamp)}
|
|
22
|
-
</span>
|
|
23
19
|
</footer>
|
|
24
20
|
);
|
|
25
21
|
}
|