mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 00:04:01 +00:00
Merge remote-tracking branch 'origin/main' into tmp/native-chat-recovery-main-integration
This commit is contained in:
@@ -17,23 +17,59 @@ differ on other Debian-derived releases.
|
||||
|
||||
## Ubuntu and Debian prerequisites
|
||||
|
||||
Install the AppImage runtime dependency and Xvfb:
|
||||
Install the CLI tools, Xvfb, and the shared libraries Electron links against.
|
||||
A minimal server or container image ships none of the Electron libraries, and
|
||||
`orca serve` then fails before Electron starts:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y curl file jq xvfb zlib1g-dev
|
||||
sudo apt-get install -y \
|
||||
curl file jq xvfb zlib1g-dev ca-certificates git \
|
||||
libgtk-3-0t64 libnss3 libatk1.0-0t64 libatk-bridge2.0-0t64 libgbm1 libasound2t64 \
|
||||
libxtst6 libcups2t64 libdrm2 libxkbcommon0 libpango-1.0-0 libcairo2 libatspi2.0-0t64 \
|
||||
libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libxrender1 libx11-xcb1 \
|
||||
libxcb-dri3-0 libxss1
|
||||
```
|
||||
|
||||
On Ubuntu 22.04, install `libfuse2` to execute the AppImage through FUSE. On
|
||||
Ubuntu 24.04 and Debian, the equivalent package may be `libfuse2t64`. FUSE is
|
||||
That command is for Ubuntu 24.04 and newer and Debian 13 and newer. Those
|
||||
releases carried out the 64-bit `time_t` transition, which renamed six of the
|
||||
packages with a `t64` suffix. On Ubuntu 20.04, Ubuntu 22.04, and Debian 12,
|
||||
substitute the unsuffixed names:
|
||||
|
||||
- `libgtk-3-0t64` becomes `libgtk-3-0`
|
||||
- `libatk1.0-0t64` becomes `libatk1.0-0`
|
||||
- `libatk-bridge2.0-0t64` becomes `libatk-bridge2.0-0`
|
||||
- `libasound2t64` becomes `libasound2`
|
||||
- `libcups2t64` becomes `libcups2`
|
||||
- `libatspi2.0-0t64` becomes `libatspi2.0-0`
|
||||
|
||||
The other names are identical on every supported release. The substitution is not
|
||||
symmetric, so use the list that matches the release. A `t64` name on Ubuntu 20.04,
|
||||
Ubuntu 22.04, or Debian 12 fails immediately with `E: Unable to locate package
|
||||
libgtk-3-0t64`. In the other direction the old names mostly still resolve, because
|
||||
each renamed package declares `Provides:` its unsuffixed name — except `libasound2`
|
||||
on Ubuntu 24.04, where `liboss4-salsa-asound2` in `universe` claims that name too.
|
||||
apt will not choose between two providers and exits with `E: Package 'libasound2'
|
||||
has no installation candidate`, which aborts the entire install line and leaves none
|
||||
of the libraries installed.
|
||||
|
||||
On Ubuntu 20.04 and 22.04, install `libfuse2` to execute the AppImage through
|
||||
FUSE. On Ubuntu 24.04 and Debian 13 the package is `libfuse2t64`, though the plain
|
||||
`libfuse2` name also resolves there because nothing else provides it. FUSE is
|
||||
optional: without it, use the AppImage's supported extraction path:
|
||||
|
||||
```bash
|
||||
cd /opt/orca
|
||||
./orca-linux.AppImage --appimage-extract
|
||||
sudo chmod -R a+rX /opt/orca/squashfs-root
|
||||
/opt/orca/squashfs-root/AppRun serve --port 6768
|
||||
```
|
||||
|
||||
The `chmod` is required whenever the extraction runs as a different user than
|
||||
the server: `--appimage-extract` creates `squashfs-root` as `drwx------` owned by
|
||||
the extracting user, so anyone else — including a dedicated service user — cannot
|
||||
even traverse it, and the run fails before Electron starts.
|
||||
|
||||
Docker commonly has no FUSE device. Use `--appimage-extract` once or
|
||||
`--appimage-extract-and-run`; neither requires a privileged container. The
|
||||
extract-and-run wrapper can print extracted paths before Orca starts, so
|
||||
@@ -144,8 +180,15 @@ AppImage, but must not be able to replace it or the rollback artifacts.
|
||||
sudo useradd --system --create-home --shell /usr/sbin/nologin orca
|
||||
sudo chown root:root /opt/orca /opt/orca/orca-linux.AppImage
|
||||
sudo chmod 755 /opt/orca /opt/orca/orca-linux.AppImage
|
||||
# Only if you ran --appimage-extract: extraction leaves squashfs-root root-only.
|
||||
sudo chmod -R a+rX /opt/orca/squashfs-root
|
||||
```
|
||||
|
||||
The last line matters because the two halves of this guide combine badly without
|
||||
it. `--appimage-extract` writes `squashfs-root` as `drwx------ root root`, so the
|
||||
`orca` service user cannot read or traverse the extracted tree and the unit fails
|
||||
at startup. `chmod 755 /opt/orca` alone does not reach into it.
|
||||
|
||||
For most hosts, one `orca serve` service is enough because Orca starts Xvfb on
|
||||
display `:99` when no display exists:
|
||||
|
||||
@@ -834,7 +877,8 @@ refuse to run there and print the command to run on the machine you want.
|
||||
- GPU or DRI warnings on a VPS: keep `LIBGL_ALWAYS_SOFTWARE=1` in the service
|
||||
environment.
|
||||
- Chromium sandbox errors: confirm the service is running as the non-root
|
||||
`orca` user and that `/opt/orca` is readable by that user.
|
||||
`orca` user and that `/opt/orca` is readable by that user, including
|
||||
`/opt/orca/squashfs-root` if you extracted the AppImage.
|
||||
- Clients cannot connect: make sure `--pairing-address` is an address reachable
|
||||
from the client, and make sure firewalls allow the selected `--port`.
|
||||
- Journal shows `Another Orca instance is already running for this userData
|
||||
@@ -857,4 +901,7 @@ profile` and the unit exits `3`: another process already owns the profile, so
|
||||
`sudo systemctl reset-failed orca-serve.service` first.
|
||||
- Diagnosing other missing libraries: extract the AppImage without launching it
|
||||
with `./orca-linux.AppImage --appimage-extract`, then run
|
||||
`ldd squashfs-root/orca` to list any shared libraries the host is missing.
|
||||
`ldd squashfs-root/orca-ide` to list any shared libraries the host is missing.
|
||||
The Electron binary is `orca-ide`, not `orca`; `ldd` on a path that does not
|
||||
exist prints nothing and exits cleanly, which reads as a clean result in
|
||||
exactly the situation where you are hunting a missing library.
|
||||
|
||||
@@ -135,6 +135,13 @@ shape is §7a; key points:
|
||||
- Use the VM image's package manager (`apt`/`dnf`/`apk`, per the base distro — not the provider brand).
|
||||
- Clone with the git token via `GIT_ASKPASS` (§5).
|
||||
- **Trap errors and remove the half-built sandbox** so a crash doesn't leave a paid resource running.
|
||||
- **Never snapshot a machine on which the Orca runtime has already run.** The first `orca serve` creates
|
||||
the runtime's user-data dir, and everything in it gets baked into the image and shared by every VM
|
||||
booted from it: the pairing keypair and device-token registry (`orca-devices.json`,
|
||||
`orca-e2ee-keypair.json`), `agent-session-authority.key`, and the build box's logs, terminal history
|
||||
and orchestration db. Confirmed: two VMs from one such snapshot emitted **identical `deviceToken` and
|
||||
`pairedDeviceId`**. Snapshot **before** the runtime has ever run, or `rm -rf` the whole user-data dir
|
||||
(`~/.config/orca` on Linux) first — deleting a named file list will drift as Orca adds state.
|
||||
- Snapshot the stopped sandbox, parse the snapshot id, and write it + scope/project/port/repo to state.
|
||||
|
||||
---
|
||||
@@ -163,6 +170,10 @@ their own terminal, or via the Claude Code harness bang-prefix (`! <cmd>`, with
|
||||
`!`). You scaffold/boot the sandbox and run steps 3–4, but **you cannot observe the interactive login
|
||||
finishing** — so **ask the user to tell you when it's done** before you verify and re-snapshot.
|
||||
|
||||
This layer inherits §3's rule: if you started `orca serve` on the base or auth sandbox to smoke-test it,
|
||||
delete the runtime's user-data dir (`~/.config/orca` on Linux) before re-snapshotting, or every workspace
|
||||
booted from this image shares one pairing identity and one `agent-session-authority.key`.
|
||||
|
||||
If the agent's credentials are short-lived, warn that the snapshot may need periodic re-auth (§10).
|
||||
|
||||
For disposable runtimes, do **not** treat a host agent config directory (for example `~/.codex`) as the
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,9 +2,14 @@ function __orca_shell_ready_marker --on-event fish_prompt
|
||||
builtin printf "\033]777;orca-shell-ready\007"
|
||||
functions -e __orca_shell_ready_marker
|
||||
end
|
||||
if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file
|
||||
# Why captured: an unquoted (type -t codex) expands to zero words when codex is
|
||||
# absent, leaving "test = file" — fish then errors instead of failing closed.
|
||||
# Quoting in place is not the fix; fish never substitutes inside double quotes.
|
||||
set -l __orca_codex_type (type -t codex 2>/dev/null)
|
||||
if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test "$__orca_codex_type" = file
|
||||
function codex
|
||||
command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true
|
||||
command codex $argv
|
||||
end
|
||||
end
|
||||
end
|
||||
set -e __orca_codex_type
|
||||
@@ -2,9 +2,14 @@ function __orca_shell_ready_marker --on-event fish_prompt
|
||||
builtin printf "\033]777;orca-shell-ready\007"
|
||||
functions -e __orca_shell_ready_marker
|
||||
end
|
||||
if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file
|
||||
# Why captured: an unquoted (type -t codex) expands to zero words when codex is
|
||||
# absent, leaving "test = file" — fish then errors instead of failing closed.
|
||||
# Quoting in place is not the fix; fish never substitutes inside double quotes.
|
||||
set -l __orca_codex_type (type -t codex 2>/dev/null)
|
||||
if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test "$__orca_codex_type" = file
|
||||
function codex
|
||||
command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true
|
||||
command codex $argv
|
||||
end
|
||||
end
|
||||
end
|
||||
set -e __orca_codex_type
|
||||
@@ -48,14 +48,18 @@ export function normalizeGitLabIssueListArgs(args: {
|
||||
state?: unknown
|
||||
assignee?: unknown
|
||||
limit?: unknown
|
||||
page?: unknown
|
||||
}): {
|
||||
state: GitLabIssueListState
|
||||
assignee: '@me' | undefined
|
||||
limit: number
|
||||
page: number
|
||||
} {
|
||||
return {
|
||||
state: normalizeGitLabIssueListState(args.state),
|
||||
assignee: normalizeGitLabIssueAssignee(args.assignee),
|
||||
limit: normalizeGitLabPositiveInteger(args.limit, 20, 100)
|
||||
limit: normalizeGitLabPositiveInteger(args.limit, 20, 100),
|
||||
// Why: GitLab is 1-based; TaskPage maps 0-based UI pages onto this (#13357).
|
||||
page: normalizeGitLabPositiveInteger(args.page, 1, 10_000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
GITLAB_ADMISSION_TIMEOUT_MS,
|
||||
getIssueProjectRef,
|
||||
parseGlabJsonList,
|
||||
parseGlabPaginationHeader,
|
||||
isMissingJobLogError,
|
||||
getGlabKnownHosts,
|
||||
getProjectRef,
|
||||
@@ -907,3 +908,24 @@ describe('getGlabKnownHosts', () => {
|
||||
unregisterSshGitProvider(connectionId)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseGlabPaginationHeader', () => {
|
||||
it('reads a usable header value', () => {
|
||||
expect(parseGlabPaginationHeader('25', 1)).toBe(25)
|
||||
expect(parseGlabPaginationHeader(' 9 ', 1)).toBe(9)
|
||||
})
|
||||
|
||||
it('returns undefined for an absent or unparseable header', () => {
|
||||
expect(parseGlabPaginationHeader(undefined, 0)).toBeUndefined()
|
||||
expect(parseGlabPaginationHeader('', 0)).toBeUndefined()
|
||||
expect(parseGlabPaginationHeader('abc', 0)).toBeUndefined()
|
||||
})
|
||||
|
||||
// Why: the minimum is what lets issues.ts tell "x-total: 0" (derive one page) apart from an
|
||||
// absent header (probe for a next page), and what makes x-total-pages: 0 fall through.
|
||||
it('rejects values below the minimum', () => {
|
||||
expect(parseGlabPaginationHeader('0', 1)).toBeUndefined()
|
||||
expect(parseGlabPaginationHeader('0', 0)).toBe(0)
|
||||
expect(parseGlabPaginationHeader('-3', 0)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,7 +29,12 @@ export type {
|
||||
ProjectRef,
|
||||
ResolvedIssueSource
|
||||
} from './gitlab-project-ref-resolution'
|
||||
export { parseGlabApiResponse, parseGlabJsonList, type GlabApiResponse } from './glab-api-response'
|
||||
export {
|
||||
parseGlabApiResponse,
|
||||
parseGlabJsonList,
|
||||
parseGlabPaginationHeader,
|
||||
type GlabApiResponse
|
||||
} from './glab-api-response'
|
||||
|
||||
const MAX_CONCURRENT = 4
|
||||
export const GITLAB_ADMISSION_TIMEOUT_MS = 30_000
|
||||
@@ -86,7 +91,7 @@ export function release(): void {
|
||||
|
||||
export async function glabApiWithHeaders(
|
||||
args: string[],
|
||||
options?: { cwd?: string }
|
||||
options?: Parameters<typeof glabExecFileAsync>[1]
|
||||
): Promise<GlabApiResponse> {
|
||||
const { stdout } = await glabExecFileAsync(['api', '-i', ...args], options)
|
||||
return parseGlabApiResponse(stdout)
|
||||
|
||||
@@ -25,6 +25,18 @@ export function parseGlabApiResponse(stdout: string): GlabApiResponse {
|
||||
return { body, headers }
|
||||
}
|
||||
|
||||
/** A GitLab pagination header, or undefined when absent, unparseable, or below `minimum`. */
|
||||
export function parseGlabPaginationHeader(
|
||||
value: string | undefined,
|
||||
minimum: number
|
||||
): number | undefined {
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
return Number.isFinite(parsed) && parsed >= minimum ? parsed : undefined
|
||||
}
|
||||
|
||||
/** A non-list body carrying no GitLab error text — opaque data, so there is nothing to classify. */
|
||||
export class GlabNonListResponseError extends Error {}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type * as GlUtils from './gl-utils'
|
||||
|
||||
const {
|
||||
glabExecFileAsyncMock,
|
||||
glabApiWithHeadersMock,
|
||||
getIssueProjectRefMock,
|
||||
resolveIssueSourceMock,
|
||||
getGlabKnownHostsMock,
|
||||
@@ -10,6 +11,7 @@ const {
|
||||
releaseMock
|
||||
} = vi.hoisted(() => ({
|
||||
glabExecFileAsyncMock: vi.fn(),
|
||||
glabApiWithHeadersMock: vi.fn(),
|
||||
getIssueProjectRefMock: vi.fn(),
|
||||
resolveIssueSourceMock: vi.fn(),
|
||||
getGlabKnownHostsMock: vi.fn(),
|
||||
@@ -22,6 +24,7 @@ vi.mock('./gl-utils', async () => {
|
||||
return {
|
||||
...actual,
|
||||
glabExecFileAsync: glabExecFileAsyncMock,
|
||||
glabApiWithHeaders: glabApiWithHeadersMock,
|
||||
getIssueProjectRef: getIssueProjectRefMock,
|
||||
resolveIssueSource: resolveIssueSourceMock,
|
||||
getGlabKnownHosts: getGlabKnownHostsMock,
|
||||
@@ -37,6 +40,7 @@ import { listAssignableUsers, listLabels } from './project-label-and-member-look
|
||||
describe('gitlab issue operations', () => {
|
||||
beforeEach(() => {
|
||||
glabExecFileAsyncMock.mockReset()
|
||||
glabApiWithHeadersMock.mockReset()
|
||||
getIssueProjectRefMock.mockReset()
|
||||
resolveIssueSourceMock.mockReset()
|
||||
getGlabKnownHostsMock.mockReset()
|
||||
@@ -86,7 +90,6 @@ describe('gitlab issue operations', () => {
|
||||
labels: []
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({ stdout: '[]' })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
iid: 924,
|
||||
@@ -104,6 +107,7 @@ describe('gitlab issue operations', () => {
|
||||
})
|
||||
.mockResolvedValueOnce({ stdout: 'bug\nfrontend\n' })
|
||||
.mockResolvedValueOnce({ stdout: '{"id":1,"username":"octo","avatar_url":""}\n' })
|
||||
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
|
||||
|
||||
await getIssue('/repo-root', 923, null, localGitOptions)
|
||||
await listIssues('/repo-root', 5, undefined, 'opened', undefined, null, localGitOptions)
|
||||
@@ -137,6 +141,12 @@ describe('gitlab issue operations', () => {
|
||||
expect(glabExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe(
|
||||
true
|
||||
)
|
||||
expect(glabApiWithHeadersMock).toHaveBeenCalledWith(
|
||||
[
|
||||
'projects/stablyai%2Forca/issues?page=1&per_page=5&order_by=updated_at&sort=desc&state=opened'
|
||||
],
|
||||
{ cwd: '/repo-root', ...localGitOptions }
|
||||
)
|
||||
})
|
||||
|
||||
it('encodes nested group paths', async () => {
|
||||
@@ -157,22 +167,65 @@ describe('gitlab issue operations', () => {
|
||||
|
||||
it('lists issues with state=opened ordering', async () => {
|
||||
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
|
||||
glabApiWithHeadersMock.mockResolvedValueOnce({
|
||||
body: '[]',
|
||||
headers: { 'x-total': '123', 'x-total-pages': '25' }
|
||||
})
|
||||
|
||||
await expect(listIssues('/repo-root', 5)).resolves.toEqual({ items: [] })
|
||||
await expect(listIssues('/repo-root', 5)).resolves.toEqual({ items: [], totalPages: 25 })
|
||||
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
expect(glabApiWithHeadersMock).toHaveBeenCalledWith(
|
||||
[
|
||||
'api',
|
||||
'projects/stablyai%2Forca/issues?per_page=5&order_by=updated_at&sort=desc&state=opened'
|
||||
'projects/stablyai%2Forca/issues?page=1&per_page=5&order_by=updated_at&sort=desc&state=opened'
|
||||
],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards an explicit page into the issues API path after localGitOptions', async () => {
|
||||
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
|
||||
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
|
||||
|
||||
await expect(
|
||||
listIssues('/repo-root', 50, undefined, 'opened', undefined, null, {}, 3)
|
||||
).resolves.toMatchObject({ totalPages: 3 })
|
||||
|
||||
expect(glabApiWithHeadersMock).toHaveBeenCalledWith(
|
||||
[
|
||||
'projects/stablyai%2Forca/issues?page=3&per_page=50&order_by=updated_at&sort=desc&state=opened'
|
||||
],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
})
|
||||
|
||||
it('derives total pages from x-total when x-total-pages is unavailable', async () => {
|
||||
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
|
||||
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: { 'x-total': '11' } })
|
||||
|
||||
await expect(listIssues('/repo-root', 5)).resolves.toMatchObject({ totalPages: 3 })
|
||||
})
|
||||
|
||||
it('keeps a next-page probe when a proxy strips pagination headers', async () => {
|
||||
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
|
||||
glabApiWithHeadersMock.mockResolvedValueOnce({
|
||||
body: JSON.stringify(
|
||||
Array.from({ length: 5 }, (_, index) => ({
|
||||
iid: index + 1,
|
||||
title: `Issue ${index + 1}`,
|
||||
state: 'opened',
|
||||
web_url: `https://gitlab.com/stablyai/orca/-/issues/${index + 1}`,
|
||||
labels: []
|
||||
}))
|
||||
),
|
||||
headers: {}
|
||||
})
|
||||
|
||||
await expect(listIssues('/repo-root', 5)).resolves.toMatchObject({ totalPages: 2 })
|
||||
})
|
||||
|
||||
it('surfaces a permission_denied error instead of collapsing to empty', async () => {
|
||||
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
|
||||
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden'))
|
||||
glabApiWithHeadersMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden'))
|
||||
|
||||
const result = await listIssues('/repo-root', 5)
|
||||
|
||||
@@ -182,8 +235,9 @@ describe('gitlab issue operations', () => {
|
||||
|
||||
it('reports the body instead of ".map is not a function" when the API returns a non-array', async () => {
|
||||
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({ data: [], total: 0 })
|
||||
glabApiWithHeadersMock.mockResolvedValueOnce({
|
||||
body: JSON.stringify({ data: [], total: 0 }),
|
||||
headers: {}
|
||||
})
|
||||
|
||||
const result = await listIssues('/repo-root', 5)
|
||||
@@ -196,8 +250,9 @@ describe('gitlab issue operations', () => {
|
||||
|
||||
it('reports a GitLab error envelope by its own message', async () => {
|
||||
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({ message: '403 Forbidden' })
|
||||
glabApiWithHeadersMock.mockResolvedValueOnce({
|
||||
body: JSON.stringify({ message: '403 Forbidden' }),
|
||||
headers: {}
|
||||
})
|
||||
|
||||
const result = await listIssues('/repo-root', 5)
|
||||
@@ -230,7 +285,7 @@ describe('gitlab issue operations', () => {
|
||||
|
||||
it('threads connectionId into getGlabKnownHosts for listIssues', async () => {
|
||||
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
|
||||
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
|
||||
|
||||
await listIssues('/repo-root', 5, undefined, 'opened', undefined, 'conn-7')
|
||||
|
||||
|
||||
@@ -3,13 +3,15 @@ import type { GitLabCommentResult, GitLabIssueInfo, MRComment } from '../../shar
|
||||
import type { IssueSourcePreference } from '../../shared/repo-types'
|
||||
import { mapGitLabIssueInfo } from './mappers'
|
||||
// prettier-ignore
|
||||
import { glabExecFileAsync, acquire, release, getIssueProjectRef, resolveIssueSource, classifyGlabError, classifyListFetchError, getGlabKnownHosts, glabRepoExecOptions, glabHostnameArgs, parseGlabJsonList, type LocalGitExecOptions, type ProjectRef } from './gl-utils'
|
||||
import { glabApiWithHeaders, glabExecFileAsync, acquire, release, getIssueProjectRef, resolveIssueSource, classifyGlabError, classifyListFetchError, getGlabKnownHosts, glabRepoExecOptions, glabHostnameArgs, parseGlabJsonList, parseGlabPaginationHeader, type LocalGitExecOptions, type ProjectRef } from './gl-utils'
|
||||
import { encodedProject } from './project-path-encoding'
|
||||
|
||||
// Why: parallel to GitHub's IssueListResult — distinguishes a successful-
|
||||
// empty listing from a failed fetch.
|
||||
export type IssueListResult = {
|
||||
items: GitLabIssueInfo[]
|
||||
/** 0 when the listing failed — the caller keeps its current pager instead of collapsing it. */
|
||||
totalPages: number
|
||||
error?: ClassifiedError
|
||||
}
|
||||
|
||||
@@ -74,8 +76,11 @@ export async function listIssues(
|
||||
state: IssueListState = 'opened',
|
||||
assignee?: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
localGitOptions: LocalGitExecOptions = {},
|
||||
page = 1
|
||||
): Promise<IssueListResult> {
|
||||
const currentPage = Number.isFinite(page) ? Math.max(1, Math.trunc(page)) : 1
|
||||
const perPage = Number.isFinite(limit) ? Math.max(1, Math.trunc(limit)) : 20
|
||||
const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions)
|
||||
const { source: projectRef } = await resolveIssueSource(
|
||||
repoPath,
|
||||
@@ -94,6 +99,7 @@ export async function listIssues(
|
||||
if (!projectRef) {
|
||||
return {
|
||||
items: [],
|
||||
totalPages: 0,
|
||||
error: {
|
||||
type: 'not_found',
|
||||
message: 'Could not resolve a GitLab project for this repository.'
|
||||
@@ -104,24 +110,33 @@ export async function listIssues(
|
||||
try {
|
||||
const stateParam = state === 'all' ? '' : `&state=${state}`
|
||||
const scopeParam = assignee === '@me' ? '&scope=assigned_to_me' : ''
|
||||
const { stdout } = await glabExecFileAsync(
|
||||
const { body, headers } = await glabApiWithHeaders(
|
||||
[
|
||||
'api',
|
||||
...glabHostnameArgs(projectRef, connectionId),
|
||||
`projects/${encodedProject(projectRef.path)}/issues?per_page=${limit}&order_by=updated_at&sort=desc${stateParam}${scopeParam}`
|
||||
`projects/${encodedProject(projectRef.path)}/issues?page=${currentPage}&per_page=${perPage}&order_by=updated_at&sort=desc${stateParam}${scopeParam}`
|
||||
],
|
||||
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
|
||||
)
|
||||
const data = parseGlabJsonList<Record<string, unknown>>(stdout)
|
||||
const data = parseGlabJsonList<Record<string, unknown>>(body)
|
||||
const headerTotalCount = parseGlabPaginationHeader(headers['x-total'], 0)
|
||||
// Why: a proxy can strip both headers, so a full page advertises one more to probe (#13357);
|
||||
// TaskPage retreats if that probe comes back empty.
|
||||
const probedTotalPages = data.length < perPage ? currentPage : currentPage + 1
|
||||
// Why: GitLab's project issues endpoint returns true issues only
|
||||
// (MRs are a separate endpoint), so no equivalent of GitHub's
|
||||
// pull_request filter is needed here.
|
||||
return {
|
||||
items: data.map((d) => mapGitLabIssueInfo(d as Parameters<typeof mapGitLabIssueInfo>[0]))
|
||||
items: data.map((d) => mapGitLabIssueInfo(d as Parameters<typeof mapGitLabIssueInfo>[0])),
|
||||
totalPages:
|
||||
parseGlabPaginationHeader(headers['x-total-pages'], 1) ??
|
||||
(headerTotalCount === undefined
|
||||
? probedTotalPages
|
||||
: Math.max(1, Math.ceil(headerTotalCount / perPage)))
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
items: [],
|
||||
totalPages: 0,
|
||||
error: classifyListFetchError(err)
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
glabRepoExecOptions,
|
||||
glabExecFileAsync,
|
||||
parseGlabJsonList,
|
||||
parseGlabPaginationHeader,
|
||||
release,
|
||||
resolveIssueSource,
|
||||
type LocalGitExecOptions
|
||||
@@ -130,11 +131,11 @@ export async function listMergeRequests(
|
||||
items: data.map((d) => mapMRToWorkItem(d, repoId, projectRef)),
|
||||
page,
|
||||
perPage,
|
||||
totalCount: parseHeaderInt(headers['x-total'], 0),
|
||||
totalCount: parseGlabPaginationHeader(headers['x-total'], 0) ?? 0,
|
||||
// Why: GitLab may omit x-total-pages for 'all' or large per_page; fall back to ceil(total/perPage).
|
||||
totalPages:
|
||||
parseHeaderInt(headers['x-total-pages'], 0) ||
|
||||
Math.max(1, Math.ceil(parseHeaderInt(headers['x-total'], 0) / perPage))
|
||||
parseGlabPaginationHeader(headers['x-total-pages'], 1) ??
|
||||
Math.max(1, Math.ceil((parseGlabPaginationHeader(headers['x-total'], 0) ?? 0) / perPage))
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
@@ -150,14 +151,6 @@ export async function listMergeRequests(
|
||||
}
|
||||
}
|
||||
|
||||
function parseHeaderInt(value: string | undefined, fallback: number): number {
|
||||
if (!value) {
|
||||
return fallback
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
return Number.isFinite(parsed) ? parsed : fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a work item (MR or issue) by explicit project ref + iid + type.
|
||||
* Used by the paste-URL flow, where the URL determines the project directly.
|
||||
|
||||
@@ -44,10 +44,12 @@ export function registerGitLabIssueHandlers(store: Store): void {
|
||||
state?: 'opened' | 'closed' | 'all'
|
||||
assignee?: string
|
||||
limit?: number
|
||||
page?: number
|
||||
}
|
||||
) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
const limit = normalizeGitLabPositiveInteger(args.limit, 20, 100)
|
||||
const page = normalizeGitLabPositiveInteger(args.page, 1, 10_000)
|
||||
const state = normalizeGitLabIssueListState(args.state)
|
||||
const assignee = normalizeGitLabIssueAssignee(args.assignee)
|
||||
const result = await listIssues(
|
||||
@@ -57,7 +59,8 @@ export function registerGitLabIssueHandlers(store: Store): void {
|
||||
state,
|
||||
assignee,
|
||||
repoConnectionId(repo),
|
||||
...localGitOptionArgs(store, repo)
|
||||
localGitOptionArgs(store, repo)[0] ?? {},
|
||||
page
|
||||
)
|
||||
// Why: Tasks page expects GitLabWorkItem[] so it can share row
|
||||
// rendering with MRs. Map IssueInfo → WorkItem here so the renderer
|
||||
@@ -74,7 +77,11 @@ export function registerGitLabIssueHandlers(store: Store): void {
|
||||
author: issue.author ?? null,
|
||||
repoId: repo.id
|
||||
}))
|
||||
return { items: workItems, ...(result.error ? { error: result.error } : {}) }
|
||||
return {
|
||||
items: workItems,
|
||||
totalPages: result.totalPages,
|
||||
...(result.error ? { error: result.error } : {})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -353,7 +353,7 @@ describe('GitLab IPC handlers', () => {
|
||||
]
|
||||
listMergeRequestsMock.mockResolvedValue({ items: [] })
|
||||
listWorkItemsMock.mockResolvedValue({ items: [] })
|
||||
listIssuesMock.mockResolvedValue({ items: [] })
|
||||
listIssuesMock.mockResolvedValue({ items: [], totalPages: 3 })
|
||||
getIssueMock.mockResolvedValue(null)
|
||||
createIssueMock.mockResolvedValue({ ok: true, number: 1, url: 'https://gitlab.example/1' })
|
||||
updateIssueMock.mockResolvedValue({ ok: true })
|
||||
@@ -385,10 +385,11 @@ describe('GitLab IPC handlers', () => {
|
||||
page: 1,
|
||||
perPage: 20
|
||||
})
|
||||
await ipcHandlers.get('gitlab:listIssues')?.(null, {
|
||||
const issueListResult = await ipcHandlers.get('gitlab:listIssues')?.(null, {
|
||||
repoPath: '/local/orca',
|
||||
state: 'opened',
|
||||
limit: 20
|
||||
limit: 20,
|
||||
page: 3
|
||||
})
|
||||
await ipcHandlers.get('gitlab:issue')?.(null, { repoPath: '/local/orca', number: 7 })
|
||||
await ipcHandlers.get('gitlab:createIssue')?.(null, {
|
||||
@@ -430,6 +431,7 @@ describe('GitLab IPC handlers', () => {
|
||||
null,
|
||||
localGitOptions
|
||||
)
|
||||
expect(issueListResult).toMatchObject({ totalPages: 3 })
|
||||
expect(listWorkItemsMock).toHaveBeenCalledWith(
|
||||
'/local/orca',
|
||||
'opened',
|
||||
@@ -447,7 +449,8 @@ describe('GitLab IPC handlers', () => {
|
||||
'opened',
|
||||
undefined,
|
||||
null,
|
||||
localGitOptions
|
||||
localGitOptions,
|
||||
3
|
||||
)
|
||||
expect(getIssueMock).toHaveBeenCalledWith('/local/orca', 7, null, localGitOptions)
|
||||
expect(createIssueMock).toHaveBeenCalledWith(
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -17,13 +19,69 @@ import {
|
||||
getPowerShellCodexShellLaunchPreflight,
|
||||
resolveCodexShellLaunchPreflightCommand
|
||||
} from './codex-shell-launch-preflight'
|
||||
import { fishRequirementViolation, resolveFishBinary } from '../../shared/fish-binary-requirement'
|
||||
|
||||
const roots: string[] = []
|
||||
const zshAvailable = existsSync('/bin/zsh')
|
||||
const bashAvailable = existsSync('/bin/bash')
|
||||
const fishAvailable = spawnSync('fish', ['--version']).status === 0
|
||||
// Why the shared lookup: it also finds a Homebrew fish that is off PATH, and it
|
||||
// carries the ORCA_REQUIRE_FISH contract asserted below.
|
||||
const fishLookup = resolveFishBinary()
|
||||
const fishAvailable = fishLookup.available
|
||||
const pwshAvailable =
|
||||
spawnSync('pwsh', ['-NoLogo', '-NoProfile', '-Command', 'exit 0']).status === 0
|
||||
// Sandboxing needs an absolute path to symlink; the lookup may report a bare name.
|
||||
const fishBinary = fishLookup.available
|
||||
? isAbsolute(fishLookup.path)
|
||||
? fishLookup.path
|
||||
: resolveOnPath(fishLookup.path)
|
||||
: null
|
||||
|
||||
/** Absolute path of an executable file on the host PATH, or null. */
|
||||
function resolveOnPath(name: string): string | null {
|
||||
for (const dir of (process.env.PATH ?? '').split(delimiter)) {
|
||||
if (!dir) {
|
||||
continue
|
||||
}
|
||||
const candidate = join(dir, name)
|
||||
try {
|
||||
if (statSync(candidate).isFile()) {
|
||||
return candidate
|
||||
}
|
||||
} catch {
|
||||
// Not in this directory.
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Sandbox whose bin is the *entire* PATH, so codex provably cannot resolve.
|
||||
*
|
||||
* Why symlink fish in rather than append its directory: fish routinely ships
|
||||
* alongside a real codex (both land in Homebrew's bin), which would silently
|
||||
* turn the absent-codex regression below into a no-op on a normal dev machine. */
|
||||
function createFishSandbox(prefix: string): { bin: string; preflight: string; marker: string } {
|
||||
if (!fishBinary) {
|
||||
throw new Error('fish ran but could not be resolved to a file to symlink')
|
||||
}
|
||||
const root = mkdtempSync(join(tmpdir(), prefix))
|
||||
roots.push(root)
|
||||
const bin = join(root, 'bin')
|
||||
mkdirSync(bin)
|
||||
symlinkSync(fishBinary, join(bin, 'fish'))
|
||||
const marker = join(root, 'preflight-ran')
|
||||
const preflight = join(bin, 'orca-preflight')
|
||||
writeExecutable(preflight, `#!/bin/sh\nprintf ran > ${JSON.stringify(marker)}\n`)
|
||||
return { bin, preflight, marker }
|
||||
}
|
||||
|
||||
// Reports Orca's own wrapper (not a user-defined codex function) and any capture leak.
|
||||
const FISH_STATE_PROBE = `if functions -q codex; and functions codex | string match -q '*prepare-codex*'
|
||||
echo -n wrapper=YES
|
||||
else
|
||||
echo -n wrapper=NO
|
||||
end
|
||||
echo " var=[$__orca_codex_type]"`
|
||||
|
||||
function writeExecutable(path: string, content: string): void {
|
||||
writeFileSync(path, content)
|
||||
@@ -242,6 +300,66 @@ describe.skipIf(process.platform === 'win32')('Codex shell launch preflight', ()
|
||||
|
||||
expect(output.trim()).toBe('custom-codex')
|
||||
})
|
||||
|
||||
// Always runs, so the fish lane cannot report green with the #16893 regression
|
||||
// unexercised — the skips below would otherwise hide a missing binary.
|
||||
it('has fish when CI demanded it', () => {
|
||||
expect(fishRequirementViolation(fishLookup)).toBeNull()
|
||||
})
|
||||
|
||||
// Regression for #16893: an unquoted `(type -t codex)` expands to zero words when
|
||||
// codex is absent, so `test` saw `= file` (2 args) and printed "Missing argument
|
||||
// at index 3" on every fish pane launch. Needs a valid executable
|
||||
// ORCA_CODEX_LAUNCH_PREFLIGHT so the `and` chain reaches the second `test`, and
|
||||
// the real `-l -C` launch shape both shell-ready call sites use.
|
||||
it.skipIf(!fishAvailable)('stays silent and installs no wrapper when codex is absent', () => {
|
||||
const { bin, preflight } = createFishSandbox('orca-codex-fish-absent-')
|
||||
|
||||
const result = spawnSync(
|
||||
join(bin, 'fish'),
|
||||
['--no-config', '-l', '-C', getFishCodexShellLaunchPreflight(), '-c', FISH_STATE_PROBE],
|
||||
{ encoding: 'utf-8', env: { PATH: bin, ORCA_CODEX_LAUNCH_PREFLIGHT: preflight } }
|
||||
)
|
||||
|
||||
expect(result.stderr).not.toContain('Missing argument')
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout.trim()).toBe('wrapper=NO var=[]')
|
||||
})
|
||||
|
||||
it.skipIf(!fishAvailable)('wraps codex and runs the preflight when codex is a real file', () => {
|
||||
const { bin, preflight, marker } = createFishSandbox('orca-codex-fish-present-')
|
||||
writeExecutable(join(bin, 'codex'), '#!/bin/sh\nprintf "real codex $*"\n')
|
||||
|
||||
const output = execFileSync(
|
||||
join(bin, 'fish'),
|
||||
['--no-config', '-l', '-C', getFishCodexShellLaunchPreflight(), '-c', 'codex hi'],
|
||||
{ encoding: 'utf-8', env: { PATH: bin, ORCA_CODEX_LAUNCH_PREFLIGHT: preflight } }
|
||||
)
|
||||
|
||||
expect(output.trim()).toBe('real codex hi')
|
||||
expect(existsSync(marker)).toBe(true)
|
||||
})
|
||||
|
||||
it.skipIf(!fishAvailable)('leaves a codex alias unwrapped', () => {
|
||||
const { bin, preflight } = createFishSandbox('orca-codex-fish-alias-')
|
||||
writeExecutable(join(bin, 'codex'), '#!/bin/sh\nprintf "real codex"\n')
|
||||
|
||||
const result = spawnSync(
|
||||
join(bin, 'fish'),
|
||||
[
|
||||
'--no-config',
|
||||
'-l',
|
||||
'-C',
|
||||
`alias codex='echo aliased'\n${getFishCodexShellLaunchPreflight()}`,
|
||||
'-c',
|
||||
FISH_STATE_PROBE
|
||||
],
|
||||
{ encoding: 'utf-8', env: { PATH: bin, ORCA_CODEX_LAUNCH_PREFLIGHT: preflight } }
|
||||
)
|
||||
|
||||
expect(result.stderr).not.toContain('Missing argument')
|
||||
expect(result.stdout.trim()).toBe('wrapper=NO var=[]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PowerShell Codex shell launch preflight', () => {
|
||||
|
||||
@@ -85,12 +85,17 @@ unset __orca_codex_binary
|
||||
}
|
||||
|
||||
export function getFishCodexShellLaunchPreflight(): string {
|
||||
return `if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file
|
||||
return `# Why captured: an unquoted (type -t codex) expands to zero words when codex is
|
||||
# absent, leaving "test = file" — fish then errors instead of failing closed.
|
||||
# Quoting in place is not the fix; fish never substitutes inside double quotes.
|
||||
set -l __orca_codex_type (type -t codex 2>/dev/null)
|
||||
if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test "$__orca_codex_type" = file
|
||||
function codex
|
||||
command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true
|
||||
command codex $argv
|
||||
end
|
||||
end`
|
||||
end
|
||||
set -e __orca_codex_type`
|
||||
}
|
||||
|
||||
export function getPowerShellCodexShellLaunchPreflight(): string {
|
||||
|
||||
@@ -47638,7 +47638,8 @@ describe('OrcaRuntimeService', () => {
|
||||
updatedAt: '2026-05-22T00:00:00Z',
|
||||
author: 'alex'
|
||||
}
|
||||
]
|
||||
],
|
||||
totalPages: 3
|
||||
})
|
||||
listGitLabTodosMock.mockResolvedValue([])
|
||||
listGitLabLabelsMock.mockResolvedValue(['bug', 'frontend'])
|
||||
@@ -47683,7 +47684,7 @@ describe('OrcaRuntimeService', () => {
|
||||
|
||||
await runtime.listGitLabRepoMRs(TEST_REPO_ID, 'closed', 2, 25, 'ambiguous selector')
|
||||
await runtime.listGitLabRepoWorkItems(TEST_REPO_ID, 'closed', 2, 25, 'ambiguous selector')
|
||||
const issues = await runtime.listGitLabRepoIssues(TEST_REPO_ID, 'opened', '@me', 50)
|
||||
const issues = await runtime.listGitLabRepoIssues(TEST_REPO_ID, 'opened', '@me', 50, 3)
|
||||
await runtime.listGitLabRepoTodos(TEST_REPO_ID)
|
||||
await runtime.listGitLabRepoLabels(TEST_REPO_ID)
|
||||
await runtime.createGitLabRepoIssue(TEST_REPO_ID, 'New issue', 'Body')
|
||||
@@ -47738,7 +47739,9 @@ describe('OrcaRuntimeService', () => {
|
||||
'origin',
|
||||
'opened',
|
||||
'@me',
|
||||
'ssh-1'
|
||||
'ssh-1',
|
||||
{},
|
||||
3
|
||||
)
|
||||
expect(issues.items).toEqual([
|
||||
{
|
||||
@@ -47754,6 +47757,7 @@ describe('OrcaRuntimeService', () => {
|
||||
repoId: TEST_REPO_ID
|
||||
}
|
||||
])
|
||||
expect(issues).toMatchObject({ totalPages: 3 })
|
||||
expect(listGitLabTodosMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1')
|
||||
expect(listGitLabLabelsMock).toHaveBeenCalledWith('/remote/repo', 'origin', 'ssh-1')
|
||||
expect(createGitLabIssueMock).toHaveBeenCalledWith(
|
||||
@@ -47924,7 +47928,8 @@ describe('OrcaRuntimeService', () => {
|
||||
'opened',
|
||||
undefined,
|
||||
null,
|
||||
localGitOptions
|
||||
localGitOptions,
|
||||
1
|
||||
)
|
||||
expect(listGitLabTodosMock).toHaveBeenCalledWith(TEST_REPO_PATH, null, localGitOptions)
|
||||
expect(listGitLabLabelsMock).toHaveBeenCalledWith(
|
||||
@@ -48131,9 +48136,21 @@ describe('OrcaRuntimeService', () => {
|
||||
it('normalizes runtime GitLab issue list arguments like the desktop IPC path', async () => {
|
||||
const runtime = new OrcaRuntimeService(store as never)
|
||||
|
||||
await runtime.listGitLabRepoIssues(TEST_REPO_ID, 'closed', 'someone-else' as never, 250.8)
|
||||
await runtime.listGitLabRepoIssues(TEST_REPO_ID, 'all', '@me', 0.7)
|
||||
await runtime.listGitLabRepoIssues(TEST_REPO_ID, 'unexpected' as never, '@me', Number.NaN)
|
||||
await runtime.listGitLabRepoIssues(
|
||||
TEST_REPO_ID,
|
||||
'closed',
|
||||
'someone-else' as never,
|
||||
250.8,
|
||||
20_000
|
||||
)
|
||||
await runtime.listGitLabRepoIssues(TEST_REPO_ID, 'all', '@me', 0.7, 0)
|
||||
await runtime.listGitLabRepoIssues(
|
||||
TEST_REPO_ID,
|
||||
'unexpected' as never,
|
||||
'@me',
|
||||
Number.NaN,
|
||||
Number.NaN
|
||||
)
|
||||
|
||||
expect(listGitLabIssuesMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
@@ -48142,7 +48159,9 @@ describe('OrcaRuntimeService', () => {
|
||||
undefined,
|
||||
'closed',
|
||||
undefined,
|
||||
null
|
||||
null,
|
||||
{},
|
||||
10_000
|
||||
)
|
||||
expect(listGitLabIssuesMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
@@ -48151,7 +48170,9 @@ describe('OrcaRuntimeService', () => {
|
||||
undefined,
|
||||
'all',
|
||||
'@me',
|
||||
null
|
||||
null,
|
||||
{},
|
||||
1
|
||||
)
|
||||
expect(listGitLabIssuesMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
@@ -48160,7 +48181,9 @@ describe('OrcaRuntimeService', () => {
|
||||
undefined,
|
||||
'opened',
|
||||
'@me',
|
||||
null
|
||||
null,
|
||||
{},
|
||||
1
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -24358,13 +24358,16 @@ export class OrcaRuntimeService {
|
||||
repoSelector: string,
|
||||
state?: GitLabIssueListState,
|
||||
assignee?: string,
|
||||
limit?: number
|
||||
limit?: number,
|
||||
page?: number
|
||||
): Promise<{
|
||||
items: GitLabWorkItem[]
|
||||
totalPages: number
|
||||
error?: Awaited<ReturnType<typeof listGitLabIssues>>['error']
|
||||
}> {
|
||||
const repo = await this.resolveRepoSelector(repoSelector)
|
||||
const normalized = normalizeGitLabIssueListArgs({ state, assignee, limit })
|
||||
const normalized = normalizeGitLabIssueListArgs({ state, assignee, limit, page })
|
||||
// Why: page is after localGitOptions; never spread optional args before it (#13538).
|
||||
const result = await listGitLabIssues(
|
||||
repo.path,
|
||||
normalized.limit,
|
||||
@@ -24372,7 +24375,8 @@ export class OrcaRuntimeService {
|
||||
normalized.state,
|
||||
normalized.assignee,
|
||||
repo.connectionId ?? null,
|
||||
...this.getLocalGitExecutionOptionArgs(repo)
|
||||
this.getLocalGitExecutionOptionArgs(repo)[0] ?? {},
|
||||
normalized.page
|
||||
)
|
||||
// Why: web runtime mirrors the desktop preload contract, where GitLab
|
||||
// issue rows share the GitLabWorkItem shape with MRs on TaskPage.
|
||||
@@ -24388,7 +24392,11 @@ export class OrcaRuntimeService {
|
||||
author: issue.author ?? null,
|
||||
repoId: repo.id
|
||||
}))
|
||||
return { items, ...(result.error ? { error: result.error } : {}) }
|
||||
return {
|
||||
items,
|
||||
totalPages: result.totalPages,
|
||||
...(result.error ? { error: result.error } : {})
|
||||
}
|
||||
}
|
||||
|
||||
async listGitLabRepoTodos(
|
||||
|
||||
@@ -64,7 +64,8 @@ describe('gitlab RPC methods', () => {
|
||||
repo: 'id:repo-1',
|
||||
state: 'opened',
|
||||
assignee: '@me',
|
||||
limit: 50
|
||||
limit: 50,
|
||||
page: 2
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
@@ -202,7 +203,7 @@ describe('gitlab RPC methods', () => {
|
||||
25,
|
||||
'bug'
|
||||
)
|
||||
expect(runtime.listGitLabRepoIssues).toHaveBeenCalledWith('id:repo-1', 'opened', '@me', 50)
|
||||
expect(runtime.listGitLabRepoIssues).toHaveBeenCalledWith('id:repo-1', 'opened', '@me', 50, 2)
|
||||
expect(runtime.createGitLabRepoIssue).toHaveBeenCalledWith('id:repo-1', 'Fix bug', 'Details')
|
||||
expect(runtime.listGitLabRepoTodos).toHaveBeenCalledWith('id:repo-1')
|
||||
expect(runtime.listGitLabRepoLabels).toHaveBeenCalledWith('id:repo-1')
|
||||
@@ -309,9 +310,17 @@ describe('gitlab RPC methods', () => {
|
||||
'id:repo-1',
|
||||
'closed',
|
||||
undefined,
|
||||
100
|
||||
100,
|
||||
1
|
||||
)
|
||||
expect(runtime.listGitLabRepoIssues).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'id:repo-1',
|
||||
'opened',
|
||||
'@me',
|
||||
1,
|
||||
1
|
||||
)
|
||||
expect(runtime.listGitLabRepoIssues).toHaveBeenNthCalledWith(2, 'id:repo-1', 'opened', '@me', 1)
|
||||
})
|
||||
|
||||
// Regression for #7732: the WS/relay transports close the connection on frames
|
||||
|
||||
@@ -36,7 +36,8 @@ const WorkItemsList = RepoSelector.extend({
|
||||
const IssuesList = RepoSelector.extend({
|
||||
state: z.unknown().optional(),
|
||||
assignee: OptionalString,
|
||||
limit: OptionalFiniteNumber
|
||||
limit: OptionalFiniteNumber,
|
||||
page: OptionalFiniteNumber
|
||||
})
|
||||
|
||||
const CreateIssue = RepoSelector.extend({
|
||||
@@ -182,7 +183,8 @@ export const GITLAB_METHODS: RpcMethod[] = [
|
||||
params.repo,
|
||||
normalized.state,
|
||||
normalized.assignee,
|
||||
normalized.limit
|
||||
normalized.limit,
|
||||
normalized.page
|
||||
)
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -911,5 +911,5 @@ describe('read-only skill freshness inventory', () => {
|
||||
expect(inventory.installations).toEqual([
|
||||
expect.objectContaining({ name: 'orca-cli', status: 'current', topology: 'canonical-copy' })
|
||||
])
|
||||
})
|
||||
}, 90_000)
|
||||
})
|
||||
|
||||
@@ -67,8 +67,14 @@ export type GitLabApi = {
|
||||
state?: 'opened' | 'closed' | 'all'
|
||||
assignee?: string
|
||||
limit?: number
|
||||
page?: number
|
||||
}
|
||||
) => Promise<{ items: GitLabWorkItem[]; error?: ClassifiedError }>
|
||||
) => Promise<{
|
||||
items: GitLabWorkItem[]
|
||||
/** Optional while paired with a host older than issue pagination. */
|
||||
totalPages?: number
|
||||
error?: ClassifiedError
|
||||
}>
|
||||
createIssue: (
|
||||
args: GitLabRepoSelectorArgs & {
|
||||
title: string
|
||||
|
||||
@@ -56,8 +56,9 @@ export const glApi = {
|
||||
state?: 'opened' | 'closed' | 'all'
|
||||
assignee?: string
|
||||
limit?: number
|
||||
page?: number
|
||||
}
|
||||
): Promise<{ items: unknown[]; error?: unknown }> =>
|
||||
): Promise<{ items: unknown[]; totalPages?: number; error?: unknown }> =>
|
||||
ipcRenderer.invoke('gitlab:listIssues', args),
|
||||
|
||||
createIssue: (
|
||||
|
||||
@@ -349,6 +349,12 @@ export default function TaskPage(): React.JSX.Element {
|
||||
setGitlabDialogItem,
|
||||
gitlabView,
|
||||
setGitlabView,
|
||||
gitlabIssuePage,
|
||||
setGitlabIssuePage,
|
||||
gitlabIssueTotalPages,
|
||||
setGitlabIssueTotalPages,
|
||||
gitlabIssueLoadingTargetPage,
|
||||
setGitlabIssueLoadingTargetPage,
|
||||
gitlabTodos,
|
||||
setGitlabTodos,
|
||||
gitlabTodosLoading,
|
||||
@@ -356,7 +362,7 @@ export default function TaskPage(): React.JSX.Element {
|
||||
gitlabEmptyState,
|
||||
activeGitlabFilter,
|
||||
displayedGitLabItems
|
||||
} = useTaskPageGitLabListState({ selectedRepos })
|
||||
} = useTaskPageGitLabListState({ taskSource, selectedRepos, selectedReposKey })
|
||||
const {
|
||||
taskSearchInput,
|
||||
setTaskSearchInput,
|
||||
@@ -1083,9 +1089,13 @@ export default function TaskPage(): React.JSX.Element {
|
||||
selectedRepos,
|
||||
selectedReposKey,
|
||||
primaryRepo,
|
||||
gitlabIssuePage,
|
||||
setGitlabItems,
|
||||
setGitlabLoading,
|
||||
setGitlabError,
|
||||
setGitlabIssuePage,
|
||||
setGitlabIssueTotalPages,
|
||||
setGitlabIssueLoadingTargetPage,
|
||||
setGitlabTodos,
|
||||
setGitlabTodosLoading
|
||||
})
|
||||
@@ -2791,7 +2801,18 @@ export default function TaskPage(): React.JSX.Element {
|
||||
displayedGitLabItems,
|
||||
gitlabEmptyState,
|
||||
openGitLabDetailPage,
|
||||
handleUseGitLabItem
|
||||
handleUseGitLabItem,
|
||||
showGitlabIssuePagination: gitlabView === 'issues' && gitlabIssueTotalPages > 1,
|
||||
gitlabIssuePage,
|
||||
gitlabIssueTotalPages,
|
||||
gitlabIssueLoadingTargetPage,
|
||||
onGitlabIssuePageChange: (page: number) => {
|
||||
if (page === gitlabIssuePage) {
|
||||
return
|
||||
}
|
||||
setGitlabIssueLoadingTargetPage(page)
|
||||
setGitlabIssuePage(page)
|
||||
}
|
||||
}
|
||||
const jiraList: JiraIssueListHostProps = {
|
||||
jiraStatusReady,
|
||||
|
||||
@@ -122,6 +122,9 @@ vi.mock('@/store', () => ({
|
||||
(selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
worktreesByRepo: {},
|
||||
settings: {},
|
||||
markdownRichModeSizeOverride: {},
|
||||
setMarkdownRichModeSizeOverride: vi.fn(),
|
||||
openFile: vi.fn(),
|
||||
openMarkdownPreview: vi.fn(),
|
||||
openConflictReviewFile: vi.fn(),
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants'
|
||||
import { formatBytes } from '../status-bar/workspace-space-format'
|
||||
import { MarkdownPreview, RichMarkdownEditor } from './editor-lazy-views'
|
||||
import { exceedsMarkdownRichModeSizeLimit } from './markdown-rich-size-limit'
|
||||
import { extractFrontMatter, prependFrontMatter } from './markdown-frontmatter'
|
||||
@@ -41,9 +45,11 @@ export function EditorMarkdownFileSurface({
|
||||
handleDirtyStateHint: (dirty: boolean) => void
|
||||
monacoEditor: React.JSX.Element
|
||||
}): React.JSX.Element {
|
||||
const sizeOverridden = useAppStore((s) => s.markdownRichModeSizeOverride[activeFile.id] === true)
|
||||
const setSizeOverride = useAppStore((s) => s.setMarkdownRichModeSizeOverride)
|
||||
const richModeUnsupportedMessage = getMarkdownRichModeUnsupportedMessage(currentContent)
|
||||
const renderMode = getMarkdownRenderMode({
|
||||
exceedsRichModeSizeLimit: exceedsMarkdownRichModeSizeLimit(currentContent),
|
||||
exceedsRichModeSizeLimit: !sizeOverridden && exceedsMarkdownRichModeSizeLimit(currentContent),
|
||||
hasRichModeUnsupportedContent: richModeUnsupportedMessage !== null,
|
||||
viewMode: mdViewMode
|
||||
})
|
||||
@@ -52,13 +58,30 @@ export function EditorMarkdownFileSurface({
|
||||
return <div className="h-full min-h-0">{monacoEditor}</div>
|
||||
}
|
||||
if (renderMode === 'source' && mdViewMode === 'rich') {
|
||||
// Why: only a size fallback is recoverable — unsupported syntax would round-trip badly, so it gets no override.
|
||||
const isSizeFallback = richModeUnsupportedMessage === null
|
||||
const richFallbackMessage =
|
||||
richModeUnsupportedMessage ??
|
||||
'File is too large for rich editing. Showing source mode instead.'
|
||||
translate(
|
||||
'editor.richMarkdown.tooLarge',
|
||||
'File is larger than the {{limit}} rich editing limit. Showing source mode instead.',
|
||||
{ limit: formatBytes(RICH_MARKDOWN_MAX_SIZE_BYTES) }
|
||||
)
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="border-b border-border/60 bg-blue-500/10 px-3 py-2 text-xs text-blue-950 dark:text-blue-100">
|
||||
{richFallbackMessage}
|
||||
<div className="flex items-center gap-3 border-b border-border/60 bg-blue-500/10 px-3 py-2 text-xs text-blue-950 dark:text-blue-100">
|
||||
<span className="min-w-0 flex-1">{richFallbackMessage}</span>
|
||||
{isSizeFallback ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
className="shrink-0"
|
||||
onClick={() => setSizeOverride(activeFile.id, true)}
|
||||
>
|
||||
{translate('editor.richMarkdown.openAnyway', 'Open anyway')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 h-full">{monacoEditor}</div>
|
||||
</div>
|
||||
|
||||
@@ -62,6 +62,7 @@ function EditorPanelInner({
|
||||
)
|
||||
const markdownViewMode = useAppStore((s) => s.markdownViewMode)
|
||||
const setMarkdownViewMode = useAppStore((s) => s.setMarkdownViewMode)
|
||||
const markdownRichModeSizeOverride = useAppStore((s) => s.markdownRichModeSizeOverride)
|
||||
const editorViewMode = useAppStore((s) => s.editorViewMode)
|
||||
const setEditorViewMode = useAppStore((s) => s.setEditorViewMode)
|
||||
const openFile = useAppStore((s) => s.openFile)
|
||||
@@ -234,6 +235,7 @@ function EditorPanelInner({
|
||||
gitStatusEntries,
|
||||
gitBranchEntries,
|
||||
markdownViewMode,
|
||||
markdownRichModeSizeOverride,
|
||||
isChangesMode,
|
||||
canOpenWorkspaceFileBrowser
|
||||
})
|
||||
|
||||
@@ -31,6 +31,7 @@ function renderModel(args: {
|
||||
fileContents?: Record<string, FileContent>
|
||||
editorDrafts?: Record<string, string>
|
||||
markdownViewMode?: Record<string, 'source' | 'rich' | 'preview'>
|
||||
markdownRichModeSizeOverride?: Record<string, boolean>
|
||||
isChangesMode?: boolean
|
||||
gitStatusByWorktree?: Record<string, GitStatusEntry[]>
|
||||
}) {
|
||||
@@ -42,6 +43,7 @@ function renderModel(args: {
|
||||
gitStatusEntries: args.gitStatusByWorktree?.[activeFile.worktreeId],
|
||||
gitBranchEntries: undefined,
|
||||
markdownViewMode: args.markdownViewMode ?? {},
|
||||
markdownRichModeSizeOverride: args.markdownRichModeSizeOverride ?? {},
|
||||
isChangesMode: args.isChangesMode ?? false,
|
||||
canOpenWorkspaceFileBrowser: true
|
||||
})
|
||||
@@ -76,6 +78,7 @@ describe('getEditorPanelRenderModel HTML preview affordance', () => {
|
||||
gitStatusEntries: undefined,
|
||||
gitBranchEntries: undefined,
|
||||
markdownViewMode: {},
|
||||
markdownRichModeSizeOverride: {},
|
||||
isChangesMode: false,
|
||||
canOpenWorkspaceFileBrowser: false
|
||||
})
|
||||
@@ -168,13 +171,35 @@ describe('getEditorPanelRenderModel markdown export affordance', () => {
|
||||
it('disables rich export when a multibyte character crosses the byte limit', () => {
|
||||
const model = renderModel({
|
||||
markdownViewMode: { '/repo/README.md': 'rich' },
|
||||
editorDrafts: { '/repo/README.md': `${'a'.repeat(RICH_MARKDOWN_MAX_SIZE_BYTES)}\u00e9` }
|
||||
editorDrafts: {
|
||||
'/repo/README.md': `${'a'.repeat(RICH_MARKDOWN_MAX_SIZE_BYTES)}\u00e9`
|
||||
}
|
||||
})
|
||||
|
||||
expect(model.shouldShowMarkdownExportAction).toBe(true)
|
||||
expect(model.canExportMarkdownToPdf).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps rich mode for an oversized file the user chose to open anyway', () => {
|
||||
const model = renderModel({
|
||||
markdownViewMode: { '/repo/README.md': 'rich' },
|
||||
editorDrafts: { '/repo/README.md': 'a'.repeat(RICH_MARKDOWN_MAX_SIZE_BYTES + 1) },
|
||||
markdownRichModeSizeOverride: { '/repo/README.md': true }
|
||||
})
|
||||
|
||||
expect(model.canExportMarkdownToPdf).toBe(true)
|
||||
})
|
||||
|
||||
it('scopes the open-anyway override to its own file', () => {
|
||||
const model = renderModel({
|
||||
markdownViewMode: { '/repo/README.md': 'rich' },
|
||||
editorDrafts: { '/repo/README.md': 'a'.repeat(RICH_MARKDOWN_MAX_SIZE_BYTES + 1) },
|
||||
markdownRichModeSizeOverride: { '/repo/OTHER.md': true }
|
||||
})
|
||||
|
||||
expect(model.canExportMarkdownToPdf).toBe(false)
|
||||
})
|
||||
|
||||
it('disables edit export while content is still loading, even with a draft', () => {
|
||||
const model = renderModel({
|
||||
fileContents: {},
|
||||
|
||||
@@ -25,6 +25,7 @@ type EditorPanelRenderModelParams = {
|
||||
gitStatusEntries: StoreState['gitStatusByWorktree'][string] | undefined
|
||||
gitBranchEntries: StoreState['gitBranchChangesByWorktree'][string] | undefined
|
||||
markdownViewMode: StoreState['markdownViewMode']
|
||||
markdownRichModeSizeOverride: StoreState['markdownRichModeSizeOverride']
|
||||
isChangesMode: boolean
|
||||
canOpenWorkspaceFileBrowser: boolean
|
||||
}
|
||||
@@ -36,6 +37,7 @@ export function getEditorPanelRenderModel({
|
||||
gitStatusEntries,
|
||||
gitBranchEntries,
|
||||
markdownViewMode,
|
||||
markdownRichModeSizeOverride,
|
||||
isChangesMode,
|
||||
canOpenWorkspaceFileBrowser
|
||||
}: EditorPanelRenderModelParams) {
|
||||
@@ -130,7 +132,9 @@ export function getEditorPanelRenderModel({
|
||||
const inlineMarkdownRenderMode =
|
||||
activeFile.mode === 'edit' && inlineMarkdownContent !== null
|
||||
? getMarkdownRenderMode({
|
||||
exceedsRichModeSizeLimit: exceedsMarkdownRichModeSizeLimit(inlineMarkdownContent),
|
||||
exceedsRichModeSizeLimit:
|
||||
markdownRichModeSizeOverride[activeFile.id] !== true &&
|
||||
exceedsMarkdownRichModeSizeLimit(inlineMarkdownContent),
|
||||
hasRichModeUnsupportedContent:
|
||||
getMarkdownRichModeUnsupportedMessage(inlineMarkdownContent) !== null,
|
||||
viewMode: mdViewMode
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { resolveGitLabIssuePageState } from './gitlab-issue-pages'
|
||||
|
||||
const page = (itemCount: number, totalPages?: number) => ({
|
||||
items: Array.from({ length: itemCount }, (_, index) => index),
|
||||
totalPages
|
||||
})
|
||||
|
||||
describe('resolveGitLabIssuePageState', () => {
|
||||
it('takes the widest pager across selected repos', () => {
|
||||
expect(
|
||||
resolveGitLabIssuePageState({
|
||||
requestedPage: 1,
|
||||
errorCount: 0,
|
||||
results: [page(50, 2), page(50, 7), page(10)]
|
||||
})
|
||||
).toEqual({ page: 1, totalPages: 7 })
|
||||
})
|
||||
|
||||
it('falls back to a single page when no repo reports a count', () => {
|
||||
expect(
|
||||
resolveGitLabIssuePageState({ requestedPage: 0, errorCount: 0, results: [page(3)] })
|
||||
).toEqual({ page: 0, totalPages: 1 })
|
||||
})
|
||||
|
||||
it('ignores a non-finite totalPages instead of poisoning the maximum', () => {
|
||||
expect(
|
||||
resolveGitLabIssuePageState({
|
||||
requestedPage: 0,
|
||||
errorCount: 0,
|
||||
results: [page(1, Number.NaN), page(1, 4)]
|
||||
})
|
||||
).toEqual({ page: 0, totalPages: 4 })
|
||||
})
|
||||
|
||||
it('lands directly on the last page the host reports instead of walking back', () => {
|
||||
expect(
|
||||
resolveGitLabIssuePageState({ requestedPage: 3, errorCount: 0, results: [page(0, 1)] })
|
||||
).toEqual({ page: 0, totalPages: 1 })
|
||||
})
|
||||
|
||||
it('steps back one page when the host still claims more pages than it can fill', () => {
|
||||
expect(
|
||||
resolveGitLabIssuePageState({ requestedPage: 3, errorCount: 0, results: [page(0, 5)] })
|
||||
).toEqual({ page: 2, totalPages: 3 })
|
||||
})
|
||||
|
||||
it('retreats when a speculative probe page comes back empty', () => {
|
||||
expect(
|
||||
resolveGitLabIssuePageState({ requestedPage: 2, errorCount: 0, results: [page(0)] })
|
||||
).toEqual({ page: 1, totalPages: 2 })
|
||||
})
|
||||
|
||||
it('keeps page 0 selected when the first page is genuinely empty', () => {
|
||||
expect(
|
||||
resolveGitLabIssuePageState({ requestedPage: 0, errorCount: 0, results: [page(0)] })
|
||||
).toEqual({ page: 0, totalPages: 1 })
|
||||
})
|
||||
|
||||
it('never sizes the pager below the page that just returned rows', () => {
|
||||
expect(
|
||||
resolveGitLabIssuePageState({ requestedPage: 3, errorCount: 0, results: [page(4, 2)] })
|
||||
).toEqual({ page: 3, totalPages: 4 })
|
||||
})
|
||||
|
||||
it('holds the requested page and pager size when every repo failed', () => {
|
||||
expect(resolveGitLabIssuePageState({ requestedPage: 2, errorCount: 1, results: [] })).toEqual({
|
||||
page: 2,
|
||||
totalPages: null
|
||||
})
|
||||
})
|
||||
|
||||
it('still sizes the pager when some repos returned rows alongside an error', () => {
|
||||
expect(
|
||||
resolveGitLabIssuePageState({ requestedPage: 1, errorCount: 1, results: [page(4, 3)] })
|
||||
).toEqual({ page: 1, totalPages: 3 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
/** Pager state derived from one settled round of per-repo GitLab issue requests. */
|
||||
export type GitLabIssuePageState = {
|
||||
/** 0-based page to show — below the requested one when the requested page overshot the list. */
|
||||
page: number
|
||||
/** null keeps the current pager size: a failed load is not evidence of the end of the list. */
|
||||
totalPages: number | null
|
||||
}
|
||||
|
||||
export function resolveGitLabIssuePageState(args: {
|
||||
requestedPage: number
|
||||
errorCount: number
|
||||
results: readonly { items: readonly unknown[]; totalPages?: number }[]
|
||||
}): GitLabIssuePageState {
|
||||
const itemCount = args.results.reduce((total, result) => total + result.items.length, 0)
|
||||
// null distinguishes "no repo reported a count" from "every repo reported one page".
|
||||
const reported = args.results.reduce<number | null>(
|
||||
(maximum, result) =>
|
||||
typeof result.totalPages === 'number' && Number.isFinite(result.totalPages)
|
||||
? Math.max(maximum ?? 1, Math.floor(result.totalPages))
|
||||
: maximum,
|
||||
null
|
||||
)
|
||||
// Why: a proxy that strips x-total makes the host advertise one speculative next page (#13357),
|
||||
// and issues can close under a deep page — either way an empty, error-free page past the first
|
||||
// means we overshot. Trust a reported count so we land on the last page in one hop instead of
|
||||
// re-fetching every page on the way back.
|
||||
if (args.requestedPage > 0 && itemCount === 0 && args.errorCount === 0) {
|
||||
const totalPages =
|
||||
reported === null ? args.requestedPage : Math.min(args.requestedPage, reported)
|
||||
return { page: totalPages - 1, totalPages }
|
||||
}
|
||||
if (itemCount === 0 && args.errorCount > 0) {
|
||||
return { page: args.requestedPage, totalPages: null }
|
||||
}
|
||||
// Why: rows on page N prove at least N+1 pages exist — without the floor a host that
|
||||
// under-reports (or omits) the count would hide the pager and strand the user on a deep page.
|
||||
return {
|
||||
page: args.requestedPage,
|
||||
totalPages: Math.max(reported ?? 1, args.requestedPage + 1)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
import type { RepoBackedTaskEmptyState } from '@/components/task-page-empty-state'
|
||||
import { getIntlLocale, translate } from '@/i18n/i18n'
|
||||
import type { GitLabWorkItem } from '../../../../../shared/gitlab-types'
|
||||
import { PaginationBar } from '../pagination/pagination-bar'
|
||||
|
||||
export type GitlabWorkItemListProps = {
|
||||
gitlabError: string | null
|
||||
@@ -16,6 +17,11 @@ export type GitlabWorkItemListProps = {
|
||||
gitlabEmptyState: RepoBackedTaskEmptyState
|
||||
openGitLabDetailPage: (item: GitLabWorkItem) => void
|
||||
handleUseGitLabItem: (item: GitLabWorkItem) => void
|
||||
showGitlabIssuePagination: boolean
|
||||
gitlabIssuePage: number
|
||||
gitlabIssueTotalPages: number
|
||||
gitlabIssueLoadingTargetPage: number | null
|
||||
onGitlabIssuePageChange: (page: number) => void
|
||||
}
|
||||
|
||||
export function GitlabWorkItemList({
|
||||
@@ -25,7 +31,12 @@ export function GitlabWorkItemList({
|
||||
displayedGitLabItems,
|
||||
gitlabEmptyState,
|
||||
openGitLabDetailPage,
|
||||
handleUseGitLabItem
|
||||
handleUseGitLabItem,
|
||||
showGitlabIssuePagination,
|
||||
gitlabIssuePage,
|
||||
gitlabIssueTotalPages,
|
||||
gitlabIssueLoadingTargetPage,
|
||||
onGitlabIssuePageChange
|
||||
}: GitlabWorkItemListProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex min-h-0 max-h-full flex-col rounded-md border border-t-0 border-border/50 bg-muted/50 overflow-hidden rounded-t-none shadow-sm">
|
||||
@@ -148,6 +159,16 @@ export function GitlabWorkItemList({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{showGitlabIssuePagination ? (
|
||||
<div className="flex-none border-t border-border/50 bg-muted/50">
|
||||
<PaginationBar
|
||||
currentPage={gitlabIssuePage}
|
||||
totalPages={gitlabIssueTotalPages}
|
||||
loadingTarget={gitlabIssueLoadingTargetPage}
|
||||
onPageChange={onGitlabIssuePageChange}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
isGitLabIssueFilter,
|
||||
isGitLabMRFilter
|
||||
} from '@/components/task-page/gitlab/gitlab-task-filters'
|
||||
import { resolveGitLabIssuePageState } from '@/components/task-page/gitlab/gitlab-issue-pages'
|
||||
import { getTaskPageRepoSourceContext } from '@/components/task-page/source/repo-source-context'
|
||||
import { withGitLabIpcTimeout } from '@/runtime/gitlab-ipc-timeout'
|
||||
import type { GitLabIssueFilter, GitLabTaskFilter } from '@/components/task-page-localized-options'
|
||||
@@ -11,6 +12,8 @@ import type { GitLabTodo, GitLabWorkItem } from '../../../../../shared/gitlab-ty
|
||||
import type { Repo } from '../../../../../shared/repo-types'
|
||||
import type { TaskProvider } from '../../../../../shared/task-providers'
|
||||
|
||||
const GITLAB_ISSUE_PAGE_SIZE = 50
|
||||
|
||||
export function useTaskPageGitLabFetch({
|
||||
taskSource,
|
||||
gitlabView,
|
||||
@@ -19,9 +22,13 @@ export function useTaskPageGitLabFetch({
|
||||
selectedRepos,
|
||||
selectedReposKey,
|
||||
primaryRepo,
|
||||
gitlabIssuePage,
|
||||
setGitlabItems,
|
||||
setGitlabLoading,
|
||||
setGitlabError,
|
||||
setGitlabIssuePage,
|
||||
setGitlabIssueTotalPages,
|
||||
setGitlabIssueLoadingTargetPage,
|
||||
setGitlabTodos,
|
||||
setGitlabTodosLoading
|
||||
}: {
|
||||
@@ -32,9 +39,13 @@ export function useTaskPageGitLabFetch({
|
||||
selectedRepos: readonly Repo[]
|
||||
selectedReposKey: string
|
||||
primaryRepo: Repo | null
|
||||
gitlabIssuePage: number
|
||||
setGitlabItems: Dispatch<SetStateAction<GitLabWorkItem[]>>
|
||||
setGitlabLoading: Dispatch<SetStateAction<boolean>>
|
||||
setGitlabError: Dispatch<SetStateAction<string | null>>
|
||||
setGitlabIssuePage: Dispatch<SetStateAction<number>>
|
||||
setGitlabIssueTotalPages: Dispatch<SetStateAction<number>>
|
||||
setGitlabIssueLoadingTargetPage: Dispatch<SetStateAction<number | null>>
|
||||
setGitlabTodos: Dispatch<SetStateAction<GitLabTodo[]>>
|
||||
setGitlabTodosLoading: Dispatch<SetStateAction<boolean>>
|
||||
}): void {
|
||||
@@ -65,6 +76,9 @@ export function useTaskPageGitLabFetch({
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
// Why: a retreat re-runs this effect immediately; clearing loading in between would flash the
|
||||
// spinner off and re-enable the pager buttons over rows that are about to be replaced.
|
||||
let retreating = false
|
||||
setGitlabLoading(true)
|
||||
setGitlabError(null)
|
||||
|
||||
@@ -79,16 +93,18 @@ export function useTaskPageGitLabFetch({
|
||||
sourceContext: getTaskPageRepoSourceContext(repo, 'gitlab'),
|
||||
state: 'opened',
|
||||
assignee: isAssignedToMe ? '@me' : undefined,
|
||||
limit: 50
|
||||
limit: GITLAB_ISSUE_PAGE_SIZE,
|
||||
page: gitlabIssuePage + 1
|
||||
})
|
||||
).then((result) => {
|
||||
const typed = result as {
|
||||
items: GitLabWorkItem[]
|
||||
totalPages?: number
|
||||
error?: { type?: string; message: string }
|
||||
}
|
||||
// Why: not_found just means the repo isn't a GitLab project (mixed selection); drop it so the list shows no false errors.
|
||||
const error = typed.error?.type === 'not_found' ? undefined : typed.error
|
||||
return { repoId: repo.id, items: typed.items, error }
|
||||
return { repoId: repo.id, items: typed.items, totalPages: typed.totalPages, error }
|
||||
})
|
||||
}
|
||||
: (repo: (typeof eligibleRepos)[0]) =>
|
||||
@@ -117,11 +133,13 @@ export function useTaskPageGitLabFetch({
|
||||
}
|
||||
const merged: GitLabWorkItem[] = []
|
||||
const errs: string[] = []
|
||||
const settled: { items: readonly GitLabWorkItem[]; totalPages?: number }[] = []
|
||||
for (const r of results) {
|
||||
if (r.status !== 'fulfilled') {
|
||||
errs.push(r.reason instanceof Error ? r.reason.message : String(r.reason))
|
||||
continue
|
||||
}
|
||||
settled.push(r.value)
|
||||
for (const item of r.value.items) {
|
||||
merged.push({ ...item, repoId: r.value.repoId })
|
||||
}
|
||||
@@ -129,6 +147,22 @@ export function useTaskPageGitLabFetch({
|
||||
errs.push(r.value.error.message)
|
||||
}
|
||||
}
|
||||
if (gitlabView === 'issues') {
|
||||
const pager = resolveGitLabIssuePageState({
|
||||
requestedPage: gitlabIssuePage,
|
||||
errorCount: errs.length,
|
||||
results: settled
|
||||
})
|
||||
if (pager.totalPages !== null) {
|
||||
setGitlabIssueTotalPages(pager.totalPages)
|
||||
}
|
||||
// Why: an overshot page holds nothing worth showing — step back and let the refetch fill the list.
|
||||
if (pager.page !== gitlabIssuePage) {
|
||||
retreating = true
|
||||
setGitlabIssuePage(pager.page)
|
||||
return
|
||||
}
|
||||
}
|
||||
merged.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
|
||||
setGitlabItems(merged)
|
||||
// Why: only banner when every eligible repo failed; a partial one would hide working rows in a mixed (non-GitLab) selection.
|
||||
@@ -137,15 +171,23 @@ export function useTaskPageGitLabFetch({
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!stale) {
|
||||
if (!stale && !retreating) {
|
||||
setGitlabLoading(false)
|
||||
setGitlabIssueLoadingTargetPage(null)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- selectedReposKey covers every selectedRepos field read above (see its GitHub-scoped-context note); keying off the array ref would re-run on every parent render.
|
||||
}, [taskSource, gitlabView, activeGitlabFilter, gitlabRefreshNonce, selectedReposKey])
|
||||
}, [
|
||||
taskSource,
|
||||
gitlabView,
|
||||
activeGitlabFilter,
|
||||
gitlabRefreshNonce,
|
||||
selectedReposKey,
|
||||
gitlabIssuePage
|
||||
])
|
||||
|
||||
// Why: Todos fetch has its own effect — different trigger (no chip filter) and data path (gl.todos is user-scoped, not repo-scoped).
|
||||
useEffect(() => {
|
||||
|
||||
@@ -6,12 +6,17 @@ import {
|
||||
isGitLabMRFilter
|
||||
} from '@/components/task-page/gitlab/gitlab-task-filters'
|
||||
import type { GitLabIssueFilter, GitLabTaskFilter } from '@/components/task-page-localized-options'
|
||||
import type { TaskProvider } from '../../../../../shared/task-providers'
|
||||
import type { GitLabTodo, GitLabWorkItem } from '../../../../../shared/gitlab-types'
|
||||
|
||||
export function useTaskPageGitLabListState({
|
||||
selectedRepos
|
||||
taskSource,
|
||||
selectedRepos,
|
||||
selectedReposKey
|
||||
}: {
|
||||
taskSource: TaskProvider
|
||||
selectedRepos: { length: number }
|
||||
selectedReposKey: string
|
||||
}) {
|
||||
// ── GitLab task-source state ──────────────────────────────────────
|
||||
// Why: parallel to Linear's slim per-source state — skips workItemsCache and cross-repo aggregation; fetches directly via window.api.gl for the primary repo.
|
||||
@@ -23,6 +28,13 @@ export function useTaskPageGitLabListState({
|
||||
// Why: separate from gitlabItems so the dialog target survives a list refresh that removes the item from the visible filter (e.g. closing an MR).
|
||||
const [gitlabDialogItem, setGitlabDialogItem] = useState<GitLabWorkItem | null>(null)
|
||||
|
||||
// Why: Issues paginate (#13357) — 0-based here, mapped onto GitLab's 1-based page at fetch time.
|
||||
const [gitlabIssuePage, setGitlabIssuePage] = useState(0)
|
||||
const [gitlabIssueTotalPages, setGitlabIssueTotalPages] = useState(1)
|
||||
const [gitlabIssueLoadingTargetPage, setGitlabIssueLoadingTargetPage] = useState<number | null>(
|
||||
null
|
||||
)
|
||||
|
||||
// Why: GitLab tab has two sub-views — the project MR/issue list and the user's cross-project Todos (a separate stream).
|
||||
const [gitlabView, setGitlabView] = useState<'issues' | 'mrs' | 'todos'>('mrs')
|
||||
const [gitlabTodos, setGitlabTodos] = useState<GitLabTodo[]>([])
|
||||
@@ -49,6 +61,17 @@ export function useTaskPageGitLabListState({
|
||||
setGitlabFilter('opened')
|
||||
}
|
||||
|
||||
// Why: reset the pager before commit, not in an effect, so the fetch effect sees page 0 in the
|
||||
// same render — an effect would fire one throwaway request for the previous chip's deep page.
|
||||
const gitlabIssueContextKey = `${taskSource}|${gitlabView}|${activeGitlabFilter}|${selectedReposKey}|${gitlabRefreshNonce}`
|
||||
const [lastGitlabIssueContextKey, setLastGitlabIssueContextKey] = useState(gitlabIssueContextKey)
|
||||
if (lastGitlabIssueContextKey !== gitlabIssueContextKey) {
|
||||
setLastGitlabIssueContextKey(gitlabIssueContextKey)
|
||||
setGitlabIssuePage(0)
|
||||
setGitlabIssueTotalPages(1)
|
||||
setGitlabIssueLoadingTargetPage(null)
|
||||
}
|
||||
|
||||
const displayedGitLabItems = useMemo(() => {
|
||||
if (gitlabView === 'issues') {
|
||||
return gitlabItems.filter((item) => item.type === 'issue')
|
||||
@@ -74,6 +97,12 @@ export function useTaskPageGitLabListState({
|
||||
setGitlabDialogItem,
|
||||
gitlabView,
|
||||
setGitlabView,
|
||||
gitlabIssuePage,
|
||||
setGitlabIssuePage,
|
||||
gitlabIssueTotalPages,
|
||||
setGitlabIssueTotalPages,
|
||||
gitlabIssueLoadingTargetPage,
|
||||
setGitlabIssueLoadingTargetPage,
|
||||
gitlabTodos,
|
||||
setGitlabTodos,
|
||||
gitlabTodosLoading,
|
||||
|
||||
@@ -100,6 +100,12 @@
|
||||
"findUnavailable": "Find in page is not available while this page streams from the remote host."
|
||||
}
|
||||
},
|
||||
"editor": {
|
||||
"richMarkdown": {
|
||||
"tooLarge": "File is larger than the {{limit}} rich editing limit. Showing source mode instead.",
|
||||
"openAnyway": "Open anyway"
|
||||
}
|
||||
},
|
||||
"githubChecks": {
|
||||
"retrying": "Retrying…"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createEditorStore } from './editor-slice-test-harness'
|
||||
|
||||
const { toastErrorMock } = vi.hoisted(() => ({
|
||||
toastErrorMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { error: toastErrorMock }
|
||||
}))
|
||||
|
||||
const { notifyHostOfMirroredEditorCloseMock } = vi.hoisted(() => ({
|
||||
notifyHostOfMirroredEditorCloseMock: vi.fn()
|
||||
}))
|
||||
vi.mock('@/runtime/close-mirrored-editor-tab', () => ({
|
||||
notifyHostOfMirroredEditorClose: (...args: unknown[]) =>
|
||||
notifyHostOfMirroredEditorCloseMock(...args)
|
||||
}))
|
||||
|
||||
function openMarkdown(store: ReturnType<typeof createEditorStore>, filePath: string): void {
|
||||
store.getState().openFile({
|
||||
filePath,
|
||||
relativePath: filePath.replace('/repo/', ''),
|
||||
worktreeId: 'wt-1',
|
||||
language: 'markdown',
|
||||
mode: 'edit'
|
||||
})
|
||||
}
|
||||
|
||||
describe('createEditorSlice rich markdown size override', () => {
|
||||
it('records an opt-in per file and clears it when turned back off', () => {
|
||||
const store = createEditorStore()
|
||||
openMarkdown(store, '/repo/big.md')
|
||||
|
||||
store.getState().setMarkdownRichModeSizeOverride('/repo/big.md', true)
|
||||
expect(store.getState().markdownRichModeSizeOverride).toEqual({ '/repo/big.md': true })
|
||||
|
||||
store.getState().setMarkdownRichModeSizeOverride('/repo/big.md', false)
|
||||
expect(store.getState().markdownRichModeSizeOverride).toEqual({})
|
||||
})
|
||||
|
||||
it('drops the opt-in when the file is closed', async () => {
|
||||
const store = createEditorStore()
|
||||
openMarkdown(store, '/repo/big.md')
|
||||
store.getState().setMarkdownRichModeSizeOverride('/repo/big.md', true)
|
||||
|
||||
await store.getState().closeFile('/repo/big.md')
|
||||
|
||||
expect(store.getState().markdownRichModeSizeOverride).toEqual({})
|
||||
})
|
||||
|
||||
it('drops the opt-in for a replaced preview tab', () => {
|
||||
const store = createEditorStore()
|
||||
|
||||
store.getState().openFile(
|
||||
{
|
||||
filePath: '/repo/docs/README.md',
|
||||
relativePath: 'docs/README.md',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'markdown',
|
||||
mode: 'edit'
|
||||
},
|
||||
{ preview: true }
|
||||
)
|
||||
store.getState().setMarkdownRichModeSizeOverride('/repo/docs/README.md', true)
|
||||
|
||||
store.getState().openFile(
|
||||
{
|
||||
filePath: '/repo/docs/guide.md',
|
||||
relativePath: 'docs/guide.md',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'markdown',
|
||||
mode: 'edit'
|
||||
},
|
||||
{ preview: true }
|
||||
)
|
||||
|
||||
expect(store.getState().markdownRichModeSizeOverride).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -32,6 +32,8 @@ export function createCloseFileAction(
|
||||
delete newEditorDrafts[fileId]
|
||||
const newMarkdownViewMode = { ...s.markdownViewMode }
|
||||
delete newMarkdownViewMode[fileId]
|
||||
const newMarkdownRichModeSizeOverride = { ...s.markdownRichModeSizeOverride }
|
||||
delete newMarkdownRichModeSizeOverride[fileId]
|
||||
const newEditorViewMode = { ...s.editorViewMode }
|
||||
delete newEditorViewMode[fileId]
|
||||
const markdownVisibilityKeys = new Set([fileId])
|
||||
@@ -179,6 +181,7 @@ export function createCloseFileAction(
|
||||
activeFileIdByWorktree: newActiveFileIdByWorktree,
|
||||
activeTabTypeByWorktree: newActiveTabTypeByWorktree,
|
||||
markdownViewMode: newMarkdownViewMode,
|
||||
markdownRichModeSizeOverride: newMarkdownRichModeSizeOverride,
|
||||
editorViewMode: newEditorViewMode,
|
||||
markdownFrontmatterVisible: newMarkdownFrontmatterVisible,
|
||||
markdownTableOfContentsVisible: newMarkdownTableOfContentsVisible,
|
||||
|
||||
@@ -14,6 +14,9 @@ export type EditorDraftState = {
|
||||
clearEditorDrafts: (fileIds: string[]) => void
|
||||
markdownViewMode: Record<string, MarkdownViewMode>
|
||||
setMarkdownViewMode: (fileId: string, mode: MarkdownViewMode) => void
|
||||
// Why: per-file opt-in to open an oversized markdown file in the rich editor despite the size limit.
|
||||
markdownRichModeSizeOverride: Record<string, boolean>
|
||||
setMarkdownRichModeSizeOverride: (fileId: string, enabled: boolean) => void
|
||||
editorViewMode: Record<string, EditorViewMode>
|
||||
setEditorViewMode: (fileId: string, mode: EditorViewMode) => void
|
||||
markdownFrontmatterVisible: Record<string, boolean>
|
||||
@@ -70,6 +73,24 @@ export function createEditorDraftState(set: EditorSet, _get: EditorGet): EditorD
|
||||
markdownViewMode: { ...s.markdownViewMode, [fileId]: mode }
|
||||
})),
|
||||
|
||||
// Rich-markdown size-limit override
|
||||
markdownRichModeSizeOverride: {},
|
||||
setMarkdownRichModeSizeOverride: (fileId, enabled) =>
|
||||
set((s) => {
|
||||
// Why: default is false — delete rather than store it so the record stays minimal.
|
||||
if (!enabled) {
|
||||
if (!(fileId in s.markdownRichModeSizeOverride)) {
|
||||
return s
|
||||
}
|
||||
const next = { ...s.markdownRichModeSizeOverride }
|
||||
delete next[fileId]
|
||||
return { markdownRichModeSizeOverride: next }
|
||||
}
|
||||
return {
|
||||
markdownRichModeSizeOverride: { ...s.markdownRichModeSizeOverride, [fileId]: true }
|
||||
}
|
||||
}),
|
||||
|
||||
// Editor view mode (edit vs changes-diff). See EditorViewMode.
|
||||
editorViewMode: {},
|
||||
setEditorViewMode: (fileId, mode) =>
|
||||
|
||||
@@ -174,6 +174,7 @@ export function applyOpenFileToState(
|
||||
editorDrafts: nextEditorDrafts,
|
||||
editorCursorLine: nextEditorCursorLine,
|
||||
markdownViewMode: nextMarkdownViewMode,
|
||||
markdownRichModeSizeOverride: nextMarkdownRichModeSizeOverride,
|
||||
editorViewMode: nextEditorViewMode,
|
||||
markdownFrontmatterVisible: nextMarkdownFrontmatterVisible,
|
||||
markdownTableOfContentsVisible: nextMarkdownTableOfContentsVisible
|
||||
@@ -235,6 +236,7 @@ export function applyOpenFileToState(
|
||||
editorDrafts: nextEditorDrafts,
|
||||
editorCursorLine: nextEditorCursorLine,
|
||||
markdownViewMode: nextMarkdownViewMode,
|
||||
markdownRichModeSizeOverride: nextMarkdownRichModeSizeOverride,
|
||||
editorViewMode: nextEditorViewMode,
|
||||
markdownFrontmatterVisible: nextMarkdownFrontmatterVisible,
|
||||
markdownTableOfContentsVisible: nextMarkdownTableOfContentsVisible,
|
||||
|
||||
@@ -77,6 +77,7 @@ export function createRecentlyClosedEditorTabs(
|
||||
activeFileId: null,
|
||||
activeTabType: 'terminal',
|
||||
markdownViewMode: {},
|
||||
markdownRichModeSizeOverride: {},
|
||||
editorViewMode: {},
|
||||
markdownFrontmatterVisible: {},
|
||||
markdownTableOfContentsVisible: {},
|
||||
@@ -93,6 +94,11 @@ export function createRecentlyClosedEditorTabs(
|
||||
const newMarkdownViewMode = Object.fromEntries(
|
||||
Object.entries(s.markdownViewMode).filter(([fileId]) => remainingFileIds.has(fileId))
|
||||
)
|
||||
const newMarkdownRichModeSizeOverride = Object.fromEntries(
|
||||
Object.entries(s.markdownRichModeSizeOverride).filter(([fileId]) =>
|
||||
remainingFileIds.has(fileId)
|
||||
)
|
||||
)
|
||||
const newEditorViewMode = Object.fromEntries(
|
||||
Object.entries(s.editorViewMode).filter(([fileId]) => remainingFileIds.has(fileId))
|
||||
)
|
||||
@@ -175,6 +181,7 @@ export function createRecentlyClosedEditorTabs(
|
||||
: s.activeBrowserTabId,
|
||||
activeTabType: browserTabsForWorktree.length > 0 ? 'browser' : 'terminal',
|
||||
markdownViewMode: newMarkdownViewMode,
|
||||
markdownRichModeSizeOverride: newMarkdownRichModeSizeOverride,
|
||||
editorViewMode: newEditorViewMode,
|
||||
markdownFrontmatterVisible: newMarkdownFrontmatterVisible,
|
||||
markdownTableOfContentsVisible: newMarkdownTableOfContentsVisible,
|
||||
|
||||
@@ -117,6 +117,10 @@ export function createRekeyOpenFilesAction(
|
||||
editorDrafts: rekeyFileIdRecord(s.editorDrafts, migrations),
|
||||
editorCursorLine: rekeyFileIdRecord(s.editorCursorLine, migrations),
|
||||
markdownViewMode: rekeyFileIdRecord(s.markdownViewMode, migrations),
|
||||
markdownRichModeSizeOverride: rekeyFileIdRecord(
|
||||
s.markdownRichModeSizeOverride,
|
||||
migrations
|
||||
),
|
||||
editorViewMode: rekeyFileIdRecord(s.editorViewMode, migrations),
|
||||
markdownFrontmatterVisible: rekeyFileIdRecord(s.markdownFrontmatterVisible, migrations),
|
||||
markdownTableOfContentsVisible: rekeyFileIdRecord(
|
||||
|
||||
@@ -205,6 +205,7 @@ export function buildRestoredEditorOwnerTransition(
|
||||
editorDrafts: rekeyFileIdRecord(s.editorDrafts, migrations),
|
||||
editorCursorLine: rekeyFileIdRecord(s.editorCursorLine, migrations),
|
||||
markdownViewMode: rekeyFileIdRecord(s.markdownViewMode, migrations),
|
||||
markdownRichModeSizeOverride: rekeyFileIdRecord(s.markdownRichModeSizeOverride, migrations),
|
||||
editorViewMode: rekeyFileIdRecord(s.editorViewMode, migrations),
|
||||
markdownFrontmatterVisible: rekeyFileIdRecord(s.markdownFrontmatterVisible, migrations),
|
||||
markdownTableOfContentsVisible: rekeyFileIdRecord(
|
||||
|
||||
@@ -77,6 +77,7 @@ export function removeEditorStateForReplacedPreview(
|
||||
| 'editorDrafts'
|
||||
| 'editorCursorLine'
|
||||
| 'markdownViewMode'
|
||||
| 'markdownRichModeSizeOverride'
|
||||
| 'editorViewMode'
|
||||
| 'markdownFrontmatterVisible'
|
||||
| 'markdownTableOfContentsVisible'
|
||||
@@ -89,6 +90,7 @@ export function removeEditorStateForReplacedPreview(
|
||||
| 'editorDrafts'
|
||||
| 'editorCursorLine'
|
||||
| 'markdownViewMode'
|
||||
| 'markdownRichModeSizeOverride'
|
||||
| 'editorViewMode'
|
||||
| 'markdownFrontmatterVisible'
|
||||
| 'markdownTableOfContentsVisible'
|
||||
@@ -110,6 +112,7 @@ export function removeEditorStateForReplacedPreview(
|
||||
editorDrafts: state.editorDrafts,
|
||||
editorCursorLine: state.editorCursorLine,
|
||||
markdownViewMode: state.markdownViewMode,
|
||||
markdownRichModeSizeOverride: state.markdownRichModeSizeOverride,
|
||||
editorViewMode: state.editorViewMode,
|
||||
markdownFrontmatterVisible: state.markdownFrontmatterVisible,
|
||||
markdownTableOfContentsVisible: state.markdownTableOfContentsVisible
|
||||
@@ -125,6 +128,11 @@ export function removeEditorStateForReplacedPreview(
|
||||
markdownViewMode: Object.fromEntries(
|
||||
Object.entries(state.markdownViewMode).filter(([fileId]) => fileId !== replacedFile.id)
|
||||
),
|
||||
markdownRichModeSizeOverride: Object.fromEntries(
|
||||
Object.entries(state.markdownRichModeSizeOverride).filter(
|
||||
([fileId]) => fileId !== replacedFile.id
|
||||
)
|
||||
),
|
||||
editorViewMode: Object.fromEntries(
|
||||
Object.entries(state.editorViewMode).filter(([fileId]) => fileId !== replacedFile.id)
|
||||
),
|
||||
|
||||
@@ -166,6 +166,7 @@ export function createTestStore() {
|
||||
openFiles: [],
|
||||
editorDrafts: {},
|
||||
markdownViewMode: {},
|
||||
markdownRichModeSizeOverride: {},
|
||||
editorViewMode: {},
|
||||
showDotfilesByWorktree: {},
|
||||
expandedDirs: {},
|
||||
|
||||
@@ -116,6 +116,10 @@ export function applyRemoveWorktreeSuccessState(
|
||||
const nextEditorDrafts = removedFileIds.size > 0 ? { ...s.editorDrafts } : s.editorDrafts
|
||||
const nextMarkdownViewMode =
|
||||
removedFileIds.size > 0 ? { ...s.markdownViewMode } : s.markdownViewMode
|
||||
const nextMarkdownRichModeSizeOverride =
|
||||
removedFileIds.size > 0
|
||||
? { ...s.markdownRichModeSizeOverride }
|
||||
: s.markdownRichModeSizeOverride
|
||||
const nextEditorViewMode = removedFileIds.size > 0 ? { ...s.editorViewMode } : s.editorViewMode
|
||||
const nextMarkdownFrontmatterVisible =
|
||||
removedFileIds.size > 0 ? { ...s.markdownFrontmatterVisible } : s.markdownFrontmatterVisible
|
||||
@@ -126,6 +130,7 @@ export function applyRemoveWorktreeSuccessState(
|
||||
for (const fileId of removedFileIds) {
|
||||
delete nextEditorDrafts[fileId]
|
||||
delete nextMarkdownViewMode[fileId]
|
||||
delete nextMarkdownRichModeSizeOverride[fileId]
|
||||
delete nextEditorViewMode[fileId]
|
||||
delete nextMarkdownFrontmatterVisible[fileId]
|
||||
delete nextEditorCursorLine[fileId]
|
||||
@@ -233,6 +238,7 @@ export function applyRemoveWorktreeSuccessState(
|
||||
activeGroupIdByWorktree: nextActiveGroupIdByWorktree,
|
||||
editorDrafts: nextEditorDrafts,
|
||||
markdownViewMode: nextMarkdownViewMode,
|
||||
markdownRichModeSizeOverride: nextMarkdownRichModeSizeOverride,
|
||||
editorViewMode: nextEditorViewMode,
|
||||
markdownFrontmatterVisible: nextMarkdownFrontmatterVisible,
|
||||
editorCursorLine: nextEditorCursorLine,
|
||||
|
||||
@@ -169,6 +169,7 @@ export function buildWorktreePurgeState(
|
||||
// Per-file editor state for removed files
|
||||
editorDrafts: omitByFileId(s.editorDrafts),
|
||||
markdownViewMode: omitByFileId(s.markdownViewMode),
|
||||
markdownRichModeSizeOverride: omitByFileId(s.markdownRichModeSizeOverride),
|
||||
markdownFrontmatterVisible: omitByFileId(s.markdownFrontmatterVisible),
|
||||
// Why: keyed by fileId; the bulk reconcile path previously kept these, leaking a cursor-line / view-mode entry per removed file.
|
||||
editorCursorLine: omitByFileId(s.editorCursorLine),
|
||||
|
||||
@@ -83,7 +83,13 @@ export const getDefaultTerminalRightClickToPaste = (
|
||||
platform = typeof process !== 'undefined' ? process.platform : ''
|
||||
): boolean => platform === 'win32'
|
||||
|
||||
/** Why: ProseMirror's full-document tree lags on large files; above this, fall back to source mode (Monaco). */
|
||||
/** Why: ProseMirror renders the whole document — no virtualization — so cost is
|
||||
* linear in file size and every keystroke re-runs it. Measured in a packaged
|
||||
* build (M-series, `out/`), typing latency and the blocking mount on open:
|
||||
* 100 KB 17 ms / 0 ms · 200 KB 46 ms / 0 ms · 300 KB 84 ms / 1.4 s ·
|
||||
* 600 KB 265 ms / 4.2 s. 300 KB is already the knee, so this is a ceiling to
|
||||
* hold rather than raise; past it, fall back to source mode (Monaco) with a
|
||||
* per-file "Open anyway" escape hatch. Real headroom needs #7056. */
|
||||
export const RICH_MARKDOWN_MAX_SIZE_BYTES = 300 * 1024
|
||||
|
||||
export const DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS = 1000
|
||||
|
||||
Reference in New Issue
Block a user