@tryinget/pi-activity-strip 0.2.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/README.md +37 -6
- package/bin/pi-activity-strip.mjs +55 -5
- package/examples/simulate-sessions.mjs +6 -1
- package/extensions/activity-strip.js +8 -2
- package/extensions/activity-strip.ts +6 -0
- package/package.json +9 -8
- package/src/broker/server.mjs +24 -3
- package/src/broker/session-store.mjs +24 -4
- package/src/client/broker-client.mjs +23 -3
- package/src/client/launcher.mjs +6 -0
- package/src/client/session-telemetry.mjs +87 -8
- package/src/common/activity-order.mjs +68 -0
- package/src/common/alignment-controller.mjs +74 -0
- package/src/common/card-display.mjs +48 -0
- package/src/common/compatibility.mjs +14 -2
- package/src/common/constants.mjs +12 -0
- package/src/common/contracts.d.ts +22 -1
- package/src/common/contracts.ts +22 -1
- package/src/common/electron.mjs +6 -0
- package/src/common/format.mjs +6 -0
- package/src/common/niri-focus.mjs +322 -0
- package/src/common/protocol.mjs +9 -0
- package/src/common/status-report.mjs +6 -0
- package/src/common/telemetry.mjs +16 -3
- package/src/electron/main.mjs +307 -127
- package/src/electron/niri-native-window-runtime.mjs +178 -0
- package/src/electron/niri-workspace-events.mjs +81 -0
- package/src/electron/preload.cjs +40 -0
- package/src/electron/renderer-visibility-runtime.mjs +107 -0
- package/src/electron/workspace-view-runtime.mjs +360 -0
- package/src/ui/strip-html.mjs +371 -246
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// ---
|
|
2
|
+
// summary: "reconciles one workspace-local Niri strip view with native show, hide, move, and alignment effects"
|
|
3
|
+
// read_when:
|
|
4
|
+
// - "changing workspace-local session filtering or Niri strip visibility lifecycle"
|
|
5
|
+
// ---
|
|
6
|
+
|
|
7
|
+
import { createLatestOnlyRunner } from "../common/alignment-controller.mjs";
|
|
8
|
+
import {
|
|
9
|
+
resolveFocusedNiriWorkspace,
|
|
10
|
+
resolveFocusedWorkspaceView,
|
|
11
|
+
resolveWorkspaceView,
|
|
12
|
+
} from "../common/niri-focus.mjs";
|
|
13
|
+
|
|
14
|
+
const STRIP_DISCOVERY_ATTEMPTS = 8;
|
|
15
|
+
const STRIP_DISCOVERY_DELAY_MS = 80;
|
|
16
|
+
|
|
17
|
+
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
18
|
+
|
|
19
|
+
function emptyView(workspace = null) {
|
|
20
|
+
return { workspace, sessions: [], focusedSessionId: null };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** @param {Array<Record<string, unknown>>} left @param {Array<Record<string, unknown>>} right */
|
|
24
|
+
export function haveSameSessionIds(left, right) {
|
|
25
|
+
if (left.length !== right.length) return false;
|
|
26
|
+
const leftIds = new Set(left.map((session) => session.sessionId));
|
|
27
|
+
return leftIds.size === left.length && right.every((session) => leftIds.has(session.sessionId));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Keep the global broker snapshot authoritative while projecting one exact,
|
|
32
|
+
* workspace-local renderer view. Native effects are injected so the lifecycle
|
|
33
|
+
* can be tested without Electron or a compositor.
|
|
34
|
+
* @param {{
|
|
35
|
+
* readWindows: () => Promise<Array<Record<string, unknown>>>;
|
|
36
|
+
* readWorkspaces: () => Promise<Array<Record<string, unknown>>>;
|
|
37
|
+
* getSessions: () => Array<Record<string, unknown>>;
|
|
38
|
+
* getStripWindow: (windows: Array<Record<string, unknown>>) => Record<string, unknown> | null;
|
|
39
|
+
* isWindowVisible: () => boolean;
|
|
40
|
+
* isWindowExpanded: () => boolean;
|
|
41
|
+
* showWindow: () => Promise<void> | void;
|
|
42
|
+
* hideWindow: () => void;
|
|
43
|
+
* concealWindow: () => Promise<boolean | void> | boolean | void;
|
|
44
|
+
* revealWindow: (isCurrent: () => boolean) => Promise<boolean | void> | boolean | void;
|
|
45
|
+
* cancelReveal: () => void;
|
|
46
|
+
* collapseWindow: () => Promise<unknown> | unknown;
|
|
47
|
+
* publishView: (view: {workspace: Record<string, unknown> | null; sessions: Array<Record<string, unknown>>; focusedSessionId: string | null}) => void;
|
|
48
|
+
* moveWindowToWorkspace: (window: Record<string, unknown>, workspace: Record<string, unknown>) => Promise<boolean>;
|
|
49
|
+
* alignWindow: (isCurrent: () => boolean) => Promise<{ok: boolean; animated: boolean}>;
|
|
50
|
+
* settleWindow: (isCurrent: () => boolean) => Promise<boolean>;
|
|
51
|
+
* isWindowAligned: (window: Record<string, unknown>) => boolean;
|
|
52
|
+
* identityOptions?: {env?: NodeJS.ProcessEnv; readFileSync?: Function; existsSync?: Function};
|
|
53
|
+
* wait?: (milliseconds: number) => Promise<unknown>;
|
|
54
|
+
* }} options
|
|
55
|
+
*/
|
|
56
|
+
export function createNiriWorkspaceViewRuntime(options) {
|
|
57
|
+
const wait = options.wait ?? delay;
|
|
58
|
+
let passiveRequestQueued = false;
|
|
59
|
+
let passiveProbe = null;
|
|
60
|
+
let reconciling = false;
|
|
61
|
+
let reconcilingWorkspaceId = null;
|
|
62
|
+
let revealing = false;
|
|
63
|
+
|
|
64
|
+
function requestLatest() {
|
|
65
|
+
passiveRequestQueued = false;
|
|
66
|
+
if (revealing) options.cancelReveal();
|
|
67
|
+
return runner.request();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function probeFocusedWorkspace() {
|
|
71
|
+
const expectedWorkspaceId = reconcilingWorkspaceId;
|
|
72
|
+
if (expectedWorkspaceId == null || passiveProbe) return passiveProbe;
|
|
73
|
+
passiveProbe = Promise.resolve()
|
|
74
|
+
.then(() => options.readWorkspaces())
|
|
75
|
+
.then((workspaces) => {
|
|
76
|
+
if (!reconciling || reconcilingWorkspaceId !== expectedWorkspaceId) return;
|
|
77
|
+
const focusedWorkspace = resolveFocusedNiriWorkspace(workspaces);
|
|
78
|
+
if (!focusedWorkspace || focusedWorkspace.id !== expectedWorkspaceId) requestLatest();
|
|
79
|
+
})
|
|
80
|
+
.catch(() => {
|
|
81
|
+
if (reconciling && reconcilingWorkspaceId === expectedWorkspaceId) requestLatest();
|
|
82
|
+
})
|
|
83
|
+
.finally(() => {
|
|
84
|
+
passiveProbe = null;
|
|
85
|
+
});
|
|
86
|
+
return passiveProbe;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function waitForPassiveProbe(isCurrent) {
|
|
90
|
+
if (passiveProbe) await passiveProbe;
|
|
91
|
+
return isCurrent();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function findStripWindow(initialWindows, isCurrent) {
|
|
95
|
+
let windows = initialWindows;
|
|
96
|
+
for (let attempt = 0; attempt < STRIP_DISCOVERY_ATTEMPTS; attempt += 1) {
|
|
97
|
+
const stripWindow = options.getStripWindow(windows);
|
|
98
|
+
if (stripWindow) return stripWindow;
|
|
99
|
+
if (attempt === STRIP_DISCOVERY_ATTEMPTS - 1) break;
|
|
100
|
+
await wait(STRIP_DISCOVERY_DELAY_MS);
|
|
101
|
+
if (!isCurrent()) return null;
|
|
102
|
+
windows = await options.readWindows();
|
|
103
|
+
if (!isCurrent()) return null;
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function concealAndClear(view, isCurrent) {
|
|
109
|
+
let concealed = false;
|
|
110
|
+
try {
|
|
111
|
+
concealed = (await options.concealWindow()) !== false;
|
|
112
|
+
} catch {
|
|
113
|
+
// A transition may proceed only after renderer concealment is acknowledged.
|
|
114
|
+
}
|
|
115
|
+
if (!isCurrent()) return false;
|
|
116
|
+
options.publishView(emptyView(view?.workspace ?? null));
|
|
117
|
+
return concealed;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function hideView(view, isCurrent, { skipConceal = false } = {}) {
|
|
121
|
+
if (options.isWindowVisible() || options.isWindowExpanded()) {
|
|
122
|
+
try {
|
|
123
|
+
await options.collapseWindow();
|
|
124
|
+
} catch {
|
|
125
|
+
// Hiding remains fail-closed even if expansion reconciliation is unavailable.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (!isCurrent()) return;
|
|
129
|
+
if (!skipConceal) {
|
|
130
|
+
try {
|
|
131
|
+
await options.concealWindow();
|
|
132
|
+
} catch {
|
|
133
|
+
// Native hiding remains the final fail-closed boundary.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (!isCurrent()) return;
|
|
137
|
+
options.publishView(emptyView(view?.workspace ?? null));
|
|
138
|
+
options.hideWindow();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function parkView(view, isCurrent) {
|
|
142
|
+
if (options.isWindowExpanded()) {
|
|
143
|
+
try {
|
|
144
|
+
await options.collapseWindow();
|
|
145
|
+
} catch {
|
|
146
|
+
// Concealment below remains the fail-closed visual boundary.
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (!isCurrent()) return;
|
|
150
|
+
const concealed = await concealAndClear(view, isCurrent);
|
|
151
|
+
if (!isCurrent()) return;
|
|
152
|
+
if (!concealed) await hideView(view, isCurrent, { skipConceal: true });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function revealView(view, isCurrent) {
|
|
156
|
+
let revealed = false;
|
|
157
|
+
revealing = true;
|
|
158
|
+
try {
|
|
159
|
+
revealed = (await options.revealWindow(isCurrent)) !== false;
|
|
160
|
+
} finally {
|
|
161
|
+
revealing = false;
|
|
162
|
+
}
|
|
163
|
+
if (!isCurrent()) {
|
|
164
|
+
try {
|
|
165
|
+
await options.concealWindow();
|
|
166
|
+
} catch {
|
|
167
|
+
// A newer reconciliation owns the next fail-closed state.
|
|
168
|
+
}
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
if (!revealed) {
|
|
172
|
+
await hideView(view, isCurrent);
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function verifyPlacement(expectedWorkspace, isCurrent, requireAlignment = false) {
|
|
179
|
+
for (let attempt = 0; attempt < STRIP_DISCOVERY_ATTEMPTS; attempt += 1) {
|
|
180
|
+
const [windows, workspaces] = await Promise.all([
|
|
181
|
+
options.readWindows(),
|
|
182
|
+
options.readWorkspaces(),
|
|
183
|
+
]);
|
|
184
|
+
if (!isCurrent()) return null;
|
|
185
|
+
const focusedWorkspace = resolveFocusedNiriWorkspace(workspaces);
|
|
186
|
+
const stripWindow = options.getStripWindow(windows);
|
|
187
|
+
if (
|
|
188
|
+
focusedWorkspace?.id === expectedWorkspace.id &&
|
|
189
|
+
stripWindow?.workspace_id === expectedWorkspace.id &&
|
|
190
|
+
(!requireAlignment || options.isWindowAligned(stripWindow))
|
|
191
|
+
) {
|
|
192
|
+
const verifiedView = resolveFocusedWorkspaceView(
|
|
193
|
+
windows,
|
|
194
|
+
workspaces,
|
|
195
|
+
options.getSessions(),
|
|
196
|
+
options.identityOptions,
|
|
197
|
+
);
|
|
198
|
+
return verifiedView?.sessions.length ? verifiedView : null;
|
|
199
|
+
}
|
|
200
|
+
if (focusedWorkspace && focusedWorkspace.id !== expectedWorkspace.id) return null;
|
|
201
|
+
if (attempt === STRIP_DISCOVERY_ATTEMPTS - 1) break;
|
|
202
|
+
await wait(STRIP_DISCOVERY_DELAY_MS);
|
|
203
|
+
if (!isCurrent()) return null;
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function reconcileOnce({ isCurrent }) {
|
|
209
|
+
const [windows, workspaces] = await Promise.all([
|
|
210
|
+
options.readWindows(),
|
|
211
|
+
options.readWorkspaces(),
|
|
212
|
+
]);
|
|
213
|
+
if (!isCurrent()) return;
|
|
214
|
+
|
|
215
|
+
let view = resolveFocusedWorkspaceView(
|
|
216
|
+
windows,
|
|
217
|
+
workspaces,
|
|
218
|
+
options.getSessions(),
|
|
219
|
+
options.identityOptions,
|
|
220
|
+
);
|
|
221
|
+
if (!view) {
|
|
222
|
+
// Ambiguous or missing focused-workspace truth must never reactivate a resident view.
|
|
223
|
+
await parkView(null, isCurrent);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
reconcilingWorkspaceId = view.workspace.id;
|
|
227
|
+
if (passiveRequestQueued) void probeFocusedWorkspace();
|
|
228
|
+
if (view.sessions.length === 0) {
|
|
229
|
+
// Keep an aligned surface rendered on its prior workspace while that workspace still owns
|
|
230
|
+
// exact tracked terminals. It then travels only with the workspace itself and never reopens
|
|
231
|
+
// at Niri's middle-left default position on a return visit.
|
|
232
|
+
const residentStrip = options.getStripWindow(windows);
|
|
233
|
+
const residentWorkspaces = residentStrip
|
|
234
|
+
? workspaces.filter((workspace) => workspace?.id === residentStrip.workspace_id)
|
|
235
|
+
: [];
|
|
236
|
+
const residentView =
|
|
237
|
+
residentWorkspaces.length === 1 && residentStrip && options.isWindowAligned(residentStrip)
|
|
238
|
+
? resolveWorkspaceView(
|
|
239
|
+
windows,
|
|
240
|
+
residentWorkspaces[0],
|
|
241
|
+
options.getSessions(),
|
|
242
|
+
options.identityOptions,
|
|
243
|
+
)
|
|
244
|
+
: null;
|
|
245
|
+
if (residentView?.sessions.length) {
|
|
246
|
+
if (!(await waitForPassiveProbe(isCurrent))) return;
|
|
247
|
+
options.publishView(residentView);
|
|
248
|
+
if (!isCurrent()) return;
|
|
249
|
+
await revealView(residentView, isCurrent);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
await parkView(view, isCurrent);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const wasVisible = options.isWindowVisible();
|
|
257
|
+
const initialStripWindow = options.getStripWindow(windows);
|
|
258
|
+
const requiresPlacement =
|
|
259
|
+
!wasVisible ||
|
|
260
|
+
!initialStripWindow ||
|
|
261
|
+
initialStripWindow.workspace_id !== view.workspace.id ||
|
|
262
|
+
!options.isWindowAligned(initialStripWindow);
|
|
263
|
+
|
|
264
|
+
if (requiresPlacement) {
|
|
265
|
+
const concealed = await concealAndClear(view, isCurrent);
|
|
266
|
+
if (!isCurrent()) return;
|
|
267
|
+
if (!concealed) {
|
|
268
|
+
await hideView(view, isCurrent, { skipConceal: true });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (!wasVisible) await options.showWindow();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const stripWindow = await findStripWindow(windows, isCurrent);
|
|
275
|
+
if (!isCurrent()) return;
|
|
276
|
+
if (!stripWindow) {
|
|
277
|
+
await hideView(view, isCurrent, { skipConceal: requiresPlacement });
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let movedBetweenWorkspaces = false;
|
|
282
|
+
if (stripWindow.workspace_id !== view.workspace.id) {
|
|
283
|
+
const moved = await options.moveWindowToWorkspace(stripWindow, view.workspace);
|
|
284
|
+
if (!isCurrent()) return;
|
|
285
|
+
if (!moved) {
|
|
286
|
+
await hideView(view, isCurrent, { skipConceal: requiresPlacement });
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
movedBetweenWorkspaces = true;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (requiresPlacement) {
|
|
293
|
+
let verifiedView = await verifyPlacement(view.workspace, isCurrent);
|
|
294
|
+
if (!verifiedView) {
|
|
295
|
+
if (isCurrent()) await hideView(view, isCurrent, { skipConceal: true });
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const alignment = await options.alignWindow(isCurrent);
|
|
299
|
+
if (!isCurrent()) return;
|
|
300
|
+
if (!alignment.ok) {
|
|
301
|
+
await hideView(view, isCurrent, { skipConceal: true });
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
verifiedView = await verifyPlacement(view.workspace, isCurrent, true);
|
|
305
|
+
if (!verifiedView) {
|
|
306
|
+
if (isCurrent()) await hideView(view, isCurrent, { skipConceal: true });
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Niri reports final logical coordinates while its render offset is still animating. Wait
|
|
311
|
+
// only after operations that can animate; an aligned resident window can reveal immediately.
|
|
312
|
+
if (!wasVisible || movedBetweenWorkspaces || alignment.animated) {
|
|
313
|
+
const settled = await options.settleWindow(isCurrent);
|
|
314
|
+
if (!isCurrent()) return;
|
|
315
|
+
if (!settled) {
|
|
316
|
+
await hideView(view, isCurrent, { skipConceal: true });
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
verifiedView = await verifyPlacement(view.workspace, isCurrent, true);
|
|
320
|
+
if (!verifiedView) {
|
|
321
|
+
if (isCurrent()) await hideView(view, isCurrent, { skipConceal: true });
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
view = verifiedView;
|
|
326
|
+
}
|
|
327
|
+
if (!isCurrent() || !(await waitForPassiveProbe(isCurrent))) return;
|
|
328
|
+
|
|
329
|
+
options.publishView(view);
|
|
330
|
+
if (!isCurrent()) return;
|
|
331
|
+
await revealView(view, isCurrent);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
let runner;
|
|
335
|
+
async function reconcile(context) {
|
|
336
|
+
reconciling = true;
|
|
337
|
+
reconcilingWorkspaceId = null;
|
|
338
|
+
try {
|
|
339
|
+
await reconcileOnce(context);
|
|
340
|
+
} catch {
|
|
341
|
+
if (context.isCurrent()) await hideView(null, context.isCurrent);
|
|
342
|
+
} finally {
|
|
343
|
+
reconcilingWorkspaceId = null;
|
|
344
|
+
reconciling = false;
|
|
345
|
+
if (passiveRequestQueued && context.isCurrent()) requestLatest();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
runner = createLatestOnlyRunner(reconcile);
|
|
350
|
+
return {
|
|
351
|
+
request: requestLatest,
|
|
352
|
+
requestPassive() {
|
|
353
|
+
if (!reconciling) return requestLatest();
|
|
354
|
+
passiveRequestQueued = true;
|
|
355
|
+
void probeFocusedWorkspace();
|
|
356
|
+
return null;
|
|
357
|
+
},
|
|
358
|
+
waitForIdle: runner.waitForIdle,
|
|
359
|
+
};
|
|
360
|
+
}
|