Skip to content

Commit a63852e

Browse files
authored
Merge pull request #75 from aaditagrawal/sync/upstream-2026-04-24
sync: merge upstream/main v0.0.21 (17 commits)
2 parents 215705b + 213f782 commit a63852e

62 files changed

Lines changed: 7874 additions & 3297 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.cursor/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
plans/

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ jobs:
1010
quality:
1111
name: Format, Lint, Typecheck, Test, Browser Test, Build
1212
runs-on: ubuntu-24.04
13-
timeout-minutes: 10
13+
timeout-minutes: 30
1414
steps:
1515
- name: Checkout
1616
uses: actions/checkout@v6

apps/desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@t3tools/desktop",
3-
"version": "0.0.20",
3+
"version": "0.0.21",
44
"private": true,
55
"type": "module",
66
"main": "dist-electron/main.cjs",

apps/desktop/src/clientPersistence.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ function makeSecretStorage(available: boolean): DesktopSecretStorage {
4949
}
5050

5151
const clientSettings: ClientSettings = {
52+
autoOpenPlanSidebar: false,
5253
confirmThreadArchive: true,
5354
confirmThreadDelete: false,
5455
diffWordWrap: true,

apps/desktop/src/main.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import {
7575
} from "./updateMachine.ts";
7676
import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts";
7777
import { resolveDesktopAppBranding } from "./appBranding.ts";
78+
import { bindFirstRevealTrigger, type RevealSubscription } from "./windowReveal.ts";
7879

7980
syncShellEnvironment();
8081

@@ -2026,16 +2027,17 @@ function createWindow(): BrowserWindow {
20262027
emitUpdateState();
20272028
});
20282029

2029-
let initialRevealScheduled = false;
2030-
const revealInitialWindow = () => {
2031-
if (initialRevealScheduled) {
2032-
return;
2033-
}
2034-
initialRevealScheduled = true;
2035-
revealWindow(window);
2036-
};
2037-
2038-
window.once("ready-to-show", revealInitialWindow);
2030+
// On Linux/Wayland with `show: false`, Electron's `ready-to-show` only
2031+
// fires after `show()` is called, deadlocking the standard "wait for
2032+
// ready, then show" pattern. Add `did-finish-load` as a Linux-only
2033+
// fallback so the window still surfaces once the renderer has loaded
2034+
// the page. Other platforms keep the no-flash `ready-to-show` path,
2035+
// since `did-finish-load` typically fires before the first paint there.
2036+
const revealSubscribers: RevealSubscription[] = [(fire) => window.once("ready-to-show", fire)];
2037+
if (process.platform === "linux") {
2038+
revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire));
2039+
}
2040+
bindFirstRevealTrigger(revealSubscribers, () => revealWindow(window));
20392041

