diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 9d2eddc7582..b54256d446a 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -95,6 +95,8 @@ All stable kinds (`patch`, `minor`, `major`) are computed off the latest *stable **Safety guarantees:** - Stable releases are refused if the new version isn't strictly greater than the latest published stable. This is the only rule `electron-updater` actually needs — it compares semver within the `latest` channel, so a regressing stable is the one thing that breaks auto-update for fresh installs. +- Complete RC draft releases created by the release workflow are published before cutting a new tag, so a GitHub queue failure in the final publish job can be resumed safely. +- If the latest RC tag exists but is still draft-only or missing its GitHub Release, the workflow resumes that tag before cutting the next RC. This keeps retries from leaving multiple unpublished draft releases behind. - Off-main releases (when `ref` is not the tip of `main`) only push the tag. `main` is never mutated from a non-main ref, so you can safely release an older commit without polluting history. - When `ref` is the tip of `main`, the version-bump commit is fast-forwarded onto `main` so local `package.json` stays in sync with what's shipped. diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index 6d68765f903..33645a3c253 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -40,6 +40,14 @@ jobs: run_install: false - if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y python3 python3-gi gir1.2-atspi-2.0 at-spi2-core xclip xdotool + # Why: pnpm's bundled node-gyp can ship gyp_main.py without execute + # permission on Linux runners; node-pty's install fallback then fails + # before this smoke job can exercise the native package. + - name: Use external node-gyp to avoid pnpm's bundled copy (Linux only) + if: runner.os == 'Linux' + run: | + npm install -g node-gyp@11.5.0 + echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV" - run: pnpm install --frozen-lockfile - run: pnpm verify:computer-native - run: pnpm build:cli @@ -80,6 +88,12 @@ jobs: with: run_install: false - run: sudo apt-get update && sudo apt-get install -y build-essential python3 python3-gi gir1.2-atspi-2.0 gedit at-spi2-core xvfb xclip xdotool + # Why: keep scheduled Linux e2e on the same native install path as PR + # smoke and pr.yml's verify job. + - name: Use external node-gyp to avoid pnpm's bundled copy (Linux only) + run: | + npm install -g node-gyp@11.5.0 + echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV" - run: pnpm install --frozen-lockfile - run: pnpm verify:computer-native - run: pnpm build:cli diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b70a92d35dc..22b332c1a21 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -71,6 +71,9 @@ jobs: - name: Check feature wall asset budget run: pnpm check:feature-wall-assets + - name: Verify macOS entitlements + run: pnpm verify:macos-entitlements + - name: Typecheck run: pnpm typecheck diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 0f2dc8ba6c0..4851157bc4e 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -16,7 +16,7 @@ name: Cut Release # 5. Write package.json, commit (detached), tag, push tag. # 6. If ref was the tip of origin/main, fast-forward main to include the # version-bump commit so developers see the right version locally. -# 7. Invoke release.yml to build and publish artifacts. +# 7. Build and publish artifacts from the tag. on: workflow_dispatch: @@ -61,7 +61,7 @@ jobs: timeout-minutes: 15 outputs: tag: ${{ steps.tag.outputs.tag || steps.version.outputs.recovered_tag }} - should_release: ${{ steps.window.outputs.allowed == 'true' && steps.existing.outputs.already_ran != 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }} + should_release: ${{ steps.tag.outputs.tag != '' || steps.version.outputs.recovered_tag != '' }} steps: - name: Checkout ref uses: actions/checkout@v4 @@ -174,9 +174,21 @@ jobs: echo "Already ran this slot: ${{ steps.existing.outputs.already_ran }}" echo "Reason: ${{ steps.existing.outputs.reason }}" + - name: Publish complete release-cut RC drafts from prior runs + id: publish_drafts + # Why: this must run even when the current slot already cut a tag; a + # later cron retry may be the first chance to unstick a complete RC draft. + if: steps.window.outputs.allowed == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node config/scripts/publish-complete-draft-releases.mjs + - name: Compute next version id: version - if: steps.window.outputs.allowed == 'true' && steps.existing.outputs.already_ran != 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) + # Why: if an RC run only had complete drafts to publish, stop there + # instead of immediately cutting another RC after the recovered one. + # Stable dispatches should still cut the requested stable release. + if: steps.window.outputs.allowed == 'true' && steps.existing.outputs.already_ran != 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) && !((github.event_name == 'schedule' || inputs.kind == 'rc') && steps.publish_drafts.outputs.published_count != '0' && steps.publish_drafts.outputs.skipped_count == '0') env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} KIND: ${{ github.event_name == 'schedule' && 'rc' || inputs.kind }} @@ -238,22 +250,50 @@ jobs: ' "$(strip_pre "$1")" "$2" } - next_rc_in_series() { - # Given a base version, find the highest existing -rc.N for it - # and return base-rc.(N+1). If none exists, base-rc.0. + highest_rc_for_base() { local base="$1" - local highest - highest="$(git tag --list "v${base}-rc.*" \ + git tag --list "v${base}-rc.*" \ | sed -E "s/^v${base//./\\.}-rc\.//" \ | sort -n \ - | tail -n 1 || true)" - if [[ -z "$highest" ]]; then - echo "${base}-rc.0" + | tail -n 1 || true + } + + release_draft_state() { + # Prints: true, false, or missing. + local tag="$1" + local state_file="$RUNNER_TEMP/release-state-${tag//[^A-Za-z0-9_.-]/_}" + if gh release view "$tag" \ + --repo "$GITHUB_REPOSITORY" \ + --json isDraft \ + --jq '.isDraft' >"$state_file" 2>/dev/null; then + cat "$state_file" else - echo "${base}-rc.$((highest + 1))" + echo "missing" fi } + recover_unpublished_tag() { + local tag="$1" + local reason="$2" + local release_state + release_state="$(release_draft_state "$tag")" + case "$release_state" in + missing|true) + echo "::warning::Tag $tag already exists but has no published release ($reason) - recovering by re-dispatching the release build against the existing tag." + echo "recovered_tag=$tag" >>"$GITHUB_OUTPUT" + echo "recovered=true" >>"$GITHUB_OUTPUT" + exit 0 + ;; + false) + return 1 + ;; + *) + echo "::error::Unexpected release state for $tag: $release_state" >&2 + exit 1 + ;; + esac + } + # Fresh repo fallback so the math below never divides by zero. if [[ -z "$latest_stable" ]]; then latest_stable="0.0.0" @@ -270,7 +310,18 @@ jobs: # that class of bug; minor/major RCs are cut by running # that stable kind first. base="$(bump "$latest_stable" patch)" - new="$(next_rc_in_series "$base")" + highest_rc="$(highest_rc_for_base "$base")" + if [[ -z "$highest_rc" ]]; then + new="${base}-rc.0" + else + existing_rc_tag="v${base}-rc.${highest_rc}" + # Why: a failed or GitHub-stuck run can leave the highest RC + # tag attached to a draft/missing release. Resume that tag so + # retries finish the intended release instead of piling up + # vX.Y.Z-rc.N+1 drafts. + recover_unpublished_tag "$existing_rc_tag" "latest RC in series" || true + new="${base}-rc.$((highest_rc + 1))" + fi ;; patch|minor|major) new="$(bump "$latest_stable" "$KIND")" @@ -289,7 +340,7 @@ jobs: # Orphan-tag recovery. # # Why: if a previous cut pushed the tag but was cancelled (or the - # dependent release.yml job otherwise failed to start) before the + # dependent release build jobs otherwise failed to start) before the # GitHub Release was published, the tag now exists on the remote # but "latest stable" still points at the prior version. Every # subsequent patch cut then recomputes the same version and dies @@ -301,7 +352,7 @@ jobs: # Recovery policy: if the tag exists AND no GitHub release has # been published for it (draft-or-absent both count as "not # shipped"), treat this as a resumable state: emit the existing - # tag as the job output so the downstream release.yml job runs + # tag as the job output so the downstream release build jobs run # against it and finishes what the earlier attempt started. The # bump/commit/push steps are skipped in that case — there is # nothing to bump; the tag is already on the remote. @@ -310,32 +361,10 @@ jobs: # already exist — that's a real conflict (someone tagged manually # over a shipped version) and needs human attention. if git rev-parse "v$new" >/dev/null 2>&1; then - # Why: use the REST API, not `gh release view`. `gh api` returns - # `true`/`false` for a real release, while a 404 means the tag is - # orphaned and can be safely re-dispatched. Capture failures - # explicitly so the 404 payload never leaks into the state check. - release_state="missing" - if gh api \ - "repos/$GITHUB_REPOSITORY/releases/tags/v$new" \ - --jq '.draft' >"$RUNNER_TEMP/release-state" 2>/dev/null; then - release_state="$(cat "$RUNNER_TEMP/release-state")" - fi - case "$release_state" in - missing|true) - echo "::warning::Tag v$new already exists but no published release is attached — recovering by re-dispatching release.yml against the existing tag." - echo "recovered_tag=v$new" >>"$GITHUB_OUTPUT" - echo "recovered=true" >>"$GITHUB_OUTPUT" - exit 0 - ;; - false) - echo "::error::Tag v$new already exists and has a published release. Refusing to re-cut over a shipped version." >&2 - exit 1 - ;; - *) - echo "::error::Unexpected release state for v$new: $release_state" >&2 - exit 1 - ;; - esac + recover_unpublished_tag "v$new" "tag collision" || { + echo "::error::Tag v$new already exists and has a published release. Refusing to re-cut over a shipped version." >&2 + exit 1 + } fi echo "version=$new" >>"$GITHUB_OUTPUT" @@ -634,6 +663,12 @@ jobs: APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + # Why: `plutil -lint` accepts duplicate plist keys, but `codesign` + # rejects duplicate entitlements after the expensive app build. + - name: Verify macOS entitlements + if: matrix.platform == 'mac' + run: pnpm verify:macos-entitlements + # Why: telemetry's transport gate (`src/main/telemetry/client.ts:IS_OFFICIAL_BUILD`) # requires the build identity to be the literal string `stable` or `rc`, # substituted by electron-vite's `define` block at build time. Derive @@ -674,6 +709,9 @@ jobs: - name: Build app run: pnpm build:release env: + # Why: Vite's web build crossed Node's default old-space ceiling on + # the macOS release runner, leaving v1.4.2-rc.8 as an incomplete draft. + NODE_OPTIONS: --max-old-space-size=4096 ORCA_BUILD_IDENTITY: ${{ steps.tag-classify.outputs.identity }} ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }} diff --git a/.gitignore b/.gitignore index 28e3868ff1c..eca545b5345 100644 --- a/.gitignore +++ b/.gitignore @@ -67,9 +67,14 @@ design-docs/ .context/ .atl/ +# Machine-local agent hook endpoint files may contain auth tokens. +/agent-hooks/ + # Local-only design/planning docs (not checked in) docs/*.md +docs/**/*.md !docs/README*.md +!docs/**/README*.md # Stably CLI (only docs/ are tracked) .stably/* @@ -87,3 +92,6 @@ playwright-report/ /.claude/skills/ /.agents/skills/ /skills-lock.json + +# Agent hook runtime endpoints (machine-local secrets) +/agent-hooks/ diff --git a/README.md b/README.md index 18883b5f506..ccd76d71fe0 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,12 @@

- English · 中文 · 日本語 · Español + English · 中文 · 日本語 · 한국어 · Español

The AI Orchestrator for 100x builders.
- Run Claude Code, Codex, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.
+ Run Claude Code, Codex, Grok, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.
Available for macOS, Windows, and Linux.

@@ -33,6 +33,7 @@ Orca supports any CLI agent (*not just this list*).

