| author | |
| committer | |
| log | 2c199aacac3244adc93c5f4a9c838683e93946dd |
| tree | 2d217d0a399d187cc47050cf938400d88306a294 |
| parent | 8ca4eec9ca46e1dd2f2427ae2405f5f417e8f95d |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: codex <codex@users.noreply.github.com>13 files changed, 917 insertions(+), 16 deletions(-)
apps/desktop/src/app/DesktopLifecycle.ts+7-1| ... | @@ -73,8 +73,14 @@ function addScopedListener<Args extends ReadonlyArray<unknown>>( | ... | @@ -73,8 +73,14 @@ function addScopedListener<Args extends ReadonlyArray<unknown>>( |
| 73 | } | 73 | } |
| 74 | 74 | ||
| 75 | const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdownAndWait")( | 75 | const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdownAndWait")( |
| 76 | function* (): Effect.fn.Return<void, never, DesktopShutdown.DesktopShutdown> { | 76 | function* (): Effect.fn.Return< |
| 77 | void, | ||
| 78 | never, | ||
| 79 | DesktopShutdown.DesktopShutdown | DesktopWindow.DesktopWindow | ||
| 80 | > { | ||
| 77 | const shutdown = yield* DesktopShutdown.DesktopShutdown; | 81 | const shutdown = yield* DesktopShutdown.DesktopShutdown; |
| 82 | const desktopWindow = yield* DesktopWindow.DesktopWindow; | ||
| 83 | yield* desktopWindow.flushMainWindowBounds; | ||
| 78 | yield* shutdown.request; | 84 | yield* shutdown.request; |
| 79 | yield* shutdown.awaitComplete; | 85 | yield* shutdown.awaitComplete; |
| 80 | }, | 86 | }, |
apps/desktop/src/backend/DesktopBackendPool.test.ts+1| ... | @@ -76,6 +76,7 @@ function makePoolLayer( | ... | @@ -76,6 +76,7 @@ function makePoolLayer( |
| 76 | showConnectingSplash: Effect.void, | 76 | showConnectingSplash: Effect.void, |
| 77 | handleBackendReady: () => Effect.void, | 77 | handleBackendReady: () => Effect.void, |
| 78 | handleBackendNotReady: Effect.void, | 78 | handleBackendNotReady: Effect.void, |
| 79 | flushMainWindowBounds: Effect.void, | ||
| 79 | dispatchMenuAction: () => Effect.die("unexpected menu action"), | 80 | dispatchMenuAction: () => Effect.die("unexpected menu action"), |
| 80 | syncAppearance: Effect.void, | 81 | syncAppearance: Effect.void, |
| 81 | } satisfies DesktopWindow.DesktopWindow["Service"]), | 82 | } satisfies DesktopWindow.DesktopWindow["Service"]), |
apps/desktop/src/backend/DesktopServerExposure.test.ts+1| ... | @@ -250,6 +250,7 @@ describe("DesktopServerExposure", () => { | ... | @@ -250,6 +250,7 @@ describe("DesktopServerExposure", () => { |
| 250 | const settingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { | 250 | const settingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { |
| 251 | get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), | 251 | get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), |
| 252 | load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), | 252 | load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), |
| 253 | setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), | ||
| 253 | setServerExposureMode: () => Effect.fail(settingsFailure), | 254 | setServerExposureMode: () => Effect.fail(settingsFailure), |
| 254 | setTailscaleServe: () => Effect.fail(settingsFailure), | 255 | setTailscaleServe: () => Effect.fail(settingsFailure), |
| 255 | setUpdateChannel: () => Effect.die("unexpected update channel change"), | 256 | setUpdateChannel: () => Effect.die("unexpected update channel change"), |
apps/desktop/src/settings/DesktopAppSettings.test.ts+45| ... | @@ -11,6 +11,17 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | ... | @@ -11,6 +11,17 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 11 | import * as DesktopAppSettings from "./DesktopAppSettings.ts"; | 11 | import * as DesktopAppSettings from "./DesktopAppSettings.ts"; |
| 12 | 12 | ||
| 13 | const DesktopSettingsPatch = Schema.Struct({ | 13 | const DesktopSettingsPatch = Schema.Struct({ |
| 14 | mainWindowBounds: Schema.optionalKey( | ||
| 15 | Schema.NullOr( | ||
| 16 | Schema.Struct({ | ||
| 17 | x: Schema.Number, | ||
| 18 | y: Schema.Number, | ||
| 19 | width: Schema.Number, | ||
| 20 | height: Schema.Number, | ||
| 21 | }), | ||
| 22 | ), | ||
| 23 | ), | ||
| 24 | mainWindowMaximized: Schema.optionalKey(Schema.Boolean), | ||
| 14 | serverExposureMode: Schema.optionalKey(Schema.Literals(["local-only", "network-accessible"])), | 25 | serverExposureMode: Schema.optionalKey(Schema.Literals(["local-only", "network-accessible"])), |
| 15 | tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), | 26 | tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), |
| 16 | tailscaleServePort: Schema.optionalKey(Schema.Number), | 27 | tailscaleServePort: Schema.optionalKey(Schema.Number), |
| ... | @@ -91,6 +102,8 @@ describe("DesktopSettings", () => { | ... | @@ -91,6 +102,8 @@ describe("DesktopSettings", () => { |
| 91 | assert.deepEqual( | 102 | assert.deepEqual( |
| 92 | DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), | 103 | DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), |
| 93 | { | 104 | { |
| 105 | mainWindowBounds: null, | ||
| 106 | mainWindowMaximized: false, | ||
| 94 | serverExposureMode: "local-only", | 107 | serverExposureMode: "local-only", |
| 95 | tailscaleServeEnabled: false, | 108 | tailscaleServeEnabled: false, |
| 96 | tailscaleServePort: 443, | 109 | tailscaleServePort: 443, |
| ... | @@ -116,6 +129,8 @@ describe("DesktopSettings", () => { | ... | @@ -116,6 +129,8 @@ describe("DesktopSettings", () => { |
| 116 | }); | 129 | }); |
| 117 | 130 | ||
| 118 | assert.deepEqual(yield* settings.load, { | 131 | assert.deepEqual(yield* settings.load, { |
| 132 | mainWindowBounds: null, | ||
| 133 | mainWindowMaximized: false, | ||
| 119 | serverExposureMode: "network-accessible", | 134 | serverExposureMode: "network-accessible", |
| 120 | tailscaleServeEnabled: true, | 135 | tailscaleServeEnabled: true, |
| 121 | tailscaleServePort: 8443, | 136 | tailscaleServePort: 8443, |
| ... | @@ -215,10 +230,13 @@ describe("DesktopSettings", () => { | ... | @@ -215,10 +230,13 @@ describe("DesktopSettings", () => { |
| 215 | "serverExposureMode": "network-accessible", | 230 | "serverExposureMode": "network-accessible", |
| 216 | "tailscaleServeEnabled": true, | 231 | "tailscaleServeEnabled": true, |
| 217 | "tailscaleServePort": 8443, | 232 | "tailscaleServePort": 8443, |
| 233 | "mainWindowBounds": { "x": 120, "y": 80, "width": 1280, "height": 900 }, | ||
| 218 | }\n`, | 234 | }\n`, |
| 219 | ); | 235 | ); |
| 220 | 236 | ||
| 221 | assert.deepEqual(yield* settings.load, { | 237 | assert.deepEqual(yield* settings.load, { |
| 238 | mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 }, | ||
| 239 | mainWindowMaximized: false, | ||
| 222 | serverExposureMode: "network-accessible", | 240 | serverExposureMode: "network-accessible", |
| 223 | tailscaleServeEnabled: true, | 241 | tailscaleServeEnabled: true, |
| 224 | tailscaleServePort: 8443, | 242 | tailscaleServePort: 8443, |
| ... | @@ -232,6 +250,24 @@ describe("DesktopSettings", () => { | ... | @@ -232,6 +250,24 @@ describe("DesktopSettings", () => { |
| 232 | ), | 250 | ), |
| 233 | ); | 251 | ); |
| 234 | 252 | ||
| 253 | it.effect("rejects window bounds that do not satisfy the domain schema", () => | ||
| 254 | withSettings( | ||
| 255 | Effect.gen(function* () { | ||
| 256 | const settings = yield* DesktopAppSettings.DesktopAppSettings; | ||
| 257 | yield* writeSettingsPatch({ | ||
| 258 | mainWindowBounds: { x: 10.5, y: 20, width: 839, height: 620 }, | ||
| 259 | mainWindowMaximized: true, | ||
| 260 | serverExposureMode: "network-accessible", | ||
| 261 | }); | ||
| 262 | |||
| 263 | const loaded = yield* settings.load; | ||
| 264 | assert.isNull(loaded.mainWindowBounds); | ||
| 265 | assert.isFalse(loaded.mainWindowMaximized); | ||
| 266 | assert.equal(loaded.serverExposureMode, "network-accessible"); | ||
| 267 | }), | ||
| 268 | ), | ||
| 269 | ); | ||
| 270 | |||
| 235 | it.effect("persists sparse desktop settings documents", () => | 271 | it.effect("persists sparse desktop settings documents", () => |
| 236 | withSettings( | 272 | withSettings( |
| 237 | Effect.gen(function* () { | 273 | Effect.gen(function* () { |
| ... | @@ -239,12 +275,15 @@ describe("DesktopSettings", () => { | ... | @@ -239,12 +275,15 @@ describe("DesktopSettings", () => { |
| 239 | const fileSystem = yield* FileSystem.FileSystem; | 275 | const fileSystem = yield* FileSystem.FileSystem; |
| 240 | const settings = yield* DesktopAppSettings.DesktopAppSettings; | 276 | const settings = yield* DesktopAppSettings.DesktopAppSettings; |
| 241 | 277 | ||
| 278 | yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 1440, height: 960 }, true); | ||
| 242 | yield* settings.setServerExposureMode("network-accessible"); | 279 | yield* settings.setServerExposureMode("network-accessible"); |
| 243 | 280 | ||
| 244 | const persisted = yield* decodeDesktopSettingsPatch( | 281 | const persisted = yield* decodeDesktopSettingsPatch( |
| 245 | yield* fileSystem.readFileString(environment.desktopSettingsPath), | 282 | yield* fileSystem.readFileString(environment.desktopSettingsPath), |
| 246 | ); | 283 | ); |
| 247 | assert.deepEqual(persisted, { | 284 | assert.deepEqual(persisted, { |
| 285 | mainWindowBounds: { x: -1200, y: 40, width: 1440, height: 960 }, | ||
| 286 | mainWindowMaximized: true, | ||
| 248 | serverExposureMode: "network-accessible", | 287 | serverExposureMode: "network-accessible", |
| 249 | } satisfies typeof DesktopSettingsPatch.Type); | 288 | } satisfies typeof DesktopSettingsPatch.Type); |
| 250 | }), | 289 | }), |
| ... | @@ -261,6 +300,8 @@ describe("DesktopSettings", () => { | ... | @@ -261,6 +300,8 @@ describe("DesktopSettings", () => { |
| 261 | }); | 300 | }); |
| 262 | 301 | ||
| 263 | assert.deepEqual(yield* settings.load, { | 302 | assert.deepEqual(yield* settings.load, { |
| 303 | mainWindowBounds: null, | ||
| 304 | mainWindowMaximized: false, | ||
| 264 | serverExposureMode: "local-only", | 305 | serverExposureMode: "local-only", |
| 265 | tailscaleServeEnabled: false, | 306 | tailscaleServeEnabled: false, |
| 266 | tailscaleServePort: 443, | 307 | tailscaleServePort: 443, |
| ... | @@ -286,6 +327,8 @@ describe("DesktopSettings", () => { | ... | @@ -286,6 +327,8 @@ describe("DesktopSettings", () => { |
| 286 | }); | 327 | }); |
| 287 | 328 | ||
| 288 | assert.deepEqual(yield* settings.load, { | 329 | assert.deepEqual(yield* settings.load, { |
| 330 | mainWindowBounds: null, | ||
| 331 | mainWindowMaximized: false, | ||
| 289 | serverExposureMode: "local-only", | 332 | serverExposureMode: "local-only", |
| 290 | tailscaleServeEnabled: false, | 333 | tailscaleServeEnabled: false, |
| 291 | tailscaleServePort: 443, | 334 | tailscaleServePort: 443, |
| ... | @@ -310,6 +353,8 @@ describe("DesktopSettings", () => { | ... | @@ -310,6 +353,8 @@ describe("DesktopSettings", () => { |
| 310 | }); | 353 | }); |
| 311 | 354 | ||
| 312 | assert.deepEqual(yield* settings.load, { | 355 | assert.deepEqual(yield* settings.load, { |
| 356 | mainWindowBounds: null, | ||
| 357 | mainWindowMaximized: false, | ||
| 313 | serverExposureMode: "local-only", | 358 | serverExposureMode: "local-only", |
| 314 | tailscaleServeEnabled: true, | 359 | tailscaleServeEnabled: true, |
| 315 | tailscaleServePort: 443, | 360 | tailscaleServePort: 443, |
apps/desktop/src/settings/DesktopAppSettings.ts+77| ... | @@ -20,6 +20,8 @@ import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts | ... | @@ -20,6 +20,8 @@ import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts |
| 20 | import { isValidDistroName } from "../wsl/wslPathParsing.ts"; | 20 | import { isValidDistroName } from "../wsl/wslPathParsing.ts"; |
| 21 | 21 | ||
| 22 | export interface DesktopSettings { | 22 | export interface DesktopSettings { |
| 23 | readonly mainWindowBounds: DesktopWindowBounds | null; | ||
| 24 | readonly mainWindowMaximized: boolean; | ||
| 23 | readonly serverExposureMode: DesktopServerExposureMode; | 25 | readonly serverExposureMode: DesktopServerExposureMode; |
| 24 | readonly tailscaleServeEnabled: boolean; | 26 | readonly tailscaleServeEnabled: boolean; |
| 25 | readonly tailscaleServePort: number; | 27 | readonly tailscaleServePort: number; |
| ... | @@ -48,8 +50,25 @@ export interface DesktopSettingsChange { | ... | @@ -48,8 +50,25 @@ export interface DesktopSettingsChange { |
| 48 | } | 50 | } |
| 49 | 51 | ||
| 50 | export const DEFAULT_TAILSCALE_SERVE_PORT = 443; | 52 | export const DEFAULT_TAILSCALE_SERVE_PORT = 443; |
| 53 | const MIN_MAIN_WINDOW_SIZE = { | ||
| 54 | width: 840, | ||
| 55 | height: 620, | ||
| 56 | } as const; | ||
| 57 | export const DesktopWindowBoundsSchema = Schema.Struct({ | ||
| 58 | x: Schema.Int, | ||
| 59 | y: Schema.Int, | ||
| 60 | width: Schema.Int.check(Schema.isGreaterThanOrEqualTo(MIN_MAIN_WINDOW_SIZE.width)), | ||
| 61 | height: Schema.Int.check(Schema.isGreaterThanOrEqualTo(MIN_MAIN_WINDOW_SIZE.height)), | ||
| 62 | }); | ||
| 63 | export type DesktopWindowBounds = typeof DesktopWindowBoundsSchema.Type; | ||
| 64 | export const DEFAULT_MAIN_WINDOW_SIZE = { | ||
| 65 | width: 1100, | ||
| 66 | height: 780, | ||
| 67 | } as const; | ||
| 51 | 68 | ||
| 52 | export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { | 69 | export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { |
| 70 | mainWindowBounds: null, | ||
| 71 | mainWindowMaximized: false, | ||
| 53 | serverExposureMode: "local-only", | 72 | serverExposureMode: "local-only", |
| 54 | tailscaleServeEnabled: false, | 73 | tailscaleServeEnabled: false, |
| 55 | tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, | 74 | tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, |
| ... | @@ -60,7 +79,16 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { | ... | @@ -60,7 +79,16 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { |
| 60 | wslOnly: false, | 79 | wslOnly: false, |
| 61 | }; | 80 | }; |
| 62 | 81 | ||
| 82 | const DesktopWindowBoundsDocument = Schema.Struct({ | ||
| 83 | x: Schema.Number, | ||
| 84 | y: Schema.Number, | ||
| 85 | width: Schema.Number, | ||
| 86 | height: Schema.Number, | ||
| 87 | }); | ||
| 88 | |||
| 63 | const DesktopSettingsDocument = Schema.Struct({ | 89 | const DesktopSettingsDocument = Schema.Struct({ |
| 90 | mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), | ||
| 91 | mainWindowMaximized: Schema.optionalKey(Schema.Boolean), | ||
| 64 | serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), | 92 | serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), |
| 65 | tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), | 93 | tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), |
| 66 | tailscaleServePort: Schema.optionalKey(Schema.Number), | 94 | tailscaleServePort: Schema.optionalKey(Schema.Number), |
| ... | @@ -81,6 +109,8 @@ type Mutable<T> = { -readonly [K in keyof T]: T[K] }; | ... | @@ -81,6 +109,8 @@ type Mutable<T> = { -readonly [K in keyof T]: T[K] }; |
| 81 | const DesktopSettingsJson = fromLenientJson(DesktopSettingsDocument); | 109 | const DesktopSettingsJson = fromLenientJson(DesktopSettingsDocument); |
| 82 | const decodeDesktopSettingsJson = Schema.decodeEffect(DesktopSettingsJson); | 110 | const decodeDesktopSettingsJson = Schema.decodeEffect(DesktopSettingsJson); |
| 83 | const encodeDesktopSettingsJson = Schema.encodeEffect(DesktopSettingsJson); | 111 | const encodeDesktopSettingsJson = Schema.encodeEffect(DesktopSettingsJson); |
| 112 | const decodeDesktopWindowBounds = Schema.decodeUnknownOption(DesktopWindowBoundsSchema); | ||
| 113 | const desktopWindowBoundsEquivalence = Schema.toEquivalence(DesktopWindowBoundsSchema); | ||
| 84 | 114 | ||
| 85 | const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSettingsChange => ({ | 115 | const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSettingsChange => ({ |
| 86 | settings, | 116 | settings, |
| ... | @@ -114,6 +144,10 @@ export class DesktopAppSettings extends Context.Service< | ... | @@ -114,6 +144,10 @@ export class DesktopAppSettings extends Context.Service< |
| 114 | { | 144 | { |
| 115 | readonly load: Effect.Effect<DesktopSettings>; | 145 | readonly load: Effect.Effect<DesktopSettings>; |
| 116 | readonly get: Effect.Effect<DesktopSettings>; | 146 | readonly get: Effect.Effect<DesktopSettings>; |
| 147 | readonly setMainWindowBounds: ( | ||
| 148 | bounds: DesktopWindowBounds, | ||
| 149 | isMaximized: boolean, | ||
| 150 | ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>; | ||
| 117 | readonly setServerExposureMode: ( | 151 | readonly setServerExposureMode: ( |
| 118 | mode: DesktopServerExposureMode, | 152 | mode: DesktopServerExposureMode, |
| 119 | ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>; | 153 | ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>; |
| ... | @@ -158,11 +192,16 @@ function normalizeWslDistro(value: unknown): string | null { | ... | @@ -158,11 +192,16 @@ function normalizeWslDistro(value: unknown): string | null { |
| 158 | return typeof value === "string" && isValidDistroName(value) ? value : null; | 192 | return typeof value === "string" && isValidDistroName(value) ? value : null; |
| 159 | } | 193 | } |
| 160 | 194 | ||
| 195 | export function normalizeMainWindowBounds(value: unknown): DesktopWindowBounds | null { | ||
| 196 | return Option.getOrNull(decodeDesktopWindowBounds(value)); | ||
| 197 | } | ||
| 198 | |||
| 161 | function normalizeDesktopSettingsDocument( | 199 | function normalizeDesktopSettingsDocument( |
| 162 | parsed: DesktopSettingsDocument, | 200 | parsed: DesktopSettingsDocument, |
| 163 | appVersion: string, | 201 | appVersion: string, |
| 164 | ): DesktopSettings { | 202 | ): DesktopSettings { |
| 165 | const defaultSettings = resolveDefaultDesktopSettings(appVersion); | 203 | const defaultSettings = resolveDefaultDesktopSettings(appVersion); |
| 204 | const mainWindowBounds = normalizeMainWindowBounds(parsed.mainWindowBounds); | ||
| 166 | const parsedUpdateChannel = Option.fromNullishOr(parsed.updateChannel); | 205 | const parsedUpdateChannel = Option.fromNullishOr(parsed.updateChannel); |
| 167 | const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined; | 206 | const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined; |
| 168 | const updateChannelConfiguredByUser = | 207 | const updateChannelConfiguredByUser = |
| ... | @@ -177,6 +216,8 @@ function normalizeDesktopSettingsDocument( | ... | @@ -177,6 +216,8 @@ function normalizeDesktopSettingsDocument( |
| 177 | (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl"); | 216 | (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl"); |
| 178 | 217 | ||
| 179 | return { | 218 | return { |
| 219 | mainWindowBounds, | ||
| 220 | mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true, | ||
| 180 | serverExposureMode: | 221 | serverExposureMode: |
| 181 | parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", | 222 | parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", |
| 182 | tailscaleServeEnabled: parsed.tailscaleServeEnabled === true, | 223 | tailscaleServeEnabled: parsed.tailscaleServeEnabled === true, |
| ... | @@ -197,6 +238,12 @@ function toDesktopSettingsDocument( | ... | @@ -197,6 +238,12 @@ function toDesktopSettingsDocument( |
| 197 | ): DesktopSettingsDocument { | 238 | ): DesktopSettingsDocument { |
| 198 | const document: Mutable<DesktopSettingsDocument> = {}; | 239 | const document: Mutable<DesktopSettingsDocument> = {}; |
| 199 | 240 | ||
| 241 | if (settings.mainWindowBounds !== null) { | ||
| 242 | document.mainWindowBounds = settings.mainWindowBounds; | ||
| 243 | } | ||
| 244 | if (settings.mainWindowMaximized) { | ||
| 245 | document.mainWindowMaximized = true; | ||
| 246 | } | ||
| 200 | if (settings.serverExposureMode !== defaults.serverExposureMode) { | 247 | if (settings.serverExposureMode !== defaults.serverExposureMode) { |
| 201 | document.serverExposureMode = settings.serverExposureMode; | 248 | document.serverExposureMode = settings.serverExposureMode; |
| 202 | } | 249 | } |
| ... | @@ -237,6 +284,22 @@ function setServerExposureMode( | ... | @@ -237,6 +284,22 @@ function setServerExposureMode( |
| 237 | }; | 284 | }; |
| 238 | } | 285 | } |
| 239 | 286 | ||
| 287 | function setMainWindowBounds( | ||
| 288 | settings: DesktopSettings, | ||
| 289 | bounds: DesktopWindowBounds, | ||
| 290 | isMaximized: boolean, | ||
| 291 | ): DesktopSettings { | ||
| 292 | return settings.mainWindowBounds !== null && | ||
| 293 | desktopWindowBoundsEquivalence(settings.mainWindowBounds, bounds) && | ||
| 294 | settings.mainWindowMaximized === isMaximized | ||
| 295 | ? settings | ||
| 296 | : { | ||
| 297 | ...settings, | ||
| 298 | mainWindowBounds: bounds, | ||
| 299 | mainWindowMaximized: isMaximized, | ||
| 300 | }; | ||
| 301 | } | ||
| 302 | |||
| 240 | function setTailscaleServe( | 303 | function setTailscaleServe( |
| 241 | settings: DesktopSettings, | 304 | settings: DesktopSettings, |
| 242 | input: { readonly enabled: boolean; readonly port: Option.Option<number> }, | 305 | input: { readonly enabled: boolean; readonly port: Option.Option<number> }, |
| ... | @@ -431,6 +494,18 @@ export const make = Effect.gen(function* () { | ... | @@ -431,6 +494,18 @@ export const make = Effect.gen(function* () { |
| 431 | ); | 494 | ); |
| 432 | return yield* SynchronizedRef.setAndGet(settingsRef, settings); | 495 | return yield* SynchronizedRef.setAndGet(settingsRef, settings); |
| 433 | }).pipe(Effect.withSpan("desktop.settings.load")), | 496 | }).pipe(Effect.withSpan("desktop.settings.load")), |
| 497 | setMainWindowBounds: (bounds, isMaximized) => | ||
| 498 | persist((settings) => setMainWindowBounds(settings, bounds, isMaximized)).pipe( | ||
| 499 | Effect.withSpan("desktop.settings.setMainWindowBounds", { | ||
| 500 | attributes: { | ||
| 501 | x: bounds.x, | ||
| 502 | y: bounds.y, | ||
| 503 | width: bounds.width, | ||
| 504 | height: bounds.height, | ||
| 505 | isMaximized, | ||
| 506 | }, | ||
| 507 | }), | ||
| 508 | ), | ||
| 434 | setServerExposureMode: (mode) => | 509 | setServerExposureMode: (mode) => |
| 435 | persist((settings) => setServerExposureMode(settings, mode)).pipe( | 510 | persist((settings) => setServerExposureMode(settings, mode)).pipe( |
| 436 | Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }), | 511 | Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }), |
| ... | @@ -488,6 +563,8 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET | ... | @@ -488,6 +563,8 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET |
| 488 | return DesktopAppSettings.of({ | 563 | return DesktopAppSettings.of({ |
| 489 | get: SynchronizedRef.get(settingsRef), | 564 | get: SynchronizedRef.get(settingsRef), |
| 490 | load: SynchronizedRef.get(settingsRef), | 565 | load: SynchronizedRef.get(settingsRef), |
| 566 | setMainWindowBounds: (bounds, isMaximized) => | ||
| 567 | update((settings) => setMainWindowBounds(settings, bounds, isMaximized)), | ||
| 491 | setServerExposureMode: (mode) => | 568 | setServerExposureMode: (mode) => |
| 492 | update((settings) => setServerExposureMode(settings, mode)), | 569 | update((settings) => setServerExposureMode(settings, mode)), |
| 493 | setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), | 570 | setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), |
apps/desktop/src/updates/DesktopUpdates.test.ts+1| ... | @@ -158,6 +158,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { | ... | @@ -158,6 +158,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { |
| 158 | ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, { | 158 | ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, { |
| 159 | get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), | 159 | get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), |
| 160 | load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), | 160 | load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), |
| 161 | setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), | ||
| 161 | setServerExposureMode: () => Effect.die("unexpected server exposure update"), | 162 | setServerExposureMode: () => Effect.die("unexpected server exposure update"), |
| 162 | setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), | 163 | setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), |
| 163 | setUpdateChannel: () => Effect.fail(setUpdateChannelError), | 164 | setUpdateChannel: () => Effect.fail(setUpdateChannelError), |
apps/desktop/src/window/DesktopApplicationMenu.test.ts+1| ... | @@ -76,6 +76,7 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred<string>) => | ... | @@ -76,6 +76,7 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred<string>) => |
| 76 | showConnectingSplash: Effect.void, | 76 | showConnectingSplash: Effect.void, |
| 77 | handleBackendReady: () => Effect.void, | 77 | handleBackendReady: () => Effect.void, |
| 78 | handleBackendNotReady: Effect.void, | 78 | handleBackendNotReady: Effect.void, |
| 79 | flushMainWindowBounds: Effect.void, | ||
| 79 | dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), | 80 | dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), |
| 80 | syncAppearance: Effect.void, | 81 | syncAppearance: Effect.void, |
| 81 | } satisfies DesktopWindow.DesktopWindow["Service"]); | 82 | } satisfies DesktopWindow.DesktopWindow["Service"]); |
apps/desktop/src/window/DesktopWindow.test.ts+516-2| ... | @@ -1,12 +1,17 @@ | ... | @@ -1,12 +1,17 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { assert, describe, it } from "@effect/vitest"; | 2 | import { assert, describe, it } from "@effect/vitest"; |
| 3 | import * as Deferred from "effect/Deferred"; | ||
| 3 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Fiber from "effect/Fiber"; | ||
| 4 | import * as Layer from "effect/Layer"; | 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Logger from "effect/Logger"; | ||
| 5 | import * as Option from "effect/Option"; | 8 | import * as Option from "effect/Option"; |
| 6 | import * as Ref from "effect/Ref"; | 9 | import * as Ref from "effect/Ref"; |
| 10 | import * as References from "effect/References"; | ||
| 11 | import * as Schema from "effect/Schema"; | ||
| 7 | import * as TestClock from "effect/testing/TestClock"; | 12 | import * as TestClock from "effect/testing/TestClock"; |
| 8 | 13 | ||
| 9 | import type * as Electron from "electron"; | 14 | import * as Electron from "electron"; |
| 10 | import { vi } from "vite-plus/test"; | 15 | import { vi } from "vite-plus/test"; |
| 11 | 16 | ||
| 12 | vi.mock("electron", async (importOriginal) => ({ | 17 | vi.mock("electron", async (importOriginal) => ({ |
| ... | @@ -18,12 +23,20 @@ vi.mock("electron", async (importOriginal) => ({ | ... | @@ -18,12 +23,20 @@ vi.mock("electron", async (importOriginal) => ({ |
| 18 | setUserAgent: vi.fn(), | 23 | setUserAgent: vi.fn(), |
| 19 | })), | 24 | })), |
| 20 | }, | 25 | }, |
| 26 | screen: { | ||
| 27 | getAllDisplays: vi.fn(() => [ | ||
| 28 | { | ||
| 29 | bounds: { x: 0, y: 0, width: 1920, height: 1080 }, | ||
| 30 | }, | ||
| 31 | ]), | ||
| 32 | }, | ||
| 21 | })); | 33 | })); |
| 22 | 34 | ||
| 23 | import * as DesktopAssets from "../app/DesktopAssets.ts"; | 35 | import * as DesktopAssets from "../app/DesktopAssets.ts"; |
| 24 | import * as DesktopConfig from "../app/DesktopConfig.ts"; | 36 | import * as DesktopConfig from "../app/DesktopConfig.ts"; |
| 25 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | 37 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 26 | import * as DesktopState from "../app/DesktopState.ts"; | 38 | import * as DesktopState from "../app/DesktopState.ts"; |
| 39 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; | ||
| 27 | import * as ElectronMenu from "../electron/ElectronMenu.ts"; | 40 | import * as ElectronMenu from "../electron/ElectronMenu.ts"; |
| 28 | import * as ElectronShell from "../electron/ElectronShell.ts"; | 41 | import * as ElectronShell from "../electron/ElectronShell.ts"; |
| 29 | import * as ElectronTheme from "../electron/ElectronTheme.ts"; | 42 | import * as ElectronTheme from "../electron/ElectronTheme.ts"; |
| ... | @@ -66,15 +79,21 @@ function makeFakeBrowserWindow() { | ... | @@ -66,15 +79,21 @@ function makeFakeBrowserWindow() { |
| 66 | const window = { | 79 | const window = { |
| 67 | close: vi.fn(), | 80 | close: vi.fn(), |
| 68 | focus: vi.fn(), | 81 | focus: vi.fn(), |
| 82 | getBounds: vi.fn(() => ({ x: 0, y: 0, width: 1100, height: 780 })), | ||
| 83 | getNormalBounds: vi.fn(() => ({ x: 0, y: 0, width: 1100, height: 780 })), | ||
| 69 | isDestroyed: vi.fn(() => false), | 84 | isDestroyed: vi.fn(() => false), |
| 70 | isFullScreen: vi.fn(() => false), | 85 | isFullScreen: vi.fn(() => false), |
| 86 | isMaximized: vi.fn(() => false), | ||
| 71 | isMinimized: vi.fn(() => false), | 87 | isMinimized: vi.fn(() => false), |
| 72 | isVisible: vi.fn(() => true), | 88 | isVisible: vi.fn(() => true), |
| 73 | loadURL: vi.fn(() => Promise.resolve()), | 89 | loadURL: vi.fn(() => Promise.resolve()), |
| 90 | maximize: vi.fn(), | ||
| 74 | on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { | 91 | on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { |
| 75 | windowListeners.set(eventName, listener); | 92 | windowListeners.set(eventName, listener); |
| 76 | }), | 93 | }), |
| 77 | once: vi.fn(), | 94 | once: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { |
| 95 | windowListeners.set(eventName, listener); | ||
| 96 | }), | ||
| 78 | restore: vi.fn(), | 97 | restore: vi.fn(), |
| 79 | setBackgroundColor: vi.fn(), | 98 | setBackgroundColor: vi.fn(), |
| 80 | setAutoHideCursor: vi.fn(), | 99 | setAutoHideCursor: vi.fn(), |
| ... | @@ -86,7 +105,14 @@ function makeFakeBrowserWindow() { | ... | @@ -86,7 +105,14 @@ function makeFakeBrowserWindow() { |
| 86 | 105 | ||
| 87 | return { | 106 | return { |
| 88 | window: window as unknown as Electron.BrowserWindow, | 107 | window: window as unknown as Electron.BrowserWindow, |
| 108 | getBounds: window.getBounds, | ||
| 109 | getNormalBounds: window.getNormalBounds, | ||
| 110 | isDestroyed: window.isDestroyed, | ||
| 111 | isFullScreen: window.isFullScreen, | ||
| 112 | isMaximized: window.isMaximized, | ||
| 113 | isMinimized: window.isMinimized, | ||
| 89 | loadURL: window.loadURL, | 114 | loadURL: window.loadURL, |
| 115 | maximize: window.maximize, | ||
| 90 | openDevTools: webContents.openDevTools, | 116 | openDevTools: webContents.openDevTools, |
| 91 | reload: webContents.reload, | 117 | reload: webContents.reload, |
| 92 | send: webContents.send, | 118 | send: webContents.send, |
| ... | @@ -144,13 +170,57 @@ const desktopEnvironmentLayer = DesktopEnvironment.layer(environmentInput).pipe( | ... | @@ -144,13 +170,57 @@ const desktopEnvironmentLayer = DesktopEnvironment.layer(environmentInput).pipe( |
| 144 | ), | 170 | ), |
| 145 | ); | 171 | ); |
| 146 | 172 | ||
| 173 | const desktopWindowBoundsEquivalence = Schema.toEquivalence( | ||
| 174 | DesktopAppSettings.DesktopWindowBoundsSchema, | ||
| 175 | ); | ||
| 176 | |||
| 147 | function makeTestLayer(input: { | 177 | function makeTestLayer(input: { |
| 148 | readonly window: Electron.BrowserWindow; | 178 | readonly window: Electron.BrowserWindow; |
| 149 | readonly createCount: Ref.Ref<number>; | 179 | readonly createCount: Ref.Ref<number>; |
| 150 | readonly mainWindow: Ref.Ref<Option.Option<Electron.BrowserWindow>>; | 180 | readonly mainWindow: Ref.Ref<Option.Option<Electron.BrowserWindow>>; |
| 151 | readonly createdWindowOptions?: Electron.BrowserWindowConstructorOptions[]; | 181 | readonly createdWindowOptions?: Electron.BrowserWindowConstructorOptions[]; |
| 182 | readonly desktopSettings?: DesktopAppSettings.DesktopSettings; | ||
| 183 | readonly mainWindowBoundsUpdates?: DesktopAppSettings.DesktopWindowBounds[]; | ||
| 184 | readonly mainWindowMaximizedUpdates?: boolean[]; | ||
| 185 | readonly beforeMainWindowBoundsUpdate?: ( | ||
| 186 | bounds: DesktopAppSettings.DesktopWindowBounds, | ||
| 187 | ) => Effect.Effect<void>; | ||
| 152 | readonly openedExternalUrls?: unknown[]; | 188 | readonly openedExternalUrls?: unknown[]; |
| 153 | }) { | 189 | }) { |
| 190 | let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS; | ||
| 191 | const desktopAppSettingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { | ||
| 192 | get: Effect.sync(() => desktopSettings), | ||
| 193 | load: Effect.sync(() => desktopSettings), | ||
| 194 | setMainWindowBounds: (bounds, isMaximized) => | ||
| 195 | Effect.gen(function* () { | ||
| 196 | if (input.beforeMainWindowBoundsUpdate) { | ||
| 197 | yield* input.beforeMainWindowBoundsUpdate(bounds); | ||
| 198 | } | ||
| 199 | const changed = | ||
| 200 | desktopSettings.mainWindowBounds === null || | ||
| 201 | !desktopWindowBoundsEquivalence(desktopSettings.mainWindowBounds, bounds) || | ||
| 202 | desktopSettings.mainWindowMaximized !== isMaximized; | ||
| 203 | if (changed) { | ||
| 204 | desktopSettings = { | ||
| 205 | ...desktopSettings, | ||
| 206 | mainWindowBounds: bounds, | ||
| 207 | mainWindowMaximized: isMaximized, | ||
| 208 | }; | ||
| 209 | input.mainWindowBoundsUpdates?.push(bounds); | ||
| 210 | input.mainWindowMaximizedUpdates?.push(isMaximized); | ||
| 211 | } | ||
| 212 | return { settings: desktopSettings, changed }; | ||
| 213 | }), | ||
| 214 | setServerExposureMode: () => Effect.die("unexpected server exposure update"), | ||
| 215 | setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), | ||
| 216 | setUpdateChannel: () => Effect.die("unexpected update channel change"), | ||
| 217 | setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), | ||
| 218 | setWslDistro: () => Effect.die("unexpected WSL distro change"), | ||
| 219 | setWslOnly: () => Effect.die("unexpected WSL-only toggle"), | ||
| 220 | applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), | ||
| 221 | applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), | ||
| 222 | } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); | ||
| 223 | |||
| 154 | const electronWindowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { | 224 | const electronWindowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { |
| 155 | create: (options) => | 225 | create: (options) => |
| 156 | Effect.sync(() => { | 226 | Effect.sync(() => { |
| ... | @@ -175,6 +245,7 @@ function makeTestLayer(input: { | ... | @@ -175,6 +245,7 @@ function makeTestLayer(input: { |
| 175 | Layer.mergeAll( | 245 | Layer.mergeAll( |
| 176 | desktopAssetsLayer, | 246 | desktopAssetsLayer, |
| 177 | desktopEnvironmentLayer, | 247 | desktopEnvironmentLayer, |
| 248 | desktopAppSettingsLayer, | ||
| 178 | desktopServerExposureLayer, | 249 | desktopServerExposureLayer, |
| 179 | DesktopState.layer, | 250 | DesktopState.layer, |
| 180 | electronMenuLayer, | 251 | electronMenuLayer, |
| ... | @@ -272,6 +343,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n | ... | @@ -272,6 +343,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n |
| 272 | Layer.mergeAll( | 343 | Layer.mergeAll( |
| 273 | desktopAssetsLayer, | 344 | desktopAssetsLayer, |
| 274 | desktopEnvironmentLayer, | 345 | desktopEnvironmentLayer, |
| 346 | DesktopAppSettings.layerTest(), | ||
| 275 | desktopServerExposureLayer, | 347 | desktopServerExposureLayer, |
| 276 | electronMenuLayer, | 348 | electronMenuLayer, |
| 277 | Layer.succeed(ElectronShell.ElectronShell, { | 349 | Layer.succeed(ElectronShell.ElectronShell, { |
| ... | @@ -294,6 +366,23 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n | ... | @@ -294,6 +366,23 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n |
| 294 | }); | 366 | }); |
| 295 | 367 | ||
| 296 | describe("DesktopWindow", () => { | 368 | describe("DesktopWindow", () => { |
| 369 | it("restores bounds only when the window fits within a connected display", () => { | ||
| 370 | const persistedBounds = { x: 2040, y: 80, width: 1320, height: 880 }; | ||
| 371 | const displays = [ | ||
| 372 | { x: 0, y: 0, width: 1920, height: 1080 }, | ||
| 373 | { x: 1920, y: 0, width: 2560, height: 1440 }, | ||
| 374 | ]; | ||
| 375 | |||
| 376 | assert.deepEqual( | ||
| 377 | DesktopWindow.resolveInitialMainWindowBounds(persistedBounds, displays), | ||
| 378 | persistedBounds, | ||
| 379 | ); | ||
| 380 | assert.deepEqual( | ||
| 381 | DesktopWindow.resolveInitialMainWindowBounds(persistedBounds, [displays[0]!]), | ||
| 382 | DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE, | ||
| 383 | ); | ||
| 384 | }); | ||
| 385 | |||
| 297 | it("recognizes only same-origin renderer navigations", () => { | 386 | it("recognizes only same-origin renderer navigations", () => { |
| 298 | assert.isTrue( | 387 | assert.isTrue( |
| 299 | DesktopWindow.isSameOriginRendererNavigation({ | 388 | DesktopWindow.isSameOriginRendererNavigation({ |
| ... | @@ -335,6 +424,10 @@ describe("DesktopWindow", () => { | ... | @@ -335,6 +424,10 @@ describe("DesktopWindow", () => { |
| 335 | 424 | ||
| 336 | yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); | 425 | yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); |
| 337 | assert.equal(yield* Ref.get(createCount), 1); | 426 | assert.equal(yield* Ref.get(createCount), 1); |
| 427 | assert.equal(createdWindowOptions[0]?.width, 1100); | ||
| 428 | assert.equal(createdWindowOptions[0]?.height, 780); | ||
| 429 | assert.isUndefined(createdWindowOptions[0]?.x); | ||
| 430 | assert.isUndefined(createdWindowOptions[0]?.y); | ||
| 338 | assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); | 431 | assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); |
| 339 | assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); | 432 | assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); |
| 340 | assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]); | 433 | assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]); |
| ... | @@ -343,6 +436,427 @@ describe("DesktopWindow", () => { | ... | @@ -343,6 +436,427 @@ describe("DesktopWindow", () => { |
| 343 | }), | 436 | }), |
| 344 | ); | 437 | ); |
| 345 | 438 | ||
| 439 | it.effect("uses the persisted main window bounds when opening the window", () => | ||
| 440 | Effect.gen(function* () { | ||
| 441 | const fakeWindow = makeFakeBrowserWindow(); | ||
| 442 | const createCount = yield* Ref.make(0); | ||
| 443 | const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none()); | ||
| 444 | const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = []; | ||
| 445 | const layer = makeTestLayer({ | ||
| 446 | window: fakeWindow.window, | ||
| 447 | createCount, | ||
| 448 | mainWindow, | ||
| 449 | createdWindowOptions, | ||
| 450 | desktopSettings: { | ||
| 451 | ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, | ||
| 452 | mainWindowBounds: { x: 120, y: 80, width: 1320, height: 880 }, | ||
| 453 | }, | ||
| 454 | }); | ||
| 455 | |||
| 456 | yield* Effect.gen(function* () { | ||
| 457 | const desktopWindow = yield* DesktopWindow.DesktopWindow; | ||
| 458 | yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); | ||
| 459 | |||
| 460 | assert.equal(createdWindowOptions[0]?.width, 1320); | ||
| 461 | assert.equal(createdWindowOptions[0]?.height, 880); | ||
| 462 | assert.equal(createdWindowOptions[0]?.x, 120); | ||
| 463 | assert.equal(createdWindowOptions[0]?.y, 80); | ||
| 464 | }).pipe(Effect.provide(layer)); | ||
| 465 | }), | ||
| 466 | ); | ||
| 467 | |||
| 468 | it.effect("restores the persisted maximized state", () => | ||
| 469 | Effect.gen(function* () { | ||
| 470 | const fakeWindow = makeFakeBrowserWindow(); | ||
| 471 | const createCount = yield* Ref.make(0); | ||
| 472 | const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none()); | ||
| 473 | const layer = makeTestLayer({ | ||
| 474 | window: fakeWindow.window, | ||
| 475 | createCount, | ||
| 476 | mainWindow, | ||
| 477 | desktopSettings: { | ||
| 478 | ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, | ||
| 479 | mainWindowBounds: { x: 120, y: 80, width: 1320, height: 880 }, | ||
| 480 | mainWindowMaximized: true, | ||
| 481 | }, | ||
| 482 | }); | ||
| 483 | |||
| 484 | yield* Effect.gen(function* () { | ||
| 485 | const desktopWindow = yield* DesktopWindow.DesktopWindow; | ||
| 486 | yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); | ||
| 487 | |||
| 488 | assert.equal(fakeWindow.maximize.mock.calls.length, 0); | ||
| 489 | const readyToShow = fakeWindow.windowListeners.get("ready-to-show"); | ||
| 490 | if (!readyToShow) { | ||
| 491 | return yield* Effect.die("window ready-to-show listener was not registered"); | ||
| 492 | } | ||
| 493 | readyToShow(); | ||
| 494 | assert.equal(fakeWindow.maximize.mock.calls.length, 1); | ||
| 495 | }).pipe(Effect.provide(layer)); | ||
| 496 | }), | ||
| 497 | ); | ||
| 498 | |||
| 499 | it.effect("debounces move and resize bounds updates", () => | ||
| 500 | Effect.gen(function* () { | ||
| 501 | const fakeWindow = makeFakeBrowserWindow(); | ||
| 502 | const createCount = yield* Ref.make(0); | ||
| 503 | const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none()); | ||
| 504 | const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; | ||
| 505 | const layer = makeTestLayer({ | ||
| 506 | window: fakeWindow.window, | ||
| 507 | createCount, | ||
| 508 | mainWindow, | ||
| 509 | mainWindowBoundsUpdates, | ||
| 510 | }); | ||
| 511 | |||
| 512 | yield* Effect.gen(function* () { | ||
| 513 | const desktopWindow = yield* DesktopWindow.DesktopWindow; | ||
| 514 | yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); | ||
| 515 | |||
| 516 | const move = fakeWindow.windowListeners.get("move"); | ||
| 517 | const resize = fakeWindow.windowListeners.get("resize"); | ||
| 518 | if (!move || !resize) { | ||
| 519 | return yield* Effect.die("window bounds listeners were not registered"); | ||
| 520 | } | ||
| 521 | |||
| 522 | fakeWindow.getBounds.mockReturnValue({ x: 120, y: 80, width: 1280, height: 840 }); | ||
| 523 | move(); | ||
| 524 | yield* TestClock.adjust(250); | ||
| 525 | |||
| 526 | fakeWindow.getBounds.mockReturnValue({ x: 160, y: 100, width: 1360, height: 900 }); | ||
| 527 | resize(); | ||
| 528 | yield* TestClock.adjust(499); | ||
| 529 | assert.deepEqual(mainWindowBoundsUpdates, []); | ||
| 530 | |||
| 531 | yield* TestClock.adjust(1); | ||
| 532 | yield* Effect.promise(() => Promise.resolve()); | ||
| 533 | assert.deepEqual(mainWindowBoundsUpdates, [{ x: 160, y: 100, width: 1360, height: 900 }]); | ||
| 534 | }).pipe(Effect.provide(layer)); | ||
| 535 | }), | ||
| 536 | ); | ||
| 537 | |||
| 538 | it.effect("persists normal bounds and state for a maximized window", () => | ||
| 539 | Effect.gen(function* () { | ||
| 540 | const fakeWindow = makeFakeBrowserWindow(); | ||
| 541 | fakeWindow.isMaximized.mockReturnValue(true); | ||
| 542 | fakeWindow.getBounds.mockReturnValue({ x: 0, y: 0, width: 1920, height: 1080 }); | ||
| 543 | fakeWindow.getNormalBounds.mockReturnValue({ x: 220, y: 140, width: 1380, height: 920 }); | ||
| 544 | const createCount = yield* Ref.make(0); | ||
| 545 | const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none()); | ||
| 546 | const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; | ||
| 547 | const mainWindowMaximizedUpdates: boolean[] = []; | ||
| 548 | const layer = makeTestLayer({ | ||
| 549 | window: fakeWindow.window, | ||
| 550 | createCount, | ||
| 551 | mainWindow, | ||
| 552 | mainWindowBoundsUpdates, | ||
| 553 | mainWindowMaximizedUpdates, | ||
| 554 | }); | ||
| 555 | |||
| 556 | yield* Effect.gen(function* () { | ||
| 557 | const desktopWindow = yield* DesktopWindow.DesktopWindow; | ||
| 558 | yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); | ||
| 559 | |||
| 560 | const close = fakeWindow.windowListeners.get("close"); | ||
| 561 | if (!close) { | ||
| 562 | return yield* Effect.die("window close listener was not registered"); | ||
| 563 | } | ||
| 564 | close(); | ||
| 565 | yield* Effect.promise(() => Promise.resolve()); | ||
| 566 | |||
| 567 | assert.deepEqual(mainWindowBoundsUpdates, [{ x: 220, y: 140, width: 1380, height: 920 }]); | ||
| 568 | assert.deepEqual(mainWindowMaximizedUpdates, [true]); | ||
| 569 | assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 1); | ||
| 570 | assert.equal(fakeWindow.getBounds.mock.calls.length, 0); | ||
| 571 | }).pipe(Effect.provide(layer)); | ||
| 572 | }), | ||
| 573 | ); | ||
| 574 | |||
| 575 | it.effect("persists normal bounds and state from the native maximize event", () => | ||
| 576 | Effect.gen(function* () { | ||
| 577 | const fakeWindow = makeFakeBrowserWindow(); | ||
| 578 | const createCount = yield* Ref.make(0); | ||
| 579 | const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none()); | ||
| 580 | const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; | ||
| 581 | const mainWindowMaximizedUpdates: boolean[] = []; | ||
| 582 | const layer = makeTestLayer({ | ||
| 583 | window: fakeWindow.window, | ||
| 584 | createCount, | ||
| 585 | mainWindow, | ||
| 586 | mainWindowBoundsUpdates, | ||
| 587 | mainWindowMaximizedUpdates, | ||
| 588 | }); | ||
| 589 | |||
| 590 | yield* Effect.gen(function* () { | ||
| 591 | const desktopWindow = yield* DesktopWindow.DesktopWindow; | ||
| 592 | yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); | ||
| 593 | |||
| 594 | const maximize = fakeWindow.windowListeners.get("maximize"); | ||
| 595 | if (!maximize) { | ||
| 596 | return yield* Effect.die("window maximize listener was not registered"); | ||
| 597 | } | ||
| 598 | |||
| 599 | fakeWindow.isMaximized.mockReturnValue(true); | ||
| 600 | fakeWindow.getBounds.mockReturnValue({ x: 0, y: 0, width: 1920, height: 1080 }); | ||
| 601 | fakeWindow.getNormalBounds.mockReturnValue({ x: 220, y: 140, width: 1380, height: 920 }); | ||
| 602 | maximize(); | ||
| 603 | yield* TestClock.adjust(500); | ||
| 604 | yield* Effect.promise(() => Promise.resolve()); | ||
| 605 | |||
| 606 | assert.deepEqual(mainWindowBoundsUpdates, [{ x: 220, y: 140, width: 1380, height: 920 }]); | ||
| 607 | assert.deepEqual(mainWindowMaximizedUpdates, [true]); | ||
| 608 | }).pipe(Effect.provide(layer)); | ||
| 609 | }), | ||
| 610 | ); | ||
| 611 | |||
| 612 | it.effect("does not persist bounds that fail the domain schema", () => | ||
| 613 | Effect.gen(function* () { | ||
| 614 | const fakeWindow = makeFakeBrowserWindow(); | ||
| 615 | fakeWindow.getBounds.mockReturnValue({ x: 100.4, y: 80.2, width: 839.4, height: 619.4 }); | ||
| 616 | const createCount = yield* Ref.make(0); | ||
| 617 | const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none()); | ||
| 618 | const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; | ||
| 619 | const layer = makeTestLayer({ | ||
| 620 | window: fakeWindow.window, | ||
| 621 | createCount, | ||
| 622 | mainWindow, | ||
| 623 | mainWindowBoundsUpdates, | ||
| 624 | }); | ||
| 625 | |||
| 626 | yield* Effect.gen(function* () { | ||
| 627 | const desktopWindow = yield* DesktopWindow.DesktopWindow; | ||
| 628 | yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); | ||
| 629 | |||
| 630 | const resize = fakeWindow.windowListeners.get("resize"); | ||
| 631 | if (!resize) { | ||
| 632 | return yield* Effect.die("window resize listener was not registered"); | ||
| 633 | } | ||
| 634 | resize(); | ||
| 635 | yield* TestClock.adjust(500); | ||
| 636 | yield* Effect.promise(() => Promise.resolve()); | ||
| 637 | |||
| 638 | assert.deepEqual(mainWindowBoundsUpdates, []); | ||
| 639 | }).pipe(Effect.provide(layer)); | ||
| 640 | }), | ||
| 641 | ); | ||
| 642 | |||
| 643 | it.effect("preserves unrestorable bounds until the user changes the window", () => | ||
| 644 | Effect.gen(function* () { | ||
| 645 | const fakeWindow = makeFakeBrowserWindow(); | ||
| 646 | const createCount = yield* Ref.make(0); | ||
| 647 | const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none()); | ||
| 648 | const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; | ||
| 649 | const layer = makeTestLayer({ | ||
| 650 | window: fakeWindow.window, | ||
| 651 | createCount, | ||
| 652 | mainWindow, | ||
| 653 | mainWindowBoundsUpdates, | ||
| 654 | desktopSettings: { | ||
| 655 | ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, | ||
| 656 | mainWindowBounds: { x: 2040, y: 80, width: 1320, height: 880 }, | ||
| 657 | }, | ||
| 658 | }); | ||
| 659 | |||
| 660 | yield* Effect.gen(function* () { | ||
| 661 | const desktopWindow = yield* DesktopWindow.DesktopWindow; | ||
| 662 | yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); | ||
| 663 | |||
| 664 | const close = fakeWindow.windowListeners.get("close"); | ||
| 665 | const move = fakeWindow.windowListeners.get("move"); | ||
| 666 | if (!close || !move) { | ||
| 667 | return yield* Effect.die("window lifecycle listeners were not registered"); | ||
| 668 | } | ||
| 669 | |||
| 670 | close(); | ||
| 671 | yield* Effect.promise(() => Promise.resolve()); | ||
| 672 | assert.deepEqual(mainWindowBoundsUpdates, []); | ||
| 673 | |||
| 674 | fakeWindow.getBounds.mockReturnValue({ x: 80, y: 60, width: 1280, height: 840 }); | ||
| 675 | move(); | ||
| 676 | yield* TestClock.adjust(500); | ||
| 677 | yield* Effect.promise(() => Promise.resolve()); | ||
| 678 | assert.deepEqual(mainWindowBoundsUpdates, [{ x: 80, y: 60, width: 1280, height: 840 }]); | ||
| 679 | }).pipe(Effect.provide(layer)); | ||
| 680 | }), | ||
| 681 | ); | ||
| 682 | |||
| 683 | it.effect("flushes normal bounds when fullscreen before the debounce completes", () => | ||
| 684 | Effect.gen(function* () { | ||
| 685 | const fakeWindow = makeFakeBrowserWindow(); | ||
| 686 | fakeWindow.getBounds.mockReturnValue({ x: 0, y: 0, width: 1920, height: 1080 }); | ||
| 687 | fakeWindow.getNormalBounds.mockReturnValue({ x: 200, y: 130, width: 1400, height: 940 }); | ||
| 688 | const createCount = yield* Ref.make(0); | ||
| 689 | const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none()); | ||
| 690 | const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; | ||
| 691 | const layer = makeTestLayer({ | ||
| 692 | window: fakeWindow.window, | ||
| 693 | createCount, | ||
| 694 | mainWindow, | ||
| 695 | mainWindowBoundsUpdates, | ||
| 696 | }); | ||
| 697 | |||
| 698 | yield* Effect.gen(function* () { | ||
| 699 | const desktopWindow = yield* DesktopWindow.DesktopWindow; | ||
| 700 | yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); | ||
| 701 | |||
| 702 | const resize = fakeWindow.windowListeners.get("resize"); | ||
| 703 | if (!resize) { | ||
| 704 | return yield* Effect.die("window resize listener was not registered"); | ||
| 705 | } | ||
| 706 | resize(); | ||
| 707 | yield* TestClock.adjust(250); | ||
| 708 | fakeWindow.isFullScreen.mockReturnValue(true); | ||
| 709 | |||
| 710 | yield* desktopWindow.flushMainWindowBounds; | ||
| 711 | |||
| 712 | assert.deepEqual(mainWindowBoundsUpdates, [{ x: 200, y: 130, width: 1400, height: 940 }]); | ||
| 713 | assert.equal(fakeWindow.getBounds.mock.calls.length, 0); | ||
| 714 | assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 1); | ||
| 715 | }).pipe(Effect.provide(layer)); | ||
| 716 | }), | ||
| 717 | ); | ||
| 718 | |||
| 719 | it.effect("flushes normal bounds when minimized before the debounce completes", () => | ||
| 720 | Effect.gen(function* () { | ||
| 721 | const fakeWindow = makeFakeBrowserWindow(); | ||
| 722 | fakeWindow.getBounds.mockReturnValue({ x: -32_000, y: -32_000, width: 160, height: 28 }); | ||
| 723 | fakeWindow.getNormalBounds.mockReturnValue({ x: 180, y: 120, width: 1440, height: 960 }); | ||
| 724 | const createCount = yield* Ref.make(0); | ||
| 725 | const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none()); | ||
| 726 | const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; | ||
| 727 | const layer = makeTestLayer({ | ||
| 728 | window: fakeWindow.window, | ||
| 729 | createCount, | ||
| 730 | mainWindow, | ||
| 731 | mainWindowBoundsUpdates, | ||
| 732 | }); | ||
| 733 | |||
| 734 | yield* Effect.gen(function* () { | ||
| 735 | const desktopWindow = yield* DesktopWindow.DesktopWindow; | ||
| 736 | yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); | ||
| 737 | |||
| 738 | const resize = fakeWindow.windowListeners.get("resize"); | ||
| 739 | if (!resize) { | ||
| 740 | return yield* Effect.die("window resize listener was not registered"); | ||
| 741 | } | ||
| 742 | resize(); | ||
| 743 | yield* TestClock.adjust(250); | ||
| 744 | fakeWindow.isMinimized.mockReturnValue(true); | ||
| 745 | |||
| 746 | yield* desktopWindow.flushMainWindowBounds; | ||
| 747 | |||
| 748 | assert.deepEqual(mainWindowBoundsUpdates, [{ x: 180, y: 120, width: 1440, height: 960 }]); | ||
| 749 | assert.equal(fakeWindow.getBounds.mock.calls.length, 0); | ||
| 750 | assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 1); | ||
| 751 | }).pipe(Effect.provide(layer)); | ||
| 752 | }), | ||
| 753 | ); | ||
| 754 | |||
| 755 | it.effect("logs display lookup failures before falling back to the default size", () => | ||
| 756 | Effect.gen(function* () { | ||
| 757 | const displayLookupFailure = new Error("screen API unavailable"); | ||
| 758 | vi.mocked(Electron.screen.getAllDisplays).mockImplementationOnce(() => { | ||
| 759 | throw displayLookupFailure; | ||
| 760 | }); | ||
| 761 | const logRecords: Array<{ | ||
| 762 | readonly message: unknown; | ||
| 763 | readonly annotations: Readonly<Record<string, unknown>>; | ||
| 764 | }> = []; | ||
| 765 | const logger = Logger.make(({ fiber, message }) => { | ||
| 766 | logRecords.push({ | ||
| 767 | message, | ||
| 768 | annotations: { ...fiber.getRef(References.CurrentLogAnnotations) }, | ||
| 769 | }); | ||
| 770 | }); | ||
| 771 | const fakeWindow = makeFakeBrowserWindow(); | ||
| 772 | const createCount = yield* Ref.make(0); | ||
| 773 | const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none()); | ||
| 774 | const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = []; | ||
| 775 | const layer = makeTestLayer({ | ||
| 776 | window: fakeWindow.window, | ||
| 777 | createCount, | ||
| 778 | mainWindow, | ||
| 779 | createdWindowOptions, | ||
| 780 | }); | ||
| 781 | |||
| 782 | yield* Effect.gen(function* () { | ||
| 783 | const desktopWindow = yield* DesktopWindow.DesktopWindow; | ||
| 784 | yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); | ||
| 785 | }).pipe( | ||
| 786 | Effect.provide(Layer.mergeAll(layer, Logger.layer([logger], { mergeWithExisting: false }))), | ||
| 787 | ); | ||
| 788 | |||
| 789 | const warning = logRecords.find( | ||
| 790 | (record) => | ||
| 791 | Array.isArray(record.message) && | ||
| 792 | record.message[0] === "failed to read connected displays; using defaults", | ||
| 793 | ); | ||
| 794 | assert.isDefined(warning); | ||
| 795 | assert.strictEqual(warning.annotations.cause, displayLookupFailure); | ||
| 796 | assert.equal(createdWindowOptions[0]?.width, 1100); | ||
| 797 | assert.equal(createdWindowOptions[0]?.height, 780); | ||
| 798 | assert.isUndefined(createdWindowOptions[0]?.x); | ||
| 799 | assert.isUndefined(createdWindowOptions[0]?.y); | ||
| 800 | }), | ||
| 801 | ); | ||
| 802 | |||
| 803 | it.effect("persists the current main window bounds before the window closes", () => | ||
| 804 | Effect.gen(function* () { | ||
| 805 | const fakeWindow = makeFakeBrowserWindow(); | ||
| 806 | fakeWindow.getBounds.mockReturnValue({ x: 240, y: 160, width: 1410, height: 930 }); | ||
| 807 | const createCount = yield* Ref.make(0); | ||
| 808 | const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none()); | ||
| 809 | const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; | ||
| 810 | const writeStarted = yield* Deferred.make<void>(); | ||
| 811 | const allowWrite = yield* Deferred.make<void>(); | ||
| 812 | const flushCompleted = yield* Deferred.make<void>(); | ||
| 813 | const layer = makeTestLayer({ | ||
| 814 | window: fakeWindow.window, | ||
| 815 | createCount, | ||
| 816 | mainWindow, | ||
| 817 | mainWindowBoundsUpdates, | ||
| 818 | beforeMainWindowBoundsUpdate: () => | ||
| 819 | Deferred.succeed(writeStarted, undefined).pipe( | ||
| 820 | Effect.andThen(Deferred.await(allowWrite)), | ||
| 821 | Effect.asVoid, | ||
| 822 | ), | ||
| 823 | }); | ||
| 824 | |||
| 825 | yield* Effect.gen(function* () { | ||
| 826 | const desktopWindow = yield* DesktopWindow.DesktopWindow; | ||
| 827 | yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); | ||
| 828 | |||
| 829 | const close = fakeWindow.windowListeners.get("close"); | ||
| 830 | if (!close) { | ||
| 831 | return yield* Effect.die("window close listener was not registered"); | ||
| 832 | } | ||
| 833 | close(); | ||
| 834 | yield* Deferred.await(writeStarted); | ||
| 835 | fakeWindow.isDestroyed.mockReturnValue(true); | ||
| 836 | |||
| 837 | const flushFiber = yield* desktopWindow.flushMainWindowBounds.pipe( | ||
| 838 | Effect.andThen(Deferred.succeed(flushCompleted, undefined)), | ||
| 839 | Effect.forkChild({ startImmediately: true }), | ||
| 840 | ); | ||
| 841 | yield* Effect.yieldNow; | ||
| 842 | assert.isFalse(yield* Deferred.isDone(flushCompleted)); | ||
| 843 | |||
| 844 | yield* Deferred.succeed(allowWrite, undefined); | ||
| 845 | yield* Fiber.join(flushFiber); | ||
| 846 | assert.isTrue(yield* Deferred.isDone(flushCompleted)); | ||
| 847 | |||
| 848 | assert.deepEqual(mainWindowBoundsUpdates, [ | ||
| 849 | { | ||
| 850 | x: 240, | ||
| 851 | y: 160, | ||
| 852 | width: 1410, | ||
| 853 | height: 930, | ||
| 854 | }, | ||
| 855 | ]); | ||
| 856 | }).pipe(Effect.provide(layer)); | ||
| 857 | }), | ||
| 858 | ); | ||
| 859 | |||
| 346 | it.effect("publishes native macOS fullscreen changes to the renderer", () => | 860 | it.effect("publishes native macOS fullscreen changes to the renderer", () => |
| 347 | Effect.gen(function* () { | 861 | Effect.gen(function* () { |
| 348 | const fakeWindow = makeFakeBrowserWindow(); | 862 | const fakeWindow = makeFakeBrowserWindow(); |
apps/desktop/src/window/DesktopWindow.ts+170-3| ... | @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; |
| 5 | import * as Option from "effect/Option"; | 5 | import * as Option from "effect/Option"; |
| 6 | import * as Ref from "effect/Ref"; | 6 | import * as Ref from "effect/Ref"; |
| 7 | 7 | ||
| 8 | import type * as Electron from "electron"; | 8 | import * as Electron from "electron"; |
| 9 | 9 | ||
| 10 | import * as DesktopAssets from "../app/DesktopAssets.ts"; | 10 | import * as DesktopAssets from "../app/DesktopAssets.ts"; |
| 11 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | 11 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| ... | @@ -17,11 +17,13 @@ import * as ElectronTheme from "../electron/ElectronTheme.ts"; | ... | @@ -17,11 +17,13 @@ import * as ElectronTheme from "../electron/ElectronTheme.ts"; |
| 17 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; | 17 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; |
| 18 | import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; | 18 | import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; |
| 19 | import * as PreviewManager from "../preview/Manager.ts"; | 19 | import * as PreviewManager from "../preview/Manager.ts"; |
| 20 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; | ||
| 20 | 21 | ||
| 21 | const TITLEBAR_HEIGHT = 40; | 22 | const TITLEBAR_HEIGHT = 40; |
| 22 | const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux | 23 | const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux |
| 23 | const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; | 24 | const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; |
| 24 | const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; | 25 | const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; |
| 26 | const MAIN_WINDOW_BOUNDS_PERSIST_DEBOUNCE_MS = 500; | ||
| 25 | const DEVELOPMENT_LOAD_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const; | 27 | const DEVELOPMENT_LOAD_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const; |
| 26 | const DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES = new Set([ | 28 | const DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES = new Set([ |
| 27 | -2, // ERR_FAILED | 29 | -2, // ERR_FAILED |
| ... | @@ -41,6 +43,7 @@ type WindowTitleBarOptions = Pick< | ... | @@ -41,6 +43,7 @@ type WindowTitleBarOptions = Pick< |
| 41 | type DesktopWindowRuntimeServices = | 43 | type DesktopWindowRuntimeServices = |
| 42 | | DesktopEnvironment.DesktopEnvironment | 44 | | DesktopEnvironment.DesktopEnvironment |
| 43 | | DesktopAssets.DesktopAssets | 45 | | DesktopAssets.DesktopAssets |
| 46 | | DesktopAppSettings.DesktopAppSettings | ||
| 44 | | ElectronMenu.ElectronMenu | 47 | | ElectronMenu.ElectronMenu |
| 45 | | ElectronShell.ElectronShell | 48 | | ElectronShell.ElectronShell |
| 46 | | ElectronTheme.ElectronTheme | 49 | | ElectronTheme.ElectronTheme |
| ... | @@ -75,6 +78,7 @@ export class DesktopWindow extends Context.Service< | ... | @@ -75,6 +78,7 @@ export class DesktopWindow extends Context.Service< |
| 75 | // window so a "macOS dock click" while the backend is down doesn't | 78 | // window so a "macOS dock click" while the backend is down doesn't |
| 76 | // produce a stranded window pointing at nothing. | 79 | // produce a stranded window pointing at nothing. |
| 77 | readonly handleBackendNotReady: Effect.Effect<void>; | 80 | readonly handleBackendNotReady: Effect.Effect<void>; |
| 81 | readonly flushMainWindowBounds: Effect.Effect<void>; | ||
| 78 | readonly dispatchMenuAction: (action: string) => Effect.Effect<void, DesktopWindowError>; | 82 | readonly dispatchMenuAction: (action: string) => Effect.Effect<void, DesktopWindowError>; |
| 79 | readonly syncAppearance: Effect.Effect<void>; | 83 | readonly syncAppearance: Effect.Effect<void>; |
| 80 | } | 84 | } |
| ... | @@ -99,6 +103,45 @@ function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string { | ... | @@ -99,6 +103,45 @@ function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string { |
| 99 | return shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; | 103 | return shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; |
| 100 | } | 104 | } |
| 101 | 105 | ||
| 106 | type DisplayBounds = Pick<Electron.Rectangle, "x" | "y" | "width" | "height">; | ||
| 107 | |||
| 108 | function windowFitsWithinDisplay( | ||
| 109 | windowBounds: DesktopAppSettings.DesktopWindowBounds, | ||
| 110 | displayBounds: DisplayBounds, | ||
| 111 | ): boolean { | ||
| 112 | return ( | ||
| 113 | windowBounds.x >= displayBounds.x && | ||
| 114 | windowBounds.y >= displayBounds.y && | ||
| 115 | windowBounds.x + windowBounds.width <= displayBounds.x + displayBounds.width && | ||
| 116 | windowBounds.y + windowBounds.height <= displayBounds.y + displayBounds.height | ||
| 117 | ); | ||
| 118 | } | ||
| 119 | |||
| 120 | function windowBoundsEqual( | ||
| 121 | left: DesktopAppSettings.DesktopWindowBounds, | ||
| 122 | right: DesktopAppSettings.DesktopWindowBounds, | ||
| 123 | ): boolean { | ||
| 124 | return ( | ||
| 125 | left.x === right.x && | ||
| 126 | left.y === right.y && | ||
| 127 | left.width === right.width && | ||
| 128 | left.height === right.height | ||
| 129 | ); | ||
| 130 | } | ||
| 131 | |||
| 132 | export function resolveInitialMainWindowBounds( | ||
| 133 | persistedBounds: DesktopAppSettings.DesktopWindowBounds | null, | ||
| 134 | displays: readonly DisplayBounds[], | ||
| 135 | ): DesktopAppSettings.DesktopWindowBounds | typeof DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE { | ||
| 136 | if ( | ||
| 137 | persistedBounds !== null && | ||
| 138 | displays.some((display) => windowFitsWithinDisplay(persistedBounds, display)) | ||
| 139 | ) { | ||
| 140 | return persistedBounds; | ||
| 141 | } | ||
| 142 | return DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE; | ||
| 143 | } | ||
| 144 | |||
| 102 | // A self-contained "Connecting to WSL" splash, shown immediately in wsl-only | 145 | // A self-contained "Connecting to WSL" splash, shown immediately in wsl-only |
| 103 | // mode while the WSL backend (which serves the renderer) cold-boots. Inlined as | 146 | // mode while the WSL backend (which serves the renderer) cold-boots. Inlined as |
| 104 | // a data URL so it needs no bundled asset and no backend — pure CSS, no JS. | 147 | // a data URL so it needs no bundled asset and no backend — pure CSS, no JS. |
| ... | @@ -202,6 +245,7 @@ export const make = Effect.gen(function* () { | ... | @@ -202,6 +245,7 @@ export const make = Effect.gen(function* () { |
| 202 | const electronTheme = yield* ElectronTheme.ElectronTheme; | 245 | const electronTheme = yield* ElectronTheme.ElectronTheme; |
| 203 | const electronWindow = yield* ElectronWindow.ElectronWindow; | 246 | const electronWindow = yield* ElectronWindow.ElectronWindow; |
| 204 | const previewManager = yield* PreviewManager.PreviewManager; | 247 | const previewManager = yield* PreviewManager.PreviewManager; |
| 248 | const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; | ||
| 205 | // Window-side latch for the primary backend's readiness. Set by | 249 | // Window-side latch for the primary backend's readiness. Set by |
| 206 | // handleBackendReady (driven by the pool's onReady callback), cleared | 250 | // handleBackendReady (driven by the pool's onReady callback), cleared |
| 207 | // by handleBackendNotReady (driven by onShutdown). Only consumed by | 251 | // by handleBackendNotReady (driven by onShutdown). Only consumed by |
| ... | @@ -214,6 +258,7 @@ export const make = Effect.gen(function* () { | ... | @@ -214,6 +258,7 @@ export const make = Effect.gen(function* () { |
| 214 | const context = yield* Effect.context<DesktopWindowRuntimeServices>(); | 258 | const context = yield* Effect.context<DesktopWindowRuntimeServices>(); |
| 215 | const runFork = Effect.runForkWith(context); | 259 | const runFork = Effect.runForkWith(context); |
| 216 | const runPromise = Effect.runPromiseWith(context); | 260 | const runPromise = Effect.runPromiseWith(context); |
| 261 | let flushMainWindowBounds: Effect.Effect<void> = Effect.void; | ||
| 217 | 262 | ||
| 218 | const dismissConnectingSplash = Effect.gen(function* () { | 263 | const dismissConnectingSplash = Effect.gen(function* () { |
| 219 | const splash = yield* Ref.getAndSet(splashWindowRef, Option.none()); | 264 | const splash = yield* Ref.getAndSet(splashWindowRef, Option.none()); |
| ... | @@ -250,9 +295,31 @@ export const make = Effect.gen(function* () { | ... | @@ -250,9 +295,31 @@ export const make = Effect.gen(function* () { |
| 250 | const iconPaths = yield* assets.iconPaths; | 295 | const iconPaths = yield* assets.iconPaths; |
| 251 | const iconOption = getIconOption(iconPaths, environment.platform); | 296 | const iconOption = getIconOption(iconPaths, environment.platform); |
| 252 | const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; | 297 | const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; |
| 298 | const persistedSettings = yield* desktopSettings.get; | ||
| 299 | const persistedBounds = persistedSettings.mainWindowBounds; | ||
| 300 | const displayBoundsResult = yield* Effect.sync(() => { | ||
| 301 | try { | ||
| 302 | return { | ||
| 303 | _tag: "Success" as const, | ||
| 304 | bounds: Electron.screen.getAllDisplays().map((display) => display.bounds), | ||
| 305 | }; | ||
| 306 | } catch (cause) { | ||
| 307 | return { _tag: "Failure" as const, cause }; | ||
| 308 | } | ||
| 309 | }); | ||
| 310 | const displayBounds = | ||
| 311 | displayBoundsResult._tag === "Success" | ||
| 312 | ? displayBoundsResult.bounds | ||
| 313 | : yield* logWindowWarning("failed to read connected displays; using defaults", { | ||
| 314 | cause: displayBoundsResult.cause, | ||
| 315 | }).pipe(Effect.as<readonly Electron.Rectangle[]>([])); | ||
| 316 | const initialBounds = resolveInitialMainWindowBounds(persistedBounds, displayBounds); | ||
| 317 | const restoredPersistedBounds = persistedBounds !== null && initialBounds === persistedBounds; | ||
| 318 | if (persistedBounds !== null && initialBounds === DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE) { | ||
| 319 | yield* logWindowWarning("saved main window bounds could not be restored; using defaults"); | ||
| 320 | } | ||
| 253 | const window = yield* electronWindow.create({ | 321 | const window = yield* electronWindow.create({ |
| 254 | width: 1100, | 322 | ...initialBounds, |
| 255 | height: 780, | ||
| 256 | minWidth: 840, | 323 | minWidth: 840, |
| 257 | minHeight: 620, | 324 | minHeight: 620, |
| 258 | show: false, | 325 | show: false, |
| ... | @@ -274,6 +341,92 @@ export const make = Effect.gen(function* () { | ... | @@ -274,6 +341,92 @@ export const make = Effect.gen(function* () { |
| 274 | if (environment.platform === "darwin") { | 341 | if (environment.platform === "darwin") { |
| 275 | window.setAutoHideCursor(false); | 342 | window.setAutoHideCursor(false); |
| 276 | } | 343 | } |
| 344 | let boundsPersistFiber: Fiber.Fiber<void, never> | undefined; | ||
| 345 | let pendingBoundsPersistFiber: Fiber.Fiber<void, never> | undefined; | ||
| 346 | let boundsPersistenceEnabled = persistedBounds === null || restoredPersistedBounds; | ||
| 347 | const readPersistableBounds = (): DesktopAppSettings.DesktopWindowBounds | null => { | ||
| 348 | if (window.isDestroyed()) { | ||
| 349 | return null; | ||
| 350 | } | ||
| 351 | const bounds = | ||
| 352 | window.isFullScreen() || window.isMaximized() || window.isMinimized() | ||
| 353 | ? window.getNormalBounds() | ||
| 354 | : window.getBounds(); | ||
| 355 | return DesktopAppSettings.normalizeMainWindowBounds({ | ||
| 356 | x: Math.round(bounds.x), | ||
| 357 | y: Math.round(bounds.y), | ||
| 358 | width: Math.round(bounds.width), | ||
| 359 | height: Math.round(bounds.height), | ||
| 360 | }); | ||
| 361 | }; | ||
| 362 | const fallbackWindowBounds = boundsPersistenceEnabled ? null : readPersistableBounds(); | ||
| 363 | const fallbackWindowMaximized = persistedSettings.mainWindowMaximized; | ||
| 364 | const persistCurrentBounds = (): Fiber.Fiber<void, never> | undefined => { | ||
| 365 | if (!boundsPersistenceEnabled) { | ||
| 366 | return pendingBoundsPersistFiber; | ||
| 367 | } | ||
| 368 | const bounds = readPersistableBounds(); | ||
| 369 | if (bounds === null) { | ||
| 370 | return pendingBoundsPersistFiber; | ||
| 371 | } | ||
| 372 | pendingBoundsPersistFiber = runFork( | ||
| 373 | desktopSettings.setMainWindowBounds(bounds, window.isMaximized()).pipe( | ||
| 374 | Effect.asVoid, | ||
| 375 | Effect.catch((error) => | ||
| 376 | logWindowWarning("failed to persist main window bounds", { | ||
| 377 | message: error.message, | ||
| 378 | }), | ||
| 379 | ), | ||
| 380 | ), | ||
| 381 | ); | ||
| 382 | return pendingBoundsPersistFiber; | ||
| 383 | }; | ||
| 384 | const scheduleBoundsPersist = () => { | ||
| 385 | if (!boundsPersistenceEnabled) { | ||
| 386 | const currentBounds = readPersistableBounds(); | ||
| 387 | if ( | ||
| 388 | currentBounds === null || | ||
| 389 | (fallbackWindowBounds !== null && | ||
| 390 | windowBoundsEqual(currentBounds, fallbackWindowBounds) && | ||
| 391 | window.isMaximized() === fallbackWindowMaximized) | ||
| 392 | ) { | ||
| 393 | return; | ||
| 394 | } | ||
| 395 | } | ||
| 396 | boundsPersistenceEnabled = true; | ||
| 397 | if (boundsPersistFiber !== undefined) { | ||
| 398 | const fiber = boundsPersistFiber; | ||
| 399 | boundsPersistFiber = undefined; | ||
| 400 | runFork(Fiber.interrupt(fiber)); | ||
| 401 | } | ||
| 402 | boundsPersistFiber = runFork( | ||
| 403 | Effect.sleep(MAIN_WINDOW_BOUNDS_PERSIST_DEBOUNCE_MS).pipe( | ||
| 404 | Effect.andThen( | ||
| 405 | Effect.sync(() => { | ||
| 406 | boundsPersistFiber = undefined; | ||
| 407 | void persistCurrentBounds(); | ||
| 408 | }), | ||
| 409 | ), | ||
| 410 | ), | ||
| 411 | ); | ||
| 412 | }; | ||
| 413 | const clearBoundsPersist = () => { | ||
| 414 | if (boundsPersistFiber === undefined) { | ||
| 415 | return; | ||
| 416 | } | ||
| 417 | const fiber = boundsPersistFiber; | ||
| 418 | boundsPersistFiber = undefined; | ||
| 419 | runFork(Fiber.interrupt(fiber)); | ||
| 420 | }; | ||
| 421 | const flushBoundsPersist = Effect.sync(() => { | ||
| 422 | clearBoundsPersist(); | ||
| 423 | return persistCurrentBounds(); | ||
| 424 | }).pipe( | ||
| 425 | Effect.flatMap((fiber) => | ||
| 426 | fiber === undefined ? Effect.void : Fiber.join(fiber).pipe(Effect.asVoid), | ||
| 427 | ), | ||
| 428 | ); | ||
| 429 | flushMainWindowBounds = flushBoundsPersist; | ||
| 277 | 430 | ||
| 278 | yield* previewManager.setMainWindow(window); | 431 | yield* previewManager.setMainWindow(window); |
| 279 | window.webContents.on("will-attach-webview", (event, webPreferences, params) => { | 432 | window.webContents.on("will-attach-webview", (event, webPreferences, params) => { |
| ... | @@ -364,6 +517,13 @@ export const make = Effect.gen(function* () { | ... | @@ -364,6 +517,13 @@ export const make = Effect.gen(function* () { |
| 364 | event.preventDefault(); | 517 | event.preventDefault(); |
| 365 | window.setTitle(environment.displayName); | 518 | window.setTitle(environment.displayName); |
| 366 | }); | 519 | }); |
| 520 | window.on("resize", scheduleBoundsPersist); | ||
| 521 | window.on("move", scheduleBoundsPersist); | ||
| 522 | window.on("maximize", scheduleBoundsPersist); | ||
| 523 | window.on("unmaximize", scheduleBoundsPersist); | ||
| 524 | window.on("close", () => { | ||
| 525 | runFork(flushBoundsPersist); | ||
| 526 | }); | ||
| 367 | 527 | ||
| 368 | if (environment.platform === "darwin") { | 528 | if (environment.platform === "darwin") { |
| 369 | window.on("enter-full-screen", () => { | 529 | window.on("enter-full-screen", () => { |
| ... | @@ -472,6 +632,9 @@ export const make = Effect.gen(function* () { | ... | @@ -472,6 +632,9 @@ export const make = Effect.gen(function* () { |
| 472 | bindFirstRevealTrigger(revealSubscribers, () => { | 632 | bindFirstRevealTrigger(revealSubscribers, () => { |
| 473 | // Reveal the real window, then close the connecting splash (if any) so the | 633 | // Reveal the real window, then close the connecting splash (if any) so the |
| 474 | // two don't overlap and there's no blank gap between them. | 634 | // two don't overlap and there's no blank gap between them. |
| 635 | if (persistedSettings.mainWindowMaximized) { | ||
| 636 | window.maximize(); | ||
| 637 | } | ||
| 475 | void runPromise(Effect.andThen(electronWindow.reveal(window), dismissConnectingSplash)); | 638 | void runPromise(Effect.andThen(electronWindow.reveal(window), dismissConnectingSplash)); |
| 476 | }); | 639 | }); |
| 477 | 640 | ||
| ... | @@ -482,6 +645,7 @@ export const make = Effect.gen(function* () { | ... | @@ -482,6 +645,7 @@ export const make = Effect.gen(function* () { |
| 482 | 645 | ||
| 483 | window.on("closed", () => { | 646 | window.on("closed", () => { |
| 484 | clearDevelopmentLoadRetry(); | 647 | clearDevelopmentLoadRetry(); |
| 648 | clearBoundsPersist(); | ||
| 485 | void runPromise(electronWindow.clearMain(Option.some(window))); | 649 | void runPromise(electronWindow.clearMain(Option.some(window))); |
| 486 | }); | 650 | }); |
| 487 | 651 | ||
| ... | @@ -598,6 +762,9 @@ export const make = Effect.gen(function* () { | ... | @@ -598,6 +762,9 @@ export const make = Effect.gen(function* () { |
| 598 | handleBackendNotReady: Ref.set(backendReadyRef, false).pipe( | 762 | handleBackendNotReady: Ref.set(backendReadyRef, false).pipe( |
| 599 | Effect.withSpan("desktop.window.handleBackendNotReady"), | 763 | Effect.withSpan("desktop.window.handleBackendNotReady"), |
| 600 | ), | 764 | ), |
| 765 | flushMainWindowBounds: Effect.suspend(() => flushMainWindowBounds).pipe( | ||
| 766 | Effect.withSpan("desktop.window.flushMainWindowBounds"), | ||
| 767 | ), | ||
| 601 | dispatchMenuAction: Effect.fn("desktop.window.dispatchMenuAction")(function* (action) { | 768 | dispatchMenuAction: Effect.fn("desktop.window.dispatchMenuAction")(function* (action) { |
| 602 | yield* Effect.annotateCurrentSpan({ action }); | 769 | yield* Effect.annotateCurrentSpan({ action }); |
| 603 | const existingWindow = yield* focusedMainWindow; | 770 | const existingWindow = yield* focusedMainWindow; |
apps/web/src/components/AppSidebarLayout.tsx+32-8| ... | @@ -1,13 +1,22 @@ | ... | @@ -1,13 +1,22 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import * as Schema from "effect/Schema"; | ||
| 2 | import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; | 3 | import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; |
| 3 | import { useLocation, useNavigate } from "@tanstack/react-router"; | 4 | import { useLocation, useNavigate } from "@tanstack/react-router"; |
| 4 | 5 | ||
| 5 | import { isElectron } from "../env"; | 6 | import { isElectron } from "../env"; |
| 7 | import { getLocalStorageItem } from "../hooks/useLocalStorage"; | ||
| 6 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; | 8 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; |
| 7 | import { cn, isMacPlatform } from "../lib/utils"; | 9 | import { cn, isMacPlatform } from "../lib/utils"; |
| 8 | import { primaryServerKeybindingsAtom } from "../state/server"; | 10 | import { primaryServerKeybindingsAtom } from "../state/server"; |
| 9 | import ThreadSidebar from "./Sidebar"; | 11 | import ThreadSidebar from "./Sidebar"; |
| 10 | import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; | 12 | import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; |
| 13 | import { | ||
| 14 | resolveInitialThreadSidebarWidth, | ||
| 15 | resolveThreadSidebarMaximumWidth, | ||
| 16 | THREAD_MAIN_CONTENT_MIN_WIDTH, | ||
| 17 | THREAD_SIDEBAR_MIN_WIDTH, | ||
| 18 | THREAD_SIDEBAR_WIDTH_STORAGE_KEY, | ||
| 19 | } from "./threadSidebarWidth"; | ||
| 11 | import { | 20 | import { |
| 12 | Sidebar, | 21 | Sidebar, |
| 13 | SidebarProvider, | 22 | SidebarProvider, |
| ... | @@ -18,11 +27,20 @@ import { | ... | @@ -18,11 +27,20 @@ import { |
| 18 | } from "./ui/sidebar"; | 27 | } from "./ui/sidebar"; |
| 19 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | 28 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 20 | 29 | ||
| 21 | const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; | ||
| 22 | const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; | ||
| 23 | const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; | ||
| 24 | const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px"; | 30 | const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px"; |
| 25 | 31 | ||
| 32 | function readInitialThreadSidebarWidth(): number { | ||
| 33 | try { | ||
| 34 | return resolveInitialThreadSidebarWidth( | ||
| 35 | getLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY, Schema.Finite), | ||
| 36 | window.innerWidth, | ||
| 37 | ); | ||
| 38 | } catch (error) { | ||
| 39 | console.error("Could not read persisted thread sidebar width.", error); | ||
| 40 | return resolveInitialThreadSidebarWidth(null, window.innerWidth); | ||
| 41 | } | ||
| 42 | } | ||
| 43 | |||
| 26 | function SidebarControl() { | 44 | function SidebarControl() { |
| 27 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); | 45 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 28 | const { toggleSidebar } = useSidebar(); | 46 | const { toggleSidebar } = useSidebar(); |
| ... | @@ -82,16 +100,20 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { | ... | @@ -82,16 +100,20 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 82 | const navigate = useNavigate(); | 100 | const navigate = useNavigate(); |
| 83 | const pathname = useLocation({ select: (location) => location.pathname }); | 101 | const pathname = useLocation({ select: (location) => location.pathname }); |
| 84 | const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); | 102 | const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); |
| 103 | const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); | ||
| 104 | const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(window.innerWidth); | ||
| 85 | const [isWindowFullscreen, setIsWindowFullscreen] = useState(() => { | 105 | const [isWindowFullscreen, setIsWindowFullscreen] = useState(() => { |
| 86 | const getWindowFullscreenState = window.desktopBridge?.getWindowFullscreenState; | 106 | const getWindowFullscreenState = window.desktopBridge?.getWindowFullscreenState; |
| 87 | return isMacosDesktop && typeof getWindowFullscreenState === "function" | 107 | return isMacosDesktop && typeof getWindowFullscreenState === "function" |
| 88 | ? getWindowFullscreenState() | 108 | ? getWindowFullscreenState() |
| 89 | : false; | 109 | : false; |
| 90 | }); | 110 | }); |
| 91 | const macosWindowControlsStyle = | 111 | const sidebarProviderStyle = { |
| 92 | isMacosDesktop && !isWindowFullscreen | 112 | "--sidebar-width": `${sidebarWidth}px`, |
| 93 | ? ({ "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } as CSSProperties) | 113 | ...(isMacosDesktop && !isWindowFullscreen |
| 94 | : undefined; | 114 | ? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } |
| 115 | : {}), | ||
| 116 | } as CSSProperties; | ||
| 95 | 117 | ||
| 96 | useEffect(() => { | 118 | useEffect(() => { |
| 97 | if (!isMacosDesktop) return; | 119 | if (!isMacosDesktop) return; |
| ... | @@ -131,17 +153,19 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { | ... | @@ -131,17 +153,19 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 131 | }, [navigate, pathname]); | 153 | }, [navigate, pathname]); |
| 132 | 154 | ||
| 133 | return ( | 155 | return ( |
| 134 | <SidebarProvider className="h-dvh! min-h-0!" defaultOpen style={macosWindowControlsStyle}> | 156 | <SidebarProvider className="h-dvh! min-h-0!" defaultOpen style={sidebarProviderStyle}> |
| 135 | <Sidebar | 157 | <Sidebar |
| 136 | side="left" | 158 | side="left" |
| 137 | collapsible="offcanvas" | 159 | collapsible="offcanvas" |
| 138 | className="border-r border-border bg-card text-foreground" | 160 | className="border-r border-border bg-card text-foreground" |
| 139 | resizable={{ | 161 | resizable={{ |
| 162 | maxWidth: sidebarMaximumWidth, | ||
| 140 | minWidth: THREAD_SIDEBAR_MIN_WIDTH, | 163 | minWidth: THREAD_SIDEBAR_MIN_WIDTH, |
| 141 | shouldAcceptWidth: ({ currentWidth, nextWidth, wrapper }) => | 164 | shouldAcceptWidth: ({ currentWidth, nextWidth, wrapper }) => |
| 142 | nextWidth <= currentWidth || | 165 | nextWidth <= currentWidth || |
| 143 | wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, | 166 | wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, |
| 144 | storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, | 167 | storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, |
| 168 | onResize: setSidebarWidth, | ||
| 145 | }} | 169 | }} |
| 146 | > | 170 | > |
| 147 | <ThreadSidebar /> | 171 | <ThreadSidebar /> |
apps/web/src/components/threadSidebarWidth.test.ts created+34| ... | @@ -0,0 +1,34 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | resolveInitialThreadSidebarWidth, | ||
| 5 | THREAD_MAIN_CONTENT_MIN_WIDTH, | ||
| 6 | THREAD_SIDEBAR_DEFAULT_WIDTH, | ||
| 7 | THREAD_SIDEBAR_MIN_WIDTH, | ||
| 8 | } from "./threadSidebarWidth"; | ||
| 9 | |||
| 10 | describe("thread sidebar width", () => { | ||
| 11 | it("uses the default width when no preference is stored", () => { | ||
| 12 | expect(resolveInitialThreadSidebarWidth(null, 1200)).toBe(THREAD_SIDEBAR_DEFAULT_WIDTH); | ||
| 13 | }); | ||
| 14 | |||
| 15 | it("uses a stored width in the initial render", () => { | ||
| 16 | expect(resolveInitialThreadSidebarWidth(360, 1200)).toBe(360); | ||
| 17 | }); | ||
| 18 | |||
| 19 | it("clamps a stored width to the sidebar minimum", () => { | ||
| 20 | expect(resolveInitialThreadSidebarWidth(120, 1200)).toBe(THREAD_SIDEBAR_MIN_WIDTH); | ||
| 21 | }); | ||
| 22 | |||
| 23 | it("leaves enough room for the main content on a smaller window", () => { | ||
| 24 | const viewportWidth = 1000; | ||
| 25 | |||
| 26 | expect(resolveInitialThreadSidebarWidth(900, viewportWidth)).toBe( | ||
| 27 | viewportWidth - THREAD_MAIN_CONTENT_MIN_WIDTH, | ||
| 28 | ); | ||
| 29 | }); | ||
| 30 | |||
| 31 | it("keeps the sidebar minimum when the whole layout is narrower than its minimums", () => { | ||
| 32 | expect(resolveInitialThreadSidebarWidth(900, 700)).toBe(THREAD_SIDEBAR_MIN_WIDTH); | ||
| 33 | }); | ||
| 34 | }); | ||
apps/web/src/components/threadSidebarWidth.ts created+22| ... | @@ -0,0 +1,22 @@ | ||
| 1 | export const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; | ||
| 2 | export const THREAD_SIDEBAR_DEFAULT_WIDTH = 16 * 16; | ||
| 3 | export const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; | ||
| 4 | export const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; | ||
| 5 | |||
| 6 | export function resolveThreadSidebarMaximumWidth(viewportWidth: number): number { | ||
| 7 | return Math.max( | ||
| 8 | THREAD_SIDEBAR_MIN_WIDTH, | ||
| 9 | Math.floor(viewportWidth) - THREAD_MAIN_CONTENT_MIN_WIDTH, | ||
| 10 | ); | ||
| 11 | } | ||
| 12 | |||
| 13 | export function resolveInitialThreadSidebarWidth( | ||
| 14 | storedWidth: number | null, | ||
| 15 | viewportWidth: number, | ||
| 16 | ): number { | ||
| 17 | const preferredWidth = | ||
| 18 | storedWidth === null | ||
| 19 | ? THREAD_SIDEBAR_DEFAULT_WIDTH | ||
| 20 | : Math.max(THREAD_SIDEBAR_MIN_WIDTH, storedWidth); | ||
| 21 | return Math.min(preferredWidth, resolveThreadSidebarMaximumWidth(viewportWidth)); | ||
| 22 | } | ||
apps/web/src/components/ui/sidebar.tsx+10-2| ... | @@ -555,16 +555,24 @@ function SidebarRail({ | ... | @@ -555,16 +555,24 @@ function SidebarRail({ |
| 555 | [onClick, open, resolvedResizable, toggleSidebar], | 555 | [onClick, open, resolvedResizable, toggleSidebar], |
| 556 | ); | 556 | ); |
| 557 | 557 | ||
| 558 | React.useEffect(() => { | 558 | React.useLayoutEffect(() => { |
| 559 | if (!resolvedResizable?.storageKey || typeof window === "undefined") return; | 559 | if (!resolvedResizable?.storageKey || typeof window === "undefined") return; |
| 560 | const rail = railRef.current; | 560 | const rail = railRef.current; |
| 561 | if (!rail) return; | 561 | if (!rail) return; |
| 562 | const wrapper = rail.closest<HTMLElement>("[data-slot='sidebar-wrapper']"); | 562 | const wrapper = rail.closest<HTMLElement>("[data-slot='sidebar-wrapper']"); |
| 563 | if (!wrapper) return; | 563 | if (!wrapper) return; |
| 564 | 564 | ||
| 565 | const storedWidth = getLocalStorageItem(resolvedResizable.storageKey, Schema.Finite); | 565 | let storedWidth: number | null; |
| 566 | try { | ||
| 567 | storedWidth = getLocalStorageItem(resolvedResizable.storageKey, Schema.Finite); | ||
| 568 | } catch (error) { | ||
| 569 | console.error("Could not restore persisted sidebar width.", error); | ||
| 570 | return; | ||
| 571 | } | ||
| 566 | if (storedWidth === null) return; | 572 | if (storedWidth === null) return; |
| 567 | const clampedWidth = clampSidebarWidth(storedWidth, resolvedResizable); | 573 | const clampedWidth = clampSidebarWidth(storedWidth, resolvedResizable); |
| 574 | // Hydrate the CSS variable before the browser paints so a restored sidebar | ||
| 575 | // never flashes at the default width first. | ||
| 568 | wrapper.style.setProperty("--sidebar-width", `${clampedWidth}px`); | 576 | wrapper.style.setProperty("--sidebar-width", `${clampedWidth}px`); |
| 569 | resolvedResizable.onResize?.(clampedWidth); | 577 | resolvedResizable.onResize?.(clampedWidth); |
| 570 | }, [resolvedResizable]); | 578 | }, [resolvedResizable]); |