diff --git a/.github/workflows/release-desktop-smoke.yml b/.github/workflows/release-desktop-smoke.yml new file mode 100644 index 00000000..6660213e --- /dev/null +++ b/.github/workflows/release-desktop-smoke.yml @@ -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"; 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) }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9764939d..f7826855 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -185,7 +185,7 @@ jobs: # target Electron ABI before packaging, otherwise better-sqlite3/ # node-pty/bun-pty crash on require inside the packaged app. 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 run: | @@ -275,6 +275,68 @@ jobs: path: packages/electron/dist/latest-mac.yml 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: needs: [create-release, build-desktop-electron-macos] runs-on: macos-26 @@ -449,7 +511,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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 steps: - uses: actions/checkout@v4 @@ -465,6 +527,12 @@ jobs: pattern: latest-yml-*-apple-darwin 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 env: LATEST_YML_DIR: ${{ github.workspace }}/artifacts @@ -472,16 +540,18 @@ jobs: OPENCHAMBER_VERSION: ${{ needs.create-release.outputs.version }} 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 with: 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: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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 env: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} diff --git a/bun.lock b/bun.lock index e62d8f72..feb3f111 100644 --- a/bun.lock +++ b/bun.lock @@ -84,6 +84,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", + "node-addon-api": "7.1.1", "nodemon": "^3.1.7", "patch-package": "^8.0.0", "sharp": "^0.34.5", @@ -97,7 +98,7 @@ }, "packages/desktop": { "name": "@openchamber/desktop", - "version": "1.11.4", + "version": "1.11.6", "devDependencies": { "@tauri-apps/cli": "^2", "@types/node": "^24.3.1", @@ -106,7 +107,7 @@ }, "packages/electron": { "name": "@openchamber/electron", - "version": "1.11.4", + "version": "1.11.6", "dependencies": { "@openchamber/web": "workspace:*", "electron-context-menu": "^4.1.2", @@ -121,7 +122,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.11.4", + "version": "1.11.6", "dependencies": { "@base-ui/react": "^1.4.0", "@codemirror/autocomplete": "^6.20.0", @@ -157,6 +158,7 @@ "@simplewebauthn/browser": "13.3.0", "@tanstack/react-virtual": "^3.13.18", "@types/react-syntax-highlighter": "^15.5.13", + "@xenova/transformers": "^2.17.2", "beautiful-mermaid": "^1.1.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -220,7 +222,7 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.11.4", + "version": "1.11.6", "dependencies": { "@openchamber/ui": "workspace:*", "@opencode-ai/sdk": "^1.15.10", @@ -243,7 +245,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.11.4", + "version": "1.11.6", "bin": { "openchamber": "./bin/cli.js", }, diff --git a/package.json b/package.json index ee9bc86a..6340fb3e 100644 --- a/package.json +++ b/package.json @@ -152,6 +152,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", + "node-addon-api": "7.1.1", "nodemon": "^3.1.7", "patch-package": "^8.0.0", "@remixicon/react": "^4.7.0", diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index bec407b8..b918c648 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -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 log from 'electron-log/main.js'; 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 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 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 // ~/Library/Logs/OpenChamber/ (not ~/Library/Logs/@openchamber/electron/). app.setName('OpenChamber'); app.setAppUserModelId(APP_USER_MODEL_ID); app.commandLine.appendSwitch('proxy-bypass-list', '<-loopback>'); +if (!app.requestSingleInstanceLock()) { + app.exit(0); + process.exit(0); +} + try { process.chdir(os.homedir()); } catch { @@ -447,6 +449,35 @@ const readWindowState = () => { 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) => { if (!browserWindow || browserWindow.isDestroyed()) 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; }; +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.). // Probe the user's login shell once so the sidecar sees the same PATH / tool env as `$SHELL -il`. const loadShellEnv = () => { if (shellEnvProbed) return cachedShellEnv; shellEnvProbed = true; - if (process.platform === 'win32') return null; + if (process.platform === 'win32') { + cachedShellEnv = loadWindowsEnv(); + return cachedShellEnv; + } const shell = process.env.SHELL || '/bin/sh'; if (isNushell(shell)) return null; cachedShellEnv = probeShellEnv(shell, '-il') || probeShellEnv(shell, '-l'); @@ -742,7 +815,8 @@ const inheritUserShellEnv = () => { const homeDir = os.homedir(); 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)) { if (key === 'PATH') continue; @@ -752,8 +826,8 @@ const inheritUserShellEnv = () => { } const shellPath = typeof shellEnv.PATH === 'string' ? shellEnv.PATH : ''; - if (!currentPathLooksUserConfigured && shellPath) { - process.env.PATH = mergePathValues(shellPath, currentPath, ':'); + if ((process.platform === 'win32' || !currentPathLooksUserConfigured) && shellPath) { + 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 parseDeepLink = (raw) => { @@ -1176,24 +1259,53 @@ const readThemeSource = () => { 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 saved = restoreGeometry ? readWindowState() : null; const useSaved = saved && typeof saved.width === 'number' && typeof saved.height === 'number'; + const restoredBounds = useSaved ? clampWindowBoundsToVisibleWorkArea(saved) : null; const desktopLocalOrigin = state.localOrigin || ''; const desktopHome = os.homedir() || ''; 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 = { title: 'OpenChamber', - width: useSaved ? Math.max(saved.width, MIN_RESTORE_WINDOW_WIDTH) : 1280, - height: useSaved ? Math.max(saved.height, MIN_RESTORE_WINDOW_HEIGHT) : 800, + ...(Number.isFinite(restoredBounds?.x) && Number.isFinite(restoredBounds?.y) + ? { x: restoredBounds.x, y: restoredBounds.y } + : {}), + width: restoredBounds?.width ?? 1280, + height: restoredBounds?.height ?? 800, minWidth: MIN_WINDOW_WIDTH, minHeight: MIN_WINDOW_HEIGHT, + icon: windowIconPath, show: false, backgroundColor: '#151313', + frame: process.platform === 'win32' ? false : undefined, + autoHideMenuBar: autoHidesNativeMenuBar, // Tauri used an overlay title bar with explicit traffic-light placement. // 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. - titleBarStyle: process.platform === 'darwin' ? 'hidden' : 'default', + titleBarStyle: usesCustomTitleBar ? 'hidden' : 'default', + titleBarOverlay: titleBarOverlayEnabled, trafficLightPosition: process.platform === 'darwin' ? { x: 16, y: 17 } : undefined, webPreferences: { additionalArguments: [ @@ -1217,10 +1329,7 @@ const createBrowserWindow = ({ label, restoreGeometry, url }) => { const browserWindow = new BrowserWindow(options); browserWindow.__ocLabel = label || nextWindowLabel(); - - if (useSaved && Number.isFinite(saved.x) && Number.isFinite(saved.y)) { - browserWindow.setPosition(saved.x, saved.y); - } + browserWindow.__ocTitleBarOverlayEnabled = titleBarOverlayEnabled; if (useSaved && saved.maximized) { browserWindow.maximize(); @@ -1260,6 +1369,14 @@ const createBrowserWindow = ({ label, restoreGeometry, url }) => { emitToWindow(browserWindow, 'openchamber:window-resized'); 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', () => { debounceWindowStatePersist(browserWindow, false); }); @@ -1458,6 +1575,7 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj height: MINI_CHAT_WINDOW_HEIGHT, minWidth: MINI_CHAT_MIN_WINDOW_WIDTH, minHeight: MINI_CHAT_MIN_WINDOW_HEIGHT, + icon: getWindowIconPath(), show: false, backgroundColor: '#151313', titleBarStyle: process.platform === 'darwin' ? 'hidden' : 'default', @@ -1557,12 +1675,16 @@ const setMiniChatPinned = (browserWindow, pinned) => { }; const resolveInitialUrl = async () => { - const localUrl = isDev && await waitForHealth('http://127.0.0.1:3901', 5_000, 100) - ? 'http://127.0.0.1:3901' + const hmrApiPort = process.env.OPENCHAMBER_HMR_API_PORT || '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(); - const localUiUrl = isDev && await waitForHealth('http://127.0.0.1:5173', 8_000, 100) - ? 'http://127.0.0.1:5173' + const localUiUrl = isDev && await waitForHealth(hmrUiUrl, 8_000, 100) + ? hmrUiUrl : localUrl; state.sidecarUrl = localUrl; @@ -1638,6 +1760,9 @@ const setupAutoUpdater = () => { }); 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({ event: 'Progress', data: { @@ -1650,12 +1775,14 @@ const setupAutoUpdater = () => { autoUpdater.on('update-downloaded', (info) => { log.info(`[electron] update-downloaded version=${info?.version || 'unknown'}`); + setTaskbarProgress(-1); if (state.pendingUpdate) { state.pendingUpdate.downloaded = true; } }); autoUpdater.on('error', (err) => { + setTaskbarProgress(-1); log.error('[electron] autoUpdater error', err); }); }; @@ -1824,6 +1951,138 @@ const CLI_BY_APP_ID = { 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 }) => { if (appId === 'finder') { return [{ program: 'open', args: [projectPath] }]; @@ -1869,10 +2128,66 @@ const buildOpenFileSpecs = ({ filePath, appId, appName }) => { 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) => { + 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 = []; 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) { failures.push(`${spec.program}: ${result.error.message}`); continue; @@ -2095,34 +2410,45 @@ const handleInvoke = async (browserWindow, command, args = {}) => { } 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 appId = typeof args.appId === 'string' ? args.appId.trim().toLowerCase() : ''; const appName = typeof args.appName === 'string' ? args.appName.trim() : ''; if (!projectPath || !appId || !appName) { 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); return null; } 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 appId = typeof args.appId === 'string' ? args.appId.trim().toLowerCase() : ''; const appName = typeof args.appName === 'string' ? args.appName.trim() : ''; if (!filePath || !appId || !appName) { 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); return null; } case 'desktop_filter_installed_apps': { + if (process.platform === 'win32') { + return buildWindowsInstalledApps(args.apps).map((app) => app.name); + } if (process.platform !== 'darwin') { 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': { + if (process.platform === 'win32') { + return []; + } if (process.platform !== 'darwin') { 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': { - if (process.platform !== 'darwin') { - throw new Error('desktop_get_installed_apps is only supported on macOS'); - } const cachePath = buildInstalledAppsCachePath(); const now = Math.floor(Date.now() / 1000); let cache = null; @@ -2163,11 +2489,16 @@ const handleInvoke = async (browserWindow, command, args = {}) => { const hasCache = Boolean(cache); const isCacheStale = !cache || (now - Number(cache.updatedAt || 0)) > INSTALLED_APPS_CACHE_TTL_SECS; 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.writeFile(cachePath, JSON.stringify({ updatedAt: now, apps }, null, 2)); 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) { void refresh(); } @@ -2216,6 +2547,14 @@ const handleInvoke = async (browserWindow, command, args = {}) => { } else { nativeTheme.themeSource = 'system'; } + if (canUseTitleBarOverlay(browserWindow)) { + const useDark = nativeTheme.shouldUseDarkColors; + browserWindow.setTitleBarOverlay({ + color: useDark ? '#151313' : '#f5f5f4', + symbolColor: useDark ? '#fafaf9' : '#1c1917', + height: 48, + }); + } return null; } @@ -2271,40 +2610,45 @@ const handleInvoke = async (browserWindow, command, args = {}) => { if (!state.pendingUpdate) { throw new Error('No pending update'); } + setTaskbarProgress(0.01); emitToAllWindows('openchamber:update-progress', mapUpdaterProgressEvent({ event: 'Started', data: { contentLength: null, }, })); - if (!state.pendingUpdate.electronUpdate) { - throw new Error('Electron updater metadata is not available for this build'); + try { + 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': { const applyUpdate = Boolean(state.pendingUpdate?.downloaded && app.isPackaged); @@ -2422,6 +2766,38 @@ const handleInvoke = async (browserWindow, command, args = {}) => { } 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': 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({ showInspectElement: isDev, showSaveImageAs: true, @@ -2612,6 +3101,10 @@ const COMMANDS_SAFE_FOR_REMOTE = new Set([ 'desktop_set_window_theme', 'desktop_is_window_fullscreen', '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_lan_address', 'desktop_capture_page_rect', @@ -2733,6 +3226,8 @@ app.whenReady().then(async () => { if (process.platform === 'darwin') { Menu.setApplicationMenu(buildMacMenu()); + } else { + Menu.setApplicationMenu(buildAutoHiddenMenu()); } if (process.platform === 'darwin' && app.isPackaged) { diff --git a/packages/electron/package.json b/packages/electron/package.json index 20aadead..971c3b86 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -20,7 +20,8 @@ "desktopPrerequisites": [ "Electron runtime dependencies installed via bun install", "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": { "dev": "node ./scripts/electron-dev.mjs", @@ -29,7 +30,7 @@ "bundle:main": "bun ./scripts/bundle-main.mjs", "generate:macos-icon": "node ./scripts/generate-macos-icon-assets.cjs", "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", "type-check": "node --check ./main.mjs && node --check ./preload.mjs", "lint": "node -e \"process.exit(0)\"" @@ -45,6 +46,10 @@ { "from": "resources/web-dist", "to": "web-dist" + }, + { + "from": "resources/icons/icon.ico", + "to": "icons/icon.ico" } ], "afterPack": "scripts/after-pack.cjs", @@ -57,6 +62,7 @@ "mac": { "category": "public.app-category.developer-tools", "icon": "resources/icons/icon.icns", + "artifactName": "${productName}-${version}-mac-${arch}.${ext}", "extendInfo": { "CFBundleIconName": "AppIcon" }, @@ -70,6 +76,21 @@ "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": { "sign": true, "title": "${productName} ${version}", diff --git a/packages/electron/preload.mjs b/packages/electron/preload.mjs index 2de3f085..9fb042f5 100644 --- a/packages/electron/preload.mjs +++ b/packages/electron/preload.mjs @@ -66,6 +66,8 @@ contextBridge.exposeInMainWorld('__OPENCHAMBER_ELECTRON__', { runtime: 'electron', }); +contextBridge.exposeInMainWorld('__OPENCHAMBER_PLATFORM__', process.platform); + // Note: bootOutcome must stay writable from the main world's initScript so // re-navigations (host switch via deep link) can refresh it. contextBridge- // exposed globals are read-only, which blocks that update — rely solely on diff --git a/packages/electron/resources/icons/icon.ico b/packages/electron/resources/icons/icon.ico new file mode 100644 index 00000000..04671682 Binary files /dev/null and b/packages/electron/resources/icons/icon.ico differ diff --git a/packages/electron/scripts/build-web-assets.mjs b/packages/electron/scripts/build-web-assets.mjs index 9cca3521..3d487271 100644 --- a/packages/electron/scripts/build-web-assets.mjs +++ b/packages/electron/scripts/build-web-assets.mjs @@ -14,8 +14,17 @@ const resourcesDir = path.join(electronDir, 'resources'); const resourcesWebDistDir = path.join(resourcesDir, 'web-dist'); const webDistDir = path.join(webDir, 'dist'); +const quoteWindowsCommandArg = (value) => `"${String(value).replace(/"/g, '""')}"`; + 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.status !== 0) { throw new Error(`Command failed: ${cmd} ${args.join(' ')}`); @@ -26,6 +35,12 @@ const resolveBun = () => { if (typeof process.env.BUN === 'string' && 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 resolved = (result.stdout || '').trim(); return resolved || 'bun'; diff --git a/packages/electron/scripts/electron-dev.mjs b/packages/electron/scripts/electron-dev.mjs index 46c2ce43..816a5a83 100644 --- a/packages/electron/scripts/electron-dev.mjs +++ b/packages/electron/scripts/electron-dev.mjs @@ -1,5 +1,6 @@ #!/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 { fileURLToPath } from 'node:url'; @@ -7,13 +8,39 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, '../../..'); 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 = {}) { - 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, env: { ...process.env, OPENCHAMBER_ELECTRON_DEV: '1' }, stdio: 'inherit', detached: process.platform !== 'win32', + windowsVerbatimArguments: isWindowsCommandScript, ...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) { if (!child || child.exitCode !== null || child.signalCode !== null) { return; @@ -66,6 +142,11 @@ async function stopChildTree(child) { signalChild(child, 'SIGINT'); 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) { signalChild(child, 'SIGTERM'); await waitForExit(child, 2500); @@ -78,16 +159,28 @@ async function stopChildTree(child) { } async function main() { + const hmrApiPort = String(await findAvailablePort(preferredHmrApiPort)); + const hmrUiPort = String(await findAvailablePort(preferredHmrUiPort)); + const devServer = spawnProcess('node', ['./scripts/dev-web-hmr.mjs'], { env: { ...process.env, OPENCHAMBER_ELECTRON_DEV: '1', - OPENCHAMBER_HMR_UI_PORT: '5173', - OPENCHAMBER_HMR_API_PORT: '3901', + OPENCHAMBER_HMR_UI_PORT: hmrUiPort, + 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', }, }); - const electron = spawnProcess('npx', ['electron', './main.mjs'], { cwd: electronDir }); let cleaning = false; const teardown = async (code) => { diff --git a/packages/electron/scripts/package.mjs b/packages/electron/scripts/package.mjs new file mode 100644 index 00000000..bfceffa9 --- /dev/null +++ b/packages/electron/scripts/package.mjs @@ -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); +}); diff --git a/packages/electron/scripts/rebuild-native.mjs b/packages/electron/scripts/rebuild-native.mjs index eb381df7..cd2a08bd 100644 --- a/packages/electron/scripts/rebuild-native.mjs +++ b/packages/electron/scripts/rebuild-native.mjs @@ -1,5 +1,8 @@ #!/usr/bin/env node 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 { createRequire } from 'node:module'; import { rebuild } from '@electron/rebuild'; @@ -14,17 +17,140 @@ const require = createRequire(import.meta.url); const electronPkg = require('electron/package.json'); 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}...`); // Rebuild against the hoisted root node_modules (bun workspace layout). // force=true re-links regardless of cached state; prebuild-install lookup is // bypassed by @electron/rebuild in favor of direct node-gyp builds. -await rebuild({ - buildPath: repoRoot, - electronVersion, - force: true, - arch: process.env.ELECTRON_BUILDER_ARCH || process.arch, - onlyModules: ['better-sqlite3', 'node-pty', 'bun-pty'], -}); +const rebuildPath = createWindowsRebuildPath(repoRoot); +let cleanupNodeAddonApi = async () => {}; +try { + cleanupNodeAddonApi = await ensureWindowsNodeAddonApiForNodePty(rebuildPath.buildPath); + await rebuild({ + 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'); diff --git a/packages/ui/package.json b/packages/ui/package.json index 1ae74c3b..b7519b2b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -45,6 +45,7 @@ "@simplewebauthn/browser": "13.3.0", "@tanstack/react-virtual": "^3.13.18", "@types/react-syntax-highlighter": "^15.5.13", + "@xenova/transformers": "^2.17.2", "beautiful-mermaid": "^1.1.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/packages/ui/src/components/auth/SessionAuthGate.tsx b/packages/ui/src/components/auth/SessionAuthGate.tsx index edf3dd43..9efed1fd 100644 --- a/packages/ui/src/components/auth/SessionAuthGate.tsx +++ b/packages/ui/src/components/auth/SessionAuthGate.tsx @@ -55,29 +55,39 @@ const submitPassword = async (password: string, trustDevice: boolean): Promise = ({ children }) => ( -
+const AuthShell: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const titlebarDragStyle = React.useMemo(() => { + return { + height: 'var(--oc-wco-titlebar-height, 0px)', + right: 'var(--oc-wco-right-inset, 0px)', + }; + }, []); + + return (
-
-
- {children} + className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background text-foreground" + style={{ fontFamily: '"Inter", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif' }} + > +
+
+
+
+ {children} +
-
-); + ); +}; const LoadingScreen: React.FC = () => (
diff --git a/packages/ui/src/components/chat/FileAttachment.tsx b/packages/ui/src/components/chat/FileAttachment.tsx index b776f652..53d84189 100644 --- a/packages/ui/src/components/chat/FileAttachment.tsx +++ b/packages/ui/src/components/chat/FileAttachment.tsx @@ -316,7 +316,7 @@ FileChip.displayName = 'FileChip'; const VSCodeFileChip = memo(({ file, onRemove }: FileChipProps) => { const { t } = useI18n(); const { displayName, extension } = useFileDetails(file); - + // Detect selection-style attachments: ends with ":N" or ":N-M" const isSelectionAttachment = /:\d+(?:-\d+)?$/.test(displayName); @@ -359,7 +359,7 @@ interface AttachedFilesListProps { onShowPopup?: (content: ToolPopupContent) => void; } -export const AttachedVSCodeFileChips = memo(({ onShowPopup }: AttachedFilesListProps) => { +export const AttachedVSCodeFileChips = memo(({ onShowPopup }: AttachedFilesListProps) => { const attachedFiles = useInputStore((state) => state.attachedFiles); const removeAttachedFile = useInputStore((state) => state.removeAttachedFile); diff --git a/packages/ui/src/components/chat/MobileSessionStatusBar.tsx b/packages/ui/src/components/chat/MobileSessionStatusBar.tsx index cf1f1973..8c0fb23f 100644 --- a/packages/ui/src/components/chat/MobileSessionStatusBar.tsx +++ b/packages/ui/src/components/chat/MobileSessionStatusBar.tsx @@ -491,7 +491,6 @@ function SessionItem({ title={`Sub-session: ${getSessionTitle(child)}`} >
); @@ -692,7 +691,6 @@ function SessionStatusHeader({ title={`Sub-session: ${child.session.title || 'Untitled'}`} >
); diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index 62cfe0d0..1f929d3c 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -39,7 +39,6 @@ import { useI18n } from '@/lib/i18n'; import { useOpenCodeReadiness } from '@/hooks/useOpenCodeReadiness'; import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts'; - type IconComponent = IconName; type ProviderModel = Record & { id?: string; name?: string }; diff --git a/packages/ui/src/components/chat/message/MessageHeader.tsx b/packages/ui/src/components/chat/message/MessageHeader.tsx index a70bf88d..aedd2e63 100644 --- a/packages/ui/src/components/chat/message/MessageHeader.tsx +++ b/packages/ui/src/components/chat/message/MessageHeader.tsx @@ -39,7 +39,6 @@ const MessageHeader: React.FC = ({ isUser, providerID, agent /> ) : ( )}
diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 80e4bc2c..bdbdc449 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -729,7 +729,6 @@ const ToolScrollableSection: React.FC = ({ disableHorizontal ? 'overflow-y-auto overflow-x-hidden' : 'overflow-auto', className, )} - >
{children} diff --git a/packages/ui/src/components/icon/sprite.ts b/packages/ui/src/components/icon/sprite.ts index 23387a55..fa282964 100644 --- a/packages/ui/src/components/icon/sprite.ts +++ b/packages/ui/src/components/icon/sprite.ts @@ -153,6 +153,7 @@ export const iconSpriteData = { "lock-unlock": ``, "loop-right-ai": ``, "macbook": ``, + "menu-2": ``, "menu-fold-2": ``, "menu-search": ``, "message-2": ``, diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 46d81c95..c4116cf6 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -77,7 +77,7 @@ type HeaderIconActionButtonProps = { visible?: boolean; title: string; ariaLabel: string; - onClick: () => void; + onClick: React.MouseEventHandler; className?: string; Icon: IconName; 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 ( +
+ + + +
+ ); +}); + type DesktopGitHubControlProps = { isMobile: boolean; githubAuthStatus: GitHubAuthStatus | null; @@ -731,6 +808,13 @@ export const Header: React.FC = ({ 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(() => { if (typeof window === 'undefined') { return null; @@ -1262,6 +1346,16 @@ export const Header: React.FC = ({ toggleSidebar(); }, [blurActiveElement, isMobile, isSessionSwitcherOpen, setSessionSwitcherOpen, toggleSidebar]); + const handleOpenWindowsAppMenu = React.useCallback((event: React.MouseEvent) => { + 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(() => { void invokeDesktop('desktop_open_draft_mini_chat_window', { directory: normalize(openDirectory || activeProject?.path || ''), @@ -1454,7 +1548,7 @@ export const Header: React.FC = ({ }, [isDesktopApp, isMacPlatform, macosMajorVersion]); const webWindowControlsOverlayStyle = React.useMemo(() => { - if (isDesktopApp || isVSCode) { + if ((isDesktopApp && !isWindowsElectronDesktop) || isVSCode) { return undefined; } @@ -1466,7 +1560,7 @@ export const Header: React.FC = ({ minHeight: '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(() => { if (typeof document === 'undefined') { @@ -1884,6 +1978,15 @@ export const Header: React.FC = ({ role="tablist" aria-label={t('header.navigation.mainAria')} > + {isWindowsElectronDesktop ? ( + + ) : null} = ({ Icon={'picture-in-picture-2'} /> {desktopSidebarActions} +
diff --git a/packages/ui/src/components/layout/SidebarFilesTree.tsx b/packages/ui/src/components/layout/SidebarFilesTree.tsx index 1295fb03..687bb560 100644 --- a/packages/ui/src/components/layout/SidebarFilesTree.tsx +++ b/packages/ui/src/components/layout/SidebarFilesTree.tsx @@ -319,7 +319,7 @@ const FileRow: React.FC = ({ export const SidebarFilesTree: React.FC = () => { const { t } = useI18n(); - const { files, runtime } = useRuntimeAPIs(); + const { files } = useRuntimeAPIs(); const currentDirectory = useEffectiveDirectory() ?? ''; const root = normalizePath(currentDirectory.trim()); const showHidden = useDirectoryShowHidden(); @@ -335,6 +335,7 @@ export const SidebarFilesTree: React.FC = () => { const [searching, setSearching] = React.useState(false); const [childrenByDir, setChildrenByDir] = React.useState>({}); + const [loadErrorsByDir, setLoadErrorsByDir] = React.useState>({}); const loadedDirsRef = React.useRef>(new Set()); const inFlightDirsRef = React.useRef>(new Set()); @@ -419,7 +420,7 @@ export const SidebarFilesTree: React.FC = () => { inFlightDirsRef.current.add(normalizedDir); const respectGitignore = !showGitignored; - const listPromise = runtime.isDesktop + const listPromise = files.listDirectory ? files.listDirectory(normalizedDir, { respectGitignore }).then((result) => result.entries.map((entry) => ({ name: entry.name, path: entry.path, @@ -437,25 +438,34 @@ export const SidebarFilesTree: React.FC = () => { loadedDirsRef.current = new Set(loadedDirsRef.current); loadedDirsRef.current.add(normalizedDir); + setLoadErrorsByDir((prev) => { + if (!prev[normalizedDir]) return prev; + const next = { ...prev }; + delete next[normalizedDir]; + return next; + }); setChildrenByDir((prev) => ({ ...prev, [normalizedDir]: mapped })); }) - .catch(() => { - setChildrenByDir((prev) => ({ + .catch((error) => { + const message = error instanceof Error ? error.message : String(error ?? ''); + console.error('Failed to load sidebar directory:', error); + setLoadErrorsByDir((prev) => ({ ...prev, - [normalizedDir]: prev[normalizedDir] ?? [], + [normalizedDir]: message, })); }) .finally(() => { inFlightDirsRef.current = new Set(inFlightDirsRef.current); inFlightDirsRef.current.delete(normalizedDir); }); - }, [files, mapDirectoryEntries, runtime.isDesktop, showGitignored]); + }, [files, mapDirectoryEntries, showGitignored]); const refreshRoot = React.useCallback(async () => { if (!root) return; loadedDirsRef.current = new Set(); inFlightDirsRef.current = new Set(); + setLoadErrorsByDir({}); setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {})); await loadDirectory(root); @@ -484,6 +494,7 @@ export const SidebarFilesTree: React.FC = () => { loadedDirsRef.current = new Set(); inFlightDirsRef.current = new Set(); + setLoadErrorsByDir({}); setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {})); void loadDirectory(root); }, [loadDirectory, root, showHidden, showGitignored]); @@ -807,6 +818,7 @@ export const SidebarFilesTree: React.FC = () => { } const hasTree = Boolean(root && childrenByDir[root]); + const rootLoadError = root ? loadErrorsByDir[root] : null; return (
@@ -923,6 +935,14 @@ export const SidebarFilesTree: React.FC = () => { ); }) + ) : rootLoadError ? ( +
  • + {rootLoadError} + +
  • ) : hasTree && root ? ( renderTree(root, 0) ) : ( diff --git a/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx b/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx index 9f8ba939..cd6958de 100644 --- a/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx +++ b/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx @@ -36,7 +36,7 @@ const guessLabelFromSource = (value: string) => { : trimmed.startsWith("git@") ? "ssh" : "shorthand"; - + if (urlFormat === 'ssh') { return `${trimmed.split(":")[1].replace(/\.git$/i, '')}`; } diff --git a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx index 59698299..df3472a1 100644 --- a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx +++ b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx @@ -777,7 +777,6 @@ export function SessionGroupSection(props: Props): React.ReactNode { @@ -810,7 +809,6 @@ export function SessionGroupSection(props: Props): React.ReactNode { ) : ( ) ) : null} diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 1831d30a..e8fe9110 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -290,6 +290,32 @@ const isFileMissingError = (error: unknown): boolean => { const MAX_VIEW_CHARS = 200_000; 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 => { if (typeof window === 'undefined') { @@ -763,6 +789,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const [draftContent, setDraftContent] = React.useState(''); const [isSaving, setIsSaving] = React.useState(false); + const [loadedFileLineEnding, setLoadedFileLineEnding] = React.useState('\n'); const dialogInputRef = React.useRef(null); const autoSaveTimerRef = React.useRef | null>(null); const lastLoadedFileStatRef = React.useRef(null); @@ -1007,7 +1034,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const isCurrentRequest = () => activeDirectoryLoadIdsRef.current.get(normalizedDir) === requestId; const respectGitignore = !showGitignored; - const listPromise = runtime.isDesktop + const listPromise = files.listDirectory ? files.listDirectory(normalizedDir, { respectGitignore }).then((result) => result.entries.map((entry) => ({ name: entry.name, path: entry.path, @@ -1051,7 +1078,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { inFlightDirsRef.current = new Set(inFlightDirsRef.current); inFlightDirsRef.current.delete(normalizedDir); }); - }, [files, mapDirectoryEntries, runtime.isDesktop, showGitignored]); + }, [files, mapDirectoryEntries, showGitignored]); const refreshRoot = React.useCallback(async () => { if (!root) { @@ -1446,7 +1473,8 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { setIsSaving(true); 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) { toast.error(t('filesView.toast.writeFileFailed')); return false; @@ -1467,7 +1495,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { } finally { setIsSaving(false); } - }, [draftContent, files, isDirty, readFileStat, selectedFile, t]); + }, [draftContent, files, isDirty, loadedFileLineEnding, readFileStat, selectedFile, t]); React.useEffect(() => { if (!isDirty) { @@ -1622,10 +1650,12 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { if (!isCurrentLoad()) { return; } - setFileContent(content); - setDraftContent(content.length > MAX_VIEW_CHARS - ? `${content.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …` - : content); + const editorContent = normalizeEditorLineEndings(content); + setLoadedFileLineEnding(detectFileLineEnding(content)); + setFileContent(editorContent); + setDraftContent(editorContent.length > MAX_VIEW_CHARS + ? `${editorContent.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …` + : editorContent); setLoadedFilePath(node.path); void readFileStat(node.path, readOptions) .then((stat) => { diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 96987710..a8b43567 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -1001,6 +1001,14 @@ export const GitView: React.FC = () => { }; }, [changeEntries, currentDirectory, git, prefetchDiffs, stagedChangeEntries, visibleChangePaths]); + const getPushedRemoteName = (result?: Awaited>) => { + 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, remote?: GitRemote) => { if (!currentDirectory) return; setSyncAction(action); @@ -1035,8 +1043,8 @@ export const GitView: React.FC = () => { : t('gitView.toast.pulledFilesPlural', { count: result.files.length, name: remote.name }) ); } else if (action === 'push') { - await git.gitPush(currentDirectory); - toast.success(t('gitView.toast.pushedToUpstream')); + const result = await git.gitPush(currentDirectory); + toast.success(t('gitView.toast.pushedToUpstream', { name: getPushedRemoteName(result) })); } else if (action === 'sync') { if (!remote) { 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 }) ); } else if (pushedChanges) { - toast.success(t('gitView.toast.pushedToUpstream')); + toast.success(t('gitView.toast.pushedToUpstream', { name: remote.name })); } else { toast.success(t('gitView.toast.alreadyUpToDate')); } @@ -1150,56 +1158,8 @@ export const GitView: React.FC = () => { await refreshStatusAndBranches(); if (options.pushAfter) { - setSyncAction('sync'); - const trackingRemoteName = status?.tracking?.split('/')[0]; - 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')); - } - + const result = await git.gitPush(currentDirectory); + toast.success(t('gitView.toast.pushedToUpstream', { name: getPushedRemoteName(result) })); triggerFireworks(); await refreshStatusAndBranches(false); } else { @@ -2257,7 +2217,7 @@ export const GitView: React.FC = () => { ); } - if (isLoading && isGitRepo === null) { + if (isGitRepo === null || (isGitRepo === true && !status)) { return (
    diff --git a/packages/ui/src/components/views/git/GitHeader.tsx b/packages/ui/src/components/views/git/GitHeader.tsx index dd8cdf8b..830399c1 100644 --- a/packages/ui/src/components/views/git/GitHeader.tsx +++ b/packages/ui/src/components/views/git/GitHeader.tsx @@ -13,7 +13,12 @@ import type { IconName } from "@/components/icon/icons"; import { BranchSelector } from './BranchSelector'; import { WorktreeBranchDisplay } from './WorktreeBranchDisplay'; 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'; type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null; @@ -178,6 +183,49 @@ export const IdentityDropdown: React.FC = ({ ); }; +interface UpstreamStatusPillProps { + comparison: GitRemoteComparison; + trackingBranch: string | null; + tooltipDelayMs?: number; +} + +const UpstreamStatusPill: React.FC = ({ + 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 ( + + +
    + + {target} + {isSynced ? ( + {t('gitView.header.upstreamSynced')} + ) : ( + + {comparison.ahead > 0 ? ( + ↑{comparison.ahead} + ) : null} + {comparison.behind > 0 ? ( + ↓{comparison.behind} + ) : null} + + )} +
    +
    + {tooltipText} +
    + ); +}; + export const GitHeader: React.FC = ({ status, localBranches, @@ -264,13 +312,20 @@ export const GitHeader: React.FC = ({ /> ); + const upstreamStatusPill = status.upstreamComparison ? ( + + ) : null; + const identityControl = ( ); @@ -293,7 +348,6 @@ export const GitHeader: React.FC = ({ onCheckout={onCheckoutBranch} onCreate={onCreateBranch} remotes={remotes} - /> )}
    @@ -317,6 +371,9 @@ export const GitHeader: React.FC = ({ className="h-full" />
    + {upstreamStatusPill ? ( +
    {upstreamStatusPill}
    + ) : null}
    {syncButtons}
    ) : null} diff --git a/packages/ui/src/hooks/useBrowserVoice.ts b/packages/ui/src/hooks/useBrowserVoice.ts index 539ae567..ed794d6c 100644 --- a/packages/ui/src/hooks/useBrowserVoice.ts +++ b/packages/ui/src/hooks/useBrowserVoice.ts @@ -343,7 +343,7 @@ export function useBrowserVoice(): UseBrowserVoiceReturn { normalizedError.includes('network') || normalizedError.includes('connection') || normalizedError.includes('check connection'); - + if (isNetworkError) { console.error('[useBrowserVoice] Network error — staying in error state:', errorMsg); setError(errorMsg); @@ -355,7 +355,7 @@ export function useBrowserVoice(): UseBrowserVoiceReturn { } return; } - + console.error('[useBrowserVoice] Recognition error:', errorMsg); setError(errorMsg); setStatus('error'); @@ -374,7 +374,7 @@ export function useBrowserVoice(): UseBrowserVoiceReturn { if (nextRetry <= MAX_RECOVERY_RETRIES) { const delay = Math.min(1000 * Math.pow(2, nextRetry - 1), 8000); console.log(`[useBrowserVoice] Scheduling recovery retry ${nextRetry}/${MAX_RECOVERY_RETRIES} in ${delay}ms`); - + if (recoveryTimerRef.current !== null) { clearTimeout(recoveryTimerRef.current); } diff --git a/packages/ui/src/hooks/useMenuActions.ts b/packages/ui/src/hooks/useMenuActions.ts index 9ba3a921..e585e31a 100644 --- a/packages/ui/src/hooks/useMenuActions.ts +++ b/packages/ui/src/hooks/useMenuActions.ts @@ -1,6 +1,9 @@ import React from 'react'; import { toast } from '@/components/ui'; 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 { useUpdateStore } from '@/stores/useUpdateStore'; import { useThemeSystem } from '@/contexts/useThemeSystem'; @@ -83,6 +86,12 @@ type MenuAction = | 'theme-system' | 'toggle-sidebar' | 'toggle-memory-debug' + | 'go-back' + | 'go-forward' + | 'previous-session' + | 'next-session' + | 'previous-project' + | 'next-project' | 'help-dialog' | 'download-logs'; @@ -136,6 +145,39 @@ export const useMenuActions = ( 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( (action: MenuAction) => { switch (action) { @@ -222,6 +264,30 @@ export const useMenuActions = ( onToggleMemoryDebug?.(); 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': toggleHelpDialog(); break; @@ -236,6 +302,8 @@ export const useMenuActions = ( }, [ handleChangeWorkspace, + navigateProject, + navigateSession, onToggleMemoryDebug, openNewSessionDraft, setAboutDialogOpen, diff --git a/packages/ui/src/hooks/useWindowControlsOverlayLayout.ts b/packages/ui/src/hooks/useWindowControlsOverlayLayout.ts index ec6a4425..8a237e56 100644 --- a/packages/ui/src/hooks/useWindowControlsOverlayLayout.ts +++ b/packages/ui/src/hooks/useWindowControlsOverlayLayout.ts @@ -100,6 +100,8 @@ export const useWindowControlsOverlayLayout = () => { if (overlay && typeof overlay.removeEventListener === 'function') { overlay.removeEventListener('geometrychange', updateGeometry); } + + applyOverlayInsets(root, 0, 0, 0); }; }, []); }; diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 747783ba..d4f332fa 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -118,11 +118,19 @@ export interface GitRebaseInProgress { onto: string; } +export interface GitRemoteComparison { + remote: string; + branch: string; + ahead: number; + behind: number; +} + export interface GitStatus { current: string; tracking: string | null; ahead: number; behind: number; + upstreamComparison?: GitRemoteComparison | null; files: GitStatusFile[]; isClean: boolean; diffStats?: Record; diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 38d0a0c3..a749919c 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -505,6 +505,9 @@ export const dict = { 'gitView.header.noProfiles': 'No profiles available to apply.', 'gitView.header.removeRemoteAria': 'Remove Remote aria label', '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.binaryNoDiff': 'Binary file — no diff available', 'gitView.history.commitsPlaceholder': 'Commits Placeholder', @@ -762,7 +765,7 @@ export const dict = { 'gitView.toast.mergedIntoBranch': 'Merged {branch} into {currentBranch}', 'gitView.toast.pulledFilesPlural': 'Pulled {count} files 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.alreadyUpToDate': 'Already up to date', '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', 'header.actions.rightSidebarWithShortcut': 'Right sidebar ({shortcut})', '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.openSessionsAria': 'Open sessions', 'header.actions.closeSessionsAria': 'Close sessions', @@ -2085,6 +2090,11 @@ export const dict = { 'header.actions.newMiniChatAria': 'Open a new Mini Chat window', 'header.actions.openSessionMiniChat': 'Open 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.description': 'The application encountered an unexpected error. This has been logged for debugging.', 'errorBoundary.state.unknownError': 'Unknown error', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index e977bf5f..6cd4b556 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -506,6 +506,9 @@ export const dict: Record = { "gitView.header.noProfiles": "No hay perfiles disponibles para aplicar.", "gitView.header.removeRemoteAria": "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.binaryNoDiff": "Archivo binario — no hay diff disponible", "gitView.history.commitsPlaceholder": "Buscar commits...", @@ -763,7 +766,7 @@ export const dict: Record = { "gitView.toast.mergedIntoBranch": "Merge de {branch} en {currentBranch}", "gitView.toast.pulledFilesPlural": "Se trajeron {count} archivos 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.alreadyUpToDate": "Ya está actualizado", "gitView.toast.syncedPulledPluralAndPushed": "Se trajeron {count} archivos de {name} y se envió al upstream", @@ -1225,6 +1228,8 @@ export const dict: Record = { "helpDialog.proTips.themeCycling": "El ciclo de tema recuerda tu preferencia entre sesiones", "header.actions.rightSidebarWithShortcut": "Barra lateral derecha ({shortcut})", "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.openSessionsAria": "Abrir sesiones", "header.actions.closeSessionsAria": "Cerrar sesiones", @@ -2051,6 +2056,11 @@ export const dict: Record = { "header.actions.newMiniChatAria": "Abrir una nueva ventana Mini Chat", "header.actions.openSessionMiniChat": "Abrir sesión 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.description": "La aplicación encontró un error inesperado. Esto se ha registrado para depuración.", "errorBoundary.state.unknownError": "Error desconocido", diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index ea915de8..735043e1 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -506,6 +506,9 @@ export const dict: Record = { 'gitView.header.noProfiles': '적용할 프로필 없음', 'gitView.header.removeRemoteAria': '리모트 제거', 'gitView.header.removeRemoteTitle': '리모트 제거', + 'gitView.header.upstreamSynced': '동기화됨', + 'gitView.header.upstreamTooltip': '{target}와 비교됨.', + 'gitView.header.upstreamTooltipTracking': '{target}와 비교됨. 기본 동기화 배지는 계속 {tracking}을 반영합니다.', 'gitView.history.binary': '바이너리', 'gitView.history.binaryNoDiff': '바이너리 파일 — diff 없음', 'gitView.history.commitsPlaceholder': '커밋 검색', @@ -763,7 +766,7 @@ export const dict: Record = { 'gitView.toast.mergedIntoBranch': '{branch}을(를) {currentBranch}에 병합했습니다', 'gitView.toast.pulledFilesPlural': '{name}에서 파일 {count}개를 풀했습니다', 'gitView.toast.pulledFilesSingle': '{name}에서 파일 {count}개를 풀했습니다', - 'gitView.toast.pushedToUpstream': '업스트림에 푸시했습니다', + 'gitView.toast.pushedToUpstream': '{name}에 푸시했습니다', 'gitView.toast.commitOrStashBeforeSync': '동기화하기 전에 변경 사항을 커밋하거나 stash하세요', 'gitView.toast.alreadyUpToDate': '이미 최신 상태입니다', 'gitView.toast.syncedPulledPluralAndPushed': '{name}에서 파일 {count}개를 풀하고 업스트림에 푸시했습니다', @@ -1261,6 +1264,8 @@ export const dict: Record = { 'helpDialog.proTips.themeCycling': '테마 순환은 세션 간에도 선호 설정을 기억합니다', 'header.actions.rightSidebarWithShortcut': '오른쪽 사이드바 ({shortcut})', 'header.actions.toggleRightSidebarAria': '오른쪽 사이드바 토글', + 'header.actions.openAppMenu': 'OpenChamber 메뉴', + 'header.actions.openAppMenuAria': 'OpenChamber 메뉴 열기', 'header.actions.openSessionsWithShortcut': '세션 ({shortcut}) 열기', 'header.actions.openSessionsAria': '세션 열기', 'header.actions.closeSessionsAria': '세션 닫기', @@ -2085,6 +2090,11 @@ export const dict: Record = { 'header.actions.newMiniChatAria': '새 Mini Chat 창 열기', 'header.actions.openSessionMiniChat': '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.description': '애플리케이션에서 예상치 못한 오류가 발생했습니다. 디버깅을 위해 기록되었습니다.', 'errorBoundary.state.unknownError': '알 수 없음 오류', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index d75d4725..4cee60ae 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -601,6 +601,11 @@ export const dict: Record = { 'header.actions.newMiniChatAria': 'Otwórz nowe okno Mini Chat', 'header.actions.openSessionMiniChat': 'Otwórz 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.description': 'Aplikacja napotkała nieoczekiwany błąd. Zostało to zalogowane do celów debugowania.', 'errorBoundary.state.unknownError': 'Nieznany błąd', @@ -1488,6 +1493,9 @@ export const dict: Record = { 'gitView.header.noProfiles': 'No profiles available to apply.', 'gitView.header.removeRemoteAria': 'Remove Remote aria label', '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.binaryNoDiff': 'Binary file — no diff available', 'gitView.history.commitsPlaceholder': 'Commits Placeholder', @@ -1729,6 +1737,8 @@ export const dict: Record = { 'header.actions.newSessionWithShortcut': 'Nowa sesja ({shortcut})', 'header.actions.openPlanAria': 'Otwórz plan', '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.planWithShortcut': 'Plan ({shortcut})', 'header.actions.rightSidebarWithShortcut': 'Prawy panel boczny ({shortcut})', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 39730d5e..3cbdc993 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -506,6 +506,9 @@ export const dict: Record = { "gitView.header.noProfiles": "Não há perfiles disponíveis para aplicar.", "gitView.header.removeRemoteAria": "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.binaryNoDiff": "Arquivo binário — diff não disponível", "gitView.history.commitsPlaceholder": "Buscar commits...", @@ -763,7 +766,7 @@ export const dict: Record = { "gitView.toast.mergedIntoBranch": "Merge de {branch} em {currentBranch}", "gitView.toast.pulledFilesPlural": "Se trajeron {count} arquivos 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.alreadyUpToDate": "Já está atualizado", "gitView.toast.syncedPulledPluralAndPushed": "Foram trazidos {count} arquivos de {name} e enviados ao upstream", @@ -1225,6 +1228,8 @@ export const dict: Record = { "helpDialog.proTips.themeCycling": "A alternância de tema lembra sua preferência entre sessões", "header.actions.rightSidebarWithShortcut": "Barra lateral direita ({shortcut})", "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.openSessionsAria": "Abrir sessões", "header.actions.closeSessionsAria": "Fechar sessões", @@ -2051,6 +2056,11 @@ export const dict: Record = { "header.actions.newMiniChatAria": "Abrir uma nova janela Mini Chat", "header.actions.openSessionMiniChat": "Abrir sessão 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.description": "O aplicativo encontrou um erro inesperado. Isso foi registrado para depuração.", "errorBoundary.state.unknownError": "Erro desconhecido", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 2cb895da..1d7fb2ea 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -506,6 +506,9 @@ export const dict: Record = { "gitView.header.noProfiles": "Немає доступних профілів для застосування.", "gitView.header.removeRemoteAria": "Видалити remote", "gitView.header.removeRemoteTitle": "Видалити remote", + "gitView.header.upstreamSynced": "синхронізовано", + "gitView.header.upstreamTooltip": "Порівняно з {target}.", + "gitView.header.upstreamTooltipTracking": "Порівняно з {target}. Основні індикатори синхронізації все ще відображають {tracking}.", "gitView.history.binary": "Бінарний", "gitView.history.binaryNoDiff": "Бінарний файл — diff недоступний", "gitView.history.commitsPlaceholder": "Пошук комітів", @@ -763,7 +766,7 @@ export const dict: Record = { "gitView.toast.mergedIntoBranch": "Злито {branch} в {currentBranch}", "gitView.toast.pulledFilesPlural": "Отримано файлів: {count} з {name}", "gitView.toast.pulledFilesSingle": "Отримано файл: {count} з {name}", - "gitView.toast.pushedToUpstream": "Надіслано в upstream", + "gitView.toast.pushedToUpstream": "Надіслано в {name}", "gitView.toast.commitOrStashBeforeSync": "Закомітьте або сховайте зміни перед синхронізацією", "gitView.toast.alreadyUpToDate": "Вже актуально", "gitView.toast.syncedPulledPluralAndPushed": "Отримано файлів: {count} з {name} і надіслано в upstream", @@ -1225,6 +1228,8 @@ export const dict: Record = { "helpDialog.proTips.themeCycling": "Перемикання теми запам’ятовує ваші переваги протягом сесій", "header.actions.rightSidebarWithShortcut": "Права бічна панель ({shortcut})", "header.actions.toggleRightSidebarAria": "Перемкнути праву бічну панель", + "header.actions.openAppMenu": "Меню OpenChamber", + "header.actions.openAppMenuAria": "Відкрити меню OpenChamber", "header.actions.openSessionsWithShortcut": "Відкрити сесії ({shortcut})", "header.actions.openSessionsAria": "Відкрити сесії", "header.actions.closeSessionsAria": "Закрити сесії", @@ -2051,6 +2056,11 @@ export const dict: Record = { "header.actions.newMiniChatAria": "Відкрити нове вікно Mini Chat", "header.actions.openSessionMiniChat": "Відкрити сесію в 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.description": "У програмі сталася неочікувана помилка. Це було зареєстровано для налагодження.", "errorBoundary.state.unknownError": "Невідома помилка", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 0c972c75..6a952754 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -506,6 +506,9 @@ export const dict: Record = { 'gitView.header.noProfiles': '没有可应用的配置。', 'gitView.header.removeRemoteAria': '移除远程 {name}', 'gitView.header.removeRemoteTitle': '移除 {name}', + 'gitView.header.upstreamSynced': '已同步', + 'gitView.header.upstreamTooltip': '与 {target} 对比。', + 'gitView.header.upstreamTooltipTracking': '与 {target} 对比。主要同步徽标仍然反映 {tracking}。', 'gitView.history.binary': '二进制', 'gitView.history.binaryNoDiff': '二进制文件,无法显示差异', 'gitView.history.commitsPlaceholder': '提交数', @@ -763,7 +766,7 @@ export const dict: Record = { 'gitView.toast.mergedIntoBranch': '已将 {branch} 合并到 {currentBranch}', 'gitView.toast.pulledFilesPlural': '已从 {name} 拉取 {count} 个文件', 'gitView.toast.pulledFilesSingle': '已从 {name} 拉取 {count} 个文件', - 'gitView.toast.pushedToUpstream': '已推送到上游', + 'gitView.toast.pushedToUpstream': '已推送到 {name}', 'gitView.toast.commitOrStashBeforeSync': '同步前请先提交或储藏你的更改', 'gitView.toast.alreadyUpToDate': '已是最新状态', 'gitView.toast.syncedPulledPluralAndPushed': '已从 {name} 拉取 {count} 个文件并推送到上游', @@ -1225,6 +1228,8 @@ export const dict: Record = { 'helpDialog.proTips.themeCycling': '主题循环会记住你在各会话中的偏好', 'header.actions.rightSidebarWithShortcut': '右侧边栏({shortcut})', 'header.actions.toggleRightSidebarAria': '切换右侧边栏', + 'header.actions.openAppMenu': 'OpenChamber 菜单', + 'header.actions.openAppMenuAria': '打开 OpenChamber 菜单', 'header.actions.openSessionsWithShortcut': '打开会话({shortcut})', 'header.actions.openSessionsAria': '打开会话', 'header.actions.closeSessionsAria': '关闭会话', @@ -2051,6 +2056,11 @@ export const dict: Record = { 'header.actions.newMiniChatAria': '打开新的 Mini Chat 窗口', 'header.actions.openSessionMiniChat': '在 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.description': '应用遇到意外错误,已记录用于调试。', 'errorBoundary.state.unknownError': '未知错误', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 06543512..704cccc4 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -506,6 +506,9 @@ export const dict: Record = { 'gitView.header.noProfiles': '沒有可套用的設定。', 'gitView.header.removeRemoteAria': '移除遠端 {name}', 'gitView.header.removeRemoteTitle': '移除 {name}', + 'gitView.header.upstreamSynced': '已同步', + 'gitView.header.upstreamTooltip': '與 {target} 比較。', + 'gitView.header.upstreamTooltipTracking': '與 {target} 比較。主要同步徽章仍反映 {tracking}。', 'gitView.history.binary': '二進位', 'gitView.history.binaryNoDiff': '二進位檔案 — 無可用 diff', 'gitView.history.commitsPlaceholder': '提交數', @@ -1223,6 +1226,8 @@ export const dict: Record = { 'helpDialog.proTips.themeCycling': '主題循環會記住你在各會話中的偏好', 'header.actions.rightSidebarWithShortcut': '右側邊欄({shortcut})', 'header.actions.toggleRightSidebarAria': '切換右側邊欄', + 'header.actions.openAppMenu': 'OpenChamber 選單', + 'header.actions.openAppMenuAria': '開啟 OpenChamber 選單', 'header.actions.openSessionsWithShortcut': '開啟會話({shortcut})', 'header.actions.openSessionsAria': '開啟會話', 'header.actions.closeSessionsAria': '關閉會話', @@ -2049,6 +2054,11 @@ export const dict: Record = { 'header.actions.newMiniChatAria': '開啟新的 Mini Chat 視窗', 'header.actions.openSessionMiniChat': '在 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.description': '應用程式遇到意外錯誤,已記錄用於偵錯。', 'errorBoundary.state.unknownError': '未知錯誤', diff --git a/packages/ui/src/lib/openInApps.ts b/packages/ui/src/lib/openInApps.ts index 19861f80..040f4feb 100644 --- a/packages/ui/src/lib/openInApps.ts +++ b/packages/ui/src/lib/openInApps.ts @@ -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_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 => { if (!id) { 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 => { - return getOpenInAppById(DEFAULT_OPEN_IN_APP_ID) ?? OPEN_IN_APPS[0]; + return getOpenInAppById(DEFAULT_OPEN_IN_APP_ID) ?? getPlatformOpenInApp(OPEN_IN_APPS[0]); }; diff --git a/packages/ui/src/stores/useGitStore.ts b/packages/ui/src/stores/useGitStore.ts index d6dd611d..b910d5be 100644 --- a/packages/ui/src/stores/useGitStore.ts +++ b/packages/ui/src/stores/useGitStore.ts @@ -204,6 +204,21 @@ const haveDiffStatsChanged = ( 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 => { if (!oldStatus && !newStatus) return false; 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.tracking !== newStatus.tracking) 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}`)); for (const file of newFiles) { @@ -465,9 +486,17 @@ export const useGitStore = create()( } // Preserve diffStats from previous status when light mode returns none - const mergedStatus = newStatus.diffStats === undefined && currentDirState.status?.diffStats - ? { ...newStatus, diffStats: currentDirState.status.diffStats } - : newStatus; + const mergedStatus = { + ...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, { ...currentDirState, diff --git a/packages/ui/src/stores/useOpenInAppsStore.ts b/packages/ui/src/stores/useOpenInAppsStore.ts index 7351f757..07ed395b 100644 --- a/packages/ui/src/stores/useOpenInAppsStore.ts +++ b/packages/ui/src/stores/useOpenInAppsStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; 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'; export type OpenInAppOption = OpenInApp & { @@ -22,7 +22,7 @@ type OpenInAppsState = { const getAlwaysAvailableApps = (): OpenInAppOption[] => { return OPEN_IN_APPS .filter((app) => OPEN_IN_ALWAYS_AVAILABLE_APP_IDS.has(app.id)) - .map((app) => ({ ...app })); + .map((app) => ({ ...getPlatformOpenInApp(app) })); }; const getStoredAppId = (): string => { @@ -78,7 +78,7 @@ export const useOpenInAppsStore = create()((set, get) => ({ ); const withIcons = filtered.map((app) => ({ - ...app, + ...getPlatformOpenInApp(app), iconDataUrl: iconMap.get(app.appName), })); diff --git a/packages/ui/src/types/desktop.d.ts b/packages/ui/src/types/desktop.d.ts index 24f3c16b..96184fbf 100644 --- a/packages/ui/src/types/desktop.d.ts +++ b/packages/ui/src/types/desktop.d.ts @@ -6,6 +6,7 @@ declare global { __OPENCHAMBER_MACOS_MAJOR__?: number; __OPENCHAMBER_LOCAL_ORIGIN__?: string; __OPENCHAMBER_ELECTRON__?: { runtime?: string }; + __OPENCHAMBER_PLATFORM__?: string; __OPENCHAMBER_DESKTOP_BOOT_OUTCOME__?: DesktopBootOutcome; } diff --git a/packages/ui/src/types/xenova-transformers.d.ts b/packages/ui/src/types/xenova-transformers.d.ts new file mode 100644 index 00000000..a6cb9faf --- /dev/null +++ b/packages/ui/src/types/xenova-transformers.d.ts @@ -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) => Promise<{ text: string }>>; +} diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 8bc94856..6c8050c5 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -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:webview": "vite build --watch", "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" }, "devDependencies": { diff --git a/packages/vscode/src/gitService.ts b/packages/vscode/src/gitService.ts index 7eb76f15..af245c5a 100644 --- a/packages/vscode/src/gitService.ts +++ b/packages/vscode/src/gitService.ts @@ -2564,7 +2564,7 @@ export async function gitPull( const files = changedFiles.exitCode === 0 ? changedFiles.stdout.split('\n').map((line) => line.trim()).filter(Boolean) : []; - + return { success: result.exitCode === 0, summary: { changes: files.length, insertions: 0, deletions: 0 }, diff --git a/packages/vscode/src/skillsCatalog.ts b/packages/vscode/src/skillsCatalog.ts index e499f2de..bccdc0dc 100644 --- a/packages/vscode/src/skillsCatalog.ts +++ b/packages/vscode/src/skillsCatalog.ts @@ -450,11 +450,11 @@ function parseSkillRepoSource(input: string, subpath?: string) { : urlFormat === 'ssh' ? (raw.split('@')[1].split(':')[1] ?? '').split('/').filter(Boolean) : null; - + const repoName = pathSegments && pathSegments.length > 0 ? pathSegments[pathSegments.length - 1].replace(/\.git$/i, '') : null; - + const gitOwner = pathSegments && pathSegments.length > 1 ? pathSegments.slice(0, -1).join('/') : (pathSegments && pathSegments.length === 1 ? pathSegments[0] : null); diff --git a/packages/web/server/lib/fs/routes.js b/packages/web/server/lib/fs/routes.js index 9984e783..14ddcbea 100644 --- a/packages/web/server/lib/fs/routes.js +++ b/packages/web/server/lib/fs/routes.js @@ -772,6 +772,11 @@ export const registerFsRoutes = (app, dependencies) => { 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.writeFile(resolved.resolved, content, 'utf8'); return res.json({ success: true, path: resolved.resolved }); diff --git a/packages/web/server/lib/fs/routes.test.js b/packages/web/server/lib/fs/routes.test.js index 4280a011..ac3c1eb8 100644 --- a/packages/web/server/lib/fs/routes.test.js +++ b/packages/web/server/lib/fs/routes.test.js @@ -90,7 +90,10 @@ const registerExec = ({ spawn }) => { registerFsRoutes(app, { os: { homedir: () => '/home/user' }, path, - fsPromises: { stat: async () => ({ isDirectory: () => true }) }, + fsPromises: { + realpath: async (targetPath) => targetPath, + stat: async () => ({ isDirectory: () => true }), + }, spawn, crypto: { randomUUID: (() => { let n = 0; return () => `job-${n++}`; })() }, normalizeDirectoryPath: (p) => p, @@ -102,12 +105,69 @@ const registerExec = ({ spawn }) => { 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 res = createMockResponse(); await handler({ body }, 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', () => { beforeEach(() => { delete process.env.OPENCHAMBER_GIT_READ_CACHE_TTL_MS; diff --git a/packages/web/server/lib/git/DOCUMENTATION.md b/packages/web/server/lib/git/DOCUMENTATION.md index 9886d14f..4bb0020e 100644 --- a/packages/web/server/lib/git/DOCUMENTATION.md +++ b/packages/web/server/lib/git/DOCUMENTATION.md @@ -101,6 +101,7 @@ The following functions are internal helpers used by exported functions: - `tracking`: Upstream branch (e.g., 'origin/main'). - `ahead`: Number of commits ahead of upstream. - `behind`: Number of commits behind upstream. +- `upstreamComparison`: Optional comparison against `upstream/`, with `{ remote, branch, ahead, behind }`. - `files`: Array of file objects with `path`, `index`, `working_dir` status codes. - `isClean`: Boolean indicating if working tree is clean. - `diffStats`: Object mapping file paths to `{ insertions, deletions }`. diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index fc25a96b..57976b75 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -12,6 +12,10 @@ const execFileAsync = promisify(execFile); const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf']; let resolvedGitBinary = null; 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 WORKTREE_BOOTSTRAP_PENDING = 'pending'; @@ -86,6 +90,30 @@ const normalizeGitExecutableCandidate = (candidate) => { 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 currentPath = process.env.PATH || ''; const seen = new Set(); @@ -133,22 +161,34 @@ const resolveGitBinary = () => { .map((value) => (typeof value === 'string' ? value.trim() : '')) .filter(Boolean); for (const candidate of explicit) { - if (isExecutableFile(candidate)) { - resolvedGitBinary = candidate; + const normalized = normalizeGitExecutableCandidate(candidate); + if (isExecutableFile(normalized)) { + resolvedGitBinary = normalized; return resolvedGitBinary; } } - const discovered = [ + const pathDiscovered = [ ...listPathExecutableCandidates('git.exe'), ...listPathExecutableCandidates('git'), + ] + .map(normalizeGitExecutableCandidate) + .filter(Boolean) + .filter((candidate) => isExecutableFile(candidate)); + if (pathDiscovered.length > 0) { + resolvedGitBinary = 'git'; + return resolvedGitBinary; + } + + const discovered = [ ...listWindowsGitInstallCandidates(), ] .map(normalizeGitExecutableCandidate) .filter(Boolean) .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'; return resolvedGitBinary; }; @@ -276,9 +316,9 @@ const createGit = async (directory) => { const hasCustomBinary = typeof binary === 'string' && binary.trim() && binary !== 'git' && binary !== 'git.exe'; const unsafe = hasCustomBinary ? { allowUnsafeCustomBinary: true } : undefined; if (!directory) { - return simpleGit({ env, spawnOptions, binary, unsafe }); + return createSimpleGit({ env, spawnOptions, binary, unsafe }); } - return simpleGit({ + return createSimpleGit({ baseDir: normalizeDirectoryPath(directory), env, spawnOptions, @@ -677,6 +717,96 @@ const parseGitErrorText = (error) => { .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 text = parseGitErrorText(error); return /not a git repository/i.test(text); @@ -1342,7 +1472,7 @@ export async function getStatus(directory, options = {}) { const lightMode = options.mode === 'light'; 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 const status = await git.status(['-uall']); @@ -1495,6 +1625,7 @@ export async function getStatus(directory, options = {}) { let tracking = status.tracking || null; let ahead = status.ahead; let behind = status.behind; + let upstreamComparison; // 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. @@ -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 let mergeInProgress = null; let rebaseInProgress = null; @@ -1574,6 +1714,7 @@ export async function getStatus(directory, options = {}) { tracking, ahead, behind, + upstreamComparison, files: status.files.map((f) => ({ path: f.path, index: f.index, @@ -1984,9 +2125,20 @@ export async function pull(directory, options = {}) { : options.options || {}; 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 `. + const status = await git.status(); + branch = String(status.current || '').trim(); + } + const result = await git.pull( - options.remote || 'origin', - options.branch, + remote || 'origin', + branch || undefined, pullOptions ); @@ -2240,11 +2392,20 @@ export async function fetch(directory, options = {}) { const { git } = await createRepositoryGitContext(directory); try { - await git.fetch( - options.remote || 'origin', - options.branch, - options.options || {} - ); + const remote = String(options.remote || '').trim(); + const branch = String(options.branch || '').trim(); + const fetchOptions = options.options || {}; + + if (remote && !branch) { + // simple-git drops the remote when branch is omitted, so use raw to preserve `git fetch `. + await git.raw(['fetch', ...buildRawGitOptions(fetchOptions), remote]); + } else { + await git.fetch( + remote || 'origin', + branch || undefined, + fetchOptions + ); + } return { success: true }; } catch (error) { diff --git a/packages/web/server/lib/git/service.test.js b/packages/web/server/lib/git/service.test.js index df61bba5..9f115dc6 100644 --- a/packages/web/server/lib/git/service.test.js +++ b/packages/web/server/lib/git/service.test.js @@ -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', () => { 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'); }); }); + +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', + }); + }); +}); diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index 265166da..736f9292 100644 --- a/packages/web/server/lib/opencode/env-runtime.js +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -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 trimmed = typeof binaryName === 'string' ? binaryName.trim() : ''; if (!trimmed) { @@ -59,7 +83,7 @@ export const createOpenCodeEnvRuntime = (deps) => { const current = process.env.PATH || ''; const parts = current.split(path.delimiter).filter(Boolean); - const candidateNames = [trimmed]; + const candidateNames = []; if (process.platform === 'win32' && !path.extname(trimmed)) { 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 candidateName of candidateNames) { const candidate = path.join(dir, candidateName); @@ -649,6 +675,9 @@ export const createOpenCodeEnvRuntime = (deps) => { if (!trimmed) { return null; } + if (process.platform === 'win32') { + return resolveWindowsExecutablePath(trimmed); + } return isExecutable(trimmed) ? trimmed : null; }; @@ -669,10 +698,20 @@ export const createOpenCodeEnvRuntime = (deps) => { return null; } + const packageShim = path.join(nodeModulesDir, 'opencode-ai', 'bin', 'opencode.exe'); + if (isExecutable(packageShim)) { + return packageShim; + } + for (const packageName of getWindowsNativeOpencodePackageNames()) { - const candidate = path.join(nodeModulesDir, packageName, 'bin', 'opencode.exe'); - if (isExecutable(candidate)) { - return candidate; + const candidates = [ + path.join(nodeModulesDir, packageName, 'bin', 'opencode.exe'), + 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); 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 { binary: directBinary, args: [], diff --git a/packages/web/server/lib/opencode/env-runtime.test.js b/packages/web/server/lib/opencode/env-runtime.test.js index 274b0aec..521e2411 100644 --- a/packages/web/server/lib/opencode/env-runtime.test.js +++ b/packages/web/server/lib/opencode/env-runtime.test.js @@ -5,6 +5,11 @@ import { afterEach, describe, expect, it } from 'vitest'; import { createOpenCodeEnvRuntime } from './env-runtime.js'; 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 tempDirs = []; const itIf = (condition) => condition ? it : it.skip; @@ -32,9 +37,39 @@ afterEach(() => { if (typeof originalOpencodeBinary === 'string') { 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) => { @@ -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'); + 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 rejection = runtime.applyOpencodeBinaryFromSettings({ strict: true }); - await expect(rejection).rejects.toThrow('uses WSL'); - const error = await rejection.catch((caught) => caught); - expect(error.code).toBeUndefined(); + try { + await rejection; + 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', + }); }); }); diff --git a/packages/web/server/lib/opencode/settings-runtime.js b/packages/web/server/lib/opencode/settings-runtime.js index 7d4379ed..638d4aea 100644 --- a/packages/web/server/lib/opencode/settings-runtime.js +++ b/packages/web/server/lib/opencode/settings-runtime.js @@ -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) => { try { 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. 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.rename(tmp, SETTINGS_FILE_PATH); + await replaceFile(tmp, SETTINGS_FILE_PATH); } catch (error) { console.warn('Failed to write settings file:', error); throw error; diff --git a/packages/web/server/lib/opencode/settings-runtime.test.js b/packages/web/server/lib/opencode/settings-runtime.test.js index 185e3cf9..1994fe3c 100644 --- a/packages/web/server/lib/opencode/settings-runtime.test.js +++ b/packages/web/server/lib/opencode/settings-runtime.test.js @@ -82,4 +82,43 @@ describe('settings runtime', () => { 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 }); + } + }); }); diff --git a/packages/web/server/lib/skills-catalog/source.js b/packages/web/server/lib/skills-catalog/source.js index 47e6f540..5af2a100 100644 --- a/packages/web/server/lib/skills-catalog/source.js +++ b/packages/web/server/lib/skills-catalog/source.js @@ -17,7 +17,7 @@ export function parseSkillRepoSource(input, options = {}) { return { ok: false, error: { kind: 'invalidSource', message: 'Repository source is required' } }; } const explicitSubpath = typeof options.subpath === 'string' && options.subpath.trim() ? options.subpath.trim() : null; - + const urlFormat = raw.startsWith('https://') ? 'https' : raw.startsWith('git@') ? 'ssh' : 'shorthand'; const gitHost = urlFormat === 'https' ? raw.split('/')[2] : urlFormat === 'ssh' ? raw.split('@')[1].split(':')[0] : null; diff --git a/packages/web/src/api/files.ts b/packages/web/src/api/files.ts index 18771a63..36b6588d 100644 --- a/packages/web/src/api/files.ts +++ b/packages/web/src/api/files.ts @@ -40,12 +40,15 @@ const toDirectoryListResult = (fallbackDirectory: string, payload: WebDirectoryL }; export const createWebFilesAPI = (): FilesAPI => ({ - async listDirectory(path: string): Promise { + async listDirectory(path: string, options): Promise { const target = normalizePath(path); const params = new URLSearchParams(); if (target) { params.set('path', target); } + if (options?.respectGitignore) { + params.set('respectGitignore', 'true'); + } const response = await fetch(`/api/fs/list${params.toString() ? `?${params.toString()}` : ''}`); diff --git a/scripts/dev-web-hmr.mjs b/scripts/dev-web-hmr.mjs index 47cb8855..a5a3f302 100644 --- a/scripts/dev-web-hmr.mjs +++ b/scripts/dev-web-hmr.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { spawn } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import { existsSync, rmSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -11,12 +11,36 @@ const repoRoot = path.resolve(__dirname, '..'); const useDetachedChildren = process.platform === 'darwin'; 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 = {}) { - 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, stdio: 'inherit', env: { ...process.env, ...env }, detached: useDetachedChildren, + windowsVerbatimArguments: isWindowsCommandScript, }).on('error', (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) { if (!child || child.exitCode !== null || child.signalCode !== null) { return; @@ -70,6 +105,11 @@ async function stopChildTree(child) { signalChild(child, 'SIGINT'); 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) { signalChild(child, 'SIGTERM'); await waitForExit(child, 2500); @@ -112,9 +152,15 @@ function clearViteCache() { clearViteCache(); -const api = run('api', 'bun', ['run', '--cwd', 'packages/web', 'dev:server:watch'], { - OPENCHAMBER_PORT: backendPort, -}); +const api = run( + 'api', + 'bun', + ['x', 'nodemon', '--watch', 'server', '--ext', 'js', '--exec', `bun server/index.js --port ${backendPort}`], + { + OPENCHAMBER_PORT: backendPort, + }, + { cwd: webRoot }, +); const vite = run( 'vite', 'bun',