Claude Code   Codex   + Grok   Gemini   Pi   Hermes Agent   @@ -60,7 +61,7 @@ Orca supports any CLI agent (*not just this list*). ## Features -- **No login required** — Bring your own Claude Code or Codex subscription. +- **No login required** — Bring your own Claude Code, Codex, or Grok subscription. - **Worktree-native** — Every feature gets its own worktree. No stashing, no branch juggling. Spin up and switch instantly. - **Multi-agent terminals** — Run multiple AI agents side-by-side in tabs and panes. See which ones are active at a glance. - **Built-in source control** — Review AI-generated diffs, make quick edits, and commit without leaving Orca. diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 30fc98ae895..87e87a92fc1 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -7,6 +7,13 @@ const featureWallResources = { from: 'resources/onboarding/feature-wall', to: 'onboarding/feature-wall' } +// Why: SSH relay deploy resolves bundles from process.resourcesPath in packaged +// apps. Keeping relay assets as extraResources makes them real directories +// instead of paths hidden inside app.asar. +const relayExtraResource = { + from: 'out/relay', + to: 'relay' +} /** @type {import('electron-builder').Configuration} */ module.exports = { @@ -39,6 +46,8 @@ module.exports = { // integration — dependencies inside the asar archive are invisible to // require(). Unpack CLI runtime deps so they resolve from // app.asar.unpacked/node_modules/. + // Why: remote runtime connections use WebSocket + E2EE from the packaged CLI + // before the GUI process starts, so those deps need the same treatment. // Why: sherpa-onnx native bindings (platform-specific subpackages) must be // unpacked because they ship .node addons + .dylib/.so files that cannot be // dlopen()'d from inside the asar archive. @@ -49,6 +58,8 @@ module.exports = { 'out/main/computer-sidecar.js', 'out/main/chunks/**', 'resources/**', + 'node_modules/ws/**', + 'node_modules/tweetnacl/**', 'node_modules/zod/**', 'node_modules/sherpa-onnx*/**' ], @@ -81,6 +92,7 @@ module.exports = { win: { executableName: 'Orca', extraResources: [ + relayExtraResource, { from: 'resources/win32/bin/orca.cmd', to: 'bin/orca.cmd' @@ -134,6 +146,7 @@ module.exports = { hardenedRuntime: isMacRelease, notarize: isMacRelease, extraResources: [ + relayExtraResource, { from: 'resources/darwin/bin/orca', to: 'bin/orca' @@ -170,6 +183,7 @@ module.exports = { // The Linux installer should not claim those system package/file names. executableName: 'orca-ide', extraResources: [ + relayExtraResource, { from: 'resources/linux/bin/orca', to: 'bin/orca' diff --git a/config/scripts/orca-dev b/config/scripts/orca-dev index 47bdb1c5b56..10754fe4ca3 100755 --- a/config/scripts/orca-dev +++ b/config/scripts/orca-dev @@ -25,4 +25,12 @@ case "$(uname -s)" in esac export ORCA_USER_DATA_PATH="${ORCA_DEV_USER_DATA_PATH:-$default_data}" +electron_executable="$REPO_ROOT/node_modules/.bin/electron" +case "$(uname -s)" in + MINGW*|MSYS*) electron_executable="$REPO_ROOT/node_modules/electron/dist/electron.exe" ;; +esac +if [ -z "${ORCA_APP_EXECUTABLE:-}" ] && [ -x "$electron_executable" ]; then + export ORCA_APP_EXECUTABLE="$electron_executable" + export ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1 +fi exec node "$CLI_ENTRY" "$@" diff --git a/config/scripts/publish-complete-draft-releases.mjs b/config/scripts/publish-complete-draft-releases.mjs new file mode 100644 index 00000000000..68af1d87c60 --- /dev/null +++ b/config/scripts/publish-complete-draft-releases.mjs @@ -0,0 +1,133 @@ +#!/usr/bin/env node + +import { appendFileSync } from 'node:fs' +import { pathToFileURL } from 'node:url' +import { verifyRequiredReleaseAssets } from './verify-release-required-assets.mjs' + +const API_VERSION = '2022-11-28' +const RELEASE_CUT_AUTHOR = 'github-actions[bot]' +const DESKTOP_RC_TAG_PATTERN = /^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$/ + +export function isReleaseCutDraft(release) { + return ( + release?.draft === true && + release?.author?.login === RELEASE_CUT_AUTHOR && + typeof release?.tag_name === 'string' && + DESKTOP_RC_TAG_PATTERN.test(release.tag_name) + ) +} + +function isRcTag(tag) { + return tag.includes('-rc.') +} + +async function githubJson(fetchImpl, url, token, options = {}) { + const res = await fetchImpl(url, { + ...options, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': API_VERSION, + ...options.headers + } + }) + if (!res.ok) { + const body = await res.text().catch(() => '') + throw new Error(`GitHub request failed ${res.status} ${res.statusText}: ${body.slice(0, 300)}`) + } + return res.json() +} + +async function fetchReleases(repo, token, fetchImpl) { + const releases = await githubJson( + fetchImpl, + `https://api.github.com/repos/${repo}/releases?per_page=100`, + token + ) + if (!Array.isArray(releases)) { + throw new Error(`GitHub releases response for ${repo} was not an array`) + } + return releases +} + +export async function publishCompleteDraftReleases({ + repo, + token, + fetchImpl = fetch, + verifyReleaseAssets = verifyRequiredReleaseAssets, + log = console.log +}) { + if (!repo) { + throw new Error('repo is required') + } + if (!token) { + throw new Error('token is required') + } + + const releases = await fetchReleases(repo, token, fetchImpl) + const candidates = releases + .filter(isReleaseCutDraft) + .sort((a, b) => new Date(a.created_at ?? 0).getTime() - new Date(b.created_at ?? 0).getTime()) + + const published = [] + const skipped = [] + + for (const release of candidates) { + const tag = release.tag_name + try { + await verifyReleaseAssets({ repo, tag, token }) + } catch (error) { + const reason = error instanceof Error ? error.message.split('\n')[0] : String(error) + skipped.push({ tag, reason }) + log(`Skipping incomplete RC draft release ${tag}: ${reason}`) + continue + } + + // Why: only release-cut-authored RC drafts with a complete asset set are + // resumed here; incomplete drafts stay private for the normal rebuild path. + await githubJson(fetchImpl, `https://api.github.com/repos/${repo}/releases/${release.id}`, token, { + method: 'PATCH', + body: JSON.stringify({ + draft: false, + prerelease: isRcTag(tag) + }) + }) + published.push(tag) + log(`Published complete RC draft release ${tag}`) + } + + if (published.length === 0 && skipped.length === 0) { + log('No complete release-cut RC drafts to publish.') + } + + return { published, skipped } +} + +export function writeGithubOutputs({ published, skipped }, outputPath = process.env.GITHUB_OUTPUT) { + if (!outputPath) { + return + } + appendFileSync( + outputPath, + `${[ + `published_count=${published.length}`, + `skipped_count=${skipped.length}`, + `published_tags=${published.join(',')}`, + `skipped_tags=${skipped.map((item) => item.tag).join(',')}` + ].join('\n')}\n` + ) +} + +async function main() { + const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN + const repo = process.env.GITHUB_REPOSITORY || 'stablyai/orca' + const result = await publishCompleteDraftReleases({ repo, token }) + writeGithubOutputs(result) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error.message) + process.exit(1) + }) +} diff --git a/config/scripts/publish-complete-draft-releases.test.mjs b/config/scripts/publish-complete-draft-releases.test.mjs new file mode 100644 index 00000000000..589f5475b50 --- /dev/null +++ b/config/scripts/publish-complete-draft-releases.test.mjs @@ -0,0 +1,136 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { + isReleaseCutDraft, + publishCompleteDraftReleases, + writeGithubOutputs +} from './publish-complete-draft-releases.mjs' + +function jsonResponse(body, init = {}) { + return { + ok: init.ok ?? true, + status: init.status ?? 200, + statusText: init.statusText ?? 'OK', + json: vi.fn(async () => body), + text: vi.fn(async () => (typeof body === 'string' ? body : JSON.stringify(body))) + } +} + +describe('isReleaseCutDraft', () => { + it('only accepts bot-authored desktop draft releases', () => { + expect( + isReleaseCutDraft({ + draft: true, + tag_name: 'v1.4.2-rc.7', + author: { login: 'github-actions[bot]' } + }) + ).toBe(true) + expect( + isReleaseCutDraft({ + draft: true, + tag_name: 'mobile-v0.0.7', + author: { login: 'github-actions[bot]' } + }) + ).toBe(false) + expect( + isReleaseCutDraft({ + draft: true, + tag_name: 'v1.4.2', + author: { login: 'github-actions[bot]' } + }) + ).toBe(false) + expect( + isReleaseCutDraft({ + draft: false, + tag_name: 'v1.4.2', + author: { login: 'github-actions[bot]' } + }) + ).toBe(false) + }) +}) + +describe('publishCompleteDraftReleases', () => { + it('publishes complete release-cut drafts and skips incomplete ones', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + jsonResponse([ + { + id: 7, + draft: true, + tag_name: 'v1.4.2-rc.7', + created_at: '2026-05-15T07:31:19Z', + author: { login: 'github-actions[bot]' } + }, + { + id: 8, + draft: true, + tag_name: 'v1.4.2-rc.8', + created_at: '2026-05-15T10:51:57Z', + author: { login: 'github-actions[bot]' } + } + ]) + ) + .mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.2-rc.7', draft: false })) + const verifyReleaseAssets = vi.fn(async ({ tag }) => { + if (tag === 'v1.4.2-rc.8') { + throw new Error('Release v1.4.2-rc.8 is missing required assets.') + } + }) + const log = vi.fn() + + const result = await publishCompleteDraftReleases({ + repo: 'stablyai/orca', + token: 'token', + fetchImpl, + verifyReleaseAssets, + log + }) + + expect(result).toEqual({ + published: ['v1.4.2-rc.7'], + skipped: [ + { + tag: 'v1.4.2-rc.8', + reason: 'Release v1.4.2-rc.8 is missing required assets.' + } + ] + }) + expect(fetchImpl).toHaveBeenLastCalledWith( + 'https://api.github.com/repos/stablyai/orca/releases/7', + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify({ draft: false, prerelease: true }) + }) + ) + }) +}) + +describe('writeGithubOutputs', () => { + it('writes count outputs for workflow conditions', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-release-outputs-')) + const outputPath = join(dir, 'output') + try { + writeGithubOutputs( + { + published: ['v1.4.2-rc.7'], + skipped: [{ tag: 'v1.4.2-rc.8', reason: 'missing assets' }] + }, + outputPath + ) + + expect(readFileSync(outputPath, 'utf8')).toBe( + `${[ + 'published_count=1', + 'skipped_count=1', + 'published_tags=v1.4.2-rc.7', + 'skipped_tags=v1.4.2-rc.8' + ].join('\n')}\n` + ) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/config/scripts/run-electron-vite-dev.mjs b/config/scripts/run-electron-vite-dev.mjs index 408206b9838..c3ac1b049a5 100644 --- a/config/scripts/run-electron-vite-dev.mjs +++ b/config/scripts/run-electron-vite-dev.mjs @@ -23,9 +23,15 @@ function getDevUserDataPath() { return path.join(process.env.HOME ?? '', 'Library', 'Application Support', 'orca-dev') } if (process.platform === 'win32') { - return path.join(process.env.APPDATA ?? path.join(process.env.USERPROFILE ?? '', 'AppData', 'Roaming'), 'orca-dev') + return path.join( + process.env.APPDATA ?? path.join(process.env.USERPROFILE ?? '', 'AppData', 'Roaming'), + 'orca-dev' + ) } - return path.join(process.env.XDG_CONFIG_HOME ?? path.join(process.env.HOME ?? '', '.config'), 'orca-dev') + return path.join( + process.env.XDG_CONFIG_HOME ?? path.join(process.env.HOME ?? '', '.config'), + 'orca-dev' + ) } function prepareDevCliWrapper() { @@ -33,18 +39,19 @@ function prepareDevCliWrapper() { mkdirSync(binDir, { recursive: true }) const userDataPath = getDevUserDataPath() const cliPath = path.join(repoRoot, 'out', 'cli', 'index.js') + const electronBin = getElectronExecutable() if (process.platform === 'win32') { writeFileSync( path.join(binDir, 'orca-dev.cmd'), - `@echo off\r\nset "ORCA_USER_DATA_PATH=${userDataPath}"\r\nnode "${cliPath}" %*\r\n`, + `@echo off\r\nset "ORCA_USER_DATA_PATH=${userDataPath}"\r\nset "ORCA_APP_EXECUTABLE=${electronBin}"\r\nset "ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1"\r\nnode "${cliPath}" %*\r\n`, 'utf8' ) } else { const wrapperPath = path.join(binDir, 'orca-dev') writeFileSync( wrapperPath, - `#!/usr/bin/env bash\nexport ORCA_USER_DATA_PATH=${JSON.stringify(userDataPath)}\nexec node ${JSON.stringify(cliPath)} "$@"\n`, + `#!/usr/bin/env bash\nexport ORCA_USER_DATA_PATH=${JSON.stringify(userDataPath)}\nexport ORCA_APP_EXECUTABLE=${JSON.stringify(electronBin)}\nexport ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1\nexec node ${JSON.stringify(cliPath)} "$@"\n`, 'utf8' ) chmodSync(wrapperPath, 0o755) @@ -54,6 +61,13 @@ function prepareDevCliWrapper() { console.log(`[orca-dev] Prepared wrapper in ${binDir}`) } +function getElectronExecutable() { + if (process.platform === 'win32') { + return path.join(repoRoot, 'node_modules', 'electron', 'dist', 'electron.exe') + } + return path.join(repoRoot, 'node_modules', '.bin', 'electron') +} + if (process.env.ORCA_SKIP_DEV_CLI_PREPARE !== '1') { prepareDevCliWrapper() } @@ -109,7 +123,10 @@ function parseDebugPortEnv(raw) { // space-separated form. `--remote-debugging-pipe` opts into pipe-based // debugging — don't fight the user's choice by injecting a port. const userPassedPort = forwardedRaw.some( - (a) => a === '--remote-debugging-port' || a.startsWith('--remote-debugging-port=') || a === '--remote-debugging-pipe' + (a) => + a === '--remote-debugging-port' || + a.startsWith('--remote-debugging-port=') || + a === '--remote-debugging-pipe' ) // Why: --help/--version exit immediately; binding a probe socket and printing // a debug-port line would be noise. @@ -136,7 +153,9 @@ if (!userPassedPort && !isHelpOrVersion) { // resolve to ::1 on IPv6-first hosts). console.error(`[orca-dev] Remote debugging on http://127.0.0.1:${port}`) } else { - console.error('[orca-dev] No free debug port found in sweep; starting without --remote-debugging-port.') + console.error( + '[orca-dev] No free debug port found in sweep; starting without --remote-debugging-port.' + ) } } const forwardedArgs = ['dev', ...forwardedRaw, ...forwardedExtras] diff --git a/config/scripts/verify-macos-entitlements.mjs b/config/scripts/verify-macos-entitlements.mjs new file mode 100644 index 00000000000..20bb7230510 --- /dev/null +++ b/config/scripts/verify-macos-entitlements.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node + +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = fileURLToPath(new URL('.', import.meta.url)) +const repoRoot = resolve(__dirname, '../..') + +const defaultPlists = [ + 'resources/build/entitlements.mac.plist', + 'resources/build/entitlements.computer-use.mac.plist' +] + +const plistPaths = process.argv.slice(2) +const pathsToCheck = plistPaths.length > 0 ? plistPaths : defaultPlists + +let failed = false + +for (const plistPath of pathsToCheck) { + const absolutePath = resolve(repoRoot, plistPath) + const xml = readFileSync(absolutePath, 'utf8') + const problems = findDuplicateDictKeys(xml) + + if (problems.length === 0) { + console.log(`${plistPath}: OK`) + continue + } + + failed = true + console.error(`${plistPath}: duplicate plist dict keys found`) + for (const problem of problems) { + console.error( + `- ${problem.key} first appears on line ${problem.firstLine}, duplicated on line ${problem.duplicateLine}` + ) + } +} + +if (failed) { + process.exit(1) +} + +function findDuplicateDictKeys(xml) { + const problems = [] + const lineStarts = buildLineStarts(xml) + const dictStack = [] + // Why: `plutil -lint` accepts duplicate keys, but `codesign` rejects + // duplicate entitlements during release signing. + const tokenPattern = + /||]*>([\s\S]*?)<\/key>|<(\/?)dict\b[^>]*>/g + + for (const match of xml.matchAll(tokenPattern)) { + if (match[0].startsWith(' B[End]\n```') + } finally { + editor.destroy() + } + }) + + it('inserts inline math as a rendered rich editor node', () => { + const editor = runCommand('inline-math') + + try { + expect(editor.getMarkdown()).toBe('$x$') + expect(editor.getJSON()).toMatchObject({ + content: [ + { + content: [ + { + type: 'inlineMath', + attrs: { latex: 'x' } + } + ] + } + ] + }) + } finally { + editor.destroy() + } + }) + + it('inserts display math as a rendered rich editor node', () => { + const editor = runCommand('math-block') + + try { + expect(editor.getMarkdown()).toBe('$$\nx\n$$') + expect(editor.getJSON()).toMatchObject({ + content: [ + { + type: 'blockMath', + attrs: { latex: 'x' } + } + ] + }) + } finally { + editor.destroy() + } + }) + + it('parses existing display math as a rendered rich editor node', () => { + const editor = createEditor('Before\n\n$$\nx + y\n$$\n\nAfter') + + try { + expect(editor.getJSON()).toMatchObject({ + content: [ + { type: 'paragraph' }, + { + type: 'blockMath', + attrs: { latex: 'x + y' } + }, + { type: 'paragraph' } + ] + }) + expect(editor.getMarkdown()).toBe('Before\n\n$$\nx + y\n$$\n\nAfter') + } finally { + editor.destroy() + } + }) + + it('inserts a plain unicode emoji', () => { + const editor = runCommand('emoji') + + try { + expect(editor.getMarkdown()).toBe('🙂') + } finally { + editor.destroy() + } + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-commands.tsx b/src/renderer/src/components/editor/rich-markdown-commands.tsx index aee9405ce83..03da78f3920 100644 --- a/src/renderer/src/components/editor/rich-markdown-commands.tsx +++ b/src/renderer/src/components/editor/rich-markdown-commands.tsx @@ -1,6 +1,20 @@ +/* eslint-disable max-lines -- Why: slash commands and doc-link command helpers share trigger-range contracts used by the editor keyboard path. */ import React from 'react' import type { Editor } from '@tiptap/react' -import { Heading1, Heading2, Heading3, ImageIcon, List, ListOrdered, Quote } from 'lucide-react' +import { TextSelection } from '@tiptap/pm/state' +import type {} from '@tiptap/extension-mathematics' +import { + Heading1, + Heading2, + Heading3, + ImageIcon, + List, + ListOrdered, + Quote, + Sigma, + Table2, + Workflow +} from 'lucide-react' import type { MarkdownDocument } from '../../../../shared/types' import { stripMarkdownExtension } from './markdown-doc-links' @@ -41,16 +55,72 @@ export type SlashCommandId = | 'code-block' | 'divider' | 'image' + | 'table' + | 'mermaid' + | 'inline-math' + | 'math-block' + | 'emoji' + +export type SlashCommandIcon = + | { kind: 'component'; component: React.ComponentType<{ className?: string }> } + | { kind: 'text'; value: string } + +export type SlashCommandGroup = 'Headings' | 'Basic blocks' | 'Advanced' | 'Media' | 'Others' export type SlashCommand = { id: SlashCommandId label: string aliases: string[] - icon: React.ComponentType<{ className?: string }> + icon: SlashCommandIcon + group: SlashCommandGroup description: string run: (editor: Editor) => void } +function icon(component: React.ComponentType<{ className?: string }>): SlashCommandIcon { + return { kind: 'component', component } +} + +function textIcon(value: string): SlashCommandIcon { + return { kind: 'text', value } +} + +function insertTextWithSelection( + editor: Editor, + text: string, + selectionStartOffset?: number, + selectionEndOffset = selectionStartOffset +): void { + editor.commands.command(({ state, dispatch }) => { + const from = state.selection.from + const tr = state.tr.insertText(text, from, state.selection.to) + + if (selectionStartOffset !== undefined) { + const selectionFrom = from + selectionStartOffset + const selectionTo = from + (selectionEndOffset ?? selectionStartOffset) + tr.setSelection(TextSelection.create(tr.doc, selectionFrom, selectionTo)) + } + + dispatch?.(tr.scrollIntoView()) + return true + }) +} + +function insertCodeBlock(editor: Editor, language: string, text: string): void { + editor.commands.command(({ state, dispatch }) => { + const codeBlockType = state.schema.nodes.codeBlock + if (!codeBlockType) { + return false + } + const node = codeBlockType.create({ language }, text ? state.schema.text(text) : undefined) + const tr = state.tr.replaceSelectionWith(node).scrollIntoView() + const cursor = tr.selection.from + 1 + tr.setSelection(TextSelection.create(tr.doc, cursor, cursor)) + dispatch?.(tr) + return true + }) +} + /** * Executes a slash command by first deleting the typed slash text, then * delegating to the command's run method. Image is special-cased because @@ -60,7 +130,8 @@ export function runSlashCommand( editor: Editor, slashMenu: { from: number; to: number }, command: SlashCommand, - onImageCommand?: () => void + onImageCommand?: () => void, + onEmojiCommand?: () => void ): void { editor.chain().focus().deleteRange({ from: slashMenu.from, to: slashMenu.to }).run() // Why: image insertion cannot rely on window.prompt() in Electron, so this @@ -69,25 +140,20 @@ export function runSlashCommand( onImageCommand() return } + if (command.id === 'emoji' && onEmojiCommand) { + onEmojiCommand() + return + } command.run(editor) } export const slashCommands: SlashCommand[] = [ - { - id: 'text', - label: 'Text', - aliases: ['paragraph', 'plain'], - icon: List, - description: 'Start a normal paragraph.', - run: (editor) => { - editor.chain().focus().setParagraph().run() - } - }, { id: 'heading-1', label: 'Heading 1', aliases: ['h1', 'title'], - icon: Heading1, + icon: icon(Heading1), + group: 'Headings', description: 'Large section heading.', run: (editor) => { // Use setHeading (not toggleHeading) so the slash command is idempotent — @@ -99,7 +165,8 @@ export const slashCommands: SlashCommand[] = [ id: 'heading-2', label: 'Heading 2', aliases: ['h2'], - icon: Heading2, + icon: icon(Heading2), + group: 'Headings', description: 'Medium section heading.', run: (editor) => { // Use setHeading (not toggleHeading) so the slash command is idempotent — @@ -111,7 +178,8 @@ export const slashCommands: SlashCommand[] = [ id: 'heading-3', label: 'Heading 3', aliases: ['h3'], - icon: Heading3, + icon: icon(Heading3), + group: 'Headings', description: 'Small section heading.', run: (editor) => { // Use setHeading (not toggleHeading) so the slash command is idempotent — @@ -119,51 +187,67 @@ export const slashCommands: SlashCommand[] = [ editor.chain().focus().setHeading({ level: 3 }).run() } }, - { - id: 'task-list', - label: 'To-do List', - aliases: ['todo', 'task', 'checkbox'], - icon: List, - description: 'Create a checklist.', - run: (editor) => { - editor.chain().focus().toggleTaskList().run() - } - }, - { - id: 'bullet-list', - label: 'Bullet List', - aliases: ['bullet', 'ul', 'list'], - icon: List, - description: 'Create an unordered list.', - run: (editor) => { - editor.chain().focus().toggleBulletList().run() - } - }, - { - id: 'ordered-list', - label: 'Numbered List', - aliases: ['ordered', 'ol', 'numbered'], - icon: ListOrdered, - description: 'Create an ordered list.', - run: (editor) => { - editor.chain().focus().toggleOrderedList().run() - } - }, { id: 'blockquote', label: 'Quote', aliases: ['quote', 'blockquote'], - icon: Quote, + icon: icon(Quote), + group: 'Basic blocks', description: 'Insert a blockquote.', run: (editor) => { editor.chain().focus().toggleBlockquote().run() } }, + { + id: 'ordered-list', + label: 'Numbered List', + aliases: ['ordered', 'ol', 'numbered'], + icon: icon(ListOrdered), + group: 'Basic blocks', + description: 'Create an ordered list.', + run: (editor) => { + editor.chain().focus().toggleOrderedList().run() + } + }, + { + id: 'bullet-list', + label: 'Bullet List', + aliases: ['bullet', 'ul', 'list'], + icon: icon(List), + group: 'Basic blocks', + description: 'Create an unordered list.', + run: (editor) => { + editor.chain().focus().toggleBulletList().run() + } + }, + { + id: 'task-list', + label: 'Check List', + aliases: ['todo', 'task', 'checkbox'], + icon: icon(List), + group: 'Basic blocks', + description: 'Create a checklist.', + run: (editor) => { + editor.chain().focus().toggleTaskList().run() + } + }, + { + id: 'text', + label: 'Paragraph', + aliases: ['paragraph', 'plain'], + icon: icon(List), + group: 'Basic blocks', + description: 'Start a normal paragraph.', + run: (editor) => { + editor.chain().focus().setParagraph().run() + } + }, { id: 'code-block', label: 'Code Block', aliases: ['code', 'snippet'], - icon: List, + icon: icon(List), + group: 'Basic blocks', description: 'Insert a fenced code block.', run: (editor) => { editor.chain().focus().toggleCodeBlock().run() @@ -173,23 +257,80 @@ export const slashCommands: SlashCommand[] = [ id: 'divider', label: 'Divider', aliases: ['divider', 'rule', 'hr'], - icon: List, + icon: icon(List), + group: 'Basic blocks', description: 'Insert a horizontal rule.', run: (editor) => { editor.chain().focus().setHorizontalRule().run() } }, + { + id: 'table', + label: 'Table', + aliases: ['grid', 'columns', 'rows'], + icon: icon(Table2), + group: 'Advanced', + description: 'Insert a 3x3 markdown table.', + run: (editor) => { + editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() + } + }, + { + id: 'mermaid', + label: 'Mermaid Diagram', + aliases: ['diagram', 'flowchart', 'chart', 'graph'], + icon: icon(Workflow), + group: 'Advanced', + description: 'Insert a Mermaid fenced block.', + run: (editor) => { + insertCodeBlock(editor, 'mermaid', 'graph TD\n A[Start] --> B[End]') + } + }, + { + id: 'inline-math', + label: 'Inline Math', + aliases: ['math', 'latex', 'equation', 'formula'], + icon: icon(Sigma), + group: 'Advanced', + description: 'Insert inline LaTeX math.', + run: (editor) => { + editor.commands.insertInlineMath({ latex: 'x' }) + } + }, + { + id: 'math-block', + label: 'Math Block', + aliases: ['display math', 'latex block', 'equation block'], + icon: icon(Sigma), + group: 'Advanced', + description: 'Insert display LaTeX math.', + run: (editor) => { + editor.commands.insertBlockMath({ latex: 'x' }) + } + }, { id: 'image', label: 'Image', aliases: ['image', 'img'], - icon: ImageIcon, + icon: icon(ImageIcon), + group: 'Media', description: 'Insert an image from your computer.', // Why: window.prompt() is not supported in Electron's renderer process, // so image URL input is handled by an inline input bar in RichMarkdownEditor. run: (editor) => { editor.chain().focus().run() } + }, + { + id: 'emoji', + label: 'Emoji', + aliases: ['smile', 'reaction', 'icon'], + icon: textIcon('🙂'), + group: 'Others', + description: 'Insert a plain Unicode emoji.', + run: (editor) => { + insertTextWithSelection(editor, '🙂') + } } ] diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts index 00479bfd89f..89e8478d134 100644 --- a/src/renderer/src/components/editor/rich-markdown-extensions.ts +++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts @@ -10,9 +10,11 @@ import { Table } from '@tiptap/extension-table' import { TableCell } from '@tiptap/extension-table-cell' import { TableHeader } from '@tiptap/extension-table-header' import { TableRow } from '@tiptap/extension-table-row' +import { BlockMath, InlineMath } from '@tiptap/extension-mathematics' import { Markdown } from '@tiptap/markdown' import { createLowlight, common } from 'lowlight' import { loadLocalImageSrc, onImageCacheInvalidated } from './useLocalImageSrc' +import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { RawMarkdownHtmlBlock, RawMarkdownHtmlInline } from './raw-markdown-html' import { MarkdownDocLink } from './rich-markdown-doc-link' import { RichMarkdownCodeBlock } from './RichMarkdownCodeBlock' @@ -55,7 +57,7 @@ export function createRichMarkdownExtensions({ // and works identically in dev and production modes. Image.extend({ addStorage() { - return { filePath: '' } + return { filePath: '', runtimeContext: undefined as RuntimeFileOperationArgs | undefined } }, addNodeView() { return ({ node, HTMLAttributes }) => { @@ -79,11 +81,14 @@ export function createRichMarkdownExtensions({ const loadImage = (src: string | undefined): void => { const fp = this.storage.filePath as string + const runtimeContext = this.storage.runtimeContext as + | RuntimeFileOperationArgs + | undefined if (src && fp) { // Why: when IPC resolution fails (e.g. unsupported format), // the ternary falls back to the raw src so the browser can // attempt its own loading rather than leaving a broken image. - void loadLocalImageSrc(src, fp).then((resolved) => { + void loadLocalImageSrc(src, fp, undefined, runtimeContext).then((resolved) => { img.src = resolved ? resolved : src }) } else if (src) { @@ -132,6 +137,17 @@ export function createRichMarkdownExtensions({ TableRow, TableHeader, TableCell, + InlineMath.configure({ + katexOptions: { + throwOnError: false + } + }), + BlockMath.configure({ + katexOptions: { + displayMode: true, + throwOnError: false + } + }), RawMarkdownHtmlInline, RawMarkdownHtmlBlock, MarkdownDocLink, diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts index 059c0a0448f..17bfb45df53 100644 --- a/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts @@ -45,6 +45,7 @@ function createContext(editor: Editor, typedMarker: boolean): KeyHandlerContext filteredDocLinkRowsRef: { current: [] }, selectedDocLinkIndexRef: { current: 0 }, handleLocalImagePickRef: { current: vi.fn() }, + handleEmojiPickRef: { current: vi.fn() }, typedEmptyOrderedListMarkerRef: { current: typedMarker }, flushPendingSerialization: vi.fn(), openSearchRef: { current: vi.fn() }, @@ -117,4 +118,31 @@ describe('rich markdown key handler', () => { editor.destroy() } }) + + it('keeps slash-menu filter input out of the document', () => { + const editor = createEditor({ + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: '/' }] }] + }) + + try { + editor.commands.setTextSelection(2) + let slashMenu = { query: '', from: 1, to: 2, left: 0, top: 0 } + const ctx = createContext(editor, false) + ctx.slashMenuRef.current = slashMenu + ctx.filteredSlashCommandsRef.current = [{ id: 'heading-1' } as never] + ctx.setSlashMenu = vi.fn((next) => { + slashMenu = typeof next === 'function' ? next(slashMenu) : next + ctx.slashMenuRef.current = slashMenu + }) + const event = keyEvent('h') + + expect(createRichMarkdownKeyHandler(ctx)(null, event)).toBe(true) + expect(event.preventDefault).toHaveBeenCalled() + expect(editor.getText()).toBe('/') + expect(ctx.slashMenuRef.current?.query).toBe('h') + } finally { + editor.destroy() + } + }) }) diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.ts index 70591cb00f9..c1bbd9851e1 100644 --- a/src/renderer/src/components/editor/rich-markdown-key-handler.ts +++ b/src/renderer/src/components/editor/rich-markdown-key-handler.ts @@ -31,6 +31,7 @@ export type KeyHandlerContext = { filteredDocLinkRowsRef: MutableRefObject selectedDocLinkIndexRef: MutableRefObject handleLocalImagePickRef: MutableRefObject<() => void> + handleEmojiPickRef: MutableRefObject<(menu: SlashMenuState) => void> typedEmptyOrderedListMarkerRef: MutableRefObject flushPendingSerialization: () => void openSearchRef: MutableRefObject<() => void> @@ -38,7 +39,7 @@ export type KeyHandlerContext = { setLinkBubble: (bubble: LinkBubbleState | null) => void setSelectedCommandIndex: Dispatch> setSelectedDocLinkIndex: Dispatch> - setSlashMenu: (menu: SlashMenuState | null) => void + setSlashMenu: Dispatch> setDocLinkMenu: (menu: DocLinkMenuState | null) => void } @@ -231,6 +232,20 @@ export function createRichMarkdownKeyHandler( return false } + if (!event.metaKey && !event.ctrlKey && !event.altKey && event.key.length === 1) { + event.preventDefault() + ctx.setSlashMenu((menu) => (menu ? { ...menu, query: `${menu.query}${event.key}` } : menu)) + ctx.setSelectedCommandIndex(0) + return true + } + + if (event.key === 'Backspace' && currentSlashMenu.query.length > 0) { + event.preventDefault() + ctx.setSlashMenu((menu) => (menu ? { ...menu, query: menu.query.slice(0, -1) } : menu)) + ctx.setSelectedCommandIndex(0) + return true + } + const currentFilteredSlashCommands = ctx.filteredSlashCommandsRef.current if (currentFilteredSlashCommands.length === 0) { return false @@ -265,8 +280,12 @@ export function createRichMarkdownKeyHandler( // mirrors the latest highlighted slash-menu item for keyboard picks. const selectedCommand = currentFilteredSlashCommands[ctx.selectedCommandIndexRef.current] if (selectedCommand) { - runSlashCommand(activeEditor, currentSlashMenu, selectedCommand, () => - ctx.handleLocalImagePickRef.current() + runSlashCommand( + activeEditor, + currentSlashMenu, + selectedCommand, + () => ctx.handleLocalImagePickRef.current(), + () => ctx.handleEmojiPickRef.current(currentSlashMenu) ) } return true diff --git a/src/renderer/src/components/editor/rich-markdown-normalize.test.ts b/src/renderer/src/components/editor/rich-markdown-normalize.test.ts new file mode 100644 index 00000000000..7f81eb795c0 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-normalize.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { Editor } from '@tiptap/core' +import StarterKit from '@tiptap/starter-kit' +import { Markdown } from '@tiptap/markdown' +import { normalizeSoftBreaks } from './rich-markdown-normalize' + +const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })] + +function createEditor(content: string): Editor { + return new Editor({ + element: null, + extensions, + content, + contentType: 'markdown' + }) +} + +describe('rich markdown normalization', () => { + it('normalizes empty ordered list items into caret targets', () => { + const editor = createEditor('1. Item 1\n2. Item 2\n3. \n\n## Next section\n') + + try { + normalizeSoftBreaks(editor) + + const list = editor.state.doc.child(0) + const emptyItem = list.child(2) + expect(emptyItem.type.name).toBe('listItem') + expect(emptyItem.childCount).toBe(1) + expect(emptyItem.child(0).type.name).toBe('paragraph') + expect(emptyItem.child(0).content.size).toBe(0) + } finally { + editor.destroy() + } + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-normalize.ts b/src/renderer/src/components/editor/rich-markdown-normalize.ts index a87aa972020..a3ead9a798b 100644 --- a/src/renderer/src/components/editor/rich-markdown-normalize.ts +++ b/src/renderer/src/components/editor/rich-markdown-normalize.ts @@ -10,8 +10,9 @@ import { Fragment, type Node as PmNode } from '@tiptap/pm/model' * of just one logical line. * * This function normalises the ProseMirror document by splitting any paragraph whose - * text nodes contain `\n` into separate paragraph nodes — one per line. Inline marks - * (bold, italic, links, etc.) are preserved on each resulting paragraph. This is + * text nodes contain `\n` into separate paragraph nodes — one per line — and by + * giving empty parsed list items a paragraph caret target. Inline marks (bold, + * italic, links, etc.) are preserved on each resulting paragraph. This is * structurally correct for the editing model: each visual line becomes its own block, * so the cut handler (and all other block-level operations) work on a per-line basis. */ @@ -30,9 +31,24 @@ export function normalizeSoftBreaks(editor: Editor): void { // Why: doc.forEach only iterates top-level children, so paragraphs nested inside // blockquotes, table cells, or other container nodes would be missed. // doc.descendants walks every node at every depth and provides absolute positions. - const replacements: { from: number; to: number; paragraphs: Fragment[] }[] = [] + const replacements: ( + | { from: number; to: number; kind: 'soft-break-paragraphs'; paragraphs: Fragment[] } + | { from: number; to: number; kind: 'empty-list-item'; node: PmNode } + )[] = [] doc.descendants((node, pos) => { + if (node.type.name === 'listItem' && node.childCount === 0) { + // Why: marked parses `3. ` immediately before a heading as a list item + // with no paragraph. It renders a marker but has no editable caret target. + replacements.push({ + from: pos, + to: pos + node.nodeSize, + kind: 'empty-list-item', + node: node.type.create(node.attrs, paragraphType.create(), node.marks) + }) + return false + } + if (node.type !== paragraphType) { return true // continue descending into container nodes } @@ -77,6 +93,7 @@ export function normalizeSoftBreaks(editor: Editor): void { replacements.push({ from: pos, to: pos + node.nodeSize, + kind: 'soft-break-paragraphs', paragraphs: lines }) @@ -90,11 +107,16 @@ export function normalizeSoftBreaks(editor: Editor): void { // Capture the transaction lazily — only after all replacements are collected. const tr = editor.view.state.tr - // Apply replacements in reverse order to preserve positions. - for (let i = replacements.length - 1; i >= 0; i--) { - const { from, to, paragraphs } = replacements[i] - const newNodes = paragraphs.map((content) => paragraphType.create(null, content)) - tr.replaceWith(from, to, newNodes) + // Apply replacements in reverse document order to preserve positions. + replacements.sort((a, b) => b.from - a.from) + for (const replacement of replacements) { + if (replacement.kind === 'empty-list-item') { + tr.replaceWith(replacement.from, replacement.to, replacement.node) + continue + } + + const newNodes = replacement.paragraphs.map((content) => paragraphType.create(null, content)) + tr.replaceWith(replacement.from, replacement.to, newNodes) } // Why: this normalization is a structural housekeeping step, not a user edit. diff --git a/src/renderer/src/components/editor/useClosedEditorTabCleanup.ts b/src/renderer/src/components/editor/useClosedEditorTabCleanup.ts new file mode 100644 index 00000000000..99cd6e199c7 --- /dev/null +++ b/src/renderer/src/components/editor/useClosedEditorTabCleanup.ts @@ -0,0 +1,62 @@ +import { useEffect, useRef } from 'react' +import * as monaco from 'monaco-editor' +import type { OpenFile } from '@/store/slices/editor' +import { cursorPositionCache, diffViewStateCache, scrollTopCache } from '@/lib/scroll-cache' + +function deleteCacheEntriesByPrefix(cache: Map, prefix: string): void { + for (const key of cache.keys()) { + if (key.startsWith(prefix)) { + cache.delete(key) + } + } +} + +export function useClosedEditorTabCleanup(openFiles: OpenFile[]): void { + const prevOpenFilesRef = useRef>(new Map()) + + useEffect(() => { + const currentFilesById = new Map(openFiles.map((f) => [f.id, f])) + for (const [prevId, prevFile] of prevOpenFilesRef.current) { + if (!currentFilesById.has(prevId)) { + disposeClosedEditorTab(prevId, prevFile) + } + } + prevOpenFilesRef.current = currentFilesById + }, [openFiles]) +} + +function disposeClosedEditorTab(prevId: string, prevFile: OpenFile): void { + switch (prevFile.mode) { + case 'edit': + // Why: the edit model URI is constructed via monaco.Uri.parse(filePath) + // to match @monaco-editor/react's `path` prop convention. + monaco.editor.getModel(monaco.Uri.parse(prevFile.filePath))?.dispose() + scrollTopCache.delete(prevFile.filePath) + deleteCacheEntriesByPrefix(scrollTopCache, `${prevFile.filePath}::`) + // Why: markdown and mermaid surfaces keep mode-scoped scroll positions. + scrollTopCache.delete(`${prevFile.filePath}:rich`) + scrollTopCache.delete(`${prevFile.filePath}:preview`) + scrollTopCache.delete(`${prevFile.filePath}:mermaid-diagram`) + cursorPositionCache.delete(prevFile.filePath) + deleteCacheEntriesByPrefix(cursorPositionCache, `${prevFile.filePath}::`) + break + case 'markdown-preview': + // Why: preview tabs own pane-scoped preview scroll cache entries even + // though they do not retain Monaco models. + scrollTopCache.delete(`${prevFile.id}:preview`) + deleteCacheEntriesByPrefix(scrollTopCache, `${prevFile.id}::`) + break + case 'diff': + // Why: kept diff models are keyed by tab id because one file can appear + // in multiple diff tabs with different contents. + monaco.editor.getModel(monaco.Uri.parse(`diff:original:${prevId}`))?.dispose() + monaco.editor.getModel(monaco.Uri.parse(`diff:modified:${prevId}`))?.dispose() + diffViewStateCache.delete(prevId) + deleteCacheEntriesByPrefix(diffViewStateCache, `${prevId}::`) + scrollTopCache.delete(`${prevId}:preview`) + deleteCacheEntriesByPrefix(scrollTopCache, `${prevId}::`) + break + case 'conflict-review': + break + } +} diff --git a/src/renderer/src/components/editor/useEditorCmdSaveRequest.ts b/src/renderer/src/components/editor/useEditorCmdSaveRequest.ts new file mode 100644 index 00000000000..f89a0ef24f8 --- /dev/null +++ b/src/renderer/src/components/editor/useEditorCmdSaveRequest.ts @@ -0,0 +1,50 @@ +import { useEffect } from 'react' +import { useAppStore } from '@/store' +import type { OpenFile } from '@/store/slices/editor' +import { ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT } from './editor-autosave' +import type { FileContent } from './editor-panel-content-types' + +type UseEditorCmdSaveRequestParams = { + activeFile: OpenFile | null + openFiles: OpenFile[] + fileContents: Record + handleSave: (content: string) => Promise +} + +export function useEditorCmdSaveRequest({ + activeFile, + openFiles, + fileContents, + handleSave +}: UseEditorCmdSaveRequestParams): void { + useEffect(() => { + const handler = (): void => { + if (!activeFile) { + return + } + const saveTargetFile = + activeFile.mode === 'markdown-preview' + ? (openFiles.find( + (openFile) => + openFile.id === activeFile.markdownPreviewSourceFileId && openFile.mode === 'edit' + ) ?? null) + : activeFile + if (!saveTargetFile) { + return + } + // Why: a markdown preview tab is read-only but fronts the same document, + // so Cmd/Ctrl+S should save the source editor's current draft. + const state = useAppStore.getState() + const draft = state.editorDrafts[saveTargetFile.id] + if (!draft && !saveTargetFile.isUntitled && !saveTargetFile.isDirty) { + return + } + const fallbackContent = + draft ?? + (activeFile.mode === 'markdown-preview' ? fileContents[activeFile.id]?.content : '') + void handleSave(fallbackContent ?? '') + } + window.addEventListener(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, handler) + return () => window.removeEventListener(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, handler) + }, [activeFile, fileContents, handleSave, openFiles]) +} diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.ts b/src/renderer/src/components/editor/useEditorPanelContentState.ts new file mode 100644 index 00000000000..f26154be5cd --- /dev/null +++ b/src/renderer/src/components/editor/useEditorPanelContentState.ts @@ -0,0 +1,289 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { OpenFile } from '@/store/slices/editor' +import { getConnectionId } from '@/lib/connection-context' +import { useAppStore } from '@/store' +import { getRuntimeFileReadScope, readRuntimeFileContent } from '@/runtime/runtime-file-client' +import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { + getRuntimeGitBranchDiff, + getRuntimeGitDiff, + getRuntimeGitScope +} from '@/runtime/runtime-git-client' +import type { DiffContent, FileContent } from './editor-panel-content-types' +import { canUseChangesModeForFile } from './editor-panel-file-mode' +import { + useEditorPanelExternalContentEvents, + usePruneClosedEditorContent +} from './useEditorPanelExternalContentEvents' +import { useEditorPanelFileLoadRetry } from './useEditorPanelFileLoadRetry' + +const inFlightFileReads = new Map>() +const inFlightDiffReads = new Map>() + +type GitStatusByWorktree = ReturnType['gitStatusByWorktree'] +type EditorViewModeByFile = ReturnType['editorViewMode'] + +type UseEditorPanelContentStateParams = { + activeFile: OpenFile | null + isChangesMode: boolean + openFiles: OpenFile[] + gitStatusByWorktree: GitStatusByWorktree + editorViewMode: EditorViewModeByFile +} + +type UseEditorPanelContentStateResult = { + fileContents: Record + diffContents: Record + reloadFileContent: (file: OpenFile) => void +} + +function inFlightReadKey(connectionId: string | undefined, filePath: string): string { + return `${connectionId ?? ''}::${filePath}` +} + +function inFlightDiffKey( + file: OpenFile, + connectionId: string | undefined, + compareAgainstHead = false +): string { + const branch = + file.diffSource === 'branch' && file.branchCompare + ? `${file.branchCompare.baseOid ?? ''}..${file.branchCompare.headOid ?? ''}::${file.branchOldPath ?? ''}` + : '' + return `${connectionId ?? ''}::${file.diffSource ?? ''}::${compareAgainstHead ? 'head' : 'default'}::${file.filePath}::${branch}` +} + +export function useEditorPanelContentState({ + activeFile, + isChangesMode, + openFiles, + gitStatusByWorktree, + editorViewMode +}: UseEditorPanelContentStateParams): UseEditorPanelContentStateResult { + const [fileContents, setFileContents] = useState>({}) + const [diffContents, setDiffContents] = useState>({}) + const fileLoadRetryAttemptsRef = useRef>({}) + const openFilesRef = useRef(openFiles) + openFilesRef.current = openFiles + const editorViewModeRef = useRef(editorViewMode) + editorViewModeRef.current = editorViewMode + + const loadFileContent = useCallback( + async (filePath: string, id: string, worktreeId?: string): Promise => { + try { + const connectionId = getConnectionId(worktreeId ?? null) ?? undefined + const restoredOpenFile = openFilesRef.current.find((file) => file.id === id) + const activeSettings = useAppStore.getState().settings + const readSettings = settingsForRuntimeOwner( + activeSettings, + restoredOpenFile?.runtimeEnvironmentId + ) + if (restoredOpenFile?.filePath === filePath && restoredOpenFile.relativePath === filePath) { + if (readSettings?.activeRuntimeEnvironmentId?.trim() || connectionId) { + // Why: restored external-file tabs contain client-local absolute + // paths. Remote runtime and SSH workspaces cannot read those paths + // without an explicit upload/import flow. + throw new Error('External local files are not available for remote workspaces.') + } + // Why: restored external-file tabs need their main-process path grant + // refreshed because that authorization is only held in memory. + await window.api.fs.authorizeExternalPath({ targetPath: filePath }) + } + const readScope = getRuntimeFileReadScope(readSettings, connectionId) + const key = inFlightReadKey(readScope, filePath) + let pending = inFlightFileReads.get(key) + if (!pending) { + pending = readRuntimeFileContent({ + settings: readSettings, + filePath, + relativePath: restoredOpenFile?.relativePath, + worktreeId, + connectionId + }) as Promise + inFlightFileReads.set(key, pending) + queueMicrotask(() => { + if (inFlightFileReads.get(key) === pending) { + inFlightFileReads.delete(key) + } + }) + } + const result = await pending + delete fileLoadRetryAttemptsRef.current[id] + setFileContents((prev) => ({ ...prev, [id]: result })) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + setFileContents((prev) => ({ + ...prev, + [id]: { content: '', isBinary: false, loadError: message } + })) + } + }, + [] + ) + + const loadDiffContent = useCallback(async (file: OpenFile | null): Promise => { + if (!file || (file.mode === 'edit' && !canUseChangesModeForFile(file))) { + return + } + try { + const worktreePath = file.filePath.slice( + 0, + file.filePath.length - file.relativePath.length - 1 + ) + const branchCompare = + file.branchCompare?.baseOid && file.branchCompare.headOid && file.branchCompare.mergeBase + ? file.branchCompare + : null + const connectionId = getConnectionId(file.worktreeId) ?? undefined + const activeSettings = useAppStore.getState().settings + const fileSettings = settingsForRuntimeOwner(activeSettings, file.runtimeEnvironmentId) + const gitScope = getRuntimeGitScope(fileSettings, connectionId) + const effectiveDiffSource: typeof file.diffSource = + file.mode === 'edit' ? 'unstaged' : file.diffSource + const compareAgainstHead = file.mode === 'edit' + const key = inFlightDiffKey( + { ...file, diffSource: effectiveDiffSource }, + gitScope, + compareAgainstHead + ) + let pending = inFlightDiffReads.get(key) + if (!pending) { + pending = ( + effectiveDiffSource === 'branch' && branchCompare + ? getRuntimeGitBranchDiff( + { + settings: fileSettings, + worktreeId: file.worktreeId, + worktreePath, + connectionId + }, + { + compare: { + baseRef: branchCompare.baseRef, + baseOid: branchCompare.baseOid!, + headOid: branchCompare.headOid!, + mergeBase: branchCompare.mergeBase! + }, + filePath: file.relativePath, + oldPath: file.branchOldPath + } + ) + : getRuntimeGitDiff( + { + settings: fileSettings, + worktreeId: file.worktreeId, + worktreePath, + connectionId + }, + { + filePath: file.relativePath, + staged: effectiveDiffSource === 'staged', + compareAgainstHead + } + ) + ) as Promise + inFlightDiffReads.set(key, pending) + queueMicrotask(() => { + if (inFlightDiffReads.get(key) === pending) { + inFlightDiffReads.delete(key) + } + }) + } + const result = await pending + setDiffContents((prev) => ({ ...prev, [file.id]: result })) + } catch (err) { + setDiffContents((prev) => ({ + ...prev, + [file.id]: { + kind: 'text', + originalContent: '', + modifiedContent: `Error loading diff: ${err}`, + originalIsBinary: false, + modifiedIsBinary: false + } + })) + } + }, []) + + const reloadFileContent = useCallback( + (file: OpenFile): void => { + delete fileLoadRetryAttemptsRef.current[file.id] + setFileContents((prev) => { + if (!prev[file.id]) { + return prev + } + const next = { ...prev } + delete next[file.id] + return next + }) + void loadFileContent(file.filePath, file.id, file.worktreeId) + }, + [loadFileContent] + ) + + useEffect(() => { + if (!activeFile || activeFile.mode === 'conflict-review') { + return + } + if (activeFile.mode === 'edit' || activeFile.mode === 'markdown-preview') { + if (activeFile.conflict?.kind === 'conflict-placeholder') { + return + } + if (!fileContents[activeFile.id]) { + void loadFileContent(activeFile.filePath, activeFile.id, activeFile.worktreeId) + } + if (isChangesMode && !diffContents[activeFile.id]) { + void loadDiffContent(activeFile) + } + } else if ( + activeFile.mode === 'diff' && + activeFile.diffSource !== undefined && + activeFile.diffSource !== 'combined-uncommitted' && + activeFile.diffSource !== 'combined-branch' && + !diffContents[activeFile.id] + ) { + void loadDiffContent(activeFile) + } + }, [activeFile?.id, isChangesMode]) // eslint-disable-line react-hooks/exhaustive-deps + + useEditorPanelFileLoadRetry({ + activeFile, + fileContents, + fileLoadRetryAttemptsRef, + loadFileContent, + openFilesRef, + setFileContents + }) + + const changesStatusEntries = activeFile?.worktreeId + ? gitStatusByWorktree[activeFile.worktreeId] + : undefined + useEffect(() => { + if (!isChangesMode || !activeFile?.id) { + return + } + const current = openFilesRef.current.find((f) => f.id === activeFile.id) + if (current) { + void loadDiffContent(current) + } + }, [ + changesStatusEntries, + isChangesMode, + activeFile?.id, + activeFile?.worktreeId, + activeFile?.relativePath, + loadDiffContent + ]) + + useEditorPanelExternalContentEvents({ + loadDiffContent, + loadFileContent, + openFilesRef, + editorViewModeRef, + setFileContents, + setDiffContents + }) + usePruneClosedEditorContent(openFiles, fileLoadRetryAttemptsRef, setFileContents, setDiffContents) + + return { fileContents, diffContents, reloadFileContent } +} diff --git a/src/renderer/src/components/editor/useEditorPanelExternalContentEvents.ts b/src/renderer/src/components/editor/useEditorPanelExternalContentEvents.ts new file mode 100644 index 00000000000..bf82c7bbc09 --- /dev/null +++ b/src/renderer/src/components/editor/useEditorPanelExternalContentEvents.ts @@ -0,0 +1,132 @@ +import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react' +import type { useAppStore } from '@/store' +import type { OpenFile } from '@/store/slices/editor' +import { + getOpenFilesForExternalFileChange, + ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, + ORCA_EDITOR_FILE_SAVED_EVENT, + type EditorFileSavedDetail, + type EditorPathMutationTarget +} from './editor-autosave' +import type { DiffContent, FileContent } from './editor-panel-content-types' + +type EditorViewModeByFile = ReturnType['editorViewMode'] + +type UseEditorPanelExternalContentEventsParams = { + loadDiffContent: (file: OpenFile | null) => Promise + loadFileContent: (filePath: string, id: string, worktreeId?: string) => Promise + openFilesRef: MutableRefObject + editorViewModeRef: MutableRefObject + setFileContents: Dispatch>> + setDiffContents: Dispatch>> +} + +export function useEditorPanelExternalContentEvents({ + loadDiffContent, + loadFileContent, + openFilesRef, + editorViewModeRef, + setFileContents, + setDiffContents +}: UseEditorPanelExternalContentEventsParams): void { + useEffect(() => { + const handler = (event: Event): void => { + const detail = (event as CustomEvent).detail + if (!detail) { + return + } + for (const file of getOpenFilesForExternalFileChange(openFilesRef.current, detail)) { + if (file.mode === 'edit' || file.mode === 'markdown-preview') { + void loadFileContent(file.filePath, file.id, file.worktreeId) + if (editorViewModeRef.current[file.id] === 'changes') { + void loadDiffContent(file) + } + } else if ( + file.mode === 'diff' && + file.diffSource !== 'combined-uncommitted' && + file.diffSource !== 'combined-branch' + ) { + void loadDiffContent(file) + } + } + } + window.addEventListener(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, handler as EventListener) + return () => + window.removeEventListener(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, handler as EventListener) + }, [editorViewModeRef, loadDiffContent, loadFileContent, openFilesRef]) + + useEffect(() => { + const handler = (event: Event): void => { + const detail = (event as CustomEvent).detail + if (!detail) { + return + } + const file = openFilesRef.current.find((openFile) => openFile.id === detail.fileId) + if (!file) { + return + } + if (file.mode === 'edit' || file.mode === 'markdown-preview') { + setFileContents((prev) => ({ + ...prev, + [file.id]: { content: detail.content, isBinary: false } + })) + } + updateSavedPreviewTabs(openFilesRef.current, detail, setFileContents) + if (file.mode === 'edit' || file.mode === 'markdown-preview') { + return + } + setDiffContents((prev) => { + const existing = prev[file.id] + if (!existing || existing.kind !== 'text') { + return prev + } + return { ...prev, [file.id]: { ...existing, modifiedContent: detail.content } } + }) + } + window.addEventListener(ORCA_EDITOR_FILE_SAVED_EVENT, handler as EventListener) + return () => window.removeEventListener(ORCA_EDITOR_FILE_SAVED_EVENT, handler as EventListener) + }, [openFilesRef, setDiffContents, setFileContents]) +} + +function updateSavedPreviewTabs( + openFiles: OpenFile[], + detail: EditorFileSavedDetail, + setFileContents: Dispatch>> +): void { + const previewTabs = openFiles.filter( + (openFile) => + openFile.mode === 'markdown-preview' && openFile.markdownPreviewSourceFileId === detail.fileId + ) + if (previewTabs.length === 0) { + return + } + setFileContents((prev) => { + const next = { ...prev } + for (const previewTab of previewTabs) { + next[previewTab.id] = { content: detail.content, isBinary: false } + } + return next + }) +} + +export function usePruneClosedEditorContent( + openFiles: OpenFile[], + fileLoadRetryAttemptsRef: MutableRefObject>, + setFileContents: Dispatch>>, + setDiffContents: Dispatch>> +): void { + useEffect(() => { + const openIds = new Set(openFiles.map((f) => f.id)) + for (const fileId of Object.keys(fileLoadRetryAttemptsRef.current)) { + if (!openIds.has(fileId)) { + delete fileLoadRetryAttemptsRef.current[fileId] + } + } + setFileContents((prev) => + Object.fromEntries(Object.entries(prev).filter(([key]) => openIds.has(key))) + ) + setDiffContents((prev) => + Object.fromEntries(Object.entries(prev).filter(([key]) => openIds.has(key))) + ) + }, [fileLoadRetryAttemptsRef, openFiles, setDiffContents, setFileContents]) +} diff --git a/src/renderer/src/components/editor/useEditorPanelFileLoadRetry.ts b/src/renderer/src/components/editor/useEditorPanelFileLoadRetry.ts new file mode 100644 index 00000000000..0c9f7bff42c --- /dev/null +++ b/src/renderer/src/components/editor/useEditorPanelFileLoadRetry.ts @@ -0,0 +1,80 @@ +import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react' +import type { OpenFile } from '@/store/slices/editor' +import type { FileContent } from './editor-panel-content-types' + +const FILE_LOAD_RETRY_DELAYS_MS = [250, 1000, 2500] + +type UseEditorPanelFileLoadRetryParams = { + activeFile: OpenFile | null + fileContents: Record + fileLoadRetryAttemptsRef: MutableRefObject> + loadFileContent: (filePath: string, id: string, worktreeId?: string) => Promise + openFilesRef: MutableRefObject + setFileContents: Dispatch>> +} + +function shouldRetryFileLoadError(message: string): boolean { + const lower = message.toLowerCase() + return ( + !lower.includes('access denied') && + !lower.includes('enoent') && + !lower.includes('no such file') && + !lower.includes('file too large') + ) +} + +export function useEditorPanelFileLoadRetry({ + activeFile, + fileContents, + fileLoadRetryAttemptsRef, + loadFileContent, + openFilesRef, + setFileContents +}: UseEditorPanelFileLoadRetryParams): void { + const activeFileLoadRetryId = activeFile?.id ?? null + const activeFileLoadError = activeFileLoadRetryId + ? fileContents[activeFileLoadRetryId]?.loadError + : undefined + + useEffect(() => { + if ( + !activeFileLoadRetryId || + !activeFileLoadError || + !shouldRetryFileLoadError(activeFileLoadError) + ) { + return + } + const retryCount = fileLoadRetryAttemptsRef.current[activeFileLoadRetryId] ?? 0 + if (retryCount >= FILE_LOAD_RETRY_DELAYS_MS.length) { + return + } + const delayMs = FILE_LOAD_RETRY_DELAYS_MS[retryCount] ?? FILE_LOAD_RETRY_DELAYS_MS[0] + fileLoadRetryAttemptsRef.current[activeFileLoadRetryId] = retryCount + 1 + const timeoutId = window.setTimeout(() => { + const currentFile = openFilesRef.current.find((file) => file.id === activeFileLoadRetryId) + if ( + !currentFile || + (currentFile.mode !== 'edit' && currentFile.mode !== 'markdown-preview') + ) { + return + } + setFileContents((prev) => { + if (prev[currentFile.id]?.loadError !== activeFileLoadError) { + return prev + } + const next = { ...prev } + delete next[currentFile.id] + return next + }) + void loadFileContent(currentFile.filePath, currentFile.id, currentFile.worktreeId) + }, delayMs) + return () => window.clearTimeout(timeoutId) + }, [ + activeFileLoadRetryId, + activeFileLoadError, + fileLoadRetryAttemptsRef, + loadFileContent, + openFilesRef, + setFileContents + ]) +} diff --git a/src/renderer/src/components/editor/useLinkBubble.ts b/src/renderer/src/components/editor/useLinkBubble.ts index a91b199b110..76941e0dc43 100644 --- a/src/renderer/src/components/editor/useLinkBubble.ts +++ b/src/renderer/src/components/editor/useLinkBubble.ts @@ -20,6 +20,7 @@ export function useLinkBubble( sourceFilePath: string worktreeId: string worktreeRoot: string | null + runtimeEnvironmentId?: string | null } ): { handleLinkSave: (href: string) => void @@ -108,7 +109,8 @@ export function useLinkBubble( void activateMarkdownLink(linkBubble.href, { sourceFilePath: linkContext.sourceFilePath, worktreeId: linkContext.worktreeId, - worktreeRoot: linkContext.worktreeRoot + worktreeRoot: linkContext.worktreeRoot, + runtimeEnvironmentId: linkContext.runtimeEnvironmentId }) }, [ activateMarkdownLink, @@ -116,6 +118,7 @@ export function useLinkBubble( linkContext.sourceFilePath, linkContext.worktreeId, linkContext.worktreeRoot, + linkContext.runtimeEnvironmentId, rootRef ]) diff --git a/src/renderer/src/components/editor/useLocalImagePick.ts b/src/renderer/src/components/editor/useLocalImagePick.ts index 8c1446ac236..444a849ca1d 100644 --- a/src/renderer/src/components/editor/useLocalImagePick.ts +++ b/src/renderer/src/components/editor/useLocalImagePick.ts @@ -2,8 +2,18 @@ import { useCallback } from 'react' import { toast } from 'sonner' import type { Editor } from '@tiptap/react' import { extractIpcErrorMessage, getImageCopyDestination } from './rich-markdown-image-utils' +import { useAppStore } from '@/store' +import { getConnectionId } from '@/lib/connection-context' +import { basename, dirname } from '@/lib/path' +import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client' +import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' -export function useLocalImagePick(editor: Editor | null, filePath: string): () => Promise { +export function useLocalImagePick( + editor: Editor | null, + filePath: string, + worktreeId: string | null, + runtimeEnvironmentId?: string | null +): () => Promise { return useCallback(async () => { if (!editor) { return @@ -18,6 +28,45 @@ export function useLocalImagePick(editor: Editor | null, filePath: string): () = if (!srcPath) { return } + const connectionId = getConnectionId(worktreeId) ?? undefined + const settings = settingsForRuntimeOwner( + useAppStore.getState().settings, + runtimeEnvironmentId + ) + if (settings?.activeRuntimeEnvironmentId?.trim() || connectionId) { + const worktreePath = getWorktreePath(worktreeId) + if (settings?.activeRuntimeEnvironmentId?.trim() && !worktreePath) { + toast.error('Worktree path not available.') + return + } + // Why: picked images are client-local files while remote markdown lives + // on the server. Upload beside the markdown file before inserting the + // relative image path so preview/save works from any client. + const { results } = await importExternalPathsToRuntime( + { + settings, + worktreeId, + worktreePath, + connectionId + }, + [srcPath], + dirname(filePath) + ) + const imported = results.find((result) => result.status === 'imported') + if (!imported) { + toast.error('Failed to insert image.') + return + } + editor + .chain() + .focus() + .insertContentAt(insertPos, { + type: 'image', + attrs: { src: basename(imported.destPath) } + }) + .run() + return + } // Why: copy the image next to the markdown file and insert a relative path // so the markdown stays portable and doesn't bloat with base64 data. const { imageName, destPath } = await getImageCopyDestination(filePath, srcPath) @@ -35,5 +84,14 @@ export function useLocalImagePick(editor: Editor | null, filePath: string): () = } catch (err) { toast.error(extractIpcErrorMessage(err, 'Failed to insert image.')) } - }, [editor, filePath]) + }, [editor, filePath, runtimeEnvironmentId, worktreeId]) +} + +function getWorktreePath(worktreeId: string | null): string | null { + if (!worktreeId) { + return null + } + const state = useAppStore.getState() + const worktrees = Object.values(state.worktreesByRepo ?? {}).flat() + return worktrees.find((worktree) => worktree.id === worktreeId)?.path ?? null } diff --git a/src/renderer/src/components/editor/useLocalImageSrc.test.ts b/src/renderer/src/components/editor/useLocalImageSrc.test.ts new file mode 100644 index 00000000000..736be5a7672 --- /dev/null +++ b/src/renderer/src/components/editor/useLocalImageSrc.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { getLocalImageCacheKey } from './useLocalImageSrc' + +describe('getLocalImageCacheKey', () => { + it('scopes local markdown image cache entries by runtime owner', () => { + const localKey = getLocalImageCacheKey('/repo/docs/logo.png', null, { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }) + const remoteKey = getLocalImageCacheKey('/repo/docs/logo.png', null, { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }) + const otherRemoteKey = getLocalImageCacheKey('/repo/docs/logo.png', null, { + settings: { activeRuntimeEnvironmentId: 'env-2' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }) + + expect(localKey).not.toBe(remoteKey) + expect(remoteKey).not.toBe(otherRemoteKey) + }) +}) diff --git a/src/renderer/src/components/editor/useLocalImageSrc.ts b/src/renderer/src/components/editor/useLocalImageSrc.ts index 3206e99b353..1f55756fba7 100644 --- a/src/renderer/src/components/editor/useLocalImageSrc.ts +++ b/src/renderer/src/components/editor/useLocalImageSrc.ts @@ -1,5 +1,7 @@ import { useEffect, useState } from 'react' import { resolveImageAbsolutePath } from './markdown-preview-links' +import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' +import { readRuntimeFilePreview } from '@/runtime/runtime-file-client' // Why: the renderer is served from http://localhost in dev mode, so file:// // URLs in tags are blocked by cross-origin restrictions. Loading images @@ -9,6 +11,21 @@ import { resolveImageAbsolutePath } from './markdown-preview-links' const BLOB_URL_CACHE_MAX_SIZE = 100 const blobUrlCache = new Map() +export function getLocalImageCacheKey( + absolutePath: string, + connectionId?: string | null, + runtimeContext?: Omit & { connectionId?: string | null } +): string { + const runtimeEnvironmentId = + runtimeContext?.settings?.activeRuntimeEnvironmentId?.trim() ?? 'client' + return [ + runtimeEnvironmentId, + runtimeContext?.connectionId ?? connectionId ?? 'local', + runtimeContext?.worktreeId ?? 'unknown-worktree', + absolutePath + ].join('\0') +} + // Why: blob URLs hold references to in-memory Blob objects; without eviction // the cache grows without bound and leaks memory. We evict the oldest entry // (Map iteration order is insertion order) and revoke its blob URL so the @@ -97,7 +114,8 @@ function isExternalUrl(src: string): boolean { export function useLocalImageSrc( rawSrc: string | undefined, filePath: string, - connectionId?: string | null + connectionId?: string | null, + runtimeContext?: Omit & { connectionId?: string | null } ): string | undefined { const [generation, setGeneration] = useState(cacheGeneration) @@ -113,8 +131,11 @@ export function useLocalImageSrc( return rawSrc } const absolutePath = resolveImageAbsolutePath(rawSrc, filePath) - if (absolutePath && blobUrlCache.has(absolutePath)) { - return blobUrlCache.get(absolutePath) + if (absolutePath) { + const cacheKey = getLocalImageCacheKey(absolutePath, connectionId, runtimeContext) + if (blobUrlCache.has(cacheKey)) { + return blobUrlCache.get(cacheKey) + } } return undefined }) @@ -136,21 +157,21 @@ export function useLocalImageSrc( return } - if (blobUrlCache.has(absolutePath)) { - setDisplaySrc(blobUrlCache.get(absolutePath)) + const cacheKey = getLocalImageCacheKey(absolutePath, connectionId, runtimeContext) + if (blobUrlCache.has(cacheKey)) { + setDisplaySrc(blobUrlCache.get(cacheKey)) return } let cancelled = false - window.api.fs - .readFile({ filePath: absolutePath, connectionId: connectionId ?? undefined }) + readImagePreview(absolutePath, connectionId, runtimeContext) .then((result) => { if (cancelled) { return } if (result.isBinary && result.content) { const url = base64ToBlobUrl(result.content, result.mimeType ?? 'image/png') - cacheBlobUrl(absolutePath, url) + cacheBlobUrl(cacheKey, url) setDisplaySrc(url) } else { // Why: if the file exists but is not binary (e.g. an SVG stored as @@ -168,7 +189,7 @@ export function useLocalImageSrc( return () => { cancelled = true } - }, [rawSrc, filePath, generation, connectionId]) + }, [rawSrc, filePath, generation, connectionId, runtimeContext]) return displaySrc } @@ -181,7 +202,8 @@ export function useLocalImageSrc( export async function loadLocalImageSrc( rawSrc: string, filePath: string, - connectionId?: string | null + connectionId?: string | null, + runtimeContext?: Omit & { connectionId?: string | null } ): Promise { if ( rawSrc.startsWith('http://') || @@ -197,19 +219,17 @@ export async function loadLocalImageSrc( return null } - const cached = blobUrlCache.get(absolutePath) + const cacheKey = getLocalImageCacheKey(absolutePath, connectionId, runtimeContext) + const cached = blobUrlCache.get(cacheKey) if (cached) { return cached } try { - const result = await window.api.fs.readFile({ - filePath: absolutePath, - connectionId: connectionId ?? undefined - }) + const result = await readImagePreview(absolutePath, connectionId, runtimeContext) if (result.isBinary && result.content) { const url = base64ToBlobUrl(result.content, result.mimeType ?? 'image/png') - cacheBlobUrl(absolutePath, url) + cacheBlobUrl(cacheKey, url) return url } // Why: if the file is not binary (e.g. an SVG stored as text) or content @@ -222,3 +242,23 @@ export async function loadLocalImageSrc( return null } + +function readImagePreview( + absolutePath: string, + connectionId?: string | null, + runtimeContext?: Omit & { connectionId?: string | null } +) { + if (!runtimeContext) { + return window.api.fs.readFile({ + filePath: absolutePath, + connectionId: connectionId ?? undefined + }) + } + return readRuntimeFilePreview( + { + ...runtimeContext, + connectionId: runtimeContext.connectionId ?? connectionId ?? undefined + }, + absolutePath + ) +} diff --git a/src/renderer/src/components/editor/useMarkdownDocuments.ts b/src/renderer/src/components/editor/useMarkdownDocuments.ts index f0fdb092c7d..96a0c3ec682 100644 --- a/src/renderer/src/components/editor/useMarkdownDocuments.ts +++ b/src/renderer/src/components/editor/useMarkdownDocuments.ts @@ -3,6 +3,8 @@ import type { MarkdownDocument } from '../../../../shared/types' import { useAppStore } from '@/store' import { findWorktreeById } from '@/store/slices/worktree-helpers' import { getConnectionId } from '@/lib/connection-context' +import { listRuntimeMarkdownDocuments, statRuntimePath } from '@/runtime/runtime-file-client' +import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor' import { createMarkdownDocumentIndex, resolveMarkdownDocLink } from './markdown-doc-links' @@ -48,10 +50,18 @@ export function useMarkdownDocuments( const requestId = requestRef.current + 1 requestRef.current = requestId try { - const documents = await window.api.fs.listMarkdownDocuments({ - rootPath: worktreePath, - connectionId: connectionId ?? undefined - }) + const documents = await listRuntimeMarkdownDocuments( + { + settings: settingsForRuntimeOwner( + useAppStore.getState().settings, + activeFile.runtimeEnvironmentId + ), + worktreeId, + worktreePath, + connectionId: connectionId ?? undefined + }, + worktreePath + ) if (requestRef.current !== requestId) { return } @@ -68,18 +78,26 @@ export function useMarkdownDocuments( })) } } - }, [connectionId, worktreeId, worktreePath]) + }, [activeFile.runtimeEnvironmentId, connectionId, worktreeId, worktreePath]) const openMarkdownDocument = useCallback( async (document: MarkdownDocument): Promise => { - if (!worktreeId) { + if (!worktreeId || !worktreePath) { return } try { - const stats = await window.api.fs.stat({ - filePath: document.filePath, - connectionId: connectionId ?? undefined - }) + const stats = await statRuntimePath( + { + settings: settingsForRuntimeOwner( + useAppStore.getState().settings, + activeFile.runtimeEnvironmentId + ), + worktreeId, + worktreePath, + connectionId: connectionId ?? undefined + }, + document.filePath + ) if (stats.isDirectory) { await refreshMarkdownDocuments() return @@ -94,10 +112,18 @@ export function useMarkdownDocuments( relativePath: document.relativePath, worktreeId, language: 'markdown', + runtimeEnvironmentId: activeFile.runtimeEnvironmentId, mode: 'edit' }) }, - [connectionId, openFile, refreshMarkdownDocuments, worktreeId] + [ + activeFile.runtimeEnvironmentId, + connectionId, + openFile, + refreshMarkdownDocuments, + worktreeId, + worktreePath + ] ) useEffect(() => { diff --git a/src/renderer/src/components/editor/useMarkdownPreviewShortcut.ts b/src/renderer/src/components/editor/useMarkdownPreviewShortcut.ts new file mode 100644 index 00000000000..cc60f0e4089 --- /dev/null +++ b/src/renderer/src/components/editor/useMarkdownPreviewShortcut.ts @@ -0,0 +1,80 @@ +import { useEffect, type RefObject } from 'react' +import { detectLanguage } from '@/lib/language-detect' +import type { OpenFile } from '@/store/slices/editor' +import { canOpenMarkdownPreview, isMarkdownPreviewShortcut } from './markdown-preview-controls' + +type UseMarkdownPreviewShortcutParams = { + activeFile: OpenFile | null + panelRef: RefObject + isMac: boolean + openMarkdownPreview: (file: { + filePath: string + relativePath: string + worktreeId: string + runtimeEnvironmentId?: string + language: string + }) => void +} + +export function useMarkdownPreviewShortcut({ + activeFile, + panelRef, + isMac, + openMarkdownPreview +}: UseMarkdownPreviewShortcutParams): void { + const activeFilePath = activeFile?.filePath ?? null + const activeFileRelativePath = activeFile?.relativePath ?? null + const activeFileWorktreeId = activeFile?.worktreeId ?? null + const activeFileMode = activeFile?.mode ?? null + const activeFileDiffSource = activeFile?.diffSource + const activeFileRuntimeEnvironmentId = activeFile?.runtimeEnvironmentId + + useEffect(() => { + if (!activeFilePath || !activeFileRelativePath || !activeFileWorktreeId || !activeFileMode) { + return + } + const shortcutLanguage = + activeFileMode === 'diff' + ? detectLanguage(activeFileRelativePath) + : detectLanguage(activeFilePath) + const canShowMarkdownPreview = canOpenMarkdownPreview({ + language: shortcutLanguage, + mode: activeFileMode, + diffSource: activeFileDiffSource + }) + if (!canShowMarkdownPreview) { + return + } + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.defaultPrevented || !isMarkdownPreviewShortcut(event, isMac)) { + return + } + const root = panelRef.current + const target = event.target + if (!root || !(target instanceof Node) || !root.contains(target)) { + return + } + event.preventDefault() + event.stopPropagation() + openMarkdownPreview({ + filePath: activeFilePath, + relativePath: activeFileRelativePath, + worktreeId: activeFileWorktreeId, + runtimeEnvironmentId: activeFileRuntimeEnvironmentId, + language: shortcutLanguage + }) + } + window.addEventListener('keydown', handleKeyDown, { capture: true }) + return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }) + }, [ + activeFileDiffSource, + activeFileMode, + activeFilePath, + activeFileRelativePath, + activeFileRuntimeEnvironmentId, + activeFileWorktreeId, + isMac, + openMarkdownPreview, + panelRef + ]) +} diff --git a/src/renderer/src/components/editor/useUntitledFileRename.ts b/src/renderer/src/components/editor/useUntitledFileRename.ts new file mode 100644 index 00000000000..a301ce6811a --- /dev/null +++ b/src/renderer/src/components/editor/useUntitledFileRename.ts @@ -0,0 +1,135 @@ +import { useCallback, useState } from 'react' +import { getConnectionId } from '@/lib/connection-context' +import { detectLanguage } from '@/lib/language-detect' +import { dirname, joinPath } from '@/lib/path' +import { useAppStore } from '@/store' +import type { OpenFile } from '@/store/slices/editor' +import { + createRuntimePath, + renameRuntimePath, + runtimePathExists +} from '@/runtime/runtime-file-client' +import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { requestEditorFileSave, requestEditorSaveQuiesce } from './editor-autosave' + +type UseUntitledFileRenameParams = { + openFiles: OpenFile[] + closeFile: (filePath: string) => void + openFile: (file: { + filePath: string + relativePath: string + worktreeId: string + runtimeEnvironmentId?: string + language: string + mode: 'edit' + }) => void + clearUntitled: (fileId: string) => void +} + +type UseUntitledFileRenameResult = { + renameDialogFileId: string | null + renameDialogFile: OpenFile | null + renameError: string | null + requestRenameForFile: (fileId: string) => void + closeRenameDialog: () => void + handleRenameConfirm: (newRelPath: string) => Promise +} + +export function useUntitledFileRename({ + openFiles, + closeFile, + openFile, + clearUntitled +}: UseUntitledFileRenameParams): UseUntitledFileRenameResult { + const [renameDialogFileId, setRenameDialogFileId] = useState(null) + const [renameError, setRenameError] = useState(null) + const renameDialogFile = renameDialogFileId + ? (openFiles.find((f) => f.id === renameDialogFileId) ?? null) + : null + + const closeRenameDialog = useCallback((): void => { + setRenameDialogFileId(null) + setRenameError(null) + }, []) + + const handleRenameConfirm = useCallback( + async (newRelPath: string) => { + if (!renameDialogFile) { + return + } + const oldPath = renameDialogFile.filePath + // Why: derive the worktree root from the old relative path so nested + // untitled saves resolve relative to the worktree, not the current folder. + const worktreeRoot = oldPath.slice( + 0, + oldPath.length - renameDialogFile.relativePath.length - 1 + ) + const newPath = joinPath(worktreeRoot, newRelPath) + const connectionId = getConnectionId(renameDialogFile.worktreeId) ?? undefined + const fileContext = { + settings: settingsForRuntimeOwner( + useAppStore.getState().settings, + renameDialogFile.runtimeEnvironmentId + ), + worktreeId: renameDialogFile.worktreeId, + worktreePath: worktreeRoot, + connectionId + } + + if (newPath !== oldPath && (await runtimePathExists(fileContext, newPath))) { + setRenameError('A file with that name already exists') + return + } + + await requestEditorSaveQuiesce({ fileId: renameDialogFile.id }) + const draft = useAppStore.getState().editorDrafts[renameDialogFile.id] + if (draft !== undefined) { + try { + await requestEditorFileSave({ fileId: renameDialogFile.id, fallbackContent: draft }) + } catch { + setRenameError('Failed to save file') + return + } + } + + if (newPath === oldPath) { + clearUntitled(renameDialogFile.id) + closeRenameDialog() + return + } + + const newDir = dirname(newPath) + if (newDir !== worktreeRoot && !(await runtimePathExists(fileContext, newDir))) { + await createRuntimePath(fileContext, newDir, 'directory') + } + + try { + await renameRuntimePath(fileContext, oldPath, newPath) + } catch (err) { + setRenameError(err instanceof Error ? err.message : 'Failed to rename file') + return + } + + closeFile(oldPath) + openFile({ + filePath: newPath, + relativePath: newRelPath, + worktreeId: renameDialogFile.worktreeId, + runtimeEnvironmentId: renameDialogFile.runtimeEnvironmentId, + language: detectLanguage(newRelPath), + mode: 'edit' + }) + closeRenameDialog() + }, + [clearUntitled, closeFile, closeRenameDialog, openFile, renameDialogFile] + ) + + return { + renameDialogFileId, + renameDialogFile, + renameError, + requestRenameForFile: setRenameDialogFileId, + closeRenameDialog, + handleRenameConfirm + } +} diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index b16d06ffaf6..be9bfa9e2e2 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -1,10 +1,7 @@ -/* eslint-disable max-lines -- Why: the floating surface owns both terminal chrome and local notes tabs until the shared floating shell is extracted. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Maximize2, Minimize2, Minus } from 'lucide-react' import TabBar from '@/components/tab-bar/TabBar' import TerminalPane from '@/components/terminal-pane/TerminalPane' import { Button } from '@/components/ui/button' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { ORCHESTRATION_SETUP_DISMISSED_STORAGE_KEY, @@ -13,14 +10,12 @@ import { isOrchestrationSetupDismissed, notifyOrchestrationSetupStateChanged } from '@/lib/orchestration-setup-state' -import { notifyProjectNotesSelectionChanged } from '@/lib/open-project-notes-tab' -import { requestProjectNotesTabClose } from '@/lib/project-notes-close-request' import { useAppStore } from '@/store' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import type { TerminalTab } from '../../../../shared/types' import { FloatingTerminalOrchestrationDialog } from './FloatingTerminalOrchestrationDialog' -import ProjectNotesTabContent from '@/components/notes/ProjectNotesTabContent' import { FloatingTerminalResizeHandles } from './FloatingTerminalResizeHandles' +import { FloatingTerminalWindowControls } from './FloatingTerminalWindowControls' export { FloatingTerminalToggleButton } from './FloatingTerminalToggleButton' import { clampFloatingTerminalBounds, @@ -29,13 +24,19 @@ import { type FloatingTerminalPanelBounds } from './floating-terminal-panel-bounds' const EMPTY_TERMINAL_TABS: TerminalTab[] = [] -type FloatingNotesTab = { id: string; label: string; noteId: string | null; isDirty: boolean } type FloatingTerminalPanelProps = { open: boolean onOpenChange: (open: boolean) => void } +const FLOATING_TERMINAL_NO_DRAG_SELECTOR = + 'button,input,textarea,select,[role="menuitem"],[data-testid="sortable-tab"],[data-floating-terminal-no-drag]' + +function isFloatingTerminalDragTarget(target: EventTarget): boolean { + return !(target instanceof HTMLElement && target.closest(FLOATING_TERMINAL_NO_DRAG_SELECTOR)) +} + export function FloatingTerminalPanel({ open, onOpenChange @@ -52,7 +53,6 @@ export function FloatingTerminalPanel({ const setTabPaneExpanded = useAppStore((s) => s.setTabPaneExpanded) const tabBarOrder = useAppStore((s) => s.tabBarOrderByWorktree[FLOATING_TERMINAL_WORKTREE_ID]) const floatingTerminalCwd = useAppStore((s) => s.settings?.floatingTerminalCwd ?? '~') - const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const [cwd, setCwd] = useState(null) const [bounds, setBounds] = useState(() => getDefaultFloatingTerminalBounds()) @@ -61,9 +61,6 @@ export function FloatingTerminalPanel({ const [showOrchestrationSetup, setShowOrchestrationSetup] = useState( () => !hasOrchestrationSetupMarker() && !isOrchestrationSetupDismissed() ) - const [notesTabs, setNotesTabs] = useState([]) - const [activeNotesTabId, setActiveNotesTabId] = useState(null) - const [activeSurface, setActiveSurface] = useState<'terminal' | 'notes'>('terminal') const restoreBoundsRef = useRef(null) const normalizedInitialBoundsRef = useRef(false) const panelRef = useRef(null) @@ -154,7 +151,6 @@ export function FloatingTerminalPanel({ const createFloatingTab = useCallback(() => { const tab = createTab(FLOATING_TERMINAL_WORKTREE_ID, undefined, undefined, { activate: false }) setActiveTabForWorktree(FLOATING_TERMINAL_WORKTREE_ID, tab.id) - setActiveSurface('terminal') const state = useAppStore.getState() const currentTabs = state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [] const stored = state.tabBarOrderByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [] @@ -173,7 +169,6 @@ export function FloatingTerminalPanel({ const closeFloatingTab = useCallback( (tabId: string) => { closeTab(tabId) - setActiveSurface('terminal') }, [closeTab] ) @@ -202,72 +197,6 @@ export function FloatingTerminalPanel({ [closeTab, tabs] ) - const openFloatingNotesTab = useCallback( - async (noteId?: string) => { - if (!activeWorktreeId) { - return - } - const state = useAppStore.getState() - const worktree = Object.values(state.worktreesByRepo) - .flat() - .find((candidate) => candidate.id === activeWorktreeId) - const projectId = - state.repos.find((candidate) => candidate.id === worktree?.repoId)?.id ?? worktree?.repoId - let label = 'Project Notes' - if (noteId && projectId) { - try { - const result = await window.api.notes.show({ - projectId, - worktreeId: activeWorktreeId, - note: noteId - }) - label = result.note.title - } catch { - label = 'Project Notes' - } - if (projectId) { - await window.api.notes.link({ - projectId, - worktreeId: activeWorktreeId, - note: noteId, - kind: 'active' - }) - notifyProjectNotesSelectionChanged() - } - } - const id = `floating-project-notes:${globalThis.crypto.randomUUID()}` - setNotesTabs((current) => [...current, { id, label, noteId: noteId ?? null, isDirty: false }]) - setActiveNotesTabId(id) - setActiveSurface('notes') - }, - [activeWorktreeId] - ) - - const closeFloatingNotesTab = useCallback((tabId: string) => { - requestProjectNotesTabClose(tabId, () => { - setNotesTabs((current) => current.filter((tab) => tab.id !== tabId)) - setActiveNotesTabId((current) => (current === tabId ? null : current)) - setActiveSurface('terminal') - }) - }, []) - - useEffect(() => { - if (!open || activeSurface !== 'notes' || !activeNotesTabId) { - return - } - const isMac = navigator.userAgent.includes('Mac') - const onKeyDown = (event: KeyboardEvent): void => { - const mod = isMac ? event.metaKey : event.ctrlKey - if (!mod || event.shiftKey || event.repeat || event.key.toLowerCase() !== 'w') { - return - } - event.preventDefault() - closeFloatingNotesTab(activeNotesTabId) - } - window.addEventListener('keydown', onKeyDown, { capture: true }) - return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) - }, [activeNotesTabId, activeSurface, closeFloatingNotesTab, open]) - const toggleMaximized = useCallback(() => { setMaximized((current) => { if (current) { @@ -289,12 +218,7 @@ export function FloatingTerminalPanel({ return } const target = event.target - if ( - target instanceof HTMLElement && - target.closest( - 'button,input,textarea,select,[role="menuitem"],[data-testid="sortable-tab"],[data-floating-terminal-no-drag]' - ) - ) { + if (!isFloatingTerminalDragTarget(target)) { return } dragRef.current = { @@ -327,6 +251,14 @@ export function FloatingTerminalPanel({ } } + const handleTitlebarDoubleClick = (event: React.MouseEvent): void => { + if (event.button !== 0 || !isFloatingTerminalDragTarget(event.target)) { + return + } + event.preventDefault() + toggleMaximized() + } + const dismissOrchestrationSetup = useCallback(() => { localStorage.setItem(ORCHESTRATION_SETUP_DISMISSED_STORAGE_KEY, '1') setShowOrchestrationSetup(false) @@ -358,11 +290,12 @@ export function FloatingTerminalPanel({ >

