mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
committed by
GitHub
parent
1377adbece
commit
df635b570a
@@ -132,6 +132,12 @@ Electron wrapper for macOS, Linux, and Windows.
|
||||
- Native file access for workspace integration
|
||||
- Same WebSocket client as mobile app
|
||||
|
||||
**Multi-window (hybrid land-on model).** `createWindow()` in `main.ts` is reusable: `⌘⇧N`/File→New Window, relaunching the app (`second-instance`), and the sidebar "Open in new window" action each open a fresh `BrowserWindow`. Every window shows the full sidebar — there is no per-window project ownership or filtering. "Land on a project" is delivered by a per-`webContents` `PendingOpenProjectStore`: each window pulls its own pending project path on mount (`paseo:get-pending-open-project`) and runs the normal open-project flow, identical to a CLI `paseo <path>` launch.
|
||||
|
||||
> **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys.
|
||||
>
|
||||
> **In-app browser panes are not yet per-window.** The active-browser id (`features/browser-webviews.ts`) and the webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) are process-global. With browser panes open in two windows, a menu Reload can target the other window's webview, and near-simultaneous webview attach across windows can register under the wrong browser id. Multi-window v1 ships windows; making the browser-webview subsystem window-scoped is a follow-up.
|
||||
|
||||
### `packages/website` — Marketing site
|
||||
|
||||
TanStack Router + Cloudflare Workers. Serves paseo.sh.
|
||||
|
||||
@@ -128,7 +128,12 @@ import {
|
||||
archiveWorkspaceOptimistically,
|
||||
archiveWorkspacesOptimistically,
|
||||
} from "@/workspace/workspace-archive";
|
||||
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
||||
import {
|
||||
isWeb as platformIsWeb,
|
||||
isNative as platformIsNative,
|
||||
getIsElectron,
|
||||
} from "@/constants/platform";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
|
||||
const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey;
|
||||
|
||||
@@ -153,14 +158,24 @@ const ThemedCopy = withUnistyles(Copy);
|
||||
const ThemedArchive = withUnistyles(Archive);
|
||||
const ThemedPencil = withUnistyles(Pencil);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
});
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] });
|
||||
const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] });
|
||||
const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] });
|
||||
const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purple[500] });
|
||||
const redColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.palette.red[500],
|
||||
});
|
||||
const amberColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.palette.amber[500],
|
||||
});
|
||||
const greenColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.palette.green[500],
|
||||
});
|
||||
const purpleColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.palette.purple[500],
|
||||
});
|
||||
const syncedLoaderColorMapping = (theme: Theme) => ({
|
||||
color:
|
||||
theme.colorScheme === "light"
|
||||
@@ -515,6 +530,7 @@ function ProjectRowTrailingActions({
|
||||
>
|
||||
<ProjectKebabMenu
|
||||
projectKey={project.projectKey}
|
||||
projectPath={project.iconWorkingDir}
|
||||
onRemoveProject={onRemoveProject}
|
||||
removeProjectStatus={removeProjectStatus}
|
||||
/>
|
||||
@@ -532,6 +548,9 @@ const markAsReadLeadingIcon = (
|
||||
);
|
||||
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const openInNewWindowLeadingIcon = (
|
||||
<ThemedExternalLink size={14} uniProps={foregroundMutedColorMapping} />
|
||||
);
|
||||
|
||||
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||
return (
|
||||
@@ -544,18 +563,35 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||
|
||||
function ProjectKebabMenu({
|
||||
projectKey,
|
||||
projectPath,
|
||||
onRemoveProject,
|
||||
removeProjectStatus,
|
||||
}: {
|
||||
projectKey: string;
|
||||
projectPath: string;
|
||||
onRemoveProject: () => void;
|
||||
removeProjectStatus: "idle" | "pending" | "success";
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const handleOpenProjectSettings = useCallback(() => {
|
||||
if (projectKey.trim().length === 0) return;
|
||||
router.navigate(buildProjectSettingsRoute(projectKey));
|
||||
}, [projectKey]);
|
||||
const canOpenProjectSettings = projectKey.trim().length > 0;
|
||||
// Desktop-only: open a second window that lands on this project via the same
|
||||
// open-project flow as a CLI launch. The project stays visible here too — no
|
||||
// ownership, no move.
|
||||
const canOpenInNewWindow = getIsElectron() && projectPath.trim().length > 0;
|
||||
const handleOpenInNewWindow = useCallback(() => {
|
||||
const trimmedPath = projectPath.trim();
|
||||
if (trimmedPath.length === 0) return;
|
||||
void getDesktopHost()
|
||||
?.window?.openNew?.({ pendingOpenProjectPath: trimmedPath })
|
||||
?.catch((error) => {
|
||||
console.warn("[sidebar] openNew failed", error);
|
||||
toast.error("Couldn't open a new window");
|
||||
});
|
||||
}, [projectPath, toast]);
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
@@ -577,6 +613,15 @@ function ProjectKebabMenu({
|
||||
Open project settings
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{canOpenInNewWindow ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-project-menu-open-new-window-${projectKey}`}
|
||||
leading={openInNewWindowLeadingIcon}
|
||||
onSelect={handleOpenInNewWindow}
|
||||
>
|
||||
Open in new window
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-project-menu-remove-${projectKey}`}
|
||||
leading={trash2LeadingIcon}
|
||||
@@ -2398,7 +2443,10 @@ function SidebarStatusModeWrapper({
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
onWorkspacePress?: () => void;
|
||||
}) {
|
||||
const hydratedWorkspaces = useStatusModeWorkspaceEntries({ serverId, projects });
|
||||
const hydratedWorkspaces = useStatusModeWorkspaceEntries({
|
||||
serverId,
|
||||
projects,
|
||||
});
|
||||
const projectNamesByKey = useProjectNamesMap(serverId);
|
||||
const showShortcutBadges = useShowShortcutBadges();
|
||||
|
||||
@@ -2459,7 +2507,10 @@ function ProjectModeList({
|
||||
[parentGestureRef],
|
||||
);
|
||||
|
||||
const projectIconByProjectKey = useProjectIconDataByProjectKey({ serverId, projects });
|
||||
const projectIconByProjectKey = useProjectIconDataByProjectKey({
|
||||
serverId,
|
||||
projects,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const timeouts = creatingWorkspaceTimeoutsRef.current;
|
||||
|
||||
@@ -98,6 +98,7 @@ export interface DesktopWindowBridge {
|
||||
}
|
||||
|
||||
export interface DesktopWindowModuleBridge {
|
||||
openNew?: (options?: { pendingOpenProjectPath?: string | null }) => Promise<void>;
|
||||
getCurrentWindow?: () => DesktopWindowBridge;
|
||||
}
|
||||
|
||||
|
||||
3
packages/desktop/.gitignore
vendored
3
packages/desktop/.gitignore
vendored
@@ -7,3 +7,6 @@ node_modules/
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Isolated dev environment (PASEO_HOME + Electron userData) created by scripts/dev.sh
|
||||
.dev/
|
||||
|
||||
@@ -49,12 +49,60 @@ $env:PASEO_ELECTRON_FLAGS = "$($ExistingElectronFlags)--remote-debugging-port=$R
|
||||
# the daemon binds to localhost and this script is never used for production.
|
||||
$env:PASEO_CORS_ORIGINS = "*"
|
||||
|
||||
# Fully isolate the dev instance from a production Paseo install so `npm run dev`
|
||||
# works while the installed app is open. Without this the dev build loses the
|
||||
# Electron single-instance lock to the installed app and quits, and ends up
|
||||
# pointed at the production daemon, whose CORS allowlist rejects the Metro origin.
|
||||
# PASEO_HOME defaults to a script-managed dev home. If you override it (to point
|
||||
# dev at real data), we DON'T touch your config.json — only the managed home gets
|
||||
# its daemon config seeded below, so we never rewrite a production config.
|
||||
$DevStateDir = "$DesktopDir\.dev"
|
||||
if (-not $env:PASEO_HOME) {
|
||||
$env:PASEO_HOME = "$DevStateDir\paseo-home"
|
||||
$PaseoHomeManaged = $true
|
||||
} else {
|
||||
$PaseoHomeManaged = $false
|
||||
}
|
||||
if (-not $env:PASEO_ELECTRON_USER_DATA_DIR) { $env:PASEO_ELECTRON_USER_DATA_DIR = "$DevStateDir\user-data" }
|
||||
New-Item -ItemType Directory -Force -Path $env:PASEO_HOME, $env:PASEO_ELECTRON_USER_DATA_DIR | Out-Null
|
||||
|
||||
$DevDaemonPort = if ($env:PASEO_DEV_DAEMON_PORT) { $env:PASEO_DEV_DAEMON_PORT } else { "6788" }
|
||||
if (-not $env:PASEO_LISTEN) { $env:PASEO_LISTEN = "127.0.0.1:$DevDaemonPort" }
|
||||
|
||||
# Seed the isolated daemon config. The desktop daemon-manager decides whether a
|
||||
# daemon is already running by reading `daemon.listen` from this config.json
|
||||
# (it does NOT honor the PASEO_LISTEN env var) and probing that address. Without
|
||||
# this it reads the default 6767, finds a production daemon there, and connects
|
||||
# the dev app to prod — whose CORS allowlist then rejects the Metro origin. Pin
|
||||
# the dev port + wildcard CORS in the file so the dev app starts its OWN daemon.
|
||||
# ONLY seed the script-managed home: never rewrite a user-supplied PASEO_HOME
|
||||
# (that could clobber a production config.json with the dev port + wildcard CORS).
|
||||
if ($PaseoHomeManaged) {
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const [path, port] = [process.argv[1], process.argv[2]];
|
||||
let cfg = {};
|
||||
try { cfg = JSON.parse(fs.readFileSync(path, "utf8")); } catch {}
|
||||
cfg.version = cfg.version || 1;
|
||||
cfg.daemon = cfg.daemon || {};
|
||||
cfg.daemon.listen = `127.0.0.1:${port}`;
|
||||
cfg.daemon.cors = cfg.daemon.cors || {};
|
||||
cfg.daemon.cors.allowedOrigins = ["*"];
|
||||
fs.writeFileSync(path, JSON.stringify(cfg, null, 2));
|
||||
' "$($env:PASEO_HOME)/config.json" $DevDaemonPort
|
||||
} else {
|
||||
Write-Host " (custom PASEO_HOME - leaving its config.json untouched)"
|
||||
}
|
||||
|
||||
Write-Host @"
|
||||
======================================================
|
||||
Paseo Desktop Dev (Windows)
|
||||
======================================================
|
||||
Metro: http://localhost:$($env:EXPO_PORT)
|
||||
CDP: http://127.0.0.1:$RemoteDebuggingPort
|
||||
Metro: http://localhost:$($env:EXPO_PORT)
|
||||
CDP: http://127.0.0.1:$RemoteDebuggingPort
|
||||
Daemon: $($env:PASEO_LISTEN) (isolated)
|
||||
PASEO_HOME: $($env:PASEO_HOME)
|
||||
userData: $($env:PASEO_ELECTRON_USER_DATA_DIR)
|
||||
======================================================
|
||||
"@
|
||||
|
||||
|
||||
@@ -22,11 +22,63 @@ export PASEO_ELECTRON_FLAGS="${PASEO_ELECTRON_FLAGS:+$PASEO_ELECTRON_FLAGS }--re
|
||||
# and the daemon still binds to localhost.
|
||||
export PASEO_CORS_ORIGINS="*"
|
||||
|
||||
# Fully isolate the dev instance from a production Paseo install so `npm run dev`
|
||||
# works while the installed app is open. Without this the dev build (a) loses the
|
||||
# Electron single-instance lock to the installed app and quits, and (b) ends up
|
||||
# pointed at the production daemon, whose CORS allowlist rejects the Metro origin.
|
||||
# PASEO_HOME defaults to a script-managed dev home. If you override it (to point
|
||||
# dev at real data), we DON'T touch your config.json — only the managed home gets
|
||||
# its daemon config seeded below, so we never rewrite a production ~/.paseo config.
|
||||
# - PASEO_ELECTRON_USER_DATA_DIR: a separate Electron profile → separate
|
||||
# single-instance lock, so dev and the installed app coexist.
|
||||
# - PASEO_LISTEN: a distinct port so the dev daemon never collides with prod's 6767.
|
||||
DEV_STATE_DIR="$DESKTOP_DIR/.dev"
|
||||
if [ -n "${PASEO_HOME:-}" ]; then
|
||||
PASEO_HOME_MANAGED=0
|
||||
else
|
||||
PASEO_HOME="$DEV_STATE_DIR/paseo-home"
|
||||
PASEO_HOME_MANAGED=1
|
||||
fi
|
||||
export PASEO_HOME
|
||||
export PASEO_ELECTRON_USER_DATA_DIR="${PASEO_ELECTRON_USER_DATA_DIR:-$DEV_STATE_DIR/user-data}"
|
||||
mkdir -p "$PASEO_HOME" "$PASEO_ELECTRON_USER_DATA_DIR"
|
||||
|
||||
DEV_DAEMON_PORT="${PASEO_DEV_DAEMON_PORT:-6788}"
|
||||
export PASEO_LISTEN="${PASEO_LISTEN:-127.0.0.1:$DEV_DAEMON_PORT}"
|
||||
|
||||
# Seed the isolated daemon config. The desktop daemon-manager decides whether a
|
||||
# daemon is already running by reading `daemon.listen` from this config.json
|
||||
# (it does NOT honor the PASEO_LISTEN env var) and probing that address. Without
|
||||
# this it reads the default 6767, finds a production daemon there, and connects
|
||||
# the dev app to prod — whose CORS allowlist then rejects the Metro origin. Pin
|
||||
# the dev port + wildcard CORS in the file so the dev app starts its OWN daemon.
|
||||
# ONLY seed the script-managed home: never rewrite a user-supplied PASEO_HOME
|
||||
# (that could clobber a production config.json with the dev port + wildcard CORS).
|
||||
if [ "$PASEO_HOME_MANAGED" = "1" ]; then
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const [path, port] = [process.argv[1], process.argv[2]];
|
||||
let cfg = {};
|
||||
try { cfg = JSON.parse(fs.readFileSync(path, "utf8")); } catch {}
|
||||
cfg.version = cfg.version || 1;
|
||||
cfg.daemon = cfg.daemon || {};
|
||||
cfg.daemon.listen = `127.0.0.1:${port}`;
|
||||
cfg.daemon.cors = cfg.daemon.cors || {};
|
||||
cfg.daemon.cors.allowedOrigins = ["*"];
|
||||
fs.writeFileSync(path, JSON.stringify(cfg, null, 2));
|
||||
' "$PASEO_HOME/config.json" "$DEV_DAEMON_PORT"
|
||||
else
|
||||
echo " (custom PASEO_HOME — leaving its config.json untouched)"
|
||||
fi
|
||||
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
echo " Paseo Desktop Dev"
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
echo " Metro: http://localhost:${EXPO_PORT}"
|
||||
echo " CDP: http://127.0.0.1:${REMOTE_DEBUGGING_PORT}"
|
||||
echo " Metro: http://localhost:${EXPO_PORT}"
|
||||
echo " CDP: http://127.0.0.1:${REMOTE_DEBUGGING_PORT}"
|
||||
echo " Daemon: ${PASEO_LISTEN} (isolated)"
|
||||
echo " PASEO_HOME: ${PASEO_HOME}"
|
||||
echo " userData: ${PASEO_ELECTRON_USER_DATA_DIR}"
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
|
||||
# Launch Metro + Electron together, kill both on exit
|
||||
|
||||
@@ -6,6 +6,10 @@ interface ShowContextMenuInput {
|
||||
hasSelection?: boolean;
|
||||
}
|
||||
|
||||
interface ApplicationMenuOptions {
|
||||
onNewWindow: () => void;
|
||||
}
|
||||
|
||||
function withBrowserWindow(
|
||||
callback: (win: BrowserWindow) => void,
|
||||
): (_item: Electron.MenuItem, baseWin: Electron.BaseWindow | undefined) => void {
|
||||
@@ -41,10 +45,12 @@ function reloadFocusedContentsOrWindow(win: BrowserWindow, options?: { ignoreCac
|
||||
win.webContents.reload();
|
||||
}
|
||||
|
||||
export function setupApplicationMenu(): void {
|
||||
function buildApplicationMenuTemplate(
|
||||
options: ApplicationMenuOptions,
|
||||
): Electron.MenuItemConstructorOptions[] {
|
||||
const isMac = process.platform === "darwin";
|
||||
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
return [
|
||||
...(isMac
|
||||
? [
|
||||
{
|
||||
@@ -63,6 +69,18 @@ export function setupApplicationMenu(): void {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: "File",
|
||||
submenu: [
|
||||
{
|
||||
label: "New Window",
|
||||
accelerator: "CmdOrCtrl+Shift+N",
|
||||
click: () => {
|
||||
options.onNewWindow();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
submenu: [
|
||||
@@ -130,8 +148,10 @@ export function setupApplicationMenu(): void {
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
export function setupApplicationMenu(options: ApplicationMenuOptions): void {
|
||||
const menu = Menu.buildFromTemplate(buildApplicationMenuTemplate(options));
|
||||
Menu.setApplicationMenu(menu);
|
||||
|
||||
ipcMain.handle("paseo:menu:showContextMenu", (event, input?: ShowContextMenuInput) => {
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
setWorkspaceActivePaseoBrowserId,
|
||||
} from "./features/browser-webviews.js";
|
||||
import { parseOpenProjectPathFromArgv } from "./open-project-routing.js";
|
||||
import { PendingOpenProjectStore } from "./pending-open-project-store.js";
|
||||
import { getDesktopSettingsStore } from "./settings/desktop-settings-electron.js";
|
||||
import { clampWindowStateToWorkAreas, createWindowStateStore } from "./settings/window-state.js";
|
||||
import {
|
||||
@@ -94,7 +95,6 @@ function preventUnsafeBrowserWebviewNavigation(
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
const OPEN_PROJECT_EVENT = "paseo:event:open-project";
|
||||
const BROWSER_SHORTCUT_EVENT = "paseo:event:browser-shortcut";
|
||||
const BROWSER_FORWARDED_KEY_EVENT = "paseo:event:browser-forwarded-key";
|
||||
|
||||
@@ -257,6 +257,12 @@ let pendingOpenProjectPath = parseOpenProjectPathFromArgv({
|
||||
isDefaultApp: process.defaultApp,
|
||||
});
|
||||
|
||||
// Each window pulls its own pending open-project path on mount, keyed by
|
||||
// webContents id, so deep-linked windows (second-instance launches, the
|
||||
// in-app "Open in new window" action) land on the right project without
|
||||
// racing a global.
|
||||
const pendingOpenProjectStore = new PendingOpenProjectStore();
|
||||
|
||||
if (PASEO_DEBUG) {
|
||||
log.info("[open-project] argv:", process.argv);
|
||||
log.info("[open-project] isDefaultApp:", process.defaultApp);
|
||||
@@ -265,10 +271,13 @@ if (PASEO_DEBUG) {
|
||||
|
||||
// The renderer pulls the pending path on mount via IPC — this avoids
|
||||
// a race where the push event arrives before React registers its listener.
|
||||
ipcMain.handle("paseo:get-pending-open-project", () => {
|
||||
log.info("[open-project] renderer requested pending path:", pendingOpenProjectPath);
|
||||
const result = pendingOpenProjectPath;
|
||||
pendingOpenProjectPath = null;
|
||||
ipcMain.handle("paseo:get-pending-open-project", (event) => {
|
||||
const webContentsId = event.sender.id;
|
||||
const result = pendingOpenProjectStore.take(webContentsId);
|
||||
log.info("[open-project] renderer requested pending path:", {
|
||||
webContentsId,
|
||||
pendingPath: result,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
@@ -326,7 +335,10 @@ ipcMain.handle("paseo:browser:clear-partition", async (_event, browserId: unknow
|
||||
});
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{ scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } },
|
||||
{
|
||||
scheme: APP_SCHEME,
|
||||
privileges: { standard: true, secure: true, supportFetchAPI: true },
|
||||
},
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -395,12 +407,24 @@ function getWorkAreasPrimaryFirst(): Electron.Rectangle[] {
|
||||
return [primary, ...others].map((display) => display.workArea);
|
||||
}
|
||||
|
||||
async function createMainWindow(): Promise<void> {
|
||||
async function createWindow(
|
||||
options: {
|
||||
pendingOpenProjectPath?: string | null;
|
||||
restoreWindowState?: boolean;
|
||||
} = {},
|
||||
): Promise<BrowserWindow> {
|
||||
const iconPath = getWindowIconPath();
|
||||
const systemTheme = resolveSystemWindowTheme();
|
||||
|
||||
const windowStateStore = createWindowStateStore({ userDataPath: app.getPath("userData") });
|
||||
const savedWindowState = await windowStateStore.load();
|
||||
// Only the first window of a session restores and persists saved geometry.
|
||||
// Additional windows (⌘N, second-instance, "Open in new window") open at the
|
||||
// default size and let the OS cascade them, so they neither stack on top of
|
||||
// the restored window nor fight over the single window-state store.
|
||||
const restoreWindowState = options.restoreWindowState ?? false;
|
||||
const windowStateStore = restoreWindowState
|
||||
? createWindowStateStore({ userDataPath: app.getPath("userData") })
|
||||
: null;
|
||||
const savedWindowState = windowStateStore ? await windowStateStore.load() : null;
|
||||
const restoredWindowState = savedWindowState
|
||||
? clampWindowStateToWorkAreas(savedWindowState, getWorkAreasPrimaryFirst())
|
||||
: null;
|
||||
@@ -424,6 +448,12 @@ async function createMainWindow(): Promise<void> {
|
||||
},
|
||||
});
|
||||
|
||||
const webContentsId = mainWindow.webContents.id;
|
||||
pendingOpenProjectStore.set(webContentsId, options.pendingOpenProjectPath);
|
||||
mainWindow.on("closed", () => {
|
||||
pendingOpenProjectStore.delete(webContentsId);
|
||||
});
|
||||
|
||||
if (devWorktreeName) {
|
||||
app.dock?.setBadge(devWorktreeName);
|
||||
}
|
||||
@@ -434,7 +464,9 @@ async function createMainWindow(): Promise<void> {
|
||||
|
||||
setupDarwinCompositorWatchdog(mainWindow);
|
||||
setupWindowResizeEvents(mainWindow);
|
||||
setupWindowStatePersistence(mainWindow, windowStateStore);
|
||||
if (windowStateStore) {
|
||||
setupWindowStatePersistence(mainWindow, windowStateStore);
|
||||
}
|
||||
setupDefaultContextMenu(mainWindow);
|
||||
setupDragDropPrevention(mainWindow);
|
||||
mainWindow.webContents.on("will-attach-webview", (event, webPreferences, params) => {
|
||||
@@ -531,31 +563,27 @@ async function createMainWindow(): Promise<void> {
|
||||
const { loadReactDevTools } = await import("./features/react-devtools.js");
|
||||
await loadReactDevTools();
|
||||
await mainWindow.loadURL(DEV_SERVER_URL);
|
||||
return;
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
await mainWindow.loadURL(`${APP_SCHEME}://app/`);
|
||||
}
|
||||
|
||||
function sendOpenProjectEvent(win: BrowserWindow, projectPath: string): void {
|
||||
const send = () => {
|
||||
log.info("[open-project] sending event to renderer:", projectPath);
|
||||
win.webContents.send(OPEN_PROJECT_EVENT, { path: projectPath });
|
||||
};
|
||||
|
||||
if (win.webContents.isLoadingMainFrame()) {
|
||||
log.info("[open-project] waiting for did-finish-load before sending event");
|
||||
win.webContents.once("did-finish-load", send);
|
||||
return;
|
||||
}
|
||||
|
||||
send();
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Resolves once bootstrap() has registered the custom protocol handler and IPC
|
||||
// handlers and created the first window. second-instance window creation waits
|
||||
// on this rather than app.whenReady(): in packaged mode createWindow loads
|
||||
// `paseo://app/`, which fails if the protocol handler isn't registered yet, and
|
||||
// a second instance can arrive mid-cold-start.
|
||||
let resolveBootstrapComplete: () => void;
|
||||
const bootstrapComplete = new Promise<void>((resolve) => {
|
||||
resolveBootstrapComplete = resolve;
|
||||
});
|
||||
|
||||
function setupSingleInstanceLock(): boolean {
|
||||
if (DISABLE_SINGLE_INSTANCE_LOCK) {
|
||||
log.info("[single-instance] disabled by PASEO_DISABLE_SINGLE_INSTANCE_LOCK");
|
||||
@@ -575,15 +603,14 @@ function setupSingleInstanceLock(): boolean {
|
||||
isDefaultApp: false,
|
||||
});
|
||||
log.info("[open-project] second-instance openProjectPath:", openProjectPath);
|
||||
const win = BrowserWindow.getAllWindows()[0];
|
||||
if (win) {
|
||||
win.show();
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.focus();
|
||||
if (openProjectPath) {
|
||||
sendOpenProjectEvent(win, openProjectPath);
|
||||
}
|
||||
}
|
||||
// Relaunching the app (CLI `paseo [path]`, double-click, etc.) opens a new
|
||||
// window rather than focusing the existing one. Wait for bootstrap (not just
|
||||
// app.whenReady) so the protocol + IPC handlers exist before the window loads.
|
||||
void bootstrapComplete
|
||||
.then(() => createWindow({ pendingOpenProjectPath: openProjectPath }))
|
||||
.catch((error) => {
|
||||
log.error("[window] failed to create window from second-instance", error);
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -689,7 +716,13 @@ async function bootstrap(): Promise<void> {
|
||||
});
|
||||
|
||||
applyAppIcon();
|
||||
setupApplicationMenu();
|
||||
setupApplicationMenu({
|
||||
onNewWindow: () => {
|
||||
void createWindow().catch((error) => {
|
||||
log.error("[window] failed to create window from menu", error);
|
||||
});
|
||||
},
|
||||
});
|
||||
ensureNotificationCenterRegistration();
|
||||
if (await runDesktopSmokeIfRequested()) {
|
||||
return;
|
||||
@@ -701,11 +734,29 @@ async function bootstrap(): Promise<void> {
|
||||
registerOpenerHandlers();
|
||||
registerEditorTargetHandlers();
|
||||
|
||||
await createMainWindow();
|
||||
// In-app "Open in new window": opens a window that lands on the given project
|
||||
// via the same open-project flow as a CLI launch (no move, no ownership).
|
||||
ipcMain.handle("paseo:window:openNew", async (_event, options?: unknown) => {
|
||||
const pendingPath =
|
||||
options && typeof options === "object" && "pendingOpenProjectPath" in options
|
||||
? (options as { pendingOpenProjectPath?: unknown }).pendingOpenProjectPath
|
||||
: null;
|
||||
await createWindow({
|
||||
pendingOpenProjectPath: typeof pendingPath === "string" ? pendingPath : null,
|
||||
});
|
||||
});
|
||||
|
||||
// The first window of the session restores and persists saved geometry.
|
||||
await createWindow({ pendingOpenProjectPath, restoreWindowState: true });
|
||||
pendingOpenProjectPath = null;
|
||||
|
||||
// Protocol + IPC handlers and the first window now exist: release any
|
||||
// second-instance launches that arrived during cold start.
|
||||
resolveBootstrapComplete();
|
||||
|
||||
app.on("activate", async () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
await createMainWindow();
|
||||
await createWindow({ restoreWindowState: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
32
packages/desktop/src/pending-open-project-store.test.ts
Normal file
32
packages/desktop/src/pending-open-project-store.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { PendingOpenProjectStore } from "./pending-open-project-store";
|
||||
|
||||
describe("PendingOpenProjectStore", () => {
|
||||
it("stores pending paths per window and consumes them independently", () => {
|
||||
const store = new PendingOpenProjectStore();
|
||||
|
||||
store.set(101, "/tmp/project-a");
|
||||
store.set(202, "/tmp/project-b");
|
||||
|
||||
expect(store.take(101)).toBe("/tmp/project-a");
|
||||
expect(store.take(202)).toBe("/tmp/project-b");
|
||||
});
|
||||
|
||||
it("clears a pending path after it is consumed", () => {
|
||||
const store = new PendingOpenProjectStore();
|
||||
|
||||
store.set(101, "/tmp/project-a");
|
||||
|
||||
expect(store.take(101)).toBe("/tmp/project-a");
|
||||
expect(store.take(101)).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores empty and whitespace-only paths", () => {
|
||||
const store = new PendingOpenProjectStore();
|
||||
|
||||
store.set(101, " ");
|
||||
|
||||
expect(store.take(101)).toBeNull();
|
||||
});
|
||||
});
|
||||
32
packages/desktop/src/pending-open-project-store.ts
Normal file
32
packages/desktop/src/pending-open-project-store.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export class PendingOpenProjectStore {
|
||||
private readonly pendingPathByWebContentsId = new Map<number, string>();
|
||||
|
||||
set(webContentsId: number, projectPath: string | null | undefined): void {
|
||||
const normalizedPath = this.normalizeProjectPath(projectPath);
|
||||
if (!normalizedPath) {
|
||||
this.pendingPathByWebContentsId.delete(webContentsId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingPathByWebContentsId.set(webContentsId, normalizedPath);
|
||||
}
|
||||
|
||||
take(webContentsId: number): string | null {
|
||||
const projectPath = this.pendingPathByWebContentsId.get(webContentsId) ?? null;
|
||||
this.pendingPathByWebContentsId.delete(webContentsId);
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
delete(webContentsId: number): void {
|
||||
this.pendingPathByWebContentsId.delete(webContentsId);
|
||||
}
|
||||
|
||||
private normalizeProjectPath(projectPath: string | null | undefined): string | null {
|
||||
if (typeof projectPath !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmedPath = projectPath.trim();
|
||||
return trimmedPath ? trimmedPath : null;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
|
||||
},
|
||||
},
|
||||
window: {
|
||||
openNew: (options?: { pendingOpenProjectPath?: string | null }) =>
|
||||
ipcRenderer.invoke("paseo:window:openNew", options),
|
||||
getCurrentWindow: () => ({
|
||||
toggleMaximize: () => ipcRenderer.invoke("paseo:window:toggleMaximize"),
|
||||
isFullscreen: () => ipcRenderer.invoke("paseo:window:isFullscreen"),
|
||||
|
||||
@@ -240,17 +240,19 @@ export function registerWindowManager(): void {
|
||||
}
|
||||
|
||||
export function setupWindowResizeEvents(win: BrowserWindow): void {
|
||||
win.on("resize", () => {
|
||||
// A resize/fullscreen event can fire while the window is tearing down; sending
|
||||
// to a destroyed webContents throws. Guard so multi-window close doesn't surface
|
||||
// "Object has been destroyed" exceptions.
|
||||
const notifyResized = () => {
|
||||
if (win.isDestroyed() || win.webContents.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
win.webContents.send("paseo:window:resized", {});
|
||||
});
|
||||
};
|
||||
|
||||
win.on("enter-full-screen", () => {
|
||||
win.webContents.send("paseo:window:resized", {});
|
||||
});
|
||||
|
||||
win.on("leave-full-screen", () => {
|
||||
win.webContents.send("paseo:window:resized", {});
|
||||
});
|
||||
win.on("resize", notifyResized);
|
||||
win.on("enter-full-screen", notifyResized);
|
||||
win.on("leave-full-screen", notifyResized);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user