20402042
if (isDevelopment) {
20412043
void window.loadURL(resolveDesktopDevServerUrl());
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { EventEmitter } from "node:events";
2+
3+
import { describe, expect, it, vi } from "vitest";
4+
5+
import { bindFirstRevealTrigger } from "./windowReveal.ts";
6+
7+
describe("bindFirstRevealTrigger", () => {
8+
it("reveals when the first trigger fires", () => {
9+
const window = new EventEmitter();
10+
const webContents = new EventEmitter();
11+
const reveal = vi.fn();
12+
13+
bindFirstRevealTrigger(
14+
[
15+
(fire) => window.once("ready-to-show", fire),
16+
(fire) => webContents.once("did-finish-load", fire),
17+
],
18+
reveal,
19+
);
20+
21+
window.emit("ready-to-show");
22+
23+
expect(reveal).toHaveBeenCalledTimes(1);
24+
});
25+
26+
it("reveals when only the fallback trigger fires (Wayland deadlock case)", () => {
27+
const window = new EventEmitter();
28+
const webContents = new EventEmitter();
29+
const reveal = vi.fn();
30+
31+
bindFirstRevealTrigger(
32+
[
33+
(fire) => window.once("ready-to-show", fire),
34+
(fire) => webContents.once("did-finish-load", fire),
35+
],
36+
reveal,
37+
);
38+
39+
webContents.emit("did-finish-load");
40+
41+
expect(reveal).toHaveBeenCalledTimes(1);
42+
});
43+
44+
it("only reveals once when multiple triggers fire", () => {
45+
const window = new EventEmitter();
46+
const webContents = new EventEmitter();
47+
const reveal = vi.fn();
48+
49+
bindFirstRevealTrigger(
50+
[
51+
(fire) => window.once("ready-to-show", fire),
52+
(fire) => webContents.once("did-finish-load", fire),
53+
],
54+
reveal,
55+
);
56+
57+
webContents.emit("did-finish-load");
58+
window.emit("ready-to-show");
59+
60+
expect(reveal).toHaveBeenCalledTimes(1);
61+
});
62+
63+
it("subscribers using `once` ignore re-emitted events after reveal", () => {
64+
const window = new EventEmitter();
65+
const reveal = vi.fn();
66+
67+
bindFirstRevealTrigger([(fire) => window.once("ready-to-show", fire)], reveal);
68+
69+
window.emit("ready-to-show");
70+
window.emit("ready-to-show");
71+
72+
expect(reveal).toHaveBeenCalledTimes(1);
73+
});
74+
});

apps/desktop/src/windowReveal.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
export type RevealSubscription = (listener: () => void) => void;
2+
3+
/**
4+
* Wire a reveal callback to fire exactly once, on whichever of the provided
5+
* event subscribers fires first. Each subscriber is responsible for binding
6+
* its own event source.
7+
*
8+
* Used by the desktop main window's first-paint reveal logic. The standard
9+
* Electron pattern is to wait for `ready-to-show` before calling `show()`,
10+
* but on Linux/Wayland with `show: false`, `ready-to-show` only fires after
11+
* `show()` is called, deadlocking that pattern. Subscribing to both
12+
* `ready-to-show` and `did-finish-load` (or any other "renderer is alive"
13+
* signal) lets the window surface reliably across platforms.
14+
*/
15+
export function bindFirstRevealTrigger(
16+
subscribers: readonly RevealSubscription[],
17+
reveal: () => void,
18+
): void {
19+
let revealed = false;
20+
const fire = () => {
21+
if (revealed) return;
22+
revealed = true;
23+
reveal();
24+
};
25+
for (const subscribe of subscribers) {
26+
subscribe(fire);
27+
}
28+
}

apps/server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "t3",
3-
"version": "0.0.20",
3+
"version": "0.0.21",
44
"license": "MIT",
55
"repository": {
66
"type": "git",

apps/server/src/atomicWrite.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Effect, FileSystem, Path } from "effect";
2+
import * as Random from "effect/Random";
3+
4+
export const writeFileStringAtomically = (input: {
5+
readonly filePath: string;
6+
readonly contents: string;
7+
}) =>
8+
Effect.scoped(
9+
Effect.gen(function* () {
10+
const fs = yield* FileSystem.FileSystem;
11+
const path = yield* Path.Path;
12+
const tempFileId = yield* Random.nextUUIDv4;
13+
const targetDirectory = path.dirname(input.filePath);
14+
15+
yield* fs.makeDirectory(targetDirectory, { recursive: true });
16+
const tempDirectory = yield* fs.makeTempDirectoryScoped({
17+
directory: targetDirectory,
18+
prefix: `${path.basename(input.filePath)}.`,
19+
});
20+
const tempPath = path.join(tempDirectory, `${tempFileId}.tmp`);
21+
22+
yield* fs.writeFileString(tempPath, input.contents);
23+
yield* fs.rename(tempPath, input.filePath);
24+
}),
25+
);

apps/server/src/keybindings.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
} from "effect";
4747
import * as Semaphore from "effect/Semaphore";
4848
import { ServerConfig } from "./config.ts";
49+
import { writeFileStringAtomically } from "./atomicWrite.ts";
4950
import { fromLenientJson } from "@t3tools/shared/schemaJson";
5051

5152
type WhenToken =
@@ -677,14 +678,17 @@ const makeKeybindings = Effect.gen(function* () {
677678
});
678679

679680
const writeConfigAtomically = (rules: readonly KeybindingRule[]) => {
680-
const tempPath = `${keybindingsConfigPath}.${process.pid}.${Date.now()}.tmp`;
681-
682681
return Schema.encodeEffect(KeybindingsConfigPrettyJson)(rules).pipe(
683682
Effect.map((encoded) => `${encoded}\n`),
684-
Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })),
685-
Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)),
686-
Effect.flatMap(() => fs.rename(tempPath, keybindingsConfigPath)),
687-
Effect.ensuring(fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }))),
683+
Effect.flatMap((encoded) =>
684+
writeFileStringAtomically({
685+
filePath: keybindingsConfigPath,
686+
contents: encoded,
687+
}).pipe(
688+
Effect.provideService(FileSystem.FileSystem, fs),
689+
Effect.provideService(Path.Path, path),
690+
),
691+
),
688692
Effect.mapError(
689693
(cause) =>
690694
new KeybindingsConfigError({

0 commit comments

Comments
 (0)