{ - setActiveSurface('terminal') - setActiveTabForWorktree(FLOATING_TERMINAL_WORKTREE_ID, tabId) - }} + onActivate={(tabId) => setActiveTabForWorktree(FLOATING_TERMINAL_WORKTREE_ID, tabId)} onClose={closeFloatingTab} onCloseOthers={closeOthers} onCloseToRight={closeToRight} onNewTerminalTab={createFloatingTab} onNewBrowserTab={() => {}} - onNewNotesTab={activeWorktreeId ? openFloatingNotesTab : undefined} - notesWorktreeId={activeWorktreeId} terminalOnly onSetCustomTitle={setTabCustomTitle} onSetTabColor={setTabColor} onTogglePaneExpand={(tabId) => setTabPaneExpanded(tabId, expandedPaneByTabId[tabId] !== true) } - notesTabs={activeWorktreeId ? notesTabs : []} - activeNotesTabId={activeSurface === 'notes' ? activeNotesTabId : null} - onActivateNotesTab={(tabId) => { - setActiveNotesTabId(tabId) - setActiveSurface('notes') - }} - onCloseNotesTab={closeFloatingNotesTab} - activeTabType={activeSurface} + activeTabType="terminal" tabBarOrder={tabBarOrder} />
-
- - - - - - {maximized ? 'Restore' : 'Maximize'} - - - - - - - - Minimize - - -
+ onOpenChange(false)} + />
- {activeSurface === 'notes' && activeWorktreeId && activeNotesTabId ? ( - tab.id === activeNotesTabId)?.noteId ?? null} - forceNew={notesTabs.find((tab) => tab.id === activeNotesTabId)?.noteId === null} - onDirtyChange={(dirty) => { - setNotesTabs((current) => - current.map((tab) => - tab.id === activeNotesTabId ? { ...tab, isDirty: dirty } : tab - ) - ) - }} - /> - ) : cwd ? ( - tabs.map((tab) => ( -
- closeTab(tab.id)} - onCloseTab={() => closeTab(tab.id)} - /> -
- )) - ) : null} + {cwd + ? tabs.map((tab) => ( +
+ closeTab(tab.id)} + onCloseTab={() => closeTab(tab.id)} + /> +
+ )) + : null}
{showOrchestrationSetup ? ( diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.tsx index 314f00efb6e..986f1612669 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.tsx @@ -23,7 +23,7 @@ export function FloatingTerminalToggleButton({ type="button" variant="outline" size="icon-sm" - className="bg-card/95 shadow-xs" + className="border-border bg-secondary text-secondary-foreground shadow-xs hover:bg-accent hover:text-accent-foreground" data-floating-terminal-toggle aria-label={open ? 'Minimize floating terminal' : 'Show floating terminal'} aria-pressed={open} diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx new file mode 100644 index 00000000000..f09949cb510 --- /dev/null +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx @@ -0,0 +1,144 @@ +import { useCallback, useMemo } from 'react' +import { Maximize2, Minimize2, Minus } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog' +import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' +import { CLIENT_PLATFORM } from '@/lib/new-workspace' +import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' +import { tuiAgentToAgentKind } from '@/lib/telemetry' +import { useAppStore } from '@/store' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' + +type FloatingTerminalWindowControlsProps = { + maximized: boolean + onToggleMaximized: () => void + onMinimize: () => void +} + +const controlButtonClassName = + 'border-border bg-secondary text-secondary-foreground shadow-xs hover:bg-accent hover:text-accent-foreground' + +export function FloatingTerminalWindowControls({ + maximized, + onToggleMaximized, + onMinimize +}: FloatingTerminalWindowControlsProps): React.JSX.Element { + const defaultTuiAgent = useAppStore((s) => s.settings?.defaultTuiAgent ?? null) + const createTab = useAppStore((s) => s.createTab) + const setActiveTabForWorktree = useAppStore((s) => s.setActiveTabForWorktree) + + const defaultAgent = defaultTuiAgent && defaultTuiAgent !== 'blank' ? defaultTuiAgent : null + const defaultAgentLabel = useMemo( + () => + defaultAgent + ? (AGENT_CATALOG.find((agent) => agent.id === defaultAgent)?.label ?? defaultAgent) + : null, + [defaultAgent] + ) + + const launchDefaultAgent = useCallback(() => { + if (!defaultAgent) { + return + } + const state = useAppStore.getState() + const startupPlan = buildAgentStartupPlan({ + agent: defaultAgent, + prompt: '', + cmdOverrides: state.settings?.agentCmdOverrides ?? {}, + platform: CLIENT_PLATFORM, + allowEmptyPromptLaunch: true + }) + if (!startupPlan) { + toast.error(`Could not build launch command for ${defaultAgentLabel ?? defaultAgent}.`) + return + } + const tab = createTab(FLOATING_TERMINAL_WORKTREE_ID, undefined, undefined, { activate: false }) + state.queueTabStartupCommand(tab.id, { + command: startupPlan.launchCommand, + ...(startupPlan.env ? { env: startupPlan.env } : {}), + telemetry: { + agent_kind: tuiAgentToAgentKind(defaultAgent), + launch_source: 'shortcut', + request_kind: 'new' + } + }) + setActiveTabForWorktree(FLOATING_TERMINAL_WORKTREE_ID, tab.id) + const fresh = useAppStore.getState() + const currentTabs = fresh.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [] + const stored = fresh.tabBarOrderByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [] + const validIds = new Set(currentTabs.map((entry) => entry.id)) + const order = stored.filter((id) => validIds.has(id) && id !== tab.id) + for (const entry of currentTabs) { + if (entry.id !== tab.id && !order.includes(entry.id)) { + order.push(entry.id) + } + } + order.push(tab.id) + fresh.setTabBarOrder(FLOATING_TERMINAL_WORKTREE_ID, order) + focusTerminalTabSurface(tab.id) + }, [createTab, defaultAgent, defaultAgentLabel, setActiveTabForWorktree]) + + return ( +
+ + + + + + {defaultAgentLabel ? `Open ${defaultAgentLabel}` : 'Choose a default agent first'} + + + + + + + + {maximized ? 'Restore' : 'Maximize'} + + + + + + + + Minimize + + +
+ ) +} diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-panel-bounds.test.ts b/src/renderer/src/components/floating-terminal/floating-terminal-panel-bounds.test.ts new file mode 100644 index 00000000000..8cce47e3f26 --- /dev/null +++ b/src/renderer/src/components/floating-terminal/floating-terminal-panel-bounds.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + TITLEBAR_SAFE_TOP, + clampFloatingTerminalBounds, + getDefaultFloatingTerminalBounds, + getMaximizedFloatingTerminalBounds +} from './floating-terminal-panel-bounds' + +function stubViewport(width: number, height: number, userAgent: string): void { + vi.stubGlobal('window', { innerWidth: width, innerHeight: height }) + vi.stubGlobal('navigator', { userAgent }) +} + +describe('floating terminal panel bounds', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('lets dragged panels touch the macOS titlebar without going above it', () => { + stubViewport(1200, 800, 'Macintosh') + + const bounds = clampFloatingTerminalBounds({ + left: 32, + top: 8, + width: 640, + height: 360 + }) + + expect(bounds.top).toBe(TITLEBAR_SAFE_TOP) + }) + + it('keeps dragged panels below the renderer titlebar on other platforms', () => { + stubViewport(1200, 800, 'Windows NT') + + const bounds = clampFloatingTerminalBounds({ + left: 32, + top: 8, + width: 640, + height: 360 + }) + + expect(bounds.top).toBe(TITLEBAR_SAFE_TOP) + }) + + it('maximizes below the renderer titlebar on non-mac platforms', () => { + stubViewport(1200, 800, 'Windows NT') + + expect(getMaximizedFloatingTerminalBounds()).toEqual( + expect.objectContaining({ + top: TITLEBAR_SAFE_TOP, + height: 800 - TITLEBAR_SAFE_TOP - 36 + }) + ) + }) + + it('defaults at or below the titlebar on compact macOS windows', () => { + stubViewport(760, 420, 'Macintosh') + + expect(getDefaultFloatingTerminalBounds().top).toBeGreaterThanOrEqual(TITLEBAR_SAFE_TOP) + }) +}) diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-panel-bounds.ts b/src/renderer/src/components/floating-terminal/floating-terminal-panel-bounds.ts index c9892cba06f..f083abe8fee 100644 --- a/src/renderer/src/components/floating-terminal/floating-terminal-panel-bounds.ts +++ b/src/renderer/src/components/floating-terminal/floating-terminal-panel-bounds.ts @@ -4,7 +4,7 @@ export const MIN_PANEL_WIDTH = 420 export const MIN_PANEL_HEIGHT = 280 export const MAXIMIZED_MARGIN = 12 export const MAXIMIZED_BOTTOM_GAP = 36 -export const MAC_TITLEBAR_SAFE_TOP = 44 +export const TITLEBAR_SAFE_TOP = 36 const DEFAULT_RIGHT_GAP = 24 const DEFAULT_BOTTOM_GAP = 84 @@ -18,11 +18,14 @@ export type FloatingTerminalPanelBounds = { export function getDefaultFloatingTerminalBounds(): FloatingTerminalPanelBounds { const viewportWidth = typeof window === 'undefined' ? 1200 : window.innerWidth const viewportHeight = typeof window === 'undefined' ? 800 : window.innerHeight + // Why: the floating panel may touch the renderer titlebar, but must not + // overlap it or the native window controls above it. + const safeTop = TITLEBAR_SAFE_TOP const width = Math.min(DEFAULT_PANEL_WIDTH, Math.max(MIN_PANEL_WIDTH, viewportWidth - 48)) const height = Math.min(DEFAULT_PANEL_HEIGHT, Math.max(MIN_PANEL_HEIGHT, viewportHeight - 96)) return { left: Math.max(16, viewportWidth - width - DEFAULT_RIGHT_GAP), - top: Math.max(40, viewportHeight - height - DEFAULT_BOTTOM_GAP), + top: Math.max(safeTop, viewportHeight - height - DEFAULT_BOTTOM_GAP), width, height } @@ -35,20 +38,18 @@ export function clampFloatingTerminalBounds( typeof window === 'undefined' ? bounds.left + bounds.width : window.innerWidth const viewportHeight = typeof window === 'undefined' ? bounds.top + bounds.height : window.innerHeight + const safeTop = TITLEBAR_SAFE_TOP return { ...bounds, left: Math.min(Math.max(8, bounds.left), Math.max(8, viewportWidth - 80)), - top: Math.min(Math.max(8, bounds.top), Math.max(8, viewportHeight - 80)) + top: Math.min(Math.max(safeTop, bounds.top), Math.max(safeTop, viewportHeight - 80)) } } export function getMaximizedFloatingTerminalBounds(): FloatingTerminalPanelBounds { const viewportWidth = typeof window === 'undefined' ? 1200 : window.innerWidth const viewportHeight = typeof window === 'undefined' ? 800 : window.innerHeight - const top = - typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') - ? MAC_TITLEBAR_SAFE_TOP - : MAXIMIZED_MARGIN + const top = TITLEBAR_SAFE_TOP return { left: MAXIMIZED_MARGIN, top, diff --git a/src/renderer/src/components/github-project/ProjectCell.tsx b/src/renderer/src/components/github-project/ProjectCell.tsx index 4182e068d92..59ccf7ea2d2 100644 --- a/src/renderer/src/components/github-project/ProjectCell.tsx +++ b/src/renderer/src/components/github-project/ProjectCell.tsx @@ -9,14 +9,17 @@ import { TYPE_FIELD_DATA_TYPE } from './columns' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Input } from '@/components/ui/input' import { cn } from '@/lib/utils' -import type { GitHubAssignableUser } from '../../../../shared/types' +import { useRepoAssigneesBySlug, useRepoLabelsBySlug } from '@/hooks/useGitHubSlugMetadata' +import { useAppStore } from '@/store' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import type { GitHubIssueType, GitHubProjectField, GitHubProjectFieldMutationValue, GitHubProjectLabel, GitHubProjectRow, - GitHubProjectUser + GitHubProjectUser, + ListIssueTypesBySlugResult } from '../../../../shared/github-project-types' type Props = { @@ -264,6 +267,7 @@ function IssueTypeCell({ const [open, setOpen] = useState(false) const [options, setOptions] = useState([]) const [loading, setLoading] = useState(false) + const settings = useAppStore((s) => s.settings) const [owner, repo] = (row.content.repository ?? '').split('/') React.useEffect(() => { @@ -272,8 +276,17 @@ function IssueTypeCell({ } let cancelled = false setLoading(true) - window.api.gh - .listIssueTypesBySlug({ owner, repo }) + const target = getActiveRuntimeTarget(settings) + const request = + target.kind === 'environment' + ? callRuntimeRpc( + target, + 'github.project.listIssueTypesBySlug', + { owner, repo }, + { timeoutMs: 30_000 } + ) + : window.api.gh.listIssueTypesBySlug({ owner, repo }) + request .then((res) => { if (cancelled) { return @@ -290,7 +303,7 @@ function IssueTypeCell({ return () => { cancelled = true } - }, [open, owner, repo]) + }, [open, owner, repo, settings]) const trigger = ( @@ -713,8 +726,7 @@ function AssigneesCell({ }): React.JSX.Element { const assignees = row.content.assignees const [open, setOpen] = useState(false) - const [options, setOptions] = useState([]) - const [loading, setLoading] = useState(false) + const settings = useAppStore((s) => s.settings) const [owner, repo] = (row.content.repository ?? '').split('/') @@ -732,37 +744,12 @@ function AssigneesCell({ [assignees] ) - // Why: only hit the slug-addressed user list when the popover actually - // opens — the assignable-users query can be expensive for large repos. - React.useEffect(() => { - if (!open || !owner || !repo) { - return - } - let cancelled = false - setLoading(true) - window.api.gh - .listAssignableUsersBySlug({ - owner, - repo, - seedLogins: seedKey ? seedKey.split(',') : [] - }) - .then((res) => { - if (cancelled) { - return - } - if (res.ok) { - setOptions(res.users) - } - }) - .finally(() => { - if (!cancelled) { - setLoading(false) - } - }) - return () => { - cancelled = true - } - }, [open, owner, repo, seedKey]) + const metadata = useRepoAssigneesBySlug( + open ? owner : null, + open ? repo : null, + seedKey ? seedKey.split(',') : [], + settings + ) const labelContent = assignees.length === 0 ? null : assignees.map((u) => ) @@ -791,10 +778,10 @@ function AssigneesCell({ {!owner || !repo ? (
Row has no repo slug.
- ) : loading ? ( + ) : metadata.loading ? (
Loading…
) : ( - options.map((u) => { + metadata.data.map((u) => { const isOn = assignees.some((a) => a.login === u.login) return ( - {loading ? ( + {metadata.loading ? (
Loading…
) : ( - users.map((u) => { + metadata.data.map((u) => { const isOn = selected.includes(u.login) return ( - {loading ? ( + {metadata.loading ? (
Loading…
) : ( - options.map((name) => { + metadata.data.map((name) => { const isOn = selected.includes(name) return ( - - -
GitHub API budget
-
- {BUCKETS.map((b) => { - const v = snapshot[b.key] - const t = toneFor(v.remaining, v.limit) - return ( -
- {b.description} - - {v.remaining} of {v.limit} left · resets in {formatReset(v.resetAt)} - -
- ) - })} -
-
Click to refresh
-
- - ) + return } diff --git a/src/renderer/src/components/github/github-rate-limit-display.tsx b/src/renderer/src/components/github/github-rate-limit-display.tsx new file mode 100644 index 00000000000..52fa0d66e6b --- /dev/null +++ b/src/renderer/src/components/github/github-rate-limit-display.tsx @@ -0,0 +1,267 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { Gauge, RefreshCw } from 'lucide-react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { useAppStore } from '@/store' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import type { GetRateLimitResult, GitHubRateLimitSnapshot } from '../../../../shared/types' + +const REFRESH_INTERVAL_MS = 60_000 + +type BucketKey = 'core' | 'search' | 'graphql' + +type BucketMeta = { + key: BucketKey + label: string + description: string +} + +const BUCKETS: BucketMeta[] = [ + { key: 'core', label: 'REST', description: 'REST API' }, + { key: 'search', label: 'Search', description: 'Search API' }, + { key: 'graphql', label: 'GraphQL', description: 'GraphQL API' } +] + +export function formatGitHubRateLimitReset(resetAt: number): string { + const deltaSec = Math.max(0, resetAt - Math.floor(Date.now() / 1000)) + if (deltaSec < 60) { + return `${deltaSec}s` + } + const mins = Math.round(deltaSec / 60) + return `${mins}m` +} + +export function toneForGitHubBucket(remaining: number, limit: number): 'ok' | 'warn' | 'crit' { + if (limit <= 0) { + return 'ok' + } + const pct = remaining / limit + if (pct < 0.1) { + return 'crit' + } + if (pct < 0.25) { + return 'warn' + } + return 'ok' +} + +function worstGitHubRateLimitTone(snapshot: GitHubRateLimitSnapshot): 'ok' | 'warn' | 'crit' { + const tones = BUCKETS.map((b) => + toneForGitHubBucket(snapshot[b.key].remaining, snapshot[b.key].limit) + ) + if (tones.includes('crit')) { + return 'crit' + } + if (tones.includes('warn')) { + return 'warn' + } + return 'ok' +} + +function tightestGitHubBucket(snapshot: GitHubRateLimitSnapshot): BucketMeta { + let worst = BUCKETS[0] + let worstPct = 1 + for (const b of BUCKETS) { + const { remaining, limit } = snapshot[b.key] + const pct = limit > 0 ? remaining / limit : 1 + if (pct < worstPct) { + worstPct = pct + worst = b + } + } + return worst +} + +export function useGitHubRateLimitSnapshot(options?: { autoRefresh?: boolean }): { + snapshot: GitHubRateLimitSnapshot | null + hasError: boolean + isFetching: boolean + refresh: (force?: boolean) => Promise +} { + const [snapshot, setSnapshot] = useState(null) + const [hasError, setHasError] = useState(false) + const [isFetching, setIsFetching] = useState(false) + const settings = useAppStore((s) => s.settings) + const latestToken = useRef(0) + const autoRefresh = options?.autoRefresh ?? true + + const refresh = useCallback( + async (force = false): Promise => { + const token = ++latestToken.current + setIsFetching(true) + try { + const target = getActiveRuntimeTarget(settings) + const params = force ? { force: true } : undefined + const res = + target.kind === 'environment' + ? await callRuntimeRpc(target, 'github.rateLimit', params ?? {}, { + timeoutMs: 30_000 + }) + : ((await window.api.gh.rateLimit(params)) as GetRateLimitResult | undefined) + if (token !== latestToken.current) { + return + } + if (res?.ok) { + setSnapshot(res.snapshot) + setHasError(false) + } else { + setHasError(true) + } + } catch { + if (token === latestToken.current) { + setHasError(true) + } + } finally { + if (token === latestToken.current) { + setIsFetching(false) + } + } + }, + [settings] + ) + + useEffect(() => { + if (!autoRefresh) { + return + } + const fetchIfVisible = (): void => { + if (document.visibilityState === 'visible' && document.hasFocus()) { + void refresh(false) + } + } + void refresh(false) + const handle = window.setInterval(fetchIfVisible, REFRESH_INTERVAL_MS) + window.addEventListener('focus', fetchIfVisible) + document.addEventListener('visibilitychange', fetchIfVisible) + return () => { + window.clearInterval(handle) + window.removeEventListener('focus', fetchIfVisible) + document.removeEventListener('visibilitychange', fetchIfVisible) + } + }, [autoRefresh, refresh]) + + return { snapshot, hasError, isFetching, refresh } +} + +function GitHubRateLimitRows({ + snapshot +}: { + snapshot: GitHubRateLimitSnapshot +}): React.JSX.Element { + return ( +
+ {BUCKETS.map((b) => { + const v = snapshot[b.key] + const tone = toneForGitHubBucket(v.remaining, v.limit) + return ( +
+ {b.description} + + {v.remaining} of {v.limit} left · resets in {formatGitHubRateLimitReset(v.resetAt)} + +
+ ) + })} +
+ ) +} + +export function GitHubRateLimitCompact({ + className, + hideHealthy = true, + label = 'GitHub API budget', + tooltipSide = 'top' +}: { + className?: string + hideHealthy?: boolean + label?: string + tooltipSide?: 'top' | 'right' | 'bottom' | 'left' +}): React.JSX.Element | null { + const { snapshot, hasError, refresh } = useGitHubRateLimitSnapshot() + if (!snapshot || hasError) { + return null + } + + const tone = worstGitHubRateLimitTone(snapshot) + if (hideHealthy && tone === 'ok') { + return null + } + const tight = tightestGitHubBucket(snapshot) + const tightBucket = snapshot[tight.key] + + return ( + + + + + +
{label}
+
+ +
+
Click to refresh
+
+
+ ) +} + +export function GitHubRateLimitPanel({ className }: { className?: string }): React.JSX.Element { + const { snapshot, hasError, isFetching, refresh } = useGitHubRateLimitSnapshot() + + return ( +
+
+
+
+ + GitHub API Budget +
+

+ Orca uses REST, Search, and GraphQL through the GitHub CLI. +

+
+ +
+ {hasError ? ( +
GitHub API budget is unavailable.
+ ) : snapshot ? ( + + ) : ( +
Loading GitHub API budget…
+ )} +
+ ) +} diff --git a/src/renderer/src/components/linear-issue-workspace-text.ts b/src/renderer/src/components/linear-issue-workspace-text.ts new file mode 100644 index 00000000000..fdf6842f23e --- /dev/null +++ b/src/renderer/src/components/linear-issue-workspace-text.ts @@ -0,0 +1,46 @@ +import { getLinkedWorkItemSuggestedName } from '@/lib/new-workspace' +import type { LinearIssue } from '../../../shared/types' + +const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) + +export function formatLinearIssueRelativeTime(input: string): string { + const date = new Date(input) + if (Number.isNaN(date.getTime())) { + return 'recently' + } + const diffMs = date.getTime() - Date.now() + const diffMinutes = Math.round(diffMs / 60_000) + if (Math.abs(diffMinutes) < 60) { + return relativeTimeFormatter.format(diffMinutes, 'minute') + } + const diffHours = Math.round(diffMinutes / 60) + if (Math.abs(diffHours) < 24) { + return relativeTimeFormatter.format(diffHours, 'hour') + } + const diffDays = Math.round(diffHours / 24) + return relativeTimeFormatter.format(diffDays, 'day') +} + +export function buildLinearIssueBranchName(issue: LinearIssue): string { + const titleSlug = getLinkedWorkItemSuggestedName(issue) + const key = issue.identifier.toLowerCase() + return titleSlug ? `${key}-${titleSlug}` : key +} + +export function buildLinearIssuePrompt(issue: LinearIssue): string { + const lines = [ + `Linear issue: ${issue.identifier} ${issue.title}`, + `URL: ${issue.url}`, + `State: ${issue.state.name}`, + `Assignee: ${issue.assignee?.displayName ?? 'Unassigned'}`, + `Team: ${issue.team.name}` + ] + if (issue.workspaceName) { + lines.push(`Workspace: ${issue.workspaceName}`) + } + const description = issue.description?.trim() + if (description) { + lines.push('', 'Description:', description) + } + return lines.join('\n') +} diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx index c6625cfbfb4..8ea4d0fa4c8 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx @@ -159,7 +159,6 @@ export default function SmartWorkspaceNameField({ searchLinearIssues: s.searchLinearIssues })) ) - const selectedRepo = useMemo( () => repos.find((repo) => repo.id === repoId) ?? null, [repoId, repos] @@ -227,7 +226,7 @@ export default function SmartWorkspaceNameField({ const shouldQueryLinear = mode === 'smart' || mode === 'linear' useEffect(() => { - if (!shouldQueryGithub || !selectedRepo?.path || selectedRepo.connectionId) { + if (!shouldQueryGithub || !selectedRepo?.path) { setGithubItems([]) setGithubLoading(false) return @@ -246,6 +245,7 @@ export default function SmartWorkspaceNameField({ handledCrossRepoUrlRef.current = debouncedQuery.trim() const item = await window.api.gh.workItemByOwnerRepo({ repoPath: selectedRepo.path, + repoId: selectedRepo.id, owner: directLink.slug.owner, repo: directLink.slug.repo, number: directLink.number, @@ -282,12 +282,17 @@ export default function SmartWorkspaceNameField({ directLink !== null ? window.api.gh.workItemByOwnerRepo({ repoPath: selectedRepo.path, + repoId: selectedRepo.id, owner: directLink.slug.owner, repo: directLink.slug.repo, number: directLink.number, type: directLink.type }) - : window.api.gh.workItem({ repoPath: selectedRepo.path, number: directNumber }) + : window.api.gh.workItem({ + repoPath: selectedRepo.path, + repoId: selectedRepo.id, + number: directNumber + }) void request .then((item) => { if (!stale) { @@ -311,7 +316,7 @@ export default function SmartWorkspaceNameField({ const trimmed = normalizedGhQuery.query.trim() const query = trimmed ? normalizedGhQuery.query : '' - const cached = getCachedWorkItems(selectedRepo.path, RESULT_LIMIT, query) + const cached = getCachedWorkItems(selectedRepo.id, RESULT_LIMIT, query) if (cached) { setGithubItems(cached.slice(0, RESULT_LIMIT)) setGithubLoading(false) @@ -707,6 +712,7 @@ export default function SmartWorkspaceNameField({ try { const item = await window.api.gh.workItemByOwnerRepo({ repoPath: targetRepo.path, + repoId: targetRepo.id, owner: crossRepoPrompt.link.slug.owner, repo: crossRepoPrompt.link.slug.repo, number: crossRepoPrompt.link.number, @@ -1169,19 +1175,16 @@ async function getRepoSlugCached( repo: RepoOption, cache: Map ): Promise { - if (cache.has(repo.id)) { - return cache.get(repo.id) ?? null - } - if (repo.connectionId) { - cache.set(repo.id, null) - return null + const cacheKey = repo.id + if (cache.has(cacheKey)) { + return cache.get(cacheKey) ?? null } try { - const slug = await window.api.gh.repoSlug({ repoPath: repo.path }) - cache.set(repo.id, slug) + const slug = await window.api.gh.repoSlug({ repoPath: repo.path, repoId: repo.id }) + cache.set(cacheKey, slug) return slug } catch { - cache.set(repo.id, null) + cache.set(cacheKey, null) return null } } diff --git a/src/renderer/src/components/notes/ProjectNotesTabContent.tsx b/src/renderer/src/components/notes/ProjectNotesTabContent.tsx deleted file mode 100644 index 4f511405a88..00000000000 --- a/src/renderer/src/components/notes/ProjectNotesTabContent.tsx +++ /dev/null @@ -1,681 +0,0 @@ -/* eslint-disable max-lines -- Why: project notes keep picker, save-as, and - rich/source/preview markdown modes together so unsaved note creation and - active-note switching cannot drift across separate surfaces. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Copy, FileText, MoreHorizontal, Plus, Save } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import RichMarkdownEditor from '@/components/editor/RichMarkdownEditor' -import MarkdownPreview from '@/components/editor/MarkdownPreview' -import EditorViewToggle from '@/components/editor/EditorViewToggle' -import { useAppStore } from '@/store' -import { useRepoById, useWorktreeById } from '@/store/selectors' -import { NOTES_ACTIVE_CHANGED_EVENT } from '@/lib/notes-events' -import { - getProjectNotesEntityId, - notifyProjectNotesSelectionChanged -} from '@/lib/open-project-notes-tab' -import { - ORCA_PROJECT_NOTES_REQUEST_CLOSE_EVENT, - type ProjectNotesCloseRequestDetail -} from '@/lib/project-notes-close-request' -import { toast } from 'sonner' -import type { MarkdownDocument } from '../../../../shared/types' -import type { NoteRecord, NoteSummary, NotesPanelOpenState } from '../../../../shared/notes-types' -import type { MarkdownViewMode } from '@/store/slices/editor' - -const SAVE_DEBOUNCE_MS = 700 - -type Draft = { - id: string | null - filePath: string | null - relativePath: string | null - title: string - bodyMarkdown: string - revision: number | null -} - -function emptyDraft(): Draft { - return { - id: null, - filePath: null, - relativePath: null, - title: 'Untitled note', - bodyMarkdown: '', - revision: null - } -} - -export default function ProjectNotesTabContent({ - worktreeId, - tabId, - noteId = null, - forceNew = false, - onDirtyChange -}: { - worktreeId: string - tabId: string - noteId?: string | null - forceNew?: boolean - onDirtyChange?: (dirty: boolean) => void -}): React.JSX.Element { - const worktree = useWorktreeById(worktreeId) - const repo = useRepoById(worktree?.repoId ?? null) - const projectId = repo?.id ?? worktree?.repoId ?? null - - const [panelState, setPanelState] = useState({ state: 'noProject' }) - const [notes, setNotes] = useState([]) - const [draft, setDraft] = useState(() => emptyDraft()) - const [dirty, setDirty] = useState(false) - const [saving, setSaving] = useState(false) - const [error, setError] = useState(null) - const [viewMode, setViewMode] = useState('rich') - const [saveAsOpen, setSaveAsOpen] = useState(false) - const [closePromptOpen, setClosePromptOpen] = useState(false) - const [saveAsTitle, setSaveAsTitle] = useState('Untitled note') - const [selectedNoteId, setSelectedNoteId] = useState(noteId) - const saveAsInputRef = useRef(null) - const pendingCreateBodyRef = useRef(null) - const pendingCloseRef = useRef<(() => void) | null>(null) - - const canSave = projectId !== null && (draft.id === null || draft.title.trim().length > 0) - - const refreshNotes = useCallback(async (): Promise => { - if (!projectId) { - setNotes([]) - return - } - const result = await window.api.notes.list({ projectId, worktreeId, limit: 100 }) - setNotes(result.notes) - }, [projectId, worktreeId]) - - const loadPanelState = useCallback(async (): Promise => { - try { - setError(null) - if (selectedNoteId && projectId) { - const result = await window.api.notes.show({ projectId, worktreeId, note: selectedNoteId }) - setPanelState({ state: 'active', projectId, worktreeId, note: result.note }) - setDraft({ - id: result.note.id, - filePath: result.note.filePath, - relativePath: result.note.relativePath, - title: result.note.title, - bodyMarkdown: result.note.bodyMarkdown, - revision: result.note.revision - }) - setDirty(false) - const state = useAppStore.getState() - state.setTabLabel(tabId, result.note.title) - state.setTabEntityId(tabId, getProjectNotesEntityId(projectId, result.note.id)) - await refreshNotes() - return - } - if (forceNew && projectId) { - setPanelState({ state: 'emptyDraft', projectId, worktreeId }) - setDraft(emptyDraft()) - setDirty(false) - useAppStore.getState().setTabLabel(tabId, 'Project Notes') - await refreshNotes() - return - } - const next = await window.api.notes.panelState({ projectId, worktreeId }) - setPanelState(next) - if (next.state === 'active') { - setDraft({ - id: next.note.id, - filePath: next.note.filePath, - relativePath: next.note.relativePath, - title: next.note.title, - bodyMarkdown: next.note.bodyMarkdown, - revision: next.note.revision - }) - const state = useAppStore.getState() - state.setTabLabel(tabId, next.note.title) - state.setTabEntityId(tabId, getProjectNotesEntityId(next.projectId, next.note.id)) - setDirty(false) - } else { - setDraft(emptyDraft()) - useAppStore - .getState() - .setTabEntityId(tabId, getProjectNotesEntityId(projectId ?? worktreeId)) - setDirty(false) - } - if (next.state === 'pickerRequired') { - setNotes(next.notes) - } else { - await refreshNotes() - } - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - } - }, [forceNew, projectId, refreshNotes, selectedNoteId, tabId, worktreeId]) - - useEffect(() => { - void loadPanelState() - }, [loadPanelState]) - - useEffect(() => { - setSelectedNoteId(noteId) - }, [noteId]) - - useEffect(() => { - useAppStore.getState().setTabDirty(tabId, dirty) - onDirtyChange?.(dirty) - }, [dirty, onDirtyChange, tabId]) - - useEffect(() => { - const listener = (): void => { - if (dirty) { - return - } - void loadPanelState() - } - window.addEventListener(NOTES_ACTIVE_CHANGED_EVENT, listener) - return () => window.removeEventListener(NOTES_ACTIVE_CHANGED_EVENT, listener) - }, [dirty, loadPanelState]) - - useEffect(() => { - const listener = (event: Event): void => { - const detail = (event as CustomEvent).detail - if (!detail || detail.tabId !== tabId) { - return - } - detail.claim() - if (!dirty) { - detail.close() - return - } - pendingCloseRef.current = detail.close - setClosePromptOpen(true) - } - window.addEventListener(ORCA_PROJECT_NOTES_REQUEST_CLOSE_EVENT, listener) - return () => window.removeEventListener(ORCA_PROJECT_NOTES_REQUEST_CLOSE_EVENT, listener) - }, [dirty, tabId]) - - const selectNote = useCallback( - async (noteId: string): Promise => { - if (!projectId) { - return - } - const result = await window.api.notes.show({ projectId, worktreeId, note: noteId }) - await window.api.notes.link({ projectId, worktreeId, note: noteId, kind: 'active' }) - setSelectedNoteId(noteId) - setDraft({ - id: result.note.id, - filePath: result.note.filePath, - relativePath: result.note.relativePath, - title: result.note.title, - bodyMarkdown: result.note.bodyMarkdown, - revision: result.note.revision - }) - setPanelState({ state: 'active', projectId, worktreeId, note: result.note }) - const state = useAppStore.getState() - state.setTabLabel(tabId, result.note.title) - state.setTabEntityId(tabId, getProjectNotesEntityId(projectId, result.note.id)) - setDirty(false) - await refreshNotes() - }, - [projectId, refreshNotes, tabId, worktreeId] - ) - - const saveDraft = useCallback( - async (bodyOverride?: string): Promise => { - if (!canSave || !projectId) { - return null - } - const bodyMarkdown = bodyOverride ?? draft.bodyMarkdown - if (!draft.id) { - // Why: project notes are markdown files. Match untitled Markdown tabs - // by asking for the user-facing name before creating the file instead - // of silently persisting "Untitled note" from autosave. - pendingCreateBodyRef.current = bodyMarkdown - setSaveAsTitle(draft.title.trim() || 'Untitled note') - setSaveAsOpen(true) - return null - } - setSaving(true) - try { - const result = await window.api.notes.save({ - projectId, - worktreeId, - note: draft.id, - title: draft.title, - bodyMarkdown, - revision: draft.revision ?? undefined, - makeActive: true - }) - setDraft({ - id: result.note.id, - filePath: result.note.filePath, - relativePath: result.note.relativePath, - title: result.note.title, - bodyMarkdown: result.note.bodyMarkdown, - revision: result.note.revision - }) - setPanelState({ state: 'active', projectId, worktreeId, note: result.note }) - setSelectedNoteId(result.note.id) - const state = useAppStore.getState() - state.setTabLabel(tabId, result.note.title) - state.setTabEntityId(tabId, getProjectNotesEntityId(projectId, result.note.id)) - setDirty(false) - await refreshNotes() - notifyProjectNotesSelectionChanged() - return result.note - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - return null - } finally { - setSaving(false) - } - }, - [canSave, draft, projectId, refreshNotes, tabId, worktreeId] - ) - - useEffect(() => { - if (!dirty || !canSave || !draft.id) { - return - } - const timer = window.setTimeout(() => { - void saveDraft() - }, SAVE_DEBOUNCE_MS) - return () => window.clearTimeout(timer) - }, [canSave, dirty, draft.id, saveDraft]) - - const createNewDraft = useCallback(() => { - setSelectedNoteId(null) - setDraft(emptyDraft()) - const state = useAppStore.getState() - state.setTabLabel(tabId, 'Project Notes') - state.setTabEntityId(tabId, getProjectNotesEntityId(projectId ?? worktreeId)) - setPanelState( - projectId ? { state: 'emptyDraft', projectId, worktreeId } : { state: 'noProject' } - ) - setDirty(false) - }, [projectId, tabId, worktreeId]) - - const markdownDocuments = useMemo( - () => - notes.map((note) => ({ - filePath: note.filePath, - relativePath: note.relativePath, - basename: note.relativePath.split('/').pop() ?? note.title, - name: note.title - })), - [notes] - ) - - const editorFilePath = - draft.filePath ?? `orca://project-notes/${projectId ?? 'project'}/untitled.md` - const editorPathLabel = draft.filePath - ? (draft.relativePath ?? draft.title) - : `notes/${draft.title.trim() || 'untitled'}.md` - - useEffect(() => { - if (error) { - toast.error(error) - } - }, [error]) - - useEffect(() => { - if (!saveAsOpen) { - return - } - requestAnimationFrame(() => saveAsInputRef.current?.select()) - }, [saveAsOpen]) - - const confirmCreateNote = useCallback(async (): Promise => { - if (!projectId) { - return - } - const title = saveAsTitle.trim().replace(/\.md$/i, '') - if (!title) { - setError('Name cannot be empty') - return - } - setSaving(true) - try { - const result = await window.api.notes.create({ - projectId, - worktreeId, - title, - bodyMarkdown: pendingCreateBodyRef.current ?? draft.bodyMarkdown, - makeActive: true - }) - setDraft({ - id: result.note.id, - filePath: result.note.filePath, - relativePath: result.note.relativePath, - title: result.note.title, - bodyMarkdown: result.note.bodyMarkdown, - revision: result.note.revision - }) - setPanelState({ state: 'active', projectId, worktreeId, note: result.note }) - setSelectedNoteId(result.note.id) - const state = useAppStore.getState() - state.setTabLabel(tabId, result.note.title) - state.setTabEntityId(tabId, getProjectNotesEntityId(projectId, result.note.id)) - setDirty(false) - setSaveAsOpen(false) - pendingCreateBodyRef.current = null - await refreshNotes() - notifyProjectNotesSelectionChanged() - const pendingClose = pendingCloseRef.current - if (pendingClose) { - pendingCloseRef.current = null - pendingClose() - } - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - } finally { - setSaving(false) - } - }, [draft.bodyMarkdown, projectId, refreshNotes, saveAsTitle, tabId, worktreeId]) - - const handleClosePromptSave = useCallback(async (): Promise => { - setClosePromptOpen(false) - const saved = await saveDraft() - if (!saved) { - if (draft.id) { - setClosePromptOpen(true) - } - return - } - const pendingClose = pendingCloseRef.current - pendingCloseRef.current = null - pendingClose?.() - }, [draft.id, saveDraft]) - - const handleClosePromptDiscard = useCallback(() => { - setClosePromptOpen(false) - setDirty(false) - const pendingClose = pendingCloseRef.current - pendingCloseRef.current = null - pendingClose?.() - }, []) - - const handleClosePromptCancel = useCallback(() => { - setClosePromptOpen(false) - pendingCloseRef.current = null - }, []) - - const handleSaveAsCancel = useCallback(() => { - setSaveAsOpen(false) - pendingCreateBodyRef.current = null - pendingCloseRef.current = null - }, []) - - return ( -
-
-
-
- -
-
- setViewMode(next as MarkdownViewMode)} - /> - - - - - - Current note -
- { - setDraft((current) => ({ ...current, title: event.target.value })) - setDirty(true) - }} - className="h-8 text-xs" - placeholder="Note title" - /> -
- - - New note - - { - void window.api.ui.writeClipboardText(draft.bodyMarkdown) - }} - > - - Copy Markdown - - void saveDraft()}> - - Save now - - {notes.length > 0 ? ( - <> - - Switch note - {notes.map((note) => ( - void selectNote(note.id)}> - - {note.title} - - ))} - - ) : null} -
-
-
- -
- {panelState.state === 'noProject' ? ( -
- Open a project worktree to use notes. -
- ) : panelState.state === 'pickerRequired' ? ( -
-
-
-
Project Notes
-
- Choose a saved note or start a new one. -
-
- -
-
- {notes.map((note) => ( - - ))} -
-
- ) : viewMode === 'source' ? ( -