pi-weave 0.1.6 → 0.1.7

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.
@@ -0,0 +1,530 @@
1
+ /**
2
+ * workspaceRoot.ts — the v2 workspace ROOT component (weave-view-tui-v2 §4, §7).
3
+ *
4
+ * Sits above the pure `workspace.ts` split-tree model and the `surface/*`
5
+ * components. It composes panes from a `Workspace` value into a real pi-tui
6
+ * tree (VStack/HStack/Pane), owns the single input listener, routes keys
7
+ * (workspace §7.1 / pane §7.2) and cross-pane SurfaceEvents, renders the
8
+ * branded header + footer + narrow-mode tab bar (§9.2, decision 5), and
9
+ * applies responsive collapse.
10
+ *
11
+ * It is drivable with a fake tui/theme/loaders exactly like v1's WeaveExplorer.
12
+ * `WeaveExplorer` (v1 single-pane path) is kept intact for backward
13
+ * compatibility; the workspace root is the v2 multi-pane path wired in run.ts.
14
+ */
15
+
16
+ import { HStack, matchesKey, truncateToWidth, visibleWidth, VStack, type Component } from "@earendil-works/pi-tui";
17
+ import { BodyStore } from "./bodyStore";
18
+ import { renderMark } from "./branding";
19
+ import type { WeaveLoaders, WeaveTheme, WeaveTui } from "./explorer";
20
+ import { countProvenance } from "./model";
21
+ import type { GraphModel, GraphNode } from "../../../core/graph/model";
22
+ import { Pane } from "./surface/base";
23
+ import type { Surface, SurfaceEvent, SurfaceContext } from "./surface/base";
24
+ import { ExploreSurface } from "./surface/explore";
25
+ import { bindDetail, DetailSurface } from "./surface/detail";
26
+ import { FocusSurface } from "./surface/focus";
27
+ import { HealthSurface } from "./surface/health";
28
+ import {
29
+ close,
30
+ collapseForWidth,
31
+ defaultWorkspace,
32
+ focusNext,
33
+ resize,
34
+ setPaneSurface,
35
+ split,
36
+ workspacePanes,
37
+ type PaneNode,
38
+ type Workspace,
39
+ type WorkspaceNode,
40
+ } from "./workspace";
41
+
42
+ export interface WeaveWorkspaceOptions {
43
+ model: GraphModel;
44
+ theme: WeaveTheme;
45
+ tui: WeaveTui;
46
+ loaders: WeaveLoaders;
47
+ done: (result: null) => void;
48
+ rows?: number;
49
+ now?: () => number;
50
+ /** Pre-rendered brand mark line (from branding.renderMark). */
51
+ logo?: string;
52
+ /**
53
+ * Raster kitty logo (branding.bundledLogoImage) rendered on its own row(s)
54
+ * above the header text strip when Kitty graphics are available. `null`/
55
+ * absent keeps the one-line glyph header (decision 1 favicon, not a splash).
56
+ */
57
+ logoImage?: Component | null;
58
+ /** Optional initial workspace (defaults to the Explore default). */
59
+ workspace?: Workspace;
60
+ }
61
+
62
+ /** Decode a workspace-level key (returns null when it's a pane key). */
63
+ export function decodeWorkspaceKey(data: string): string | null {
64
+ if (data === "\\") return "splitV";
65
+ if (data === "|") return "splitH";
66
+ if (data === "x") return "close";
67
+ if (data === "w") return "workspace";
68
+ if (data === "?") return "help";
69
+ if (data === "q") return "quit";
70
+ if (data === "r") return "refresh";
71
+ return null;
72
+ }
73
+
74
+ export class WeaveWorkspace implements Component {
75
+ model: GraphModel;
76
+ private readonly theme: WeaveTheme;
77
+ private readonly tui: WeaveTui;
78
+ private readonly loaders: WeaveLoaders;
79
+ private readonly done: (result: null) => void;
80
+ private readonly rows: number;
81
+ private readonly nowFn: () => number;
82
+ private readonly logo: string;
83
+ private readonly logoImage: Component | null;
84
+ private readonly bodies: BodyStore;
85
+ private ctx: SurfaceContext;
86
+ /** paneId → surface instance. */
87
+ private panes = new Map<string, Surface>();
88
+ /** The workspace split tree + focus. */
89
+ workspace: Workspace;
90
+ private helpOpen = false;
91
+ refreshing = false;
92
+ private quitted = false;
93
+ private renderCache = new Map<string, string[]>();
94
+ wantsKeyRelease = false;
95
+
96
+ constructor(opts: WeaveWorkspaceOptions) {
97
+ this.model = opts.model;
98
+ this.theme = opts.theme;
99
+ this.tui = opts.tui;
100
+ this.loaders = opts.loaders;
101
+ this.done = opts.done;
102
+ this.rows = opts.rows ?? 24;
103
+ this.nowFn = opts.now ?? Date.now;
104
+ this.logo = opts.logo ?? renderMark("glyph", opts.theme, 20);
105
+ this.logoImage = opts.logoImage ?? null;
106
+ this.bodies = new BodyStore({
107
+ loaders: opts.loaders,
108
+ onChange: () => this.invalidateAndRender(),
109
+ });
110
+ this.ctx = {
111
+ model: opts.model,
112
+ theme: opts.theme,
113
+ loaders: opts.loaders,
114
+ bodies: this.bodies,
115
+ now: this.nowFn,
116
+ };
117
+ this.workspace = opts.workspace ?? defaultWorkspace(opts.model);
118
+ this.syncPanes();
119
+ }
120
+
121
+ invalidate(): void {
122
+ this.renderCache.clear();
123
+ for (const s of this.panes.values()) s.invalidate();
124
+ }
125
+
126
+ private invalidateAndRender(): void {
127
+ this.invalidate();
128
+ this.tui.requestRender();
129
+ }
130
+
131
+ setModel(model: GraphModel): void {
132
+ this.model = model;
133
+ // Surfaces read the model through the shared context (this.ctx.model), not
134
+ // this.model — so a refresh that only swaps this.model leaves every pane
135
+ // showing the pre-refresh graph. Propagate to the context so `r` actually
136
+ // re-renders the body, then drop stale pane selections that no longer exist.
137
+ this.ctx.model = model;
138
+ for (const s of this.panes.values()) s.rebind?.(model);
139
+ this.bodies.clear();
140
+ this.refreshing = false;
141
+ this.invalidateAndRender();
142
+ }
143
+
144
+ /** Rebuild surface instances so every pane node in the tree has one. */
145
+ private syncPanes(): void {
146
+ const wanted = workspacePanes(this.workspace);
147
+ const next = new Map<string, Surface>();
148
+ for (const pn of wanted) {
149
+ let s = this.panes.get(pn.id);
150
+ if (!s) s = this.createSurface(pn);
151
+ next.set(pn.id, s);
152
+ }
153
+ this.panes = next;
154
+ }
155
+
156
+ private createSurface(pn: PaneNode): Surface {
157
+ const init = { context: this.ctx, onEvent: (e: SurfaceEvent) => this.onSurfaceEvent(e) };
158
+ switch (pn.surface) {
159
+ case "explore":
160
+ return new ExploreSurface(init);
161
+ case "detail":
162
+ return pn.nodeId ? bindDetail(init, pn.nodeId) : new DetailSurface(init);
163
+ case "focus": {
164
+ const f = new FocusSurface(init);
165
+ if (pn.nodeId) f.setFocus(pn.nodeId);
166
+ return f;
167
+ }
168
+ case "health":
169
+ return new HealthSurface(init);
170
+ }
171
+ }
172
+
173
+ handleInput(data: string): void {
174
+ if (matchesKey(data, "tab")) return this.cycleFocus(1);
175
+ if (matchesKey(data, "shift+tab")) return this.cycleFocus(-1);
176
+ if (matchesKey(data, "escape")) {
177
+ // Esc precedence (mirrors v1): if the active pane is in a sub-mode
178
+ // (Explore search OR Detail goto-line), Esc clears it; otherwise Esc
179
+ // quits the explorer.
180
+ const active = this.activePane();
181
+ if (active && inSubMode(active)) {
182
+ active.handleInput?.(data);
183
+ this.invalidate();
184
+ this.tui.requestRender();
185
+ return;
186
+ }
187
+ this.quit();
188
+ return;
189
+ }
190
+ const wsKey = decodeWorkspaceKey(data);
191
+ if (wsKey) {
192
+ this.applyWorkspaceKey(wsKey);
193
+ return;
194
+ }
195
+ if (data === "e" || data === "d" || data === "h") {
196
+ this.swapSurface(data);
197
+ return;
198
+ }
199
+ // Ctrl+letters resize — only the four resize bytes are intercepted so
200
+ // enter (\r/0x0d), esc (0x1b) and other control keys reach the pane.
201
+ const ctrl = charCode(data);
202
+ if (ctrl !== undefined && (ctrl === 0x08 || ctrl === 0x0a || ctrl === 0x0b || ctrl === 0x0c)) {
203
+ this.applyResize(ctrl);
204
+ return;
205
+ }
206
+ const active = this.activePane();
207
+ if (active?.handleInput) {
208
+ active.handleInput(data);
209
+ // CRITICAL: the surface updated its own selection/scroll state, but it
210
+ // has no `tui` and the workspace render cache is now stale. Invalidate
211
+ // (clears the workspace + per-surface caches) and request a fresh render
212
+ // — without this, arrow/enter/filter keys change state but the screen
213
+ // never updates (the "arrows don't navigate" bug).
214
+ this.invalidate();
215
+ this.tui.requestRender();
216
+ }
217
+ }
218
+
219
+ private quit(): void {
220
+ if (!this.quitted) {
221
+ this.quitted = true;
222
+ this.done(null);
223
+ }
224
+ }
225
+
226
+ private applyWorkspaceKey(key: string): void {
227
+ switch (key) {
228
+ case "splitV":
229
+ this.workspace = split(this.workspace, this.workspace.activePaneId, "vertical");
230
+ this.syncPanes();
231
+ this.invalidateAndRender();
232
+ return;
233
+ case "splitH":
234
+ this.workspace = split(this.workspace, this.workspace.activePaneId, "horizontal");
235
+ this.syncPanes();
236
+ this.invalidateAndRender();
237
+ return;
238
+ case "close":
239
+ this.workspace = close(this.workspace, this.workspace.activePaneId);
240
+ this.syncPanes();
241
+ this.invalidateAndRender();
242
+ return;
243
+ case "quit":
244
+ this.quit();
245
+ return;
246
+ case "refresh":
247
+ if (this.refreshing) return;
248
+ this.refreshing = true;
249
+ this.invalidateAndRender();
250
+ void this.loaders
251
+ .rebuild()
252
+ .then((m) => this.setModel(m))
253
+ .catch(() => {
254
+ this.refreshing = false;
255
+ this.invalidateAndRender();
256
+ });
257
+ return;
258
+ case "workspace":
259
+ // TODO(M5): named-workspace switcher overlay; for now toggles help.
260
+ this.helpOpen = !this.helpOpen;
261
+ this.invalidateAndRender();
262
+ return;
263
+ case "help":
264
+ this.helpOpen = !this.helpOpen;
265
+ this.invalidateAndRender();
266
+ return;
267
+ }
268
+ }
269
+
270
+ /** Ctrl+h/l adjust row weights; Ctrl+j/k adjust column weights. */
271
+ private applyResize(byte: number): void {
272
+ const active = this.workspace.activePaneId;
273
+ const d = 2;
274
+ if (byte === 0x08) this.workspace = resize(this.workspace, active, "row", -d); // Ctrl-h
275
+ else if (byte === 0x0c) this.workspace = resize(this.workspace, active, "row", d); // Ctrl-l
276
+ else if (byte === 0x0a) this.workspace = resize(this.workspace, active, "column", d); // Ctrl-j
277
+ else if (byte === 0x0b) this.workspace = resize(this.workspace, active, "column", -d); // Ctrl-k
278
+ this.syncPanes();
279
+ this.invalidateAndRender();
280
+ }
281
+
282
+ /** e/d/h — swap the active pane's surface in place (f stays the pane focus key). */
283
+ private swapSurface(key: string): void {
284
+ const kind = key === "e" ? "explore" : key === "d" ? "detail" : "health";
285
+ const activeId = this.workspace.activePaneId;
286
+ this.workspace = setPaneSurface(this.workspace, activeId, kind);
287
+ const s = this.createSurface({ type: "pane", id: activeId, surface: kind, nodeId: null });
288
+ this.panes.set(activeId, s);
289
+ this.invalidateAndRender();
290
+ }
291
+
292
+ private cycleFocus(dir: 1 | -1): void {
293
+ this.workspace = focusNext(this.workspace, dir);
294
+ this.refreshFocusFlags();
295
+ this.invalidateAndRender();
296
+ }
297
+
298
+ private refreshFocusFlags(): void {
299
+ for (const pn of workspacePanes(this.workspace)) {
300
+ const s = this.panes.get(pn.id);
301
+ s?.setFocused(pn.id === this.workspace.activePaneId);
302
+ }
303
+ }
304
+
305
+ private setActive(paneId: string): void {
306
+ this.workspace = { ...this.workspace, activePaneId: paneId };
307
+ this.refreshFocusFlags();
308
+ }
309
+
310
+ private activePane(): Surface | undefined {
311
+ return this.panes.get(this.workspace.activePaneId);
312
+ }
313
+
314
+ private onSurfaceEvent(e: SurfaceEvent): void {
315
+ if (e.type === "openEditor") {
316
+ this.openInEditor(e.id);
317
+ return;
318
+ }
319
+ if (e.type === "openDetail") {
320
+ this.openDetail(e.id);
321
+ return;
322
+ }
323
+ if (e.type === "focusNode") {
324
+ this.openFocus(e.id);
325
+ }
326
+ }
327
+
328
+ private openInEditor(id: string): void {
329
+ const node = this.model.nodes.find((n) => n.id === id);
330
+ if (!node || node.kind !== "note") return;
331
+ if (node.detail.slug) void this.loaders.openNote(node.detail.slug);
332
+ }
333
+
334
+ /** Obsidian "open in main": nearest Detail pane to the right, else split. */
335
+ private openDetail(id: string): void {
336
+ // (The active Detail surface already rebinds itself on enter, so no
337
+ // active-pane special case is needed here.)
338
+ const panes = workspacePanes(this.workspace);
339
+ const activeIdx = panes.findIndex((p) => p.id === this.workspace.activePaneId);
340
+ const toRight = panes.slice(activeIdx + 1).find((p) => p.surface === "detail");
341
+ if (toRight) {
342
+ this.bindDetail(toRight.id, id);
343
+ this.setActive(toRight.id);
344
+ return;
345
+ }
346
+ // none to the right — split the active pane horizontally into a Detail
347
+ this.workspace = split(this.workspace, this.workspace.activePaneId, "horizontal", "detail");
348
+ this.syncPanes();
349
+ const detail = workspacePanes(this.workspace).find((p) => p.surface === "detail");
350
+ if (detail) {
351
+ this.bindDetail(detail.id, id);
352
+ this.setActive(detail.id);
353
+ }
354
+ this.invalidateAndRender();
355
+ }
356
+
357
+ private bindDetail(paneId: string, id: string): void {
358
+ const s = this.panes.get(paneId) as DetailSurface;
359
+ if (!s) return;
360
+ s.state = { nodeId: id, selectedId: id, scrollOffset: 0 };
361
+ this.requestBody(id);
362
+ this.invalidateAndRender();
363
+ }
364
+
365
+ private openFocus(id: string): void {
366
+ const active = this.activePane();
367
+ if (active && active.kind === "focus") {
368
+ (active as FocusSurface).setFocus(id);
369
+ this.invalidateAndRender();
370
+ return;
371
+ }
372
+ const panes = workspacePanes(this.workspace);
373
+ const focus = panes.find((p) => p.surface === "focus");
374
+ if (focus) {
375
+ (this.panes.get(focus.id) as FocusSurface).setFocus(id);
376
+ this.setActive(focus.id);
377
+ this.invalidateAndRender();
378
+ return;
379
+ }
380
+ this.workspace = split(this.workspace, this.workspace.activePaneId, "horizontal", "focus");
381
+ this.syncPanes();
382
+ const fp = workspacePanes(this.workspace).find((p) => p.surface === "focus");
383
+ if (fp) {
384
+ (this.panes.get(fp.id) as FocusSurface).setFocus(id);
385
+ this.setActive(fp.id);
386
+ }
387
+ this.invalidateAndRender();
388
+ }
389
+
390
+ private requestBody(id: string): void {
391
+ const node = this.model.nodes.find((n) => n.id === id);
392
+ if (!node) return;
393
+ if (node.kind === "note") this.bodies.load(id, "note", node.detail.slug);
394
+ else if (node.kind === "file") this.bodies.load(id, "file", node.detail.path);
395
+ }
396
+
397
+ render(width: number): string[] {
398
+ const key = `${width}:${this.workspace.activePaneId}:${this.helpOpen}:${this.refreshing}`;
399
+ const cached = this.renderCache.get(key);
400
+ if (cached) return cached;
401
+ const out: string[] = [];
402
+ out.push(...this.renderHeader(width));
403
+ out.push(...this.renderBody(width));
404
+ if (width < 80) out.push(...this.renderTabBar(width));
405
+ out.push(...this.renderFooter(width));
406
+ const clamped = out.slice(0, Math.max(1, this.rows)).map((l) => (visibleWidth(l) > width ? truncateToWidth(l, width) : l));
407
+ this.renderCache.set(key, clamped);
408
+ return clamped;
409
+ }
410
+
411
+ private renderHeader(width: number): string[] {
412
+ const t = this.theme;
413
+ const counts = countProvenance(this.model.nodes);
414
+ const repo = this.model.nodes.find((n) => n.kind === "repository");
415
+ const repoState = repoStaleness(this.model, repo);
416
+ const repoPart = repo ? ` · repo ${repo.label}:${repoState}` : "";
417
+ // Kitty: the raster logo leads on its own row(s) and replaces the glyph in
418
+ // the text strip (decision 1 favicon). Otherwise the one-line glyph header
419
+ // stays intact.
420
+ let imageLines: string[] | null = null;
421
+ if (this.logoImage) {
422
+ try {
423
+ const lines = this.logoImage.render(width);
424
+ if (lines && lines.length) imageLines = lines;
425
+ } catch {
426
+ imageLines = null;
427
+ }
428
+ }
429
+ const mark = imageLines ? "" : this.logo;
430
+ const head = `${mark}${mark ? " " : ""}${t.bold("weave view")}`;
431
+ const fill = "─".repeat(Math.max(1, width - visibleWidth(head) - 2));
432
+ const line1 = `${head} ${fill} ${this.workspace.name} · ${workspacePanes(this.workspace).length} panes${repoPart}`;
433
+ const out: string[] = [];
434
+ if (imageLines) out.push(...imageLines);
435
+ out.push(truncateToWidth(line1, width));
436
+ const banner = this.bannerText();
437
+ if (banner) out.push(t.fg("warning", truncateToWidth(banner, width)));
438
+ const countsLine = `notes ${counts.total} (● ${counts.human} / ◐ ${counts.agent} / ○ ${counts.generated})`;
439
+ out.push(truncateToWidth(t.fg("dim", countsLine), width));
440
+ return out;
441
+ }
442
+
443
+ private bannerText(): string | null {
444
+ return this.refreshing ? "refreshing…" : null;
445
+ }
446
+
447
+ private renderBody(width: number): string[] {
448
+ const effective = collapseForWidth(this.workspace, width);
449
+ const bodyRows = Math.max(1, this.rows - 4);
450
+ const tree = this.buildSplit(effective.root, bodyRows);
451
+ return tree.render(width);
452
+ }
453
+
454
+ private buildSplit(node: WorkspaceNode, rows: number): Component {
455
+ if (node.type === "pane") {
456
+ const s = this.panes.get(node.id);
457
+ if (!s) return { render: () => [], invalidate: () => {} };
458
+ (s as { paneRows?: number }).paneRows = Math.max(1, rows - 3);
459
+ const p = new Pane(s, this.theme);
460
+ p.rows = rows;
461
+ p.setFocused(node.id === this.workspace.activePaneId);
462
+ return p;
463
+ }
464
+ const StackCtor = node.direction === "row" ? HStack : VStack;
465
+ const stack = new StackCtor();
466
+ // VStack stacks panes vertically, so each child's height must be its share
467
+ // of the available rows (by grow weight) — otherwise every child renders at
468
+ // full height, the stack overflows, and the render clamp leaves only the
469
+ // first pane visible (the "split hides the new pane" bug). HStack shares
470
+ // width, so its children keep the full height.
471
+ const vertical = node.direction !== "row";
472
+ const sizes = node.sizes.length ? node.sizes : node.children.map(() => 1);
473
+ const total = sizes.reduce((a, b) => a + b, 0) || node.children.length;
474
+ node.children.forEach((c, i) => {
475
+ const grow = sizes[i] ?? 1;
476
+ const childRows = vertical ? Math.max(1, Math.round((rows * grow) / total)) : rows;
477
+ stack.addChild(this.buildSplit(c, childRows), { grow });
478
+ });
479
+ return stack;
480
+ }
481
+
482
+ private renderTabBar(width: number): string[] {
483
+ const panes = workspacePanes(this.workspace);
484
+ const t = this.theme;
485
+ const parts = panes.map((p) => {
486
+ const active = p.id === this.workspace.activePaneId;
487
+ return active ? t.fg("accent", `[${p.surface}]`) : p.surface;
488
+ });
489
+ return [truncateToWidth(parts.join(" "), width)];
490
+ }
491
+
492
+ private renderFooter(width: number): string[] {
493
+ const t = this.theme;
494
+ if (this.helpOpen) {
495
+ const help = [
496
+ "Tab focus · \\ | split · Ctrl-hjkl resize · x close · e/d/f/h swap",
497
+ "↑↓/jk move · ←→/hl expand · enter open · / filter · p prov · f focus · r refresh · ? help · q quit",
498
+ ];
499
+ return help.map((l) => t.fg("dim", truncateToWidth(l, width)));
500
+ }
501
+ const hint = "Tab focus · \\ split · Ctrl-hjkl resize · e/d/f/h pane · r refresh · ? help · q quit";
502
+ return [t.fg("dim", truncateToWidth(hint, width))];
503
+ }
504
+ }
505
+
506
+ function charCode(data: string): number | undefined {
507
+ if (data.length !== 1) return undefined;
508
+ const c = data.charCodeAt(0);
509
+ return c >= 0 && c < 0x20 ? c : undefined;
510
+ }
511
+
512
+ /** True when the active surface is in a sub-mode that Esc should clear instead
513
+ * of quitting — Explore's search (`state.searching`) or Detail's goto-line
514
+ * (`state.gotoBuf`). Surfaces own their own state; read defensively so a
515
+ * surface without either field simply returns false. */
516
+ function inSubMode(s: Surface | undefined): boolean {
517
+ const st = (s as { state?: { searching?: boolean; gotoBuf?: string | null } }).state;
518
+ return !!st && (st.searching === true || st.gotoBuf != null);
519
+ }
520
+
521
+ function repoStaleness(model: GraphModel, repo: GraphNode | undefined): string {
522
+ if (repo) {
523
+ const st = model.staleness;
524
+ if (st && "state" in st) return st.state;
525
+ return "fresh";
526
+ }
527
+ return "missing";
528
+ }
529
+
530
+ export type { Component };
@@ -1,29 +0,0 @@
1
- /**
2
- * Browser opening for /weave-view (docs/weave-view.md §4).
3
- * Kept separate from the command so the per-platform command mapping is
4
- * unit-testable without stubbing globals.
5
- */
6
-
7
- import { platform } from "node:os";
8
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
-
10
- /** The OS command used to open a URL in the default browser. */
11
- export function browserCommand(
12
- url: string,
13
- os: NodeJS.Platform = platform(),
14
- ): { command: string; args: string[] } {
15
- if (os === "darwin") return { command: "open", args: [url] };
16
- if (os === "win32") return { command: "cmd", args: ["/c", "start", "", url] };
17
- return { command: "xdg-open", args: [url] };
18
- }
19
-
20
- /**
21
- * Open the viewer URL in the user's browser — only in TUI mode with UI
22
- * present; suppressible via PI_WEAVE_VIEW_NO_OPEN=1 (CI/headless/evals).
23
- */
24
- export async function openInBrowser(pi: ExtensionAPI, ctx: ExtensionContext, url: string): Promise<void> {
25
- if (!ctx.hasUI || ctx.mode !== "tui") return;
26
- if (process.env.PI_WEAVE_VIEW_NO_OPEN === "1") return;
27
- const { command, args } = browserCommand(url);
28
- await pi.exec(command, args, { timeout: 5_000 });
29
- }