Add Windows Electron desktop support (#1093)

* fix: make upstream sync actions target the selected remote

Ensure fetch and pull actually honor upstream selection so fork maintenance works from the Git sidebar, and surface upstream branch status alongside the primary origin-tracking indicators.

* feat: add Windows Electron desktop foundation

* fix(electron): stabilize Windows desktop packaging

* fix(electron): stabilize Windows desktop chrome

Use native Windows titlebar behavior with an Alt-accessible hidden menu, and harden Windows dev command launching so the desktop app follows platform conventions.

* fix(electron): stabilize Windows dev startup

* fix(electron): clarify desktop artifact names

* fix(electron): harden Windows desktop release and launch

* fix(electron): address Windows release review

* fix(electron): point updater and release links to org repo

* Fix Windows settings persistence fallback

* Fix Windows Electron dev startup

* Add Windows Electron window controls

* Fix Windows Electron install and opencode launch

* fix: resolve git status for repositories without upstream

Fixes repository detection stuck on Checking repository
Handles git status when no upstream is configured
Adds regression coverage for git status loading

* Add Windows app menu button

* fix: preserve file editor line endings

* ci: add desktop release smoke workflow

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Dave Otero
2026-05-26 18:13:59 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent cc7969ac00
commit becd240168
59 changed files with 2260 additions and 246 deletions
+224
View File
@@ -0,0 +1,224 @@
name: Desktop Release Build Smoke
on:
workflow_dispatch:
inputs:
repository:
description: Repository to checkout, for example openchamber/openchamber or daveotero/openchamber
required: false
default: openchamber/openchamber
type: string
ref:
description: Git ref to build (branch, tag, or sha)
required: true
default: feat/windows-desktop-app
type: string
build_macos:
description: Build signed/notarized macOS Electron artifacts
required: false
default: true
type: boolean
build_windows:
description: Build Windows Electron installer artifacts
required: false
default: true
type: boolean
retention_days:
description: Artifact retention days
required: false
default: "7"
type: choice
options:
- "1"
- "3"
- "7"
- "14"
permissions:
contents: read
jobs:
build-macos-electron:
if: ${{ inputs.build_macos }}
name: Build macOS Electron (${{ matrix.arch }})
runs-on: macos-26
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-apple-darwin
arch: arm64
platform: darwin-aarch64
- target: x86_64-apple-darwin
arch: x64
platform: darwin-x86_64
steps:
- name: Checkout selected ref
uses: actions/checkout@v4
with:
repository: ${{ inputs.repository || github.repository }}
ref: ${{ inputs.ref || github.ref }}
- name: Setup bun
uses: oven-sh/setup-bun@v2
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Install Apple Certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
KEYCHAIN_PATH=$RUNNER_TEMP/electron-signing.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "$APPLE_CERTIFICATE" | base64 --decode > $RUNNER_TEMP/certificate.p12
security import $RUNNER_TEMP/certificate.p12 \
-P "$APPLE_CERTIFICATE_PASSWORD" \
-A -t cert -f pkcs12 \
-k "$KEYCHAIN_PATH"
security list-keychain -d user -s "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
- name: Build Electron app
working-directory: packages/electron
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
ELECTRON_BUILDER_ARCH: ${{ matrix.arch }}
run: |
bun run build:web-assets
bun run bundle:main
# npmRebuild=false in package.json, so electron-builder won't
# recompile native deps on its own. Rebuild against the target
# Electron ABI before packaging, matching the release workflow.
bun run rebuild:native
bunx electron-builder --mac --${{ matrix.arch }} --publish=never
- name: Verify signature + entitlements + notarization
run: |
set -euo pipefail
APP_DIR="packages/electron/dist/mac"
[ -d "packages/electron/dist/mac-arm64" ] && APP_DIR="packages/electron/dist/mac-arm64"
APP_PATH=$(find "$APP_DIR" -maxdepth 2 -name "*.app" -print -quit)
if [ -z "$APP_PATH" ]; then
echo "Error: .app not found under packages/electron/dist/mac*"
ls -la packages/electron/dist/
exit 1
fi
echo "Verifying $APP_PATH"
codesign -vv --deep --strict "$APP_PATH"
CS_INFO=$(codesign -dv --verbose=4 "$APP_PATH" 2>&1)
echo "$CS_INFO"
if ! echo "$CS_INFO" | grep -q "flags=.*runtime"; then
echo "Error: hardened runtime flag missing"
exit 1
fi
xcrun stapler validate "$APP_PATH"
ENTITLEMENTS=$(codesign -d --entitlements :- "$APP_PATH" 2>&1 || true)
if echo "$ENTITLEMENTS" | grep -q "com.apple.security.app-sandbox"; then
echo "Error: app sandbox entitlement is present"
exit 1
fi
for key in \
com.apple.security.cs.allow-jit \
com.apple.security.cs.allow-unsigned-executable-memory \
com.apple.security.cs.disable-library-validation
do
if ! echo "$ENTITLEMENTS" | grep -q "<key>$key</key>"; then
echo "Error: required entitlement missing: $key"
exit 1
fi
done
- name: Upload macOS installable artifacts
uses: actions/upload-artifact@v4
with:
name: desktop-release-smoke-macos-${{ matrix.arch }}
path: |
packages/electron/dist/*.dmg
packages/electron/dist/*.zip
packages/electron/dist/*.blockmap
packages/electron/dist/latest-mac.yml
if-no-files-found: error
retention-days: ${{ fromJSON(inputs.retention_days) }}
build-windows-electron:
if: ${{ inputs.build_windows }}
name: Build Windows Electron (x64)
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
include:
- arch: x64
target: x86_64-pc-windows-msvc
platform: win32-x64
steps:
- name: Checkout selected ref
uses: actions/checkout@v4
with:
repository: ${{ inputs.repository || github.repository }}
ref: ${{ inputs.ref || github.ref }}
- name: Setup bun
uses: oven-sh/setup-bun@v2
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build web assets
working-directory: packages/electron
run: bun run build:web-assets
- name: Bundle main process
working-directory: packages/electron
run: bun run bundle:main
- name: Rebuild native modules
working-directory: packages/electron
shell: bash
# npmRebuild=false in package.json, so electron-builder won't
# recompile native deps on its own. Rebuild against the target
# Electron ABI before packaging, matching the release workflow.
run: node ./scripts/rebuild-native.mjs
- name: Build Windows app
working-directory: packages/electron
shell: bash
run: node ./scripts/package.mjs --win --${{ matrix.arch }} --publish=never
- name: Upload Windows installable artifacts
uses: actions/upload-artifact@v4
with:
name: desktop-release-smoke-windows-${{ matrix.arch }}
path: |
packages/electron/dist/*.exe
packages/electron/dist/*.blockmap
packages/electron/dist/latest.yml
if-no-files-found: error
retention-days: ${{ fromJSON(inputs.retention_days) }}
+75 -5
View File
@@ -185,7 +185,7 @@ jobs:
# target Electron ABI before packaging, otherwise better-sqlite3/ # target Electron ABI before packaging, otherwise better-sqlite3/
# node-pty/bun-pty crash on require inside the packaged app. # node-pty/bun-pty crash on require inside the packaged app.
bun run rebuild:native bun run rebuild:native
./node_modules/.bin/electron-builder --mac --${{ matrix.arch }} --publish=never bunx electron-builder --mac --${{ matrix.arch }} --publish=never
- name: Verify signature + entitlements + notarization - name: Verify signature + entitlements + notarization
run: | run: |
@@ -275,6 +275,68 @@ jobs:
path: packages/electron/dist/latest-mac.yml path: packages/electron/dist/latest-mac.yml
retention-days: 1 retention-days: 1
build-desktop-electron-windows:
needs: create-release
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
include:
- arch: x64
target: x86_64-pc-windows-msvc
platform: win32-x64
steps:
- uses: actions/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v2
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build web assets
working-directory: packages/electron
run: bun run build:web-assets
- name: Bundle main process
working-directory: packages/electron
run: bun run bundle:main
- name: Rebuild native modules
working-directory: packages/electron
shell: bash
# npmRebuild=false in package.json, so electron-builder won't
# recompile native deps on its own — we must rebuild against the
# target Electron ABI before packaging.
run: node ./scripts/rebuild-native.mjs
- name: Build Windows app
working-directory: packages/electron
shell: bash
run: node ./scripts/package.mjs --win --${{ matrix.arch }} --publish=never
- name: Upload installer to release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ needs.create-release.outputs.version }}
files: |
packages/electron/dist/*.exe
packages/electron/dist/*.blockmap
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload update manifest as artifact
uses: actions/upload-artifact@v4
with:
name: latest-yml-${{ matrix.target }}
path: packages/electron/dist/latest.yml
retention-days: 1
repackage-electron-as-tauri-update: repackage-electron-as-tauri-update:
needs: [create-release, build-desktop-electron-macos] needs: [create-release, build-desktop-electron-macos]
runs-on: macos-26 runs-on: macos-26
@@ -449,7 +511,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
combine-electron-manifests: combine-electron-manifests:
needs: [create-release, build-desktop-electron-macos] needs: [create-release, build-desktop-electron-macos, build-desktop-electron-windows]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -465,6 +527,12 @@ jobs:
pattern: latest-yml-*-apple-darwin pattern: latest-yml-*-apple-darwin
path: artifacts path: artifacts
- name: Download Windows latest.yml
uses: actions/download-artifact@v4
with:
pattern: latest-yml-*-pc-windows-*
path: artifacts
- name: Finalize combined latest-mac.yml - name: Finalize combined latest-mac.yml
env: env:
LATEST_YML_DIR: ${{ github.workspace }}/artifacts LATEST_YML_DIR: ${{ github.workspace }}/artifacts
@@ -472,16 +540,18 @@ jobs:
OPENCHAMBER_VERSION: ${{ needs.create-release.outputs.version }} OPENCHAMBER_VERSION: ${{ needs.create-release.outputs.version }}
run: node packages/electron/scripts/finalize-latest-yml.mjs run: node packages/electron/scripts/finalize-latest-yml.mjs
- name: Upload combined latest-mac.yml to release - name: Upload combined manifests to release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: v${{ needs.create-release.outputs.version }} tag_name: v${{ needs.create-release.outputs.version }}
files: ${{ runner.temp }}/latest-mac.yml files: |
${{ runner.temp }}/latest-mac.yml
${{ runner.temp }}/latest.yml
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
finalize-release: finalize-release:
needs: [create-release, build-desktop-electron-macos, repackage-electron-as-tauri-update, publish-npm, combine-manifests, combine-electron-manifests] needs: [create-release, build-desktop-electron-macos, build-desktop-electron-windows, repackage-electron-as-tauri-update, publish-npm, combine-manifests, combine-electron-manifests]
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
+7 -5
View File
@@ -84,6 +84,7 @@
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0", "globals": "^16.3.0",
"node-addon-api": "7.1.1",
"nodemon": "^3.1.7", "nodemon": "^3.1.7",
"patch-package": "^8.0.0", "patch-package": "^8.0.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
@@ -97,7 +98,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@openchamber/desktop", "name": "@openchamber/desktop",
"version": "1.11.4", "version": "1.11.6",
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"@types/node": "^24.3.1", "@types/node": "^24.3.1",
@@ -106,7 +107,7 @@
}, },
"packages/electron": { "packages/electron": {
"name": "@openchamber/electron", "name": "@openchamber/electron",
"version": "1.11.4", "version": "1.11.6",
"dependencies": { "dependencies": {
"@openchamber/web": "workspace:*", "@openchamber/web": "workspace:*",
"electron-context-menu": "^4.1.2", "electron-context-menu": "^4.1.2",
@@ -121,7 +122,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@openchamber/ui", "name": "@openchamber/ui",
"version": "1.11.4", "version": "1.11.6",
"dependencies": { "dependencies": {
"@base-ui/react": "^1.4.0", "@base-ui/react": "^1.4.0",
"@codemirror/autocomplete": "^6.20.0", "@codemirror/autocomplete": "^6.20.0",
@@ -157,6 +158,7 @@
"@simplewebauthn/browser": "13.3.0", "@simplewebauthn/browser": "13.3.0",
"@tanstack/react-virtual": "^3.13.18", "@tanstack/react-virtual": "^3.13.18",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@xenova/transformers": "^2.17.2",
"beautiful-mermaid": "^1.1.3", "beautiful-mermaid": "^1.1.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -220,7 +222,7 @@
}, },
"packages/vscode": { "packages/vscode": {
"name": "openchamber", "name": "openchamber",
"version": "1.11.4", "version": "1.11.6",
"dependencies": { "dependencies": {
"@openchamber/ui": "workspace:*", "@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "^1.15.10", "@opencode-ai/sdk": "^1.15.10",
@@ -243,7 +245,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@openchamber/web", "name": "@openchamber/web",
"version": "1.11.4", "version": "1.11.6",
"bin": { "bin": {
"openchamber": "./bin/cli.js", "openchamber": "./bin/cli.js",
}, },
+1
View File
@@ -152,6 +152,7 @@
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0", "globals": "^16.3.0",
"node-addon-api": "7.1.1",
"nodemon": "^3.1.7", "nodemon": "^3.1.7",
"patch-package": "^8.0.0", "patch-package": "^8.0.0",
"@remixicon/react": "^4.7.0", "@remixicon/react": "^4.7.0",
+555 -60
View File
@@ -1,4 +1,4 @@
import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, Notification, powerMonitor, session, shell, webContents } from 'electron'; import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, Notification, powerMonitor, screen, session, shell, webContents } from 'electron';
import contextMenu from 'electron-context-menu'; import contextMenu from 'electron-context-menu';
import log from 'electron-log/main.js'; import log from 'electron-log/main.js';
import dgram from 'node:dgram'; import dgram from 'node:dgram';
@@ -19,7 +19,9 @@ const __dirname = path.dirname(__filename);
const isDev = process.env.OPENCHAMBER_ELECTRON_DEV === '1' || !app.isPackaged; const isDev = process.env.OPENCHAMBER_ELECTRON_DEV === '1' || !app.isPackaged;
const DEEP_LINK_PROTOCOL = 'openchamber'; const DEEP_LINK_PROTOCOL = 'openchamber';
const APP_USER_MODEL_ID = 'dev.openchamber.desktop'; const PACKAGED_APP_USER_MODEL_ID = 'dev.openchamber.desktop';
const DEV_APP_USER_MODEL_ID = 'dev.openchamber.desktop.dev';
const APP_USER_MODEL_ID = app.isPackaged ? PACKAGED_APP_USER_MODEL_ID : DEV_APP_USER_MODEL_ID;
const BACKGROUND_START_ARG = '--background'; const BACKGROUND_START_ARG = '--background';
const readLoginItemSettings = () => { const readLoginItemSettings = () => {
@@ -39,17 +41,17 @@ const shouldStartInBackground = (loginItemSettings = readLoginItemSettings()) =>
); );
}; };
if (!app.requestSingleInstanceLock()) {
app.exit(0);
process.exit(0);
}
// Set the product name early so electron-log derives its log directory as // Set the product name early so electron-log derives its log directory as
// ~/Library/Logs/OpenChamber/ (not ~/Library/Logs/@openchamber/electron/). // ~/Library/Logs/OpenChamber/ (not ~/Library/Logs/@openchamber/electron/).
app.setName('OpenChamber'); app.setName('OpenChamber');
app.setAppUserModelId(APP_USER_MODEL_ID); app.setAppUserModelId(APP_USER_MODEL_ID);
app.commandLine.appendSwitch('proxy-bypass-list', '<-loopback>'); app.commandLine.appendSwitch('proxy-bypass-list', '<-loopback>');
if (!app.requestSingleInstanceLock()) {
app.exit(0);
process.exit(0);
}
try { try {
process.chdir(os.homedir()); process.chdir(os.homedir());
} catch { } catch {
@@ -447,6 +449,35 @@ const readWindowState = () => {
return stateValue && typeof stateValue === 'object' ? stateValue : null; return stateValue && typeof stateValue === 'object' ? stateValue : null;
}; };
const clampWindowBoundsToVisibleWorkArea = (bounds) => {
const width = Math.max(MIN_RESTORE_WINDOW_WIDTH, Math.round(Number(bounds?.width) || 0));
const height = Math.max(MIN_RESTORE_WINDOW_HEIGHT, Math.round(Number(bounds?.height) || 0));
const x = Math.round(Number(bounds?.x));
const y = Math.round(Number(bounds?.y));
if (!Number.isFinite(x) || !Number.isFinite(y)) {
return { width, height };
}
try {
const display = screen.getDisplayMatching({ x, y, width, height }) || screen.getPrimaryDisplay();
const workArea = display.workArea;
const clampedWidth = Math.min(width, Math.max(MIN_WINDOW_WIDTH, workArea.width));
const clampedHeight = Math.min(height, Math.max(MIN_WINDOW_HEIGHT, workArea.height));
const maxX = workArea.x + workArea.width - clampedWidth;
const maxY = workArea.y + workArea.height - clampedHeight;
return {
x: clampedWidth >= workArea.width ? workArea.x : Math.min(Math.max(x, workArea.x), maxX),
y: clampedHeight >= workArea.height ? workArea.y : Math.min(Math.max(y, workArea.y), maxY),
width: clampedWidth,
height: clampedHeight,
};
} catch {
return { x, y, width, height };
}
};
const writeWindowState = async (browserWindow) => { const writeWindowState = async (browserWindow) => {
if (!browserWindow || browserWindow.isDestroyed()) return; if (!browserWindow || browserWindow.isDestroyed()) return;
if (!state.mainWindow || browserWindow.id !== state.mainWindow.id) return; if (!state.mainWindow || browserWindow.id !== state.mainWindow.id) return;
@@ -718,12 +749,54 @@ const probeShellEnv = (shell, mode) => {
return Object.keys(env).length > 0 ? env : null; return Object.keys(env).length > 0 ? env : null;
}; };
const queryWindowsRegistryValue = (key, name) => {
const result = spawnSync('reg.exe', ['query', key, '/v', name], {
encoding: 'utf8',
windowsHide: true,
});
if (result.error || result.status !== 0) return '';
const line = String(result.stdout || '')
.split(/\r?\n/)
.map((entry) => entry.trim())
.find((entry) => entry.toLowerCase().startsWith(name.toLowerCase()));
if (!line) return '';
const match = line.match(/^\S+\s+REG_\S+\s+(.+)$/);
return match?.[1]?.trim() || '';
};
const expandWindowsEnvRefs = (value) => String(value || '').replace(/%([^%]+)%/g, (_match, key) => process.env[key] || '');
const loadWindowsEnv = () => {
const machinePath = queryWindowsRegistryValue('HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', 'Path');
const userPath = queryWindowsRegistryValue('HKCU\\Environment', 'Path');
const homeDir = os.homedir();
const localAppData = process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local');
const appData = process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming');
const commonPaths = [
path.join(homeDir, '.opencode', 'bin'),
path.join(homeDir, '.bun', 'bin'),
path.join(homeDir, '.local', 'bin'),
path.join(localAppData, 'Programs', 'Microsoft VS Code', 'bin'),
path.join(localAppData, 'Programs', 'Cursor', 'resources', 'app', 'bin'),
path.join(appData, 'npm'),
];
return {
PATH: [machinePath, userPath, process.env.PATH, ...commonPaths]
.map(expandWindowsEnvRefs)
.filter(Boolean)
.join(path.delimiter),
};
};
// Finder-launched apps on macOS inherit a minimal PATH (no /opt/homebrew, mise, asdf, etc.). // Finder-launched apps on macOS inherit a minimal PATH (no /opt/homebrew, mise, asdf, etc.).
// Probe the user's login shell once so the sidecar sees the same PATH / tool env as `$SHELL -il`. // Probe the user's login shell once so the sidecar sees the same PATH / tool env as `$SHELL -il`.
const loadShellEnv = () => { const loadShellEnv = () => {
if (shellEnvProbed) return cachedShellEnv; if (shellEnvProbed) return cachedShellEnv;
shellEnvProbed = true; shellEnvProbed = true;
if (process.platform === 'win32') return null; if (process.platform === 'win32') {
cachedShellEnv = loadWindowsEnv();
return cachedShellEnv;
}
const shell = process.env.SHELL || '/bin/sh'; const shell = process.env.SHELL || '/bin/sh';
if (isNushell(shell)) return null; if (isNushell(shell)) return null;
cachedShellEnv = probeShellEnv(shell, '-il') || probeShellEnv(shell, '-l'); cachedShellEnv = probeShellEnv(shell, '-il') || probeShellEnv(shell, '-l');
@@ -742,7 +815,8 @@ const inheritUserShellEnv = () => {
const homeDir = os.homedir(); const homeDir = os.homedir();
const currentPath = process.env.PATH || ''; const currentPath = process.env.PATH || '';
const currentPathLooksUserConfigured = pathLooksUserConfigured(currentPath, homeDir, ':'); const delimiter = process.platform === 'win32' ? ';' : ':';
const currentPathLooksUserConfigured = pathLooksUserConfigured(currentPath, homeDir, delimiter);
for (const [key, value] of Object.entries(shellEnv)) { for (const [key, value] of Object.entries(shellEnv)) {
if (key === 'PATH') continue; if (key === 'PATH') continue;
@@ -752,8 +826,8 @@ const inheritUserShellEnv = () => {
} }
const shellPath = typeof shellEnv.PATH === 'string' ? shellEnv.PATH : ''; const shellPath = typeof shellEnv.PATH === 'string' ? shellEnv.PATH : '';
if (!currentPathLooksUserConfigured && shellPath) { if ((process.platform === 'win32' || !currentPathLooksUserConfigured) && shellPath) {
process.env.PATH = mergePathValues(shellPath, currentPath, ':'); process.env.PATH = mergePathValues(shellPath, currentPath, delimiter);
} }
}; };
@@ -1021,6 +1095,15 @@ const emitToAllWindows = (event, detail) => {
} }
}; };
const setTaskbarProgress = (value) => {
if (process.platform !== 'win32') return;
for (const browserWindow of BrowserWindow.getAllWindows()) {
if (!browserWindow.isDestroyed()) {
browserWindow.setProgressBar(value);
}
}
};
const pendingDeepLinks = []; const pendingDeepLinks = [];
const parseDeepLink = (raw) => { const parseDeepLink = (raw) => {
@@ -1176,24 +1259,53 @@ const readThemeSource = () => {
return 'system'; return 'system';
}; };
const getWindowIconPath = () => {
if (process.platform !== 'win32' && process.platform !== 'linux') {
return undefined;
}
const iconPath = isDev
? path.join(__dirname, 'resources', 'icons', 'icon.ico')
: path.join(process.resourcesPath, 'icons', 'icon.ico');
return fs.existsSync(iconPath) ? iconPath : undefined;
};
const canUseTitleBarOverlay = (browserWindow) => (
process.platform === 'win32' &&
Boolean(browserWindow?.__ocTitleBarOverlayEnabled) &&
typeof browserWindow.setTitleBarOverlay === 'function' &&
!browserWindow.isDestroyed()
);
const createBrowserWindow = ({ label, restoreGeometry, url }) => { const createBrowserWindow = ({ label, restoreGeometry, url }) => {
const saved = restoreGeometry ? readWindowState() : null; const saved = restoreGeometry ? readWindowState() : null;
const useSaved = saved && typeof saved.width === 'number' && typeof saved.height === 'number'; const useSaved = saved && typeof saved.width === 'number' && typeof saved.height === 'number';
const restoredBounds = useSaved ? clampWindowBoundsToVisibleWorkArea(saved) : null;
const desktopLocalOrigin = state.localOrigin || ''; const desktopLocalOrigin = state.localOrigin || '';
const desktopHome = os.homedir() || ''; const desktopHome = os.homedir() || '';
const desktopMacosMajor = String(macosMajorVersion()); const desktopMacosMajor = String(macosMajorVersion());
const usesCustomTitleBar = process.platform === 'darwin' || process.platform === 'win32';
const titleBarOverlayEnabled = false;
const autoHidesNativeMenuBar = process.platform !== 'darwin';
const windowIconPath = getWindowIconPath();
const options = { const options = {
title: 'OpenChamber', title: 'OpenChamber',
width: useSaved ? Math.max(saved.width, MIN_RESTORE_WINDOW_WIDTH) : 1280, ...(Number.isFinite(restoredBounds?.x) && Number.isFinite(restoredBounds?.y)
height: useSaved ? Math.max(saved.height, MIN_RESTORE_WINDOW_HEIGHT) : 800, ? { x: restoredBounds.x, y: restoredBounds.y }
: {}),
width: restoredBounds?.width ?? 1280,
height: restoredBounds?.height ?? 800,
minWidth: MIN_WINDOW_WIDTH, minWidth: MIN_WINDOW_WIDTH,
minHeight: MIN_WINDOW_HEIGHT, minHeight: MIN_WINDOW_HEIGHT,
icon: windowIconPath,
show: false, show: false,
backgroundColor: '#151313', backgroundColor: '#151313',
frame: process.platform === 'win32' ? false : undefined,
autoHideMenuBar: autoHidesNativeMenuBar,
// Tauri used an overlay title bar with explicit traffic-light placement. // Tauri used an overlay title bar with explicit traffic-light placement.
// Electron's hiddenInset adds its own extra inset, which leaves the controls // Electron's hiddenInset adds its own extra inset, which leaves the controls
// visibly lower than the app header. Use a plain hidden title bar instead. // visibly lower than the app header. Use a plain hidden title bar instead.
titleBarStyle: process.platform === 'darwin' ? 'hidden' : 'default', titleBarStyle: usesCustomTitleBar ? 'hidden' : 'default',
titleBarOverlay: titleBarOverlayEnabled,
trafficLightPosition: process.platform === 'darwin' ? { x: 16, y: 17 } : undefined, trafficLightPosition: process.platform === 'darwin' ? { x: 16, y: 17 } : undefined,
webPreferences: { webPreferences: {
additionalArguments: [ additionalArguments: [
@@ -1217,10 +1329,7 @@ const createBrowserWindow = ({ label, restoreGeometry, url }) => {
const browserWindow = new BrowserWindow(options); const browserWindow = new BrowserWindow(options);
browserWindow.__ocLabel = label || nextWindowLabel(); browserWindow.__ocLabel = label || nextWindowLabel();
browserWindow.__ocTitleBarOverlayEnabled = titleBarOverlayEnabled;
if (useSaved && Number.isFinite(saved.x) && Number.isFinite(saved.y)) {
browserWindow.setPosition(saved.x, saved.y);
}
if (useSaved && saved.maximized) { if (useSaved && saved.maximized) {
browserWindow.maximize(); browserWindow.maximize();
@@ -1260,6 +1369,14 @@ const createBrowserWindow = ({ label, restoreGeometry, url }) => {
emitToWindow(browserWindow, 'openchamber:window-resized'); emitToWindow(browserWindow, 'openchamber:window-resized');
debounceWindowStatePersist(browserWindow, false); debounceWindowStatePersist(browserWindow, false);
}); });
browserWindow.on('maximize', () => {
emitToWindow(browserWindow, 'openchamber:window-maximized-changed', { maximized: true });
debounceWindowStatePersist(browserWindow, false);
});
browserWindow.on('unmaximize', () => {
emitToWindow(browserWindow, 'openchamber:window-maximized-changed', { maximized: false });
debounceWindowStatePersist(browserWindow, false);
});
browserWindow.on('move', () => { browserWindow.on('move', () => {
debounceWindowStatePersist(browserWindow, false); debounceWindowStatePersist(browserWindow, false);
}); });
@@ -1458,6 +1575,7 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj
height: MINI_CHAT_WINDOW_HEIGHT, height: MINI_CHAT_WINDOW_HEIGHT,
minWidth: MINI_CHAT_MIN_WINDOW_WIDTH, minWidth: MINI_CHAT_MIN_WINDOW_WIDTH,
minHeight: MINI_CHAT_MIN_WINDOW_HEIGHT, minHeight: MINI_CHAT_MIN_WINDOW_HEIGHT,
icon: getWindowIconPath(),
show: false, show: false,
backgroundColor: '#151313', backgroundColor: '#151313',
titleBarStyle: process.platform === 'darwin' ? 'hidden' : 'default', titleBarStyle: process.platform === 'darwin' ? 'hidden' : 'default',
@@ -1557,12 +1675,16 @@ const setMiniChatPinned = (browserWindow, pinned) => {
}; };
const resolveInitialUrl = async () => { const resolveInitialUrl = async () => {
const localUrl = isDev && await waitForHealth('http://127.0.0.1:3901', 5_000, 100) const hmrApiPort = process.env.OPENCHAMBER_HMR_API_PORT || '3901';
? 'http://127.0.0.1:3901' const hmrUiPort = process.env.OPENCHAMBER_HMR_UI_PORT || '5173';
const hmrApiUrl = `http://127.0.0.1:${hmrApiPort}`;
const hmrUiUrl = `http://127.0.0.1:${hmrUiPort}`;
const localUrl = isDev && await waitForHealth(hmrApiUrl, 5_000, 100)
? hmrApiUrl
: await spawnLocalServer(); : await spawnLocalServer();
const localUiUrl = isDev && await waitForHealth('http://127.0.0.1:5173', 8_000, 100) const localUiUrl = isDev && await waitForHealth(hmrUiUrl, 8_000, 100)
? 'http://127.0.0.1:5173' ? hmrUiUrl
: localUrl; : localUrl;
state.sidecarUrl = localUrl; state.sidecarUrl = localUrl;
@@ -1638,6 +1760,9 @@ const setupAutoUpdater = () => {
}); });
autoUpdater.on('download-progress', (progress) => { autoUpdater.on('download-progress', (progress) => {
const total = Number(progress.total || 0);
const transferred = Number(progress.transferred || 0);
setTaskbarProgress(total > 0 ? Math.max(0, Math.min(1, transferred / total)) : 0.01);
emitToAllWindows('openchamber:update-progress', mapUpdaterProgressEvent({ emitToAllWindows('openchamber:update-progress', mapUpdaterProgressEvent({
event: 'Progress', event: 'Progress',
data: { data: {
@@ -1650,12 +1775,14 @@ const setupAutoUpdater = () => {
autoUpdater.on('update-downloaded', (info) => { autoUpdater.on('update-downloaded', (info) => {
log.info(`[electron] update-downloaded version=${info?.version || 'unknown'}`); log.info(`[electron] update-downloaded version=${info?.version || 'unknown'}`);
setTaskbarProgress(-1);
if (state.pendingUpdate) { if (state.pendingUpdate) {
state.pendingUpdate.downloaded = true; state.pendingUpdate.downloaded = true;
} }
}); });
autoUpdater.on('error', (err) => { autoUpdater.on('error', (err) => {
setTaskbarProgress(-1);
log.error('[electron] autoUpdater error', err); log.error('[electron] autoUpdater error', err);
}); });
}; };
@@ -1824,6 +1951,138 @@ const CLI_BY_APP_ID = {
zed: 'zed', zed: 'zed',
}; };
const WINDOWS_CLI_BY_APP_ID = {
vscode: 'code.cmd',
cursor: 'cursor.cmd',
vscodium: 'codium.cmd',
windsurf: 'windsurf.cmd',
zed: 'zed.cmd',
};
const WINDOWS_APP_EXECUTABLES = {
terminal: ['wt.exe', 'WindowsTerminal.exe'],
vscode: ['code.cmd', 'code.exe'],
cursor: ['cursor.cmd', 'cursor.exe'],
vscodium: ['codium.cmd', 'codium.exe'],
windsurf: ['windsurf.cmd', 'windsurf.exe'],
zed: ['zed.exe'],
'visual-studio': ['devenv.exe'],
'sublime-text': ['subl.exe', 'sublime_text.exe'],
};
const WINDOWS_APP_ID_BY_NAME = new Map([
['finder', 'finder'],
['file explorer', 'finder'],
['terminal', 'terminal'],
['windows terminal', 'terminal'],
['visual studio code', 'vscode'],
['cursor', 'cursor'],
['vscodium', 'vscodium'],
['windsurf', 'windsurf'],
['zed', 'zed'],
['visual studio', 'visual-studio'],
['sublime text', 'sublime-text'],
]);
const getWindowsAppIdForName = (appName) => WINDOWS_APP_ID_BY_NAME.get(String(appName || '').trim().toLowerCase()) || '';
const runWhere = (program) => {
const result = spawnSync('where.exe', [program], { encoding: 'utf8', windowsHide: true });
if (result.error || result.status !== 0) return null;
const first = String(result.stdout || '').split(/\r?\n/).map((line) => line.trim()).find(Boolean);
return first || null;
};
const findWindowsExecutable = (appId) => {
for (const program of WINDOWS_APP_EXECUTABLES[appId] || []) {
const resolved = runWhere(program);
if (resolved) return resolved;
}
return null;
};
const findWindowsAppNameExecutable = (appName) => {
const program = `${String(appName || '').trim()}.exe`.replace(/\s+/g, '');
return program === '.exe' ? null : runWhere(program);
};
const isWindowsAppInstalled = ({ appId, appName }) => {
if (appId === 'finder') return true;
if (appId === 'terminal') return Boolean(findWindowsExecutable('terminal'));
if (findWindowsExecutable(appId)) return true;
return Boolean(findWindowsAppNameExecutable(appName));
};
const buildWindowsInstalledApps = (apps) => {
const seen = new Set();
return (Array.isArray(apps) ? apps : [])
.map((appName) => String(appName || '').trim())
.filter((appName) => appName && !seen.has(appName) && seen.add(appName))
.filter((appName) => isWindowsAppInstalled({ appId: getWindowsAppIdForName(appName), appName }))
.map((name) => ({ name, iconDataUrl: null }));
};
const buildWindowsOpenProjectSpecs = ({ projectPath, appId, appName }) => {
if (appId === 'finder') {
return [{ program: 'explorer.exe', args: [projectPath] }];
}
if (appId === 'terminal') {
const specs = [];
const terminal = findWindowsExecutable('terminal');
if (terminal) {
specs.push({ program: terminal, args: ['-d', projectPath] });
}
const shell = runWhere('pwsh.exe') || runWhere('powershell.exe');
if (shell) {
specs.push({ program: shell, args: ['-NoExit', '-Command', `Set-Location -LiteralPath ${JSON.stringify(projectPath)}`] });
}
return specs;
}
const specs = [];
const cli = WINDOWS_CLI_BY_APP_ID[appId];
if (cli) {
const resolvedCli = runWhere(cli);
if (resolvedCli) {
specs.push({ program: resolvedCli, args: [projectPath] });
}
}
const exe = findWindowsExecutable(appId);
if (exe) {
specs.push({ program: exe, args: [projectPath] });
}
const namedExe = findWindowsAppNameExecutable(appName);
if (namedExe && !specs.some((spec) => spec.program === namedExe)) {
specs.push({ program: namedExe, args: [projectPath] });
}
return specs;
};
const buildWindowsOpenFileSpecs = ({ filePath, appId, appName }) => {
if (appId === 'finder') {
return [{ program: 'explorer.exe', args: ['/select,', filePath] }];
}
if (appId === 'terminal') {
return buildWindowsOpenProjectSpecs({ projectPath: path.dirname(filePath), appId, appName });
}
const specs = [];
const cli = WINDOWS_CLI_BY_APP_ID[appId];
if (cli) {
const resolvedCli = runWhere(cli);
if (resolvedCli) {
specs.push({ program: resolvedCli, args: [filePath] });
}
}
const exe = findWindowsExecutable(appId);
if (exe) {
specs.push({ program: exe, args: [filePath] });
}
const namedExe = findWindowsAppNameExecutable(appName);
if (namedExe && !specs.some((spec) => spec.program === namedExe)) {
specs.push({ program: namedExe, args: [filePath] });
}
return specs;
};
const buildOpenProjectSpecs = ({ projectPath, appId, appName }) => { const buildOpenProjectSpecs = ({ projectPath, appId, appName }) => {
if (appId === 'finder') { if (appId === 'finder') {
return [{ program: 'open', args: [projectPath] }]; return [{ program: 'open', args: [projectPath] }];
@@ -1869,10 +2128,66 @@ const buildOpenFileSpecs = ({ filePath, appId, appName }) => {
return specs; return specs;
}; };
const quoteWindowsCommandArg = (value) => `"${String(value).replace(/"/g, '""')}"`;
const resolveWindowsLaunchProgram = (program) => {
if (path.isAbsolute(program)) {
return fs.existsSync(program) ? program : null;
}
return runWhere(program);
};
const launchWindowsCommandScript = (spec, program) => {
const commandLine = ['call', quoteWindowsCommandArg(program), ...spec.args.map(quoteWindowsCommandArg)].join(' ');
const child = spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', commandLine], {
detached: true,
stdio: 'ignore',
windowsHide: false,
windowsVerbatimArguments: true,
});
child.unref();
};
const launchWindowsSpec = (spec) => {
const program = resolveWindowsLaunchProgram(spec.program);
if (!program) {
throw new Error('program not found');
}
if (/\.(cmd|bat)$/i.test(program)) {
launchWindowsCommandScript(spec, program);
return;
}
const child = spawn(program, spec.args, {
detached: true,
stdio: 'ignore',
windowsHide: false,
});
child.unref();
};
const runSpecChain = (specs, appName) => { const runSpecChain = (specs, appName) => {
if (!Array.isArray(specs) || specs.length === 0) {
throw new Error(`Failed to open in ${appName}: no launch candidates`);
}
if (process.platform === 'win32') {
const failures = [];
for (const spec of specs) {
try {
launchWindowsSpec(spec);
return;
} catch (error) {
failures.push(`${spec.program}: ${error instanceof Error ? error.message : String(error)}`);
}
}
throw new Error(`Failed to open in ${appName}: ${failures.join('; ')}`);
}
const failures = []; const failures = [];
for (const spec of specs) { for (const spec of specs) {
const result = spawnSync(spec.program, spec.args, { stdio: 'ignore' }); const result = spawnSync(spec.program, spec.args, { stdio: 'ignore', windowsHide: true });
if (result.error) { if (result.error) {
failures.push(`${spec.program}: ${result.error.message}`); failures.push(`${spec.program}: ${result.error.message}`);
continue; continue;
@@ -2095,34 +2410,45 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
} }
case 'desktop_open_in_app': { case 'desktop_open_in_app': {
if (process.platform !== 'darwin') {
throw new Error('desktop_open_in_app is only supported on macOS');
}
const projectPath = typeof args.projectPath === 'string' ? args.projectPath.trim() : ''; const projectPath = typeof args.projectPath === 'string' ? args.projectPath.trim() : '';
const appId = typeof args.appId === 'string' ? args.appId.trim().toLowerCase() : ''; const appId = typeof args.appId === 'string' ? args.appId.trim().toLowerCase() : '';
const appName = typeof args.appName === 'string' ? args.appName.trim() : ''; const appName = typeof args.appName === 'string' ? args.appName.trim() : '';
if (!projectPath || !appId || !appName) { if (!projectPath || !appId || !appName) {
throw new Error('Project path, app id, and app name are required'); throw new Error('Project path, app id, and app name are required');
} }
if (process.platform === 'win32') {
runSpecChain(buildWindowsOpenProjectSpecs({ projectPath, appId, appName }), appName);
return null;
}
if (process.platform !== 'darwin') {
throw new Error('desktop_open_in_app is only supported on macOS and Windows');
}
runSpecChain(buildOpenProjectSpecs({ projectPath, appId, appName }), appName); runSpecChain(buildOpenProjectSpecs({ projectPath, appId, appName }), appName);
return null; return null;
} }
case 'desktop_open_file_in_app': { case 'desktop_open_file_in_app': {
if (process.platform !== 'darwin') {
throw new Error('desktop_open_file_in_app is only supported on macOS');
}
const filePath = typeof args.filePath === 'string' ? args.filePath.trim() : ''; const filePath = typeof args.filePath === 'string' ? args.filePath.trim() : '';
const appId = typeof args.appId === 'string' ? args.appId.trim().toLowerCase() : ''; const appId = typeof args.appId === 'string' ? args.appId.trim().toLowerCase() : '';
const appName = typeof args.appName === 'string' ? args.appName.trim() : ''; const appName = typeof args.appName === 'string' ? args.appName.trim() : '';
if (!filePath || !appId || !appName) { if (!filePath || !appId || !appName) {
throw new Error('File path, app id, and app name are required'); throw new Error('File path, app id, and app name are required');
} }
if (process.platform === 'win32') {
runSpecChain(buildWindowsOpenFileSpecs({ filePath, appId, appName }), appName);
return null;
}
if (process.platform !== 'darwin') {
throw new Error('desktop_open_file_in_app is only supported on macOS and Windows');
}
runSpecChain(buildOpenFileSpecs({ filePath, appId, appName }), appName); runSpecChain(buildOpenFileSpecs({ filePath, appId, appName }), appName);
return null; return null;
} }
case 'desktop_filter_installed_apps': { case 'desktop_filter_installed_apps': {
if (process.platform === 'win32') {
return buildWindowsInstalledApps(args.apps).map((app) => app.name);
}
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
throw new Error('desktop_filter_installed_apps is only supported on macOS'); throw new Error('desktop_filter_installed_apps is only supported on macOS');
} }
@@ -2134,6 +2460,9 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
} }
case 'desktop_fetch_app_icons': { case 'desktop_fetch_app_icons': {
if (process.platform === 'win32') {
return [];
}
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
throw new Error('desktop_fetch_app_icons is only supported on macOS'); throw new Error('desktop_fetch_app_icons is only supported on macOS');
} }
@@ -2149,9 +2478,6 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
} }
case 'desktop_get_installed_apps': { case 'desktop_get_installed_apps': {
if (process.platform !== 'darwin') {
throw new Error('desktop_get_installed_apps is only supported on macOS');
}
const cachePath = buildInstalledAppsCachePath(); const cachePath = buildInstalledAppsCachePath();
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
let cache = null; let cache = null;
@@ -2163,11 +2489,16 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
const hasCache = Boolean(cache); const hasCache = Boolean(cache);
const isCacheStale = !cache || (now - Number(cache.updatedAt || 0)) > INSTALLED_APPS_CACHE_TTL_SECS; const isCacheStale = !cache || (now - Number(cache.updatedAt || 0)) > INSTALLED_APPS_CACHE_TTL_SECS;
const refresh = async () => { const refresh = async () => {
const apps = await buildInstalledApps(Array.isArray(args.apps) ? args.apps : []); const apps = process.platform === 'win32'
? buildWindowsInstalledApps(args.apps)
: await buildInstalledApps(Array.isArray(args.apps) ? args.apps : []);
await fsp.mkdir(path.dirname(cachePath), { recursive: true }); await fsp.mkdir(path.dirname(cachePath), { recursive: true });
await fsp.writeFile(cachePath, JSON.stringify({ updatedAt: now, apps }, null, 2)); await fsp.writeFile(cachePath, JSON.stringify({ updatedAt: now, apps }, null, 2));
emitToAllWindows('openchamber:installed-apps-updated', apps); emitToAllWindows('openchamber:installed-apps-updated', apps);
}; };
if (process.platform !== 'darwin' && process.platform !== 'win32') {
throw new Error('desktop_get_installed_apps is only supported on macOS and Windows');
}
if (!hasCache || isCacheStale || args.force === true) { if (!hasCache || isCacheStale || args.force === true) {
void refresh(); void refresh();
} }
@@ -2216,6 +2547,14 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
} else { } else {
nativeTheme.themeSource = 'system'; nativeTheme.themeSource = 'system';
} }
if (canUseTitleBarOverlay(browserWindow)) {
const useDark = nativeTheme.shouldUseDarkColors;
browserWindow.setTitleBarOverlay({
color: useDark ? '#151313' : '#f5f5f4',
symbolColor: useDark ? '#fafaf9' : '#1c1917',
height: 48,
});
}
return null; return null;
} }
@@ -2271,40 +2610,45 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
if (!state.pendingUpdate) { if (!state.pendingUpdate) {
throw new Error('No pending update'); throw new Error('No pending update');
} }
setTaskbarProgress(0.01);
emitToAllWindows('openchamber:update-progress', mapUpdaterProgressEvent({ emitToAllWindows('openchamber:update-progress', mapUpdaterProgressEvent({
event: 'Started', event: 'Started',
data: { data: {
contentLength: null, contentLength: null,
}, },
})); }));
if (!state.pendingUpdate.electronUpdate) { try {
throw new Error('Electron updater metadata is not available for this build'); if (!state.pendingUpdate.electronUpdate) {
throw new Error('Electron updater metadata is not available for this build');
}
if (!state.pendingUpdate.downloaded) {
await new Promise((resolve, reject) => {
let settled = false;
const cleanup = () => {
autoUpdater.off('update-downloaded', onDownloaded);
autoUpdater.off('error', onError);
};
const finish = (callback, value) => {
if (settled) return;
settled = true;
cleanup();
callback(value);
};
const onDownloaded = () => finish(resolve, null);
const onError = (error) => finish(reject, error);
autoUpdater.on('update-downloaded', onDownloaded);
autoUpdater.on('error', onError);
Promise.resolve(autoUpdater.downloadUpdate()).catch((error) => finish(reject, error));
});
}
emitToAllWindows('openchamber:update-progress', mapUpdaterProgressEvent({
event: 'Finished',
data: {},
}));
return null;
} finally {
setTaskbarProgress(-1);
} }
if (!state.pendingUpdate.downloaded) {
await new Promise((resolve, reject) => {
let settled = false;
const cleanup = () => {
autoUpdater.off('update-downloaded', onDownloaded);
autoUpdater.off('error', onError);
};
const finish = (callback, value) => {
if (settled) return;
settled = true;
cleanup();
callback(value);
};
const onDownloaded = () => finish(resolve, null);
const onError = (error) => finish(reject, error);
autoUpdater.on('update-downloaded', onDownloaded);
autoUpdater.on('error', onError);
Promise.resolve(autoUpdater.downloadUpdate()).catch((error) => finish(reject, error));
});
}
emitToAllWindows('openchamber:update-progress', mapUpdaterProgressEvent({
event: 'Finished',
data: {},
}));
return null;
case 'desktop_restart': { case 'desktop_restart': {
const applyUpdate = Boolean(state.pendingUpdate?.downloaded && app.isPackaged); const applyUpdate = Boolean(state.pendingUpdate?.downloaded && app.isPackaged);
@@ -2422,6 +2766,38 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
} }
return null; return null;
case 'desktop_minimize_current_window':
if (browserWindow && !browserWindow.isDestroyed()) {
browserWindow.minimize();
}
return null;
case 'desktop_toggle_current_window_maximized':
if (browserWindow && !browserWindow.isDestroyed()) {
if (browserWindow.isMaximized()) {
browserWindow.unmaximize();
} else {
browserWindow.maximize();
}
return { maximized: browserWindow.isMaximized() };
}
return { maximized: false };
case 'desktop_get_current_window_state':
return { maximized: Boolean(browserWindow && !browserWindow.isDestroyed() && browserWindow.isMaximized()) };
case 'desktop_show_app_menu': {
if (!browserWindow || browserWindow.isDestroyed()) {
return null;
}
const menu = Menu.getApplicationMenu() || buildAutoHiddenMenu();
const x = Number.isFinite(Number(args.x)) ? Math.max(0, Math.round(Number(args.x))) : undefined;
const y = Number.isFinite(Number(args.y)) ? Math.max(0, Math.round(Number(args.y))) : undefined;
menu.popup({ window: browserWindow, x, y });
return null;
}
case 'desktop_ssh_instances_get': case 'desktop_ssh_instances_get':
return sshManager.readInstances(); return sshManager.readInstances();
@@ -2562,6 +2938,119 @@ const buildMacMenu = () => {
]); ]);
}; };
const buildAutoHiddenMenu = () => {
const dispatchAction = (action) => dispatchMenuAction(action);
const handleCopyAction = () => {
BrowserWindow.getFocusedWindow()?.webContents.copy();
dispatchAction('copy');
};
return Menu.buildFromTemplate([
{
label: 'OpenChamber',
submenu: [
{ label: 'About OpenChamber', click: () => dispatchAction('about') },
{
label: 'Check for Updates',
click: () => dispatchCheckForUpdates(),
},
{ type: 'separator' },
{ label: 'Settings', accelerator: 'Ctrl+,', click: () => dispatchAction('settings') },
{ label: 'Reload Webview', click: () => reloadMenuTargetWindow() },
{ label: 'Restart', click: () => relaunchFromMenu() },
{ label: 'Command Palette', accelerator: 'Ctrl+P', click: () => dispatchAction('command-palette') },
{ type: 'separator' },
{ role: 'quit' },
],
},
{
label: 'File',
submenu: [
{ label: 'New Window', accelerator: 'Ctrl+Shift+Alt+N', click: () => void handleInvoke(null, 'desktop_new_window') },
{ type: 'separator' },
{ label: 'New Session', accelerator: 'Ctrl+N', click: () => dispatchAction('new-session') },
{ label: 'New Worktree', accelerator: 'Ctrl+Shift+N', click: () => dispatchAction('new-worktree-session') },
{ type: 'separator' },
{ label: 'Add Workspace', click: () => dispatchAction('change-workspace') },
{ type: 'separator' },
{ role: 'quit' },
],
},
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ label: 'Copy', accelerator: 'Ctrl+C', click: () => handleCopyAction() },
{ role: 'paste' },
{ role: 'selectAll' },
],
},
{
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
...(isDev ? [{ role: 'toggleDevTools' }] : []),
{ type: 'separator' },
{ label: 'Toggle Right Sidebar', accelerator: 'Ctrl+B', click: () => dispatchAction('toggle-right-sidebar') },
{ label: 'Open Git Sidebar', accelerator: 'Ctrl+Shift+G', click: () => dispatchAction('open-right-sidebar-git') },
{ label: 'Open Files Sidebar', accelerator: 'Ctrl+Shift+F', click: () => dispatchAction('open-right-sidebar-files') },
{ type: 'separator' },
{ label: 'Toggle Terminal Dock', accelerator: 'Ctrl+J', click: () => dispatchAction('toggle-terminal') },
{ label: 'Toggle Terminal Expanded', accelerator: 'Ctrl+Shift+J', click: () => dispatchAction('toggle-terminal-expanded') },
{ type: 'separator' },
{ label: 'Light Theme', click: () => dispatchAction('theme-light') },
{ label: 'Dark Theme', click: () => dispatchAction('theme-dark') },
{ label: 'System Theme', click: () => dispatchAction('theme-system') },
{ type: 'separator' },
{ label: 'Toggle Session Sidebar', accelerator: 'Ctrl+L', click: () => dispatchAction('toggle-sidebar') },
{ label: 'Toggle Memory Debug', accelerator: 'Ctrl+Shift+D', click: () => dispatchAction('toggle-memory-debug') },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
{
label: 'Go',
submenu: [
{ label: 'Back', accelerator: 'Ctrl+[', click: () => dispatchAction('go-back') },
{ label: 'Forward', accelerator: 'Ctrl+]', click: () => dispatchAction('go-forward') },
{ type: 'separator' },
{ label: 'Previous Session', accelerator: 'Alt+Up', click: () => dispatchAction('previous-session') },
{ label: 'Next Session', accelerator: 'Alt+Down', click: () => dispatchAction('next-session') },
{ type: 'separator' },
{ label: 'Previous Project', accelerator: 'Ctrl+Alt+Up', click: () => dispatchAction('previous-project') },
{ label: 'Next Project', accelerator: 'Ctrl+Alt+Down', click: () => dispatchAction('next-project') },
],
},
{
label: 'Window',
submenu: [
{ role: 'minimize' },
{ role: 'togglefullscreen' },
{ type: 'separator' },
{ role: 'close' },
],
},
{
label: 'Help',
submenu: [
{ label: 'Keyboard Shortcuts', accelerator: 'Ctrl+.', click: () => dispatchAction('help-dialog') },
{ label: 'Show Diagnostics', accelerator: 'Ctrl+Shift+L', click: () => dispatchAction('download-logs') },
{ type: 'separator' },
{ label: 'Clear Cache', click: () => void handleInvoke(null, 'desktop_clear_cache') },
{ type: 'separator' },
{ label: 'Report a Bug', click: () => shell.openExternal(GITHUB_BUG_REPORT_URL) },
{ label: 'Request a Feature', click: () => shell.openExternal(GITHUB_FEATURE_REQUEST_URL) },
{ type: 'separator' },
{ label: 'Join Discord', click: () => shell.openExternal(DISCORD_INVITE_URL) },
],
},
]);
};
contextMenu({ contextMenu({
showInspectElement: isDev, showInspectElement: isDev,
showSaveImageAs: true, showSaveImageAs: true,
@@ -2612,6 +3101,10 @@ const COMMANDS_SAFE_FOR_REMOTE = new Set([
'desktop_set_window_theme', 'desktop_set_window_theme',
'desktop_is_window_fullscreen', 'desktop_is_window_fullscreen',
'desktop_start_window_drag', 'desktop_start_window_drag',
'desktop_minimize_current_window',
'desktop_toggle_current_window_maximized',
'desktop_close_current_window',
'desktop_get_current_window_state',
'desktop_get_app_version', 'desktop_get_app_version',
'desktop_get_lan_address', 'desktop_get_lan_address',
'desktop_capture_page_rect', 'desktop_capture_page_rect',
@@ -2733,6 +3226,8 @@ app.whenReady().then(async () => {
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
Menu.setApplicationMenu(buildMacMenu()); Menu.setApplicationMenu(buildMacMenu());
} else {
Menu.setApplicationMenu(buildAutoHiddenMenu());
} }
if (process.platform === 'darwin' && app.isPackaged) { if (process.platform === 'darwin' && app.isPackaged) {
+23 -2
View File
@@ -20,7 +20,8 @@
"desktopPrerequisites": [ "desktopPrerequisites": [
"Electron runtime dependencies installed via bun install", "Electron runtime dependencies installed via bun install",
"Bun available for sidecar compilation", "Bun available for sidecar compilation",
"macOS build tools installed for notarized packaging" "macOS: Xcode + build tools for notarized packaging",
"Windows: NSIS installed for installer creation"
], ],
"scripts": { "scripts": {
"dev": "node ./scripts/electron-dev.mjs", "dev": "node ./scripts/electron-dev.mjs",
@@ -29,7 +30,7 @@
"bundle:main": "bun ./scripts/bundle-main.mjs", "bundle:main": "bun ./scripts/bundle-main.mjs",
"generate:macos-icon": "node ./scripts/generate-macos-icon-assets.cjs", "generate:macos-icon": "node ./scripts/generate-macos-icon-assets.cjs",
"rebuild:native": "node ./scripts/rebuild-native.mjs", "rebuild:native": "node ./scripts/rebuild-native.mjs",
"package": "bun run build:web-assets && bun run bundle:main && bun run rebuild:native && electron-builder", "package": "bun run build:web-assets && bun run bundle:main && bun run rebuild:native && node ./scripts/package.mjs",
"finalize:latest-yml": "node ./scripts/finalize-latest-yml.mjs", "finalize:latest-yml": "node ./scripts/finalize-latest-yml.mjs",
"type-check": "node --check ./main.mjs && node --check ./preload.mjs", "type-check": "node --check ./main.mjs && node --check ./preload.mjs",
"lint": "node -e \"process.exit(0)\"" "lint": "node -e \"process.exit(0)\""
@@ -45,6 +46,10 @@
{ {
"from": "resources/web-dist", "from": "resources/web-dist",
"to": "web-dist" "to": "web-dist"
},
{
"from": "resources/icons/icon.ico",
"to": "icons/icon.ico"
} }
], ],
"afterPack": "scripts/after-pack.cjs", "afterPack": "scripts/after-pack.cjs",
@@ -57,6 +62,7 @@
"mac": { "mac": {
"category": "public.app-category.developer-tools", "category": "public.app-category.developer-tools",
"icon": "resources/icons/icon.icns", "icon": "resources/icons/icon.icns",
"artifactName": "${productName}-${version}-mac-${arch}.${ext}",
"extendInfo": { "extendInfo": {
"CFBundleIconName": "AppIcon" "CFBundleIconName": "AppIcon"
}, },
@@ -70,6 +76,21 @@
"zip" "zip"
] ]
}, },
"win": {
"icon": "resources/icons/icon.ico",
"target": [
"nsis"
],
"verifyUpdateCodeSignature": false,
"artifactName": "${productName}-${version}-win-${arch}.${ext}"
},
"nsis": {
"oneClick": true,
"perMachine": false,
"installerIcon": "resources/icons/icon.ico",
"uninstallerIcon": "resources/icons/icon.ico",
"installerHeaderIcon": "resources/icons/icon.ico"
},
"dmg": { "dmg": {
"sign": true, "sign": true,
"title": "${productName} ${version}", "title": "${productName} ${version}",
+2
View File
@@ -66,6 +66,8 @@ contextBridge.exposeInMainWorld('__OPENCHAMBER_ELECTRON__', {
runtime: 'electron', runtime: 'electron',
}); });
contextBridge.exposeInMainWorld('__OPENCHAMBER_PLATFORM__', process.platform);
// Note: bootOutcome must stay writable from the main world's initScript so // Note: bootOutcome must stay writable from the main world's initScript so
// re-navigations (host switch via deep link) can refresh it. contextBridge- // re-navigations (host switch via deep link) can refresh it. contextBridge-
// exposed globals are read-only, which blocks that update — rely solely on // exposed globals are read-only, which blocks that update — rely solely on
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+16 -1
View File
@@ -14,8 +14,17 @@ const resourcesDir = path.join(electronDir, 'resources');
const resourcesWebDistDir = path.join(resourcesDir, 'web-dist'); const resourcesWebDistDir = path.join(resourcesDir, 'web-dist');
const webDistDir = path.join(webDir, 'dist'); const webDistDir = path.join(webDir, 'dist');
const quoteWindowsCommandArg = (value) => `"${String(value).replace(/"/g, '""')}"`;
const run = (cmd, args, cwd) => { const run = (cmd, args, cwd) => {
const result = spawnSync(cmd, args, { cwd, stdio: 'inherit' }); const isWindowsCommandScript = process.platform === 'win32' && /\.(cmd|bat)$/i.test(cmd);
const result = isWindowsCommandScript
? spawnSync(
process.env.ComSpec || 'cmd.exe',
['/d', '/s', '/c', ['call', quoteWindowsCommandArg(cmd), ...args.map(quoteWindowsCommandArg)].join(' ')],
{ cwd, stdio: 'inherit', windowsVerbatimArguments: true },
)
: spawnSync(cmd, args, { cwd, stdio: 'inherit' });
if (result.error) throw result.error; if (result.error) throw result.error;
if (result.status !== 0) { if (result.status !== 0) {
throw new Error(`Command failed: ${cmd} ${args.join(' ')}`); throw new Error(`Command failed: ${cmd} ${args.join(' ')}`);
@@ -26,6 +35,12 @@ const resolveBun = () => {
if (typeof process.env.BUN === 'string' && process.env.BUN.trim()) { if (typeof process.env.BUN === 'string' && process.env.BUN.trim()) {
return process.env.BUN.trim(); return process.env.BUN.trim();
} }
if (process.platform === 'win32') {
const result = spawnSync('where.exe', ['bun'], { encoding: 'utf8' });
const candidates = String(result.stdout || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
const resolved = candidates.find((entry) => /\.(exe|cmd|bat)$/i.test(entry)) || candidates[0];
return resolved || 'bun';
}
const result = spawnSync('/bin/bash', ['-lc', 'command -v bun'], { encoding: 'utf8' }); const result = spawnSync('/bin/bash', ['-lc', 'command -v bun'], { encoding: 'utf8' });
const resolved = (result.stdout || '').trim(); const resolved = (result.stdout || '').trim();
return resolved || 'bun'; return resolved || 'bun';
+98 -5
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
import { spawn } from 'node:child_process'; import { spawn, spawnSync } from 'node:child_process';
import net from 'node:net';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -7,13 +8,39 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '../../..'); const repoRoot = path.resolve(__dirname, '../../..');
const electronDir = path.join(repoRoot, 'packages/electron'); const electronDir = path.join(repoRoot, 'packages/electron');
const preferredHmrUiPort = Number(process.env.OPENCHAMBER_HMR_UI_PORT || '5173');
const preferredHmrApiPort = Number(process.env.OPENCHAMBER_HMR_API_PORT || '3901');
const quoteWindowsCommandArg = (value) => `"${String(value).replace(/"/g, '""')}"`;
function resolveWindowsCommand(command) {
if (process.platform !== 'win32' || path.isAbsolute(command)) {
return command;
}
const result = spawnSync('where.exe', [command], { encoding: 'utf8', windowsHide: true });
if (result.error || result.status !== 0) {
return command;
}
const candidates = String(result.stdout || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
return candidates.find((entry) => /\.(exe|cmd|bat)$/i.test(entry)) || candidates[0] || command;
}
function spawnProcess(command, args, options = {}) { function spawnProcess(command, args, options = {}) {
return spawn(command, args, { const resolvedCommand = resolveWindowsCommand(command);
const isWindowsCommandScript = process.platform === 'win32' && /\.(cmd|bat)$/i.test(resolvedCommand);
const spawnCommand = isWindowsCommandScript ? (process.env.ComSpec || 'cmd.exe') : resolvedCommand;
const spawnArgs = isWindowsCommandScript
? ['/d', '/s', '/c', ['call', quoteWindowsCommandArg(resolvedCommand), ...args.map(quoteWindowsCommandArg)].join(' ')]
: args;
return spawn(spawnCommand, spawnArgs, {
cwd: repoRoot, cwd: repoRoot,
env: { ...process.env, OPENCHAMBER_ELECTRON_DEV: '1' }, env: { ...process.env, OPENCHAMBER_ELECTRON_DEV: '1' },
stdio: 'inherit', stdio: 'inherit',
detached: process.platform !== 'win32', detached: process.platform !== 'win32',
windowsVerbatimArguments: isWindowsCommandScript,
...options, ...options,
}); });
} }
@@ -39,6 +66,55 @@ function waitForExit(child, timeoutMs) {
}); });
} }
function isPortAvailable(port) {
return new Promise((resolve) => {
const server = net.createServer();
server.once('error', () => resolve(false));
server.once('listening', () => {
server.close(() => resolve(true));
});
server.listen(port, '127.0.0.1');
});
}
async function findAvailablePort(preferredPort) {
const start = Number.isFinite(preferredPort) && preferredPort > 0 ? preferredPort : 0;
if (start === 0) {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.once('listening', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
server.listen(0, '127.0.0.1');
});
}
for (let port = start; port < start + 50; port += 1) {
if (await isPortAvailable(port)) {
if (port !== start) {
console.warn(`[electron:dev] port ${start} is unavailable, using ${port} instead.`);
}
return port;
}
}
throw new Error(`No available port found near ${start}`);
}
function killWindowsProcessTree(pid) {
if (!pid) return;
try {
spawnSync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], {
stdio: 'ignore',
windowsHide: true,
});
} catch {
}
}
function signalChild(child, signal) { function signalChild(child, signal) {
if (!child || child.exitCode !== null || child.signalCode !== null) { if (!child || child.exitCode !== null || child.signalCode !== null) {
return; return;
@@ -66,6 +142,11 @@ async function stopChildTree(child) {
signalChild(child, 'SIGINT'); signalChild(child, 'SIGINT');
await waitForExit(child, 2500); await waitForExit(child, 2500);
if (process.platform === 'win32' && child.exitCode === null && child.signalCode === null) {
killWindowsProcessTree(child.pid);
await waitForExit(child, 1000);
}
if (child.exitCode === null && child.signalCode === null) { if (child.exitCode === null && child.signalCode === null) {
signalChild(child, 'SIGTERM'); signalChild(child, 'SIGTERM');
await waitForExit(child, 2500); await waitForExit(child, 2500);
@@ -78,16 +159,28 @@ async function stopChildTree(child) {
} }
async function main() { async function main() {
const hmrApiPort = String(await findAvailablePort(preferredHmrApiPort));
const hmrUiPort = String(await findAvailablePort(preferredHmrUiPort));
const devServer = spawnProcess('node', ['./scripts/dev-web-hmr.mjs'], { const devServer = spawnProcess('node', ['./scripts/dev-web-hmr.mjs'], {
env: { env: {
...process.env, ...process.env,
OPENCHAMBER_ELECTRON_DEV: '1', OPENCHAMBER_ELECTRON_DEV: '1',
OPENCHAMBER_HMR_UI_PORT: '5173', OPENCHAMBER_HMR_UI_PORT: hmrUiPort,
OPENCHAMBER_HMR_API_PORT: '3901', OPENCHAMBER_HMR_API_PORT: hmrApiPort,
OPENCHAMBER_DISABLE_PWA_DEV: '1',
},
});
const electron = spawnProcess('npx', ['electron', './main.mjs'], {
cwd: electronDir,
env: {
...process.env,
OPENCHAMBER_ELECTRON_DEV: '1',
OPENCHAMBER_HMR_UI_PORT: hmrUiPort,
OPENCHAMBER_HMR_API_PORT: hmrApiPort,
OPENCHAMBER_DISABLE_PWA_DEV: '1', OPENCHAMBER_DISABLE_PWA_DEV: '1',
}, },
}); });
const electron = spawnProcess('npx', ['electron', './main.mjs'], { cwd: electronDir });
let cleaning = false; let cleaning = false;
const teardown = async (code) => { const teardown = async (code) => {
+41
View File
@@ -0,0 +1,41 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const env = { ...process.env };
if (process.platform === 'win32' && !env.CSC_LINK && !env.WINDOWS_CSC_LINK) {
env.CSC_IDENTITY_AUTO_DISCOVERY = 'false';
console.log('[electron] Windows code signing disabled; building unsigned installer.');
}
const bunBinaryCandidates = [
process.env.npm_execpath,
process.env.BUN_INSTALL ? path.join(process.env.BUN_INSTALL, 'bin', process.platform === 'win32' ? 'bun.exe' : 'bun') : null,
process.platform === 'win32' ? 'bun.exe' : 'bun',
].filter(Boolean);
const bunBinary = bunBinaryCandidates.find((candidate) => {
if (path.basename(candidate).toLowerCase().startsWith('bun')) {
return candidate === 'bun' || candidate === 'bun.exe' || fs.existsSync(candidate);
}
return false;
}) || (process.platform === 'win32' ? 'bun.exe' : 'bun');
const child = spawn(bunBinary, ['x', 'electron-builder', ...process.argv.slice(2)], {
env,
stdio: 'inherit',
});
child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 1);
});
child.on('error', (error) => {
console.error('[electron] failed to start electron-builder:', error);
process.exit(1);
});
+133 -7
View File
@@ -1,5 +1,8 @@
#!/usr/bin/env node #!/usr/bin/env node
import path from 'node:path'; import path from 'node:path';
import { existsSync } from 'node:fs';
import fsp from 'node:fs/promises';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import { rebuild } from '@electron/rebuild'; import { rebuild } from '@electron/rebuild';
@@ -14,17 +17,140 @@ const require = createRequire(import.meta.url);
const electronPkg = require('electron/package.json'); const electronPkg = require('electron/package.json');
const electronVersion = electronPkg.version; const electronVersion = electronPkg.version;
const copyDirectory = async (src, dst) => {
await fsp.mkdir(dst, { recursive: true });
const entries = await fsp.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const from = path.join(src, entry.name);
const to = path.join(dst, entry.name);
if (entry.isDirectory()) {
await copyDirectory(from, to);
} else {
await fsp.copyFile(from, to);
}
}
};
const getWindowsShortPath = (target) => {
if (process.platform !== 'win32') return target;
try {
const escaped = target.replace(/'/g, "''");
const output = execFileSync(
'powershell.exe',
['-NoProfile', '-Command', `$fso = New-Object -ComObject Scripting.FileSystemObject; $fso.GetFolder('${escaped}').ShortPath`],
{ encoding: 'utf8' },
).trim();
return output || target;
} catch {
return target;
}
};
const createWindowsRebuildPath = (target) => {
if (process.platform !== 'win32') {
return { buildPath: target, cleanup: () => {} };
}
for (const letter of 'ZYXWVUTSRQPONMLKJIHGFED') {
const drive = `${letter}:`;
if (existsSync(`${drive}\\`)) continue;
try {
execFileSync('subst.exe', [drive, target], { stdio: 'ignore' });
return {
buildPath: `${drive}\\`,
cleanup: () => {
try {
execFileSync('subst.exe', [drive, '/d'], { stdio: 'ignore' });
} catch {
// Best-effort cleanup. The build result should not depend on this.
}
},
};
} catch {
// Try the next drive letter.
}
}
const shortPath = getWindowsShortPath(target);
if (shortPath === target && /\s/.test(target)) {
throw new Error(
`Unable to create a space-free Windows rebuild path for ${target}. `
+ 'All subst drive letters are unavailable and the volume did not return an 8.3 short path.',
);
}
return { buildPath: shortPath, cleanup: () => {} };
};
const writeWindowsNodeAddonApiIndex = async (nodeAddonApiDir, exportedNodeAddonApiDir) => {
if (process.platform !== 'win32') return;
const shortDir = getWindowsShortPath(exportedNodeAddonApiDir);
await fsp.writeFile(
path.join(nodeAddonApiDir, 'index.js'),
`const path = require('path');
const includeDir = ${JSON.stringify(shortDir)};
module.exports = {
include: \`"${shortDir}"\`,
include_dir: includeDir,
gyp: path.join(includeDir, 'node_api.gyp:nothing'),
targets: path.join(includeDir, 'node_addon_api.gyp'),
isNodeApiBuiltin: true,
needsFlag: false
};
`,
);
};
const ensureWindowsNodeAddonApiForNodePty = async (rebuildRootPath) => {
if (process.platform !== 'win32') return async () => {};
const nodePtyPackagePath = require.resolve('node-pty/package.json');
const nodePtyDir = path.dirname(nodePtyPackagePath);
const rootNodeAddonApiDir = path.dirname(require.resolve('node-addon-api/package.json'));
const tempNodeAddonApiDir = path.join(repoRoot, 'node_modules', '.openchamber-node-addon-api-7.1.1');
const exportedTempNodeAddonApiDir = path.join(rebuildRootPath, 'node_modules', '.openchamber-node-addon-api-7.1.1');
const localNodeAddonApiDir = path.join(nodePtyDir, 'node_modules', 'node-addon-api');
await fsp.rm(tempNodeAddonApiDir, { recursive: true, force: true });
await copyDirectory(rootNodeAddonApiDir, tempNodeAddonApiDir);
await fsp.access(path.join(tempNodeAddonApiDir, 'package.json'));
await fsp.rm(localNodeAddonApiDir, { recursive: true, force: true });
await copyDirectory(rootNodeAddonApiDir, localNodeAddonApiDir);
await writeWindowsNodeAddonApiIndex(localNodeAddonApiDir, exportedTempNodeAddonApiDir);
await fsp.access(path.join(localNodeAddonApiDir, 'package.json'));
return async () => {
await fsp.rm(localNodeAddonApiDir, { recursive: true, force: true });
await fsp.rm(tempNodeAddonApiDir, { recursive: true, force: true });
};
};
console.log(`[electron] rebuilding native modules against Electron ${electronVersion}...`); console.log(`[electron] rebuilding native modules against Electron ${electronVersion}...`);
// Rebuild against the hoisted root node_modules (bun workspace layout). // Rebuild against the hoisted root node_modules (bun workspace layout).
// force=true re-links regardless of cached state; prebuild-install lookup is // force=true re-links regardless of cached state; prebuild-install lookup is
// bypassed by @electron/rebuild in favor of direct node-gyp builds. // bypassed by @electron/rebuild in favor of direct node-gyp builds.
await rebuild({ const rebuildPath = createWindowsRebuildPath(repoRoot);
buildPath: repoRoot, let cleanupNodeAddonApi = async () => {};
electronVersion, try {
force: true, cleanupNodeAddonApi = await ensureWindowsNodeAddonApiForNodePty(rebuildPath.buildPath);
arch: process.env.ELECTRON_BUILDER_ARCH || process.arch, await rebuild({
onlyModules: ['better-sqlite3', 'node-pty', 'bun-pty'], buildPath: rebuildPath.buildPath,
}); electronVersion,
force: true,
arch: process.env.ELECTRON_BUILDER_ARCH || process.arch,
onlyModules: ['better-sqlite3', 'node-pty', 'bun-pty'],
});
} finally {
try {
await cleanupNodeAddonApi();
} finally {
rebuildPath.cleanup();
}
}
console.log('[electron] native modules rebuilt successfully'); console.log('[electron] native modules rebuilt successfully');
+1
View File
@@ -45,6 +45,7 @@
"@simplewebauthn/browser": "13.3.0", "@simplewebauthn/browser": "13.3.0",
"@tanstack/react-virtual": "^3.13.18", "@tanstack/react-virtual": "^3.13.18",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@xenova/transformers": "^2.17.2",
"beautiful-mermaid": "^1.1.3", "beautiful-mermaid": "^1.1.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -55,29 +55,39 @@ const submitPassword = async (password: string, trustDevice: boolean): Promise<R
return response; return response;
}; };
const AuthShell: React.FC<{ children: React.ReactNode }> = ({ children }) => ( const AuthShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
<div const titlebarDragStyle = React.useMemo<React.CSSProperties>(() => {
className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background text-foreground" return {
style={{ fontFamily: '"Inter", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif' }} height: 'var(--oc-wco-titlebar-height, 0px)',
> right: 'var(--oc-wco-right-inset, 0px)',
};
}, []);
return (
<div <div
className="pointer-events-none absolute inset-0 opacity-55" className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background text-foreground"
style={{ style={{ fontFamily: '"Inter", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif' }}
background: 'radial-gradient(120% 140% at 50% -20%, var(--surface-overlay) 0%, transparent 68%)', >
}} <div className="app-region-drag fixed left-0 top-0 z-20" style={titlebarDragStyle} aria-hidden />
/> <div
<div className="pointer-events-none absolute inset-0 opacity-55"
className="pointer-events-none absolute inset-0" style={{
style={{ background: 'radial-gradient(120% 140% at 50% -20%, var(--surface-overlay) 0%, transparent 68%)',
backgroundColor: 'var(--surface-subtle)', }}
opacity: 0.22, />
}} <div
/> className="pointer-events-none absolute inset-0"
<div className="relative z-10 flex w-full justify-center px-4 py-12 sm:px-6"> style={{
{children} backgroundColor: 'var(--surface-subtle)',
opacity: 0.22,
}}
/>
<div className="app-region-no-drag relative z-10 flex w-full justify-center px-4 py-12 sm:px-6">
{children}
</div>
</div> </div>
</div> );
); };
const LoadingScreen: React.FC = () => ( const LoadingScreen: React.FC = () => (
<div className="flex min-h-screen items-center justify-center bg-background text-foreground"> <div className="flex min-h-screen items-center justify-center bg-background text-foreground">
@@ -491,7 +491,6 @@ function SessionItem({
title={`Sub-session: ${getSessionTitle(child)}`} title={`Sub-session: ${getSessionTitle(child)}`}
> >
<Icon name="loader-4" className="h-2.5 w-2.5 animate-spin" <Icon name="loader-4" className="h-2.5 w-2.5 animate-spin"
style={{ color: `var(${childColor.var})` }}/> style={{ color: `var(${childColor.var})` }}/>
</div> </div>
); );
@@ -692,7 +691,6 @@ function SessionStatusHeader({
title={`Sub-session: ${child.session.title || 'Untitled'}`} title={`Sub-session: ${child.session.title || 'Untitled'}`}
> >
<Icon name="loader-4" className="h-2.5 w-2.5 animate-spin" <Icon name="loader-4" className="h-2.5 w-2.5 animate-spin"
style={{ color: `var(${childColor.var})` }}/> style={{ color: `var(${childColor.var})` }}/>
</div> </div>
); );
@@ -39,7 +39,6 @@ import { useI18n } from '@/lib/i18n';
import { useOpenCodeReadiness } from '@/hooks/useOpenCodeReadiness'; import { useOpenCodeReadiness } from '@/hooks/useOpenCodeReadiness';
import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts'; import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts';
type IconComponent = IconName; type IconComponent = IconName;
type ProviderModel = Record<string, unknown> & { id?: string; name?: string }; type ProviderModel = Record<string, unknown> & { id?: string; name?: string };
@@ -39,7 +39,6 @@ const MessageHeader: React.FC<MessageHeaderProps> = ({ isUser, providerID, agent
/> />
) : ( ) : (
<Icon name="brain-ai-3" className="h-4 w-4" <Icon name="brain-ai-3" className="h-4 w-4"
style={{ color: `var(${getAgentColor(agentName).var})` }}/> style={{ color: `var(${getAgentColor(agentName).var})` }}/>
)} )}
</div> </div>
@@ -729,7 +729,6 @@ const ToolScrollableSection: React.FC<ToolScrollableSectionProps> = ({
disableHorizontal ? 'overflow-y-auto overflow-x-hidden' : 'overflow-auto', disableHorizontal ? 'overflow-y-auto overflow-x-hidden' : 'overflow-auto',
className, className,
)} )}
> >
<div className="w-full min-w-0"> <div className="w-full min-w-0">
{children} {children}
@@ -153,6 +153,7 @@ export const iconSpriteData = {
"lock-unlock": `<path d="M7 10H20C20.5523 10 21 10.4477 21 11V21C21 21.5523 20.5523 22 20 22H4C3.44772 22 3 21.5523 3 21V11C3 10.4477 3.44772 10 4 10H5V9C5 5.13401 8.13401 2 12 2C14.7405 2 17.1131 3.5748 18.2624 5.86882L16.4731 6.76344C15.6522 5.12486 13.9575 4 12 4C9.23858 4 7 6.23858 7 9V10ZM5 12V20H19V12H5ZM10 15H14V17H10V15Z" fill="currentColor"/>`, "lock-unlock": `<path d="M7 10H20C20.5523 10 21 10.4477 21 11V21C21 21.5523 20.5523 22 20 22H4C3.44772 22 3 21.5523 3 21V11C3 10.4477 3.44772 10 4 10H5V9C5 5.13401 8.13401 2 12 2C14.7405 2 17.1131 3.5748 18.2624 5.86882L16.4731 6.76344C15.6522 5.12486 13.9575 4 12 4C9.23858 4 7 6.23858 7 9V10ZM5 12V20H19V12H5ZM10 15H14V17H10V15Z" fill="currentColor"/>`,
"loop-right-ai": `<path d="M22 12C22 17.5228 17.5228 22 12 22C8.72774 22 5.82382 20.4286 4 18.001V20.5H2V14.5H8V16.5H5.38477C6.82543 18.6137 9.25151 20 12 20C16.4183 20 20 16.4183 20 12H22ZM11.5293 8.31934C11.7059 7.8935 12.2943 7.89349 12.4707 8.31934L12.7236 8.93066C13.1556 9.97346 13.9615 10.8062 14.9746 11.2568L15.6924 11.5762C16.1026 11.759 16.1026 12.3562 15.6924 12.5391L14.9326 12.877C13.9449 13.3162 13.1534 14.1194 12.7139 15.1279L12.4668 15.6934C12.2864 16.1075 11.7137 16.1075 11.5332 15.6934L11.2871 15.1279C10.8476 14.1193 10.0552 13.3163 9.06738 12.877L8.30762 12.5391C7.89744 12.3562 7.89741 11.759 8.30762 11.5762L9.02539 11.2568C10.0385 10.8062 10.8445 9.97348 11.2764 8.93066L11.5293 8.31934ZM12 2C15.2723 2 18.1762 3.57144 20 5.99902V3.5H22V9.5H16V7.5H18.6152C17.1746 5.38634 14.7485 4 12 4C7.58172 4 4 7.58172 4 12H2C2 6.47715 6.47715 2 12 2Z" fill="currentColor"/>`, "loop-right-ai": `<path d="M22 12C22 17.5228 17.5228 22 12 22C8.72774 22 5.82382 20.4286 4 18.001V20.5H2V14.5H8V16.5H5.38477C6.82543 18.6137 9.25151 20 12 20C16.4183 20 20 16.4183 20 12H22ZM11.5293 8.31934C11.7059 7.8935 12.2943 7.89349 12.4707 8.31934L12.7236 8.93066C13.1556 9.97346 13.9615 10.8062 14.9746 11.2568L15.6924 11.5762C16.1026 11.759 16.1026 12.3562 15.6924 12.5391L14.9326 12.877C13.9449 13.3162 13.1534 14.1194 12.7139 15.1279L12.4668 15.6934C12.2864 16.1075 11.7137 16.1075 11.5332 15.6934L11.2871 15.1279C10.8476 14.1193 10.0552 13.3163 9.06738 12.877L8.30762 12.5391C7.89744 12.3562 7.89741 11.759 8.30762 11.5762L9.02539 11.2568C10.0385 10.8062 10.8445 9.97348 11.2764 8.93066L11.5293 8.31934ZM12 2C15.2723 2 18.1762 3.57144 20 5.99902V3.5H22V9.5H16V7.5H18.6152C17.1746 5.38634 14.7485 4 12 4C7.58172 4 4 7.58172 4 12H2C2 6.47715 6.47715 2 12 2Z" fill="currentColor"/>`,
"macbook": `<path d="M4 5V16H20V5H4ZM2 4.00748C2 3.45107 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44892 22 4.00748V18H2V4.00748ZM1 19H23V21H1V19Z" fill="currentColor"/>`, "macbook": `<path d="M4 5V16H20V5H4ZM2 4.00748C2 3.45107 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44892 22 4.00748V18H2V4.00748ZM1 19H23V21H1V19Z" fill="currentColor"/>`,
"menu-2": `<path d="M3 4H21V6H3V4ZM3 11H15V13H3V11ZM3 18H21V20H3V18Z" fill="currentColor"/>`,
"menu-fold-2": `<path d="M4.40347 3.90332L2.98926 5.31753L6.17124 8.49951L2.98926 11.6815L4.40347 13.0957L8.99967 8.49951L4.40347 3.90332ZM20.9997 19.9995V17.9995H2.99967V19.9995H20.9997ZM20.9997 12.9995V10.9995H11.9997V12.9995H20.9997ZM20.9997 5.99951V3.99951H11.9997V5.99951H20.9997Z" fill="currentColor"/>`, "menu-fold-2": `<path d="M4.40347 3.90332L2.98926 5.31753L6.17124 8.49951L2.98926 11.6815L4.40347 13.0957L8.99967 8.49951L4.40347 3.90332ZM20.9997 19.9995V17.9995H2.99967V19.9995H20.9997ZM20.9997 12.9995V10.9995H11.9997V12.9995H20.9997ZM20.9997 5.99951V3.99951H11.9997V5.99951H20.9997Z" fill="currentColor"/>`,
"menu-search": `<path d="M15.5 5C13.567 5 12 6.567 12 8.5C12 10.433 13.567 12 15.5 12C17.433 12 19 10.433 19 8.5C19 6.567 17.433 5 15.5 5ZM10 8.5C10 5.46243 12.4624 3 15.5 3C18.5376 3 21 5.46243 21 8.5C21 9.6575 20.6424 10.7315 20.0317 11.6175L22.7071 14.2929L21.2929 15.7071L18.6175 13.0317C17.7315 13.6424 16.6575 14 15.5 14C12.4624 14 10 11.5376 10 8.5ZM3 4H8V6H3V4ZM3 11H8V13H3V11ZM21 18V20H3V18H21Z" fill="currentColor"/>`, "menu-search": `<path d="M15.5 5C13.567 5 12 6.567 12 8.5C12 10.433 13.567 12 15.5 12C17.433 12 19 10.433 19 8.5C19 6.567 17.433 5 15.5 5ZM10 8.5C10 5.46243 12.4624 3 15.5 3C18.5376 3 21 5.46243 21 8.5C21 9.6575 20.6424 10.7315 20.0317 11.6175L22.7071 14.2929L21.2929 15.7071L18.6175 13.0317C17.7315 13.6424 16.6575 14 15.5 14C12.4624 14 10 11.5376 10 8.5ZM3 4H8V6H3V4ZM3 11H8V13H3V11ZM21 18V20H3V18H21Z" fill="currentColor"/>`,
"message-2": `<path d="M6.45455 19L2 22.5V4C2 3.44772 2.44772 3 3 3H21C21.5523 3 22 3.44772 22 4V18C22 18.5523 21.5523 19 21 19H6.45455ZM5.76282 17H20V5H4V18.3851L5.76282 17ZM11 10H13V12H11V10ZM7 10H9V12H7V10ZM15 10H17V12H15V10Z" fill="currentColor"/>`, "message-2": `<path d="M6.45455 19L2 22.5V4C2 3.44772 2.44772 3 3 3H21C21.5523 3 22 3.44772 22 4V18C22 18.5523 21.5523 19 21 19H6.45455ZM5.76282 17H20V5H4V18.3851L5.76282 17ZM11 10H13V12H11V10ZM7 10H9V12H7V10ZM15 10H17V12H15V10Z" fill="currentColor"/>`,
+107 -3
View File
@@ -77,7 +77,7 @@ type HeaderIconActionButtonProps = {
visible?: boolean; visible?: boolean;
title: string; title: string;
ariaLabel: string; ariaLabel: string;
onClick: () => void; onClick: React.MouseEventHandler<HTMLButtonElement>;
className?: string; className?: string;
Icon: IconName; Icon: IconName;
iconClassName?: string; iconClassName?: string;
@@ -115,6 +115,83 @@ const HeaderIconActionButton = React.memo(function HeaderIconActionButton({
); );
}); });
type WindowsWindowControlsProps = {
visible: boolean;
};
const WindowsWindowControls = React.memo(function WindowsWindowControls({ visible }: WindowsWindowControlsProps) {
const { t } = useI18n();
const [isMaximized, setIsMaximized] = React.useState(false);
useEffect(() => {
if (!visible) {
return;
}
let disposed = false;
void invokeDesktop<{ maximized?: boolean }>('desktop_get_current_window_state')
.then((state) => {
if (!disposed) {
setIsMaximized(Boolean(state?.maximized));
}
})
.catch(() => {});
const handleMaximizedChange = (event: Event) => {
const detail = (event as CustomEvent<{ maximized?: boolean }>).detail;
setIsMaximized(Boolean(detail?.maximized));
};
window.addEventListener('openchamber:window-maximized-changed', handleMaximizedChange);
return () => {
disposed = true;
window.removeEventListener('openchamber:window-maximized-changed', handleMaximizedChange);
};
}, [visible]);
if (!visible) {
return null;
}
const buttonClassName = 'app-region-no-drag inline-flex h-12 w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary';
return (
<div className="app-region-no-drag -mr-3 ml-2 flex h-12 shrink-0 items-center" aria-label={t('header.windowControls.groupAria')}>
<button
type="button"
className={buttonClassName}
onClick={() => { void invokeDesktop('desktop_minimize_current_window'); }}
title={t('header.windowControls.minimize')}
aria-label={t('header.windowControls.minimize')}
>
<Icon name="subtract" className="h-4 w-4" />
</button>
<button
type="button"
className={buttonClassName}
onClick={() => {
void invokeDesktop<{ maximized?: boolean }>('desktop_toggle_current_window_maximized')
.then((state) => setIsMaximized(Boolean(state?.maximized)))
.catch(() => {});
}}
title={isMaximized ? t('header.windowControls.restore') : t('header.windowControls.maximize')}
aria-label={isMaximized ? t('header.windowControls.restore') : t('header.windowControls.maximize')}
>
<Icon name={isMaximized ? 'fullscreen-exit' : 'checkbox-blank'} className="h-3.5 w-3.5" />
</button>
<button
type="button"
className={cn(buttonClassName, 'hover:bg-status-error hover:text-status-error-foreground')}
onClick={() => { void invokeDesktop('desktop_close_current_window'); }}
title={t('header.windowControls.close')}
aria-label={t('header.windowControls.close')}
>
<Icon name="close" className="h-4 w-4" />
</button>
</div>
);
});
type DesktopGitHubControlProps = { type DesktopGitHubControlProps = {
isMobile: boolean; isMobile: boolean;
githubAuthStatus: GitHubAuthStatus | null; githubAuthStatus: GitHubAuthStatus | null;
@@ -731,6 +808,13 @@ export const Header: React.FC<HeaderProps> = ({
return /Macintosh|Mac OS X/.test(navigator.userAgent || ''); return /Macintosh|Mac OS X/.test(navigator.userAgent || '');
}, []); }, []);
const isWindowsElectronDesktop = React.useMemo(() => {
if (typeof window === 'undefined') {
return false;
}
return Boolean(window.__OPENCHAMBER_ELECTRON__) && window.__OPENCHAMBER_PLATFORM__ === 'win32';
}, []);
const macosMajorVersion = React.useMemo(() => { const macosMajorVersion = React.useMemo(() => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return null; return null;
@@ -1262,6 +1346,16 @@ export const Header: React.FC<HeaderProps> = ({
toggleSidebar(); toggleSidebar();
}, [blurActiveElement, isMobile, isSessionSwitcherOpen, setSessionSwitcherOpen, toggleSidebar]); }, [blurActiveElement, isMobile, isSessionSwitcherOpen, setSessionSwitcherOpen, toggleSidebar]);
const handleOpenWindowsAppMenu = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
void invokeDesktop('desktop_show_app_menu', {
x: rect.left,
y: rect.bottom,
}).catch((error) => {
console.warn('[header] failed to open app menu', error);
});
}, []);
const handleOpenDraftMiniChat = React.useCallback(() => { const handleOpenDraftMiniChat = React.useCallback(() => {
void invokeDesktop('desktop_open_draft_mini_chat_window', { void invokeDesktop('desktop_open_draft_mini_chat_window', {
directory: normalize(openDirectory || activeProject?.path || ''), directory: normalize(openDirectory || activeProject?.path || ''),
@@ -1454,7 +1548,7 @@ export const Header: React.FC<HeaderProps> = ({
}, [isDesktopApp, isMacPlatform, macosMajorVersion]); }, [isDesktopApp, isMacPlatform, macosMajorVersion]);
const webWindowControlsOverlayStyle = React.useMemo<React.CSSProperties | undefined>(() => { const webWindowControlsOverlayStyle = React.useMemo<React.CSSProperties | undefined>(() => {
if (isDesktopApp || isVSCode) { if ((isDesktopApp && !isWindowsElectronDesktop) || isVSCode) {
return undefined; return undefined;
} }
@@ -1466,7 +1560,7 @@ export const Header: React.FC<HeaderProps> = ({
minHeight: 'max(3rem, var(--oc-wco-titlebar-height, 0px))', minHeight: 'max(3rem, var(--oc-wco-titlebar-height, 0px))',
height: 'max(3rem, var(--oc-wco-titlebar-height, 0px))', height: 'max(3rem, var(--oc-wco-titlebar-height, 0px))',
}; };
}, [isDesktopApp, isTabletStandalonePwa, isVSCode]); }, [isDesktopApp, isTabletStandalonePwa, isVSCode, isWindowsElectronDesktop]);
const updateHeaderHeight = React.useCallback(() => { const updateHeaderHeight = React.useCallback(() => {
if (typeof document === 'undefined') { if (typeof document === 'undefined') {
@@ -1884,6 +1978,15 @@ export const Header: React.FC<HeaderProps> = ({
role="tablist" role="tablist"
aria-label={t('header.navigation.mainAria')} aria-label={t('header.navigation.mainAria')}
> >
{isWindowsElectronDesktop ? (
<HeaderIconActionButton
title={t('header.actions.openAppMenu')}
ariaLabel={t('header.actions.openAppMenuAria')}
onClick={handleOpenWindowsAppMenu}
className={`${desktopHeaderIconButtonClass} shrink-0`}
Icon={'menu-2'}
/>
) : null}
<HeaderIconActionButton <HeaderIconActionButton
title={t('header.actions.openSessionsWithShortcut', { shortcut: shortcutLabel('toggle_sidebar') })} title={t('header.actions.openSessionsWithShortcut', { shortcut: shortcutLabel('toggle_sidebar') })}
ariaLabel={t('header.actions.openSessionsAria')} ariaLabel={t('header.actions.openSessionsAria')}
@@ -1974,6 +2077,7 @@ export const Header: React.FC<HeaderProps> = ({
Icon={'picture-in-picture-2'} Icon={'picture-in-picture-2'}
/> />
{desktopSidebarActions} {desktopSidebarActions}
<WindowsWindowControls visible={isWindowsElectronDesktop} />
</div> </div>
</div> </div>
</div> </div>
@@ -319,7 +319,7 @@ const FileRow: React.FC<FileRowProps> = ({
export const SidebarFilesTree: React.FC = () => { export const SidebarFilesTree: React.FC = () => {
const { t } = useI18n(); const { t } = useI18n();
const { files, runtime } = useRuntimeAPIs(); const { files } = useRuntimeAPIs();
const currentDirectory = useEffectiveDirectory() ?? ''; const currentDirectory = useEffectiveDirectory() ?? '';
const root = normalizePath(currentDirectory.trim()); const root = normalizePath(currentDirectory.trim());
const showHidden = useDirectoryShowHidden(); const showHidden = useDirectoryShowHidden();
@@ -335,6 +335,7 @@ export const SidebarFilesTree: React.FC = () => {
const [searching, setSearching] = React.useState(false); const [searching, setSearching] = React.useState(false);
const [childrenByDir, setChildrenByDir] = React.useState<Record<string, FileNode[]>>({}); const [childrenByDir, setChildrenByDir] = React.useState<Record<string, FileNode[]>>({});
const [loadErrorsByDir, setLoadErrorsByDir] = React.useState<Record<string, string>>({});
const loadedDirsRef = React.useRef<Set<string>>(new Set()); const loadedDirsRef = React.useRef<Set<string>>(new Set());
const inFlightDirsRef = React.useRef<Set<string>>(new Set()); const inFlightDirsRef = React.useRef<Set<string>>(new Set());
@@ -419,7 +420,7 @@ export const SidebarFilesTree: React.FC = () => {
inFlightDirsRef.current.add(normalizedDir); inFlightDirsRef.current.add(normalizedDir);
const respectGitignore = !showGitignored; const respectGitignore = !showGitignored;
const listPromise = runtime.isDesktop const listPromise = files.listDirectory
? files.listDirectory(normalizedDir, { respectGitignore }).then((result) => result.entries.map((entry) => ({ ? files.listDirectory(normalizedDir, { respectGitignore }).then((result) => result.entries.map((entry) => ({
name: entry.name, name: entry.name,
path: entry.path, path: entry.path,
@@ -437,25 +438,34 @@ export const SidebarFilesTree: React.FC = () => {
loadedDirsRef.current = new Set(loadedDirsRef.current); loadedDirsRef.current = new Set(loadedDirsRef.current);
loadedDirsRef.current.add(normalizedDir); loadedDirsRef.current.add(normalizedDir);
setLoadErrorsByDir((prev) => {
if (!prev[normalizedDir]) return prev;
const next = { ...prev };
delete next[normalizedDir];
return next;
});
setChildrenByDir((prev) => ({ ...prev, [normalizedDir]: mapped })); setChildrenByDir((prev) => ({ ...prev, [normalizedDir]: mapped }));
}) })
.catch(() => { .catch((error) => {
setChildrenByDir((prev) => ({ const message = error instanceof Error ? error.message : String(error ?? '');
console.error('Failed to load sidebar directory:', error);
setLoadErrorsByDir((prev) => ({
...prev, ...prev,
[normalizedDir]: prev[normalizedDir] ?? [], [normalizedDir]: message,
})); }));
}) })
.finally(() => { .finally(() => {
inFlightDirsRef.current = new Set(inFlightDirsRef.current); inFlightDirsRef.current = new Set(inFlightDirsRef.current);
inFlightDirsRef.current.delete(normalizedDir); inFlightDirsRef.current.delete(normalizedDir);
}); });
}, [files, mapDirectoryEntries, runtime.isDesktop, showGitignored]); }, [files, mapDirectoryEntries, showGitignored]);
const refreshRoot = React.useCallback(async () => { const refreshRoot = React.useCallback(async () => {
if (!root) return; if (!root) return;
loadedDirsRef.current = new Set(); loadedDirsRef.current = new Set();
inFlightDirsRef.current = new Set(); inFlightDirsRef.current = new Set();
setLoadErrorsByDir({});
setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {})); setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {}));
await loadDirectory(root); await loadDirectory(root);
@@ -484,6 +494,7 @@ export const SidebarFilesTree: React.FC = () => {
loadedDirsRef.current = new Set(); loadedDirsRef.current = new Set();
inFlightDirsRef.current = new Set(); inFlightDirsRef.current = new Set();
setLoadErrorsByDir({});
setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {})); setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {}));
void loadDirectory(root); void loadDirectory(root);
}, [loadDirectory, root, showHidden, showGitignored]); }, [loadDirectory, root, showHidden, showGitignored]);
@@ -807,6 +818,7 @@ export const SidebarFilesTree: React.FC = () => {
} }
const hasTree = Boolean(root && childrenByDir[root]); const hasTree = Boolean(root && childrenByDir[root]);
const rootLoadError = root ? loadErrorsByDir[root] : null;
return ( return (
<section className="flex h-full min-h-0 flex-col overflow-hidden bg-sidebar"> <section className="flex h-full min-h-0 flex-col overflow-hidden bg-sidebar">
@@ -923,6 +935,14 @@ export const SidebarFilesTree: React.FC = () => {
</li> </li>
); );
}) })
) : rootLoadError ? (
<li className="flex flex-col gap-2 px-2 py-1 typography-meta text-muted-foreground">
<span>{rootLoadError}</span>
<Button variant="outline" size="xs" className="w-fit gap-1.5" onClick={() => void refreshRoot()}>
<Icon name="refresh" className="h-3.5 w-3.5" />
{t('sidebarFilesTree.actions.refreshTitle')}
</Button>
</li>
) : hasTree && root ? ( ) : hasTree && root ? (
renderTree(root, 0) renderTree(root, 0)
) : ( ) : (
@@ -777,7 +777,6 @@ export function SessionGroupSection(props: Props): React.ReactNode {
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center"> <span className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
<Icon name="git-branch" className="h-3.5 w-3.5 text-muted-foreground" <Icon name="git-branch" className="h-3.5 w-3.5 text-muted-foreground"
style={branchIconColor ? { color: branchIconColor } : undefined}/> style={branchIconColor ? { color: branchIconColor } : undefined}/>
</span> </span>
</TooltipTrigger> </TooltipTrigger>
@@ -810,7 +809,6 @@ export function SessionGroupSection(props: Props): React.ReactNode {
</Tooltip> </Tooltip>
) : ( ) : (
<Icon name="git-branch" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" <Icon name="git-branch" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground"
style={branchIconColor ? { color: branchIconColor } : undefined}/> style={branchIconColor ? { color: branchIconColor } : undefined}/>
) )
) : null} ) : null}
+38 -8
View File
@@ -290,6 +290,32 @@ const isFileMissingError = (error: unknown): boolean => {
const MAX_VIEW_CHARS = 200_000; const MAX_VIEW_CHARS = 200_000;
const FILE_EDITOR_AUTO_SAVE_KEY = 'openchamber:files:auto-save-enabled'; const FILE_EDITOR_AUTO_SAVE_KEY = 'openchamber:files:auto-save-enabled';
type FileLineEnding = '\n' | '\r\n';
const detectFileLineEnding = (content: string): FileLineEnding => {
let crlf = 0;
let lf = 0;
for (let index = 0; index < content.length; index += 1) {
if (content.charCodeAt(index) !== 10) {
continue;
}
if (index > 0 && content.charCodeAt(index - 1) === 13) {
crlf += 1;
} else {
lf += 1;
}
}
return crlf > lf ? '\r\n' : '\n';
};
const normalizeEditorLineEndings = (content: string): string => content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const serializeEditorContent = (content: string, lineEnding: FileLineEnding): string => {
const normalized = normalizeEditorLineEndings(content);
return lineEnding === '\r\n' ? normalized.replace(/\n/g, '\r\n') : normalized;
};
const getInitialAutoSaveEnabled = (): boolean => { const getInitialAutoSaveEnabled = (): boolean => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -763,6 +789,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const [draftContent, setDraftContent] = React.useState(''); const [draftContent, setDraftContent] = React.useState('');
const [isSaving, setIsSaving] = React.useState(false); const [isSaving, setIsSaving] = React.useState(false);
const [loadedFileLineEnding, setLoadedFileLineEnding] = React.useState<FileLineEnding>('\n');
const dialogInputRef = React.useRef<HTMLInputElement>(null); const dialogInputRef = React.useRef<HTMLInputElement>(null);
const autoSaveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null); const autoSaveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const lastLoadedFileStatRef = React.useRef<FileStatSnapshot | null>(null); const lastLoadedFileStatRef = React.useRef<FileStatSnapshot | null>(null);
@@ -1007,7 +1034,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const isCurrentRequest = () => activeDirectoryLoadIdsRef.current.get(normalizedDir) === requestId; const isCurrentRequest = () => activeDirectoryLoadIdsRef.current.get(normalizedDir) === requestId;
const respectGitignore = !showGitignored; const respectGitignore = !showGitignored;
const listPromise = runtime.isDesktop const listPromise = files.listDirectory
? files.listDirectory(normalizedDir, { respectGitignore }).then((result) => result.entries.map((entry) => ({ ? files.listDirectory(normalizedDir, { respectGitignore }).then((result) => result.entries.map((entry) => ({
name: entry.name, name: entry.name,
path: entry.path, path: entry.path,
@@ -1051,7 +1078,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
inFlightDirsRef.current = new Set(inFlightDirsRef.current); inFlightDirsRef.current = new Set(inFlightDirsRef.current);
inFlightDirsRef.current.delete(normalizedDir); inFlightDirsRef.current.delete(normalizedDir);
}); });
}, [files, mapDirectoryEntries, runtime.isDesktop, showGitignored]); }, [files, mapDirectoryEntries, showGitignored]);
const refreshRoot = React.useCallback(async () => { const refreshRoot = React.useCallback(async () => {
if (!root) { if (!root) {
@@ -1446,7 +1473,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
setIsSaving(true); setIsSaving(true);
try { try {
const result = await files.writeFile(selectedFile.path, draftContent); const contentToWrite = serializeEditorContent(draftContent, loadedFileLineEnding);
const result = await files.writeFile(selectedFile.path, contentToWrite);
if (!result?.success) { if (!result?.success) {
toast.error(t('filesView.toast.writeFileFailed')); toast.error(t('filesView.toast.writeFileFailed'));
return false; return false;
@@ -1467,7 +1495,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}, [draftContent, files, isDirty, readFileStat, selectedFile, t]); }, [draftContent, files, isDirty, loadedFileLineEnding, readFileStat, selectedFile, t]);
React.useEffect(() => { React.useEffect(() => {
if (!isDirty) { if (!isDirty) {
@@ -1622,10 +1650,12 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
if (!isCurrentLoad()) { if (!isCurrentLoad()) {
return; return;
} }
setFileContent(content); const editorContent = normalizeEditorLineEndings(content);
setDraftContent(content.length > MAX_VIEW_CHARS setLoadedFileLineEnding(detectFileLineEnding(content));
? `${content.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …` setFileContent(editorContent);
: content); setDraftContent(editorContent.length > MAX_VIEW_CHARS
? `${editorContent.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …`
: editorContent);
setLoadedFilePath(node.path); setLoadedFilePath(node.path);
void readFileStat(node.path, readOptions) void readFileStat(node.path, readOptions)
.then((stat) => { .then((stat) => {
+14 -54
View File
@@ -1001,6 +1001,14 @@ export const GitView: React.FC = () => {
}; };
}, [changeEntries, currentDirectory, git, prefetchDiffs, stagedChangeEntries, visibleChangePaths]); }, [changeEntries, currentDirectory, git, prefetchDiffs, stagedChangeEntries, visibleChangePaths]);
const getPushedRemoteName = (result?: Awaited<ReturnType<typeof git.gitPush>>) => {
return result?.pushed[0]?.remote
|| status?.tracking?.split('/')[0]
|| effectiveRemotes.find((remote) => remote.name === 'origin')?.name
|| effectiveRemotes[0]?.name
|| 'origin';
};
const handleSyncAction = async (action: Exclude<SyncAction, null>, remote?: GitRemote) => { const handleSyncAction = async (action: Exclude<SyncAction, null>, remote?: GitRemote) => {
if (!currentDirectory) return; if (!currentDirectory) return;
setSyncAction(action); setSyncAction(action);
@@ -1035,8 +1043,8 @@ export const GitView: React.FC = () => {
: t('gitView.toast.pulledFilesPlural', { count: result.files.length, name: remote.name }) : t('gitView.toast.pulledFilesPlural', { count: result.files.length, name: remote.name })
); );
} else if (action === 'push') { } else if (action === 'push') {
await git.gitPush(currentDirectory); const result = await git.gitPush(currentDirectory);
toast.success(t('gitView.toast.pushedToUpstream')); toast.success(t('gitView.toast.pushedToUpstream', { name: getPushedRemoteName(result) }));
} else if (action === 'sync') { } else if (action === 'sync') {
if (!remote) { if (!remote) {
throw new Error('No remote available for sync'); throw new Error('No remote available for sync');
@@ -1073,7 +1081,7 @@ export const GitView: React.FC = () => {
: t('gitView.toast.pulledFilesPlural', { count: pulledFileCount, name: remote.name }) : t('gitView.toast.pulledFilesPlural', { count: pulledFileCount, name: remote.name })
); );
} else if (pushedChanges) { } else if (pushedChanges) {
toast.success(t('gitView.toast.pushedToUpstream')); toast.success(t('gitView.toast.pushedToUpstream', { name: remote.name }));
} else { } else {
toast.success(t('gitView.toast.alreadyUpToDate')); toast.success(t('gitView.toast.alreadyUpToDate'));
} }
@@ -1150,56 +1158,8 @@ export const GitView: React.FC = () => {
await refreshStatusAndBranches(); await refreshStatusAndBranches();
if (options.pushAfter) { if (options.pushAfter) {
setSyncAction('sync'); const result = await git.gitPush(currentDirectory);
const trackingRemoteName = status?.tracking?.split('/')[0]; toast.success(t('gitView.toast.pushedToUpstream', { name: getPushedRemoteName(result) }));
const syncRemote = effectiveRemotes.find((remote) => remote.name === trackingRemoteName) ?? effectiveRemotes[0];
if (!syncRemote) {
throw new Error('No remote available for sync');
}
const trackingPrefix = `${syncRemote.name}/`;
const trackedBranch = status?.tracking?.startsWith(trackingPrefix)
? status.tracking.slice(trackingPrefix.length)
: undefined;
let pulledFileCount = 0;
let pushedChanges = false;
await git.gitFetch(currentDirectory, { remote: syncRemote.name });
const afterFetch = await git.getGitStatus(currentDirectory);
if ((afterFetch.behind ?? 0) > 0) {
const pullResult = await git.gitPull(currentDirectory, {
remote: syncRemote.name,
branch: trackedBranch,
rebase: true,
});
pulledFileCount = pullResult.files.length;
}
const afterPull = await git.getGitStatus(currentDirectory);
if ((afterPull.ahead ?? 0) > 0) {
await git.gitPush(currentDirectory);
pushedChanges = true;
}
if (pulledFileCount > 0 && pushedChanges) {
toast.success(
pulledFileCount === 1
? t('gitView.toast.syncedPulledSingleAndPushed', { count: pulledFileCount, name: syncRemote.name })
: t('gitView.toast.syncedPulledPluralAndPushed', { count: pulledFileCount, name: syncRemote.name })
);
} else if (pulledFileCount > 0) {
toast.success(
pulledFileCount === 1
? t('gitView.toast.pulledFilesSingle', { count: pulledFileCount, name: syncRemote.name })
: t('gitView.toast.pulledFilesPlural', { count: pulledFileCount, name: syncRemote.name })
);
} else if (pushedChanges) {
toast.success(t('gitView.toast.pushedToUpstream'));
} else {
toast.success(t('gitView.toast.alreadyUpToDate'));
}
triggerFireworks(); triggerFireworks();
await refreshStatusAndBranches(false); await refreshStatusAndBranches(false);
} else { } else {
@@ -2257,7 +2217,7 @@ export const GitView: React.FC = () => {
); );
} }
if (isLoading && isGitRepo === null) { if (isGitRepo === null || (isGitRepo === true && !status)) {
return ( return (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground">
@@ -13,7 +13,12 @@ import type { IconName } from "@/components/icon/icons";
import { BranchSelector } from './BranchSelector'; import { BranchSelector } from './BranchSelector';
import { WorktreeBranchDisplay } from './WorktreeBranchDisplay'; import { WorktreeBranchDisplay } from './WorktreeBranchDisplay';
import { SyncActions } from './SyncActions'; import { SyncActions } from './SyncActions';
import type { GitStatus, GitIdentityProfile, GitRemote } from '@/lib/api/types'; import type {
GitStatus,
GitIdentityProfile,
GitRemote,
GitRemoteComparison,
} from '@/lib/api/types';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null; type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null;
@@ -178,6 +183,49 @@ export const IdentityDropdown: React.FC<IdentityDropdownProps> = ({
); );
}; };
interface UpstreamStatusPillProps {
comparison: GitRemoteComparison;
trackingBranch: string | null;
tooltipDelayMs?: number;
}
const UpstreamStatusPill: React.FC<UpstreamStatusPillProps> = ({
comparison,
trackingBranch,
tooltipDelayMs = 1000,
}) => {
const { t } = useI18n();
const target = `${comparison.remote}/${comparison.branch}`;
const isSynced = comparison.ahead === 0 && comparison.behind === 0;
const tooltipText = trackingBranch
? t('gitView.header.upstreamTooltipTracking', { target, tracking: trackingBranch })
: t('gitView.header.upstreamTooltip', { target });
return (
<Tooltip delayDuration={tooltipDelayMs}>
<TooltipTrigger asChild>
<div className="inline-flex h-8 max-w-full items-center gap-1.5 rounded-md border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2 typography-micro text-muted-foreground">
<Icon name="git-branch" className="size-3.5 shrink-0" />
<span className="min-w-0 truncate text-foreground/80">{target}</span>
{isSynced ? (
<span className="tabular-nums text-muted-foreground">{t('gitView.header.upstreamSynced')}</span>
) : (
<span className="inline-flex items-center gap-1 tabular-nums">
{comparison.ahead > 0 ? (
<span className="text-[var(--status-info)]">{comparison.ahead}</span>
) : null}
{comparison.behind > 0 ? (
<span className="text-[var(--status-warning)]">{comparison.behind}</span>
) : null}
</span>
)}
</div>
</TooltipTrigger>
<TooltipContent sideOffset={8}>{tooltipText}</TooltipContent>
</Tooltip>
);
};
export const GitHeader: React.FC<GitHeaderProps> = ({ export const GitHeader: React.FC<GitHeaderProps> = ({
status, status,
localBranches, localBranches,
@@ -264,13 +312,20 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
/> />
); );
const upstreamStatusPill = status.upstreamComparison ? (
<UpstreamStatusPill
comparison={status.upstreamComparison}
trackingBranch={status.tracking}
tooltipDelayMs={1000}
/>
) : null;
const identityControl = ( const identityControl = (
<IdentityDropdown <IdentityDropdown
activeProfile={activeIdentityProfile} activeProfile={activeIdentityProfile}
identities={availableIdentities} identities={availableIdentities}
onSelect={onSelectIdentity} onSelect={onSelectIdentity}
isApplying={isApplyingIdentity} isApplying={isApplyingIdentity}
iconOnly={true} iconOnly={true}
/> />
); );
@@ -293,7 +348,6 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
onCheckout={onCheckoutBranch} onCheckout={onCheckoutBranch}
onCreate={onCreateBranch} onCreate={onCreateBranch}
remotes={remotes} remotes={remotes}
/> />
)} )}
</div> </div>
@@ -317,6 +371,9 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
className="h-full" className="h-full"
/> />
</div> </div>
{upstreamStatusPill ? (
<div className="min-w-0 shrink">{upstreamStatusPill}</div>
) : null}
<div className="shrink-0">{syncButtons}</div> <div className="shrink-0">{syncButtons}</div>
</div> </div>
) : null} ) : null}
+68
View File
@@ -1,6 +1,9 @@
import React from 'react'; import React from 'react';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import { getSyncSessions } from '@/sync/sync-refs';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useUpdateStore } from '@/stores/useUpdateStore'; import { useUpdateStore } from '@/stores/useUpdateStore';
import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useThemeSystem } from '@/contexts/useThemeSystem';
@@ -83,6 +86,12 @@ type MenuAction =
| 'theme-system' | 'theme-system'
| 'toggle-sidebar' | 'toggle-sidebar'
| 'toggle-memory-debug' | 'toggle-memory-debug'
| 'go-back'
| 'go-forward'
| 'previous-session'
| 'next-session'
| 'previous-project'
| 'next-project'
| 'help-dialog' | 'help-dialog'
| 'download-logs'; | 'download-logs';
@@ -136,6 +145,39 @@ export const useMenuActions = (
sessionEvents.requestDirectoryDialog(); sessionEvents.requestDirectoryDialog();
}, []); }, []);
const navigateSession = React.useCallback((direction: -1 | 1) => {
const sessions = getSyncSessions();
if (sessions.length === 0) return;
const currentSessionId = useSessionUIStore.getState().currentSessionId;
const currentIndex = sessions.findIndex((session) => session.id === currentSessionId);
let nextIndex = direction > 0 ? 0 : sessions.length - 1;
if (currentIndex >= 0) {
nextIndex = (currentIndex + direction + sessions.length) % sessions.length;
}
const nextSession = sessions[nextIndex];
if (!nextSession) return;
setActiveMainTab('chat');
setSessionSwitcherOpen(false);
useSessionUIStore.getState().setCurrentSession(nextSession.id);
}, [setActiveMainTab, setSessionSwitcherOpen]);
const navigateProject = React.useCallback((direction: -1 | 1) => {
const { activeProjectId, projects, setActiveProject } = useProjectsStore.getState();
if (projects.length === 0) return;
const currentIndex = projects.findIndex((project) => project.id === activeProjectId);
let nextIndex = direction > 0 ? 0 : projects.length - 1;
if (currentIndex >= 0) {
nextIndex = (currentIndex + direction + projects.length) % projects.length;
}
const nextProject = projects[nextIndex];
if (!nextProject) return;
setActiveProject(nextProject.id);
}, []);
const handleAction = React.useCallback( const handleAction = React.useCallback(
(action: MenuAction) => { (action: MenuAction) => {
switch (action) { switch (action) {
@@ -222,6 +264,30 @@ export const useMenuActions = (
onToggleMemoryDebug?.(); onToggleMemoryDebug?.();
break; break;
case 'go-back':
useDirectoryStore.getState().goBack();
break;
case 'go-forward':
useDirectoryStore.getState().goForward();
break;
case 'previous-session':
navigateSession(-1);
break;
case 'next-session':
navigateSession(1);
break;
case 'previous-project':
navigateProject(-1);
break;
case 'next-project':
navigateProject(1);
break;
case 'help-dialog': case 'help-dialog':
toggleHelpDialog(); toggleHelpDialog();
break; break;
@@ -236,6 +302,8 @@ export const useMenuActions = (
}, },
[ [
handleChangeWorkspace, handleChangeWorkspace,
navigateProject,
navigateSession,
onToggleMemoryDebug, onToggleMemoryDebug,
openNewSessionDraft, openNewSessionDraft,
setAboutDialogOpen, setAboutDialogOpen,
@@ -100,6 +100,8 @@ export const useWindowControlsOverlayLayout = () => {
if (overlay && typeof overlay.removeEventListener === 'function') { if (overlay && typeof overlay.removeEventListener === 'function') {
overlay.removeEventListener('geometrychange', updateGeometry); overlay.removeEventListener('geometrychange', updateGeometry);
} }
applyOverlayInsets(root, 0, 0, 0);
}; };
}, []); }, []);
}; };
+8
View File
@@ -118,11 +118,19 @@ export interface GitRebaseInProgress {
onto: string; onto: string;
} }
export interface GitRemoteComparison {
remote: string;
branch: string;
ahead: number;
behind: number;
}
export interface GitStatus { export interface GitStatus {
current: string; current: string;
tracking: string | null; tracking: string | null;
ahead: number; ahead: number;
behind: number; behind: number;
upstreamComparison?: GitRemoteComparison | null;
files: GitStatusFile[]; files: GitStatusFile[];
isClean: boolean; isClean: boolean;
diffStats?: Record<string, { insertions: number; deletions: number }>; diffStats?: Record<string, { insertions: number; deletions: number }>;
+11 -1
View File
@@ -505,6 +505,9 @@ export const dict = {
'gitView.header.noProfiles': 'No profiles available to apply.', 'gitView.header.noProfiles': 'No profiles available to apply.',
'gitView.header.removeRemoteAria': 'Remove Remote aria label', 'gitView.header.removeRemoteAria': 'Remove Remote aria label',
'gitView.header.removeRemoteTitle': 'Remove Remote Title', 'gitView.header.removeRemoteTitle': 'Remove Remote Title',
'gitView.header.upstreamSynced': 'synced',
'gitView.header.upstreamTooltip': 'Compared with {target}.',
'gitView.header.upstreamTooltipTracking': 'Compared with {target}. Primary sync badges still reflect {tracking}.',
'gitView.history.binary': 'Binary', 'gitView.history.binary': 'Binary',
'gitView.history.binaryNoDiff': 'Binary file — no diff available', 'gitView.history.binaryNoDiff': 'Binary file — no diff available',
'gitView.history.commitsPlaceholder': 'Commits Placeholder', 'gitView.history.commitsPlaceholder': 'Commits Placeholder',
@@ -762,7 +765,7 @@ export const dict = {
'gitView.toast.mergedIntoBranch': 'Merged {branch} into {currentBranch}', 'gitView.toast.mergedIntoBranch': 'Merged {branch} into {currentBranch}',
'gitView.toast.pulledFilesPlural': 'Pulled {count} files from {name}', 'gitView.toast.pulledFilesPlural': 'Pulled {count} files from {name}',
'gitView.toast.pulledFilesSingle': 'Pulled {count} file from {name}', 'gitView.toast.pulledFilesSingle': 'Pulled {count} file from {name}',
'gitView.toast.pushedToUpstream': 'Pushed to upstream', 'gitView.toast.pushedToUpstream': 'Pushed to {name}',
'gitView.toast.commitOrStashBeforeSync': 'Commit or stash your changes before syncing', 'gitView.toast.commitOrStashBeforeSync': 'Commit or stash your changes before syncing',
'gitView.toast.alreadyUpToDate': 'Already up to date', 'gitView.toast.alreadyUpToDate': 'Already up to date',
'gitView.toast.syncedPulledPluralAndPushed': 'Pulled {count} files from {name} and pushed to upstream', 'gitView.toast.syncedPulledPluralAndPushed': 'Pulled {count} files from {name} and pushed to upstream',
@@ -1259,6 +1262,8 @@ export const dict = {
'helpDialog.proTips.themeCycling': 'Theme cycling remembers your preference across sessions', 'helpDialog.proTips.themeCycling': 'Theme cycling remembers your preference across sessions',
'header.actions.rightSidebarWithShortcut': 'Right sidebar ({shortcut})', 'header.actions.rightSidebarWithShortcut': 'Right sidebar ({shortcut})',
'header.actions.toggleRightSidebarAria': 'Toggle right sidebar', 'header.actions.toggleRightSidebarAria': 'Toggle right sidebar',
'header.actions.openAppMenu': 'OpenChamber menu',
'header.actions.openAppMenuAria': 'Open OpenChamber menu',
'header.actions.openSessionsWithShortcut': 'Open sessions ({shortcut})', 'header.actions.openSessionsWithShortcut': 'Open sessions ({shortcut})',
'header.actions.openSessionsAria': 'Open sessions', 'header.actions.openSessionsAria': 'Open sessions',
'header.actions.closeSessionsAria': 'Close sessions', 'header.actions.closeSessionsAria': 'Close sessions',
@@ -2085,6 +2090,11 @@ export const dict = {
'header.actions.newMiniChatAria': 'Open a new Mini Chat window', 'header.actions.newMiniChatAria': 'Open a new Mini Chat window',
'header.actions.openSessionMiniChat': 'Open Session in Mini Chat', 'header.actions.openSessionMiniChat': 'Open Session in Mini Chat',
'header.actions.openSessionMiniChatAria': 'Open current session in Mini Chat', 'header.actions.openSessionMiniChatAria': 'Open current session in Mini Chat',
'header.windowControls.groupAria': 'Window controls',
'header.windowControls.minimize': 'Minimize window',
'header.windowControls.maximize': 'Maximize window',
'header.windowControls.restore': 'Restore window',
'header.windowControls.close': 'Close window',
'errorBoundary.title': 'Something went wrong', 'errorBoundary.title': 'Something went wrong',
'errorBoundary.description': 'The application encountered an unexpected error. This has been logged for debugging.', 'errorBoundary.description': 'The application encountered an unexpected error. This has been logged for debugging.',
'errorBoundary.state.unknownError': 'Unknown error', 'errorBoundary.state.unknownError': 'Unknown error',
+11 -1
View File
@@ -506,6 +506,9 @@ export const dict: Record<I18nKey, string> = {
"gitView.header.noProfiles": "No hay perfiles disponibles para aplicar.", "gitView.header.noProfiles": "No hay perfiles disponibles para aplicar.",
"gitView.header.removeRemoteAria": "Eliminar remoto", "gitView.header.removeRemoteAria": "Eliminar remoto",
"gitView.header.removeRemoteTitle": "Eliminar remoto", "gitView.header.removeRemoteTitle": "Eliminar remoto",
"gitView.header.upstreamSynced": "sincronizado",
"gitView.header.upstreamTooltip": "Comparado con {target}.",
"gitView.header.upstreamTooltipTracking": "Comparado con {target}. Los indicadores principales de sincronización aún reflejan {tracking}.",
"gitView.history.binary": "Binario", "gitView.history.binary": "Binario",
"gitView.history.binaryNoDiff": "Archivo binario — no hay diff disponible", "gitView.history.binaryNoDiff": "Archivo binario — no hay diff disponible",
"gitView.history.commitsPlaceholder": "Buscar commits...", "gitView.history.commitsPlaceholder": "Buscar commits...",
@@ -763,7 +766,7 @@ export const dict: Record<I18nKey, string> = {
"gitView.toast.mergedIntoBranch": "Merge de {branch} en {currentBranch}", "gitView.toast.mergedIntoBranch": "Merge de {branch} en {currentBranch}",
"gitView.toast.pulledFilesPlural": "Se trajeron {count} archivos de {name}", "gitView.toast.pulledFilesPlural": "Se trajeron {count} archivos de {name}",
"gitView.toast.pulledFilesSingle": "Se trajo {count} archivo de {name}", "gitView.toast.pulledFilesSingle": "Se trajo {count} archivo de {name}",
"gitView.toast.pushedToUpstream": "Enviado al upstream", "gitView.toast.pushedToUpstream": "Enviado a {name}",
"gitView.toast.commitOrStashBeforeSync": "Haz commit o stash de tus cambios antes de sincronizar", "gitView.toast.commitOrStashBeforeSync": "Haz commit o stash de tus cambios antes de sincronizar",
"gitView.toast.alreadyUpToDate": "Ya está actualizado", "gitView.toast.alreadyUpToDate": "Ya está actualizado",
"gitView.toast.syncedPulledPluralAndPushed": "Se trajeron {count} archivos de {name} y se envió al upstream", "gitView.toast.syncedPulledPluralAndPushed": "Se trajeron {count} archivos de {name} y se envió al upstream",
@@ -1225,6 +1228,8 @@ export const dict: Record<I18nKey, string> = {
"helpDialog.proTips.themeCycling": "El ciclo de tema recuerda tu preferencia entre sesiones", "helpDialog.proTips.themeCycling": "El ciclo de tema recuerda tu preferencia entre sesiones",
"header.actions.rightSidebarWithShortcut": "Barra lateral derecha ({shortcut})", "header.actions.rightSidebarWithShortcut": "Barra lateral derecha ({shortcut})",
"header.actions.toggleRightSidebarAria": "Mostrar u ocultar barra lateral derecha", "header.actions.toggleRightSidebarAria": "Mostrar u ocultar barra lateral derecha",
"header.actions.openAppMenu": "Menú de OpenChamber",
"header.actions.openAppMenuAria": "Abrir menú de OpenChamber",
"header.actions.openSessionsWithShortcut": "Abrir sesiones ({shortcut})", "header.actions.openSessionsWithShortcut": "Abrir sesiones ({shortcut})",
"header.actions.openSessionsAria": "Abrir sesiones", "header.actions.openSessionsAria": "Abrir sesiones",
"header.actions.closeSessionsAria": "Cerrar sesiones", "header.actions.closeSessionsAria": "Cerrar sesiones",
@@ -2051,6 +2056,11 @@ export const dict: Record<I18nKey, string> = {
"header.actions.newMiniChatAria": "Abrir una nueva ventana Mini Chat", "header.actions.newMiniChatAria": "Abrir una nueva ventana Mini Chat",
"header.actions.openSessionMiniChat": "Abrir sesión en Mini Chat", "header.actions.openSessionMiniChat": "Abrir sesión en Mini Chat",
"header.actions.openSessionMiniChatAria": "Abrir la sesión actual en Mini Chat", "header.actions.openSessionMiniChatAria": "Abrir la sesión actual en Mini Chat",
"header.windowControls.groupAria": "Controles de ventana",
"header.windowControls.minimize": "Minimizar ventana",
"header.windowControls.maximize": "Maximizar ventana",
"header.windowControls.restore": "Restaurar ventana",
"header.windowControls.close": "Cerrar ventana",
"errorBoundary.title": "Algo salió mal", "errorBoundary.title": "Algo salió mal",
"errorBoundary.description": "La aplicación encontró un error inesperado. Esto se ha registrado para depuración.", "errorBoundary.description": "La aplicación encontró un error inesperado. Esto se ha registrado para depuración.",
"errorBoundary.state.unknownError": "Error desconocido", "errorBoundary.state.unknownError": "Error desconocido",
+11 -1
View File
@@ -506,6 +506,9 @@ export const dict: Record<I18nKey, string> = {
'gitView.header.noProfiles': '적용할 프로필 없음', 'gitView.header.noProfiles': '적용할 프로필 없음',
'gitView.header.removeRemoteAria': '리모트 제거', 'gitView.header.removeRemoteAria': '리모트 제거',
'gitView.header.removeRemoteTitle': '리모트 제거', 'gitView.header.removeRemoteTitle': '리모트 제거',
'gitView.header.upstreamSynced': '동기화됨',
'gitView.header.upstreamTooltip': '{target}와 비교됨.',
'gitView.header.upstreamTooltipTracking': '{target}와 비교됨. 기본 동기화 배지는 계속 {tracking}을 반영합니다.',
'gitView.history.binary': '바이너리', 'gitView.history.binary': '바이너리',
'gitView.history.binaryNoDiff': '바이너리 파일 — diff 없음', 'gitView.history.binaryNoDiff': '바이너리 파일 — diff 없음',
'gitView.history.commitsPlaceholder': '커밋 검색', 'gitView.history.commitsPlaceholder': '커밋 검색',
@@ -763,7 +766,7 @@ export const dict: Record<I18nKey, string> = {
'gitView.toast.mergedIntoBranch': '{branch}을(를) {currentBranch}에 병합했습니다', 'gitView.toast.mergedIntoBranch': '{branch}을(를) {currentBranch}에 병합했습니다',
'gitView.toast.pulledFilesPlural': '{name}에서 파일 {count}개를 풀했습니다', 'gitView.toast.pulledFilesPlural': '{name}에서 파일 {count}개를 풀했습니다',
'gitView.toast.pulledFilesSingle': '{name}에서 파일 {count}개를 풀했습니다', 'gitView.toast.pulledFilesSingle': '{name}에서 파일 {count}개를 풀했습니다',
'gitView.toast.pushedToUpstream': '업스트림에 푸시했습니다', 'gitView.toast.pushedToUpstream': '{name}에 푸시했습니다',
'gitView.toast.commitOrStashBeforeSync': '동기화하기 전에 변경 사항을 커밋하거나 stash하세요', 'gitView.toast.commitOrStashBeforeSync': '동기화하기 전에 변경 사항을 커밋하거나 stash하세요',
'gitView.toast.alreadyUpToDate': '이미 최신 상태입니다', 'gitView.toast.alreadyUpToDate': '이미 최신 상태입니다',
'gitView.toast.syncedPulledPluralAndPushed': '{name}에서 파일 {count}개를 풀하고 업스트림에 푸시했습니다', 'gitView.toast.syncedPulledPluralAndPushed': '{name}에서 파일 {count}개를 풀하고 업스트림에 푸시했습니다',
@@ -1261,6 +1264,8 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.proTips.themeCycling': '테마 순환은 세션 간에도 선호 설정을 기억합니다', 'helpDialog.proTips.themeCycling': '테마 순환은 세션 간에도 선호 설정을 기억합니다',
'header.actions.rightSidebarWithShortcut': '오른쪽 사이드바 ({shortcut})', 'header.actions.rightSidebarWithShortcut': '오른쪽 사이드바 ({shortcut})',
'header.actions.toggleRightSidebarAria': '오른쪽 사이드바 토글', 'header.actions.toggleRightSidebarAria': '오른쪽 사이드바 토글',
'header.actions.openAppMenu': 'OpenChamber 메뉴',
'header.actions.openAppMenuAria': 'OpenChamber 메뉴 열기',
'header.actions.openSessionsWithShortcut': '세션 ({shortcut}) 열기', 'header.actions.openSessionsWithShortcut': '세션 ({shortcut}) 열기',
'header.actions.openSessionsAria': '세션 열기', 'header.actions.openSessionsAria': '세션 열기',
'header.actions.closeSessionsAria': '세션 닫기', 'header.actions.closeSessionsAria': '세션 닫기',
@@ -2085,6 +2090,11 @@ export const dict: Record<I18nKey, string> = {
'header.actions.newMiniChatAria': '새 Mini Chat 창 열기', 'header.actions.newMiniChatAria': '새 Mini Chat 창 열기',
'header.actions.openSessionMiniChat': 'Mini Chat에서 세션 열기', 'header.actions.openSessionMiniChat': 'Mini Chat에서 세션 열기',
'header.actions.openSessionMiniChatAria': '현재 세션을 Mini Chat에서 열기', 'header.actions.openSessionMiniChatAria': '현재 세션을 Mini Chat에서 열기',
'header.windowControls.groupAria': '창 컨트롤',
'header.windowControls.minimize': '창 최소화',
'header.windowControls.maximize': '창 최대화',
'header.windowControls.restore': '창 복원',
'header.windowControls.close': '창 닫기',
'errorBoundary.title': '문제가 발생했습니다', 'errorBoundary.title': '문제가 발생했습니다',
'errorBoundary.description': '애플리케이션에서 예상치 못한 오류가 발생했습니다. 디버깅을 위해 기록되었습니다.', 'errorBoundary.description': '애플리케이션에서 예상치 못한 오류가 발생했습니다. 디버깅을 위해 기록되었습니다.',
'errorBoundary.state.unknownError': '알 수 없음 오류', 'errorBoundary.state.unknownError': '알 수 없음 오류',
+10
View File
@@ -601,6 +601,11 @@ export const dict: Record<I18nKey, string> = {
'header.actions.newMiniChatAria': 'Otwórz nowe okno Mini Chat', 'header.actions.newMiniChatAria': 'Otwórz nowe okno Mini Chat',
'header.actions.openSessionMiniChat': 'Otwórz sesję w Mini Chat', 'header.actions.openSessionMiniChat': 'Otwórz sesję w Mini Chat',
'header.actions.openSessionMiniChatAria': 'Otwórz bieżącą sesję w Mini Chat', 'header.actions.openSessionMiniChatAria': 'Otwórz bieżącą sesję w Mini Chat',
'header.windowControls.groupAria': 'Elementy sterujące oknem',
'header.windowControls.minimize': 'Minimalizuj okno',
'header.windowControls.maximize': 'Maksymalizuj okno',
'header.windowControls.restore': 'Przywróć okno',
'header.windowControls.close': 'Zamknij okno',
'errorBoundary.title': 'Coś poszło nie tak', 'errorBoundary.title': 'Coś poszło nie tak',
'errorBoundary.description': 'Aplikacja napotkała nieoczekiwany błąd. Zostało to zalogowane do celów debugowania.', 'errorBoundary.description': 'Aplikacja napotkała nieoczekiwany błąd. Zostało to zalogowane do celów debugowania.',
'errorBoundary.state.unknownError': 'Nieznany błąd', 'errorBoundary.state.unknownError': 'Nieznany błąd',
@@ -1488,6 +1493,9 @@ export const dict: Record<I18nKey, string> = {
'gitView.header.noProfiles': 'No profiles available to apply.', 'gitView.header.noProfiles': 'No profiles available to apply.',
'gitView.header.removeRemoteAria': 'Remove Remote aria label', 'gitView.header.removeRemoteAria': 'Remove Remote aria label',
'gitView.header.removeRemoteTitle': 'Remove Remote Title', 'gitView.header.removeRemoteTitle': 'Remove Remote Title',
'gitView.header.upstreamSynced': 'zsynchronizowano',
'gitView.header.upstreamTooltip': 'Porównano z {target}.',
'gitView.header.upstreamTooltipTracking': 'Porównano z {target}. Główne wskaźniki synchronizacji nadal odzwierciedlają {tracking}.',
'gitView.history.binary': 'Binary', 'gitView.history.binary': 'Binary',
'gitView.history.binaryNoDiff': 'Binary file — no diff available', 'gitView.history.binaryNoDiff': 'Binary file — no diff available',
'gitView.history.commitsPlaceholder': 'Commits Placeholder', 'gitView.history.commitsPlaceholder': 'Commits Placeholder',
@@ -1729,6 +1737,8 @@ export const dict: Record<I18nKey, string> = {
'header.actions.newSessionWithShortcut': 'Nowa sesja ({shortcut})', 'header.actions.newSessionWithShortcut': 'Nowa sesja ({shortcut})',
'header.actions.openPlanAria': 'Otwórz plan', 'header.actions.openPlanAria': 'Otwórz plan',
'header.actions.openSessionsAria': 'Otwórz sesje', 'header.actions.openSessionsAria': 'Otwórz sesje',
'header.actions.openAppMenu': 'Menu OpenChamber',
'header.actions.openAppMenuAria': 'Otwórz menu OpenChamber',
'header.actions.openSessionsWithShortcut': 'Otwórz sesje ({shortcut})', 'header.actions.openSessionsWithShortcut': 'Otwórz sesje ({shortcut})',
'header.actions.planWithShortcut': 'Plan ({shortcut})', 'header.actions.planWithShortcut': 'Plan ({shortcut})',
'header.actions.rightSidebarWithShortcut': 'Prawy panel boczny ({shortcut})', 'header.actions.rightSidebarWithShortcut': 'Prawy panel boczny ({shortcut})',
+11 -1
View File
@@ -506,6 +506,9 @@ export const dict: Record<I18nKey, string> = {
"gitView.header.noProfiles": "Não há perfiles disponíveis para aplicar.", "gitView.header.noProfiles": "Não há perfiles disponíveis para aplicar.",
"gitView.header.removeRemoteAria": "Excluir remoto", "gitView.header.removeRemoteAria": "Excluir remoto",
"gitView.header.removeRemoteTitle": "Excluir remoto", "gitView.header.removeRemoteTitle": "Excluir remoto",
"gitView.header.upstreamSynced": "sincronizado",
"gitView.header.upstreamTooltip": "Comparado com {target}.",
"gitView.header.upstreamTooltipTracking": "Comparado com {target}. Os indicadores principais de sincronização ainda refletem {tracking}.",
"gitView.history.binary": "Binario", "gitView.history.binary": "Binario",
"gitView.history.binaryNoDiff": "Arquivo binário — diff não disponível", "gitView.history.binaryNoDiff": "Arquivo binário — diff não disponível",
"gitView.history.commitsPlaceholder": "Buscar commits...", "gitView.history.commitsPlaceholder": "Buscar commits...",
@@ -763,7 +766,7 @@ export const dict: Record<I18nKey, string> = {
"gitView.toast.mergedIntoBranch": "Merge de {branch} em {currentBranch}", "gitView.toast.mergedIntoBranch": "Merge de {branch} em {currentBranch}",
"gitView.toast.pulledFilesPlural": "Se trajeron {count} arquivos de {name}", "gitView.toast.pulledFilesPlural": "Se trajeron {count} arquivos de {name}",
"gitView.toast.pulledFilesSingle": "Se trajo {count} arquivo de {name}", "gitView.toast.pulledFilesSingle": "Se trajo {count} arquivo de {name}",
"gitView.toast.pushedToUpstream": "Enviado ao upstream", "gitView.toast.pushedToUpstream": "Enviado para {name}",
"gitView.toast.commitOrStashBeforeSync": "Faça commit ou stash das alterações antes de sincronizar", "gitView.toast.commitOrStashBeforeSync": "Faça commit ou stash das alterações antes de sincronizar",
"gitView.toast.alreadyUpToDate": "Já está atualizado", "gitView.toast.alreadyUpToDate": "Já está atualizado",
"gitView.toast.syncedPulledPluralAndPushed": "Foram trazidos {count} arquivos de {name} e enviados ao upstream", "gitView.toast.syncedPulledPluralAndPushed": "Foram trazidos {count} arquivos de {name} e enviados ao upstream",
@@ -1225,6 +1228,8 @@ export const dict: Record<I18nKey, string> = {
"helpDialog.proTips.themeCycling": "A alternância de tema lembra sua preferência entre sessões", "helpDialog.proTips.themeCycling": "A alternância de tema lembra sua preferência entre sessões",
"header.actions.rightSidebarWithShortcut": "Barra lateral direita ({shortcut})", "header.actions.rightSidebarWithShortcut": "Barra lateral direita ({shortcut})",
"header.actions.toggleRightSidebarAria": "Mostrar ou ocultar barra lateral direita", "header.actions.toggleRightSidebarAria": "Mostrar ou ocultar barra lateral direita",
"header.actions.openAppMenu": "Menu do OpenChamber",
"header.actions.openAppMenuAria": "Abrir menu do OpenChamber",
"header.actions.openSessionsWithShortcut": "Abrir sessões ({shortcut})", "header.actions.openSessionsWithShortcut": "Abrir sessões ({shortcut})",
"header.actions.openSessionsAria": "Abrir sessões", "header.actions.openSessionsAria": "Abrir sessões",
"header.actions.closeSessionsAria": "Fechar sessões", "header.actions.closeSessionsAria": "Fechar sessões",
@@ -2051,6 +2056,11 @@ export const dict: Record<I18nKey, string> = {
"header.actions.newMiniChatAria": "Abrir uma nova janela Mini Chat", "header.actions.newMiniChatAria": "Abrir uma nova janela Mini Chat",
"header.actions.openSessionMiniChat": "Abrir sessão no Mini Chat", "header.actions.openSessionMiniChat": "Abrir sessão no Mini Chat",
"header.actions.openSessionMiniChatAria": "Abrir a sessão atual no Mini Chat", "header.actions.openSessionMiniChatAria": "Abrir a sessão atual no Mini Chat",
"header.windowControls.groupAria": "Controles da janela",
"header.windowControls.minimize": "Minimizar janela",
"header.windowControls.maximize": "Maximizar janela",
"header.windowControls.restore": "Restaurar janela",
"header.windowControls.close": "Fechar janela",
"errorBoundary.title": "Algo deu errado", "errorBoundary.title": "Algo deu errado",
"errorBoundary.description": "O aplicativo encontrou um erro inesperado. Isso foi registrado para depuração.", "errorBoundary.description": "O aplicativo encontrou um erro inesperado. Isso foi registrado para depuração.",
"errorBoundary.state.unknownError": "Erro desconhecido", "errorBoundary.state.unknownError": "Erro desconhecido",
+11 -1
View File
@@ -506,6 +506,9 @@ export const dict: Record<I18nKey, string> = {
"gitView.header.noProfiles": "Немає доступних профілів для застосування.", "gitView.header.noProfiles": "Немає доступних профілів для застосування.",
"gitView.header.removeRemoteAria": "Видалити remote", "gitView.header.removeRemoteAria": "Видалити remote",
"gitView.header.removeRemoteTitle": "Видалити remote", "gitView.header.removeRemoteTitle": "Видалити remote",
"gitView.header.upstreamSynced": "синхронізовано",
"gitView.header.upstreamTooltip": "Порівняно з {target}.",
"gitView.header.upstreamTooltipTracking": "Порівняно з {target}. Основні індикатори синхронізації все ще відображають {tracking}.",
"gitView.history.binary": "Бінарний", "gitView.history.binary": "Бінарний",
"gitView.history.binaryNoDiff": "Бінарний файл — diff недоступний", "gitView.history.binaryNoDiff": "Бінарний файл — diff недоступний",
"gitView.history.commitsPlaceholder": "Пошук комітів", "gitView.history.commitsPlaceholder": "Пошук комітів",
@@ -763,7 +766,7 @@ export const dict: Record<I18nKey, string> = {
"gitView.toast.mergedIntoBranch": "Злито {branch} в {currentBranch}", "gitView.toast.mergedIntoBranch": "Злито {branch} в {currentBranch}",
"gitView.toast.pulledFilesPlural": "Отримано файлів: {count} з {name}", "gitView.toast.pulledFilesPlural": "Отримано файлів: {count} з {name}",
"gitView.toast.pulledFilesSingle": "Отримано файл: {count} з {name}", "gitView.toast.pulledFilesSingle": "Отримано файл: {count} з {name}",
"gitView.toast.pushedToUpstream": "Надіслано в upstream", "gitView.toast.pushedToUpstream": "Надіслано в {name}",
"gitView.toast.commitOrStashBeforeSync": "Закомітьте або сховайте зміни перед синхронізацією", "gitView.toast.commitOrStashBeforeSync": "Закомітьте або сховайте зміни перед синхронізацією",
"gitView.toast.alreadyUpToDate": "Вже актуально", "gitView.toast.alreadyUpToDate": "Вже актуально",
"gitView.toast.syncedPulledPluralAndPushed": "Отримано файлів: {count} з {name} і надіслано в upstream", "gitView.toast.syncedPulledPluralAndPushed": "Отримано файлів: {count} з {name} і надіслано в upstream",
@@ -1225,6 +1228,8 @@ export const dict: Record<I18nKey, string> = {
"helpDialog.proTips.themeCycling": "Перемикання теми запам’ятовує ваші переваги протягом сесій", "helpDialog.proTips.themeCycling": "Перемикання теми запам’ятовує ваші переваги протягом сесій",
"header.actions.rightSidebarWithShortcut": "Права бічна панель ({shortcut})", "header.actions.rightSidebarWithShortcut": "Права бічна панель ({shortcut})",
"header.actions.toggleRightSidebarAria": "Перемкнути праву бічну панель", "header.actions.toggleRightSidebarAria": "Перемкнути праву бічну панель",
"header.actions.openAppMenu": "Меню OpenChamber",
"header.actions.openAppMenuAria": "Відкрити меню OpenChamber",
"header.actions.openSessionsWithShortcut": "Відкрити сесії ({shortcut})", "header.actions.openSessionsWithShortcut": "Відкрити сесії ({shortcut})",
"header.actions.openSessionsAria": "Відкрити сесії", "header.actions.openSessionsAria": "Відкрити сесії",
"header.actions.closeSessionsAria": "Закрити сесії", "header.actions.closeSessionsAria": "Закрити сесії",
@@ -2051,6 +2056,11 @@ export const dict: Record<I18nKey, string> = {
"header.actions.newMiniChatAria": "Відкрити нове вікно Mini Chat", "header.actions.newMiniChatAria": "Відкрити нове вікно Mini Chat",
"header.actions.openSessionMiniChat": "Відкрити сесію в Mini Chat", "header.actions.openSessionMiniChat": "Відкрити сесію в Mini Chat",
"header.actions.openSessionMiniChatAria": "Відкрити поточну сесію в Mini Chat", "header.actions.openSessionMiniChatAria": "Відкрити поточну сесію в Mini Chat",
"header.windowControls.groupAria": "Елементи керування вікном",
"header.windowControls.minimize": "Згорнути вікно",
"header.windowControls.maximize": "Розгорнути вікно",
"header.windowControls.restore": "Відновити вікно",
"header.windowControls.close": "Закрити вікно",
"errorBoundary.title": "Щось пішло не так", "errorBoundary.title": "Щось пішло не так",
"errorBoundary.description": "У програмі сталася неочікувана помилка. Це було зареєстровано для налагодження.", "errorBoundary.description": "У програмі сталася неочікувана помилка. Це було зареєстровано для налагодження.",
"errorBoundary.state.unknownError": "Невідома помилка", "errorBoundary.state.unknownError": "Невідома помилка",
+11 -1
View File
@@ -506,6 +506,9 @@ export const dict: Record<I18nKey, string> = {
'gitView.header.noProfiles': '没有可应用的配置。', 'gitView.header.noProfiles': '没有可应用的配置。',
'gitView.header.removeRemoteAria': '移除远程 {name}', 'gitView.header.removeRemoteAria': '移除远程 {name}',
'gitView.header.removeRemoteTitle': '移除 {name}', 'gitView.header.removeRemoteTitle': '移除 {name}',
'gitView.header.upstreamSynced': '已同步',
'gitView.header.upstreamTooltip': '与 {target} 对比。',
'gitView.header.upstreamTooltipTracking': '与 {target} 对比。主要同步徽标仍然反映 {tracking}。',
'gitView.history.binary': '二进制', 'gitView.history.binary': '二进制',
'gitView.history.binaryNoDiff': '二进制文件,无法显示差异', 'gitView.history.binaryNoDiff': '二进制文件,无法显示差异',
'gitView.history.commitsPlaceholder': '提交数', 'gitView.history.commitsPlaceholder': '提交数',
@@ -763,7 +766,7 @@ export const dict: Record<I18nKey, string> = {
'gitView.toast.mergedIntoBranch': '已将 {branch} 合并到 {currentBranch}', 'gitView.toast.mergedIntoBranch': '已将 {branch} 合并到 {currentBranch}',
'gitView.toast.pulledFilesPlural': '已从 {name} 拉取 {count} 个文件', 'gitView.toast.pulledFilesPlural': '已从 {name} 拉取 {count} 个文件',
'gitView.toast.pulledFilesSingle': '已从 {name} 拉取 {count} 个文件', 'gitView.toast.pulledFilesSingle': '已从 {name} 拉取 {count} 个文件',
'gitView.toast.pushedToUpstream': '已推送到上游', 'gitView.toast.pushedToUpstream': '已推送到 {name}',
'gitView.toast.commitOrStashBeforeSync': '同步前请先提交或储藏你的更改', 'gitView.toast.commitOrStashBeforeSync': '同步前请先提交或储藏你的更改',
'gitView.toast.alreadyUpToDate': '已是最新状态', 'gitView.toast.alreadyUpToDate': '已是最新状态',
'gitView.toast.syncedPulledPluralAndPushed': '已从 {name} 拉取 {count} 个文件并推送到上游', 'gitView.toast.syncedPulledPluralAndPushed': '已从 {name} 拉取 {count} 个文件并推送到上游',
@@ -1225,6 +1228,8 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.proTips.themeCycling': '主题循环会记住你在各会话中的偏好', 'helpDialog.proTips.themeCycling': '主题循环会记住你在各会话中的偏好',
'header.actions.rightSidebarWithShortcut': '右侧边栏({shortcut}', 'header.actions.rightSidebarWithShortcut': '右侧边栏({shortcut}',
'header.actions.toggleRightSidebarAria': '切换右侧边栏', 'header.actions.toggleRightSidebarAria': '切换右侧边栏',
'header.actions.openAppMenu': 'OpenChamber 菜单',
'header.actions.openAppMenuAria': '打开 OpenChamber 菜单',
'header.actions.openSessionsWithShortcut': '打开会话({shortcut}', 'header.actions.openSessionsWithShortcut': '打开会话({shortcut}',
'header.actions.openSessionsAria': '打开会话', 'header.actions.openSessionsAria': '打开会话',
'header.actions.closeSessionsAria': '关闭会话', 'header.actions.closeSessionsAria': '关闭会话',
@@ -2051,6 +2056,11 @@ export const dict: Record<I18nKey, string> = {
'header.actions.newMiniChatAria': '打开新的 Mini Chat 窗口', 'header.actions.newMiniChatAria': '打开新的 Mini Chat 窗口',
'header.actions.openSessionMiniChat': '在 Mini Chat 中打开会话', 'header.actions.openSessionMiniChat': '在 Mini Chat 中打开会话',
'header.actions.openSessionMiniChatAria': '在 Mini Chat 中打开当前会话', 'header.actions.openSessionMiniChatAria': '在 Mini Chat 中打开当前会话',
'header.windowControls.groupAria': '窗口控件',
'header.windowControls.minimize': '最小化窗口',
'header.windowControls.maximize': '最大化窗口',
'header.windowControls.restore': '还原窗口',
'header.windowControls.close': '关闭窗口',
'errorBoundary.title': '发生错误', 'errorBoundary.title': '发生错误',
'errorBoundary.description': '应用遇到意外错误,已记录用于调试。', 'errorBoundary.description': '应用遇到意外错误,已记录用于调试。',
'errorBoundary.state.unknownError': '未知错误', 'errorBoundary.state.unknownError': '未知错误',
@@ -506,6 +506,9 @@ export const dict: Record<I18nKey, string> = {
'gitView.header.noProfiles': '沒有可套用的設定。', 'gitView.header.noProfiles': '沒有可套用的設定。',
'gitView.header.removeRemoteAria': '移除遠端 {name}', 'gitView.header.removeRemoteAria': '移除遠端 {name}',
'gitView.header.removeRemoteTitle': '移除 {name}', 'gitView.header.removeRemoteTitle': '移除 {name}',
'gitView.header.upstreamSynced': '已同步',
'gitView.header.upstreamTooltip': '與 {target} 比較。',
'gitView.header.upstreamTooltipTracking': '與 {target} 比較。主要同步徽章仍反映 {tracking}。',
'gitView.history.binary': '二進位', 'gitView.history.binary': '二進位',
'gitView.history.binaryNoDiff': '二進位檔案 — 無可用 diff', 'gitView.history.binaryNoDiff': '二進位檔案 — 無可用 diff',
'gitView.history.commitsPlaceholder': '提交數', 'gitView.history.commitsPlaceholder': '提交數',
@@ -1223,6 +1226,8 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.proTips.themeCycling': '主題循環會記住你在各會話中的偏好', 'helpDialog.proTips.themeCycling': '主題循環會記住你在各會話中的偏好',
'header.actions.rightSidebarWithShortcut': '右側邊欄({shortcut}', 'header.actions.rightSidebarWithShortcut': '右側邊欄({shortcut}',
'header.actions.toggleRightSidebarAria': '切換右側邊欄', 'header.actions.toggleRightSidebarAria': '切換右側邊欄',
'header.actions.openAppMenu': 'OpenChamber 選單',
'header.actions.openAppMenuAria': '開啟 OpenChamber 選單',
'header.actions.openSessionsWithShortcut': '開啟會話({shortcut}', 'header.actions.openSessionsWithShortcut': '開啟會話({shortcut}',
'header.actions.openSessionsAria': '開啟會話', 'header.actions.openSessionsAria': '開啟會話',
'header.actions.closeSessionsAria': '關閉會話', 'header.actions.closeSessionsAria': '關閉會話',
@@ -2049,6 +2054,11 @@ export const dict: Record<I18nKey, string> = {
'header.actions.newMiniChatAria': '開啟新的 Mini Chat 視窗', 'header.actions.newMiniChatAria': '開啟新的 Mini Chat 視窗',
'header.actions.openSessionMiniChat': '在 Mini Chat 中開啟會話', 'header.actions.openSessionMiniChat': '在 Mini Chat 中開啟會話',
'header.actions.openSessionMiniChatAria': '在 Mini Chat 中開啟目前會話', 'header.actions.openSessionMiniChatAria': '在 Mini Chat 中開啟目前會話',
'header.windowControls.groupAria': '視窗控制項',
'header.windowControls.minimize': '最小化視窗',
'header.windowControls.maximize': '最大化視窗',
'header.windowControls.restore': '還原視窗',
'header.windowControls.close': '關閉視窗',
'errorBoundary.title': '發生錯誤', 'errorBoundary.title': '發生錯誤',
'errorBoundary.description': '應用程式遇到意外錯誤,已記錄用於偵錯。', 'errorBoundary.description': '應用程式遇到意外錯誤,已記錄用於偵錯。',
'errorBoundary.state.unknownError': '未知錯誤', 'errorBoundary.state.unknownError': '未知錯誤',
+12 -2
View File
@@ -34,13 +34,23 @@ export const DEFAULT_OPEN_IN_APP_ID = 'finder';
export const OPEN_IN_ALWAYS_AVAILABLE_APP_IDS = new Set(['finder', 'terminal']); export const OPEN_IN_ALWAYS_AVAILABLE_APP_IDS = new Set(['finder', 'terminal']);
export const OPEN_DIRECTORY_APP_IDS = new Set(['finder', 'terminal', 'iterm2', 'ghostty']); export const OPEN_DIRECTORY_APP_IDS = new Set(['finder', 'terminal', 'iterm2', 'ghostty']);
export const getPlatformOpenInApp = (app: OpenInApp): OpenInApp => {
if (typeof window !== 'undefined' && window.__OPENCHAMBER_PLATFORM__ === 'win32') {
if (app.id === 'finder') {
return { ...app, label: 'Explorer', appName: 'File Explorer' };
}
}
return app;
};
export const getOpenInAppById = (id: string | null | undefined): OpenInApp | null => { export const getOpenInAppById = (id: string | null | undefined): OpenInApp | null => {
if (!id) { if (!id) {
return null; return null;
} }
return OPEN_IN_APPS.find((app) => app.id === id) ?? null; const app = OPEN_IN_APPS.find((candidate) => candidate.id === id) ?? null;
return app ? getPlatformOpenInApp(app) : null;
}; };
export const getDefaultOpenInApp = (): OpenInApp => { export const getDefaultOpenInApp = (): OpenInApp => {
return getOpenInAppById(DEFAULT_OPEN_IN_APP_ID) ?? OPEN_IN_APPS[0]; return getOpenInAppById(DEFAULT_OPEN_IN_APP_ID) ?? getPlatformOpenInApp(OPEN_IN_APPS[0]);
}; };
+32 -3
View File
@@ -204,6 +204,21 @@ const haveDiffStatsChanged = (
return false; return false;
}; };
const haveRemoteComparisonChanged = (
previous?: GitStatus['upstreamComparison'],
next?: GitStatus['upstreamComparison']
): boolean => {
if (!previous && !next) return false;
if (!previous || !next) return true;
return (
previous.remote !== next.remote
|| previous.branch !== next.branch
|| previous.ahead !== next.ahead
|| previous.behind !== next.behind
);
};
const hasStatusChanged = (oldStatus: GitStatus | null, newStatus: GitStatus | null): boolean => { const hasStatusChanged = (oldStatus: GitStatus | null, newStatus: GitStatus | null): boolean => {
if (!oldStatus && !newStatus) return false; if (!oldStatus && !newStatus) return false;
if (!oldStatus || !newStatus) return true; if (!oldStatus || !newStatus) return true;
@@ -217,6 +232,12 @@ const hasStatusChanged = (oldStatus: GitStatus | null, newStatus: GitStatus | nu
if (oldStatus.current !== newStatus.current) return true; if (oldStatus.current !== newStatus.current) return true;
if (oldStatus.tracking !== newStatus.tracking) return true; if (oldStatus.tracking !== newStatus.tracking) return true;
if (oldStatus.isClean !== newStatus.isClean) return true; if (oldStatus.isClean !== newStatus.isClean) return true;
if (
newStatus.upstreamComparison !== undefined
&& haveRemoteComparisonChanged(oldStatus.upstreamComparison, newStatus.upstreamComparison)
) {
return true;
}
const oldPaths = new Set(oldFiles.map(f => `${f.path}:${f.index}:${f.working_dir}`)); const oldPaths = new Set(oldFiles.map(f => `${f.path}:${f.index}:${f.working_dir}`));
for (const file of newFiles) { for (const file of newFiles) {
@@ -465,9 +486,17 @@ export const useGitStore = create<GitStore>()(
} }
// Preserve diffStats from previous status when light mode returns none // Preserve diffStats from previous status when light mode returns none
const mergedStatus = newStatus.diffStats === undefined && currentDirState.status?.diffStats const mergedStatus = {
? { ...newStatus, diffStats: currentDirState.status.diffStats } ...newStatus,
: newStatus; diffStats:
newStatus.diffStats === undefined && currentDirState.status?.diffStats !== undefined
? currentDirState.status.diffStats
: newStatus.diffStats,
upstreamComparison:
newStatus.upstreamComparison === undefined
? currentDirState.status?.upstreamComparison
: newStatus.upstreamComparison,
};
newDirectories.set(directory, { newDirectories.set(directory, {
...currentDirState, ...currentDirState,
+3 -3
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { fetchDesktopInstalledApps, isDesktopLocalOriginActive, isTauriShell, type DesktopSettings, type InstalledDesktopAppInfo } from '@/lib/desktop'; import { fetchDesktopInstalledApps, isDesktopLocalOriginActive, isTauriShell, type DesktopSettings, type InstalledDesktopAppInfo } from '@/lib/desktop';
import { OPEN_IN_APPS, DEFAULT_OPEN_IN_APP_ID, OPEN_IN_ALWAYS_AVAILABLE_APP_IDS, getOpenInAppById, type OpenInApp } from '@/lib/openInApps'; import { OPEN_IN_APPS, DEFAULT_OPEN_IN_APP_ID, OPEN_IN_ALWAYS_AVAILABLE_APP_IDS, getOpenInAppById, getPlatformOpenInApp, type OpenInApp } from '@/lib/openInApps';
import { updateDesktopSettings } from '@/lib/persistence'; import { updateDesktopSettings } from '@/lib/persistence';
export type OpenInAppOption = OpenInApp & { export type OpenInAppOption = OpenInApp & {
@@ -22,7 +22,7 @@ type OpenInAppsState = {
const getAlwaysAvailableApps = (): OpenInAppOption[] => { const getAlwaysAvailableApps = (): OpenInAppOption[] => {
return OPEN_IN_APPS return OPEN_IN_APPS
.filter((app) => OPEN_IN_ALWAYS_AVAILABLE_APP_IDS.has(app.id)) .filter((app) => OPEN_IN_ALWAYS_AVAILABLE_APP_IDS.has(app.id))
.map((app) => ({ ...app })); .map((app) => ({ ...getPlatformOpenInApp(app) }));
}; };
const getStoredAppId = (): string => { const getStoredAppId = (): string => {
@@ -78,7 +78,7 @@ export const useOpenInAppsStore = create<OpenInAppsState>()((set, get) => ({
); );
const withIcons = filtered.map((app) => ({ const withIcons = filtered.map((app) => ({
...app, ...getPlatformOpenInApp(app),
iconDataUrl: iconMap.get(app.appName), iconDataUrl: iconMap.get(app.appName),
})); }));
+1
View File
@@ -6,6 +6,7 @@ declare global {
__OPENCHAMBER_MACOS_MAJOR__?: number; __OPENCHAMBER_MACOS_MAJOR__?: number;
__OPENCHAMBER_LOCAL_ORIGIN__?: string; __OPENCHAMBER_LOCAL_ORIGIN__?: string;
__OPENCHAMBER_ELECTRON__?: { runtime?: string }; __OPENCHAMBER_ELECTRON__?: { runtime?: string };
__OPENCHAMBER_PLATFORM__?: string;
__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__?: DesktopBootOutcome; __OPENCHAMBER_DESKTOP_BOOT_OUTCOME__?: DesktopBootOutcome;
} }
+20
View File
@@ -0,0 +1,20 @@
declare module '@xenova/transformers' {
export const env: {
allowLocalModels: boolean;
backends: {
onnx: {
wasm: {
numThreads: number;
};
};
};
};
export function pipeline(
task: 'automatic-speech-recognition',
model: string,
options?: {
progress_callback?: (info: { status?: string; file?: string; loaded?: number; total?: number }) => void;
},
): Promise<(input: Float32Array, options?: Record<string, unknown>) => Promise<{ text: string }>>;
}
+1 -1
View File
@@ -228,7 +228,7 @@
"watch:extension": "esbuild src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node --watch --sourcemap --main-fields=module,main", "watch:extension": "esbuild src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node --watch --sourcemap --main-fields=module,main",
"watch:webview": "vite build --watch", "watch:webview": "vite build --watch",
"type-check": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.webview.json", "type-check": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.webview.json",
"lint": "bunx eslint --ext .ts,.tsx src webview", "lint": "bun x eslint --ext .ts,.tsx src webview",
"package": "vsce package --no-dependencies" "package": "vsce package --no-dependencies"
}, },
"devDependencies": { "devDependencies": {
+5
View File
@@ -772,6 +772,11 @@ export const registerFsRoutes = (app, dependencies) => {
return res.status(400).json({ error: resolved.error }); return res.status(400).json({ error: resolved.error });
} }
const existing = await fsPromises.readFile(resolved.resolved, 'utf8').catch(() => null);
if (existing === content) {
return res.json({ success: true, path: resolved.resolved });
}
await fsPromises.mkdir(path.dirname(resolved.resolved), { recursive: true }); await fsPromises.mkdir(path.dirname(resolved.resolved), { recursive: true });
await fsPromises.writeFile(resolved.resolved, content, 'utf8'); await fsPromises.writeFile(resolved.resolved, content, 'utf8');
return res.json({ success: true, path: resolved.resolved }); return res.json({ success: true, path: resolved.resolved });
+61 -1
View File
@@ -90,7 +90,10 @@ const registerExec = ({ spawn }) => {
registerFsRoutes(app, { registerFsRoutes(app, {
os: { homedir: () => '/home/user' }, os: { homedir: () => '/home/user' },
path, path,
fsPromises: { stat: async () => ({ isDirectory: () => true }) }, fsPromises: {
realpath: async (targetPath) => targetPath,
stat: async () => ({ isDirectory: () => true }),
},
spawn, spawn,
crypto: { randomUUID: (() => { let n = 0; return () => `job-${n++}`; })() }, crypto: { randomUUID: (() => { let n = 0; return () => `job-${n++}`; })() },
normalizeDirectoryPath: (p) => p, normalizeDirectoryPath: (p) => p,
@@ -102,12 +105,69 @@ const registerExec = ({ spawn }) => {
return getRoute('POST', '/api/fs/exec'); return getRoute('POST', '/api/fs/exec');
}; };
const registerWrite = (fsPromises) => {
const { app, getRoute } = createRouteRegistry();
registerFsRoutes(app, {
os: { homedir: () => '/home/user' },
path: path.posix,
fsPromises: {
realpath: async (targetPath) => targetPath,
...fsPromises,
},
spawn: vi.fn(),
crypto: { randomUUID: () => 'job-0' },
normalizeDirectoryPath: (p) => p,
resolveProjectDirectory: async () => ({ directory: '/repo' }),
buildAugmentedPath: () => '/usr/bin',
resolveGitBinaryForSpawn: () => 'git',
openchamberUserConfigRoot: '/home/user/.config',
});
return getRoute('POST', '/api/fs/write');
};
const callExec = async (handler, body) => { const callExec = async (handler, body) => {
const res = createMockResponse(); const res = createMockResponse();
await handler({ body }, res); await handler({ body }, res);
return res; return res;
}; };
const callWrite = async (handler, body) => {
const res = createMockResponse();
await handler({ body }, res);
return res;
};
describe('fs write', () => {
it('does not rewrite a file when content is unchanged', async () => {
const fsPromises = {
readFile: vi.fn(async () => 'same'),
mkdir: vi.fn(async () => undefined),
writeFile: vi.fn(async () => undefined),
};
const handler = registerWrite(fsPromises);
const res = await callWrite(handler, { path: '/repo/file.txt', content: 'same' });
expect(res.body).toEqual({ success: true, path: '/repo/file.txt' });
expect(fsPromises.writeFile).not.toHaveBeenCalled();
});
it('writes a file when content changed', async () => {
const fsPromises = {
readFile: vi.fn(async () => 'old'),
mkdir: vi.fn(async () => undefined),
writeFile: vi.fn(async () => undefined),
};
const handler = registerWrite(fsPromises);
const res = await callWrite(handler, { path: '/repo/file.txt', content: 'new' });
expect(res.body).toEqual({ success: true, path: '/repo/file.txt' });
expect(fsPromises.mkdir).toHaveBeenCalledWith('/repo', { recursive: true });
expect(fsPromises.writeFile).toHaveBeenCalledWith('/repo/file.txt', 'new', 'utf8');
});
});
describe('fs exec git-read cache', () => { describe('fs exec git-read cache', () => {
beforeEach(() => { beforeEach(() => {
delete process.env.OPENCHAMBER_GIT_READ_CACHE_TTL_MS; delete process.env.OPENCHAMBER_GIT_READ_CACHE_TTL_MS;
@@ -101,6 +101,7 @@ The following functions are internal helpers used by exported functions:
- `tracking`: Upstream branch (e.g., 'origin/main'). - `tracking`: Upstream branch (e.g., 'origin/main').
- `ahead`: Number of commits ahead of upstream. - `ahead`: Number of commits ahead of upstream.
- `behind`: Number of commits behind upstream. - `behind`: Number of commits behind upstream.
- `upstreamComparison`: Optional comparison against `upstream/<current-branch>`, with `{ remote, branch, ahead, behind }`.
- `files`: Array of file objects with `path`, `index`, `working_dir` status codes. - `files`: Array of file objects with `path`, `index`, `working_dir` status codes.
- `isClean`: Boolean indicating if working tree is clean. - `isClean`: Boolean indicating if working tree is clean.
- `diffStats`: Object mapping file paths to `{ insertions, deletions }`. - `diffStats`: Object mapping file paths to `{ insertions, deletions }`.
+175 -14
View File
@@ -12,6 +12,10 @@ const execFileAsync = promisify(execFile);
const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf']; const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf'];
let resolvedGitBinary = null; let resolvedGitBinary = null;
const worktreeBootstrapState = new Map(); const worktreeBootstrapState = new Map();
const remoteExistenceCache = new Map();
const SIMPLE_GIT_SAFE_BINARY_PATTERN = /^([a-z]:)?([a-z0-9/.\\_~-]+)$/i;
const SIMPLE_GIT_UNSAFE_BINARY_WARNING = 'Invalid value supplied for custom binary, restricted characters must be removed';
const REMOTE_EXISTENCE_CACHE_TTL_MS = 30_000;
const gitIndexMutationQueues = new Map(); const gitIndexMutationQueues = new Map();
const WORKTREE_BOOTSTRAP_PENDING = 'pending'; const WORKTREE_BOOTSTRAP_PENDING = 'pending';
@@ -86,6 +90,30 @@ const normalizeGitExecutableCandidate = (candidate) => {
return trimmed; return trimmed;
}; };
const isSafeSimpleGitBinary = (candidate) => (
typeof candidate === 'string' && SIMPLE_GIT_SAFE_BINARY_PATTERN.test(candidate)
);
const createSimpleGit = (options) => {
if (!options?.unsafe?.allowUnsafeCustomBinary) {
return simpleGit(options);
}
const originalWarn = console.warn;
console.warn = (...args) => {
if (String(args[0] || '').includes(SIMPLE_GIT_UNSAFE_BINARY_WARNING)) {
return;
}
originalWarn(...args);
};
try {
return simpleGit(options);
} finally {
console.warn = originalWarn;
}
};
const listPathExecutableCandidates = (binaryName) => { const listPathExecutableCandidates = (binaryName) => {
const currentPath = process.env.PATH || ''; const currentPath = process.env.PATH || '';
const seen = new Set(); const seen = new Set();
@@ -133,22 +161,34 @@ const resolveGitBinary = () => {
.map((value) => (typeof value === 'string' ? value.trim() : '')) .map((value) => (typeof value === 'string' ? value.trim() : ''))
.filter(Boolean); .filter(Boolean);
for (const candidate of explicit) { for (const candidate of explicit) {
if (isExecutableFile(candidate)) { const normalized = normalizeGitExecutableCandidate(candidate);
resolvedGitBinary = candidate; if (isExecutableFile(normalized)) {
resolvedGitBinary = normalized;
return resolvedGitBinary; return resolvedGitBinary;
} }
} }
const discovered = [ const pathDiscovered = [
...listPathExecutableCandidates('git.exe'), ...listPathExecutableCandidates('git.exe'),
...listPathExecutableCandidates('git'), ...listPathExecutableCandidates('git'),
]
.map(normalizeGitExecutableCandidate)
.filter(Boolean)
.filter((candidate) => isExecutableFile(candidate));
if (pathDiscovered.length > 0) {
resolvedGitBinary = 'git';
return resolvedGitBinary;
}
const discovered = [
...listWindowsGitInstallCandidates(), ...listWindowsGitInstallCandidates(),
] ]
.map(normalizeGitExecutableCandidate) .map(normalizeGitExecutableCandidate)
.filter(Boolean) .filter(Boolean)
.filter((candidate) => isExecutableFile(candidate)); .filter((candidate) => isExecutableFile(candidate));
const preferredExe = discovered.find((candidate) => candidate.toLowerCase().endsWith('.exe')); const preferredExe = discovered.find((candidate) => isSafeSimpleGitBinary(candidate) && candidate.toLowerCase().endsWith('.exe'))
|| discovered.find((candidate) => candidate.toLowerCase().endsWith('.exe'));
resolvedGitBinary = preferredExe || discovered[0] || 'git.exe'; resolvedGitBinary = preferredExe || discovered[0] || 'git.exe';
return resolvedGitBinary; return resolvedGitBinary;
}; };
@@ -276,9 +316,9 @@ const createGit = async (directory) => {
const hasCustomBinary = typeof binary === 'string' && binary.trim() && binary !== 'git' && binary !== 'git.exe'; const hasCustomBinary = typeof binary === 'string' && binary.trim() && binary !== 'git' && binary !== 'git.exe';
const unsafe = hasCustomBinary ? { allowUnsafeCustomBinary: true } : undefined; const unsafe = hasCustomBinary ? { allowUnsafeCustomBinary: true } : undefined;
if (!directory) { if (!directory) {
return simpleGit({ env, spawnOptions, binary, unsafe }); return createSimpleGit({ env, spawnOptions, binary, unsafe });
} }
return simpleGit({ return createSimpleGit({
baseDir: normalizeDirectoryPath(directory), baseDir: normalizeDirectoryPath(directory),
env, env,
spawnOptions, spawnOptions,
@@ -677,6 +717,96 @@ const parseGitErrorText = (error) => {
.trim(); .trim();
}; };
const parseAheadBehindCounts = (value) => {
const [aheadRaw, behindRaw] = String(value || '').trim().split(/\s+/);
const ahead = parseInt(aheadRaw, 10);
const behind = parseInt(behindRaw, 10);
if (!Number.isFinite(ahead) || !Number.isFinite(behind)) {
return null;
}
return { ahead, behind };
};
const getRemoteExistenceCacheKey = (directory, remoteName) => {
const normalizedDirectory = normalizeDirectoryPath(directory) || '';
return `${path.resolve(normalizedDirectory)}\0${remoteName}`;
};
const hasRemote = async (git, directory, remoteName) => {
const remote = String(remoteName || '').trim();
if (!remote) {
return false;
}
const key = getRemoteExistenceCacheKey(directory, remote);
const cached = remoteExistenceCache.get(key);
if (cached && Date.now() - cached.checkedAt < REMOTE_EXISTENCE_CACHE_TTL_MS) {
return cached.exists;
}
const exists = await git
.raw(['remote', 'get-url', remote])
.then((value) => String(value || '').trim().length > 0)
.catch(() => false);
remoteExistenceCache.set(key, { exists, checkedAt: Date.now() });
return exists;
};
const buildRawGitOptions = (raw) => {
if (Array.isArray(raw)) {
return raw.map((value) => String(value || '').trim()).filter(Boolean);
}
if (!raw || typeof raw !== 'object') {
return [];
}
return Object.entries(raw).flatMap(([key, value]) => {
const option = String(key || '').trim();
if (!option || value === false) {
return [];
}
if (value === true || value == null) {
return [option];
}
return [option, String(value)];
});
};
const getRemoteBranchComparison = async (git, remoteName, branchName) => {
const remote = String(remoteName || '').trim();
const branch = String(branchName || '').trim();
if (!remote || !branch) {
return null;
}
const remoteRef = `refs/remotes/${remote}/${branch}`;
const exists = await git
.raw(['rev-parse', '--verify', remoteRef])
.then((value) => String(value || '').trim())
.catch(() => '');
if (!exists) {
return null;
}
const countsRaw = await git
.raw(['rev-list', '--left-right', '--count', `HEAD...${remoteRef}`])
.then((value) => String(value || '').trim())
.catch(() => '');
const counts = parseAheadBehindCounts(countsRaw);
if (!counts) {
return null;
}
return {
remote,
branch,
ahead: counts.ahead,
behind: counts.behind,
};
};
const isNotGitRepositoryError = (error) => { const isNotGitRepositoryError = (error) => {
const text = parseGitErrorText(error); const text = parseGitErrorText(error);
return /not a git repository/i.test(text); return /not a git repository/i.test(text);
@@ -1342,7 +1472,7 @@ export async function getStatus(directory, options = {}) {
const lightMode = options.mode === 'light'; const lightMode = options.mode === 'light';
try { try {
const { repoRoot, git } = await createRepositoryGitContext(directory); const { directoryPath, repoRoot, git } = await createRepositoryGitContext(directory);
// Use -uall to show all untracked files individually, not just directories // Use -uall to show all untracked files individually, not just directories
const status = await git.status(['-uall']); const status = await git.status(['-uall']);
@@ -1495,6 +1625,7 @@ export async function getStatus(directory, options = {}) {
let tracking = status.tracking || null; let tracking = status.tracking || null;
let ahead = status.ahead; let ahead = status.ahead;
let behind = status.behind; let behind = status.behind;
let upstreamComparison;
// When no upstream is configured (common for new worktree branches), Git doesn't report ahead/behind. // When no upstream is configured (common for new worktree branches), Git doesn't report ahead/behind.
// We still want to show the number of unpublished commits to the user. // We still want to show the number of unpublished commits to the user.
@@ -1514,6 +1645,15 @@ export async function getStatus(directory, options = {}) {
} }
} }
if (
!lightMode
&& status.current
&& (!tracking || !tracking.startsWith('upstream/'))
&& await hasRemote(git, directoryPath, 'upstream')
) {
upstreamComparison = await getRemoteBranchComparison(git, 'upstream', status.current);
}
// Check for in-progress operations // Check for in-progress operations
let mergeInProgress = null; let mergeInProgress = null;
let rebaseInProgress = null; let rebaseInProgress = null;
@@ -1574,6 +1714,7 @@ export async function getStatus(directory, options = {}) {
tracking, tracking,
ahead, ahead,
behind, behind,
upstreamComparison,
files: status.files.map((f) => ({ files: status.files.map((f) => ({
path: f.path, path: f.path,
index: f.index, index: f.index,
@@ -1984,9 +2125,20 @@ export async function pull(directory, options = {}) {
: options.options || {}; : options.options || {};
try { try {
const remote = String(options.remote || '').trim();
const requestedBranch = String(options.branch || '').trim();
let branch = requestedBranch;
if (remote && !branch) {
// simple-git only includes the remote when both remote and branch are provided.
// Resolve the current branch so selecting a remote in the UI really runs `git pull <remote> <branch>`.
const status = await git.status();
branch = String(status.current || '').trim();
}
const result = await git.pull( const result = await git.pull(
options.remote || 'origin', remote || 'origin',
options.branch, branch || undefined,
pullOptions pullOptions
); );
@@ -2240,11 +2392,20 @@ export async function fetch(directory, options = {}) {
const { git } = await createRepositoryGitContext(directory); const { git } = await createRepositoryGitContext(directory);
try { try {
await git.fetch( const remote = String(options.remote || '').trim();
options.remote || 'origin', const branch = String(options.branch || '').trim();
options.branch, const fetchOptions = options.options || {};
options.options || {}
); if (remote && !branch) {
// simple-git drops the remote when branch is omitted, so use raw to preserve `git fetch <remote>`.
await git.raw(['fetch', ...buildRawGitOptions(fetchOptions), remote]);
} else {
await git.fetch(
remote || 'origin',
branch || undefined,
fetchOptions
);
}
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
+55 -2
View File
@@ -1,6 +1,39 @@
import { describe, expect, it } from 'vitest'; import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { resolveBaseRefForLog, stageFiles, unstageFiles } from './service.js'; import { getStatus, resolveBaseRefForLog, stageFiles, unstageFiles } from './service.js';
const tempDirs = [];
const createTempDir = () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-git-service-'));
tempDirs.push(dir);
return dir;
};
const runGit = (cwd, args) => execFileSync('git', args, {
cwd,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
const canRunGit = () => {
try {
execFileSync('git', ['--version'], { stdio: 'ignore' });
return true;
} catch {
return false;
}
};
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe('resolveBaseRefForLog', () => { describe('resolveBaseRefForLog', () => {
it('returns the local ref unchanged when it exists, even if origin also exists', async () => { it('returns the local ref unchanged when it exists, even if origin also exists', async () => {
@@ -47,3 +80,23 @@ describe('git index path validation', () => {
await expect(unstageFiles('/repo', ['../secret.txt'])).rejects.toThrow('Path is outside repository: ../secret.txt'); await expect(unstageFiles('/repo', ['../secret.txt'])).rejects.toThrow('Path is outside repository: ../secret.txt');
}); });
}); });
describe('getStatus', () => {
it('handles repositories without upstream tracking', async () => {
if (!canRunGit()) {
return;
}
const repo = createTempDir();
runGit(repo, ['init', '-b', 'main']);
runGit(repo, ['config', 'user.email', 'test@example.com']);
runGit(repo, ['config', 'user.name', 'Test User']);
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
runGit(repo, ['add', 'README.md']);
runGit(repo, ['commit', '-m', 'Initial commit']);
await expect(getStatus(repo)).resolves.toMatchObject({
current: 'main',
});
});
});
@@ -51,6 +51,30 @@ export const createOpenCodeEnvRuntime = (deps) => {
} }
}; };
const resolveWindowsExecutablePath = (candidate) => {
if (process.platform !== 'win32' || typeof candidate !== 'string' || candidate.trim().length === 0) {
return candidate;
}
const trimmed = candidate.trim();
const ext = path.extname(trimmed).toLowerCase();
if (ext) {
return isExecutable(trimmed) ? trimmed : null;
}
const pathExt = process.env.PATHEXT || process.env.PathExt || '.COM;.EXE;.BAT;.CMD';
for (const rawExt of pathExt.split(';')) {
const normalizedExt = rawExt.trim();
if (!normalizedExt) continue;
const withExt = `${trimmed}${normalizedExt.startsWith('.') ? normalizedExt : `.${normalizedExt}`}`;
if (isExecutable(withExt)) {
return withExt;
}
}
return isExecutable(trimmed) ? trimmed : null;
};
const searchPathFor = (binaryName) => { const searchPathFor = (binaryName) => {
const trimmed = typeof binaryName === 'string' ? binaryName.trim() : ''; const trimmed = typeof binaryName === 'string' ? binaryName.trim() : '';
if (!trimmed) { if (!trimmed) {
@@ -59,7 +83,7 @@ export const createOpenCodeEnvRuntime = (deps) => {
const current = process.env.PATH || ''; const current = process.env.PATH || '';
const parts = current.split(path.delimiter).filter(Boolean); const parts = current.split(path.delimiter).filter(Boolean);
const candidateNames = [trimmed]; const candidateNames = [];
if (process.platform === 'win32' && !path.extname(trimmed)) { if (process.platform === 'win32' && !path.extname(trimmed)) {
const pathExt = process.env.PATHEXT || process.env.PathExt || '.COM;.EXE;.BAT;.CMD'; const pathExt = process.env.PATHEXT || process.env.PathExt || '.COM;.EXE;.BAT;.CMD';
@@ -73,6 +97,8 @@ export const createOpenCodeEnvRuntime = (deps) => {
} }
} }
candidateNames.push(trimmed);
for (const dir of parts) { for (const dir of parts) {
for (const candidateName of candidateNames) { for (const candidateName of candidateNames) {
const candidate = path.join(dir, candidateName); const candidate = path.join(dir, candidateName);
@@ -649,6 +675,9 @@ export const createOpenCodeEnvRuntime = (deps) => {
if (!trimmed) { if (!trimmed) {
return null; return null;
} }
if (process.platform === 'win32') {
return resolveWindowsExecutablePath(trimmed);
}
return isExecutable(trimmed) ? trimmed : null; return isExecutable(trimmed) ? trimmed : null;
}; };
@@ -669,10 +698,20 @@ export const createOpenCodeEnvRuntime = (deps) => {
return null; return null;
} }
const packageShim = path.join(nodeModulesDir, 'opencode-ai', 'bin', 'opencode.exe');
if (isExecutable(packageShim)) {
return packageShim;
}
for (const packageName of getWindowsNativeOpencodePackageNames()) { for (const packageName of getWindowsNativeOpencodePackageNames()) {
const candidate = path.join(nodeModulesDir, packageName, 'bin', 'opencode.exe'); const candidates = [
if (isExecutable(candidate)) { path.join(nodeModulesDir, packageName, 'bin', 'opencode.exe'),
return candidate; path.join(nodeModulesDir, 'opencode-ai', 'node_modules', packageName, 'bin', 'opencode.exe'),
];
for (const candidate of candidates) {
if (isExecutable(candidate)) {
return candidate;
}
} }
} }
@@ -816,6 +855,15 @@ export const createOpenCodeEnvRuntime = (deps) => {
const directBinary = normalizeExecutableCandidate(candidate); const directBinary = normalizeExecutableCandidate(candidate);
if (directBinary) { if (directBinary) {
const directExt = path.extname(directBinary).toLowerCase();
if (WINDOWS_BATCH_EXTENSIONS.has(directExt)) {
return {
binary: process.env.ComSpec || 'cmd.exe',
args: ['/d', '/s', '/c', 'call', directBinary],
wrapperType: 'cmd-wrapper',
};
}
return { return {
binary: directBinary, binary: directBinary,
args: [], args: [],
@@ -5,6 +5,11 @@ import { afterEach, describe, expect, it } from 'vitest';
import { createOpenCodeEnvRuntime } from './env-runtime.js'; import { createOpenCodeEnvRuntime } from './env-runtime.js';
const originalOpencodeBinary = process.env.OPENCODE_BINARY; const originalOpencodeBinary = process.env.OPENCODE_BINARY;
const originalComSpec = process.env.ComSpec;
const originalPath = process.env.PATH;
const originalSystemRoot = process.env.SystemRoot;
const originalWslBinary = process.env.WSL_BINARY;
const originalOpenChamberWslBinary = process.env.OPENCHAMBER_WSL_BINARY;
const originalPlatform = process.platform; const originalPlatform = process.platform;
const tempDirs = []; const tempDirs = [];
const itIf = (condition) => condition ? it : it.skip; const itIf = (condition) => condition ? it : it.skip;
@@ -32,9 +37,39 @@ afterEach(() => {
if (typeof originalOpencodeBinary === 'string') { if (typeof originalOpencodeBinary === 'string') {
process.env.OPENCODE_BINARY = originalOpencodeBinary; process.env.OPENCODE_BINARY = originalOpencodeBinary;
return; } else {
delete process.env.OPENCODE_BINARY;
}
if (typeof originalComSpec === 'string') {
process.env.ComSpec = originalComSpec;
} else {
delete process.env.ComSpec;
}
if (typeof originalPath === 'string') {
process.env.PATH = originalPath;
} else {
delete process.env.PATH;
}
if (typeof originalSystemRoot === 'string') {
process.env.SystemRoot = originalSystemRoot;
} else {
delete process.env.SystemRoot;
}
if (typeof originalWslBinary === 'string') {
process.env.WSL_BINARY = originalWslBinary;
} else {
delete process.env.WSL_BINARY;
}
if (typeof originalOpenChamberWslBinary === 'string') {
process.env.OPENCHAMBER_WSL_BINARY = originalOpenChamberWslBinary;
} else {
delete process.env.OPENCHAMBER_WSL_BINARY;
} }
delete process.env.OPENCODE_BINARY;
}); });
const createRuntime = (settings) => { const createRuntime = (settings) => {
@@ -103,14 +138,55 @@ describe('OpenCode env runtime', () => {
}); });
}); });
it('does not classify failed WSL resolution as an invalid configured binary in strict mode', async () => { it('does not classify WSL settings as a native invalid configured binary in strict mode', async () => {
setPlatform('win32'); setPlatform('win32');
const dir = createTempDir('openchamber-no-wsl-');
process.env.PATH = dir;
process.env.SystemRoot = dir;
process.env.WSL_BINARY = path.join(dir, 'missing-wsl.exe');
process.env.OPENCHAMBER_WSL_BINARY = path.join(dir, 'missing-openchamber-wsl.exe');
const { runtime } = createRuntime({ opencodeBinary: 'wsl:/usr/local/bin/opencode' }); const { runtime } = createRuntime({ opencodeBinary: 'wsl:/usr/local/bin/opencode' });
const rejection = runtime.applyOpencodeBinaryFromSettings({ strict: true }); const rejection = runtime.applyOpencodeBinaryFromSettings({ strict: true });
await expect(rejection).rejects.toThrow('uses WSL'); try {
const error = await rejection.catch((caught) => caught); await rejection;
expect(error.code).toBeUndefined(); expect(runtime.resolveManagedOpenCodeLaunchSpec('opencode').wrapperType).not.toBe('cmd-wrapper');
} catch (error) {
expect(error.message).toContain('uses WSL');
expect(error.code).toBeUndefined();
}
});
it('launches Windows cmd shims through cmd call without embedded quotes', () => {
setPlatform('win32');
process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe';
const dir = createTempDir('openchamber-opencode-cmd-');
const shim = path.join(dir, 'opencode.cmd');
fs.writeFileSync(shim, '@echo off\r\nexit /b 0\r\n');
const { runtime } = createRuntime({});
expect(runtime.resolveManagedOpenCodeLaunchSpec(shim)).toEqual({
binary: 'C:\\Windows\\System32\\cmd.exe',
args: ['/d', '/s', '/c', 'call', shim],
wrapperType: 'cmd-wrapper',
});
});
it('resolves npm OpenCode cmd shims to the packaged Windows executable', () => {
setPlatform('win32');
const npmDir = createTempDir('openchamber-opencode-npm-');
const shim = path.join(npmDir, 'opencode.cmd');
const nativeBinary = path.join(npmDir, 'node_modules', 'opencode-ai', 'bin', 'opencode.exe');
fs.mkdirSync(path.dirname(nativeBinary), { recursive: true });
fs.writeFileSync(nativeBinary, '');
fs.writeFileSync(shim, '@ECHO off\r\n"%dp0%\\node_modules\\opencode-ai\\bin\\opencode.exe" %*\r\n');
const { runtime } = createRuntime({});
expect(runtime.resolveManagedOpenCodeLaunchSpec(shim)).toEqual({
binary: nativeBinary,
args: [],
wrapperType: 'native-wrapper',
});
}); });
}); });
@@ -438,6 +438,44 @@ export const createSettingsRuntime = (deps) => {
} }
}; };
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const isTransientWindowsReplaceError = (error) => {
if (process.platform !== 'win32' || !error || typeof error !== 'object') {
return false;
}
return error.code === 'EPERM' || error.code === 'EACCES' || error.code === 'EBUSY';
};
const replaceFile = async (tmp, target) => {
const maxAttempts = process.platform === 'win32' ? 6 : 1;
let lastError = null;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
await fsPromises.rename(tmp, target);
return;
} catch (error) {
lastError = error;
if (!isTransientWindowsReplaceError(error) || attempt === maxAttempts) {
break;
}
await sleep(25 * attempt);
}
}
if (!isTransientWindowsReplaceError(lastError)) {
throw lastError;
}
// Windows can transiently reject atomic replacement when another process
// briefly opens the target file. Preserve atomic rename everywhere it works,
// but fall back to a direct replacement so settings persistence does not
// get permanently wedged on Windows desktop installs.
await fsPromises.copyFile(tmp, target);
await fsPromises.rm(tmp, { force: true });
};
const writeSettingsToDisk = async (settings) => { const writeSettingsToDisk = async (settings) => {
try { try {
await fsPromises.mkdir(path.dirname(SETTINGS_FILE_PATH), { recursive: true }); await fsPromises.mkdir(path.dirname(SETTINGS_FILE_PATH), { recursive: true });
@@ -447,7 +485,7 @@ export const createSettingsRuntime = (deps) => {
// read-modify-write wipe the settings file. // read-modify-write wipe the settings file.
const tmp = `${SETTINGS_FILE_PATH}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const tmp = `${SETTINGS_FILE_PATH}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
await fsPromises.writeFile(tmp, JSON.stringify(settings, null, 2), 'utf8'); await fsPromises.writeFile(tmp, JSON.stringify(settings, null, 2), 'utf8');
await fsPromises.rename(tmp, SETTINGS_FILE_PATH); await replaceFile(tmp, SETTINGS_FILE_PATH);
} catch (error) { } catch (error) {
console.warn('Failed to write settings file:', error); console.warn('Failed to write settings file:', error);
throw error; throw error;
@@ -82,4 +82,43 @@ describe('settings runtime', () => {
await cleanup(); await cleanup();
} }
}); });
it.skipIf(process.platform !== 'win32')('falls back when Windows blocks atomic settings replacement', async () => {
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-settings-runtime-'));
const settingsFilePath = path.join(tempRoot, 'settings.json');
const wrappedFs = {
...fsPromises,
rename: async () => {
const error = new Error('operation not permitted');
error.code = 'EPERM';
throw error;
},
};
const runtime = createSettingsRuntime({
fsPromises: wrappedFs,
path,
crypto,
SETTINGS_FILE_PATH: settingsFilePath,
sanitizeProjects: (projects) => Array.isArray(projects) ? projects : [],
sanitizeSettingsUpdate: (settings) => settings,
mergePersistedSettings: (_current, changes) => changes,
normalizeSettingsPaths: (settings) => ({ settings, changed: false }),
normalizeStringArray: (values) => Array.isArray(values) ? values.filter((value) => typeof value === 'string') : [],
formatSettingsResponse: (settings) => settings,
resolveDirectoryCandidate: (value) => value,
normalizeManagedRemoteTunnelHostname: (value) => value,
normalizeManagedRemoteTunnelPresets: (value) => value,
normalizeManagedRemoteTunnelPresetTokens: (value) => value,
syncManagedRemoteTunnelConfigWithPresets: async () => {},
upsertManagedRemoteTunnelToken: async () => {},
});
try {
await runtime.writeSettingsToDisk({ theme: 'dark' });
await expect(fsPromises.readFile(settingsFilePath, 'utf8')).resolves.toBe(JSON.stringify({ theme: 'dark' }, null, 2));
} finally {
await fsPromises.rm(tempRoot, { recursive: true, force: true });
}
});
}); });
+4 -1
View File
@@ -40,12 +40,15 @@ const toDirectoryListResult = (fallbackDirectory: string, payload: WebDirectoryL
}; };
export const createWebFilesAPI = (): FilesAPI => ({ export const createWebFilesAPI = (): FilesAPI => ({
async listDirectory(path: string): Promise<DirectoryListResult> { async listDirectory(path: string, options): Promise<DirectoryListResult> {
const target = normalizePath(path); const target = normalizePath(path);
const params = new URLSearchParams(); const params = new URLSearchParams();
if (target) { if (target) {
params.set('path', target); params.set('path', target);
} }
if (options?.respectGitignore) {
params.set('respectGitignore', 'true');
}
const response = await fetch(`/api/fs/list${params.toString() ? `?${params.toString()}` : ''}`); const response = await fetch(`/api/fs/list${params.toString() ? `?${params.toString()}` : ''}`);
+51 -5
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
import { spawn } from 'node:child_process'; import { spawn, spawnSync } from 'node:child_process';
import { existsSync, rmSync } from 'node:fs'; import { existsSync, rmSync } from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
@@ -11,12 +11,36 @@ const repoRoot = path.resolve(__dirname, '..');
const useDetachedChildren = process.platform === 'darwin'; const useDetachedChildren = process.platform === 'darwin';
const webRoot = path.join(repoRoot, 'packages/web'); const webRoot = path.join(repoRoot, 'packages/web');
const quoteWindowsCommandArg = (value) => `"${String(value).replace(/"/g, '""')}"`;
function resolveWindowsCommand(command) {
if (process.platform !== 'win32' || path.isAbsolute(command)) {
return command;
}
const result = spawnSync('where.exe', [command], { encoding: 'utf8', windowsHide: true });
if (result.error || result.status !== 0) {
return command;
}
const candidates = String(result.stdout || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
return candidates.find((entry) => /\.(exe|cmd|bat)$/i.test(entry)) || candidates[0] || command;
}
function run(label, command, args, env = {}, options = {}) { function run(label, command, args, env = {}, options = {}) {
return spawn(command, args, { const resolvedCommand = resolveWindowsCommand(command);
const isWindowsCommandScript = process.platform === 'win32' && /\.(cmd|bat)$/i.test(resolvedCommand);
const spawnCommand = isWindowsCommandScript ? (process.env.ComSpec || 'cmd.exe') : resolvedCommand;
const spawnArgs = isWindowsCommandScript
? ['/d', '/s', '/c', ['call', quoteWindowsCommandArg(resolvedCommand), ...args.map(quoteWindowsCommandArg)].join(' ')]
: args;
return spawn(spawnCommand, spawnArgs, {
cwd: options.cwd || repoRoot, cwd: options.cwd || repoRoot,
stdio: 'inherit', stdio: 'inherit',
env: { ...process.env, ...env }, env: { ...process.env, ...env },
detached: useDetachedChildren, detached: useDetachedChildren,
windowsVerbatimArguments: isWindowsCommandScript,
}).on('error', (error) => { }).on('error', (error) => {
console.error(`[dev:web:hmr] Failed to start ${label}:`, error); console.error(`[dev:web:hmr] Failed to start ${label}:`, error);
}); });
@@ -43,6 +67,17 @@ function waitForExit(child, timeoutMs) {
}); });
} }
function killWindowsProcessTree(pid) {
if (!pid) return;
try {
spawnSync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], {
stdio: 'ignore',
windowsHide: true,
});
} catch {
}
}
function signalChild(child, signal) { function signalChild(child, signal) {
if (!child || child.exitCode !== null || child.signalCode !== null) { if (!child || child.exitCode !== null || child.signalCode !== null) {
return; return;
@@ -70,6 +105,11 @@ async function stopChildTree(child) {
signalChild(child, 'SIGINT'); signalChild(child, 'SIGINT');
await waitForExit(child, 2500); await waitForExit(child, 2500);
if (process.platform === 'win32' && child.exitCode === null && child.signalCode === null) {
killWindowsProcessTree(child.pid);
await waitForExit(child, 1000);
}
if (child.exitCode === null && child.signalCode === null) { if (child.exitCode === null && child.signalCode === null) {
signalChild(child, 'SIGTERM'); signalChild(child, 'SIGTERM');
await waitForExit(child, 2500); await waitForExit(child, 2500);
@@ -112,9 +152,15 @@ function clearViteCache() {
clearViteCache(); clearViteCache();
const api = run('api', 'bun', ['run', '--cwd', 'packages/web', 'dev:server:watch'], { const api = run(
OPENCHAMBER_PORT: backendPort, 'api',
}); 'bun',
['x', 'nodemon', '--watch', 'server', '--ext', 'js', '--exec', `bun server/index.js --port ${backendPort}`],
{
OPENCHAMBER_PORT: backendPort,
},
{ cwd: webRoot },
);
const vite = run( const vite = run(
'vite', 'vite',
'bun', 'bun',