@tryinget/pi-activity-strip 0.3.0 → 0.5.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 +96 -152
- package/bin/pi-activity-strip.mjs +31 -62
- package/examples/simulate-sessions.mjs +1 -1
- package/extensions/activity-strip.js +2 -2
- package/native/bin/linux-x64-gnu/artifact.json +21 -0
- package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
- package/native/panel/Cargo.lock +1045 -0
- package/native/panel/Cargo.toml +23 -0
- package/native/panel/src/app.rs +606 -0
- package/native/panel/src/card_view.rs +303 -0
- package/native/panel/src/main.rs +31 -0
- package/native/panel/src/protocol.rs +234 -0
- package/native/panel/src/runtime.rs +110 -0
- package/native/panel/src/style.css +176 -0
- package/package.json +20 -7
- package/src/broker/server.mjs +43 -7
- package/src/broker/session-store.mjs +53 -11
- package/src/client/broker-client.mjs +25 -4
- package/src/client/session-telemetry.mjs +123 -13
- package/src/common/activity-order.mjs +15 -9
- package/src/common/alignment-controller.mjs +24 -42
- package/src/common/card-display.mjs +53 -0
- package/src/common/compatibility.mjs +15 -13
- package/src/common/constants.mjs +12 -28
- package/src/common/contracts.d.ts +54 -5
- package/src/common/contracts.ts +54 -5
- package/src/common/niri-focus.mjs +79 -79
- package/src/common/protocol.mjs +49 -14
- package/src/common/session-cards.mjs +102 -0
- package/src/common/status-report.mjs +2 -1
- package/src/common/telemetry.mjs +11 -1
- package/src/common/terminal-identity.mjs +149 -0
- package/src/native/main.mjs +393 -0
- package/src/native/panel-projection.mjs +99 -0
- package/src/{electron/niri-workspace-events.mjs → native/workspace-events.mjs} +4 -1
- package/src/common/electron.mjs +0 -57
- package/src/electron/main.mjs +0 -499
- package/src/electron/niri-native-window-runtime.mjs +0 -178
- package/src/electron/preload.cjs +0 -50
- package/src/electron/renderer-visibility-runtime.mjs +0 -107
- package/src/electron/workspace-view-runtime.mjs +0 -360
- package/src/ui/strip-html.mjs +0 -484
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
// ---
|
|
2
|
-
// summary: "owns Niri native strip remapping, floating conversion, and exact top-edge alignment"
|
|
3
|
-
// read_when:
|
|
4
|
-
// - "changing compositor-native activity-strip placement or animation detection"
|
|
5
|
-
// ---
|
|
6
|
-
|
|
7
|
-
import { hasNiriFloatingPosition, isNiriWindowAligned } from "../common/alignment-controller.mjs";
|
|
8
|
-
import { resolveActivityStripWindow } from "../common/niri-focus.mjs";
|
|
9
|
-
|
|
10
|
-
const defaultWait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
11
|
-
|
|
12
|
-
function formatDelta(value) {
|
|
13
|
-
const rounded = Math.round(value);
|
|
14
|
-
return rounded >= 0 ? `+${rounded}` : String(rounded);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function readPosition(window) {
|
|
18
|
-
const position = window?.layout?.tile_pos_in_workspace_view;
|
|
19
|
-
if (!Array.isArray(position) || position.length < 2) return null;
|
|
20
|
-
return {
|
|
21
|
-
x: Number(position[0] ?? 0),
|
|
22
|
-
y: Number(position[1] ?? 0),
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* @param {{
|
|
28
|
-
* execFileAsync: (file: string, args: string[], options: object) => Promise<unknown>;
|
|
29
|
-
* env: NodeJS.ProcessEnv;
|
|
30
|
-
* timeoutMs: number;
|
|
31
|
-
* processId: number;
|
|
32
|
-
* readWindows: () => Promise<Array<Record<string, unknown>>>;
|
|
33
|
-
* getBrowserWindow: () => import("electron").BrowserWindow | null;
|
|
34
|
-
* getBounds: () => {x: number; y: number; width: number; height: number};
|
|
35
|
-
* isNiriSession: () => boolean;
|
|
36
|
-
* wait?: (milliseconds: number) => Promise<unknown>;
|
|
37
|
-
* }} options
|
|
38
|
-
*/
|
|
39
|
-
export function createNiriNativeWindowRuntime(options) {
|
|
40
|
-
const wait = options.wait ?? defaultWait;
|
|
41
|
-
|
|
42
|
-
function resolveProcessWindow(windows) {
|
|
43
|
-
return resolveActivityStripWindow(
|
|
44
|
-
windows.filter((window) => window?.pid === options.processId),
|
|
45
|
-
);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
async function getProcessWindow() {
|
|
49
|
-
return resolveProcessWindow(await options.readWindows());
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
async function moveWindowToWorkspace(stripWindow, workspace) {
|
|
53
|
-
const reference = workspace.name || workspace.idx;
|
|
54
|
-
if (reference == null) return false;
|
|
55
|
-
try {
|
|
56
|
-
await options.execFileAsync(
|
|
57
|
-
"niri",
|
|
58
|
-
[
|
|
59
|
-
"msg",
|
|
60
|
-
"action",
|
|
61
|
-
"move-window-to-workspace",
|
|
62
|
-
"--window-id",
|
|
63
|
-
String(stripWindow.id),
|
|
64
|
-
"--focus",
|
|
65
|
-
"false",
|
|
66
|
-
String(reference),
|
|
67
|
-
],
|
|
68
|
-
{ env: options.env, timeout: options.timeoutMs },
|
|
69
|
-
);
|
|
70
|
-
return true;
|
|
71
|
-
} catch {
|
|
72
|
-
return false;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
async function moveWindowToTop(isCurrent) {
|
|
77
|
-
let animated = false;
|
|
78
|
-
const browserWindow = options.getBrowserWindow();
|
|
79
|
-
if (!isCurrent() || !browserWindow || browserWindow.isDestroyed() || !options.isNiriSession()) {
|
|
80
|
-
return { ok: false, animated };
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
let niriWindow = await getProcessWindow();
|
|
84
|
-
if (!isCurrent() || !niriWindow?.id) return { ok: false, animated };
|
|
85
|
-
if (niriWindow.is_floating !== true) {
|
|
86
|
-
try {
|
|
87
|
-
await options.execFileAsync(
|
|
88
|
-
"niri",
|
|
89
|
-
["msg", "action", "move-window-to-floating", "--id", String(niriWindow.id)],
|
|
90
|
-
{ env: options.env },
|
|
91
|
-
);
|
|
92
|
-
animated = true;
|
|
93
|
-
} catch {
|
|
94
|
-
return { ok: false, animated };
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
99
|
-
if (!isCurrent()) return { ok: false, animated };
|
|
100
|
-
const position = readPosition(niriWindow);
|
|
101
|
-
if (hasNiriFloatingPosition(niriWindow) && position) {
|
|
102
|
-
const target = options.getBounds();
|
|
103
|
-
if (isNiriWindowAligned(niriWindow, target)) return { ok: true, animated };
|
|
104
|
-
const deltaX = target.x - position.x;
|
|
105
|
-
const deltaY = target.y - position.y;
|
|
106
|
-
if (Math.abs(deltaX) >= 1 || Math.abs(deltaY) >= 1) {
|
|
107
|
-
try {
|
|
108
|
-
await options.execFileAsync(
|
|
109
|
-
"niri",
|
|
110
|
-
[
|
|
111
|
-
"msg",
|
|
112
|
-
"action",
|
|
113
|
-
"move-floating-window",
|
|
114
|
-
"--id",
|
|
115
|
-
String(niriWindow.id),
|
|
116
|
-
"-x",
|
|
117
|
-
formatDelta(deltaX),
|
|
118
|
-
"-y",
|
|
119
|
-
formatDelta(deltaY),
|
|
120
|
-
],
|
|
121
|
-
{ env: options.env },
|
|
122
|
-
);
|
|
123
|
-
animated = true;
|
|
124
|
-
} catch {
|
|
125
|
-
return { ok: false, animated };
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
await wait(60);
|
|
130
|
-
if (!isCurrent()) return { ok: false, animated };
|
|
131
|
-
niriWindow = await getProcessWindow();
|
|
132
|
-
if (!niriWindow?.id) return { ok: false, animated };
|
|
133
|
-
}
|
|
134
|
-
return { ok: false, animated };
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
async function alignWindowToTop(isCurrent) {
|
|
138
|
-
const browserWindow = options.getBrowserWindow();
|
|
139
|
-
if (!isCurrent() || !browserWindow || browserWindow.isDestroyed()) {
|
|
140
|
-
return { ok: false, animated: false };
|
|
141
|
-
}
|
|
142
|
-
if (!options.isNiriSession()) {
|
|
143
|
-
browserWindow.setBounds(options.getBounds(), false);
|
|
144
|
-
return { ok: true, animated: false };
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const niriWindow = await getProcessWindow();
|
|
148
|
-
if (!isCurrent()) return { ok: false, animated: false };
|
|
149
|
-
const target = options.getBounds();
|
|
150
|
-
const currentSize = niriWindow?.layout?.window_size;
|
|
151
|
-
const widthMatches = Array.isArray(currentSize) && Number(currentSize[0]) === target.width;
|
|
152
|
-
const heightMatches = Array.isArray(currentSize) && Number(currentSize[1]) === target.height;
|
|
153
|
-
let animated = false;
|
|
154
|
-
|
|
155
|
-
if (!widthMatches || !heightMatches) {
|
|
156
|
-
browserWindow.setSize(target.width, target.height, false);
|
|
157
|
-
animated = true;
|
|
158
|
-
await wait(80);
|
|
159
|
-
if (!isCurrent()) return { ok: false, animated };
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
163
|
-
const result = await moveWindowToTop(isCurrent);
|
|
164
|
-
animated ||= result.animated;
|
|
165
|
-
if (result.ok) return { ok: true, animated };
|
|
166
|
-
if (!isCurrent()) return { ok: false, animated };
|
|
167
|
-
await wait(60);
|
|
168
|
-
}
|
|
169
|
-
return { ok: false, animated };
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
return {
|
|
173
|
-
alignWindowToTop,
|
|
174
|
-
isWindowAligned: (window) => isNiriWindowAligned(window, options.getBounds()),
|
|
175
|
-
moveWindowToWorkspace,
|
|
176
|
-
resolveProcessWindow,
|
|
177
|
-
};
|
|
178
|
-
}
|
package/src/electron/preload.cjs
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
// ---
|
|
2
|
-
// summary: "exposes a sandboxed renderer subscription API for activity-strip snapshot IPC messages"
|
|
3
|
-
// read_when:
|
|
4
|
-
// - "changing the preload bridge or renderer snapshot subscription contract"
|
|
5
|
-
// ---
|
|
6
|
-
|
|
7
|
-
const { contextBridge, ipcRenderer } = require("electron");
|
|
8
|
-
|
|
9
|
-
contextBridge.exposeInMainWorld("activityStrip", {
|
|
10
|
-
activate(sessionId) {
|
|
11
|
-
return ipcRenderer.invoke("pi-activity-strip:focus", String(sessionId ?? ""));
|
|
12
|
-
},
|
|
13
|
-
setExpanded(expanded) {
|
|
14
|
-
return ipcRenderer.invoke("pi-activity-strip:set-expanded", Boolean(expanded));
|
|
15
|
-
},
|
|
16
|
-
onCollapse(handler) {
|
|
17
|
-
if (typeof handler !== "function") return () => {};
|
|
18
|
-
const listener = () => handler();
|
|
19
|
-
ipcRenderer.on("pi-activity-strip:collapse", listener);
|
|
20
|
-
return () => ipcRenderer.removeListener("pi-activity-strip:collapse", listener);
|
|
21
|
-
},
|
|
22
|
-
onVisibility(handler) {
|
|
23
|
-
if (typeof handler !== "function") return () => {};
|
|
24
|
-
let latestRequestId = 0;
|
|
25
|
-
const listener = (_event, visible, requestId) => {
|
|
26
|
-
if (!Number.isInteger(requestId) || requestId <= 0) return;
|
|
27
|
-
latestRequestId = Math.max(latestRequestId, requestId);
|
|
28
|
-
const isCurrent = () => requestId === latestRequestId;
|
|
29
|
-
Promise.resolve()
|
|
30
|
-
.then(() => handler(Boolean(visible), isCurrent))
|
|
31
|
-
.then((applied) =>
|
|
32
|
-
ipcRenderer.send(
|
|
33
|
-
"pi-activity-strip:visibility-applied",
|
|
34
|
-
requestId,
|
|
35
|
-
Boolean(visible),
|
|
36
|
-
applied !== false,
|
|
37
|
-
),
|
|
38
|
-
)
|
|
39
|
-
.catch(() => {});
|
|
40
|
-
};
|
|
41
|
-
ipcRenderer.on("pi-activity-strip:visibility", listener);
|
|
42
|
-
return () => ipcRenderer.removeListener("pi-activity-strip:visibility", listener);
|
|
43
|
-
},
|
|
44
|
-
subscribe(handler) {
|
|
45
|
-
if (typeof handler !== "function") return () => {};
|
|
46
|
-
const listener = (_event, snapshot) => handler(snapshot);
|
|
47
|
-
ipcRenderer.on("pi-activity-strip:snapshot", listener);
|
|
48
|
-
return () => ipcRenderer.removeListener("pi-activity-strip:snapshot", listener);
|
|
49
|
-
},
|
|
50
|
-
});
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
// ---
|
|
2
|
-
// summary: "acknowledges renderer conceal and reveal frames before native Niri lifecycle effects"
|
|
3
|
-
// read_when:
|
|
4
|
-
// - "changing renderer visibility handshakes or wrong-workspace flash prevention"
|
|
5
|
-
// ---
|
|
6
|
-
|
|
7
|
-
export const VISIBILITY_CHANNEL = "pi-activity-strip:visibility";
|
|
8
|
-
export const VISIBILITY_APPLIED_CHANNEL = "pi-activity-strip:visibility-applied";
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Coordinate one bounded main-to-renderer visibility request. The renderer
|
|
12
|
-
* acknowledges only after its CSS visibility change crosses two animation
|
|
13
|
-
* frames, so callers can keep stale cards concealed during native remaps.
|
|
14
|
-
* @param {{
|
|
15
|
-
* getWebContents: () => {send: (channel: string, ...args: unknown[]) => void} | null;
|
|
16
|
-
* setInputEnabled: (enabled: boolean) => void;
|
|
17
|
-
* timeoutMs: number;
|
|
18
|
-
* setTimer?: typeof setTimeout;
|
|
19
|
-
* clearTimer?: typeof clearTimeout;
|
|
20
|
-
* }} options
|
|
21
|
-
*/
|
|
22
|
-
export function createRendererVisibilityRuntime(options) {
|
|
23
|
-
const setTimer = options.setTimer ?? setTimeout;
|
|
24
|
-
const clearTimer = options.clearTimer ?? clearTimeout;
|
|
25
|
-
let nextRequestId = 0;
|
|
26
|
-
let latestRequestId = 0;
|
|
27
|
-
const pending = new Map();
|
|
28
|
-
|
|
29
|
-
function settle(requestId, applied) {
|
|
30
|
-
const request = pending.get(requestId);
|
|
31
|
-
if (!request) return false;
|
|
32
|
-
pending.delete(requestId);
|
|
33
|
-
clearTimer(request.timer);
|
|
34
|
-
request.resolve(applied);
|
|
35
|
-
return true;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
async function apply(visible, isCurrent = () => true) {
|
|
39
|
-
const webContents = options.getWebContents();
|
|
40
|
-
if (!webContents) return false;
|
|
41
|
-
if (!visible) options.setInputEnabled(false);
|
|
42
|
-
|
|
43
|
-
nextRequestId += 1;
|
|
44
|
-
const requestId = nextRequestId;
|
|
45
|
-
latestRequestId = requestId;
|
|
46
|
-
const applied = new Promise((resolve) => {
|
|
47
|
-
const timer = setTimer(() => settle(requestId, false), options.timeoutMs);
|
|
48
|
-
pending.set(requestId, { resolve, timer, webContents, visible: Boolean(visible) });
|
|
49
|
-
});
|
|
50
|
-
try {
|
|
51
|
-
webContents.send(VISIBILITY_CHANNEL, Boolean(visible), requestId);
|
|
52
|
-
} catch {
|
|
53
|
-
settle(requestId, false);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const succeeded = await applied;
|
|
57
|
-
const appliedToCurrentRenderer =
|
|
58
|
-
succeeded &&
|
|
59
|
-
requestId === latestRequestId &&
|
|
60
|
-
isCurrent() &&
|
|
61
|
-
options.getWebContents() === webContents;
|
|
62
|
-
if (appliedToCurrentRenderer && visible) options.setInputEnabled(true);
|
|
63
|
-
return appliedToCurrentRenderer;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function acknowledge(event, requestId, visible, applied = true) {
|
|
67
|
-
const request = pending.get(requestId);
|
|
68
|
-
if (!request || event?.sender !== request.webContents || Boolean(visible) !== request.visible) {
|
|
69
|
-
return false;
|
|
70
|
-
}
|
|
71
|
-
return settle(requestId, Boolean(applied));
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function dispose() {
|
|
75
|
-
nextRequestId += 1;
|
|
76
|
-
latestRequestId = nextRequestId;
|
|
77
|
-
for (const requestId of [...pending.keys()]) settle(requestId, false);
|
|
78
|
-
options.setInputEnabled(false);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return {
|
|
82
|
-
acknowledge,
|
|
83
|
-
appliedChannel: VISIBILITY_APPLIED_CHANNEL,
|
|
84
|
-
apply,
|
|
85
|
-
dispose,
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* @param {() => import("electron").BrowserWindow | null} getWindow
|
|
91
|
-
* @param {boolean} interactive
|
|
92
|
-
*/
|
|
93
|
-
export function createBrowserWindowVisibilityRuntime(getWindow, interactive) {
|
|
94
|
-
return createRendererVisibilityRuntime({
|
|
95
|
-
getWebContents: () => {
|
|
96
|
-
const window = getWindow();
|
|
97
|
-
return window && !window.isDestroyed() ? window.webContents : null;
|
|
98
|
-
},
|
|
99
|
-
setInputEnabled: (enabled) => {
|
|
100
|
-
const window = getWindow();
|
|
101
|
-
if (!window || window.isDestroyed()) return;
|
|
102
|
-
window.setFocusable(enabled && interactive);
|
|
103
|
-
window.setIgnoreMouseEvents(!enabled || !interactive, { forward: true });
|
|
104
|
-
},
|
|
105
|
-
timeoutMs: 750,
|
|
106
|
-
});
|
|
107
|
-
}
|
|
@@ -1,360 +0,0 @@
|
|
|
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
|
-
}
|