@neosh/api 0.3.0 → 0.4.0
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/package.json +1 -1
- package/src/generated/ApiCall.ts +37 -0
- package/src/generated/ApiOk.ts +8 -1
- package/src/generated/AscpMessage.ts +2 -0
- package/src/generated/AuthRef.ts +6 -1
- package/src/generated/CredentialSource.ts +1 -0
- package/src/generated/Direction.ts +6 -0
- package/src/generated/Dock.ts +10 -4
- package/src/generated/NodeCapabilities.ts +11 -0
- package/src/generated/PaneChild.ts +18 -0
- package/src/generated/PaneId.ts +15 -0
- package/src/generated/PaneNode.ts +17 -0
- package/src/generated/RemoteProject.ts +18 -0
- package/src/generated/SplitDir.ts +12 -0
- package/src/generated/TabId.ts +10 -0
- package/src/generated/TabInfo.ts +27 -0
- package/src/generated/UiEvent.ts +4 -0
- package/src/generated/WindowLayout.ts +11 -0
- package/src/index.ts +203 -6
- package/src/ui.ts +127 -15
package/package.json
CHANGED
package/src/generated/ApiCall.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { Capability } from "./Capability";
|
|
|
5
5
|
import type { CursorMotion } from "./CursorMotion";
|
|
6
6
|
import type { CursorShape } from "./CursorShape";
|
|
7
7
|
import type { DiffTarget } from "./DiffTarget";
|
|
8
|
+
import type { Direction } from "./Direction";
|
|
8
9
|
import type { DriverKind } from "./DriverKind";
|
|
9
10
|
import type { ExtmarkId } from "./ExtmarkId";
|
|
10
11
|
import type { ExtmarkOpts } from "./ExtmarkOpts";
|
|
@@ -25,6 +26,7 @@ import type { NodeId } from "./NodeId";
|
|
|
25
26
|
import type { NoticeKind } from "./NoticeKind";
|
|
26
27
|
import type { OptionSpec } from "./OptionSpec";
|
|
27
28
|
import type { OptionValue } from "./OptionValue";
|
|
29
|
+
import type { PaneId } from "./PaneId";
|
|
28
30
|
import type { PermissionMode } from "./PermissionMode";
|
|
29
31
|
import type { ProjectKey } from "./ProjectKey";
|
|
30
32
|
import type { ProviderEvent } from "./ProviderEvent";
|
|
@@ -36,6 +38,7 @@ import type { StatusSegment } from "./StatusSegment";
|
|
|
36
38
|
import type { StreamId } from "./StreamId";
|
|
37
39
|
import type { SurfaceCell } from "./SurfaceCell";
|
|
38
40
|
import type { SurfaceId } from "./SurfaceId";
|
|
41
|
+
import type { TabId } from "./TabId";
|
|
39
42
|
import type { TextEdit } from "./TextEdit";
|
|
40
43
|
import type { ToolDef } from "./ToolDef";
|
|
41
44
|
import type { UsageResolution } from "./UsageResolution";
|
|
@@ -55,6 +58,7 @@ export type ApiCall =
|
|
|
55
58
|
*/
|
|
56
59
|
kind?: string | null;
|
|
57
60
|
}
|
|
61
|
+
| { "call": "buf_delete"; buf: BufferId }
|
|
58
62
|
| { "call": "buf_line_count"; buf: BufferId }
|
|
59
63
|
| {
|
|
60
64
|
"call": "buf_get_lines";
|
|
@@ -110,6 +114,38 @@ export type ApiCall =
|
|
|
110
114
|
| { "call": "win_get_viewport"; win: WindowId }
|
|
111
115
|
| { "call": "win_scroll_to"; win: WindowId; top_line: number | null }
|
|
112
116
|
| { "call": "win_list" }
|
|
117
|
+
| {
|
|
118
|
+
"call": "pane_split";
|
|
119
|
+
pane: PaneId;
|
|
120
|
+
/**
|
|
121
|
+
* Which side of `pane` the new one takes. `Right` and `Down` put it after `pane` in the
|
|
122
|
+
* split; `Left` and `Up` put it before.
|
|
123
|
+
*/
|
|
124
|
+
dir: Direction;
|
|
125
|
+
}
|
|
126
|
+
| { "call": "pane_close"; pane: PaneId }
|
|
127
|
+
| { "call": "pane_focus"; pane: PaneId }
|
|
128
|
+
| { "call": "pane_focus_dir"; view?: ViewId | null; dir: Direction }
|
|
129
|
+
| { "call": "pane_resize"; pane: PaneId; dir: Direction; delta: number }
|
|
130
|
+
| { "call": "pane_swap"; pane: PaneId; with: PaneId }
|
|
131
|
+
| { "call": "pane_move_edge"; pane: PaneId; dir: Direction }
|
|
132
|
+
| { "call": "pane_equalize"; view?: ViewId | null }
|
|
133
|
+
| {
|
|
134
|
+
"call": "tab_new";
|
|
135
|
+
view?: ViewId | null;
|
|
136
|
+
title?: string | null;
|
|
137
|
+
/**
|
|
138
|
+
* Whether to go to it. A tab opened by a key is; a tab opened by an orchestrator putting
|
|
139
|
+
* work somewhere is not, for the same reason `SessionNew { activate: false }` exists.
|
|
140
|
+
*/
|
|
141
|
+
activate: boolean;
|
|
142
|
+
}
|
|
143
|
+
| { "call": "tab_close"; tab: TabId }
|
|
144
|
+
| { "call": "tab_select"; tab: TabId }
|
|
145
|
+
| { "call": "tab_step"; view?: ViewId | null; delta: number }
|
|
146
|
+
| { "call": "tab_move"; tab: TabId; to: number }
|
|
147
|
+
| { "call": "tab_rename"; tab: TabId; title: string | null }
|
|
148
|
+
| { "call": "tab_list"; view?: ViewId | null }
|
|
113
149
|
| {
|
|
114
150
|
"call": "win_motion";
|
|
115
151
|
win: WindowId;
|
|
@@ -370,6 +406,7 @@ export type ApiCall =
|
|
|
370
406
|
| { "call": "swarm_reconnect"; node: NodeId }
|
|
371
407
|
| { "call": "swarm_disconnect"; node: NodeId }
|
|
372
408
|
| { "call": "swarm_strangers" }
|
|
409
|
+
| { "call": "swarm_browse"; node: NodeId; prefix: string }
|
|
373
410
|
| { "call": "quota_list" }
|
|
374
411
|
| { "call": "quota_refresh"; instance?: InstanceId | null }
|
|
375
412
|
| { "call": "quota_report"; snapshot: QuotaSnapshot }
|
package/src/generated/ApiOk.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type { ModelSelection } from "./ModelSelection";
|
|
|
20
20
|
import type { NamespaceId } from "./NamespaceId";
|
|
21
21
|
import type { NodeInfo } from "./NodeInfo";
|
|
22
22
|
import type { OptionEntry } from "./OptionEntry";
|
|
23
|
+
import type { PaneId } from "./PaneId";
|
|
23
24
|
import type { PermissionDecision } from "./PermissionDecision";
|
|
24
25
|
import type { PermissionMode } from "./PermissionMode";
|
|
25
26
|
import type { PluginInfo } from "./PluginInfo";
|
|
@@ -34,6 +35,8 @@ import type { SurfaceId } from "./SurfaceId";
|
|
|
34
35
|
import type { SwarmAgent } from "./SwarmAgent";
|
|
35
36
|
import type { SwarmNode } from "./SwarmNode";
|
|
36
37
|
import type { SwarmStranger } from "./SwarmStranger";
|
|
38
|
+
import type { TabId } from "./TabId";
|
|
39
|
+
import type { TabInfo } from "./TabInfo";
|
|
37
40
|
import type { ToolDef } from "./ToolDef";
|
|
38
41
|
import type { UpdateOutcome } from "./UpdateOutcome";
|
|
39
42
|
import type { UpdateStatus } from "./UpdateStatus";
|
|
@@ -56,6 +59,9 @@ export type ApiOk =
|
|
|
56
59
|
| { "ok": "views"; views: Array<ViewInfo> }
|
|
57
60
|
| { "ok": "ns"; ns: NamespaceId }
|
|
58
61
|
| { "ok": "mark"; id: ExtmarkId }
|
|
62
|
+
| { "ok": "pane"; pane: PaneId | null }
|
|
63
|
+
| { "ok": "tab"; tab: TabId }
|
|
64
|
+
| { "ok": "tabs"; tabs: Array<TabInfo>; active: TabId }
|
|
59
65
|
| { "ok": "surface"; surface: SurfaceId }
|
|
60
66
|
| { "ok": "lines"; lines: Array<string> }
|
|
61
67
|
| { "ok": "count"; n: number }
|
|
@@ -76,7 +82,7 @@ export type ApiOk =
|
|
|
76
82
|
| { "ok": "focused_win"; win: WindowId | null }
|
|
77
83
|
| { "ok": "option"; entry: OptionEntry | null }
|
|
78
84
|
| { "ok": "options"; options: Array<OptionEntry> }
|
|
79
|
-
| { "ok": "paths"; paths: Array<string
|
|
85
|
+
| { "ok": "paths"; paths: Array<string>; denied?: string | null }
|
|
80
86
|
| { "ok": "viewport"; viewport: Viewport | null }
|
|
81
87
|
| { "ok": "sessions"; sessions: Array<SessionInfo> }
|
|
82
88
|
| { "ok": "credentials"; credentials: Array<CredentialInfo> }
|
|
@@ -107,6 +113,7 @@ export type ApiOk =
|
|
|
107
113
|
| { "ok": "swarm_nodes"; nodes: Array<SwarmNode> }
|
|
108
114
|
| { "ok": "swarm_agents"; agents: Array<SwarmAgent> }
|
|
109
115
|
| { "ok": "swarm_strangers"; strangers: Array<SwarmStranger> }
|
|
116
|
+
| { "ok": "swarm_commanded"; session?: SessionId | null }
|
|
110
117
|
| { "ok": "quotas"; quotas: Array<QuotaSnapshot> }
|
|
111
118
|
| { "ok": "quota_history"; samples: Array<QuotaSample> }
|
|
112
119
|
| { "ok": "usage_history"; history: UsageHistory }
|
|
@@ -70,4 +70,6 @@ export type AscpMessage =
|
|
|
70
70
|
session?: SessionId | null;
|
|
71
71
|
}
|
|
72
72
|
| { "type": "refused"; id: string; refusal: Refusal }
|
|
73
|
+
| { "type": "browse"; id: string; prefix: string }
|
|
74
|
+
| { "type": "browsed"; id: string; paths: Array<string> }
|
|
73
75
|
| { "type": "goodbye"; message?: string | null };
|
package/src/generated/AuthRef.ts
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
export type AuthRef =
|
|
10
10
|
| { "kind": "env"; var: string }
|
|
11
11
|
| { "kind": "command"; argv: Array<string> }
|
|
12
|
-
| {
|
|
12
|
+
| {
|
|
13
|
+
"kind": "cli";
|
|
14
|
+
program: string;
|
|
15
|
+
login?: string | null;
|
|
16
|
+
retired?: string | null;
|
|
17
|
+
}
|
|
13
18
|
| { "kind": "inherited" }
|
|
14
19
|
| { "kind": "none" };
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
export type CredentialSource =
|
|
10
10
|
| { "kind": "plan"; via: string }
|
|
11
11
|
| { "kind": "plan_missing"; program: string; hint?: string | null }
|
|
12
|
+
| { "kind": "plan_retired"; program: string; note: string }
|
|
12
13
|
| { "kind": "env"; var: string }
|
|
13
14
|
| { "kind": "keychain" }
|
|
14
15
|
| { "kind": "session" }
|
package/src/generated/Dock.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* Which edge of a rectangle a non-floating window sits against.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* The rectangle is the screen, or — when the window names a [`PaneId`] — one pane of the main
|
|
7
|
+
* region. That is the only difference between the two, and it is what let splitting arrive without
|
|
8
|
+
* a second geometry vocabulary: a composer docked `Bottom` in a pane is the same call as a
|
|
9
|
+
* composer docked `Bottom` on the screen, asked about a smaller rectangle.
|
|
10
|
+
*
|
|
11
|
+
* Still not an arbitrary split tree *here*: a window says which edge of which rectangle it wants,
|
|
12
|
+
* and the tree that makes those rectangles is [`PaneNode`], one level up. Keeping the two apart is
|
|
13
|
+
* what stops every plugin from having to reason about nesting to place a status line.
|
|
8
14
|
*/
|
|
9
|
-
export type Dock = "left" | "right" | "bottom" | "main";
|
|
15
|
+
export type Dock = "left" | "right" | "top" | "bottom" | "main";
|
|
@@ -23,6 +23,17 @@ export type NodeCapabilities = {
|
|
|
23
23
|
* Whether [`AscpMessage::Subscribe`] will produce anything.
|
|
24
24
|
*/
|
|
25
25
|
streams: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Whether [`AscpMessage::Browse`] will be answered.
|
|
28
|
+
*
|
|
29
|
+
* Not a permission — it follows `accepts_commands`, for the reason written on `Browse` — but a
|
|
30
|
+
* *compatibility* flag, and that is why it is here rather than derived. A node built before
|
|
31
|
+
* `Browse` existed cannot skip a message it has never heard of: the frame parses or the
|
|
32
|
+
* connection fails, so a new node that sent one on spec would drop an old peer's link every
|
|
33
|
+
* time somebody opened a directory picker. It defaults to `false`, which is exactly what an
|
|
34
|
+
* older node's handshake decodes to, so "does not say" and "cannot" are the same answer.
|
|
35
|
+
*/
|
|
36
|
+
browse: boolean;
|
|
26
37
|
/**
|
|
27
38
|
* The checkouts this node has, for starting something on it.
|
|
28
39
|
*/
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
|
2
|
+
import type { PaneNode } from "./PaneNode";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One child of a split, and how much of the axis it gets.
|
|
6
|
+
*/
|
|
7
|
+
export type PaneChild = {
|
|
8
|
+
node: PaneNode;
|
|
9
|
+
/**
|
|
10
|
+
* Share of the parent's axis, relative to its siblings.
|
|
11
|
+
*
|
|
12
|
+
* A weight rather than cells, because the terminal is resized by somebody dragging its corner
|
|
13
|
+
* and a layout stored in cells has to be recomputed — badly, and with rounding that
|
|
14
|
+
* accumulates — every time that happens. Weights survive a resize by meaning the same thing at
|
|
15
|
+
* every size. [`WEIGHT`] is what an even split is made of.
|
|
16
|
+
*/
|
|
17
|
+
weight: number;
|
|
18
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One rectangle of the main region, and everything docked inside it.
|
|
5
|
+
*
|
|
6
|
+
* A pane is *where* rather than *what*: it owns no buffer and draws nothing. A chat pane is a
|
|
7
|
+
* transcript window docked `Main` in it plus a composer docked `Bottom` in it; a pane showing
|
|
8
|
+
* one thing is one window docked `Main`. That is the whole of why splitting did not need a
|
|
9
|
+
* second dock vocabulary — [`Dock`](crate::Dock) already answers "where inside this rectangle",
|
|
10
|
+
* and a pane is just a smaller rectangle to ask it about.
|
|
11
|
+
*
|
|
12
|
+
* Panes live in a tree per tab ([`PaneNode`](crate::PaneNode)). The tree says how the main
|
|
13
|
+
* region is divided; the frontend turns it into rectangles, exactly as it does for docks.
|
|
14
|
+
*/
|
|
15
|
+
export type PaneId = number;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
|
2
|
+
import type { PaneChild } from "./PaneChild";
|
|
3
|
+
import type { PaneId } from "./PaneId";
|
|
4
|
+
import type { SplitDir } from "./SplitDir";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* How the main region of one tab is divided.
|
|
8
|
+
*
|
|
9
|
+
* A tree rather than a list because a split of a split is the thing people actually build — a chat
|
|
10
|
+
* on the left, and on the right a chat above a terminal — and a flat list of rectangles cannot say
|
|
11
|
+
* that without every consumer re-deriving the nesting from coordinates.
|
|
12
|
+
*/
|
|
13
|
+
export type PaneNode = { "kind": "leaf"; pane: PaneId } | {
|
|
14
|
+
"kind": "split";
|
|
15
|
+
dir: SplitDir;
|
|
16
|
+
children: Array<PaneChild>;
|
|
17
|
+
};
|
|
@@ -14,6 +14,24 @@ export type RemoteProject = {
|
|
|
14
14
|
cwd: string;
|
|
15
15
|
/**
|
|
16
16
|
* Whether this node has a conversation open in it now.
|
|
17
|
+
*
|
|
18
|
+
* Meant something for exactly as long as this list was derived from live conversations: every
|
|
19
|
+
* project on it had one, so every one of them said `true` and a board drawing the flag drew
|
|
20
|
+
* one colour. A node now advertises the places it *works in* — which is the list its own panel
|
|
21
|
+
* shows, and includes the project you cleared out this morning — so the two states exist and
|
|
22
|
+
* the flag is worth reading.
|
|
17
23
|
*/
|
|
18
24
|
active: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* How many conversations are open in it there.
|
|
27
|
+
*/
|
|
28
|
+
sessions: number;
|
|
29
|
+
/**
|
|
30
|
+
* How many of those are mid-turn.
|
|
31
|
+
*
|
|
32
|
+
* Separate from `sessions` because they are different questions and a board answers both in
|
|
33
|
+
* one row: `3 conversations · 1 working` is what the sidebar says about a local project, and a
|
|
34
|
+
* remote one should not have to say less.
|
|
35
|
+
*/
|
|
36
|
+
running: number;
|
|
19
37
|
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which way a split lays its children out.
|
|
5
|
+
*
|
|
6
|
+
* Named for what it does to the screen rather than for the key that made it, because those two
|
|
7
|
+
* disagree in every editor that has ever shipped one: Vim's `:vsplit` produces panes side by side,
|
|
8
|
+
* and a `Vertical` that means "stacked" to the layout code and "side by side" to the user is a
|
|
9
|
+
* bug waiting for its first off-by-one. `Row` is a row of panes; `Column` is a column of them.
|
|
10
|
+
* The *commands* say `right` and `down`, which is the one description nobody can misread.
|
|
11
|
+
*/
|
|
12
|
+
export type SplitDir = "row" | "column";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One tab of one view: a title, and a tree of panes.
|
|
5
|
+
*
|
|
6
|
+
* Per view rather than per workspace, for the same reason a window is: what the agent produced
|
|
7
|
+
* is the workspace's, and where you are looking is yours. Two terminals attached to one
|
|
8
|
+
* workspace have their own tabs over the same conversations.
|
|
9
|
+
*/
|
|
10
|
+
export type TabId = number;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
|
2
|
+
import type { PaneId } from "./PaneId";
|
|
3
|
+
import type { PaneNode } from "./PaneNode";
|
|
4
|
+
import type { TabId } from "./TabId";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One tab of one view.
|
|
8
|
+
*/
|
|
9
|
+
export type TabInfo = {
|
|
10
|
+
id: TabId;
|
|
11
|
+
/**
|
|
12
|
+
* What the tab bar calls it.
|
|
13
|
+
*
|
|
14
|
+
* `None` is "name it after what is in it" — the conversation's title, the terminal's command —
|
|
15
|
+
* which is what a tab nobody has renamed should say, and it has to stay `None` to keep saying
|
|
16
|
+
* it: a title resolved once and stored is a tab still called `main` three conversations later.
|
|
17
|
+
*/
|
|
18
|
+
title?: string | null;
|
|
19
|
+
root: PaneNode;
|
|
20
|
+
/**
|
|
21
|
+
* Which pane in this tab has the keyboard when the tab does.
|
|
22
|
+
*
|
|
23
|
+
* Per tab rather than per view, so that leaving a tab and coming back puts you where you were
|
|
24
|
+
* rather than in whichever pane happens to be first.
|
|
25
|
+
*/
|
|
26
|
+
active_pane: PaneId;
|
|
27
|
+
};
|
package/src/generated/UiEvent.ts
CHANGED
|
@@ -8,6 +8,8 @@ import type { NoticeKind } from "./NoticeKind";
|
|
|
8
8
|
import type { Rect } from "./Rect";
|
|
9
9
|
import type { SurfaceCell } from "./SurfaceCell";
|
|
10
10
|
import type { SurfaceId } from "./SurfaceId";
|
|
11
|
+
import type { TabId } from "./TabId";
|
|
12
|
+
import type { TabInfo } from "./TabInfo";
|
|
11
13
|
import type { WindowId } from "./WindowId";
|
|
12
14
|
import type { WindowLayout } from "./WindowLayout";
|
|
13
15
|
|
|
@@ -45,7 +47,9 @@ export type UiEvent =
|
|
|
45
47
|
| { "type": "surface_claimed"; surface: SurfaceId; win: WindowId; rect: Rect }
|
|
46
48
|
| { "type": "surface_cells"; surface: SurfaceId; cells: Array<SurfaceCell> }
|
|
47
49
|
| { "type": "surface_released"; surface: SurfaceId }
|
|
50
|
+
| { "type": "panes_changed"; tabs: Array<TabInfo>; active: TabId }
|
|
48
51
|
| { "type": "focus_changed"; win: WindowId | null }
|
|
52
|
+
| { "type": "home_changed"; win: WindowId | null }
|
|
49
53
|
| {
|
|
50
54
|
"type": "message";
|
|
51
55
|
level: MessageLevel;
|
|
@@ -2,12 +2,23 @@
|
|
|
2
2
|
import type { Dock } from "./Dock";
|
|
3
3
|
import type { FloatConfig } from "./FloatConfig";
|
|
4
4
|
import type { Gravity } from "./Gravity";
|
|
5
|
+
import type { PaneId } from "./PaneId";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Declarative placement for a window. The frontend turns this into a rectangle.
|
|
8
9
|
*/
|
|
9
10
|
export type WindowLayout = {
|
|
10
11
|
"kind": "docked";
|
|
12
|
+
/**
|
|
13
|
+
* Which pane's rectangle `dock` is measured against, or the screen's when absent.
|
|
14
|
+
*
|
|
15
|
+
* An `Option` rather than a pane that happens to mean "the whole screen", because the two
|
|
16
|
+
* are different things and one of them outlives the other: a pane is closed when its split
|
|
17
|
+
* is, and a window docked to the screen — the sidebar, the status line — must not go with
|
|
18
|
+
* it. A window naming a pane that has gone is not drawn at all, which is the honest answer
|
|
19
|
+
* and the one the frontend can give without inventing a rectangle.
|
|
20
|
+
*/
|
|
21
|
+
pane?: PaneId | null;
|
|
11
22
|
dock: Dock;
|
|
12
23
|
/**
|
|
13
24
|
* Preferred extent along the dock's variable axis.
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,10 @@ import type { ApiError } from "./generated/ApiError";
|
|
|
17
17
|
import type { ApiOk } from "./generated/ApiOk";
|
|
18
18
|
import type { ApiResponse } from "./generated/ApiResponse";
|
|
19
19
|
import type { BranchInfo } from "./generated/BranchInfo";
|
|
20
|
+
import type { Direction } from "./generated/Direction";
|
|
21
|
+
import type { PaneId } from "./generated/PaneId";
|
|
22
|
+
import type { TabId } from "./generated/TabId";
|
|
23
|
+
import type { TabInfo } from "./generated/TabInfo";
|
|
20
24
|
import type { BufferId } from "./generated/BufferId";
|
|
21
25
|
import type { Capability } from "./generated/Capability";
|
|
22
26
|
import type { CommitInfo } from "./generated/CommitInfo";
|
|
@@ -137,7 +141,8 @@ import type { WorktreeInfo } from "./generated/WorktreeInfo";
|
|
|
137
141
|
export type {
|
|
138
142
|
AccountKind, Activity, ApiError, BranchInfo, Brand, BufferId, Capability, CommandEntry, CommitInfo,
|
|
139
143
|
AgentCommand, AgentState, AgentSummary,
|
|
140
|
-
Contribution, CredentialInfo, CredentialSource, CursorShape, DiffTarget, Dock, CursorMotion, ExtmarkId, ExtmarkInfo, ExtmarkOpts, FileChange, FileState, FloatConfig,
|
|
144
|
+
Contribution, CredentialInfo, CredentialSource, CursorShape, DiffTarget, Direction, Dock, CursorMotion, ExtmarkId, ExtmarkInfo, ExtmarkOpts, FileChange, FileState, FloatConfig,
|
|
145
|
+
PaneId, TabId, TabInfo,
|
|
141
146
|
Gravity, HighlightDef, HighlightEntry, HighlightSpec, Hint, HlTarget, HookName, HookOutcome, HookPayload, InstanceConfig, KeyContext,
|
|
142
147
|
AttachmentInfo,
|
|
143
148
|
KeymapEntry, KeymapScope, MessageLevel, Mode, ModelEntry, ModelInfo, ModelSelection, ModelTier, NamespaceId,
|
|
@@ -385,6 +390,10 @@ export interface Neosh {
|
|
|
385
390
|
readonly version: number;
|
|
386
391
|
readonly buf: BufferApi;
|
|
387
392
|
readonly win: WindowApi;
|
|
393
|
+
/** The panes of the main region. See {@link PaneApi}. */
|
|
394
|
+
readonly pane: PaneApi;
|
|
395
|
+
/** The tabs each terminal has. See {@link TabApi}. */
|
|
396
|
+
readonly tab: TabApi;
|
|
388
397
|
readonly float: FloatApi;
|
|
389
398
|
readonly edit: EditApi;
|
|
390
399
|
readonly ns: NamespaceApi;
|
|
@@ -558,6 +567,84 @@ export interface BufferApi {
|
|
|
558
567
|
): Promise<Disposable>;
|
|
559
568
|
}
|
|
560
569
|
|
|
570
|
+
/**
|
|
571
|
+
* The panes of the main region, and the tabs they live in.
|
|
572
|
+
*
|
|
573
|
+
* A pane is *where*, not *what*: it owns no buffer and draws nothing. You split one, then dock
|
|
574
|
+
* windows into it with {@link WindowApi.open} — which is why splitting a chat and splitting a
|
|
575
|
+
* terminal are the same two calls.
|
|
576
|
+
*
|
|
577
|
+
* Everything here is what the workspace's own `<C-w>` keys are built from. There is no private
|
|
578
|
+
* path: `<C-w>v` is `pane.split(await pane.active(), "right")` and nothing else, so a plugin that
|
|
579
|
+
* wants a different window manager can have one without forking anything.
|
|
580
|
+
*/
|
|
581
|
+
export interface PaneApi {
|
|
582
|
+
/** The pane this terminal's keys belong to. */
|
|
583
|
+
active(view?: ViewId): Promise<PaneId>;
|
|
584
|
+
/**
|
|
585
|
+
* Divide a pane in two, and answer with the new one.
|
|
586
|
+
*
|
|
587
|
+
* The new pane is *empty* — nothing is drawn in it until something is docked into it. Focus does
|
|
588
|
+
* not move either: a plugin splitting to show you something and a person pressing a key to go
|
|
589
|
+
* and work there are different intentions, and only the second one wants the keyboard.
|
|
590
|
+
*/
|
|
591
|
+
split(pane: PaneId, dir: Direction): Promise<PaneId>;
|
|
592
|
+
/**
|
|
593
|
+
* Close a pane and every window docked in it.
|
|
594
|
+
*
|
|
595
|
+
* Rejects on the last pane of the last tab: a terminal with nowhere to draw has no key that
|
|
596
|
+
* would bring a pane back.
|
|
597
|
+
*/
|
|
598
|
+
close(pane: PaneId): Promise<void>;
|
|
599
|
+
focus(pane: PaneId): Promise<void>;
|
|
600
|
+
/**
|
|
601
|
+
* Move the keyboard to the nearest pane in a direction, and answer with it — or `null` at the
|
|
602
|
+
* edge of the layout, which is an ordinary outcome of holding a key down rather than an error.
|
|
603
|
+
* There is deliberately no wrap-around.
|
|
604
|
+
*/
|
|
605
|
+
focusDir(dir: Direction, view?: ViewId): Promise<PaneId | null>;
|
|
606
|
+
/**
|
|
607
|
+
* Move the boundary on a pane's `dir` side. `delta` is in weight units — an even split is
|
|
608
|
+
* {@link WEIGHT} each — so a resize means the same thing at every terminal size and survives the
|
|
609
|
+
* terminal being resized under it. A pane with no boundary on that side is a no-op.
|
|
610
|
+
*/
|
|
611
|
+
resize(pane: PaneId, dir: Direction, delta: number): Promise<void>;
|
|
612
|
+
/**
|
|
613
|
+
* Exchange two panes' places, keeping what is in each. The weights belong to the slots, so
|
|
614
|
+
* swapping into the wide one is how you make a pane wide.
|
|
615
|
+
*/
|
|
616
|
+
swap(pane: PaneId, withPane: PaneId): Promise<void>;
|
|
617
|
+
/**
|
|
618
|
+
* Send a pane to the far edge of its tab — the counterpart to {@link PaneApi.focusDir}, which
|
|
619
|
+
* moves the keyboard rather than the pane. It arrives with an even share rather than the size it
|
|
620
|
+
* had: it is among different neighbours now, and carrying the old number over would make it the
|
|
621
|
+
* odd one out for a reason nobody could see.
|
|
622
|
+
*/
|
|
623
|
+
moveToEdge(pane: PaneId, dir: Direction): Promise<void>;
|
|
624
|
+
/** Give every pane of the active tab an equal share, at every level. */
|
|
625
|
+
equalize(view?: ViewId): Promise<void>;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** The tabs of one terminal. Each is a title and a tree of panes. */
|
|
629
|
+
export interface TabApi {
|
|
630
|
+
/** Every tab of a terminal, its panes, and which one is on screen. */
|
|
631
|
+
list(view?: ViewId): Promise<{ tabs: TabInfo[]; active: TabId }>;
|
|
632
|
+
/**
|
|
633
|
+
* Open a tab with one empty pane. `activate` goes to it — `false` is for putting work somewhere
|
|
634
|
+
* without moving the screen out from under whoever is reading it.
|
|
635
|
+
*/
|
|
636
|
+
create(opts?: { title?: string; activate?: boolean; view?: ViewId }): Promise<TabId>;
|
|
637
|
+
/** Close a tab and every pane in it. Rejects on a terminal's last tab. */
|
|
638
|
+
close(tab: TabId): Promise<void>;
|
|
639
|
+
select(tab: TabId): Promise<void>;
|
|
640
|
+
/** Go `delta` tabs along, wrapping — a tab bar is a list you can see all of. */
|
|
641
|
+
step(delta: number, view?: ViewId): Promise<void>;
|
|
642
|
+
/** Move a tab along the bar, clamped at the ends. This is a drag; a drag that teleports is a bug. */
|
|
643
|
+
move(tab: TabId, to: number): Promise<void>;
|
|
644
|
+
/** Name a tab, or pass `null` to give it back its derived name. */
|
|
645
|
+
rename(tab: TabId, title: string | null): Promise<void>;
|
|
646
|
+
}
|
|
647
|
+
|
|
561
648
|
export interface WindowApi {
|
|
562
649
|
/**
|
|
563
650
|
* `gravity` is which end short content settles against: `"start"` (the default) pins it to the
|
|
@@ -568,11 +655,17 @@ export interface WindowApi {
|
|
|
568
655
|
* wrapped a long path would reflow every row below it — but a text field is prose and wants
|
|
569
656
|
* this on. A bottom dock that wraps also grows to show the folded rows, `size` acting as its
|
|
570
657
|
* floor.
|
|
658
|
+
*
|
|
659
|
+
* `pane` docks the window inside one pane of the main region rather than against the screen.
|
|
660
|
+
* The same four edges either way — a composer at the foot of a pane is this call with a `pane`,
|
|
661
|
+
* and a status line along the foot of the terminal is this call without one. Omit it for
|
|
662
|
+
* anything that belongs to the whole terminal: a sidebar docked into a pane would be a sidebar
|
|
663
|
+
* that disappears when you close that split.
|
|
571
664
|
*/
|
|
572
665
|
open(
|
|
573
666
|
buf: BufferId,
|
|
574
667
|
dock: Dock,
|
|
575
|
-
opts?: { size?: number; gravity?: Gravity; wrap?: boolean },
|
|
668
|
+
opts?: { size?: number; gravity?: Gravity; wrap?: boolean; pane?: PaneId },
|
|
576
669
|
): Promise<WindowId>;
|
|
577
670
|
close(win: WindowId): Promise<void>;
|
|
578
671
|
/**
|
|
@@ -1612,8 +1705,29 @@ export interface SwarmApi {
|
|
|
1612
1705
|
* with the owner's reason when it says no. A node may refuse anything; `NodeCapabilities` on its
|
|
1613
1706
|
* {@link SwarmNode} says in advance what it is likely to accept, so a menu can grey out a verb
|
|
1614
1707
|
* rather than offering one that will bounce.
|
|
1708
|
+
*
|
|
1709
|
+
* Answers with the conversation a `new_session` created, and `null` for everything else — which
|
|
1710
|
+
* is what makes starting something over there a place you can then go: pass it to
|
|
1711
|
+
* {@link subscribe}, or to `swarm.open`, without waiting for the next inventory to notice it.
|
|
1615
1712
|
*/
|
|
1616
|
-
command(node: NodeId, session: string, command: AgentCommand): Promise<
|
|
1713
|
+
command(node: NodeId, session: string, command: AgentCommand): Promise<string | null>;
|
|
1714
|
+
/**
|
|
1715
|
+
* Which directories on another machine start with `prefix` — `path.complete`, asked over there.
|
|
1716
|
+
*
|
|
1717
|
+
* The same answers in the same shape as the local one: each entry as you would have typed it,
|
|
1718
|
+
* trailing separator and all, so a single field can complete against either machine and the only
|
|
1719
|
+
* thing that changes is which one is asked. Directory names only.
|
|
1720
|
+
*
|
|
1721
|
+
* {@link NodeCapabilities.projects} is what a machine *offers*, and it is the short list of
|
|
1722
|
+
* places it already works in; this is how you reach the directory over there that neither machine
|
|
1723
|
+
* has ever opened. Rejects when the peer is not connected, when it does not accept commands, and
|
|
1724
|
+
* when it is running a neosh too old to have heard of the question — the message says which, and
|
|
1725
|
+
* an empty list is a directory with nothing in it rather than a failure.
|
|
1726
|
+
*
|
|
1727
|
+
* Passing this machine's own node id answers locally, so a picker that walks `nodes()` needs no
|
|
1728
|
+
* special case for the row that is itself.
|
|
1729
|
+
*/
|
|
1730
|
+
browse(node: NodeId, prefix?: string): Promise<string[]>;
|
|
1617
1731
|
/**
|
|
1618
1732
|
* Watch a remote conversation: its history now, then everything as it happens, delivered to
|
|
1619
1733
|
* {@link onStream}.
|
|
@@ -1706,8 +1820,15 @@ export interface PathApi {
|
|
|
1706
1820
|
*
|
|
1707
1821
|
* `~` expands against the home directory. A prefix with no `/` completes against the active
|
|
1708
1822
|
* conversation's directory, which is what someone typing `src` means.
|
|
1823
|
+
*
|
|
1824
|
+
* `denied` is set when the list is empty *because the operating system refused*, rather than
|
|
1825
|
+
* because nothing matched. The two are the same empty array and they are not the same answer:
|
|
1826
|
+
* macOS grants folder access to the **terminal**, so `~/Documents` full of projects reads as
|
|
1827
|
+
* empty until somebody ticks a box, and a field that draws "no directory matches" over that
|
|
1828
|
+
* sends people to go and check a path that was right all along. When it is set it says which
|
|
1829
|
+
* folder, which application and which System Settings pane, and it is meant to be shown.
|
|
1709
1830
|
*/
|
|
1710
|
-
complete(prefix: string): Promise<string[]>;
|
|
1831
|
+
complete(prefix: string): Promise<{ paths: string[]; denied?: string }>;
|
|
1711
1832
|
}
|
|
1712
1833
|
|
|
1713
1834
|
export interface TimerApi {
|
|
@@ -1979,7 +2100,8 @@ function build(
|
|
|
1979
2100
|
},
|
|
1980
2101
|
path: {
|
|
1981
2102
|
async complete(prefix) {
|
|
1982
|
-
|
|
2103
|
+
const answer = expect(await c({ call: "path_complete", prefix }), "paths");
|
|
2104
|
+
return { paths: answer.paths, denied: answer.denied ?? undefined };
|
|
1983
2105
|
},
|
|
1984
2106
|
},
|
|
1985
2107
|
state: {
|
|
@@ -2080,7 +2202,13 @@ function build(
|
|
|
2080
2202
|
return expect(await c({ call: "swarm_hosts_of", project }), "names").names;
|
|
2081
2203
|
},
|
|
2082
2204
|
async command(node, session, command) {
|
|
2083
|
-
await c({ call: "swarm_command", node, session, command });
|
|
2205
|
+
const done = await c({ call: "swarm_command", node, session, command });
|
|
2206
|
+
// Only `new_session` says anything, so an `ok` of any other shape is an older peer
|
|
2207
|
+
// answering a command that had nothing to report — which is a success, not a mismatch.
|
|
2208
|
+
return done.ok === "swarm_commanded" ? done.session ?? null : null;
|
|
2209
|
+
},
|
|
2210
|
+
async browse(node, prefix) {
|
|
2211
|
+
return expect(await c({ call: "swarm_browse", node, prefix: prefix ?? "" }), "paths").paths;
|
|
2084
2212
|
},
|
|
2085
2213
|
async subscribe(node, session) {
|
|
2086
2214
|
await c({ call: "swarm_subscribe", node, session });
|
|
@@ -2272,10 +2400,79 @@ function build(
|
|
|
2272
2400
|
return listener(list, cb);
|
|
2273
2401
|
},
|
|
2274
2402
|
},
|
|
2403
|
+
pane: {
|
|
2404
|
+
async active(view) {
|
|
2405
|
+
const { tabs, active } = expect(await c({ call: "tab_list", view }), "tabs");
|
|
2406
|
+
// The active tab, or the first — a terminal always has at least one, and a `tabs` that
|
|
2407
|
+
// came back empty is a workspace that has gone wrong in a way this cannot paper over.
|
|
2408
|
+
const tab = tabs.find((t) => t.id === active) ?? tabs[0];
|
|
2409
|
+
if (!tab) throw new Error("this terminal has no tabs");
|
|
2410
|
+
return tab.active_pane;
|
|
2411
|
+
},
|
|
2412
|
+
async split(pane, dir) {
|
|
2413
|
+
const p = expect(await c({ call: "pane_split", pane, dir }), "pane").pane;
|
|
2414
|
+
if (p === null) throw new Error(`pane ${pane} could not be split`);
|
|
2415
|
+
return p;
|
|
2416
|
+
},
|
|
2417
|
+
async close(pane) {
|
|
2418
|
+
await c({ call: "pane_close", pane });
|
|
2419
|
+
},
|
|
2420
|
+
async focus(pane) {
|
|
2421
|
+
await c({ call: "pane_focus", pane });
|
|
2422
|
+
},
|
|
2423
|
+
async focusDir(dir, view) {
|
|
2424
|
+
return expect(await c({ call: "pane_focus_dir", dir, view }), "pane").pane;
|
|
2425
|
+
},
|
|
2426
|
+
async resize(pane, dir, delta) {
|
|
2427
|
+
await c({ call: "pane_resize", pane, dir, delta });
|
|
2428
|
+
},
|
|
2429
|
+
async swap(pane, withPane) {
|
|
2430
|
+
await c({ call: "pane_swap", pane, with: withPane });
|
|
2431
|
+
},
|
|
2432
|
+
async moveToEdge(pane, dir) {
|
|
2433
|
+
await c({ call: "pane_move_edge", pane, dir });
|
|
2434
|
+
},
|
|
2435
|
+
async equalize(view) {
|
|
2436
|
+
await c({ call: "pane_equalize", view });
|
|
2437
|
+
},
|
|
2438
|
+
},
|
|
2439
|
+
tab: {
|
|
2440
|
+
async list(view) {
|
|
2441
|
+
const r = expect(await c({ call: "tab_list", view }), "tabs");
|
|
2442
|
+
return { tabs: r.tabs, active: r.active };
|
|
2443
|
+
},
|
|
2444
|
+
async create(opts) {
|
|
2445
|
+
return expect(
|
|
2446
|
+
await c({
|
|
2447
|
+
call: "tab_new",
|
|
2448
|
+
view: opts?.view,
|
|
2449
|
+
title: opts?.title,
|
|
2450
|
+
activate: opts?.activate ?? true,
|
|
2451
|
+
}),
|
|
2452
|
+
"tab",
|
|
2453
|
+
).tab;
|
|
2454
|
+
},
|
|
2455
|
+
async close(tab) {
|
|
2456
|
+
await c({ call: "tab_close", tab });
|
|
2457
|
+
},
|
|
2458
|
+
async select(tab) {
|
|
2459
|
+
await c({ call: "tab_select", tab });
|
|
2460
|
+
},
|
|
2461
|
+
async step(delta, view) {
|
|
2462
|
+
await c({ call: "tab_step", delta, view });
|
|
2463
|
+
},
|
|
2464
|
+
async move(tab, to) {
|
|
2465
|
+
await c({ call: "tab_move", tab, to });
|
|
2466
|
+
},
|
|
2467
|
+
async rename(tab, title) {
|
|
2468
|
+
await c({ call: "tab_rename", tab, title });
|
|
2469
|
+
},
|
|
2470
|
+
},
|
|
2275
2471
|
win: {
|
|
2276
2472
|
async open(buf, dock, opts) {
|
|
2277
2473
|
const layout: WindowLayout = {
|
|
2278
2474
|
kind: "docked",
|
|
2475
|
+
pane: opts?.pane ?? null,
|
|
2279
2476
|
dock,
|
|
2280
2477
|
size: opts?.size ?? null,
|
|
2281
2478
|
gravity: opts?.gravity ?? "start",
|
package/src/ui.ts
CHANGED
|
@@ -1352,6 +1352,10 @@ export async function pathPicker(
|
|
|
1352
1352
|
title: string,
|
|
1353
1353
|
opts: { initial?: string; width?: number; height?: number } = {},
|
|
1354
1354
|
): Promise<string | null> {
|
|
1355
|
+
// The last refusal said out loud, so a folder the OS is hiding is reported once rather than on
|
|
1356
|
+
// every keystroke into it. `source` runs per character typed, and a notice per character is the
|
|
1357
|
+
// same message forty times for one cause.
|
|
1358
|
+
let saidDenied: string | null = null;
|
|
1355
1359
|
return picker<string>(neosh, [], {
|
|
1356
1360
|
title,
|
|
1357
1361
|
width: Math.max(40, opts.width ?? 72),
|
|
@@ -1359,8 +1363,19 @@ export async function pathPicker(
|
|
|
1359
1363
|
query: opts.initial ?? "",
|
|
1360
1364
|
placeholder: "no directory matches — <CR> takes what you typed",
|
|
1361
1365
|
source: async (query) => {
|
|
1362
|
-
const
|
|
1363
|
-
|
|
1366
|
+
const answer = await neosh.path
|
|
1367
|
+
.complete(query)
|
|
1368
|
+
.catch(() => ({ paths: [] as string[], denied: undefined }));
|
|
1369
|
+
// An empty list because nobody was allowed to look is not an empty directory, and the
|
|
1370
|
+
// placeholder under this field can only say the second one. Said the way the remote branch
|
|
1371
|
+
// of the same question says it — a notice, because it is feedback for a key you pressed.
|
|
1372
|
+
if (answer.denied && answer.denied !== saidDenied) {
|
|
1373
|
+
saidDenied = answer.denied;
|
|
1374
|
+
neosh.notify(answer.denied, "warn");
|
|
1375
|
+
} else if (!answer.denied) {
|
|
1376
|
+
saidDenied = null;
|
|
1377
|
+
}
|
|
1378
|
+
return answer.paths.map((path) => ({ label: path, value: path }));
|
|
1364
1379
|
},
|
|
1365
1380
|
// What you typed, when it is not one of the offered rows. A completion list that refuses a path
|
|
1366
1381
|
// it did not think of is a list that gets in the way.
|
|
@@ -2755,12 +2770,34 @@ export interface ActionItem {
|
|
|
2755
2770
|
on?: string;
|
|
2756
2771
|
}
|
|
2757
2772
|
|
|
2773
|
+
/** One coloured run of a badge. A part with no `hl` is the panel's own colour — a separator. */
|
|
2774
|
+
export interface BadgePart {
|
|
2775
|
+
text: string;
|
|
2776
|
+
hl?: string;
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
/**
|
|
2780
|
+
* A mark after a row's name: one coloured run, or several.
|
|
2781
|
+
*
|
|
2782
|
+
* Several, because a badge is often several facts rather than one — `↓2` is not the same news as
|
|
2783
|
+
* `~7`, and a strip of git stats drawn in one colour is a string you have to read rather than a
|
|
2784
|
+
* row you can glance at. The single form stays because most badges *are* one fact.
|
|
2785
|
+
*
|
|
2786
|
+
* `short` is the same badge with less in it, for a row too narrow to carry all of it — the fewest
|
|
2787
|
+
* parts still worth drawing, not a truncation. A panel is what knows how many columns there are;
|
|
2788
|
+
* which fact to give up is the badge's to say, exactly as it is for a status segment. Absent means
|
|
2789
|
+
* there is no shorter true version, and the full badge stands.
|
|
2790
|
+
*/
|
|
2791
|
+
export type BadgeSpec =
|
|
2792
|
+
| { text: string; hl?: string }
|
|
2793
|
+
| { parts: BadgePart[]; short?: BadgePart[] };
|
|
2794
|
+
|
|
2758
2795
|
/** A mark on a row a panel already draws, keyed by what the row is about. */
|
|
2759
2796
|
export interface DecorationItem {
|
|
2760
2797
|
/** The row it is about, in the panel's own terms: `{ project: cwd }`, `{ task: id }`. */
|
|
2761
2798
|
target: Record<string, string>;
|
|
2762
|
-
/** A short mark after the name
|
|
2763
|
-
badge?:
|
|
2799
|
+
/** A short mark after the name. The name is clipped to make room. */
|
|
2800
|
+
badge?: BadgeSpec;
|
|
2764
2801
|
/** The row's highlight, when the panel has no opinion of its own. */
|
|
2765
2802
|
hl?: string;
|
|
2766
2803
|
/** The right-hand column, on a row that is not busy. */
|
|
@@ -2769,11 +2806,43 @@ export interface DecorationItem {
|
|
|
2769
2806
|
|
|
2770
2807
|
/** Every decoration on one target, merged. */
|
|
2771
2808
|
export interface Decoration {
|
|
2772
|
-
|
|
2809
|
+
/** `text` is every part run together — what a caller measuring the badge reads. */
|
|
2810
|
+
badge?: { text: string; hl?: string; parts: BadgePart[]; short: BadgePart[] };
|
|
2773
2811
|
hl?: string;
|
|
2774
2812
|
right?: { text: string; hl?: string };
|
|
2775
2813
|
}
|
|
2776
2814
|
|
|
2815
|
+
/** A badge in either form, as the run of parts the rest of this file works in. */
|
|
2816
|
+
function badgeParts(badge: BadgeSpec | undefined): BadgePart[] {
|
|
2817
|
+
if (!badge || typeof badge !== "object") return [];
|
|
2818
|
+
const many = (badge as { parts?: unknown }).parts;
|
|
2819
|
+
if (Array.isArray(many)) {
|
|
2820
|
+
return many.filter((p): p is BadgePart =>
|
|
2821
|
+
Boolean(p) && typeof p === "object" && typeof (p as BadgePart).text === "string" &&
|
|
2822
|
+
(p as BadgePart).text !== ""
|
|
2823
|
+
);
|
|
2824
|
+
}
|
|
2825
|
+
const one = badge as { text?: unknown; hl?: unknown };
|
|
2826
|
+
if (typeof one.text !== "string" || one.text === "") return [];
|
|
2827
|
+
return [{ text: one.text, hl: typeof one.hl === "string" ? one.hl : undefined }];
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
/**
|
|
2831
|
+
* A badge's short form, falling back to the whole of it.
|
|
2832
|
+
*
|
|
2833
|
+
* The fallback is what lets several decorations merge without anybody declaring one: a plugin with
|
|
2834
|
+
* nothing to give up keeps all of its parts in both forms, and {@link fitBadge} notices that the
|
|
2835
|
+
* two are the same width and leaves the badge alone.
|
|
2836
|
+
*/
|
|
2837
|
+
function badgeShort(badge: BadgeSpec | undefined): BadgePart[] {
|
|
2838
|
+
const declared = (badge as { short?: unknown } | undefined)?.short;
|
|
2839
|
+
if (!Array.isArray(declared)) return badgeParts(badge);
|
|
2840
|
+
return declared.filter((p): p is BadgePart =>
|
|
2841
|
+
Boolean(p) && typeof p === "object" && typeof (p as BadgePart).text === "string" &&
|
|
2842
|
+
(p as BadgePart).text !== ""
|
|
2843
|
+
);
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2777
2846
|
/** The key a decoration's `target` files under: `project:/w/x`, `task:17`. */
|
|
2778
2847
|
export function decorationKey(target: unknown): string | null {
|
|
2779
2848
|
if (!target || typeof target !== "object") return null;
|
|
@@ -2797,11 +2866,16 @@ export function mergeDecorations(
|
|
|
2797
2866
|
const key = decorationKey(c.item?.target);
|
|
2798
2867
|
if (key === null) continue;
|
|
2799
2868
|
const d = out.get(key) ?? {};
|
|
2800
|
-
const
|
|
2801
|
-
if (
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2869
|
+
const parts = badgeParts(c.item.badge);
|
|
2870
|
+
if (parts.length > 0) {
|
|
2871
|
+
const short = badgeShort(c.item.badge);
|
|
2872
|
+
const merged = d.badge ? [...d.badge.parts, { text: " " }, ...parts] : parts;
|
|
2873
|
+
d.badge = {
|
|
2874
|
+
text: merged.map((p) => p.text).join(""),
|
|
2875
|
+
hl: d.badge?.hl ?? parts[0]?.hl,
|
|
2876
|
+
parts: merged,
|
|
2877
|
+
short: d.badge ? [...d.badge.short, { text: " " }, ...short] : short,
|
|
2878
|
+
};
|
|
2805
2879
|
}
|
|
2806
2880
|
if (d.hl === undefined && typeof c.item.hl === "string") d.hl = c.item.hl;
|
|
2807
2881
|
if (d.right === undefined && typeof c.item.right?.text === "string") {
|
|
@@ -2812,9 +2886,41 @@ export function mergeDecorations(
|
|
|
2812
2886
|
return out;
|
|
2813
2887
|
}
|
|
2814
2888
|
|
|
2815
|
-
/**
|
|
2889
|
+
/**
|
|
2890
|
+
* How many columns a decoration's badge will take, for a row builder to leave free.
|
|
2891
|
+
*
|
|
2892
|
+
* Columns rather than bytes, which is what a row builder is subtracting this from: `●3` is four
|
|
2893
|
+
* bytes and two columns, and a strip of arrows is three bytes each — measured in bytes, a badge
|
|
2894
|
+
* that says `↓2 ↑5 ~7` takes eleven columns off a name to occupy nine.
|
|
2895
|
+
*/
|
|
2816
2896
|
export function badgeWidth(d: Decoration | undefined): number {
|
|
2817
|
-
return d?.badge ?
|
|
2897
|
+
return d?.badge ? width(` ${d.badge.text}`) : 0;
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
/**
|
|
2901
|
+
* The badge as this row can actually afford it: the whole of it, or its short form.
|
|
2902
|
+
*
|
|
2903
|
+
* **The badge yields before the name does.** A row's name is what the row *is* — a project you
|
|
2904
|
+
* cannot read the name of is not a row you can use — and a badge is news about it. So the full
|
|
2905
|
+
* strip is drawn whenever the name fits beside it, and a name that would be clipped by the badge
|
|
2906
|
+
* gets the short form instead. Only if it still does not fit is the name clipped, which is the
|
|
2907
|
+
* panel's own business and unchanged.
|
|
2908
|
+
*
|
|
2909
|
+
* `room` is every column the name and the badge have between them, `name` the columns the name
|
|
2910
|
+
* wants. A badge whose short form saves nothing is left alone, which is what makes the fallback in
|
|
2911
|
+
* {@link badgeShort} safe: a plugin that declared no short form never has one chosen for it.
|
|
2912
|
+
*/
|
|
2913
|
+
export function fitBadge(
|
|
2914
|
+
d: Decoration | undefined,
|
|
2915
|
+
room: number,
|
|
2916
|
+
name: number,
|
|
2917
|
+
): Decoration | undefined {
|
|
2918
|
+
if (!d?.badge) return d;
|
|
2919
|
+
const full = badgeWidth(d);
|
|
2920
|
+
if (name + full <= room) return d;
|
|
2921
|
+
const short = { ...d.badge, text: d.badge.short.map((p) => p.text).join("") };
|
|
2922
|
+
if (width(` ${short.text}`) >= full) return d;
|
|
2923
|
+
return { ...d, badge: { ...short, parts: d.badge.short } };
|
|
2818
2924
|
}
|
|
2819
2925
|
|
|
2820
2926
|
/**
|
|
@@ -2833,12 +2939,18 @@ export function decorateRow<T>(
|
|
|
2833
2939
|
if (!d) return row;
|
|
2834
2940
|
if (d.badge) {
|
|
2835
2941
|
const mark = ` ${d.badge.text}`;
|
|
2836
|
-
|
|
2942
|
+
// Past the space this puts between the name and the badge: it is the panel's colour, not the
|
|
2943
|
+
// badge's, and a span over it would be one colour claiming a column it does not fill.
|
|
2944
|
+
let at = byteLength(row.text) + 1;
|
|
2837
2945
|
row.text = `${row.text}${mark}`;
|
|
2838
2946
|
if (row.full !== undefined) row.full = `${row.full}${mark}`;
|
|
2839
|
-
|
|
2840
|
-
|
|
2947
|
+
const spans = [...(row.spans ?? [])];
|
|
2948
|
+
for (const part of d.badge.parts) {
|
|
2949
|
+
const to = at + byteLength(part.text);
|
|
2950
|
+
if (part.hl) spans.push({ from: at, to, hl: part.hl });
|
|
2951
|
+
at = to;
|
|
2841
2952
|
}
|
|
2953
|
+
if (spans.length > 0) row.spans = spans;
|
|
2842
2954
|
}
|
|
2843
2955
|
if (d.hl && row.hl === undefined) row.hl = d.hl;
|
|
2844
2956
|
if (d.right && !busy) row.right = { text: `${d.right.text} `, hl: d.right.hl };
|