Compare commits
63 Commits
v0.12.3-de
...
codenomad/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afc554ef98 | ||
|
|
46150cda5e | ||
|
|
0874f78ccf | ||
|
|
88da377795 | ||
|
|
3533dabda0 | ||
|
|
25555ed42c | ||
|
|
df6c96453f | ||
|
|
db3a786b48 | ||
|
|
1e47389df3 | ||
|
|
d7ae575042 | ||
|
|
8346b7b631 | ||
|
|
c441d7d3ce | ||
|
|
be8fcc98c5 | ||
|
|
658253a3fd | ||
|
|
0e96662a07 | ||
|
|
eb77c06571 | ||
|
|
a6cb70ed41 | ||
|
|
13596e8082 | ||
|
|
d9d56d77bc | ||
|
|
c886344e2f | ||
|
|
69cb049a39 | ||
|
|
38cdb4ddb1 | ||
|
|
b11a9e3ec8 | ||
|
|
268d23e9f6 | ||
|
|
f266577c75 | ||
|
|
3bad0afd7d | ||
|
|
8567d49178 | ||
|
|
09284ee2ce | ||
|
|
a2e30f1b54 | ||
|
|
a4af811de3 | ||
|
|
c5aa59ca75 | ||
|
|
b8e0714b68 | ||
|
|
3f890e5de1 | ||
|
|
935926d875 | ||
|
|
74f753abf4 | ||
|
|
d15340a4b8 | ||
|
|
108cad82d0 | ||
|
|
823dd2d687 | ||
|
|
313e82880b | ||
|
|
68407a01a4 | ||
|
|
0283493f2a | ||
|
|
e989795de3 | ||
|
|
103d2bf1a8 | ||
|
|
0ce7a47e03 | ||
|
|
5df8809c82 | ||
|
|
6e22614648 | ||
|
|
5d87e1e563 | ||
|
|
d735b189f5 | ||
|
|
3d575f4f68 | ||
|
|
b58728dc0e | ||
|
|
672177f570 | ||
|
|
6961efde0b | ||
|
|
b3e0233f4b | ||
|
|
fcebcb0174 | ||
|
|
eaab5e2e9f | ||
|
|
b12825f923 | ||
|
|
8245f474b8 | ||
|
|
3a15b311a8 | ||
|
|
6cb6c0af32 | ||
|
|
7f631611fd | ||
|
|
9d91ecc649 | ||
|
|
87afb06d34 | ||
|
|
4402d9afb0 |
95
.github/workflows/build-and-upload.yml
vendored
95
.github/workflows/build-and-upload.yml
vendored
@@ -28,6 +28,21 @@ on:
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
upload_actions_artifacts:
|
||||
description: "Upload built artifacts to GitHub Actions run artifacts"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
actions_artifacts_retention_days:
|
||||
description: "Retention (days) for GitHub Actions artifacts"
|
||||
required: false
|
||||
default: 7
|
||||
type: number
|
||||
actions_artifacts_name_prefix:
|
||||
description: "Optional prefix for Actions artifact names"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
set_versions:
|
||||
description: "Run npm version to set workspace versions"
|
||||
required: false
|
||||
@@ -203,6 +218,15 @@ jobs:
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
- name: Upload Actions artifacts (Electron macOS)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-macos
|
||||
path: packages/electron-app/release/*.zip
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: error
|
||||
|
||||
build-windows:
|
||||
runs-on: windows-2025
|
||||
env:
|
||||
@@ -244,6 +268,15 @@ jobs:
|
||||
gh release upload $env:TAG $_.FullName --clobber
|
||||
}
|
||||
|
||||
- name: Upload Actions artifacts (Electron Windows)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-windows
|
||||
path: packages/electron-app/release/*.zip
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: error
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
@@ -286,6 +319,15 @@ jobs:
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
- name: Upload Actions artifacts (Electron Linux)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux
|
||||
path: packages/electron-app/release/*.zip
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: error
|
||||
|
||||
build-tauri-macos:
|
||||
runs-on: macos-15-intel
|
||||
env:
|
||||
@@ -339,7 +381,7 @@ jobs:
|
||||
run: npm exec -- tauri build
|
||||
|
||||
- name: Package Tauri artifacts (macOS)
|
||||
if: ${{ inputs.upload }}
|
||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||
@@ -350,6 +392,15 @@ jobs:
|
||||
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
|
||||
fi
|
||||
|
||||
- name: Upload Actions artifacts (Tauri macOS)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-macos
|
||||
path: packages/tauri-app/release-tauri/*.zip
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload Tauri release assets (macOS)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
@@ -414,7 +465,7 @@ jobs:
|
||||
run: npm exec -- tauri build
|
||||
|
||||
- name: Package Tauri artifacts (macOS arm64)
|
||||
if: ${{ inputs.upload }}
|
||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||
@@ -425,6 +476,15 @@ jobs:
|
||||
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
|
||||
fi
|
||||
|
||||
- name: Upload Actions artifacts (Tauri macOS arm64)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-macos-arm64
|
||||
path: packages/tauri-app/release-tauri/*.zip
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload Tauri release assets (macOS arm64)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
@@ -492,7 +552,7 @@ jobs:
|
||||
run: npm exec -- tauri build
|
||||
|
||||
- name: Package Tauri artifacts (Windows)
|
||||
if: ${{ inputs.upload }}
|
||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
$bundleRoot = "packages/tauri-app/target/release/bundle"
|
||||
@@ -505,6 +565,15 @@ jobs:
|
||||
Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force
|
||||
}
|
||||
|
||||
- name: Upload Actions artifacts (Tauri Windows)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-windows
|
||||
path: packages/tauri-app/release-tauri/*.zip
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload Tauri release assets (Windows)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
shell: pwsh
|
||||
@@ -582,7 +651,7 @@ jobs:
|
||||
run: npm exec -- tauri build
|
||||
|
||||
- name: Package Tauri artifacts (Linux)
|
||||
if: ${{ inputs.upload }}
|
||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SEARCH_ROOT="packages/tauri-app/target"
|
||||
@@ -608,6 +677,15 @@ jobs:
|
||||
cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb"
|
||||
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
|
||||
|
||||
- name: Upload Actions artifacts (Tauri Linux)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-linux
|
||||
path: packages/tauri-app/release-tauri/*
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload Tauri release assets (Linux)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
@@ -766,3 +844,12 @@ jobs:
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
- name: Upload Actions artifacts (Electron Linux RPM)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux-rpm
|
||||
path: packages/electron-app/release/*.rpm
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: error
|
||||
|
||||
121
.github/workflows/comment-pr-artifacts.yml
vendored
Normal file
121
.github/workflows/comment-pr-artifacts.yml
vendored
Normal file
@@ -0,0 +1,121 @@
|
||||
name: Comment PR Artifacts
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
IS_DRAFT: ${{ github.event.pull_request.draft }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
RETENTION_DAYS: 7
|
||||
steps:
|
||||
- name: Check PR authorization
|
||||
id: auth
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$BASE_REF" = "dev" ]; then
|
||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
normalized=",${ALLOWED_ACTORS},"
|
||||
if [[ "$normalized" == *",${ACTOR},"* ]]; then
|
||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Wait for PR build and comment
|
||||
if: ${{ steps.auth.outputs.allowed == 'true' && env.IS_DRAFT != 'true' }}
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prNumber = Number(process.env.PR_NUMBER);
|
||||
const headSha = process.env.HEAD_SHA;
|
||||
const retentionDays = Number(process.env.RETENTION_DAYS || '7');
|
||||
const marker = '<!-- codenomad-pr-artifacts -->';
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
let matchedRun = null;
|
||||
for (let attempt = 1; attempt <= 30; attempt += 1) {
|
||||
const runs = await github.paginate(github.rest.actions.listWorkflowRuns, {
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: 'pr-build.yml',
|
||||
event: 'pull_request',
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const matchingRuns = runs
|
||||
.filter((run) => run.head_sha === headSha)
|
||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
|
||||
matchedRun = matchingRuns[0] || null;
|
||||
if (matchedRun && matchedRun.status === 'completed') {
|
||||
break;
|
||||
}
|
||||
|
||||
core.info(`Waiting for PR Build Validation run for ${headSha} (attempt ${attempt}/30)`);
|
||||
await sleep(10000);
|
||||
}
|
||||
|
||||
if (!matchedRun) {
|
||||
core.setFailed(`Could not find PR Build Validation run for ${headSha}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchedRun.status !== 'completed') {
|
||||
core.setFailed(`PR Build Validation run ${matchedRun.id} did not complete in time.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const artifacts = await github.paginate(
|
||||
github.rest.actions.listWorkflowRunArtifacts,
|
||||
{ owner, repo, run_id: matchedRun.id, per_page: 100 }
|
||||
);
|
||||
const active = artifacts.filter((artifact) => !artifact.expired);
|
||||
|
||||
const runUrl = matchedRun.html_url;
|
||||
const artifactsBlock = active.length
|
||||
? ['Artifacts:', ...active.map((artifact) => `- ${artifact.name}`)].join('\n')
|
||||
: 'Artifacts: (none found on this run)';
|
||||
|
||||
const body = [
|
||||
marker,
|
||||
'PR builds are available as GitHub Actions artifacts:',
|
||||
'',
|
||||
runUrl,
|
||||
'',
|
||||
`Artifacts expire in ${retentionDays} days.`,
|
||||
artifactsBlock,
|
||||
].join('\n');
|
||||
|
||||
const created = await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body,
|
||||
});
|
||||
core.info(`Created artifacts comment: ${created.data.html_url}`);
|
||||
57
.github/workflows/pr-build.yml
vendored
Normal file
57
.github/workflows/pr-build.yml
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
name: PR Build Validation
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
|
||||
concurrency:
|
||||
group: pr-build-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
authorize:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
allowed: ${{ steps.auth.outputs.allowed }}
|
||||
env:
|
||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
steps:
|
||||
- name: Check PR authorization
|
||||
id: auth
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$BASE_REF" = "dev" ]; then
|
||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
normalized=",${ALLOWED_ACTORS},"
|
||||
if [[ "$normalized" == *",${ACTOR},"* ]]; then
|
||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Skipping builds for unauthorized PR targeting $BASE_REF" >&2
|
||||
fi
|
||||
|
||||
build:
|
||||
needs: authorize
|
||||
if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }}
|
||||
uses: ./.github/workflows/build-and-upload.yml
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
upload: false
|
||||
upload_actions_artifacts: true
|
||||
actions_artifacts_retention_days: 7
|
||||
actions_artifacts_name_prefix: pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}-
|
||||
set_versions: false
|
||||
54
.github/workflows/restrict-non-dev-prs.yml
vendored
Normal file
54
.github/workflows/restrict-non-dev-prs.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
name: Restrict Non-Dev PRs
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
restrict-non-dev-prs:
|
||||
if: ${{ github.event.pull_request.base.ref != 'dev' }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
steps:
|
||||
- name: Check allowed actor
|
||||
id: auth
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
normalized=",${ALLOWED_ACTORS},"
|
||||
if [[ "$normalized" == *",${ACTOR},"* ]]; then
|
||||
echo "authorized=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "authorized=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Comment on unauthorized PR
|
||||
if: ${{ steps.auth.outputs.authorized != 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh pr comment "$PR_NUMBER" --body "Thanks for the contribution. PRs need to target \`dev\` branch. Please retarget this PR to the dev branch"
|
||||
|
||||
- name: Close unauthorized PR
|
||||
if: ${{ steps.auth.outputs.authorized != 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh pr close "$PR_NUMBER"
|
||||
|
||||
- name: Fail unauthorized PR
|
||||
if: ${{ steps.auth.outputs.authorized != 'true' }}
|
||||
run: |
|
||||
echo "Actor $ACTOR is not allowed to open PRs targeting $BASE_REF" >&2
|
||||
exit 1
|
||||
57
package-lock.json
generated
57
package-lock.json
generated
@@ -3253,9 +3253,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz",
|
||||
"integrity": "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==",
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
|
||||
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -3322,6 +3322,15 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
|
||||
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-notification": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||
@@ -10235,14 +10244,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/tauri-plugin-keepawake-api": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tauri-plugin-keepawake-api/-/tauri-plugin-keepawake-api-0.1.0.tgz",
|
||||
"integrity": "sha512-XPUl66zUYiB7kCRxsTdmCoNjFM/++NWCJ4kdTo2NUOgBUa8UVYfayDWnnTzGIQbhT7qNAHs+jgKSjhqSKs/QHA==",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": ">=2.0.0-beta.6"
|
||||
}
|
||||
},
|
||||
"node_modules/temp-dir": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
|
||||
@@ -10983,6 +10984,36 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/virtua": {
|
||||
"version": "0.48.8",
|
||||
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.48.8.tgz",
|
||||
"integrity": "sha512-jpsxOw5V4B6hg44JePRLo9DL0TV7N1lBEVtPjKpAJebXyhI2s9lfiXJESaLapNtr3vtiSk/pWHiLf7B2a6UcgQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.14.0",
|
||||
"react-dom": ">=16.14.0",
|
||||
"solid-js": ">=1.0",
|
||||
"svelte": ">=5.0",
|
||||
"vue": ">=3.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"solid-js": {
|
||||
"optional": true
|
||||
},
|
||||
"svelte": {
|
||||
"optional": true
|
||||
},
|
||||
"vue": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"dev": true,
|
||||
@@ -12098,6 +12129,8 @@
|
||||
"@suid/icons-material": "^0.9.0",
|
||||
"@suid/material": "^0.19.0",
|
||||
"@suid/system": "^0.14.0",
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"ansi-sequence-parser": "^1.1.3",
|
||||
@@ -12110,7 +12143,7 @@
|
||||
"shiki": "^3.13.0",
|
||||
"solid-js": "^1.8.0",
|
||||
"solid-toast": "^0.5.0",
|
||||
"tauri-plugin-keepawake-api": "^0.1.0",
|
||||
"virtua": "^0.48.8",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -31,4 +31,4 @@
|
||||
"devDependencies": {
|
||||
"baseline-browser-mapping": "^2.9.11"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,6 @@
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.2.25"
|
||||
"@opencode-ai/plugin": "1.2.24"
|
||||
}
|
||||
}
|
||||
@@ -46,4 +46,4 @@
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
2474
packages/tauri-app/Cargo.lock
generated
2474
packages/tauri-app/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ const serverDevInstallCommand =
|
||||
"npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||
const uiDevInstallCommand =
|
||||
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||
const serverPrepareUiCommand = "npm run prepare-ui --workspace @neuralnomads/codenomad"
|
||||
|
||||
const envWithRootBin = {
|
||||
...process.env,
|
||||
@@ -91,6 +92,15 @@ function ensureUiBuild() {
|
||||
}
|
||||
}
|
||||
|
||||
function syncServerUiBundle() {
|
||||
console.log("[prebuild] syncing server public UI bundle...")
|
||||
execSync(serverPrepareUiCommand, {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
env: envWithRootBin,
|
||||
})
|
||||
}
|
||||
|
||||
function ensureServerDevDependencies() {
|
||||
if (fs.existsSync(braceExpansionPath)) {
|
||||
return
|
||||
@@ -246,6 +256,7 @@ function copyUiLoadingAssets() {
|
||||
ensureServerDependencies()
|
||||
ensureServerBuild()
|
||||
ensureUiBuild()
|
||||
syncServerUiBundle()
|
||||
copyServerArtifacts()
|
||||
stripNodeModuleBins()
|
||||
copyUiLoadingAssets()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "codenomad-tauri"
|
||||
version = "0.1.0"
|
||||
version = "0.12.3"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
@@ -19,9 +19,13 @@ thiserror = "1"
|
||||
anyhow = "1"
|
||||
which = "4"
|
||||
libc = "0.2"
|
||||
keepawake = "0.6"
|
||||
tauri-plugin-dialog = "2"
|
||||
dirs = "5"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
url = "2"
|
||||
tauri-plugin-keepawake = "0.1.1"
|
||||
tauri-plugin-notification = "2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.59", features = ["Win32_UI_Shell"] }
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"core:menu:default",
|
||||
"dialog:allow-open",
|
||||
"opener:allow-default-urls",
|
||||
"opener:allow-open-url",
|
||||
"notification:allow-is-permission-granted",
|
||||
"notification:allow-request-permission",
|
||||
"notification:allow-notify",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
|
||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
|
||||
|
||||
@@ -2379,34 +2379,70 @@
|
||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
|
||||
"description": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n",
|
||||
"type": "string",
|
||||
"const": "keepawake:default",
|
||||
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
|
||||
"const": "global-shortcut:default",
|
||||
"markdownDescription": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n"
|
||||
},
|
||||
{
|
||||
"description": "Enables the start command without any pre-configured scope.",
|
||||
"description": "Enables the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:allow-start",
|
||||
"markdownDescription": "Enables the start command without any pre-configured scope."
|
||||
"const": "global-shortcut:allow-is-registered",
|
||||
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the stop command without any pre-configured scope.",
|
||||
"description": "Enables the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:allow-stop",
|
||||
"markdownDescription": "Enables the stop command without any pre-configured scope."
|
||||
"const": "global-shortcut:allow-register",
|
||||
"markdownDescription": "Enables the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the start command without any pre-configured scope.",
|
||||
"description": "Enables the register_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:deny-start",
|
||||
"markdownDescription": "Denies the start command without any pre-configured scope."
|
||||
"const": "global-shortcut:allow-register-all",
|
||||
"markdownDescription": "Enables the register_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the stop command without any pre-configured scope.",
|
||||
"description": "Enables the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:deny-stop",
|
||||
"markdownDescription": "Denies the stop command without any pre-configured scope."
|
||||
"const": "global-shortcut:allow-unregister",
|
||||
"markdownDescription": "Enables the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the unregister_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-unregister-all",
|
||||
"markdownDescription": "Enables the unregister_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-is-registered",
|
||||
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-register",
|
||||
"markdownDescription": "Denies the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-register-all",
|
||||
"markdownDescription": "Denies the register_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-unregister",
|
||||
"markdownDescription": "Denies the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the unregister_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-unregister-all",
|
||||
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||
|
||||
@@ -2378,36 +2378,6 @@
|
||||
"const": "dialog:deny-save",
|
||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
|
||||
"type": "string",
|
||||
"const": "keepawake:default",
|
||||
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the start command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:allow-start",
|
||||
"markdownDescription": "Enables the start command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the stop command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:allow-stop",
|
||||
"markdownDescription": "Enables the stop command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the start command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:deny-start",
|
||||
"markdownDescription": "Denies the start command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the stop command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:deny-stop",
|
||||
"markdownDescription": "Denies the stop command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||
"type": "string",
|
||||
|
||||
@@ -9,6 +9,8 @@ use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::TcpStream;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -17,10 +19,24 @@ use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
||||
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
#[cfg(windows)]
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
fn log_line(message: &str) {
|
||||
println!("[tauri-cli] {message}");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn configure_spawn(command: &mut Command) {
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn configure_spawn(_command: &mut Command) {}
|
||||
|
||||
fn workspace_root() -> Option<PathBuf> {
|
||||
std::env::current_dir().ok().and_then(|mut dir| {
|
||||
for _ in 0..3 {
|
||||
@@ -36,6 +52,46 @@ const SESSION_COOKIE_NAME: &str = "codenomad_session";
|
||||
|
||||
const CLI_STOP_GRACE_SECS: u64 = 30;
|
||||
|
||||
#[cfg(unix)]
|
||||
fn configure_posix_process_group(command: &mut Command) {
|
||||
// Ensure the CLI runs in its own process group so we can terminate wrapper
|
||||
// processes (login shell/tsx) without leaving the server orphaned.
|
||||
unsafe {
|
||||
command.pre_exec(|| {
|
||||
if libc::setpgid(0, 0) != 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn kill_process_tree_windows(pid: u32, force: bool) -> bool {
|
||||
let mut args = vec!["/PID".to_string(), pid.to_string(), "/T".to_string()];
|
||||
if force {
|
||||
args.push("/F".to_string());
|
||||
}
|
||||
|
||||
let mut command = Command::new("taskkill");
|
||||
command.args(&args);
|
||||
configure_spawn(&mut command);
|
||||
|
||||
match command.output() {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the PID is already gone, treat it as success.
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
|
||||
let combined = format!("{stdout}\n{stderr}");
|
||||
combined.contains("not found") || combined.contains("no running instance")
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
fn navigate_main(app: &AppHandle, url: &str) {
|
||||
if let Some(win) = app.webview_windows().get("main") {
|
||||
let mut display = url.to_string();
|
||||
@@ -348,11 +404,19 @@ impl CliProcessManager {
|
||||
log_line(&format!("stopping CLI pid={}", child.id()));
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::kill(child.id() as i32, libc::SIGTERM);
|
||||
let pid = child.id() as i32;
|
||||
// Prefer signaling the process group to avoid orphaning children
|
||||
// when the CLI was launched via a wrapper shell.
|
||||
let group_res = libc::kill(-pid, libc::SIGTERM);
|
||||
if group_res != 0 {
|
||||
let _ = libc::kill(pid, libc::SIGTERM);
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = child.kill();
|
||||
if !kill_process_tree_windows(child.id(), false) {
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
@@ -368,11 +432,17 @@ impl CliProcessManager {
|
||||
));
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::kill(child.id() as i32, libc::SIGKILL);
|
||||
let pid = child.id() as i32;
|
||||
let group_res = libc::kill(-pid, libc::SIGKILL);
|
||||
if group_res != 0 {
|
||||
let _ = libc::kill(pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = child.kill();
|
||||
if !kill_process_tree_windows(child.id(), true) {
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -450,9 +520,12 @@ impl CliProcessManager {
|
||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
configure_spawn(&mut c);
|
||||
if let Some(ref cwd) = cwd {
|
||||
c.current_dir(cwd);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
configure_posix_process_group(&mut c);
|
||||
c.spawn()?
|
||||
}
|
||||
ShellCommandType::Direct(cmd) => {
|
||||
@@ -462,9 +535,12 @@ impl CliProcessManager {
|
||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
configure_spawn(&mut c);
|
||||
if let Some(ref cwd) = cwd {
|
||||
c.current_dir(cwd);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
configure_posix_process_group(&mut c);
|
||||
c.spawn()?
|
||||
}
|
||||
};
|
||||
@@ -537,7 +613,24 @@ impl CliProcessManager {
|
||||
locked.error = Some("CLI did not start in time".to_string());
|
||||
log_line("timeout waiting for CLI readiness");
|
||||
if let Some(child) = child_holder_clone.lock().as_mut() {
|
||||
let _ = child.kill();
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
let pid = child.id() as i32;
|
||||
let group_res = libc::kill(-pid, libc::SIGKILL);
|
||||
if group_res != 0 {
|
||||
let _ = libc::kill(pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if !kill_process_tree_windows(child.id(), true) {
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"}));
|
||||
Self::emit_status(&app_clone, &locked);
|
||||
|
||||
@@ -3,20 +3,52 @@
|
||||
mod cli_manager;
|
||||
|
||||
use cli_manager::{CliProcessManager, CliStatus};
|
||||
use keepawake::KeepAwake;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
||||
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
||||
use tauri::webview::Webview;
|
||||
use tauri::{AppHandle, Emitter, Manager, Runtime, Wry};
|
||||
use tauri::{AppHandle, Emitter, Manager, Runtime, WindowEvent, Wry};
|
||||
use tauri_plugin_global_shortcut::{
|
||||
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
|
||||
};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use url::Url;
|
||||
|
||||
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
|
||||
#[cfg(windows)]
|
||||
use std::ffi::OsStr;
|
||||
#[cfg(windows)]
|
||||
use std::iter;
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
#[cfg(windows)]
|
||||
use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
|
||||
|
||||
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
|
||||
const DEFAULT_ZOOM_LEVEL: f64 = 1.0;
|
||||
const ZOOM_STEP: f64 = 0.2;
|
||||
const MIN_ZOOM_LEVEL: f64 = 0.2;
|
||||
const MAX_ZOOM_LEVEL: f64 = 5.0;
|
||||
|
||||
#[cfg(windows)]
|
||||
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub manager: CliProcessManager,
|
||||
pub wake_lock: Mutex<Option<KeepAwake>>,
|
||||
pub zoom_level: Mutex<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
struct WakeLockConfig {
|
||||
display: bool,
|
||||
idle: bool,
|
||||
sleep: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -35,6 +67,39 @@ fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatu
|
||||
Ok(state.manager.status())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn wake_lock_start(
|
||||
state: tauri::State<AppState>,
|
||||
config: Option<WakeLockConfig>,
|
||||
) -> Result<(), String> {
|
||||
let config = config.unwrap_or(WakeLockConfig {
|
||||
display: true,
|
||||
idle: false,
|
||||
sleep: false,
|
||||
});
|
||||
|
||||
let mut builder = keepawake::Builder::default();
|
||||
builder
|
||||
.display(config.display)
|
||||
.idle(config.idle)
|
||||
.sleep(config.sleep)
|
||||
.reason("CodeNomad active session")
|
||||
.app_name("CodeNomad")
|
||||
.app_reverse_domain("ai.neuralnomads.codenomad.client");
|
||||
|
||||
let wake_lock = builder.create().map_err(|err| err.to_string())?;
|
||||
let mut state_lock = state.wake_lock.lock().map_err(|err| err.to_string())?;
|
||||
*state_lock = Some(wake_lock);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn wake_lock_stop(state: tauri::State<AppState>) -> Result<(), String> {
|
||||
let mut state_lock = state.wake_lock.lock().map_err(|err| err.to_string())?;
|
||||
state_lock.take();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_dev_mode() -> bool {
|
||||
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
|
||||
}
|
||||
@@ -101,6 +166,99 @@ fn emit_folder_drop_event(
|
||||
}
|
||||
}
|
||||
|
||||
fn clamp_zoom_level(value: f64) -> f64 {
|
||||
value.clamp(MIN_ZOOM_LEVEL, MAX_ZOOM_LEVEL)
|
||||
}
|
||||
|
||||
fn set_main_window_zoom(app_handle: &AppHandle, next_zoom: f64) {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let normalized = clamp_zoom_level(next_zoom);
|
||||
if window.set_zoom(normalized).is_ok() {
|
||||
if let Ok(mut zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
|
||||
*zoom_level = normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reload_main_window(app_handle: &AppHandle) {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.reload();
|
||||
}
|
||||
}
|
||||
|
||||
fn force_reload_main_window(app_handle: &AppHandle) {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
if let Ok(mut url) = window.url() {
|
||||
if should_allow_internal(&url) {
|
||||
let reload_token = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
.to_string();
|
||||
|
||||
let existing_pairs: Vec<(String, String)> = url
|
||||
.query_pairs()
|
||||
.into_owned()
|
||||
.filter(|(key, _)| key != "__codenomad_force_reload")
|
||||
.collect();
|
||||
|
||||
{
|
||||
let mut pairs = url.query_pairs_mut();
|
||||
pairs.clear();
|
||||
for (key, value) in existing_pairs {
|
||||
pairs.append_pair(&key, &value);
|
||||
}
|
||||
pairs.append_pair("__codenomad_force_reload", &reload_token);
|
||||
}
|
||||
|
||||
let _ = window.navigate(url);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let _ = window.reload();
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_fullscreen_window(app_handle: &AppHandle) {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let next_fullscreen = !window.is_fullscreen().unwrap_or(false);
|
||||
let _ = window.set_fullscreen(next_fullscreen);
|
||||
if cfg!(not(target_os = "macos")) {
|
||||
if next_fullscreen {
|
||||
let _ = window.hide_menu();
|
||||
} else {
|
||||
let _ = window.show_menu();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fullscreen_shortcut() -> Option<Shortcut> {
|
||||
if cfg!(target_os = "macos") {
|
||||
None
|
||||
} else {
|
||||
Some(Shortcut::new(None, ShortcutCode::F11))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn set_windows_app_user_model_id() {
|
||||
let app_id: Vec<u16> = OsStr::new(WINDOWS_APP_USER_MODEL_ID)
|
||||
.encode_wide()
|
||||
.chain(iter::once(0))
|
||||
.collect();
|
||||
|
||||
let result = unsafe { SetCurrentProcessExplicitAppUserModelID(app_id.as_ptr()) };
|
||||
if result < 0 {
|
||||
eprintln!("[tauri] failed to set AppUserModelID: {result}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn set_windows_app_user_model_id() {}
|
||||
|
||||
fn main() {
|
||||
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
|
||||
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
||||
@@ -109,14 +267,48 @@ fn main() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_keepawake::init())
|
||||
.plugin(
|
||||
tauri_plugin_global_shortcut::Builder::new()
|
||||
.with_handler(|app, shortcut, event| {
|
||||
if event.state() != ShortcutState::Pressed {
|
||||
return;
|
||||
}
|
||||
|
||||
if fullscreen_shortcut().as_ref() == Some(shortcut) {
|
||||
toggle_fullscreen_window(app);
|
||||
}
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(navigation_guard)
|
||||
.manage(AppState {
|
||||
manager: CliProcessManager::new(),
|
||||
wake_lock: Mutex::new(None),
|
||||
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
|
||||
})
|
||||
.setup(|app| {
|
||||
set_windows_app_user_model_id();
|
||||
build_menu(&app.handle())?;
|
||||
if let Some(shortcut) = fullscreen_shortcut() {
|
||||
let shortcut_manager = app.handle().global_shortcut();
|
||||
let _ = shortcut_manager.register(shortcut.clone());
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let app_handle = app.handle().clone();
|
||||
window.on_window_event(move |event| {
|
||||
if let WindowEvent::Focused(focused) = event {
|
||||
let shortcut_manager = app_handle.global_shortcut();
|
||||
if *focused {
|
||||
let _ = shortcut_manager.register(shortcut.clone());
|
||||
} else {
|
||||
let _ = shortcut_manager.unregister(shortcut.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let dev_mode = is_dev_mode();
|
||||
let app_handle = app.handle().clone();
|
||||
let manager = app.state::<AppState>().manager.clone();
|
||||
@@ -127,7 +319,12 @@ fn main() {
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![cli_get_status, cli_restart])
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
cli_get_status,
|
||||
cli_restart,
|
||||
wake_lock_start,
|
||||
wake_lock_stop
|
||||
])
|
||||
.on_menu_event(|app_handle, event| {
|
||||
match event.id().0.as_str() {
|
||||
// File menu
|
||||
@@ -136,36 +333,42 @@ fn main() {
|
||||
let _ = window.emit("menu:newInstance", ());
|
||||
}
|
||||
}
|
||||
"close" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.close();
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
app_handle.exit(0);
|
||||
}
|
||||
|
||||
// View menu
|
||||
"reload" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.eval("window.location.reload()");
|
||||
}
|
||||
reload_main_window(app_handle);
|
||||
}
|
||||
"force_reload" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.eval("window.location.reload(true)");
|
||||
}
|
||||
force_reload_main_window(app_handle);
|
||||
}
|
||||
"toggle_devtools" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
window.open_devtools();
|
||||
if window.is_devtools_open() {
|
||||
window.close_devtools();
|
||||
} else {
|
||||
window.open_devtools();
|
||||
}
|
||||
}
|
||||
}
|
||||
"reset_zoom" => {
|
||||
set_main_window_zoom(app_handle, DEFAULT_ZOOM_LEVEL);
|
||||
}
|
||||
"zoom_in" => {
|
||||
if let Ok(zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
|
||||
set_main_window_zoom(app_handle, *zoom_level + ZOOM_STEP);
|
||||
}
|
||||
}
|
||||
"zoom_out" => {
|
||||
if let Ok(zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
|
||||
set_main_window_zoom(app_handle, *zoom_level - ZOOM_STEP);
|
||||
}
|
||||
}
|
||||
|
||||
"toggle_fullscreen" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.set_fullscreen(!window.is_fullscreen().unwrap_or(false));
|
||||
}
|
||||
toggle_fullscreen_window(app_handle);
|
||||
}
|
||||
|
||||
// Window menu
|
||||
@@ -179,6 +382,11 @@ fn main() {
|
||||
let _ = window.maximize();
|
||||
}
|
||||
}
|
||||
"close_window" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.close();
|
||||
}
|
||||
}
|
||||
|
||||
// App menu (macOS)
|
||||
"about" => {
|
||||
@@ -266,6 +474,7 @@ fn main() {
|
||||
|
||||
fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
let is_mac = cfg!(target_os = "macos");
|
||||
let is_linux = cfg!(target_os = "linux");
|
||||
|
||||
// Create submenus
|
||||
let mut submenus = Vec::new();
|
||||
@@ -293,16 +502,74 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
Some("CmdOrCtrl+N"),
|
||||
)?;
|
||||
|
||||
let file_menu = SubmenuBuilder::new(app, "File")
|
||||
.item(&new_instance_item)
|
||||
.separator()
|
||||
.text(
|
||||
if is_mac { "close" } else { "quit" },
|
||||
if is_mac { "Close" } else { "Quit" },
|
||||
)
|
||||
.build()?;
|
||||
let file_menu = if is_mac {
|
||||
SubmenuBuilder::new(app, "File")
|
||||
.item(&new_instance_item)
|
||||
.separator()
|
||||
.close_window()
|
||||
.build()?
|
||||
} else {
|
||||
SubmenuBuilder::new(app, "File")
|
||||
.item(&new_instance_item)
|
||||
.separator()
|
||||
.text("quit", "Quit")
|
||||
.build()?
|
||||
};
|
||||
submenus.push(file_menu);
|
||||
|
||||
let reload_item = MenuItem::with_id(app, "reload", "Reload", true, Some("CmdOrCtrl+R"))?;
|
||||
let force_reload_item = MenuItem::with_id(
|
||||
app,
|
||||
"force_reload",
|
||||
"Force Reload",
|
||||
true,
|
||||
Some("CmdOrCtrl+Shift+R"),
|
||||
)?;
|
||||
let toggle_devtools_item = MenuItem::with_id(
|
||||
app,
|
||||
"toggle_devtools",
|
||||
"Toggle Developer Tools",
|
||||
true,
|
||||
Some("Alt+CmdOrCtrl+I"),
|
||||
)?;
|
||||
let reset_zoom_item =
|
||||
MenuItem::with_id(app, "reset_zoom", "Actual Size", true, Some("CmdOrCtrl+0"))?;
|
||||
let zoom_in_item = MenuItem::with_id(
|
||||
app,
|
||||
"zoom_in",
|
||||
if is_mac { "Zoom In" } else { "Zoom In\tCtrl++" },
|
||||
true,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let zoom_out_item = MenuItem::with_id(
|
||||
app,
|
||||
"zoom_out",
|
||||
if is_mac {
|
||||
"Zoom Out"
|
||||
} else {
|
||||
"Zoom Out\tCtrl+-"
|
||||
},
|
||||
true,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let toggle_fullscreen_item = MenuItem::with_id(
|
||||
app,
|
||||
"toggle_fullscreen",
|
||||
if is_mac {
|
||||
"Toggle Full Screen"
|
||||
} else {
|
||||
"Toggle Full Screen\tF11"
|
||||
},
|
||||
true,
|
||||
if is_mac {
|
||||
Some("Ctrl+Cmd+F")
|
||||
} else {
|
||||
None::<&str>
|
||||
},
|
||||
)?;
|
||||
let close_window_item =
|
||||
MenuItem::with_id(app, "close_window", "Close", true, Some("CmdOrCtrl+W"))?;
|
||||
|
||||
// Edit menu with predefined items for standard functionality
|
||||
let edit_menu = SubmenuBuilder::new(app, "Edit")
|
||||
.undo()
|
||||
@@ -318,20 +585,39 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
|
||||
// View menu
|
||||
let view_menu = SubmenuBuilder::new(app, "View")
|
||||
.text("reload", "Reload")
|
||||
.text("force_reload", "Force Reload")
|
||||
.text("toggle_devtools", "Toggle Developer Tools")
|
||||
.item(&reload_item)
|
||||
.item(&force_reload_item)
|
||||
.item(&toggle_devtools_item)
|
||||
.separator()
|
||||
.item(&reset_zoom_item)
|
||||
.item(&zoom_in_item)
|
||||
.item(&zoom_out_item)
|
||||
.separator()
|
||||
.text("toggle_fullscreen", "Toggle Full Screen")
|
||||
.item(&toggle_fullscreen_item)
|
||||
.build()?;
|
||||
submenus.push(view_menu);
|
||||
|
||||
// Window menu
|
||||
let window_menu = SubmenuBuilder::new(app, "Window")
|
||||
.text("minimize", "Minimize")
|
||||
.text("zoom", "Zoom")
|
||||
.build()?;
|
||||
let window_menu = if is_linux {
|
||||
SubmenuBuilder::new(app, "Window")
|
||||
.text("minimize", "Minimize")
|
||||
.text("zoom", "Zoom")
|
||||
.separator()
|
||||
.item(&close_window_item)
|
||||
.build()?
|
||||
} else if is_mac {
|
||||
SubmenuBuilder::new(app, "Window")
|
||||
.minimize()
|
||||
.maximize()
|
||||
.build()?
|
||||
} else {
|
||||
SubmenuBuilder::new(app, "Window")
|
||||
.minimize()
|
||||
.maximize()
|
||||
.separator()
|
||||
.close_window()
|
||||
.build()?
|
||||
};
|
||||
submenus.push(window_menu);
|
||||
|
||||
// Build the main menu with all submenus
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CodeNomad",
|
||||
"version": "0.1.0",
|
||||
"identifier": "ai.opencode.client",
|
||||
"version": "0.12.3",
|
||||
"identifier": "ai.neuralnomads.codenomad.client",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev:bootstrap",
|
||||
"beforeBuildCommand": "npm run bundle:server",
|
||||
|
||||
@@ -18,8 +18,10 @@
|
||||
"@suid/icons-material": "^0.9.0",
|
||||
"@suid/material": "^0.19.0",
|
||||
"@suid/system": "^0.14.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"ansi-sequence-parser": "^1.1.3",
|
||||
"debug": "^4.4.3",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
@@ -30,7 +32,7 @@
|
||||
"shiki": "^3.13.0",
|
||||
"solid-js": "^1.8.0",
|
||||
"solid-toast": "^0.5.0",
|
||||
"tauri-plugin-keepawake-api": "^0.1.0",
|
||||
"virtua": "^0.48.8",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -43,4 +45,4 @@
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vite-plugin-solid": "^2.10.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,8 @@ import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
||||
import InstanceShell from "./components/instance/instance-shell2"
|
||||
import { SettingsScreen } from "./components/settings-screen"
|
||||
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
||||
import { initMarkdown } from "./lib/markdown"
|
||||
import { initGithubStars } from "./stores/github-stars"
|
||||
|
||||
import { useTheme } from "./lib/theme"
|
||||
import { useCommands } from "./lib/hooks/use-commands"
|
||||
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
|
||||
import { getLogger } from "./lib/logger"
|
||||
@@ -59,7 +57,6 @@ import { openSettings } from "./stores/settings-screen"
|
||||
const log = getLogger("actions")
|
||||
|
||||
const App: Component = () => {
|
||||
const { isDark } = useTheme()
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
preferences,
|
||||
@@ -183,10 +180,6 @@ const App: Component = () => {
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
initReleaseNotifications()
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createSignal, onMount, Show, createEffect } from "solid-js"
|
||||
import type { Highlighter } from "shiki/bundle/full"
|
||||
import { useTheme } from "../lib/theme"
|
||||
import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
|
||||
import { getSharedHighlighter } from "../lib/markdown"
|
||||
import { escapeHtml } from "../lib/text-render-utils"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
|
||||
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
|
||||
import "@git-diff-view/solid/styles/diff-view-pure.css"
|
||||
import { disableCache } from "@git-diff-view/core"
|
||||
import type { DiffHighlighterLang } from "@git-diff-view/core"
|
||||
import { ErrorBoundary } from "solid-js"
|
||||
import { getLanguageFromPath } from "../lib/markdown"
|
||||
import { getLanguageFromPath } from "../lib/text-render-utils"
|
||||
import { normalizeDiffText } from "../lib/diff-utils"
|
||||
import { setCacheEntry } from "../lib/global-cache"
|
||||
import type { CacheEntryParams } from "../lib/global-cache"
|
||||
@@ -134,4 +135,4 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,11 @@ import { formatCompactCount } from "../lib/formatters"
|
||||
import { useI18n, type Locale } from "../lib/i18n"
|
||||
import { showAlertDialog } from "../stores/alerts"
|
||||
import { openSettings, settingsOpen } from "../stores/settings-screen"
|
||||
import { openExternalUrl } from "../lib/external-url"
|
||||
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad"
|
||||
const DISCORD_URL = "https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
||||
|
||||
|
||||
interface FolderSelectionViewProps {
|
||||
@@ -42,6 +45,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
{ value: "ru", label: "Русский" },
|
||||
{ value: "ja", label: "日本語" },
|
||||
{ value: "zh-Hans", label: "简体中文" },
|
||||
{ value: "he", label: "עברית" },
|
||||
]
|
||||
|
||||
const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0]
|
||||
@@ -232,11 +236,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
props.onSelectFolder(path, selectedBinary())
|
||||
}
|
||||
|
||||
const openExternalLink = (url: string) => {
|
||||
if (typeof window === "undefined") return
|
||||
window.open(url, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
|
||||
async function handleBrowse() {
|
||||
if (isLoading()) return
|
||||
setFocusMode("new")
|
||||
@@ -343,7 +342,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
|
||||
aria-busy={isLoading() ? "true" : "false"}
|
||||
>
|
||||
<div class="absolute top-4 left-6">
|
||||
<div class="absolute top-4" style="inset-inline-start: 1.5rem;">
|
||||
<Select<LanguageOption>
|
||||
value={selectedLanguageOption()}
|
||||
onChange={(value) => {
|
||||
@@ -387,7 +386,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</Select.Portal>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="absolute top-4 right-6 flex items-center gap-2">
|
||||
<div class="absolute top-4 flex items-center gap-2" style="inset-inline-end: 1.5rem;">
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
@@ -425,7 +424,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||
<div class="mt-3 flex justify-center gap-2">
|
||||
<a
|
||||
href="https://github.com/NeuralNomadsAI/CodeNomad"
|
||||
href={GITHUB_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
@@ -433,13 +432,13 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
title={t("folderSelection.links.github")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
||||
void openExternalUrl(GITHUB_URL, "folder-selection")
|
||||
}}
|
||||
>
|
||||
<GitHubMarkIcon class="w-4 h-4" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/NeuralNomadsAI/CodeNomad"
|
||||
href={GITHUB_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
|
||||
@@ -447,7 +446,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
title={t("folderSelection.links.githubStars")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
||||
void openExternalUrl(GITHUB_URL, "folder-selection")
|
||||
}}
|
||||
>
|
||||
<Star class="w-4 h-4" />
|
||||
@@ -456,7 +455,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</Show>
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
||||
href={DISCORD_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
@@ -464,9 +463,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
title={t("folderSelection.links.discord")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
openExternalLink(
|
||||
"https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945",
|
||||
)
|
||||
void openExternalUrl(DISCORD_URL, "folder-selection")
|
||||
}}
|
||||
>
|
||||
<DiscordSymbolIcon class="w-4 h-4" />
|
||||
|
||||
@@ -82,7 +82,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
<div class="panel-body space-y-3">
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("instanceInfo.labels.folder")}</div>
|
||||
<div class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
<div dir="ltr" class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
{currentInstance().folder}
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,7 +94,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||
{t("instanceInfo.labels.project")}
|
||||
</div>
|
||||
<div class="text-xs font-mono px-2 py-1.5 rounded border truncate bg-surface-secondary border-base text-primary">
|
||||
<div dir="ltr" class="text-xs font-mono px-2 py-1.5 rounded border truncate bg-surface-secondary border-base text-primary">
|
||||
{project().id}
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,7 +137,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||
{t("instanceInfo.labels.binaryPath")}
|
||||
</div>
|
||||
<div class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
||||
<div dir="ltr" class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
||||
{currentInstance().binaryPath}
|
||||
</div>
|
||||
</div>
|
||||
@@ -151,7 +151,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
<div class="space-y-1">
|
||||
<For each={environmentEntries()}>
|
||||
{([key, value]) => (
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
<div dir="ltr" class="flex items-center gap-2 px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
<span class="text-xs font-mono font-medium flex-1 text-primary" title={key}>
|
||||
{key}
|
||||
</span>
|
||||
|
||||
@@ -404,6 +404,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-sm font-medium text-primary whitespace-normal break-words transition-colors"
|
||||
dir="auto"
|
||||
classList={{
|
||||
"text-accent": isFocused(),
|
||||
}}
|
||||
|
||||
@@ -81,7 +81,8 @@ interface InstanceShellProps {
|
||||
}
|
||||
|
||||
const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
const isRTL = () => locale() === "he"
|
||||
|
||||
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
|
||||
const [rightDrawerWidth, setRightDrawerWidth] = createSignal(
|
||||
@@ -371,7 +372,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
sx={{
|
||||
width: `${sessionSidebarWidth()}px`,
|
||||
flexShrink: 0,
|
||||
borderRight: "1px solid var(--border-base)",
|
||||
borderInlineEnd: "1px solid var(--border-base)",
|
||||
backgroundColor: "var(--surface-secondary)",
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
@@ -413,7 +414,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const modalProps = container ? { container: container as Element } : undefined
|
||||
return (
|
||||
<Drawer
|
||||
anchor="left"
|
||||
anchor={isRTL() ? "right" : "left"}
|
||||
variant="temporary"
|
||||
open={leftOpen()}
|
||||
onClose={closeLeftDrawer}
|
||||
@@ -422,7 +423,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
"& .MuiDrawer-paper": {
|
||||
width: isPhoneLayout() ? "100vw" : `${sessionSidebarWidth()}px`,
|
||||
boxSizing: "border-box",
|
||||
borderRight: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
|
||||
borderInlineEnd: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
|
||||
backgroundColor: "var(--surface-secondary)",
|
||||
backgroundImage: "none",
|
||||
color: "var(--text-primary)",
|
||||
@@ -480,7 +481,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
sx={{
|
||||
width: `${rightDrawerWidth()}px`,
|
||||
flexShrink: 0,
|
||||
borderLeft: "1px solid var(--border-base)",
|
||||
borderInlineStart: "1px solid var(--border-base)",
|
||||
backgroundColor: "var(--surface-secondary)",
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
@@ -523,7 +524,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const modalProps = container ? { container: container as Element } : undefined
|
||||
return (
|
||||
<Drawer
|
||||
anchor="right"
|
||||
anchor={isRTL() ? "left" : "right"}
|
||||
variant="temporary"
|
||||
open={rightOpen()}
|
||||
onClose={closeRightDrawer}
|
||||
@@ -532,7 +533,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
"& .MuiDrawer-paper": {
|
||||
width: isPhoneLayout() ? "100vw" : `${rightDrawerWidth()}px`,
|
||||
boxSizing: "border-box",
|
||||
borderLeft: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
|
||||
borderInlineStart: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
|
||||
backgroundColor: "var(--surface-secondary)",
|
||||
backgroundImage: "none",
|
||||
color: "var(--text-primary)",
|
||||
@@ -742,7 +743,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
<Kbd shortcut="cmd+shift+p" />
|
||||
</span>
|
||||
|
||||
<div class="ml-auto flex items-center gap-3">
|
||||
<div class="ms-auto flex items-center gap-3">
|
||||
<div class="connection-status-meta flex items-center gap-3">
|
||||
<Show when={connectionStatus() === "connected"}>
|
||||
<span class="status-indicator connected">
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
Show,
|
||||
Suspense,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
lazy,
|
||||
onCleanup,
|
||||
type Accessor,
|
||||
type Component,
|
||||
@@ -20,11 +22,6 @@ import type { Session } from "../../../../types/session"
|
||||
import type { DrawerViewState } from "../types"
|
||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types"
|
||||
|
||||
import ChangesTab from "./tabs/ChangesTab"
|
||||
import FilesTab from "./tabs/FilesTab"
|
||||
import GitChangesTab from "./tabs/GitChangesTab"
|
||||
import StatusTab from "./tabs/StatusTab"
|
||||
|
||||
import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees"
|
||||
import { requestData } from "../../../../lib/opencode-api"
|
||||
import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse"
|
||||
@@ -49,6 +46,15 @@ import {
|
||||
readStoredRightPanelTab,
|
||||
} from "../storage"
|
||||
|
||||
const LazyChangesTab = lazy(() => import("./tabs/ChangesTab"))
|
||||
const LazyGitChangesTab = lazy(() => import("./tabs/GitChangesTab"))
|
||||
const LazyFilesTab = lazy(() => import("./tabs/FilesTab"))
|
||||
const LazyStatusTab = lazy(() => import("./tabs/StatusTab"))
|
||||
|
||||
function RightPanelTabFallback() {
|
||||
return <div class="flex-1 min-h-0" />
|
||||
}
|
||||
|
||||
interface RightPanelProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
|
||||
@@ -243,7 +249,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
const mode = activeSplitResize()
|
||||
if (!mode) return
|
||||
event.preventDefault()
|
||||
const delta = event.clientX - splitResizeStartX()
|
||||
const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl"
|
||||
const delta = (event.clientX - splitResizeStartX()) * (isRtl ? -1 : 1)
|
||||
const next = clampSplitWidth(splitResizeStartWidth() + delta)
|
||||
if (mode === "changes") setChangesSplitWidth(next)
|
||||
else if (mode === "git-changes") setGitChangesSplitWidth(next)
|
||||
@@ -266,7 +273,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
const touch = event.touches[0]
|
||||
if (!touch) return
|
||||
event.preventDefault()
|
||||
const delta = touch.clientX - splitResizeStartX()
|
||||
const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl"
|
||||
const delta = (touch.clientX - splitResizeStartX()) * (isRtl ? -1 : 1)
|
||||
const next = clampSplitWidth(splitResizeStartWidth() + delta)
|
||||
if (mode === "changes") setChangesSplitWidth(next)
|
||||
else if (mode === "git-changes") setGitChangesSplitWidth(next)
|
||||
@@ -565,6 +573,13 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
void loadBrowserEntries(browserPath())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (rightPanelTab() === "files") return
|
||||
setBrowserSelectedContent(null)
|
||||
setBrowserSelectedLoading(false)
|
||||
setBrowserSelectedError(null)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (rightPanelTab() !== "git-changes") return
|
||||
if (gitStatusLoading()) return
|
||||
@@ -572,6 +587,14 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
void loadGitStatus()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (rightPanelTab() === "git-changes") return
|
||||
setGitSelectedBefore(null)
|
||||
setGitSelectedAfter(null)
|
||||
setGitSelectedLoading(false)
|
||||
setGitSelectedError(null)
|
||||
})
|
||||
|
||||
const handleSelectChangesFile = (file: string, closeList: boolean) => {
|
||||
setSelectedFile(file)
|
||||
if (closeList) {
|
||||
@@ -738,101 +761,109 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<Show when={rightPanelTab() === "changes"}>
|
||||
<ChangesTab
|
||||
t={props.t}
|
||||
instanceId={props.instanceId}
|
||||
activeSessionId={props.activeSessionId}
|
||||
activeSessionDiffs={props.activeSessionDiffs}
|
||||
selectedFile={selectedFile}
|
||||
onSelectFile={handleSelectChangesFile}
|
||||
diffViewMode={diffViewMode}
|
||||
diffContextMode={diffContextMode}
|
||||
diffWordWrapMode={diffWordWrapMode}
|
||||
onViewModeChange={setDiffViewMode}
|
||||
onContextModeChange={setDiffContextMode}
|
||||
onWordWrapModeChange={setDiffWordWrapMode}
|
||||
listOpen={changesListOpen}
|
||||
onToggleList={toggleChangesList}
|
||||
splitWidth={changesSplitWidth}
|
||||
onResizeMouseDown={handleSplitResizeMouseDown("changes")}
|
||||
onResizeTouchStart={handleSplitResizeTouchStart("changes")}
|
||||
isPhoneLayout={props.isPhoneLayout}
|
||||
/>
|
||||
<Suspense fallback={<RightPanelTabFallback />}>
|
||||
<LazyChangesTab
|
||||
t={props.t}
|
||||
instanceId={props.instanceId}
|
||||
activeSessionId={props.activeSessionId}
|
||||
activeSessionDiffs={props.activeSessionDiffs}
|
||||
selectedFile={selectedFile}
|
||||
onSelectFile={handleSelectChangesFile}
|
||||
diffViewMode={diffViewMode}
|
||||
diffContextMode={diffContextMode}
|
||||
diffWordWrapMode={diffWordWrapMode}
|
||||
onViewModeChange={setDiffViewMode}
|
||||
onContextModeChange={setDiffContextMode}
|
||||
onWordWrapModeChange={setDiffWordWrapMode}
|
||||
listOpen={changesListOpen}
|
||||
onToggleList={toggleChangesList}
|
||||
splitWidth={changesSplitWidth}
|
||||
onResizeMouseDown={handleSplitResizeMouseDown("changes")}
|
||||
onResizeTouchStart={handleSplitResizeTouchStart("changes")}
|
||||
isPhoneLayout={props.isPhoneLayout}
|
||||
/>
|
||||
</Suspense>
|
||||
</Show>
|
||||
|
||||
<Show when={rightPanelTab() === "git-changes"}>
|
||||
<GitChangesTab
|
||||
t={props.t}
|
||||
activeSessionId={props.activeSessionId}
|
||||
entries={gitStatusEntries}
|
||||
statusLoading={gitStatusLoading}
|
||||
statusError={gitStatusError}
|
||||
selectedPath={gitSelectedPath}
|
||||
selectedLoading={gitSelectedLoading}
|
||||
selectedError={gitSelectedError}
|
||||
selectedBefore={gitSelectedBefore}
|
||||
selectedAfter={gitSelectedAfter}
|
||||
mostChangedPath={gitMostChangedPath}
|
||||
scopeKey={gitScopeKey}
|
||||
diffViewMode={diffViewMode}
|
||||
diffContextMode={diffContextMode}
|
||||
diffWordWrapMode={diffWordWrapMode}
|
||||
onViewModeChange={setDiffViewMode}
|
||||
onContextModeChange={setDiffContextMode}
|
||||
onWordWrapModeChange={setDiffWordWrapMode}
|
||||
onOpenFile={(path) => void openGitFile(path)}
|
||||
onRefresh={() => void refreshGitStatus()}
|
||||
listOpen={gitChangesListOpen}
|
||||
onToggleList={toggleGitList}
|
||||
splitWidth={gitChangesSplitWidth}
|
||||
onResizeMouseDown={handleSplitResizeMouseDown("git-changes")}
|
||||
onResizeTouchStart={handleSplitResizeTouchStart("git-changes")}
|
||||
isPhoneLayout={props.isPhoneLayout}
|
||||
/>
|
||||
<Suspense fallback={<RightPanelTabFallback />}>
|
||||
<LazyGitChangesTab
|
||||
t={props.t}
|
||||
activeSessionId={props.activeSessionId}
|
||||
entries={gitStatusEntries}
|
||||
statusLoading={gitStatusLoading}
|
||||
statusError={gitStatusError}
|
||||
selectedPath={gitSelectedPath}
|
||||
selectedLoading={gitSelectedLoading}
|
||||
selectedError={gitSelectedError}
|
||||
selectedBefore={gitSelectedBefore}
|
||||
selectedAfter={gitSelectedAfter}
|
||||
mostChangedPath={gitMostChangedPath}
|
||||
scopeKey={gitScopeKey}
|
||||
diffViewMode={diffViewMode}
|
||||
diffContextMode={diffContextMode}
|
||||
diffWordWrapMode={diffWordWrapMode}
|
||||
onViewModeChange={setDiffViewMode}
|
||||
onContextModeChange={setDiffContextMode}
|
||||
onWordWrapModeChange={setDiffWordWrapMode}
|
||||
onOpenFile={(path: string) => void openGitFile(path)}
|
||||
onRefresh={() => void refreshGitStatus()}
|
||||
listOpen={gitChangesListOpen}
|
||||
onToggleList={toggleGitList}
|
||||
splitWidth={gitChangesSplitWidth}
|
||||
onResizeMouseDown={handleSplitResizeMouseDown("git-changes")}
|
||||
onResizeTouchStart={handleSplitResizeTouchStart("git-changes")}
|
||||
isPhoneLayout={props.isPhoneLayout}
|
||||
/>
|
||||
</Suspense>
|
||||
</Show>
|
||||
|
||||
<Show when={rightPanelTab() === "files"}>
|
||||
<FilesTab
|
||||
t={props.t}
|
||||
browserPath={browserPath}
|
||||
browserEntries={browserEntries}
|
||||
browserLoading={browserLoading}
|
||||
browserError={browserError}
|
||||
browserSelectedPath={browserSelectedPath}
|
||||
browserSelectedContent={browserSelectedContent}
|
||||
browserSelectedLoading={browserSelectedLoading}
|
||||
browserSelectedError={browserSelectedError}
|
||||
parentPath={browserParentPath}
|
||||
scopeKey={browserScopeKey}
|
||||
onLoadEntries={(path) => void loadBrowserEntries(path)}
|
||||
onOpenFile={(path) => void openBrowserFile(path)}
|
||||
onRefresh={() => void refreshFilesTab()}
|
||||
listOpen={filesListOpen}
|
||||
onToggleList={toggleFilesList}
|
||||
splitWidth={filesSplitWidth}
|
||||
onResizeMouseDown={handleSplitResizeMouseDown("files")}
|
||||
onResizeTouchStart={handleSplitResizeTouchStart("files")}
|
||||
isPhoneLayout={props.isPhoneLayout}
|
||||
/>
|
||||
<Suspense fallback={<RightPanelTabFallback />}>
|
||||
<LazyFilesTab
|
||||
t={props.t}
|
||||
browserPath={browserPath}
|
||||
browserEntries={browserEntries}
|
||||
browserLoading={browserLoading}
|
||||
browserError={browserError}
|
||||
browserSelectedPath={browserSelectedPath}
|
||||
browserSelectedContent={browserSelectedContent}
|
||||
browserSelectedLoading={browserSelectedLoading}
|
||||
browserSelectedError={browserSelectedError}
|
||||
parentPath={browserParentPath}
|
||||
scopeKey={browserScopeKey}
|
||||
onLoadEntries={(path: string) => void loadBrowserEntries(path)}
|
||||
onOpenFile={(path: string) => void openBrowserFile(path)}
|
||||
onRefresh={() => void refreshFilesTab()}
|
||||
listOpen={filesListOpen}
|
||||
onToggleList={toggleFilesList}
|
||||
splitWidth={filesSplitWidth}
|
||||
onResizeMouseDown={handleSplitResizeMouseDown("files")}
|
||||
onResizeTouchStart={handleSplitResizeTouchStart("files")}
|
||||
isPhoneLayout={props.isPhoneLayout}
|
||||
/>
|
||||
</Suspense>
|
||||
</Show>
|
||||
|
||||
<Show when={rightPanelTab() === "status"}>
|
||||
<StatusTab
|
||||
t={props.t}
|
||||
instanceId={props.instanceId}
|
||||
instance={props.instance}
|
||||
activeSessionId={props.activeSessionId}
|
||||
activeSession={props.activeSession}
|
||||
activeSessionDiffs={props.activeSessionDiffs}
|
||||
latestTodoState={props.latestTodoState}
|
||||
backgroundProcessList={props.backgroundProcessList}
|
||||
onOpenBackgroundOutput={props.onOpenBackgroundOutput}
|
||||
onStopBackgroundProcess={props.onStopBackgroundProcess}
|
||||
onTerminateBackgroundProcess={props.onTerminateBackgroundProcess}
|
||||
expandedItems={rightPanelExpandedItems}
|
||||
onExpandedItemsChange={handleAccordionChange}
|
||||
onOpenChangesTab={openChangesTabFromStatus}
|
||||
/>
|
||||
<Suspense fallback={<RightPanelTabFallback />}>
|
||||
<LazyStatusTab
|
||||
t={props.t}
|
||||
instanceId={props.instanceId}
|
||||
instance={props.instance}
|
||||
activeSessionId={props.activeSessionId}
|
||||
activeSession={props.activeSession}
|
||||
activeSessionDiffs={props.activeSessionDiffs}
|
||||
latestTodoState={props.latestTodoState}
|
||||
backgroundProcessList={props.backgroundProcessList}
|
||||
onOpenBackgroundOutput={props.onOpenBackgroundOutput}
|
||||
onStopBackgroundProcess={props.onStopBackgroundProcess}
|
||||
onTerminateBackgroundProcess={props.onTerminateBackgroundProcess}
|
||||
expandedItems={rightPanelExpandedItems}
|
||||
onExpandedItemsChange={handleAccordionChange}
|
||||
onOpenChangesTab={openChangesTabFromStatus}
|
||||
/>
|
||||
</Suspense>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Component } from "solid-js"
|
||||
|
||||
import { AlignJustify, FoldVertical, Split, UnfoldVertical, WrapText } from "lucide-solid"
|
||||
|
||||
import { useI18n } from "../../../../../lib/i18n"
|
||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
||||
|
||||
interface DiffToolbarProps {
|
||||
@@ -14,14 +15,15 @@ interface DiffToolbarProps {
|
||||
}
|
||||
|
||||
const DiffToolbar: Component<DiffToolbarProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const nextViewMode = (): DiffViewMode => (props.viewMode === "split" ? "unified" : "split")
|
||||
const nextContextMode = (): DiffContextMode => (props.contextMode === "collapsed" ? "expanded" : "collapsed")
|
||||
const nextWordWrapMode = (): DiffWordWrapMode => (props.wordWrapMode === "on" ? "off" : "on")
|
||||
|
||||
const viewModeTitle = () => (nextViewMode() === "split" ? "Switch to split view" : "Switch to unified view")
|
||||
const viewModeTitle = () => (nextViewMode() === "split" ? t("instanceShell.diff.switchToSplit") : t("instanceShell.diff.switchToUnified"))
|
||||
const contextModeTitle = () =>
|
||||
nextContextMode() === "collapsed" ? "Hide unchanged regions" : "Show full file"
|
||||
const wordWrapTitle = () => (nextWordWrapMode() === "on" ? "Enable word wrap" : "Disable word wrap")
|
||||
nextContextMode() === "collapsed" ? t("instanceShell.diff.hideUnchanged") : t("instanceShell.diff.showFull")
|
||||
const wordWrapTitle = () => (nextWordWrapMode() === "on" ? t("instanceShell.diff.enableWordWrap") : t("instanceShell.diff.disableWordWrap"))
|
||||
|
||||
return (
|
||||
<div class="file-viewer-toolbar">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Show, type Component, type JSX } from "solid-js"
|
||||
|
||||
import { useI18n } from "../../../../../lib/i18n"
|
||||
import OverlayList from "./OverlayList"
|
||||
|
||||
type SplitFilePanelList = {
|
||||
@@ -24,12 +25,13 @@ interface SplitFilePanelProps {
|
||||
}
|
||||
|
||||
const SplitFilePanel: Component<SplitFilePanelProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
return (
|
||||
<div class="files-tab-container">
|
||||
<div class="files-tab-header">
|
||||
<div class="files-tab-header-row">
|
||||
<button type="button" class="files-toggle-button" onClick={props.onToggleList}>
|
||||
{props.listOpen ? "Hide files" : "Show files"}
|
||||
{props.listOpen ? t("instanceShell.filesShell.hideFiles") : t("instanceShell.filesShell.showFiles")}
|
||||
</button>
|
||||
|
||||
{props.header}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { For, Show, createMemo, type Accessor, type Component, type JSX } from "solid-js"
|
||||
|
||||
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
|
||||
import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
||||
|
||||
import DiffToolbar from "../components/DiffToolbar"
|
||||
import SplitFilePanel from "../components/SplitFilePanel"
|
||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
||||
|
||||
const LazyMonacoDiffViewer = lazy(() =>
|
||||
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
|
||||
)
|
||||
|
||||
interface ChangesTabProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
|
||||
@@ -113,15 +115,23 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
||||
}
|
||||
>
|
||||
{(file) => (
|
||||
<MonacoDiffViewer
|
||||
scopeKey={scopeKey()}
|
||||
path={String(file().file || "")}
|
||||
before={String((file() as any).before || "")}
|
||||
after={String((file() as any).after || "")}
|
||||
viewMode={props.diffViewMode()}
|
||||
contextMode={props.diffContextMode()}
|
||||
wordWrap={props.diffWordWrapMode()}
|
||||
/>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LazyMonacoDiffViewer
|
||||
scopeKey={scopeKey()}
|
||||
path={String(file().file || "")}
|
||||
before={String((file() as any).before || "")}
|
||||
after={String((file() as any).after || "")}
|
||||
viewMode={props.diffViewMode()}
|
||||
contextMode={props.diffContextMode()}
|
||||
wordWrap={props.diffWordWrapMode()}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
@@ -220,7 +230,7 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
||||
onResizeMouseDown={props.onResizeMouseDown}
|
||||
onResizeTouchStart={props.onResizeTouchStart}
|
||||
isPhoneLayout={props.isPhoneLayout()}
|
||||
overlayAriaLabel="Changes"
|
||||
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.changes")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
|
||||
import { For, Show, Suspense, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
import { RefreshCw } from "lucide-solid"
|
||||
|
||||
import { MonacoFileViewer } from "../../../../file-viewer/monaco-file-viewer"
|
||||
|
||||
import SplitFilePanel from "../components/SplitFilePanel"
|
||||
|
||||
const LazyMonacoFileViewer = lazy(() =>
|
||||
import("../../../../file-viewer/monaco-file-viewer").then((module) => ({ default: module.MonacoFileViewer })),
|
||||
)
|
||||
|
||||
interface FilesTabProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
|
||||
@@ -51,8 +53,8 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath()
|
||||
|
||||
const emptyViewerMessage = () => {
|
||||
if (props.browserLoading() && entriesValue === null) return "Loading files..."
|
||||
return "Select a file to preview"
|
||||
if (props.browserLoading() && entriesValue === null) return props.t("instanceInfo.loading")
|
||||
return props.t("instanceShell.filesShell.viewerEmpty")
|
||||
}
|
||||
|
||||
const renderViewer = () => (
|
||||
@@ -77,7 +79,15 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
}
|
||||
>
|
||||
{(payload) => (
|
||||
<MonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LazyMonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
|
||||
</Suspense>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
@@ -91,7 +101,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
}
|
||||
>
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">Loading…</span>
|
||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -113,7 +123,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
</Show>
|
||||
|
||||
<Show when={props.browserLoading() && entriesValue === null}>
|
||||
<div class="p-3 text-xs text-secondary">Loading files...</div>
|
||||
<div class="p-3 text-xs text-secondary">{props.t("instanceInfo.loading")}</div>
|
||||
</Show>
|
||||
|
||||
<For each={sorted}>
|
||||
@@ -154,7 +164,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
</span>
|
||||
</span>
|
||||
<Show when={props.browserLoading()}>
|
||||
<span>Loading…</span>
|
||||
<span>{props.t("instanceInfo.loading")}</span>
|
||||
</Show>
|
||||
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
||||
</div>
|
||||
@@ -165,7 +175,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
title={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||
disabled={props.browserLoading()}
|
||||
style={{ "margin-left": "auto" }}
|
||||
style={{ "margin-inline-start": "auto" }}
|
||||
onClick={() => props.onRefresh()}
|
||||
>
|
||||
<RefreshCw class={`h-4 w-4${props.browserLoading() ? " animate-spin" : ""}`} />
|
||||
@@ -180,7 +190,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
onResizeMouseDown={props.onResizeMouseDown}
|
||||
onResizeTouchStart={props.onResizeTouchStart}
|
||||
isPhoneLayout={props.isPhoneLayout()}
|
||||
overlayAriaLabel="Files"
|
||||
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.files")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { For, Show, createMemo, type Accessor, type Component, type JSX } from "solid-js"
|
||||
import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
||||
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
import { RefreshCw } from "lucide-solid"
|
||||
|
||||
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
|
||||
|
||||
import DiffToolbar from "../components/DiffToolbar"
|
||||
import SplitFilePanel from "../components/SplitFilePanel"
|
||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
||||
|
||||
const LazyMonacoDiffViewer = lazy(() =>
|
||||
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
|
||||
)
|
||||
|
||||
interface GitChangesTabProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
|
||||
@@ -80,11 +82,11 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
})
|
||||
|
||||
const emptyViewerMessage = createMemo(() => {
|
||||
if (!hasSession()) return "Select a session to view changes."
|
||||
if (!hasSession()) return props.t("instanceShell.gitChanges.noSessionSelected")
|
||||
const currentEntries = entries()
|
||||
if (currentEntries === null) return "Loading git changes…"
|
||||
if (nonDeleted().length === 0) return "No git changes yet."
|
||||
return "No file selected."
|
||||
if (currentEntries === null) return props.t("instanceShell.gitChanges.loading")
|
||||
if (nonDeleted().length === 0) return props.t("instanceShell.gitChanges.empty")
|
||||
return props.t("instanceShell.filesShell.viewerEmpty")
|
||||
})
|
||||
|
||||
const renderContent = (): JSX.Element => {
|
||||
@@ -122,7 +124,14 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
}
|
||||
>
|
||||
{(file) => (
|
||||
<MonacoDiffViewer
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LazyMonacoDiffViewer
|
||||
scopeKey={props.scopeKey()}
|
||||
path={String(file().path || "")}
|
||||
before={String((file() as any).before || "")}
|
||||
@@ -131,7 +140,8 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
contextMode={props.diffContextMode()}
|
||||
wordWrap={props.diffWordWrapMode()}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
@@ -144,7 +154,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
}
|
||||
>
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">Loading…</span>
|
||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -169,7 +179,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
</div>
|
||||
<div class="file-list-item-stats">
|
||||
<Show when={item.status === "deleted"}>
|
||||
<span class="text-[10px] text-secondary">deleted</span>
|
||||
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
|
||||
</Show>
|
||||
<Show when={item.status !== "deleted"}>
|
||||
<>
|
||||
@@ -200,7 +210,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
</div>
|
||||
<div class="file-list-item-stats">
|
||||
<Show when={item.status === "deleted"}>
|
||||
<span class="text-[10px] text-secondary">deleted</span>
|
||||
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
|
||||
</Show>
|
||||
<Show when={item.status !== "deleted"}>
|
||||
<>
|
||||
@@ -220,8 +230,8 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
<SplitFilePanel
|
||||
header={
|
||||
<>
|
||||
<span class="files-tab-selected-path" title={selected?.path || "Git Changes"}>
|
||||
<span class="file-path-text">{selected?.path || "Git Changes"}</span>
|
||||
<span class="files-tab-selected-path" title={selected?.path || props.t("instanceShell.rightPanel.tabs.gitChanges")}>
|
||||
<span class="file-path-text">{selected?.path || props.t("instanceShell.rightPanel.tabs.gitChanges")}</span>
|
||||
</span>
|
||||
|
||||
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
|
||||
@@ -264,7 +274,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
onResizeMouseDown={props.onResizeMouseDown}
|
||||
onResizeTouchStart={props.onResizeTouchStart}
|
||||
isPhoneLayout={props.isPhoneLayout()}
|
||||
overlayAriaLabel="Git Changes"
|
||||
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.gitChanges")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,7 +46,9 @@ export function useDrawerResize(options: DrawerResizeOptions): DrawerResizeApi {
|
||||
if (!side) return
|
||||
const startWidth = resizeStartWidth()
|
||||
const clamp = side === "left" ? options.clampLeft : options.clampRight
|
||||
const delta = side === "left" ? clientX - resizeStartX() : resizeStartX() - clientX
|
||||
const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl"
|
||||
const rawDelta = side === "left" ? clientX - resizeStartX() : resizeStartX() - clientX
|
||||
const delta = isRtl ? -rawDelta : rawDelta
|
||||
const nextWidth = clamp(startWidth + delta)
|
||||
applyDrawerWidth(side, nextWidth)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { renderMarkdown, onLanguagesLoaded, decodeHtmlEntities, setMarkdownTheme } from "../lib/markdown"
|
||||
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
||||
import type { TextPart, RenderCache } from "../types/message"
|
||||
import { getLogger } from "../lib/logger"
|
||||
@@ -8,6 +7,20 @@ import { useI18n } from "../lib/i18n"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
type MarkdownModule = typeof import("../lib/markdown")
|
||||
|
||||
let markdownModulePromise: Promise<MarkdownModule> | null = null
|
||||
|
||||
function loadMarkdownModule(): Promise<MarkdownModule> {
|
||||
if (!markdownModulePromise) {
|
||||
markdownModulePromise = import("../lib/markdown").catch((error) => {
|
||||
markdownModulePromise = null
|
||||
throw error
|
||||
})
|
||||
}
|
||||
return markdownModulePromise
|
||||
}
|
||||
|
||||
function hashText(value: string): string {
|
||||
let hash = 2166136261
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
@@ -24,6 +37,45 @@ function resolvePartVersion(part: TextPart, text: string): string {
|
||||
return `text-${hashText(text)}`
|
||||
}
|
||||
|
||||
function resolvePartCacheId(part: TextPart, text: string): string {
|
||||
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
|
||||
if (partId) {
|
||||
return partId
|
||||
}
|
||||
|
||||
return `anonymous:${hashText(text)}`
|
||||
}
|
||||
|
||||
function decodeHtmlEntitiesLocally(content: string): string {
|
||||
if (!content.includes("&") || typeof document === "undefined") {
|
||||
return content
|
||||
}
|
||||
|
||||
const textarea = document.createElement("textarea")
|
||||
textarea.innerHTML = content
|
||||
return textarea.value
|
||||
}
|
||||
|
||||
function escapeHtml(content: string): string {
|
||||
const map: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
}
|
||||
|
||||
return content.replace(/[&<>"']/g, (match) => map[match] ?? match)
|
||||
}
|
||||
|
||||
function renderFallbackHtml(content: string): string {
|
||||
if (!content) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return escapeHtml(content).replace(/\n/g, "<br />")
|
||||
}
|
||||
|
||||
interface MarkdownProps {
|
||||
part: TextPart
|
||||
instanceId?: string
|
||||
@@ -38,7 +90,8 @@ export function Markdown(props: MarkdownProps) {
|
||||
const { t } = useI18n()
|
||||
const [html, setHtml] = createSignal("")
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
let latestRequestedText = ""
|
||||
let latestRequestKey = ""
|
||||
let cleanupLanguageListener: (() => void) | undefined
|
||||
|
||||
const notifyRendered = () => {
|
||||
Promise.resolve().then(() => props.onRendered?.())
|
||||
@@ -47,15 +100,14 @@ export function Markdown(props: MarkdownProps) {
|
||||
const resolved = createMemo(() => {
|
||||
const part = props.part
|
||||
const rawText = typeof part.text === "string" ? part.text : ""
|
||||
const text = decodeHtmlEntities(rawText)
|
||||
const text = decodeHtmlEntitiesLocally(rawText)
|
||||
const themeKey = Boolean(props.isDark) ? "dark" : "light"
|
||||
const highlightEnabled = !props.disableHighlight
|
||||
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
|
||||
if (!partId) {
|
||||
throw new Error("Markdown rendering requires a part id")
|
||||
}
|
||||
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined
|
||||
const cacheId = resolvePartCacheId(part, text)
|
||||
const version = resolvePartVersion(part, text)
|
||||
return { part, text, themeKey, highlightEnabled, partId, version }
|
||||
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${version}`
|
||||
return { part, text, themeKey, highlightEnabled, partId, cacheId, version, requestKey }
|
||||
})
|
||||
|
||||
const cacheHandle = useGlobalCache({
|
||||
@@ -63,26 +115,46 @@ export function Markdown(props: MarkdownProps) {
|
||||
sessionId: () => props.sessionId,
|
||||
scope: "markdown",
|
||||
cacheId: () => {
|
||||
const { partId, themeKey, highlightEnabled } = resolved()
|
||||
return `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}`
|
||||
const { cacheId, themeKey, highlightEnabled } = resolved()
|
||||
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}`
|
||||
},
|
||||
version: () => resolved().version,
|
||||
})
|
||||
|
||||
createEffect(async () => {
|
||||
const { part, text, themeKey, highlightEnabled, version } = resolved()
|
||||
const commitCacheEntry = (snapshot: ReturnType<typeof resolved>, renderedHtml: string) => {
|
||||
const cacheEntry: RenderCache = {
|
||||
text: snapshot.text,
|
||||
html: renderedHtml,
|
||||
theme: snapshot.themeKey,
|
||||
mode: snapshot.version,
|
||||
}
|
||||
setHtml(renderedHtml)
|
||||
cacheHandle.set(cacheEntry)
|
||||
notifyRendered()
|
||||
}
|
||||
|
||||
// Ensure the markdown highlighter theme matches the active UI theme.
|
||||
setMarkdownTheme(themeKey === "dark")
|
||||
const renderSnapshot = async (snapshot: ReturnType<typeof resolved>) => {
|
||||
const markdown = await loadMarkdownModule()
|
||||
markdown.setMarkdownTheme(snapshot.themeKey === "dark")
|
||||
const rendered = await markdown.renderMarkdown(snapshot.text, {
|
||||
suppressHighlight: !snapshot.highlightEnabled,
|
||||
})
|
||||
|
||||
latestRequestedText = text
|
||||
if (latestRequestKey === snapshot.requestKey) {
|
||||
commitCacheEntry(snapshot, rendered)
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const snapshot = resolved()
|
||||
latestRequestKey = snapshot.requestKey
|
||||
|
||||
const cacheMatches = (cache: RenderCache | undefined) => {
|
||||
if (!cache) return false
|
||||
return cache.theme === themeKey && cache.mode === version
|
||||
return cache.theme === snapshot.themeKey && cache.mode === snapshot.version
|
||||
}
|
||||
|
||||
const localCache = part.renderCache
|
||||
const localCache = snapshot.part.renderCache
|
||||
if (localCache && cacheMatches(localCache)) {
|
||||
setHtml(localCache.html)
|
||||
notifyRendered()
|
||||
@@ -96,111 +168,83 @@ export function Markdown(props: MarkdownProps) {
|
||||
return
|
||||
}
|
||||
|
||||
const commitCacheEntry = (renderedHtml: string) => {
|
||||
const cacheEntry: RenderCache = { text, html: renderedHtml, theme: themeKey, mode: version }
|
||||
setHtml(renderedHtml)
|
||||
cacheHandle.set(cacheEntry)
|
||||
notifyRendered()
|
||||
}
|
||||
setHtml(renderFallbackHtml(snapshot.text))
|
||||
notifyRendered()
|
||||
|
||||
if (!highlightEnabled) {
|
||||
try {
|
||||
const rendered = await renderMarkdown(text, { suppressHighlight: true })
|
||||
|
||||
if (latestRequestedText === text) {
|
||||
commitCacheEntry(rendered)
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to render markdown:", error)
|
||||
if (latestRequestedText === text) {
|
||||
commitCacheEntry(text)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const rendered = await renderMarkdown(text)
|
||||
if (latestRequestedText === text) {
|
||||
commitCacheEntry(rendered)
|
||||
}
|
||||
} catch (error) {
|
||||
void renderSnapshot(snapshot).catch((error) => {
|
||||
log.error("Failed to render markdown:", error)
|
||||
if (latestRequestedText === text) {
|
||||
commitCacheEntry(text)
|
||||
if (latestRequestKey === snapshot.requestKey) {
|
||||
commitCacheEntry(snapshot, renderFallbackHtml(snapshot.text))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const handleClick = async (e: Event) => {
|
||||
const target = e.target as HTMLElement
|
||||
const handleClick = async (event: Event) => {
|
||||
const target = event.target as HTMLElement
|
||||
const copyButton = target.closest(".code-block-copy") as HTMLButtonElement
|
||||
|
||||
if (copyButton) {
|
||||
e.preventDefault()
|
||||
const code = copyButton.getAttribute("data-code")
|
||||
if (code) {
|
||||
const decodedCode = decodeURIComponent(code)
|
||||
const success = await copyToClipboard(decodedCode)
|
||||
const copyText = copyButton.querySelector(".copy-text")
|
||||
if (copyText) {
|
||||
if (success) {
|
||||
copyText.textContent = t("markdown.codeBlock.copy.copied")
|
||||
setTimeout(() => {
|
||||
copyText.textContent = t("markdown.codeBlock.copy.label")
|
||||
}, 2000)
|
||||
} else {
|
||||
copyText.textContent = t("markdown.codeBlock.copy.failed")
|
||||
setTimeout(() => {
|
||||
copyText.textContent = t("markdown.codeBlock.copy.label")
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!copyButton) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
const code = copyButton.getAttribute("data-code")
|
||||
if (!code) {
|
||||
return
|
||||
}
|
||||
|
||||
const decodedCode = decodeURIComponent(code)
|
||||
const success = await copyToClipboard(decodedCode)
|
||||
const copyText = copyButton.querySelector(".copy-text")
|
||||
if (!copyText) {
|
||||
return
|
||||
}
|
||||
|
||||
copyText.textContent = success ? t("markdown.codeBlock.copy.copied") : t("markdown.codeBlock.copy.failed")
|
||||
setTimeout(() => {
|
||||
copyText.textContent = t("markdown.codeBlock.copy.label")
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
containerRef?.addEventListener("click", handleClick)
|
||||
|
||||
const cleanupLanguageListener = onLanguagesLoaded(async () => {
|
||||
if (props.disableHighlight) {
|
||||
return
|
||||
}
|
||||
|
||||
const { part, text, themeKey, version } = resolved()
|
||||
|
||||
setMarkdownTheme(themeKey === "dark")
|
||||
|
||||
if (latestRequestedText !== text) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const rendered = await renderMarkdown(text)
|
||||
if (latestRequestedText === text) {
|
||||
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: version }
|
||||
setHtml(rendered)
|
||||
cacheHandle.set(cacheEntry)
|
||||
notifyRendered()
|
||||
let disposed = false
|
||||
void loadMarkdownModule()
|
||||
.then((markdown) => {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to re-render markdown after language load:", error)
|
||||
}
|
||||
})
|
||||
|
||||
cleanupLanguageListener = markdown.onLanguagesLoaded(() => {
|
||||
const snapshot = resolved()
|
||||
if (!snapshot.highlightEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
latestRequestKey = snapshot.requestKey
|
||||
void renderSnapshot(snapshot).catch((error) => {
|
||||
log.error("Failed to re-render markdown after language load:", error)
|
||||
})
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("Failed to load markdown module:", error)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
containerRef?.removeEventListener("click", handleClick)
|
||||
cleanupLanguageListener()
|
||||
cleanupLanguageListener?.()
|
||||
cleanupLanguageListener = undefined
|
||||
})
|
||||
})
|
||||
|
||||
const proseClass = () => "markdown-body"
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
class={proseClass()}
|
||||
class="markdown-body"
|
||||
dir="auto"
|
||||
data-view="markdown"
|
||||
data-part-id={resolved().partId}
|
||||
data-markdown-theme={resolved().themeKey}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
|
||||
import { For, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack } from "solid-js"
|
||||
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid"
|
||||
import MessageItem from "./message-item"
|
||||
import ToolCall from "./tool-call"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
import type { ClientPart, MessageInfo } from "../types/message"
|
||||
import { partHasRenderableText } from "../types/message"
|
||||
@@ -29,6 +28,12 @@ const USER_BORDER_COLOR = "var(--message-user-border)"
|
||||
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
|
||||
const TOOL_BORDER_COLOR = "var(--message-tool-border)"
|
||||
|
||||
const LazyToolCall = lazy(() => import("./tool-call"))
|
||||
|
||||
function ToolCallFallback() {
|
||||
return <div class="tool-call tool-call-loading" />
|
||||
}
|
||||
|
||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
|
||||
|
||||
@@ -500,16 +505,18 @@ function ToolCallItem(props: ToolCallItemProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ToolCall
|
||||
toolCall={resolvedToolPart()}
|
||||
toolCallId={props.partId}
|
||||
messageId={props.messageId}
|
||||
messageVersion={messageVersion()}
|
||||
partVersion={partVersion()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
<Suspense fallback={<ToolCallFallback />}>
|
||||
<LazyToolCall
|
||||
toolCall={resolvedToolPart()}
|
||||
toolCallId={props.partId}
|
||||
messageId={props.messageId}
|
||||
messageVersion={messageVersion()}
|
||||
partVersion={partVersion()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
@@ -902,6 +909,7 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
selectedMessageIds={props.selectedMessageIds}
|
||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
@@ -1280,6 +1288,7 @@ interface ReasoningCardProps {
|
||||
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||
selectedMessageIds?: () => Set<string>
|
||||
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
||||
onContentRendered?: () => void
|
||||
}
|
||||
|
||||
function ReasoningCard(props: ReasoningCardProps) {
|
||||
@@ -1288,6 +1297,25 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
||||
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
||||
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
||||
let pendingRenderNotificationFrame: number | null = null
|
||||
|
||||
const notifyContentRendered = () => {
|
||||
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
|
||||
if (pendingRenderNotificationFrame !== null) {
|
||||
cancelAnimationFrame(pendingRenderNotificationFrame)
|
||||
}
|
||||
pendingRenderNotificationFrame = requestAnimationFrame(() => {
|
||||
pendingRenderNotificationFrame = null
|
||||
props.onContentRendered?.()
|
||||
})
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (pendingRenderNotificationFrame !== null) {
|
||||
cancelAnimationFrame(pendingRenderNotificationFrame)
|
||||
pendingRenderNotificationFrame = null
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
setExpanded(Boolean(props.defaultExpanded))
|
||||
@@ -1356,6 +1384,12 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
const viewHideLabel = () =>
|
||||
expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")
|
||||
|
||||
createEffect(() => {
|
||||
if (!expanded()) return
|
||||
reasoningText()
|
||||
notifyContentRendered()
|
||||
})
|
||||
|
||||
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
|
||||
|
||||
const handleDeleteMessage = async (event: MouseEvent) => {
|
||||
@@ -1497,7 +1531,7 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
<div class="message-reasoning-expanded">
|
||||
<div class="message-reasoning-body">
|
||||
<div class="message-reasoning-output" role="region" aria-label={t("messageBlock.reasoning.detailsAriaLabel")}>
|
||||
<pre class="message-reasoning-text">{reasoningText() || ""}</pre>
|
||||
<pre class="message-reasoning-text" dir="auto">{reasoningText() || ""}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -542,7 +542,7 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
|
||||
</header>
|
||||
|
||||
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]">
|
||||
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]" dir="auto">
|
||||
|
||||
|
||||
<Show when={props.isQueued && isUser()}>
|
||||
@@ -550,7 +550,7 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
</Show>
|
||||
|
||||
<Show when={errorMessage()}>
|
||||
<div class="message-error-block">⚠️ {errorMessage()}</div>
|
||||
<div class="message-error-block" dir="auto">⚠️ {errorMessage()}</div>
|
||||
</Show>
|
||||
|
||||
<Show when={isGenerating()}>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Show, Match, Switch } from "solid-js"
|
||||
import ToolCall from "./tool-call"
|
||||
import { Match, Show, Suspense, Switch, lazy } from "solid-js"
|
||||
import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state"
|
||||
import { Markdown } from "./markdown"
|
||||
import { useTheme } from "../lib/theme"
|
||||
@@ -7,6 +6,8 @@ import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/m
|
||||
|
||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
|
||||
const LazyToolCall = lazy(() => import("./tool-call"))
|
||||
|
||||
interface MessagePartProps {
|
||||
part: ClientPart
|
||||
messageType?: "user" | "assistant"
|
||||
@@ -133,11 +134,12 @@ export default function MessagePart(props: MessagePartProps) {
|
||||
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
|
||||
<div
|
||||
class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()}
|
||||
dir="auto"
|
||||
data-role={textContainerRole()}
|
||||
data-part-type="text"
|
||||
data-part-id={typeof (props.part as any)?.id === "string" ? (props.part as any).id : undefined}
|
||||
>
|
||||
<Show when={canRenderMarkdown()} fallback={<span class="text-primary">{plainTextContent()}</span>}>
|
||||
<Show when={canRenderMarkdown()} fallback={<span class="text-primary" dir="auto">{plainTextContent()}</span>}>
|
||||
<Markdown
|
||||
part={createTextPartForMarkdown()}
|
||||
instanceId={props.instanceId}
|
||||
@@ -152,12 +154,14 @@ export default function MessagePart(props: MessagePartProps) {
|
||||
</Match>
|
||||
|
||||
<Match when={partType() === "tool"}>
|
||||
<ToolCall
|
||||
toolCall={props.part as ToolCallPart}
|
||||
toolCallId={props.part?.id}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
/>
|
||||
<Suspense fallback={<div class="tool-call tool-call-loading" />}>
|
||||
<LazyToolCall
|
||||
toolCall={props.part as ToolCallPart}
|
||||
toolCallId={props.part?.id}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
/>
|
||||
</Suspense>
|
||||
</Match>
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import type { DeleteHoverState } from "../types/delete-hover"
|
||||
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||
import { getPartCharCount } from "../lib/token-utils"
|
||||
|
||||
const SCROLL_SENTINEL_MARGIN_PX = 48
|
||||
const SCROLL_SENTINEL_MARGIN_PX = 8
|
||||
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
|
||||
const QUOTE_SELECTION_MAX_LENGTH = 2000
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
|
||||
@@ -295,7 +295,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
||||
{t("modelSelector.trigger.primary", { model: currentModelValue()?.name ?? t("modelSelector.none") })}
|
||||
</span>
|
||||
{currentModelValue() && (
|
||||
<span class="selector-trigger-secondary">
|
||||
<span class="selector-trigger-secondary" dir="ltr">
|
||||
{currentModelValue()!.providerId}/{currentModelValue()!.id}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, Show, createMemo, createSignal, createEffect, onCleanup, type Component } from "solid-js"
|
||||
import { For, Show, Suspense, createMemo, createSignal, createEffect, lazy, onCleanup, type Component } from "solid-js"
|
||||
import type { PermissionRequestLike } from "../types/permission"
|
||||
import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission"
|
||||
import { getQuestionCallId, getQuestionMessageId, getQuestionSessionId, type QuestionRequest } from "../types/question"
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
} from "../stores/instances"
|
||||
import { ensureSessionParentExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import ToolCall from "./tool-call"
|
||||
|
||||
const LazyToolCall = lazy(() => import("./tool-call"))
|
||||
|
||||
interface PermissionApprovalModalProps {
|
||||
instanceId: string
|
||||
@@ -408,15 +409,17 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
||||
}
|
||||
>
|
||||
{(data) => (
|
||||
<ToolCall
|
||||
toolCall={data().toolPart}
|
||||
toolCallId={data().toolPart.id}
|
||||
messageId={data().messageId}
|
||||
messageVersion={data().messageVersion}
|
||||
partVersion={data().partVersion}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={data().sessionId}
|
||||
/>
|
||||
<Suspense fallback={<div class="tool-call tool-call-loading" />}>
|
||||
<LazyToolCall
|
||||
toolCall={data().toolPart}
|
||||
toolCallId={data().toolPart.id}
|
||||
messageId={data().messageId}
|
||||
messageVersion={data().messageVersion}
|
||||
partVersion={data().partVersion}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={data().sessionId}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createSignal, Show, onMount, onCleanup, createEffect, on } from "solid-js"
|
||||
import { Suspense, createEffect, createSignal, lazy, on, onCleanup, onMount, Show } from "solid-js"
|
||||
import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
|
||||
import UnifiedPicker from "./unified-picker"
|
||||
import ExpandButton from "./expand-button"
|
||||
import { clearAttachments, removeAttachment } from "../stores/attachments"
|
||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||
import { createPastedPlaceholderRegex, pastedDisplayCounterRegex } from "./prompt-input/attachmentPlaceholders"
|
||||
import Kbd from "./kbd"
|
||||
import { getActiveInstance } from "../stores/instances"
|
||||
import { agents, executeCustomCommand } from "../stores/sessions"
|
||||
@@ -13,11 +13,41 @@ import { useI18n } from "../lib/i18n"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { preferences } from "../stores/preferences"
|
||||
import type { ExpandState, PromptInputApi, PromptInputProps, PromptInsertMode, PromptMode } from "./prompt-input/types"
|
||||
import type { Attachment } from "../types/attachment"
|
||||
import { usePromptState } from "./prompt-input/usePromptState"
|
||||
import { usePromptAttachments } from "./prompt-input/usePromptAttachments"
|
||||
import { usePromptPicker } from "./prompt-input/usePromptPicker"
|
||||
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
|
||||
const log = getLogger("actions")
|
||||
const LazyUnifiedPicker = lazy(() => import("./unified-picker"))
|
||||
|
||||
function getConsumedPastedTextAttachmentIds(text: string, attachments: Attachment[]): string[] {
|
||||
if (!text || attachments.length === 0) return []
|
||||
|
||||
const usedCounters = new Set<string>()
|
||||
for (const match of text.matchAll(createPastedPlaceholderRegex())) {
|
||||
const counter = match?.[1]
|
||||
if (counter) usedCounters.add(counter)
|
||||
}
|
||||
|
||||
if (usedCounters.size === 0) return []
|
||||
|
||||
const consumed = new Set<string>()
|
||||
|
||||
for (const attachment of attachments) {
|
||||
if (!attachment?.id) continue
|
||||
if (attachment?.source?.type !== "text") continue
|
||||
const display = attachment.display
|
||||
if (typeof display !== "string") continue
|
||||
const match = display.match(pastedDisplayCounterRegex)
|
||||
if (!match?.[1]) continue
|
||||
if (usedCounters.has(match[1])) {
|
||||
consumed.add(attachment.id)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(consumed)
|
||||
}
|
||||
|
||||
export default function PromptInput(props: PromptInputProps) {
|
||||
const { t } = useI18n()
|
||||
@@ -246,7 +276,12 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
commandName.length > 0 &&
|
||||
getCommands(props.instanceId).some((cmd) => cmd.name === commandName)
|
||||
|
||||
const resolvedPrompt = isKnownSlashCommand ? text : resolvePastedPlaceholders(text, currentAttachments)
|
||||
const resolvedCommandArgs = isKnownSlashCommand ? resolvePastedPlaceholders(commandArgs, currentAttachments) : ""
|
||||
const resolvedPrompt = isKnownSlashCommand
|
||||
? resolvedCommandArgs
|
||||
? `${commandToken} ${resolvedCommandArgs}`
|
||||
: commandToken
|
||||
: resolvePastedPlaceholders(text, currentAttachments)
|
||||
const historyEntry = resolvedPrompt
|
||||
|
||||
const refreshHistory = () => recordHistoryEntry(historyEntry)
|
||||
@@ -262,6 +297,10 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
syncAttachmentCounters("")
|
||||
setIgnoredAtPositions(new Set<number>())
|
||||
} else {
|
||||
const consumedIds = getConsumedPastedTextAttachmentIds(commandArgs, currentAttachments)
|
||||
for (const attachmentId of consumedIds) {
|
||||
removeAttachment(props.instanceId, props.sessionId, attachmentId)
|
||||
}
|
||||
syncAttachmentCounters("")
|
||||
setIgnoredAtPositions(new Set<number>())
|
||||
}
|
||||
@@ -281,7 +320,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
await props.onSend(resolvedPrompt, [])
|
||||
}
|
||||
} else if (isKnownSlashCommand) {
|
||||
await executeCustomCommand(props.instanceId, props.sessionId, commandName, commandArgs)
|
||||
await executeCustomCommand(props.instanceId, props.sessionId, commandName, resolvedCommandArgs)
|
||||
} else {
|
||||
await props.onSend(resolvedPrompt, currentAttachments)
|
||||
}
|
||||
@@ -428,18 +467,20 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Show when={showPicker() && instance()}>
|
||||
<UnifiedPicker
|
||||
open={showPicker()}
|
||||
mode={pickerMode()}
|
||||
onClose={handlePickerClose}
|
||||
onSelect={handlePickerSelect}
|
||||
agents={instanceAgents()}
|
||||
commands={getCommands(props.instanceId)}
|
||||
instanceClient={instance()!.client}
|
||||
searchQuery={searchQuery()}
|
||||
textareaRef={textareaRef}
|
||||
workspaceId={props.instanceId}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<LazyUnifiedPicker
|
||||
open={showPicker()}
|
||||
mode={pickerMode()}
|
||||
onClose={handlePickerClose}
|
||||
onSelect={handlePickerSelect}
|
||||
agents={instanceAgents()}
|
||||
commands={getCommands(props.instanceId)}
|
||||
instanceClient={instance()!.client}
|
||||
searchQuery={searchQuery()}
|
||||
textareaRef={textareaRef}
|
||||
workspaceId={props.instanceId}
|
||||
/>
|
||||
</Suspense>
|
||||
</Show>
|
||||
|
||||
<div class="flex flex-1 flex-col">
|
||||
@@ -449,6 +490,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""} ${expandState() === "expanded" ? "is-expanded" : ""}`}
|
||||
dir="auto"
|
||||
placeholder={getPlaceholder()}
|
||||
value={prompt()}
|
||||
onInput={handleInput}
|
||||
|
||||
@@ -444,7 +444,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
</Show>
|
||||
|
||||
{rowProps.isChild ? <Bot class="w-4 h-4 flex-shrink-0" /> : <User class="w-4 h-4 flex-shrink-0" />}
|
||||
<span class="session-item-title session-item-title--clamp">{title()}</span>
|
||||
<span class="session-item-title session-item-title--clamp" dir="auto">{title()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-item-row session-item-meta">
|
||||
|
||||
@@ -76,6 +76,7 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
|
||||
inputRef = element
|
||||
}}
|
||||
type="text"
|
||||
dir="auto"
|
||||
value={title()}
|
||||
onInput={(event) => setTitle(event.currentTarget.value)}
|
||||
placeholder={t("sessionRenameDialog.input.placeholder")}
|
||||
|
||||
@@ -514,6 +514,7 @@ function ToolCallDetails(props: {
|
||||
})
|
||||
|
||||
const { renderDiffContent } = createDiffContentRenderer({
|
||||
toolState: props.toolState,
|
||||
preferences: props.preferences,
|
||||
setDiffViewMode: props.setDiffViewMode,
|
||||
isDark: props.isDark,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Accessor, JSXElement } from "solid-js"
|
||||
import type { RenderCache } from "../../types/message"
|
||||
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi"
|
||||
import { escapeHtml } from "../../lib/markdown"
|
||||
import { escapeHtml } from "../../lib/text-render-utils"
|
||||
import type { AnsiRenderOptions, ToolScrollHelpers } from "./types"
|
||||
|
||||
type AnsiRenderCache = RenderCache & { hasAnsi: boolean }
|
||||
@@ -20,6 +20,14 @@ export function createAnsiContentRenderer(params: {
|
||||
const runningAnsiRenderer = createAnsiStreamRenderer()
|
||||
let runningAnsiSource = ""
|
||||
|
||||
const registerTracked = (element: HTMLDivElement | null) => {
|
||||
params.scrollHelpers.registerContainer(element)
|
||||
}
|
||||
|
||||
const registerUntracked = (element: HTMLDivElement | null) => {
|
||||
params.scrollHelpers.registerContainer(element, { disableTracking: true })
|
||||
}
|
||||
|
||||
const getMode = () => {
|
||||
const version = params.partVersion?.()
|
||||
return typeof version === "number" ? String(version) : undefined
|
||||
@@ -36,6 +44,8 @@ export function createAnsiContentRenderer(params: {
|
||||
const cached = cacheHandle.get<AnsiRenderCache>()
|
||||
const mode = getMode()
|
||||
const isRunningVariant = options.variant === "running"
|
||||
const disableScrollTracking = !isRunningVariant
|
||||
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
|
||||
|
||||
let nextCache: AnsiRenderCache
|
||||
|
||||
@@ -87,9 +97,9 @@ export function createAnsiContentRenderer(params: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={messageClass} ref={params.scrollHelpers.registerContainer} onScroll={params.scrollHelpers.handleScroll}>
|
||||
<pre class="tool-call-content tool-call-ansi" innerHTML={nextCache.html} />
|
||||
{params.scrollHelpers.renderSentinel()}
|
||||
<div class={messageClass} ref={registerRef} onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}>
|
||||
<pre class="tool-call-content tool-call-ansi" dir="auto" innerHTML={nextCache.html} />
|
||||
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function renderDiagnosticsSection(
|
||||
{entry.displayPath}
|
||||
<span class="tool-call-diagnostic-coords">:L{entry.line || "-"}:C{entry.column || "-"}</span>
|
||||
</span>
|
||||
<span class="tool-call-diagnostic-message">{entry.message}</span>
|
||||
<span class="tool-call-diagnostic-message" dir="auto">{entry.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
import type { Accessor, JSXElement } from "solid-js"
|
||||
import { Suspense, lazy, onMount, type Accessor, type JSXElement } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||
import type { RenderCache } from "../../types/message"
|
||||
import type { DiffViewMode } from "../../stores/preferences"
|
||||
import { ToolCallDiffViewer } from "../diff-viewer"
|
||||
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
|
||||
import { getRelativePath } from "./utils"
|
||||
import { getCacheEntry } from "../../lib/global-cache"
|
||||
|
||||
const LazyToolCallDiffViewer = lazy(() =>
|
||||
import("../diff-viewer").then((module) => ({ default: module.ToolCallDiffViewer })),
|
||||
)
|
||||
|
||||
function CachedDiffMarkup(props: { html: string; onRendered?: () => void }) {
|
||||
onMount(() => {
|
||||
props.onRendered?.()
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="tool-call-diff-viewer">
|
||||
<div innerHTML={props.html} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type CacheHandle = {
|
||||
get<T>(): T | undefined
|
||||
params(): unknown
|
||||
@@ -16,6 +32,7 @@ type DiffPrefs = {
|
||||
}
|
||||
|
||||
export function createDiffContentRenderer(params: {
|
||||
toolState: Accessor<ToolState | undefined>
|
||||
preferences: Accessor<DiffPrefs>
|
||||
setDiffViewMode: (mode: DiffViewMode) => void
|
||||
isDark: Accessor<boolean>
|
||||
@@ -43,7 +60,10 @@ export function createDiffContentRenderer(params: {
|
||||
const cacheHandle = selectedVariant === "permission-diff" ? params.permissionDiffCache : params.diffCache
|
||||
const diffMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode
|
||||
const themeKey = params.isDark() ? "dark" : "light"
|
||||
const disableScrollTracking = Boolean(options?.disableScrollTracking)
|
||||
const state = params.toolState()
|
||||
const disableScrollTracking = Boolean(
|
||||
options?.disableScrollTracking || (state?.status !== "running" && state?.status !== "pending"),
|
||||
)
|
||||
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
|
||||
|
||||
const baseEntryParams = cacheHandle.params() as any
|
||||
@@ -101,15 +121,20 @@ export function createDiffContentRenderer(params: {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ToolCallDiffViewer
|
||||
diffText={payload.diffText}
|
||||
filePath={payload.filePath}
|
||||
theme={themeKey}
|
||||
mode={diffMode()}
|
||||
cachedHtml={cachedHtml}
|
||||
cacheEntryParams={cacheEntryParams as any}
|
||||
onRendered={handleDiffRendered}
|
||||
/>
|
||||
{cachedHtml ? (
|
||||
<CachedDiffMarkup html={cachedHtml} onRendered={handleDiffRendered} />
|
||||
) : (
|
||||
<Suspense fallback={<pre class="tool-call-diff-fallback">{payload.diffText}</pre>}>
|
||||
<LazyToolCallDiffViewer
|
||||
diffText={payload.diffText}
|
||||
filePath={payload.filePath}
|
||||
theme={themeKey}
|
||||
mode={diffMode()}
|
||||
cacheEntryParams={cacheEntryParams as any}
|
||||
onRendered={handleDiffRendered}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -31,10 +31,9 @@ export function createMarkdownContentRenderer(params: {
|
||||
const size = options.size || "default"
|
||||
const disableHighlight = options.disableHighlight || false
|
||||
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}`
|
||||
const disableScrollTracking = options.disableScrollTracking || false
|
||||
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
|
||||
|
||||
const state = params.toolState()
|
||||
const disableScrollTracking = options.disableScrollTracking || (state?.status !== "running" && state?.status !== "pending")
|
||||
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
|
||||
const shouldDeferMarkdown = Boolean(state && (state.status === "running" || state.status === "pending") && disableHighlight)
|
||||
if (shouldDeferMarkdown) {
|
||||
return (
|
||||
@@ -43,7 +42,7 @@ export function createMarkdownContentRenderer(params: {
|
||||
ref={registerRef}
|
||||
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
|
||||
>
|
||||
<pre class="whitespace-pre-wrap break-words text-sm font-mono">{options.content}</pre>
|
||||
<pre class="whitespace-pre-wrap break-words text-sm font-mono" dir="auto">{options.content}</pre>
|
||||
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isRenderableDiffText } from "../../lib/diff-utils"
|
||||
import { getLanguageFromPath } from "../../lib/markdown"
|
||||
import { getLanguageFromPath } from "../../lib/text-render-utils"
|
||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||
import type { DiffPayload } from "./types"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Index, Show, createEffect, createMemo, createSignal, onCleanup, type Accessor, type JSX } from "solid-js"
|
||||
import VirtualItem, { type VirtualItemHeightChangeMeta } from "./virtual-item"
|
||||
import { Show, createEffect, createMemo, createSignal, onCleanup, type Accessor, type JSX, on } from "solid-js"
|
||||
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
|
||||
|
||||
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||
const USER_SCROLL_INTENT_WINDOW_MS = 600
|
||||
@@ -122,55 +122,28 @@ export interface VirtualFollowListProps<T> {
|
||||
}
|
||||
|
||||
export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
const getAnchorId = (key: string) => (props.getAnchorId ? props.getAnchorId(key) : key)
|
||||
const getKeyFromAnchorId = (anchorId: string) => (props.getKeyFromAnchorId ? props.getKeyFromAnchorId(anchorId) : anchorId)
|
||||
|
||||
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
|
||||
const [shellElement, setShellElement] = createSignal<HTMLDivElement | undefined>()
|
||||
const [topSentinel, setTopSentinel] = createSignal<HTMLDivElement | null>(null)
|
||||
const [bottomSentinelSignal, setBottomSentinelSignal] = createSignal<HTMLDivElement | null>(null)
|
||||
const bottomSentinel = () => bottomSentinelSignal()
|
||||
const [virtuaHandle, setVirtuaHandle] = createSignal<VirtualizerHandle | undefined>()
|
||||
|
||||
const isActive = () => (props.isActive ? props.isActive() : true)
|
||||
const scrollToBottomOnActivate = () => (props.scrollToBottomOnActivate ? props.scrollToBottomOnActivate() : true)
|
||||
const initialScrollToBottom = () => (props.initialScrollToBottom ? props.initialScrollToBottom() : true)
|
||||
const initialAutoScroll = () => (props.initialAutoScroll ? props.initialAutoScroll() : true)
|
||||
const isLoading = () => Boolean(props.loading?.())
|
||||
const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true)
|
||||
const measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
|
||||
|
||||
const [autoScroll, setAutoScroll] = createSignal(Boolean(initialAutoScroll()))
|
||||
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
|
||||
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
|
||||
const [topSentinelVisible, setTopSentinelVisible] = createSignal(true)
|
||||
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
|
||||
const [activeKey, setActiveKey] = createSignal<string | null>(null)
|
||||
|
||||
const [anchorLock, setAnchorLock] = createSignal<{ key: string; block: ScrollLogicalPosition } | null>(null)
|
||||
|
||||
const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0))
|
||||
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
let shellRef: HTMLDivElement | undefined
|
||||
let pendingScrollFrame: number | null = null
|
||||
let pendingAnchorScroll: number | null = null
|
||||
let pendingAnchorCorrectionFrame: number | null = null
|
||||
let pendingScrollCompensationScheduled = false
|
||||
let pendingScrollCompensations = new Map<string, number>()
|
||||
let scrollCompensationGen = 0
|
||||
let pendingActiveScroll = false
|
||||
let userScrollIntentUntil = 0
|
||||
let lastUserScrollIntentDirection: "up" | "down" | null = null
|
||||
let detachScrollIntentListeners: (() => void) | undefined
|
||||
let lastResetKey: string | number | undefined
|
||||
let suppressAutoScrollOnce = false
|
||||
let pendingInitialScroll = true
|
||||
let scrollToBottomFrame: number | null = null
|
||||
let scrollToBottomDelayedFrame: number | null = null
|
||||
|
||||
let lastKnownScrollTop = 0
|
||||
let lastUserScrollIntentDirection: "up" | "down" | null = null
|
||||
|
||||
let userScrollIntentUntil = 0
|
||||
let detachScrollIntentListeners: (() => void) | undefined
|
||||
|
||||
let lastResetKey: string | number | undefined
|
||||
|
||||
const state: VirtualFollowListState = {
|
||||
autoScroll,
|
||||
@@ -181,7 +154,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
}
|
||||
|
||||
function markUserScrollIntent(direction?: "up" | "down" | null) {
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||
const now = performance.now()
|
||||
userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS
|
||||
if (direction) {
|
||||
lastUserScrollIntentDirection = direction
|
||||
@@ -189,8 +162,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
}
|
||||
|
||||
function hasUserScrollIntent() {
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||
return now <= userScrollIntentUntil
|
||||
return performance.now() <= userScrollIntentUntil
|
||||
}
|
||||
|
||||
function attachScrollIntentListeners(element: HTMLDivElement | undefined) {
|
||||
@@ -231,670 +203,189 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
}
|
||||
}
|
||||
|
||||
function updateScrollIndicatorsFromVisibility() {
|
||||
function updateScrollButtons() {
|
||||
const handle = virtuaHandle()
|
||||
const element = scrollElement()
|
||||
if (!handle || !element) return
|
||||
|
||||
const offset = handle.scrollOffset
|
||||
const scrollHeight = handle.scrollSize
|
||||
const clientHeight = element.clientHeight
|
||||
const atBottom = scrollHeight - (offset + clientHeight) <= (props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX)
|
||||
const atTop = offset <= (props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX)
|
||||
|
||||
const hasItems = props.items().length > 0
|
||||
const bottomVisible = bottomSentinelVisible()
|
||||
const topVisible = topSentinelVisible()
|
||||
setShowScrollBottomButton(hasItems && !bottomVisible)
|
||||
setShowScrollTopButton(hasItems && !topVisible)
|
||||
}
|
||||
setShowScrollBottomButton(hasItems && !atBottom)
|
||||
setShowScrollTopButton(hasItems && !atTop)
|
||||
|
||||
function clearScrollToBottomFrames() {
|
||||
if (scrollToBottomFrame !== null) {
|
||||
cancelAnimationFrame(scrollToBottomFrame)
|
||||
scrollToBottomFrame = null
|
||||
}
|
||||
if (scrollToBottomDelayedFrame !== null) {
|
||||
cancelAnimationFrame(scrollToBottomDelayedFrame)
|
||||
scrollToBottomDelayedFrame = null
|
||||
// Sync autoScroll state based on scroll position if it was a user scroll
|
||||
if (hasUserScrollIntent()) {
|
||||
if (atBottom && !autoScroll()) {
|
||||
setAutoScroll(true)
|
||||
} else if (!atBottom && autoScroll()) {
|
||||
setAutoScroll(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottom(immediate = false, options?: { suppressAutoAnchor?: boolean }) {
|
||||
if (!containerRef) return
|
||||
if (anchorLock()) {
|
||||
clearAnchorLock()
|
||||
}
|
||||
const sentinel = bottomSentinel()
|
||||
const behavior: ScrollBehavior = immediate ? "auto" : "smooth"
|
||||
const suppressAutoAnchor = options?.suppressAutoAnchor ?? !immediate
|
||||
if (suppressAutoAnchor) {
|
||||
function scrollToBottom(immediate = true, options?: { suppressAutoAnchor?: boolean }) {
|
||||
const handle = virtuaHandle()
|
||||
if (!handle) return
|
||||
if (options?.suppressAutoAnchor ?? !immediate) {
|
||||
suppressAutoScrollOnce = true
|
||||
}
|
||||
sentinel?.scrollIntoView({ block: "end", inline: "nearest", behavior })
|
||||
handle.scrollToIndex(props.items().length - 1, { align: "end", smooth: !immediate })
|
||||
setAutoScroll(true)
|
||||
}
|
||||
|
||||
function requestScrollToBottom(immediate = true) {
|
||||
if (!isActive()) {
|
||||
pendingActiveScroll = true
|
||||
return
|
||||
}
|
||||
if (!containerRef || !bottomSentinel()) {
|
||||
pendingActiveScroll = true
|
||||
return
|
||||
}
|
||||
pendingActiveScroll = false
|
||||
clearScrollToBottomFrames()
|
||||
scrollToBottomFrame = requestAnimationFrame(() => {
|
||||
scrollToBottomFrame = null
|
||||
scrollToBottomDelayedFrame = requestAnimationFrame(() => {
|
||||
scrollToBottomDelayedFrame = null
|
||||
scrollToBottom(immediate)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function resolvePendingActiveScroll() {
|
||||
if (!pendingActiveScroll) return
|
||||
if (!isActive()) return
|
||||
requestScrollToBottom(true)
|
||||
}
|
||||
|
||||
function scrollToTop(immediate = false) {
|
||||
if (!containerRef) return
|
||||
const behavior: ScrollBehavior = immediate ? "auto" : "smooth"
|
||||
if (anchorLock()) {
|
||||
clearAnchorLock()
|
||||
}
|
||||
function scrollToTop(immediate = true) {
|
||||
const handle = virtuaHandle()
|
||||
if (!handle) return
|
||||
handle.scrollToIndex(0, { align: "start", smooth: !immediate })
|
||||
setAutoScroll(false)
|
||||
topSentinel()?.scrollIntoView({ block: "start", inline: "nearest", behavior })
|
||||
}
|
||||
|
||||
function scheduleAnchorScroll(immediate = false) {
|
||||
if (!autoScroll()) return
|
||||
if (!isActive()) {
|
||||
pendingActiveScroll = true
|
||||
return
|
||||
}
|
||||
const sentinel = bottomSentinel()
|
||||
if (!sentinel) {
|
||||
pendingActiveScroll = true
|
||||
return
|
||||
}
|
||||
if (pendingAnchorScroll !== null) {
|
||||
cancelAnimationFrame(pendingAnchorScroll)
|
||||
pendingAnchorScroll = null
|
||||
}
|
||||
pendingAnchorScroll = requestAnimationFrame(() => {
|
||||
pendingAnchorScroll = null
|
||||
sentinel.scrollIntoView({ block: "end", inline: "nearest", behavior: immediate ? "auto" : "smooth" })
|
||||
})
|
||||
}
|
||||
|
||||
function clearAnchorLock() {
|
||||
setAnchorLock(null)
|
||||
if (pendingAnchorCorrectionFrame !== null) {
|
||||
cancelAnimationFrame(pendingAnchorCorrectionFrame)
|
||||
pendingAnchorCorrectionFrame = null
|
||||
}
|
||||
}
|
||||
|
||||
function computeDesiredOffset(block: ScrollLogicalPosition, container: HTMLElement, anchorRect: DOMRect) {
|
||||
if (block === "end") {
|
||||
return Math.max(0, container.clientHeight - anchorRect.height)
|
||||
}
|
||||
if (block === "center") {
|
||||
return Math.max(0, container.clientHeight / 2 - anchorRect.height / 2)
|
||||
}
|
||||
// Default to start.
|
||||
return 0
|
||||
}
|
||||
|
||||
function applyAnchorCorrection() {
|
||||
const lock = anchorLock()
|
||||
if (!lock) return
|
||||
if (autoScroll()) return
|
||||
if (!containerRef) return
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const anchorId = getAnchorId(lock.key)
|
||||
const anchor = document.getElementById(anchorId)
|
||||
if (!anchor) return
|
||||
|
||||
const containerRect = containerRef.getBoundingClientRect()
|
||||
const anchorRect = anchor.getBoundingClientRect()
|
||||
const currentOffset = anchorRect.top - containerRect.top
|
||||
const desiredOffset = computeDesiredOffset(lock.block, containerRef, anchorRect)
|
||||
const delta = currentOffset - desiredOffset
|
||||
if (!Number.isFinite(delta) || Math.abs(delta) < 0.5) {
|
||||
return
|
||||
}
|
||||
const nextTop = containerRef.scrollTop + delta
|
||||
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0)
|
||||
containerRef.scrollTop = Math.min(maxScrollTop, Math.max(0, nextTop))
|
||||
}
|
||||
|
||||
function scheduleAnchorCorrection() {
|
||||
if (pendingAnchorCorrectionFrame !== null) return
|
||||
pendingAnchorCorrectionFrame = requestAnimationFrame(() => {
|
||||
pendingAnchorCorrectionFrame = null
|
||||
applyAnchorCorrection()
|
||||
})
|
||||
}
|
||||
|
||||
function handleContentRendered() {
|
||||
if (autoScroll() && !anchorLock()) {
|
||||
scheduleAutoPinToBottom()
|
||||
return
|
||||
}
|
||||
if (anchorLock() && !autoScroll()) {
|
||||
scheduleAnchorCorrection()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
if (!containerRef) return
|
||||
if (pendingScrollFrame !== null) {
|
||||
cancelAnimationFrame(pendingScrollFrame)
|
||||
}
|
||||
const isUserScroll = hasUserScrollIntent()
|
||||
pendingScrollFrame = requestAnimationFrame(() => {
|
||||
pendingScrollFrame = null
|
||||
if (!containerRef) return
|
||||
const previousScrollTop = lastKnownScrollTop
|
||||
const currentScrollTop = containerRef.scrollTop
|
||||
const deltaScrollTop = currentScrollTop - previousScrollTop
|
||||
if (currentScrollTop !== lastKnownScrollTop) {
|
||||
lastKnownScrollTop = currentScrollTop
|
||||
if (isUserScroll) {
|
||||
if (lastUserScrollIntentDirection === "up" && autoScroll()) {
|
||||
setAutoScroll(false)
|
||||
}
|
||||
const atBottom = bottomSentinelVisible()
|
||||
}
|
||||
updateScrollButtons()
|
||||
props.onScroll?.()
|
||||
|
||||
const beforeAutoScroll = autoScroll()
|
||||
|
||||
const inferredDirection: "up" | "down" | null =
|
||||
lastUserScrollIntentDirection ?? (deltaScrollTop < 0 ? "up" : deltaScrollTop > 0 ? "down" : null)
|
||||
|
||||
// If the user scrolls manually, exit key-anchored mode.
|
||||
if (isUserScroll && anchorLock()) {
|
||||
clearAnchorLock()
|
||||
}
|
||||
|
||||
if (isUserScroll) {
|
||||
// If the user is actively scrolling upward, exit follow-to-bottom mode
|
||||
// immediately. The bottom sentinel can remain "visible" for a short
|
||||
// distance due to its observer margin, which otherwise keeps autoScroll
|
||||
// enabled and makes the list feel stuck.
|
||||
if (inferredDirection === "up" && deltaScrollTop < -0.5 && autoScroll()) {
|
||||
if (pendingAnchorScroll !== null) {
|
||||
cancelAnimationFrame(pendingAnchorScroll)
|
||||
pendingAnchorScroll = null
|
||||
}
|
||||
setAutoScroll(false)
|
||||
}
|
||||
|
||||
// Do not re-enable follow mode while the user's current scroll intent
|
||||
// is upward. This prevents transient anchor/pin scrolls from pulling
|
||||
// the list back into autoScroll(true).
|
||||
if (inferredDirection !== "up") {
|
||||
if (atBottom) {
|
||||
if (!autoScroll()) setAutoScroll(true)
|
||||
} else if (autoScroll()) {
|
||||
setAutoScroll(false)
|
||||
}
|
||||
} else if (!atBottom && autoScroll()) {
|
||||
// If the user is scrolling up and we are no longer at the bottom,
|
||||
// ensure follow mode is disabled.
|
||||
setAutoScroll(false)
|
||||
// Find active key (roughly the first visible item)
|
||||
const handle = virtuaHandle()
|
||||
if (handle) {
|
||||
const start = handle.findItemIndex(handle.scrollOffset)
|
||||
const items = props.items()
|
||||
if (items[start]) {
|
||||
const key = props.getKey(items[start], start)
|
||||
if (key !== activeKey()) {
|
||||
setActiveKey(key)
|
||||
props.onActiveKeyChange?.(key)
|
||||
}
|
||||
}
|
||||
|
||||
props.onScroll?.()
|
||||
})
|
||||
}
|
||||
|
||||
function setContainerRef(element: HTMLDivElement | null) {
|
||||
containerRef = element || undefined
|
||||
setScrollElement(containerRef)
|
||||
props.onScrollElementChange?.(containerRef)
|
||||
attachScrollIntentListeners(containerRef)
|
||||
lastKnownScrollTop = containerRef?.scrollTop ?? 0
|
||||
lastUserScrollIntentDirection = null
|
||||
if (!containerRef) {
|
||||
return
|
||||
}
|
||||
resolvePendingActiveScroll()
|
||||
}
|
||||
|
||||
function scheduleScrollCompensation(key: string, delta: number) {
|
||||
if (!containerRef) return
|
||||
if (!delta || !Number.isFinite(delta)) return
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
// Only compensate while the user scrolls upward (testing default).
|
||||
if (!hasUserScrollIntent() || lastUserScrollIntentDirection !== "up") return
|
||||
if (autoScroll() || anchorLock()) return
|
||||
|
||||
const anchorId = getAnchorId(key)
|
||||
const anchor = document.getElementById(anchorId)
|
||||
if (!anchor) return
|
||||
const containerRect = containerRef.getBoundingClientRect()
|
||||
const rect = anchor.getBoundingClientRect()
|
||||
// Determine whether the item was fully above the viewport *before* the
|
||||
// height delta applied. Items can expand downward into the viewport; in that
|
||||
// case we still need to compensate to keep existing visible content stable.
|
||||
const bottomAfter = rect.bottom
|
||||
const bottomBefore = bottomAfter - delta
|
||||
const wasAboveViewport = bottomBefore < containerRect.top
|
||||
if (!wasAboveViewport) return
|
||||
|
||||
const next = (pendingScrollCompensations.get(key) ?? 0) + delta
|
||||
pendingScrollCompensations.set(key, next)
|
||||
|
||||
if (pendingScrollCompensationScheduled) return
|
||||
pendingScrollCompensationScheduled = true
|
||||
const gen = scrollCompensationGen
|
||||
|
||||
// Flush in a microtask so compensation lands before the next paint.
|
||||
queueMicrotask(() => {
|
||||
if (gen !== scrollCompensationGen) return
|
||||
pendingScrollCompensationScheduled = false
|
||||
if (!containerRef) return
|
||||
if (!hasUserScrollIntent() || lastUserScrollIntentDirection !== "up") {
|
||||
pendingScrollCompensations = new Map()
|
||||
return
|
||||
}
|
||||
if (autoScroll() || anchorLock()) {
|
||||
pendingScrollCompensations = new Map()
|
||||
return
|
||||
}
|
||||
|
||||
let applied = 0
|
||||
let count = 0
|
||||
for (const pendingDelta of pendingScrollCompensations.values()) {
|
||||
if (!pendingDelta) continue
|
||||
applied += pendingDelta
|
||||
count += 1
|
||||
}
|
||||
pendingScrollCompensations = new Map()
|
||||
if (!applied) return
|
||||
|
||||
const before = containerRef.scrollTop
|
||||
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0)
|
||||
const nextTop = Math.min(maxScrollTop, Math.max(0, before + applied))
|
||||
if (nextTop !== before) {
|
||||
containerRef.scrollTop = nextTop
|
||||
lastKnownScrollTop = nextTop
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let pendingAutoPin = false
|
||||
let pendingAutoPinFrame: number | null = null
|
||||
|
||||
function clearPendingAutoPinFrame() {
|
||||
if (pendingAutoPinFrame !== null) {
|
||||
cancelAnimationFrame(pendingAutoPinFrame)
|
||||
pendingAutoPinFrame = null
|
||||
}
|
||||
}
|
||||
|
||||
function applyAutoPinToBottom() {
|
||||
if (!containerRef) return false
|
||||
if (!autoScroll()) return false
|
||||
if (anchorLock()) return false
|
||||
|
||||
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0)
|
||||
if (containerRef.scrollTop !== maxScrollTop) {
|
||||
containerRef.scrollTop = maxScrollTop
|
||||
lastKnownScrollTop = maxScrollTop
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function scheduleAutoPinToBottom() {
|
||||
if (!containerRef) return
|
||||
if (pendingAutoPin) return
|
||||
pendingAutoPin = true
|
||||
clearPendingAutoPinFrame()
|
||||
const gen = scrollCompensationGen
|
||||
|
||||
// Flush in a microtask so adjustments land before the next paint,
|
||||
// then re-apply on the next two frames to catch deferred layout.
|
||||
queueMicrotask(() => {
|
||||
if (gen !== scrollCompensationGen) return
|
||||
pendingAutoPin = false
|
||||
if (!applyAutoPinToBottom()) return
|
||||
pendingAutoPinFrame = requestAnimationFrame(() => {
|
||||
pendingAutoPinFrame = null
|
||||
if (gen !== scrollCompensationGen) return
|
||||
if (!applyAutoPinToBottom()) return
|
||||
pendingAutoPinFrame = requestAnimationFrame(() => {
|
||||
pendingAutoPinFrame = null
|
||||
if (gen !== scrollCompensationGen) return
|
||||
applyAutoPinToBottom()
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function setShellRef(element: HTMLDivElement | null) {
|
||||
shellRef = element || undefined
|
||||
setShellElement(shellRef)
|
||||
props.onShellElementChange?.(shellRef)
|
||||
}
|
||||
|
||||
function setBottomSentinel(element: HTMLDivElement | null) {
|
||||
setBottomSentinelSignal(element)
|
||||
resolvePendingActiveScroll()
|
||||
}
|
||||
|
||||
const api: VirtualFollowListApi = {
|
||||
scrollToTop: (opts) => scrollToTop(Boolean(opts?.immediate)),
|
||||
scrollToBottom: (opts) => scrollToBottom(Boolean(opts?.immediate), { suppressAutoAnchor: opts?.suppressAutoAnchor }),
|
||||
scrollToTop: (opts) => scrollToTop(opts?.immediate ?? true),
|
||||
scrollToBottom: (opts) => scrollToBottom(opts?.immediate ?? true, { suppressAutoAnchor: opts?.suppressAutoAnchor }),
|
||||
scrollToKey: (key, opts) => {
|
||||
if (typeof document === "undefined") return
|
||||
const anchorId = getAnchorId(key)
|
||||
const behavior = opts?.behavior ?? "smooth"
|
||||
const block = opts?.block ?? "start"
|
||||
const index = props.items().findIndex((item, i) => props.getKey(item, i) === key)
|
||||
if (index === -1) return
|
||||
const nextAutoScroll = opts?.setAutoScroll ?? false
|
||||
setAutoScroll(nextAutoScroll)
|
||||
if (!nextAutoScroll) {
|
||||
if (anchorLock()) {
|
||||
clearAnchorLock()
|
||||
}
|
||||
setAnchorLock({ key, block })
|
||||
} else {
|
||||
if (anchorLock()) {
|
||||
clearAnchorLock()
|
||||
}
|
||||
}
|
||||
const first = document.getElementById(anchorId)
|
||||
first?.scrollIntoView({ block, behavior })
|
||||
// When using virtualization, the placeholder height can be stale until the
|
||||
// item mounts/measures. Re-run scrollIntoView() on the next frame to
|
||||
// stabilize the final position.
|
||||
requestAnimationFrame(() => {
|
||||
const second = document.getElementById(anchorId)
|
||||
second?.scrollIntoView({ block, behavior })
|
||||
})
|
||||
virtuaHandle()?.scrollToIndex(index, { align: opts?.block ?? "start", smooth: opts?.behavior === "smooth" })
|
||||
},
|
||||
notifyContentRendered: () => {
|
||||
if (autoScroll()) {
|
||||
scrollToBottom(true)
|
||||
}
|
||||
},
|
||||
notifyContentRendered: () => handleContentRendered(),
|
||||
setAutoScroll: (enabled) => setAutoScroll(Boolean(enabled)),
|
||||
getAutoScroll: () => autoScroll(),
|
||||
getScrollElement: () => scrollElement(),
|
||||
getShellElement: () => shellElement(),
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
props.registerApi?.(api)
|
||||
})
|
||||
createEffect(() => props.registerApi?.(api))
|
||||
createEffect(() => props.registerState?.(state))
|
||||
|
||||
createEffect(() => {
|
||||
props.registerState?.(state)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const nextKey = props.resetKey?.()
|
||||
if (nextKey === undefined) return
|
||||
if (lastResetKey === undefined) {
|
||||
lastResetKey = nextKey
|
||||
return
|
||||
// Handle autoScroll (Follow) on items change
|
||||
createEffect(on(() => props.items().length, (len, prevLen) => {
|
||||
if (len > (prevLen ?? 0) && autoScroll() && !suppressAutoScrollOnce) {
|
||||
requestAnimationFrame(() => scrollToBottom(true))
|
||||
}
|
||||
suppressAutoScrollOnce = false
|
||||
}, { defer: true }))
|
||||
|
||||
// Handle followToken change
|
||||
createEffect(on(() => props.followToken?.(), () => {
|
||||
if (autoScroll()) {
|
||||
scrollToBottom(true)
|
||||
}
|
||||
}, { defer: true }))
|
||||
|
||||
// Reset state on resetKey change
|
||||
createEffect(on(() => props.resetKey?.(), (nextKey) => {
|
||||
if (nextKey === lastResetKey) return
|
||||
lastResetKey = nextKey
|
||||
|
||||
// Reset internal state when consumers swap datasets (e.g. session switch).
|
||||
if (pendingScrollFrame !== null) {
|
||||
cancelAnimationFrame(pendingScrollFrame)
|
||||
pendingScrollFrame = null
|
||||
}
|
||||
if (pendingAnchorScroll !== null) {
|
||||
cancelAnimationFrame(pendingAnchorScroll)
|
||||
pendingAnchorScroll = null
|
||||
}
|
||||
if (pendingAnchorCorrectionFrame !== null) {
|
||||
cancelAnimationFrame(pendingAnchorCorrectionFrame)
|
||||
pendingAnchorCorrectionFrame = null
|
||||
}
|
||||
clearScrollToBottomFrames()
|
||||
|
||||
scrollCompensationGen += 1
|
||||
pendingScrollCompensationScheduled = false
|
||||
pendingScrollCompensations = new Map()
|
||||
pendingAutoPin = false
|
||||
clearPendingAutoPinFrame()
|
||||
|
||||
suppressAutoScrollOnce = false
|
||||
pendingActiveScroll = false
|
||||
setAutoScroll(initialAutoScroll())
|
||||
pendingInitialScroll = true
|
||||
}))
|
||||
|
||||
setAnchorLock(null)
|
||||
setActiveKey(null)
|
||||
setShowScrollTopButton(false)
|
||||
setShowScrollBottomButton(false)
|
||||
setTopSentinelVisible(true)
|
||||
setBottomSentinelVisible(true)
|
||||
setAutoScroll(Boolean(initialAutoScroll()))
|
||||
|
||||
lastKnownScrollTop = containerRef?.scrollTop ?? 0
|
||||
lastUserScrollIntentDirection = null
|
||||
})
|
||||
|
||||
let lastActiveState = false
|
||||
// Initial scroll and session activation
|
||||
createEffect(() => {
|
||||
const active = isActive()
|
||||
if (active) {
|
||||
resolvePendingActiveScroll()
|
||||
if (!lastActiveState && autoScroll() && scrollToBottomOnActivate()) {
|
||||
requestScrollToBottom(true)
|
||||
|
||||
// When switching back to a cached session pane, items can mount/measure
|
||||
// after the initial scroll jump. Re-pin once layout settles so the
|
||||
// viewport stays at the bottom.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
scheduleAutoPinToBottom()
|
||||
})
|
||||
})
|
||||
if (!active) return
|
||||
if (pendingInitialScroll && props.items().length > 0) {
|
||||
pendingInitialScroll = false
|
||||
if (initialScrollToBottom()) {
|
||||
scrollToBottom(true)
|
||||
}
|
||||
} else if (autoScroll() && scrollToBottomOnActivate()) {
|
||||
pendingActiveScroll = true
|
||||
}
|
||||
lastActiveState = active
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const loading = isLoading()
|
||||
if (loading) {
|
||||
// Keep the initial scroll pending while loading so we can
|
||||
// anchor to the bottom as soon as items appear.
|
||||
pendingInitialScroll = true
|
||||
}
|
||||
|
||||
if (!pendingInitialScroll) return
|
||||
|
||||
const container = scrollElement()
|
||||
const sentinel = bottomSentinel()
|
||||
if (!container || !sentinel || props.items().length === 0) return
|
||||
|
||||
if (!initialScrollToBottom()) {
|
||||
// An outer component is responsible for restoring scroll.
|
||||
pendingInitialScroll = false
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure we're in follow-to-bottom mode for the initial position.
|
||||
if (anchorLock()) {
|
||||
clearAnchorLock()
|
||||
}
|
||||
setAutoScroll(true)
|
||||
|
||||
pendingInitialScroll = false
|
||||
// Scroll synchronously so the first paint prefers bottom content.
|
||||
scrollToBottom(true)
|
||||
})
|
||||
|
||||
let previousFollowToken: string | number | undefined
|
||||
createEffect(() => {
|
||||
const token = props.followToken?.()
|
||||
if (token === undefined) {
|
||||
previousFollowToken = token
|
||||
return
|
||||
}
|
||||
if (previousFollowToken === undefined) {
|
||||
previousFollowToken = token
|
||||
return
|
||||
}
|
||||
if (token === previousFollowToken) {
|
||||
return
|
||||
}
|
||||
previousFollowToken = token
|
||||
if (suppressAutoScrollOnce) {
|
||||
suppressAutoScrollOnce = false
|
||||
return
|
||||
}
|
||||
if (autoScroll()) {
|
||||
scheduleAutoPinToBottom()
|
||||
return
|
||||
}
|
||||
if (anchorLock() && !autoScroll()) {
|
||||
scheduleAnchorCorrection()
|
||||
scrollToBottom(true)
|
||||
}
|
||||
})
|
||||
|
||||
// Drop anchor lock if the anchored key is removed.
|
||||
createEffect(() => {
|
||||
const lock = anchorLock()
|
||||
if (!lock) return
|
||||
const keys = props.items().map((item, idx) => props.getKey(item, idx))
|
||||
if (!keys.includes(lock.key)) {
|
||||
clearAnchorLock()
|
||||
}
|
||||
})
|
||||
return (
|
||||
<div class="virtual-follow-list-shell" ref={shellElement => {
|
||||
setShellElement(shellElement)
|
||||
props.onShellElementChange?.(shellElement)
|
||||
}}>
|
||||
<div
|
||||
class="message-stream"
|
||||
ref={el => {
|
||||
setScrollElement(el)
|
||||
props.onScrollElementChange?.(el)
|
||||
attachScrollIntentListeners(el)
|
||||
}}
|
||||
onMouseUp={props.onMouseUp}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<Show when={props.renderBeforeItems}>
|
||||
{props.renderBeforeItems!()}
|
||||
</Show>
|
||||
<Virtualizer
|
||||
ref={setVirtuaHandle}
|
||||
scrollRef={scrollElement()}
|
||||
data={props.items()}
|
||||
bufferSize={props.overscanPx ?? 400}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{(item, index) => props.renderItem(item, index())}
|
||||
</Virtualizer>
|
||||
</div>
|
||||
|
||||
createEffect(() => {
|
||||
if (props.items().length === 0) {
|
||||
setShowScrollTopButton(false)
|
||||
setShowScrollBottomButton(false)
|
||||
setAutoScroll(true)
|
||||
return
|
||||
}
|
||||
updateScrollIndicatorsFromVisibility()
|
||||
})
|
||||
<Show when={props.renderOverlay}>
|
||||
<div class="virtual-follow-list-overlay">{props.renderOverlay!()}</div>
|
||||
</Show>
|
||||
|
||||
createEffect(() => {
|
||||
const container = scrollElement()
|
||||
const topTarget = topSentinel()
|
||||
const bottomTarget = bottomSentinel()
|
||||
if (!container || !topTarget || !bottomTarget) return
|
||||
if (typeof IntersectionObserver === "undefined") return
|
||||
<Show when={props.renderControls}>
|
||||
<div class="virtual-follow-list-controls-container">{props.renderControls!(state, api)}</div>
|
||||
</Show>
|
||||
|
||||
const margin = props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
let visibilityChanged = false
|
||||
for (const entry of entries) {
|
||||
if (entry.target === topTarget) {
|
||||
setTopSentinelVisible(entry.isIntersecting)
|
||||
visibilityChanged = true
|
||||
} else if (entry.target === bottomTarget) {
|
||||
setBottomSentinelVisible(entry.isIntersecting)
|
||||
visibilityChanged = true
|
||||
}
|
||||
<Show
|
||||
when={
|
||||
!props.renderControls &&
|
||||
(showScrollTopButton() || showScrollBottomButton()) &&
|
||||
props.scrollToTopAriaLabel &&
|
||||
props.scrollToBottomAriaLabel
|
||||
}
|
||||
if (visibilityChanged) {
|
||||
updateScrollIndicatorsFromVisibility()
|
||||
}
|
||||
},
|
||||
{ root: container, threshold: 0, rootMargin: `${margin}px 0px ${margin}px 0px` },
|
||||
)
|
||||
observer.observe(topTarget)
|
||||
observer.observe(bottomTarget)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const container = scrollElement()
|
||||
const items = props.items()
|
||||
if (!container || items.length === 0) return
|
||||
if (typeof document === "undefined") return
|
||||
if (typeof IntersectionObserver === "undefined") return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
let best: IntersectionObserverEntry | null = null
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue
|
||||
if (!best || entry.boundingClientRect.top < best.boundingClientRect.top) {
|
||||
best = entry
|
||||
}
|
||||
}
|
||||
if (best) {
|
||||
const anchorId = (best.target as HTMLElement).id
|
||||
const key = getKeyFromAnchorId(anchorId)
|
||||
setActiveKey((current) => (current === key ? current : key))
|
||||
}
|
||||
},
|
||||
{ root: container, rootMargin: "-10% 0px -80% 0px", threshold: 0 },
|
||||
)
|
||||
|
||||
const anchorIds = items.map((item, idx) => getAnchorId(props.getKey(item, idx)))
|
||||
anchorIds.forEach((anchorId) => {
|
||||
const anchor = document.getElementById(anchorId)
|
||||
if (anchor) observer.observe(anchor)
|
||||
})
|
||||
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const key = activeKey()
|
||||
props.onActiveKeyChange?.(key)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (pendingScrollFrame !== null) {
|
||||
cancelAnimationFrame(pendingScrollFrame)
|
||||
}
|
||||
if (pendingAnchorScroll !== null) {
|
||||
cancelAnimationFrame(pendingAnchorScroll)
|
||||
}
|
||||
if (pendingAnchorCorrectionFrame !== null) {
|
||||
cancelAnimationFrame(pendingAnchorCorrectionFrame)
|
||||
}
|
||||
scrollCompensationGen += 1
|
||||
pendingScrollCompensationScheduled = false
|
||||
pendingScrollCompensations = new Map()
|
||||
clearPendingAutoPinFrame()
|
||||
clearScrollToBottomFrames()
|
||||
if (detachScrollIntentListeners) {
|
||||
detachScrollIntentListeners()
|
||||
}
|
||||
})
|
||||
|
||||
const controls = () => {
|
||||
if (props.renderControls) {
|
||||
return props.renderControls(state, api)
|
||||
}
|
||||
|
||||
// Avoid hardcoded user-visible strings; require consumers to supply
|
||||
// localized aria labels when using the default controls.
|
||||
if (!props.scrollToTopAriaLabel || !props.scrollToBottomAriaLabel) {
|
||||
return null
|
||||
}
|
||||
|
||||
const labelTop = props.scrollToTopAriaLabel()
|
||||
const labelBottom = props.scrollToBottomAriaLabel()
|
||||
return (
|
||||
<Show when={showScrollTopButton() || showScrollBottomButton()}>
|
||||
>
|
||||
<div class="message-scroll-button-wrapper">
|
||||
<Show when={showScrollTopButton()}>
|
||||
<button type="button" class="message-scroll-button" onClick={() => scrollToTop()} aria-label={labelTop}>
|
||||
<button type="button" class="message-scroll-button" onClick={() => scrollToTop()} aria-label={props.scrollToTopAriaLabel!()}>
|
||||
<span class="message-scroll-icon" aria-hidden="true">
|
||||
↑
|
||||
</span>
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={showScrollBottomButton()}>
|
||||
<button
|
||||
type="button"
|
||||
class="message-scroll-button"
|
||||
onClick={() => scrollToBottom(false, { suppressAutoAnchor: false })}
|
||||
aria-label={labelBottom}
|
||||
>
|
||||
<button type="button" class="message-scroll-button" onClick={() => scrollToBottom()} aria-label={props.scrollToBottomAriaLabel!()}>
|
||||
<span class="message-scroll-icon" aria-hidden="true">
|
||||
↓
|
||||
</span>
|
||||
@@ -902,71 +393,6 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="message-stream-shell" ref={setShellRef}>
|
||||
<div
|
||||
class="message-stream"
|
||||
ref={setContainerRef}
|
||||
onScroll={handleScroll}
|
||||
onMouseUp={(event) => props.onMouseUp?.(event)}
|
||||
onClick={(event) => props.onClick?.(event)}
|
||||
>
|
||||
<div ref={setTopSentinel} aria-hidden="true" style={{ height: "1px" }} />
|
||||
{props.renderBeforeItems?.()}
|
||||
<Index each={props.items()}>
|
||||
{(item, index) => {
|
||||
const key = () => props.getKey(item(), index)
|
||||
const anchorId = () => getAnchorId(key())
|
||||
const overscanPx = props.overscanPx ?? 800
|
||||
const suspendMeasurements = () => measurementsSuspended() || !isActive()
|
||||
const itemVirtualizationEnabled = () => virtualizationEnabled() && !autoScroll()
|
||||
return (
|
||||
<VirtualItem
|
||||
id={anchorId()}
|
||||
cacheKey={key()}
|
||||
scrollContainer={scrollElement}
|
||||
threshold={overscanPx}
|
||||
placeholderClass="message-stream-placeholder"
|
||||
virtualizationEnabled={itemVirtualizationEnabled}
|
||||
suspendMeasurements={suspendMeasurements}
|
||||
onHeightChange={(nextHeight, previousHeight, meta: VirtualItemHeightChangeMeta) => {
|
||||
const delta = nextHeight - previousHeight
|
||||
|
||||
// Follow mode: keep the viewport pinned to the bottom as
|
||||
// items mount/measure and change height.
|
||||
if (delta && autoScroll() && !anchorLock()) {
|
||||
scheduleAutoPinToBottom()
|
||||
return
|
||||
}
|
||||
|
||||
// Key-anchored mode: keep the target key in view when
|
||||
// items above it mount/measure and shift layout.
|
||||
if (anchorLock() && !autoScroll()) {
|
||||
scheduleAnchorCorrection()
|
||||
return
|
||||
}
|
||||
|
||||
// Free-scroll mode: if items above the viewport change height
|
||||
// while scrolling upward, compensate scrollTop so visible
|
||||
// content stays stable.
|
||||
if (delta) {
|
||||
if (meta.isStaleCacheCorrection) return
|
||||
scheduleScrollCompensation(key(), delta)
|
||||
}
|
||||
}}
|
||||
>{() => props.renderItem(item(), index)}</VirtualItem>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
<div ref={setBottomSentinel} aria-hidden="true" style={{ height: "1px" }} />
|
||||
</div>
|
||||
|
||||
{controls()}
|
||||
|
||||
{props.renderOverlay?.()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,492 +0,0 @@
|
||||
import { JSX, Accessor, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
|
||||
const sizeCache = new Map<string, number>()
|
||||
const DEFAULT_MARGIN_PX = 600
|
||||
const MIN_PLACEHOLDER_HEIGHT = 400
|
||||
const VISIBILITY_BUFFER_PX = 0
|
||||
|
||||
type ObserverRoot = Element | Document | null
|
||||
|
||||
type IntersectionCallback = (entry: IntersectionObserverEntry) => void
|
||||
|
||||
interface SharedObserver {
|
||||
observer: IntersectionObserver
|
||||
listeners: Map<Element, Set<IntersectionCallback>>
|
||||
}
|
||||
|
||||
const NULL_ROOT_KEY = "__null__"
|
||||
const rootIds = new WeakMap<Element | Document, number>()
|
||||
let sharedRootId = 0
|
||||
const sharedObservers = new Map<string, SharedObserver>()
|
||||
|
||||
function getRootKey(root: ObserverRoot, margin: number): string {
|
||||
if (!root) {
|
||||
return `${NULL_ROOT_KEY}:${margin}`
|
||||
}
|
||||
let id = rootIds.get(root)
|
||||
if (id === undefined) {
|
||||
id = ++sharedRootId
|
||||
rootIds.set(root, id)
|
||||
}
|
||||
return `${id}:${margin}`
|
||||
}
|
||||
|
||||
function createSharedObserver(root: ObserverRoot, margin: number): SharedObserver {
|
||||
const listeners = new Map<Element, Set<IntersectionCallback>>()
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
const callbacks = listeners.get(entry.target as Element)
|
||||
if (!callbacks) return
|
||||
callbacks.forEach((fn) => fn(entry))
|
||||
})
|
||||
},
|
||||
{
|
||||
root: root ?? undefined,
|
||||
rootMargin: `${margin}px 0px ${margin}px 0px`,
|
||||
},
|
||||
)
|
||||
return { observer, listeners }
|
||||
}
|
||||
|
||||
function shouldRenderEntry(entry: IntersectionObserverEntry) {
|
||||
const rootBounds = entry.rootBounds
|
||||
if (!rootBounds) {
|
||||
return entry.isIntersecting
|
||||
}
|
||||
|
||||
// Above the root: compare bottom edge to root top.
|
||||
if (entry.boundingClientRect.bottom < rootBounds.top) {
|
||||
const distance = rootBounds.top - entry.boundingClientRect.bottom
|
||||
return distance <= VISIBILITY_BUFFER_PX
|
||||
}
|
||||
|
||||
// Below the root: compare top edge to root bottom.
|
||||
if (entry.boundingClientRect.top > rootBounds.bottom) {
|
||||
const distance = entry.boundingClientRect.top - rootBounds.bottom
|
||||
return distance <= VISIBILITY_BUFFER_PX
|
||||
}
|
||||
|
||||
// Overlapping the root bounds.
|
||||
return true
|
||||
}
|
||||
|
||||
function getViewportRect(): { top: number; bottom: number } {
|
||||
if (typeof window === "undefined") {
|
||||
return { top: 0, bottom: 0 }
|
||||
}
|
||||
return { top: 0, bottom: window.innerHeight }
|
||||
}
|
||||
|
||||
function isRenderableRoot(root: ObserverRoot): boolean {
|
||||
if (!root) return true
|
||||
if (root instanceof Document) return true
|
||||
if (typeof window === "undefined") return false
|
||||
|
||||
const element = root as Element
|
||||
const style = window.getComputedStyle(element as Element)
|
||||
if (style.display === "none" || style.visibility === "hidden") {
|
||||
return false
|
||||
}
|
||||
const rect = (element as Element).getBoundingClientRect()
|
||||
return rect.width > 0 && rect.height > 0
|
||||
}
|
||||
|
||||
function shouldRenderByRects(params: {
|
||||
wrapperRect: DOMRect
|
||||
rootRect: { top: number; bottom: number }
|
||||
margin: number
|
||||
}): boolean {
|
||||
const { wrapperRect, rootRect, margin } = params
|
||||
const threshold = margin + VISIBILITY_BUFFER_PX
|
||||
|
||||
// Above the root: compare bottom edge to root top.
|
||||
if (wrapperRect.bottom < rootRect.top) {
|
||||
const distance = rootRect.top - wrapperRect.bottom
|
||||
return distance <= threshold
|
||||
}
|
||||
|
||||
// Below the root: compare top edge to root bottom.
|
||||
if (wrapperRect.top > rootRect.bottom) {
|
||||
const distance = wrapperRect.top - rootRect.bottom
|
||||
return distance <= threshold
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function subscribeToSharedObserver(
|
||||
target: Element,
|
||||
root: ObserverRoot,
|
||||
margin: number,
|
||||
callback: IntersectionCallback,
|
||||
): () => void {
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
callback({ isIntersecting: true } as IntersectionObserverEntry)
|
||||
return () => {}
|
||||
}
|
||||
const key = getRootKey(root, margin)
|
||||
let shared = sharedObservers.get(key)
|
||||
if (!shared) {
|
||||
shared = createSharedObserver(root, margin)
|
||||
sharedObservers.set(key, shared)
|
||||
}
|
||||
let targetCallbacks = shared.listeners.get(target)
|
||||
if (!targetCallbacks) {
|
||||
targetCallbacks = new Set()
|
||||
shared.listeners.set(target, targetCallbacks)
|
||||
shared.observer.observe(target)
|
||||
}
|
||||
targetCallbacks.add(callback)
|
||||
return () => {
|
||||
const current = shared?.listeners.get(target)
|
||||
if (current) {
|
||||
current.delete(callback)
|
||||
if (current.size === 0) {
|
||||
shared?.listeners.delete(target)
|
||||
shared?.observer.unobserve(target)
|
||||
}
|
||||
}
|
||||
if (shared && shared.listeners.size === 0) {
|
||||
shared.observer.disconnect()
|
||||
sharedObservers.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface VirtualItemProps {
|
||||
cacheKey: string
|
||||
children: JSX.Element | (() => JSX.Element)
|
||||
scrollContainer?: Accessor<HTMLElement | undefined | null>
|
||||
threshold?: number
|
||||
minPlaceholderHeight?: number
|
||||
class?: string
|
||||
contentClass?: string
|
||||
placeholderClass?: string
|
||||
virtualizationEnabled?: Accessor<boolean>
|
||||
forceVisible?: Accessor<boolean>
|
||||
suspendMeasurements?: Accessor<boolean>
|
||||
onMeasured?: () => void
|
||||
onHeightChange?: (nextHeight: number, previousHeight: number, meta: VirtualItemHeightChangeMeta) => void
|
||||
id?: string
|
||||
}
|
||||
|
||||
export interface VirtualItemHeightChangeMeta {
|
||||
source: "initial-visible-measure" | "resize"
|
||||
previousCachedHeight: number | null
|
||||
isStaleCacheCorrection: boolean
|
||||
wasHidden: boolean
|
||||
}
|
||||
|
||||
export default function VirtualItem(props: VirtualItemProps) {
|
||||
const resolveContent = () => (typeof props.children === "function" ? (props.children as () => JSX.Element)() : props.children)
|
||||
const cachedHeight = sizeCache.get(props.cacheKey)
|
||||
const fallbackPlaceholderHeight = () => props.minPlaceholderHeight ?? MIN_PLACEHOLDER_HEIGHT
|
||||
// Default to hidden until we can determine visibility.
|
||||
// This avoids keeping heavy DOM alive when IntersectionObserver
|
||||
// doesn't fire (common for hidden/zero-sized scroll roots).
|
||||
const [isIntersecting, setIsIntersecting] = createSignal(false)
|
||||
// Keep measuredHeight aligned with the *effective layout height* while hidden.
|
||||
// When content first mounts, onHeightChange deltas should reflect the DOM's
|
||||
// placeholder height (not 0), otherwise scroll compensation can overshoot.
|
||||
const [measuredHeight, setMeasuredHeight] = createSignal(cachedHeight ?? fallbackPlaceholderHeight())
|
||||
let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0)
|
||||
let pendingVisibility: boolean | null = null
|
||||
let visibilityFrame: number | null = null
|
||||
let awaitingVisibleMeasurement = true
|
||||
let lastMeasurementWhileHidden = true
|
||||
const flushVisibility = () => {
|
||||
if (visibilityFrame !== null) {
|
||||
cancelAnimationFrame(visibilityFrame)
|
||||
visibilityFrame = null
|
||||
}
|
||||
if (pendingVisibility !== null) {
|
||||
setIsIntersecting(pendingVisibility)
|
||||
pendingVisibility = null
|
||||
}
|
||||
}
|
||||
const queueVisibility = (nextValue: boolean) => {
|
||||
pendingVisibility = nextValue
|
||||
if (visibilityFrame !== null) return
|
||||
visibilityFrame = requestAnimationFrame(() => {
|
||||
visibilityFrame = null
|
||||
if (pendingVisibility !== null) {
|
||||
setIsIntersecting(pendingVisibility)
|
||||
pendingVisibility = null
|
||||
}
|
||||
})
|
||||
}
|
||||
const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true)
|
||||
const measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
|
||||
const forceVisible = () => Boolean(props.forceVisible?.())
|
||||
const shouldHideContent = createMemo(() => {
|
||||
if (forceVisible()) return false
|
||||
if (!virtualizationEnabled()) return false
|
||||
return !isIntersecting()
|
||||
})
|
||||
|
||||
let wrapperRef: HTMLDivElement | undefined
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
let intersectionCleanup: (() => void) | undefined
|
||||
|
||||
function cleanupResizeObserver() {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
resizeObserver = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleVisibleMeasurements() {
|
||||
if (shouldHideContent() || measurementsSuspended()) return
|
||||
if (!contentRef) return
|
||||
queueMicrotask(() => {
|
||||
if (shouldHideContent() || measurementsSuspended()) return
|
||||
if (!contentRef) return
|
||||
updateMeasuredHeight()
|
||||
setupResizeObserver()
|
||||
})
|
||||
}
|
||||
|
||||
function cleanupIntersectionObserver() {
|
||||
if (intersectionCleanup) {
|
||||
intersectionCleanup()
|
||||
intersectionCleanup = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function persistMeasurement(nextHeight: number, meta?: { source: "initial-visible-measure" | "resize"; wasHidden: boolean }) {
|
||||
if (!Number.isFinite(nextHeight) || nextHeight < 0) {
|
||||
return
|
||||
}
|
||||
const before = measuredHeight()
|
||||
const normalized = nextHeight
|
||||
const previousCachedHeight = sizeCache.get(props.cacheKey) ?? null
|
||||
const previous = previousCachedHeight ?? measuredHeight()
|
||||
const measurementMeta: VirtualItemHeightChangeMeta = {
|
||||
source: meta?.source ?? "resize",
|
||||
previousCachedHeight,
|
||||
isStaleCacheCorrection:
|
||||
(meta?.source ?? "resize") === "initial-visible-measure" &&
|
||||
previousCachedHeight !== null &&
|
||||
normalized > 0 &&
|
||||
Math.abs(normalized - previousCachedHeight) > 1,
|
||||
wasHidden: meta?.wasHidden ?? shouldHideContent(),
|
||||
}
|
||||
// Only keep the previous measurement when the element reports 0 height.
|
||||
// Allow shrinkage so placeholder height matches real content height;
|
||||
// keeping the max height can cause mount/unmount jitter near the
|
||||
// virtualization boundary.
|
||||
const shouldKeepPrevious = previous > 0 && normalized === 0
|
||||
if (shouldKeepPrevious) {
|
||||
if (!hasReportedMeasurement) {
|
||||
hasReportedMeasurement = true
|
||||
props.onMeasured?.()
|
||||
}
|
||||
sizeCache.set(props.cacheKey, previous)
|
||||
setMeasuredHeight(previous)
|
||||
if (previous !== before) props.onHeightChange?.(previous, before, measurementMeta)
|
||||
return
|
||||
}
|
||||
if (normalized > 0) {
|
||||
sizeCache.set(props.cacheKey, normalized)
|
||||
if (!hasReportedMeasurement) {
|
||||
hasReportedMeasurement = true
|
||||
props.onMeasured?.()
|
||||
}
|
||||
}
|
||||
setMeasuredHeight(normalized)
|
||||
if (normalized !== before) props.onHeightChange?.(normalized, before, measurementMeta)
|
||||
}
|
||||
|
||||
function updateMeasuredHeight() {
|
||||
if (!contentRef) return
|
||||
if (measurementsSuspended()) return
|
||||
// Prefer subpixel-accurate height for scroll compensation.
|
||||
// offsetHeight rounds to integers which can accumulate error.
|
||||
const rect = contentRef.getBoundingClientRect()
|
||||
const next = Math.max(0, Math.round(rect.height * 2) / 2)
|
||||
const currentMeasured = measuredHeight()
|
||||
const measurementSource: "initial-visible-measure" | "resize" = awaitingVisibleMeasurement ? "initial-visible-measure" : "resize"
|
||||
const wasHidden = lastMeasurementWhileHidden
|
||||
if (measurementSource === "initial-visible-measure") {
|
||||
awaitingVisibleMeasurement = false
|
||||
lastMeasurementWhileHidden = false
|
||||
}
|
||||
if (next === currentMeasured) return
|
||||
persistMeasurement(next, { source: measurementSource, wasHidden })
|
||||
}
|
||||
|
||||
function setupResizeObserver() {
|
||||
if (!contentRef || measurementsSuspended()) return
|
||||
cleanupResizeObserver()
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
updateMeasuredHeight()
|
||||
return
|
||||
}
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (measurementsSuspended()) return
|
||||
updateMeasuredHeight()
|
||||
})
|
||||
resizeObserver.observe(contentRef)
|
||||
}
|
||||
|
||||
|
||||
function refreshIntersectionObserver(targetRoot: Element | Document | null) {
|
||||
cleanupIntersectionObserver()
|
||||
if (!wrapperRef) {
|
||||
setIsIntersecting(false)
|
||||
return
|
||||
}
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
setIsIntersecting(true)
|
||||
return
|
||||
}
|
||||
|
||||
const margin = props.threshold ?? DEFAULT_MARGIN_PX
|
||||
|
||||
// If the scroll root is hidden / 0x0, IntersectionObserver can report
|
||||
// `isIntersecting` in unexpected ways (often "true" with null rootBounds),
|
||||
// which keeps heavy DOM alive in background tabs.
|
||||
//
|
||||
// In that state, force-hide and skip attaching the observer. When the
|
||||
// pane becomes visible again, VirtualItem will re-run this setup and
|
||||
// re-attach the observer.
|
||||
const renderable = isRenderableRoot(targetRoot)
|
||||
if (!renderable) {
|
||||
setIsIntersecting(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Avoid doing an eager geometry read here.
|
||||
// During large list hydration / initial layout, wrapper rects can be
|
||||
// transiently 0/incorrect and cause many offscreen items to mount.
|
||||
// Rely on the observer callback (which we harden below) to determine
|
||||
// visibility.
|
||||
|
||||
const wrapperEl = wrapperRef
|
||||
intersectionCleanup = subscribeToSharedObserver(wrapperEl, targetRoot, margin, (entry) => {
|
||||
// IntersectionObserver can produce transient false-positives during pane
|
||||
// activation/layout transitions (e.g. `isIntersecting: true` for items far
|
||||
// outside the scroll root). For element roots, prefer explicit rect math.
|
||||
if (targetRoot && !(targetRoot instanceof Document)) {
|
||||
// When rootBounds is null we cannot trust the entry; treat as hidden.
|
||||
if (entry.rootBounds === null) {
|
||||
queueVisibility(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const rootRect = (targetRoot as Element).getBoundingClientRect()
|
||||
const visible = shouldRenderByRects({
|
||||
wrapperRect: wrapperEl.getBoundingClientRect(),
|
||||
rootRect: { top: rootRect.top, bottom: rootRect.bottom },
|
||||
margin,
|
||||
})
|
||||
queueVisibility(visible)
|
||||
return
|
||||
} catch {
|
||||
// Fall through to the entry-based heuristic.
|
||||
}
|
||||
}
|
||||
|
||||
const nextVisible = shouldRenderEntry(entry)
|
||||
queueVisibility(nextVisible)
|
||||
})
|
||||
}
|
||||
|
||||
function setWrapperRef(element: HTMLDivElement | null) {
|
||||
wrapperRef = element ?? undefined
|
||||
const root = props.scrollContainer ? props.scrollContainer() : null
|
||||
refreshIntersectionObserver(root ?? null)
|
||||
}
|
||||
|
||||
function setContentRef(element: HTMLDivElement | null) {
|
||||
contentRef = element ?? undefined
|
||||
if (contentRef) {
|
||||
queueMicrotask(() => {
|
||||
if (shouldHideContent() || measurementsSuspended()) return
|
||||
updateMeasuredHeight()
|
||||
setupResizeObserver()
|
||||
})
|
||||
} else {
|
||||
cleanupResizeObserver()
|
||||
}
|
||||
}
|
||||
createEffect(() => {
|
||||
const hidden = shouldHideContent()
|
||||
if (hidden) {
|
||||
awaitingVisibleMeasurement = true
|
||||
lastMeasurementWhileHidden = true
|
||||
}
|
||||
if (hidden || measurementsSuspended()) {
|
||||
cleanupResizeObserver()
|
||||
}
|
||||
if (!hidden && !measurementsSuspended() && contentRef) {
|
||||
scheduleVisibleMeasurements()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
createEffect(() => {
|
||||
const key = props.cacheKey
|
||||
|
||||
const cached = sizeCache.get(key)
|
||||
if (cached !== undefined) {
|
||||
setMeasuredHeight(cached)
|
||||
} else {
|
||||
setMeasuredHeight(fallbackPlaceholderHeight())
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
measurementsSuspended()
|
||||
const root = props.scrollContainer ? props.scrollContainer() : null
|
||||
refreshIntersectionObserver(root ?? null)
|
||||
})
|
||||
|
||||
const placeholderHeight = createMemo(() => {
|
||||
|
||||
const seenHeight = measuredHeight()
|
||||
if (seenHeight > 0) {
|
||||
return seenHeight
|
||||
}
|
||||
return props.minPlaceholderHeight ?? MIN_PLACEHOLDER_HEIGHT
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
cleanupResizeObserver()
|
||||
cleanupIntersectionObserver()
|
||||
flushVisibility()
|
||||
})
|
||||
|
||||
const wrapperClass = () => ["virtual-item-wrapper", props.class].filter(Boolean).join(" ")
|
||||
const contentClass = () => {
|
||||
const classes = ["virtual-item-content", props.contentClass]
|
||||
if (shouldHideContent()) {
|
||||
classes.push("virtual-item-content-hidden")
|
||||
}
|
||||
return classes.filter(Boolean).join(" ")
|
||||
}
|
||||
const placeholderClass = () => ["virtual-item-placeholder", props.placeholderClass].filter(Boolean).join(" ")
|
||||
const lazyContent = createMemo<JSX.Element | null>(() => {
|
||||
if (shouldHideContent()) return null
|
||||
return resolveContent()
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={setWrapperRef} id={props.id} class={wrapperClass()} style={{ width: "100%" }}>
|
||||
<div
|
||||
class={placeholderClass()}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: shouldHideContent() ? `${placeholderHeight()}px` : undefined,
|
||||
}}
|
||||
>
|
||||
<div ref={setContentRef} class={contentClass()}>
|
||||
{lazyContent()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
setWorktreeSlugForParentSession,
|
||||
} from "../stores/worktrees"
|
||||
import { sessions } from "../stores/sessions"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
@@ -25,8 +26,6 @@ type WorktreeOption =
|
||||
| { kind: "action"; key: "__create__"; label: string }
|
||||
| { kind: "worktree"; key: string; slug: string; directory: string; raw: WorktreeDescriptor }
|
||||
|
||||
const CREATE_OPTION: WorktreeOption = { kind: "action", key: "__create__", label: "+ Create worktree" }
|
||||
|
||||
function preventSelectPress(event: PointerEvent | MouseEvent) {
|
||||
// Prevent Select.Item from treating this as a selection.
|
||||
// We intentionally prevent default to stop Kobalte's internal press handling.
|
||||
@@ -71,6 +70,7 @@ interface WorktreeSelectorProps {
|
||||
}
|
||||
|
||||
export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
||||
const { t } = useI18n()
|
||||
const [isOpen, setIsOpen] = createSignal(false)
|
||||
const [createOpen, setCreateOpen] = createSignal(false)
|
||||
const [createSlug, setCreateSlug] = createSignal("")
|
||||
@@ -99,7 +99,8 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
||||
directory: wt.directory,
|
||||
raw: wt,
|
||||
}))
|
||||
return [CREATE_OPTION, ...mapped]
|
||||
const createOption: WorktreeOption = { kind: "action", key: "__create__", label: t("instanceShell.worktree.create") }
|
||||
return [createOption, ...mapped]
|
||||
})
|
||||
|
||||
const selectedOption = createMemo<WorktreeOption | undefined>(() => {
|
||||
|
||||
23
packages/ui/src/lib/external-url.ts
Normal file
23
packages/ui/src/lib/external-url.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { isTauriHost } from "./runtime-env"
|
||||
|
||||
export async function openExternalUrl(url: string, context = "ui"): Promise<void> {
|
||||
if (typeof window === "undefined") {
|
||||
return
|
||||
}
|
||||
|
||||
if (isTauriHost()) {
|
||||
try {
|
||||
const { openUrl } = await import("@tauri-apps/plugin-opener")
|
||||
await openUrl(url)
|
||||
return
|
||||
} catch (error) {
|
||||
console.warn(`[${context}] unable to open via system opener`, error)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
window.open(url, "_blank", "noopener,noreferrer")
|
||||
} catch (error) {
|
||||
console.warn(`[${context}] unable to open external url`, error)
|
||||
}
|
||||
}
|
||||
@@ -2,27 +2,32 @@ import { createContext, createEffect, createMemo, createSignal, onCleanup, onMou
|
||||
import type { ParentComponent } from "solid-js"
|
||||
import { useConfig } from "../../stores/preferences"
|
||||
import { enMessages } from "./messages/en"
|
||||
import { esMessages } from "./messages/es"
|
||||
import { frMessages } from "./messages/fr"
|
||||
import { ruMessages } from "./messages/ru"
|
||||
import { jaMessages } from "./messages/ja"
|
||||
import { zhHansMessages } from "./messages/zh-Hans"
|
||||
|
||||
type Messages = Record<string, string>
|
||||
|
||||
export type TranslateParams = Record<string, unknown>
|
||||
|
||||
export type Locale = "en" | "es" | "fr" | "ru" | "ja" | "zh-Hans"
|
||||
export type Locale = "en" | "es" | "fr" | "ru" | "ja" | "zh-Hans" | "he"
|
||||
|
||||
const SUPPORTED_LOCALES: readonly Locale[] = ["en", "es", "fr", "ru", "ja", "zh-Hans"] as const
|
||||
const SUPPORTED_LOCALES: readonly Locale[] = ["en", "es", "fr", "ru", "ja", "zh-Hans", "he"] as const
|
||||
const SUPPORTED_LOCALES_BY_LOWER = new Map(SUPPORTED_LOCALES.map((locale) => [locale.toLowerCase(), locale]))
|
||||
const RTL_LOCALES = new Set<Locale>(["he"])
|
||||
|
||||
const messagesByLocale: Record<Locale, Messages> = {
|
||||
en: enMessages,
|
||||
es: esMessages,
|
||||
fr: frMessages,
|
||||
ru: ruMessages,
|
||||
ja: jaMessages,
|
||||
"zh-Hans": zhHansMessages,
|
||||
const localeMessagesCache = new Map<Locale, Messages>([["en", enMessages]])
|
||||
const localeMessagesPromises = new Map<Locale, Promise<Messages>>()
|
||||
|
||||
const localeLoaders: Record<Locale, () => Promise<Messages>> = {
|
||||
en: async () => enMessages,
|
||||
es: async () => (await import("./messages/es")).esMessages,
|
||||
fr: async () => (await import("./messages/fr")).frMessages,
|
||||
ru: async () => (await import("./messages/ru")).ruMessages,
|
||||
ja: async () => (await import("./messages/ja")).jaMessages,
|
||||
"zh-Hans": async () => (await import("./messages/zh-Hans")).zhHansMessages,
|
||||
he: async () => (await import("./messages/he")).heMessages,
|
||||
}
|
||||
|
||||
function getLocaleDirection(locale: Locale): "ltr" | "rtl" {
|
||||
return RTL_LOCALES.has(locale) ? "rtl" : "ltr"
|
||||
}
|
||||
|
||||
function normalizeLocaleTag(value: string): string {
|
||||
@@ -34,8 +39,7 @@ function matchSupportedLocale(value: string | undefined): Locale | null {
|
||||
|
||||
const normalized = normalizeLocaleTag(value)
|
||||
const lower = normalized.toLowerCase()
|
||||
const supportedLower = new Map(SUPPORTED_LOCALES.map((locale) => [locale.toLowerCase(), locale]))
|
||||
const exact = supportedLower.get(lower)
|
||||
const exact = SUPPORTED_LOCALES_BY_LOWER.get(lower)
|
||||
if (exact) return exact
|
||||
|
||||
const parts = lower.split("-")
|
||||
@@ -43,11 +47,11 @@ function matchSupportedLocale(value: string | undefined): Locale | null {
|
||||
if (!base) return null
|
||||
|
||||
if (base === "zh") {
|
||||
const zhHans = supportedLower.get("zh-hans")
|
||||
const zhHans = SUPPORTED_LOCALES_BY_LOWER.get("zh-hans")
|
||||
return zhHans ?? null
|
||||
}
|
||||
|
||||
const baseMatch = supportedLower.get(base)
|
||||
const baseMatch = SUPPORTED_LOCALES_BY_LOWER.get(base)
|
||||
return baseMatch ?? null
|
||||
}
|
||||
|
||||
@@ -84,8 +88,54 @@ function translateFrom(messages: Messages, key: string, params?: TranslateParams
|
||||
}
|
||||
|
||||
const [globalRevision, setGlobalRevision] = createSignal(0)
|
||||
const initialGlobalLocale: Locale = detectNavigatorLocale() ?? "en"
|
||||
let globalMessages: Messages = messagesByLocale[initialGlobalLocale]
|
||||
let globalMessages: Messages = enMessages
|
||||
let globalLocale: Locale = "en"
|
||||
|
||||
function getMessagesForLocale(locale: Locale): Messages {
|
||||
return localeMessagesCache.get(locale) ?? enMessages
|
||||
}
|
||||
|
||||
async function loadLocaleMessages(locale: Locale): Promise<Messages> {
|
||||
const cached = localeMessagesCache.get(locale)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const pending = localeMessagesPromises.get(locale)
|
||||
if (pending) {
|
||||
return pending
|
||||
}
|
||||
|
||||
const loader = localeLoaders[locale]
|
||||
const promise = loader()
|
||||
.then((messages) => {
|
||||
localeMessagesCache.set(locale, messages)
|
||||
localeMessagesPromises.delete(locale)
|
||||
return messages
|
||||
})
|
||||
.catch((error) => {
|
||||
localeMessagesPromises.delete(locale)
|
||||
throw error
|
||||
})
|
||||
|
||||
localeMessagesPromises.set(locale, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
export async function preloadLocaleMessages(preferredLocale?: string | null): Promise<Locale> {
|
||||
const resolvedLocale = matchSupportedLocale(preferredLocale ?? undefined) ?? detectNavigatorLocale() ?? "en"
|
||||
try {
|
||||
globalMessages = await loadLocaleMessages(resolvedLocale)
|
||||
globalLocale = resolvedLocale
|
||||
setGlobalRevision((value) => value + 1)
|
||||
return resolvedLocale
|
||||
} catch {
|
||||
globalMessages = enMessages
|
||||
globalLocale = "en"
|
||||
setGlobalRevision((value) => value + 1)
|
||||
return "en"
|
||||
}
|
||||
}
|
||||
|
||||
export function tGlobal(key: string, params?: TranslateParams): string {
|
||||
globalRevision()
|
||||
@@ -101,9 +151,12 @@ const I18nContext = createContext<I18nContextValue>()
|
||||
|
||||
export const I18nProvider: ParentComponent = (props) => {
|
||||
const { preferences } = useConfig()
|
||||
const [detectedLocale, setDetectedLocale] = createSignal<Locale>("en")
|
||||
|
||||
const previousMessages = globalMessages
|
||||
const [detectedLocale, setDetectedLocale] = createSignal<Locale>(globalLocale)
|
||||
const [resolvedLocale, setResolvedLocale] = createSignal<Locale>(globalLocale)
|
||||
const previousGlobalMessages = globalMessages
|
||||
const previousGlobalLocale = globalLocale
|
||||
const previousDocumentLanguage = typeof document !== "undefined" ? document.documentElement.lang : ""
|
||||
const previousDocumentDirection = typeof document !== "undefined" ? document.documentElement.dir : ""
|
||||
|
||||
onMount(() => {
|
||||
const detected = detectNavigatorLocale()
|
||||
@@ -115,20 +168,56 @@ export const I18nProvider: ParentComponent = (props) => {
|
||||
return configured ?? detectedLocale() ?? "en"
|
||||
})
|
||||
|
||||
const messages = createMemo<Messages>(() => messagesByLocale[locale()])
|
||||
const messages = createMemo<Messages>(() => getMessagesForLocale(resolvedLocale()))
|
||||
|
||||
function t(key: string, params?: TranslateParams): string {
|
||||
return translateFrom(messages(), key, params)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
globalMessages = messages()
|
||||
setGlobalRevision((value) => value + 1)
|
||||
const nextLocale = locale()
|
||||
let cancelled = false
|
||||
|
||||
void loadLocaleMessages(nextLocale)
|
||||
.then((loadedMessages) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
setResolvedLocale(nextLocale)
|
||||
globalLocale = nextLocale
|
||||
globalMessages = loadedMessages
|
||||
setGlobalRevision((value) => value + 1)
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
setResolvedLocale("en")
|
||||
globalMessages = enMessages
|
||||
globalLocale = "en"
|
||||
setGlobalRevision((value) => value + 1)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
cancelled = true
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
const activeLocale = locale()
|
||||
document.documentElement.dir = getLocaleDirection(activeLocale)
|
||||
document.documentElement.lang = activeLocale
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
globalMessages = previousMessages
|
||||
globalMessages = previousGlobalMessages
|
||||
globalLocale = previousGlobalLocale
|
||||
setGlobalRevision((value) => value + 1)
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.lang = previousDocumentLanguage
|
||||
document.documentElement.dir = previousDocumentDirection
|
||||
}
|
||||
})
|
||||
|
||||
const value: I18nContextValue = {
|
||||
|
||||
@@ -114,12 +114,26 @@ export const instanceMessages = {
|
||||
"instanceShell.sessionChanges.filesChanged": "{count} files changed",
|
||||
"instanceShell.sessionChanges.actions.show": "Show changes",
|
||||
|
||||
"instanceShell.gitChanges.noSessionSelected": "Select a session to view git changes.",
|
||||
"instanceShell.gitChanges.loading": "Loading git changes...",
|
||||
"instanceShell.gitChanges.empty": "No git changes yet.",
|
||||
"instanceShell.gitChanges.deleted": "Deleted",
|
||||
|
||||
"instanceShell.filesShell.fileListTitle": "File list",
|
||||
"instanceShell.filesShell.mobileSelectorLabel": "Select file",
|
||||
"instanceShell.filesShell.mobileSelectorEmpty": "Select a file",
|
||||
"instanceShell.filesShell.viewerTitle": "Change viewer",
|
||||
"instanceShell.filesShell.viewerPlaceholder": "Detailed change rendering will be added in the next step.",
|
||||
"instanceShell.filesShell.viewerEmpty": "No file selected.",
|
||||
"instanceShell.filesShell.hideFiles": "Hide files",
|
||||
"instanceShell.filesShell.showFiles": "Show files",
|
||||
"instanceShell.diff.hideUnchanged": "Hide unchanged regions",
|
||||
"instanceShell.diff.showFull": "Show full file",
|
||||
"instanceShell.diff.switchToSplit": "Switch to split view",
|
||||
"instanceShell.diff.switchToUnified": "Switch to unified view",
|
||||
"instanceShell.diff.enableWordWrap": "Enable word wrap",
|
||||
"instanceShell.diff.disableWordWrap": "Disable word wrap",
|
||||
"instanceShell.worktree.create": "+ Create worktree",
|
||||
|
||||
"instanceShell.plan.noSessionSelected": "Select a session to view plan.",
|
||||
"instanceShell.plan.empty": "Nothing planned yet.",
|
||||
|
||||
@@ -90,6 +90,7 @@ export const instanceMessages = {
|
||||
|
||||
"instanceShell.rightPanel.title": "Panel de estado",
|
||||
"instanceShell.rightPanel.tabs.changes": "Cambios",
|
||||
"instanceShell.rightPanel.tabs.gitChanges": "Cambios de Git",
|
||||
"instanceShell.rightPanel.tabs.files": "Archivos",
|
||||
"instanceShell.rightPanel.tabs.status": "Estado",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho",
|
||||
@@ -112,6 +113,10 @@ export const instanceMessages = {
|
||||
"instanceShell.sessionChanges.filesChanged": "{count} archivos cambiados",
|
||||
"instanceShell.sessionChanges.actions.show": "Mostrar cambios",
|
||||
|
||||
"instanceShell.gitChanges.loading": "Cargando cambios de Git...",
|
||||
"instanceShell.gitChanges.empty": "Aún no hay cambios de Git.",
|
||||
"instanceShell.gitChanges.deleted": "Eliminado",
|
||||
|
||||
"instanceShell.filesShell.fileListTitle": "Lista de archivos",
|
||||
"instanceShell.filesShell.mobileSelectorLabel": "Seleccionar archivo",
|
||||
"instanceShell.filesShell.mobileSelectorEmpty": "Selecciona un archivo",
|
||||
|
||||
@@ -90,6 +90,7 @@ export const instanceMessages = {
|
||||
|
||||
"instanceShell.rightPanel.title": "Panneau d'état",
|
||||
"instanceShell.rightPanel.tabs.changes": "Modifications",
|
||||
"instanceShell.rightPanel.tabs.gitChanges": "Changements Git",
|
||||
"instanceShell.rightPanel.tabs.files": "Fichiers",
|
||||
"instanceShell.rightPanel.tabs.status": "Statut",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "Onglets du panneau droit",
|
||||
@@ -112,6 +113,10 @@ export const instanceMessages = {
|
||||
"instanceShell.sessionChanges.filesChanged": "{count} fichiers modifiés",
|
||||
"instanceShell.sessionChanges.actions.show": "Afficher les changements",
|
||||
|
||||
"instanceShell.gitChanges.loading": "Chargement des changements Git...",
|
||||
"instanceShell.gitChanges.empty": "Aucun changement Git pour l'instant.",
|
||||
"instanceShell.gitChanges.deleted": "Supprimé",
|
||||
|
||||
"instanceShell.filesShell.fileListTitle": "Liste des fichiers",
|
||||
"instanceShell.filesShell.mobileSelectorLabel": "Sélectionner un fichier",
|
||||
"instanceShell.filesShell.mobileSelectorEmpty": "Sélectionnez un fichier",
|
||||
|
||||
6
packages/ui/src/lib/i18n/messages/he/advancedSettings.ts
Normal file
6
packages/ui/src/lib/i18n/messages/he/advancedSettings.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const advancedSettingsMessages = {
|
||||
"advancedSettings.title": "הגדרות מתקדמות",
|
||||
"advancedSettings.environmentVariables.title": "משתני סביבה",
|
||||
"advancedSettings.environmentVariables.subtitle": "מוחלים בכל פעם שמופע OpenCode חדש מופעל",
|
||||
"advancedSettings.actions.close": "סגור",
|
||||
} as const
|
||||
42
packages/ui/src/lib/i18n/messages/he/app.ts
Normal file
42
packages/ui/src/lib/i18n/messages/he/app.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export const appMessages = {
|
||||
"app.launchError.title": "לא ניתן להפעיל את OpenCode",
|
||||
"app.launchError.description": "לא הצלחנו להפעיל את קובץ ה-OpenCode שנבחר. בדוק את פלט השגיאה למטה או בחר קובץ בינארי אחר מהגדרות OpenCode.",
|
||||
"app.launchError.binaryPathLabel": "נתיב הקובץ הבינארי",
|
||||
"app.launchError.errorOutputLabel": "פלט שגיאה",
|
||||
"app.launchError.openAdvancedSettings": "פתח הגדרות OpenCode",
|
||||
"app.launchError.close": "סגור",
|
||||
"app.launchError.closeTitle": "סגור (Esc)",
|
||||
"app.launchError.fallbackMessage": "הפעלת סביבת העבודה נכשלה",
|
||||
|
||||
"app.stopInstance.confirmMessage": "לעצור את מופע OpenCode? פעולה זו תעצור את השרת.",
|
||||
"app.stopInstance.title": "עצור מופע",
|
||||
"app.stopInstance.confirmLabel": "עצור",
|
||||
"app.stopInstance.cancelLabel": "המשך להריץ",
|
||||
|
||||
"emptyState.logoAlt": "לוגו CodeNomad",
|
||||
"emptyState.brandTitle": "CodeNomad",
|
||||
"emptyState.tagline": "בחר תיקייה כדי להתחיל לתכנת עם AI",
|
||||
"emptyState.actions.selectFolder": "בחר תיקייה",
|
||||
"emptyState.actions.selecting": "בוחר...",
|
||||
"emptyState.keyboardShortcut": "קיצור מקלדת: {shortcut}",
|
||||
"emptyState.examples": "דוגמאות: {example}",
|
||||
"emptyState.multipleInstances": "ניתן לפתוח מספר מופעים של אותה תיקייה",
|
||||
|
||||
"releases.upgradeRequired.title": "נדרש שדרוג",
|
||||
"releases.upgradeRequired.message.withVersion": "שדרג ל-CodeNomad {version} כדי להשתמש בממשק המעודכן.",
|
||||
"releases.upgradeRequired.message.noVersion": "שדרג את CodeNomad כדי להשתמש בממשק המעודכן.",
|
||||
"releases.upgradeRequired.action.getUpdate": "קבל עדכון",
|
||||
|
||||
"releases.uiUpdated.title": "הממשק עודכן",
|
||||
"releases.uiUpdated.message": "הממשק עודכן לגרסה {version}.",
|
||||
|
||||
"releases.devUpdateAvailable.title": "גרסת פיתוח זמינה",
|
||||
"releases.devUpdateAvailable.message": "גרסת פיתוח חדשה זמינה: {version}.",
|
||||
"releases.devUpdateAvailable.action": "צפה בגרסה",
|
||||
|
||||
"theme.mode.system": "מערכת",
|
||||
"theme.mode.light": "בהיר",
|
||||
"theme.mode.dark": "כהה",
|
||||
"theme.toggle.title": "ערכת נושא: {mode}",
|
||||
"theme.toggle.ariaLabel": "ערכת נושא: {mode}",
|
||||
} as const
|
||||
176
packages/ui/src/lib/i18n/messages/he/commands.ts
Normal file
176
packages/ui/src/lib/i18n/messages/he/commands.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
export const commandMessages = {
|
||||
"commandPalette.title": "לוח פקודות",
|
||||
"commandPalette.description": "חיפוש והפעלה של פקודות",
|
||||
"commandPalette.searchPlaceholder": "הקלד פקודה או חיפוש...",
|
||||
"commandPalette.empty": "לא נמצאו פקודות עבור \"{query}\"",
|
||||
"commandPalette.category.customCommands": "פקודות מותאמות אישית",
|
||||
"commandPalette.category.instance": "מופע",
|
||||
"commandPalette.category.session": "סשן",
|
||||
"commandPalette.category.agentModel": "סוכן ומודל",
|
||||
"commandPalette.category.inputFocus": "קלט ופוקוס",
|
||||
"commandPalette.category.system": "מערכת",
|
||||
"commandPalette.category.other": "אחר",
|
||||
|
||||
"commands.newInstance.label": "מופע חדש",
|
||||
"commands.newInstance.description": "פתח בורר תיקיות ליצירת מופע חדש",
|
||||
"commands.newInstance.keywords": "תיקייה, פרויקט, סביבת עבודה",
|
||||
|
||||
"commands.closeInstance.label": "סגור מופע",
|
||||
"commands.closeInstance.description": "עצור את השרת של המופע הנוכחי",
|
||||
"commands.closeInstance.keywords": "עצור, סגור",
|
||||
|
||||
"commands.nextInstance.label": "מופע הבא",
|
||||
"commands.nextInstance.description": "עבור למופע הבא",
|
||||
"commands.nextInstance.keywords": "החלף, נווט",
|
||||
|
||||
"commands.previousInstance.label": "מופע קודם",
|
||||
"commands.previousInstance.description": "עבור למופע הקודם",
|
||||
"commands.previousInstance.keywords": "החלף, נווט",
|
||||
|
||||
"commands.newSession.label": "סשן חדש",
|
||||
"commands.newSession.description": "צור סשן הורה חדש",
|
||||
"commands.newSession.keywords": "צור, התחל",
|
||||
|
||||
"commands.closeSession.label": "סגור סשן",
|
||||
"commands.closeSession.description": "סגור את סשן ההורה הנוכחי",
|
||||
"commands.closeSession.keywords": "סגור, עצור",
|
||||
|
||||
"commands.scrubSessions.label": "נקה סשנים",
|
||||
"commands.scrubSessions.description": "הסר סשנים ריקים, סשני תת-סוכן שסיימו את משימתם הראשית, וסשני פיצול מיותרים.",
|
||||
"commands.scrubSessions.keywords": "ניקוי, ריק, סשנים, הסר, מחק",
|
||||
|
||||
"commands.instanceInfo.label": "מידע על מופע",
|
||||
"commands.instanceInfo.description": "פתח את סקירת המופע ללוגים וסטטוס",
|
||||
"commands.instanceInfo.keywords": "מידע, לוגים, קונסולה, פלט",
|
||||
|
||||
"commands.nextSession.label": "סשן הבא",
|
||||
"commands.nextSession.description": "עבור לסשן הבא",
|
||||
"commands.nextSession.keywords": "החלף, נווט",
|
||||
|
||||
"commands.previousSession.label": "סשן קודם",
|
||||
"commands.previousSession.description": "עבור לסשן הקודם",
|
||||
"commands.previousSession.keywords": "החלף, נווט",
|
||||
|
||||
"commands.compactSession.label": "סכם סשן",
|
||||
"commands.compactSession.description": "סכם ודחוס את הסשן הנוכחי",
|
||||
"commands.compactSession.keywords": "סיכום, דחיסה",
|
||||
"commands.compactSession.errorFallback": "סיכום הסשן נכשל",
|
||||
"commands.compactSession.alert.title": "הסיכום נכשל",
|
||||
"commands.compactSession.alert.message": "הסיכום נכשל: {message}",
|
||||
|
||||
"commands.undoLastMessage.label": "בטל הודעה אחרונה",
|
||||
"commands.undoLastMessage.description": "בטל את ההודעה האחרונה",
|
||||
"commands.undoLastMessage.keywords": "חזרה, ביטול",
|
||||
"commands.undoLastMessage.none.title": "אין פעולות לביטול",
|
||||
"commands.undoLastMessage.none.message": "אין מה לבטל",
|
||||
"commands.undoLastMessage.failed.title": "הביטול נכשל",
|
||||
"commands.undoLastMessage.failed.message": "ביטול ההודעה נכשל",
|
||||
|
||||
"commands.openModelSelector.label": "פתח בורר מודלים",
|
||||
"commands.openModelSelector.description": "בחר מודל אחר",
|
||||
"commands.openModelSelector.keywords": "מודל, llm, ai",
|
||||
|
||||
"commands.selectModelVariant.label": "בחר גרסת מודל",
|
||||
"commands.selectModelVariant.description": "בחר רמת מאמץ חשיבה למודל הנוכחי",
|
||||
"commands.selectModelVariant.keywords": "גרסה, חשיבה, מאמץ",
|
||||
|
||||
"commands.openAgentSelector.label": "פתח בורר סוכנים",
|
||||
"commands.openAgentSelector.description": "בחר סוכן אחר",
|
||||
"commands.openAgentSelector.keywords": "סוכן, מצב",
|
||||
|
||||
"commands.clearInput.label": "נקה קלט",
|
||||
"commands.clearInput.description": "נקה את תיבת הטקסט של הפקודה",
|
||||
"commands.clearInput.keywords": "נקה, אפס",
|
||||
|
||||
"commands.promptSubmitShortcut.label.default": "Enter: שורה חדשה, Cmd/Ctrl+Enter: שלח פקודה",
|
||||
"commands.promptSubmitShortcut.label.swapped": "Enter: שלח פקודה, Cmd/Ctrl+Enter: שורה חדשה",
|
||||
"commands.promptSubmitShortcut.description": "החלף את התנהגות Enter ו-Cmd/Ctrl+Enter בקלט הפקודה",
|
||||
"commands.promptSubmitShortcut.keywords": "enter, cmd, ctrl, שלח, שורה חדשה, קיצור",
|
||||
|
||||
"commands.thinkingBlocks.label.show": "הצג חשיבה",
|
||||
"commands.thinkingBlocks.label.hide": "הסתר חשיבה",
|
||||
"commands.thinkingBlocks.description": "הצג או הסתר קטעי חשיבה של ה-AI",
|
||||
"commands.thinkingBlocks.keywords": "חשיבה, הצג, הסתר",
|
||||
|
||||
"commands.timelineToolCalls.label.show": "הצג קריאות כלי בציר הזמן",
|
||||
"commands.timelineToolCalls.label.hide": "הסתר קריאות כלי בציר הזמן",
|
||||
"commands.timelineToolCalls.description": "הצג/הסתר קריאות כלי בציר הודעות",
|
||||
"commands.timelineToolCalls.keywords": "ציר זמן, כלי, הצג, הסתר",
|
||||
|
||||
"commands.keyboardShortcutHints.label.show": "הצג רמזי קיצורי מקלדת",
|
||||
"commands.keyboardShortcutHints.label.hide": "הסתר רמזי קיצורי מקלדת",
|
||||
"commands.keyboardShortcutHints.description": "הצג או הסתר רמזי קיצורי מקלדת בכל הממשק",
|
||||
"commands.keyboardShortcutHints.description.disabledWeb": "מושבת בממשק Web (רמזי קיצורים תמיד מוסתרים)",
|
||||
"commands.keyboardShortcutHints.keywords": "קיצור, מקלדת, רמזים",
|
||||
|
||||
"commands.common.expanded": "פרוס",
|
||||
"commands.common.collapsed": "מכווץ",
|
||||
"commands.common.visible": "גלוי",
|
||||
"commands.common.hidden": "מוסתר",
|
||||
"commands.common.enabled": "מופעל",
|
||||
"commands.common.disabled": "מושבת",
|
||||
|
||||
"commands.thinkingBlocksDefault.label": "תצוגת חשיבה: {state}",
|
||||
"commands.thinkingBlocksDefault.description": "כווץ / פרוס קטעי חשיבה של ה-AI",
|
||||
"commands.thinkingBlocksDefault.keywords": "חשיבה, פרוס, כווץ, ברירת מחדל",
|
||||
|
||||
"commands.diffViewSplit.label": "השתמש בתצוגת diff מפוצלת",
|
||||
"commands.diffViewSplit.description": "הצג diff של קריאות כלי זה לצד זה",
|
||||
"commands.diffViewSplit.keywords": "diff, מפוצל, תצוגה",
|
||||
|
||||
"commands.diffViewUnified.label": "השתמש בתצוגת diff מאוחדת",
|
||||
"commands.diffViewUnified.description": "הצג diff של קריאות כלי בשורה אחת",
|
||||
"commands.diffViewUnified.keywords": "diff, מאוחד, תצוגה",
|
||||
|
||||
"commands.toolOutputsDefault.label": "ברירת מחדל לפלטי כלים · {state}",
|
||||
"commands.toolOutputsDefault.description": "החלף ברירת מחדל לפריסת פלטי כלים",
|
||||
"commands.toolOutputsDefault.keywords": "כלי, פלט, פרוס, כווץ",
|
||||
|
||||
"commands.diagnosticsDefault.label": "ברירת מחדל לאבחון · {state}",
|
||||
"commands.diagnosticsDefault.description": "החלף ברירת מחדל לפריסת פלט אבחון",
|
||||
"commands.diagnosticsDefault.keywords": "אבחון, פרוס, כווץ",
|
||||
|
||||
"commands.toolInputsVisibility.label": "נראות קלטי כלים · {state}",
|
||||
"commands.toolInputsVisibility.description": "הגדר נראות ברירת מחדל לארגומנטים של קריאות כלי",
|
||||
"commands.toolInputsVisibility.keywords": "כלי, קלטים, ארגומנטים, נראות, הסתר, הצג",
|
||||
|
||||
"commands.tokenUsageDisplay.label": "תצוגת שימוש בטוקנים · {state}",
|
||||
"commands.tokenUsageDisplay.description": "הצג או הסתר נתוני טוקנים ועלות להודעות הסוכן",
|
||||
"commands.tokenUsageDisplay.keywords": "טוקן, שימוש, עלות, נתונים",
|
||||
|
||||
"commands.autoCleanupBlankSessions.label": "ניקוי אוטומטי של סשנים ריקים · {state}",
|
||||
"commands.autoCleanupBlankSessions.description": "נקה אוטומטית סשנים ריקים בעת יצירת סשנים חדשים",
|
||||
"commands.autoCleanupBlankSessions.keywords": "אוטומטי, ניקוי, ריק, סשנים",
|
||||
|
||||
"commands.showHelp.label": "הצג עזרה",
|
||||
"commands.showHelp.description": "הצג קיצורי מקלדת ועזרה",
|
||||
"commands.showHelp.keywords": "קיצורים, עזרה",
|
||||
|
||||
"commands.custom.argumentsPrompt.message": "ארגומנטים עבור /{name}",
|
||||
"commands.custom.argumentsPrompt.title": "פקודה מותאמת אישית",
|
||||
"commands.custom.argumentsPrompt.inputLabel": "ארגומנטים",
|
||||
"commands.custom.argumentsPrompt.inputPlaceholder": "למשל: foo bar",
|
||||
"commands.custom.argumentsPrompt.confirmLabel": "הפעל",
|
||||
"commands.custom.argumentsPrompt.cancelLabel": "ביטול",
|
||||
"commands.custom.argumentsPrompt.openFailed.message": "פתיחת תיבת ארגומנטים נכשלה.",
|
||||
"commands.custom.argumentsPrompt.openFailed.title": "ארגומנטים לפקודה",
|
||||
"commands.custom.entries.descriptionFallback": "פקודה מותאמת אישית",
|
||||
"commands.custom.sessionRequired.message": "בחר סשן לפני הפעלת פקודה מותאמת אישית.",
|
||||
"commands.custom.sessionRequired.title": "נדרש סשן",
|
||||
"commands.custom.runFailed.message": "הפעלת הפקודה המותאמת אישית נכשלה. בדוק את הקונסולה לפרטים.",
|
||||
"commands.custom.runFailed.title": "הפקודה נכשלה",
|
||||
|
||||
"unifiedPicker.loading.searching": "מחפש...",
|
||||
"unifiedPicker.loading.loadingWorkspace": "טוען סביבת עבודה...",
|
||||
"unifiedPicker.title.command": "בחר פקודה",
|
||||
"unifiedPicker.title.mention": "בחר סוכן או קובץ",
|
||||
"unifiedPicker.empty": "לא נמצאו תוצאות",
|
||||
"unifiedPicker.sections.commands": "פקודות",
|
||||
"unifiedPicker.sections.agents": "סוכנים",
|
||||
"unifiedPicker.sections.files": "קבצים",
|
||||
"unifiedPicker.sections.workspaceRoot": "שורש סביבת העבודה",
|
||||
"unifiedPicker.badge.subagent": "תת-סוכן",
|
||||
"unifiedPicker.footer.navigate": "ניווט",
|
||||
"unifiedPicker.footer.select": "בחירה",
|
||||
"unifiedPicker.footer.close": "סגירה",
|
||||
} as const
|
||||
16
packages/ui/src/lib/i18n/messages/he/dialogs.ts
Normal file
16
packages/ui/src/lib/i18n/messages/he/dialogs.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const dialogMessages = {
|
||||
"alertDialog.fallbackTitle.info": "לתשומת לבך",
|
||||
"alertDialog.fallbackTitle.warning": "נא לבדוק",
|
||||
"alertDialog.fallbackTitle.error": "משהו השתבש",
|
||||
"alertDialog.actions.confirm": "אישור",
|
||||
"alertDialog.actions.run": "הפעל",
|
||||
"alertDialog.actions.ok": "אישור",
|
||||
"alertDialog.actions.cancel": "ביטול",
|
||||
"alertDialog.prompt.inputLabel": "קלט",
|
||||
|
||||
"backgroundProcessOutputDialog.title": "פלט תהליך רקע",
|
||||
"backgroundProcessOutputDialog.actions.close": "סגור",
|
||||
"backgroundProcessOutputDialog.loading": "טוען פלט...",
|
||||
"backgroundProcessOutputDialog.truncatedNotice": "הפלט קוצר לצורך התצוגה.",
|
||||
"backgroundProcessOutputDialog.loadErrorFallback": "טעינת הפלט נכשלה.",
|
||||
} as const
|
||||
43
packages/ui/src/lib/i18n/messages/he/filesystem.ts
Normal file
43
packages/ui/src/lib/i18n/messages/he/filesystem.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export const filesystemMessages = {
|
||||
"directoryBrowser.defaultDescription": "עיון בתיקיות תחת שורש סביבת העבודה המוגדר.",
|
||||
"directoryBrowser.close": "סגור",
|
||||
"directoryBrowser.currentFolder": "תיקייה נוכחית",
|
||||
"directoryBrowser.selectCurrent": "בחר נוכחית",
|
||||
"directoryBrowser.newFolder": "תיקייה חדשה",
|
||||
"directoryBrowser.creating": "יוצר…",
|
||||
"directoryBrowser.loadingFolders": "טוען תיקיות…",
|
||||
"directoryBrowser.noFolders": "אין תיקיות זמינות.",
|
||||
"directoryBrowser.upOneLevel": "עלה רמה אחת",
|
||||
"directoryBrowser.select": "בחר",
|
||||
"directoryBrowser.load.errorFallback": "לא ניתן לטעון את מערכת הקבצים",
|
||||
"directoryBrowser.createFolder.promptMessage": "צור תיקייה חדשה בספרייה הנוכחית.",
|
||||
"directoryBrowser.createFolder.title": "תיקייה חדשה",
|
||||
"directoryBrowser.createFolder.inputLabel": "שם תיקייה",
|
||||
"directoryBrowser.createFolder.inputPlaceholder": "למשל: my-new-project",
|
||||
"directoryBrowser.createFolder.confirmLabel": "צור",
|
||||
"directoryBrowser.createFolder.cancelLabel": "ביטול",
|
||||
"directoryBrowser.createFolder.invalidNameMessage": "נא להזין שם תיקייה יחיד.",
|
||||
"directoryBrowser.createFolder.invalidNameDetail": "שמות תיקיות אינם יכולים לכלול נטויות, '..', או '~'.",
|
||||
"directoryBrowser.createFolder.errorFallback": "יצירת התיקייה נכשלה",
|
||||
|
||||
"filesystemBrowser.descriptionFallback": "חפש נתיב תחת שורש סביבת העבודה המוגדר.",
|
||||
"filesystemBrowser.rootLabel": "שורש: {root}",
|
||||
"filesystemBrowser.actions.close": "סגור",
|
||||
"filesystemBrowser.actions.retry": "נסה שוב",
|
||||
"filesystemBrowser.actions.select": "בחר",
|
||||
"filesystemBrowser.filterLabel": "סינון",
|
||||
"filesystemBrowser.search.placeholder.directories": "חפש תיקיות",
|
||||
"filesystemBrowser.search.placeholder.files": "חפש קבצים",
|
||||
"filesystemBrowser.currentFolder.label": "תיקייה נוכחית",
|
||||
"filesystemBrowser.currentFolder.selectCurrent": "בחר נוכחית",
|
||||
"filesystemBrowser.loading.filesystem": "מערכת קבצים",
|
||||
"filesystemBrowser.loading.workspaceRoot": "שורש סביבת עבודה",
|
||||
"filesystemBrowser.loading.loadingWithPath": "טוען {path}…",
|
||||
"filesystemBrowser.empty.noEntries": "לא נמצאו רשומות.",
|
||||
"filesystemBrowser.navigation.upOneLevel": "עלה רמה אחת",
|
||||
"filesystemBrowser.hints.navigate": "ניווט",
|
||||
"filesystemBrowser.hints.select": "בחירה",
|
||||
"filesystemBrowser.hints.close": "סגירה",
|
||||
"filesystemBrowser.errors.loadFilesystemFallback": "לא ניתן לטעון את מערכת הקבצים",
|
||||
"filesystemBrowser.errors.openDirectoryFallback": "לא ניתן לפתוח את הספרייה",
|
||||
} as const
|
||||
42
packages/ui/src/lib/i18n/messages/he/folderSelection.ts
Normal file
42
packages/ui/src/lib/i18n/messages/he/folderSelection.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export const folderSelectionMessages = {
|
||||
"folderSelection.language.ariaLabel": "שפה",
|
||||
|
||||
"folderSelection.logoAlt": "לוגו CodeNomad",
|
||||
"folderSelection.tagline": "בחר תיקייה כדי להתחיל לתכנת עם AI",
|
||||
|
||||
"folderSelection.links.github": "CodeNomad GitHub",
|
||||
"folderSelection.links.githubStars": "כוכבי CodeNomad ב-GitHub",
|
||||
"folderSelection.links.discord": "CodeNomad Discord",
|
||||
|
||||
"folderSelection.empty.title": "אין תיקיות אחרונות",
|
||||
"folderSelection.empty.description": "עיין בתיקייה כדי להתחיל",
|
||||
|
||||
"folderSelection.recent.title": "תיקיות אחרונות",
|
||||
"folderSelection.recent.subtitle.one": "תיקייה אחת זמינה",
|
||||
"folderSelection.recent.subtitle.other": "{count} תיקיות זמינות",
|
||||
"folderSelection.recent.remove": "הסר מהרשימה האחרונה",
|
||||
|
||||
"folderSelection.browse.title": "עיון בתיקייה",
|
||||
"folderSelection.browse.subtitle": "בחר כל תיקייה במחשב שלך",
|
||||
"folderSelection.browse.button": "עיון בתיקיות",
|
||||
"folderSelection.browse.buttonOpening": "פותח...",
|
||||
|
||||
"folderSelection.advancedSettings": "הגדרות מתקדמות",
|
||||
"folderSelection.opencode": "OpenCode",
|
||||
|
||||
"folderSelection.hints.navigate": "ניווט",
|
||||
"folderSelection.hints.select": "בחירה",
|
||||
"folderSelection.hints.remove": "הסרה",
|
||||
"folderSelection.hints.browse": "עיון",
|
||||
|
||||
"folderSelection.loading.title": "מפעיל מופע...",
|
||||
"folderSelection.loading.subtitle": "המתן בזמן שאנו מכינים את סביבת העבודה שלך.",
|
||||
|
||||
"folderSelection.drop.title": "שחרר תיקייה כדי לפתוח אותה",
|
||||
"folderSelection.drop.subtitle": "התחל מופע חדש בתיקייה שנשחררה.",
|
||||
"folderSelection.drop.invalidTitle": "לא ניתן לפתוח את הפריט שנשחרר",
|
||||
"folderSelection.drop.invalidMessage": "שחרר תיקייה כדי להתחיל מופע חדש.",
|
||||
|
||||
"folderSelection.dialog.title": "בחר סביבת עבודה",
|
||||
"folderSelection.dialog.description": "בחר סביבת עבודה כדי להתחיל לתכנת.",
|
||||
} as const
|
||||
36
packages/ui/src/lib/i18n/messages/he/index.ts
Normal file
36
packages/ui/src/lib/i18n/messages/he/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { advancedSettingsMessages } from "./advancedSettings"
|
||||
import { appMessages } from "./app"
|
||||
import { commandMessages } from "./commands"
|
||||
import { dialogMessages } from "./dialogs"
|
||||
import { filesystemMessages } from "./filesystem"
|
||||
import { folderSelectionMessages } from "./folderSelection"
|
||||
import { instanceMessages } from "./instance"
|
||||
import { loadingScreenMessages } from "./loadingScreen"
|
||||
import { logMessages } from "./logs"
|
||||
import { markdownMessages } from "./markdown"
|
||||
import { messagingMessages } from "./messaging"
|
||||
import { remoteAccessMessages } from "./remoteAccess"
|
||||
import { sessionMessages } from "./session"
|
||||
import { settingsMessages } from "./settings"
|
||||
import { timeMessages } from "./time"
|
||||
import { toolCallMessages } from "./toolCall"
|
||||
import { mergeMessageParts } from "../merge"
|
||||
|
||||
export const heMessages = mergeMessageParts(
|
||||
folderSelectionMessages,
|
||||
advancedSettingsMessages,
|
||||
loadingScreenMessages,
|
||||
timeMessages,
|
||||
appMessages,
|
||||
dialogMessages,
|
||||
filesystemMessages,
|
||||
instanceMessages,
|
||||
logMessages,
|
||||
sessionMessages,
|
||||
messagingMessages,
|
||||
toolCallMessages,
|
||||
markdownMessages,
|
||||
settingsMessages,
|
||||
remoteAccessMessages,
|
||||
commandMessages,
|
||||
)
|
||||
166
packages/ui/src/lib/i18n/messages/he/instance.ts
Normal file
166
packages/ui/src/lib/i18n/messages/he/instance.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
export const instanceMessages = {
|
||||
"instanceTabs.new.title": "מופע חדש (Cmd/Ctrl+N)",
|
||||
"instanceTabs.new.ariaLabel": "מופע חדש",
|
||||
"instanceTabs.remote.title": "חיבור מרוחק",
|
||||
"instanceTabs.remote.ariaLabel": "חיבור מרוחק",
|
||||
|
||||
"instanceInfo.title": "מידע על המופע",
|
||||
"instanceInfo.labels.folder": "תיקייה",
|
||||
"instanceInfo.labels.project": "פרויקט",
|
||||
"instanceInfo.labels.versionControl": "בקרת גרסאות",
|
||||
"instanceInfo.labels.opencodeVersion": "גרסת OpenCode",
|
||||
"instanceInfo.labels.binaryPath": "נתיב קובץ בינארי",
|
||||
"instanceInfo.labels.environmentVariables": "משתני סביבה ({count})",
|
||||
"instanceInfo.loading": "טוען...",
|
||||
"instanceInfo.server.title": "שרת",
|
||||
"instanceInfo.server.port": "פורט:",
|
||||
"instanceInfo.server.pid": "PID:",
|
||||
"instanceInfo.server.status": "סטטוס:",
|
||||
|
||||
"instanceTab.status.permission": "ממתין לאישור",
|
||||
"instanceTab.status.compacting": "מסכם",
|
||||
"instanceTab.status.working": "עובד",
|
||||
"instanceTab.status.idle": "מוכן",
|
||||
"instanceTab.status.ariaLabel": "סטטוס מופע: {status}",
|
||||
"instanceTab.actions.close.ariaLabel": "סגור מופע",
|
||||
|
||||
"instanceShell.leftPanel.sessionsTitle": "סשנים",
|
||||
"instanceShell.leftPanel.instanceInfo": "מידע על המופע",
|
||||
|
||||
"instanceShell.leftDrawer.pin": "נעץ מגירה שמאלית",
|
||||
"instanceShell.leftDrawer.unpin": "שחרר נעיצת מגירה שמאלית",
|
||||
"instanceShell.leftDrawer.toggle.pinned": "המגירה השמאלית נעוצה",
|
||||
"instanceShell.leftDrawer.toggle.open": "פתח מגירה שמאלית",
|
||||
"instanceShell.leftDrawer.toggle.close": "סגור מגירה שמאלית",
|
||||
|
||||
"instanceShell.rightDrawer.pin": "נעץ מגירה ימנית",
|
||||
"instanceShell.rightDrawer.unpin": "שחרר נעיצת מגירה ימנית",
|
||||
"instanceShell.rightDrawer.toggle.pinned": "המגירה הימנית נעוצה",
|
||||
"instanceShell.rightDrawer.toggle.open": "פתח מגירה ימנית",
|
||||
"instanceShell.rightDrawer.toggle.close": "סגור מגירה ימנית",
|
||||
|
||||
"instanceShell.fullscreen.enter": "מסך מלא",
|
||||
"instanceShell.fullscreen.exit": "יציאה ממסך מלא",
|
||||
|
||||
"instanceShell.metrics.usedLabel": "בשימוש",
|
||||
"instanceShell.metrics.availableLabel": "זמין",
|
||||
|
||||
"instanceShell.commandPalette.openAriaLabel": "פתח לוח פקודות",
|
||||
"instanceShell.commandPalette.button": "לוח פקודות",
|
||||
|
||||
"instanceShell.connection.ariaLabel": "חיבור {status}",
|
||||
"instanceShell.connection.connected": "מחובר",
|
||||
"instanceShell.connection.connecting": "מתחבר...",
|
||||
"instanceShell.connection.disconnected": "מנותק",
|
||||
"instanceShell.connection.unknown": "לא ידוע",
|
||||
|
||||
"instanceWelcome.shortcuts.newSession": "סשן חדש",
|
||||
"instanceWelcome.empty.title": "אין סשנים קודמים",
|
||||
"instanceWelcome.empty.description": "צור סשן חדש למטה כדי להתחיל",
|
||||
"instanceWelcome.loading.title": "טוען סשנים",
|
||||
"instanceWelcome.loading.description": "מאחזר את הסשנים הקודמים שלך...",
|
||||
"instanceWelcome.resume.title": "המשך סשן",
|
||||
"instanceWelcome.resume.subtitle.one": "סשן אחד זמין",
|
||||
"instanceWelcome.resume.subtitle.other": "{count} סשנים זמינים",
|
||||
"instanceWelcome.session.untitled": "סשן ללא שם",
|
||||
"instanceWelcome.new.title": "התחל סשן חדש",
|
||||
"instanceWelcome.new.subtitle": "ישתמש אוטומטית בסוכן/מודל האחרון שלך",
|
||||
"instanceWelcome.new.createButton": "צור סשן",
|
||||
"instanceWelcome.overlay.close": "סגור",
|
||||
"instanceWelcome.actions.viewInstanceInfo": "צפה במידע על המופע",
|
||||
"instanceWelcome.actions.renameTitle": "שנה שם סשן",
|
||||
"instanceWelcome.actions.deleteTitle": "מחק סשן",
|
||||
"instanceWelcome.hints.navigate": "ניווט",
|
||||
"instanceWelcome.hints.jump": "קפיצה",
|
||||
"instanceWelcome.hints.firstLast": "ראשון/אחרון",
|
||||
"instanceWelcome.hints.resume": "המשך",
|
||||
"instanceWelcome.hints.delete": "מחיקה",
|
||||
"instanceWelcome.toasts.renameError": "לא ניתן לשנות שם הסשן",
|
||||
|
||||
"instanceDisconnected.title": "המופע התנתק",
|
||||
"instanceDisconnected.folderFallback": "סביבת עבודה זו",
|
||||
"instanceDisconnected.reasonFallback": "השרת הפסיק להגיב",
|
||||
"instanceDisconnected.description": "לא ניתן עוד להגיע ל-{folder}. סגור את הלשונית כדי להמשיך לעבוד.",
|
||||
"instanceDisconnected.details.title": "פרטים",
|
||||
"instanceDisconnected.details.folderLabel": "תיקייה:",
|
||||
"instanceDisconnected.actions.closeInstance": "סגור מופע",
|
||||
|
||||
"instanceShell.empty.title": "לא נבחר סשן",
|
||||
"instanceShell.empty.description": "בחר סשן לצפייה בהודעות",
|
||||
|
||||
"instanceShell.rightPanel.title": "לוח סטטוס",
|
||||
"instanceShell.rightPanel.tabs.changes": "שינויי סשן",
|
||||
"instanceShell.rightPanel.tabs.gitChanges": "שינויי Git",
|
||||
"instanceShell.rightPanel.tabs.files": "קבצים",
|
||||
"instanceShell.rightPanel.tabs.status": "סטטוס",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "לשוניות לוח ימני",
|
||||
"instanceShell.rightPanel.actions.refresh": "רענן",
|
||||
"instanceShell.rightPanel.sections.sessionChanges": "שינויי סשן",
|
||||
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "קבצים שהשתנו בסשן הנוכחי. מציג הוספות ומחיקות לכל קובץ.",
|
||||
"instanceShell.rightPanel.sections.plan": "תוכנית",
|
||||
"instanceShell.rightPanel.sections.plan.tooltip": "מפת הדרכים של הסוכן לסשן זה. עוקב אחר משימות, תת-משימות וסטטוס השלמתן.",
|
||||
"instanceShell.rightPanel.sections.backgroundProcesses": "מעטפות רקע",
|
||||
"instanceShell.rightPanel.sections.backgroundProcesses.tooltip": "תהליכים ממושכים שהופעלו על ידי הסוכן. ניתן לעקוב אחר פלטם, לעצור אותם או לסיים אותם.",
|
||||
"instanceShell.rightPanel.sections.mcp": "שרתי MCP",
|
||||
"instanceShell.rightPanel.sections.mcp.tooltip": "שרתי Model Context Protocol המרחיבים את יכולות הסוכן עם כלים ושירותים חיצוניים.",
|
||||
"instanceShell.rightPanel.sections.lsp": "שרתי LSP",
|
||||
"instanceShell.rightPanel.sections.lsp.tooltip": "שרתי Language Server Protocol המספקים בינת קוד, אבחון ותכונות ספציפיות לשפה.",
|
||||
"instanceShell.rightPanel.sections.plugins": "תוספים",
|
||||
"instanceShell.rightPanel.sections.plugins.tooltip": "תוספים המתאימים אישית את הממשק ואת התנהגות השרת, ומוסיפים תכונות מעבר ל-MCP ו-LSP.",
|
||||
|
||||
"instanceShell.sessionChanges.noSessionSelected": "בחר סשן לצפייה בשינויים.",
|
||||
"instanceShell.sessionChanges.loading": "מאחזר שינויי סשן...",
|
||||
"instanceShell.sessionChanges.empty": "אין שינויי סשן עדיין.",
|
||||
"instanceShell.sessionChanges.filesChanged": "{count} קבצים שונו",
|
||||
"instanceShell.sessionChanges.actions.show": "הצג שינויים",
|
||||
|
||||
"instanceShell.filesShell.fileListTitle": "רשימת קבצים",
|
||||
"instanceShell.filesShell.mobileSelectorLabel": "בחר קובץ",
|
||||
"instanceShell.filesShell.mobileSelectorEmpty": "בחר קובץ",
|
||||
"instanceShell.filesShell.viewerTitle": "מציג שינויים",
|
||||
"instanceShell.filesShell.viewerPlaceholder": "תצוגת שינויים מפורטת תתווסף בשלב הבא.",
|
||||
"instanceShell.filesShell.viewerEmpty": "לא נבחר קובץ.",
|
||||
"instanceShell.filesShell.hideFiles": "הסתר קבצים",
|
||||
"instanceShell.filesShell.showFiles": "הצג קבצים",
|
||||
"instanceShell.gitChanges.noSessionSelected": "בחר סשן לצפייה בשינויי Git.",
|
||||
"instanceShell.gitChanges.loading": "טוען שינויי Git…",
|
||||
"instanceShell.gitChanges.empty": "אין שינויי Git עדיין.",
|
||||
"instanceShell.diff.hideUnchanged": "הסתר אזורים ללא שינוי",
|
||||
"instanceShell.diff.showFull": "הצג קובץ מלא",
|
||||
"instanceShell.diff.switchToSplit": "עבור לתצוגה מפוצלת",
|
||||
"instanceShell.diff.switchToUnified": "עבור לתצוגה מאוחדת",
|
||||
"instanceShell.diff.enableWordWrap": "הפעל גלישת מילים",
|
||||
"instanceShell.diff.disableWordWrap": "כבה גלישת מילים",
|
||||
"instanceShell.worktree.create": "+ צור worktree",
|
||||
|
||||
"instanceShell.plan.noSessionSelected": "בחר סשן לצפייה בתוכנית.",
|
||||
"instanceShell.plan.empty": "עדיין לא תוכנן דבר.",
|
||||
|
||||
"instanceShell.backgroundProcesses.empty": "אין תהליכי רקע.",
|
||||
"instanceShell.backgroundProcesses.status": "סטטוס: {status}",
|
||||
"instanceShell.backgroundProcesses.output": "פלט: {sizeKb}KB",
|
||||
"instanceShell.backgroundProcesses.actions.output": "פלט",
|
||||
"instanceShell.backgroundProcesses.actions.stop": "עצור",
|
||||
"instanceShell.backgroundProcesses.actions.terminate": "סיים",
|
||||
|
||||
"versionPill.appWithVersion": "אפליקציה {version}",
|
||||
"versionPill.ui": "ממשק",
|
||||
"versionPill.uiWithVersion": "ממשק {version}",
|
||||
"versionPill.source": " ({source})",
|
||||
|
||||
"opencodeBinarySelector.title": "קובץ בינארי של OpenCode",
|
||||
"opencodeBinarySelector.subtitle": "בחר איזה קובץ הרצה OpenCode ישתמש",
|
||||
"opencodeBinarySelector.customPath.placeholder": "הזן נתיב לקובץ בינארי של opencode…",
|
||||
"opencodeBinarySelector.actions.add": "הוסף",
|
||||
"opencodeBinarySelector.actions.browse": "עיין אחר קובץ בינארי…",
|
||||
"opencodeBinarySelector.actions.removeTitle": "הסר קובץ בינארי",
|
||||
"opencodeBinarySelector.badge.systemPath": "השתמש בקובץ בינארי מנתיב המערכת",
|
||||
"opencodeBinarySelector.status.checkingVersions": "בודק גרסאות…",
|
||||
"opencodeBinarySelector.status.checking": "בודק…",
|
||||
"opencodeBinarySelector.dialog.title": "בחר קובץ בינארי של OpenCode",
|
||||
"opencodeBinarySelector.dialog.description": "עיין בקבצים החשופים על ידי שרת ה-CLI.",
|
||||
"opencodeBinarySelector.validation.invalidBinary": "קובץ בינארי לא תקין של OpenCode",
|
||||
"opencodeBinarySelector.validation.alreadyValidating": "כבר מאמת",
|
||||
"opencodeBinarySelector.display.systemPath": "{name} (נתיב מערכת)",
|
||||
"opencodeBinarySelector.versionLabel": "v{version}",
|
||||
} as const
|
||||
17
packages/ui/src/lib/i18n/messages/he/loadingScreen.ts
Normal file
17
packages/ui/src/lib/i18n/messages/he/loadingScreen.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export const loadingScreenMessages = {
|
||||
"loadingScreen.logoAlt": "לוגו CodeNomad",
|
||||
"loadingScreen.status.issue": "נתקלנו בבעיה",
|
||||
"loadingScreen.actions.showAnother": "הצג עוד",
|
||||
"loadingScreen.errors.missingRoot": "אלמנט השורש לטעינה לא נמצא",
|
||||
|
||||
"loadingScreen.phrases.neurons": "מחמם את הנוירונים של ה-AI…",
|
||||
"loadingScreen.phrases.daydreaming": "משכנע את ה-AI להפסיק לחלום בהקיץ…",
|
||||
"loadingScreen.phrases.goggles": "מצחצח את משקפי הקוד של ה-AI…",
|
||||
"loadingScreen.phrases.reorganizingFiles": "מבקש מה-AI להפסיק לארגן מחדש את הקבצים שלך…",
|
||||
"loadingScreen.phrases.coffee": "מאכיל את ה-AI עוד קפה…",
|
||||
"loadingScreen.phrases.nodeModules": "מלמד את ה-AI לא למחוק node_modules (שוב)…",
|
||||
"loadingScreen.phrases.actNatural": "אומר ל-AI להיראות טבעי לפני שתגיע…",
|
||||
"loadingScreen.phrases.rewritingHistory": "מבקש מה-AI בבקשה להפסיק לשכתב היסטוריה…",
|
||||
"loadingScreen.phrases.stretch": "מאפשר ל-AI להתמתח לפני ספרינט הקוד שלו…",
|
||||
"loadingScreen.phrases.keyboardControl": "משכנע את ה-AI לתת לך שליטה על המקלדת…",
|
||||
} as const
|
||||
27
packages/ui/src/lib/i18n/messages/he/logs.ts
Normal file
27
packages/ui/src/lib/i18n/messages/he/logs.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export const logMessages = {
|
||||
"logsView.title": "לוגי שרת",
|
||||
"logsView.actions.show": "הצג לוגי שרת",
|
||||
"logsView.actions.hide": "הסתר לוגי שרת",
|
||||
"logsView.envVars.title": "משתני סביבה ({count})",
|
||||
"logsView.paused.title": "לוגי השרת מושהים",
|
||||
"logsView.paused.description": "הפעל זרימה לצפייה בפעילות שרת OpenCode שלך.",
|
||||
"logsView.empty.waiting": "ממתין לפלט שרת...",
|
||||
"logsView.scrollToBottom": "גלול למטה",
|
||||
|
||||
"infoView.logs.title": "לוגי שרת",
|
||||
"infoView.logs.actions.show": "הצג לוגי שרת",
|
||||
"infoView.logs.actions.hide": "הסתר לוגי שרת",
|
||||
"infoView.logs.paused.title": "לוגי השרת מושהים",
|
||||
"infoView.logs.paused.description": "הפעל זרימה לצפייה בפעילות שרת OpenCode שלך.",
|
||||
"infoView.logs.empty.waiting": "ממתין לפלט שרת...",
|
||||
"infoView.logs.scrollToBottom": "גלול למטה",
|
||||
|
||||
"infoView.dispose.actions.dispose": "בטל מופע",
|
||||
"infoView.dispose.actions.disposing": "מבטל...",
|
||||
"infoView.dispose.confirm.title": "לבטל את המופע?",
|
||||
"infoView.dispose.confirm.message": "פעולה זו מנקה את המצב השמור לפי פרויקט עבור ספרייה זו ומטעינה מחדש את המופע.",
|
||||
"infoView.dispose.confirm.confirmLabel": "בטל",
|
||||
"infoView.dispose.confirm.cancelLabel": "ביטול",
|
||||
"infoView.dispose.toast.success": "המופע בוטל. מטעין מחדש...",
|
||||
"infoView.dispose.toast.error": "ביטול המופע נכשל.",
|
||||
} as const
|
||||
7
packages/ui/src/lib/i18n/messages/he/markdown.ts
Normal file
7
packages/ui/src/lib/i18n/messages/he/markdown.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const markdownMessages = {
|
||||
"markdown.codeBlock.copy.label": "העתק",
|
||||
"markdown.codeBlock.copy.copied": "הועתק!",
|
||||
"markdown.codeBlock.copy.failed": "נכשל",
|
||||
|
||||
"markdown.copy": "העתק",
|
||||
} as const
|
||||
141
packages/ui/src/lib/i18n/messages/he/messaging.ts
Normal file
141
packages/ui/src/lib/i18n/messages/he/messaging.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
export const messagingMessages = {
|
||||
"messageListHeader.sidebar.openSessionListAriaLabel": "פתח רשימת סשנים",
|
||||
"messageListHeader.metrics.usedLabel": "בשימוש",
|
||||
"messageListHeader.metrics.availableLabel": "זמין",
|
||||
"messageListHeader.commandPalette.ariaLabel": "פתח לוח פקודות",
|
||||
"messageListHeader.commandPalette.button": "לוח פקודות",
|
||||
"messageListHeader.connection.connected": "מחובר",
|
||||
"messageListHeader.connection.connecting": "מתחבר...",
|
||||
"messageListHeader.connection.disconnected": "מנותק",
|
||||
|
||||
"messageSection.empty.logoAlt": "לוגו CodeNomad",
|
||||
"messageSection.empty.brandTitle": "CodeNomad",
|
||||
"messageSection.empty.title": "התחל שיחה",
|
||||
"messageSection.empty.description": "הקלד הודעה למטה או פתח את לוח הפקודות:",
|
||||
"messageSection.empty.tips.commandPalette": "לוח פקודות",
|
||||
"messageSection.empty.tips.askAboutCodebase": "שאל על בסיס הקוד שלך",
|
||||
"messageSection.empty.tips.attachFilesPrefix": "צרף קבצים עם",
|
||||
"messageSection.loading.messages": "טוען הודעות...",
|
||||
"messageSection.scroll.toFirstAriaLabel": "גלול להודעה הראשונה",
|
||||
"messageSection.scroll.toLatestAriaLabel": "גלול להודעה האחרונה",
|
||||
"messageSection.quote.addAsQuote": "הוסף כציטוט",
|
||||
"messageSection.quote.addAsCode": "הוסף כקוד",
|
||||
"messageSection.quote.copy": "העתק",
|
||||
"messageSection.quote.copied": "הועתק!",
|
||||
"messageSection.quote.copyFailed": "ההעתקה נכשלה",
|
||||
"messageTimeline.ariaLabel": "ציר זמן הודעות",
|
||||
"messageTimeline.segment.user.label": "אתה",
|
||||
"messageTimeline.segment.assistant.label": "סוכן",
|
||||
"messageTimeline.segment.compaction.label": "סיכום",
|
||||
"messageTimeline.tool.fallbackLabel": "קריאת כלי",
|
||||
"messageTimeline.tooltip.userFallback": "הודעת משתמש",
|
||||
"messageTimeline.tooltip.assistantFallback": "תגובת הסוכן",
|
||||
"messageTimeline.tooltip.compaction.auto": "סיכום אוטומטי",
|
||||
"messageTimeline.tooltip.compaction.manual": "סיכום ידני",
|
||||
"messageTimeline.text.filePrefix": "[קובץ] {filename}",
|
||||
"messageTimeline.text.attachment": "קובץ מצורף",
|
||||
"messageBlock.tool.header": "קריאת כלי",
|
||||
"messageBlock.tool.unknown": "לא ידוע",
|
||||
"messageBlock.tool.goToSession.label": "עבור לסשן",
|
||||
"messageBlock.tool.goToSession.title": "עבור לסשן",
|
||||
"messageBlock.tool.goToSession.unavailableTitle": "הסשן עדיין אינו זמין",
|
||||
"messageBlock.tool.deletePart.label": "מחק חלק",
|
||||
"messageBlock.tool.deletePart.deleting": "מוחק...",
|
||||
"messageBlock.tool.deletePart.title": "מחק את פלט קריאת הכלי הזו",
|
||||
"messageBlock.tool.deletePart.failed.title": "המחיקה נכשלה",
|
||||
"messageBlock.tool.deletePart.failed.message": "מחיקת פלט קריאת הכלי נכשלה",
|
||||
|
||||
"messageBlock.compaction.ariaLabel": "סיכום סשן",
|
||||
"messageBlock.compaction.autoLabel": "הסשן סוכם אוטומטית",
|
||||
"messageBlock.compaction.manualLabel": "הסשן סוכם על ידך",
|
||||
"messageBlock.usage.input": "קלט",
|
||||
"messageBlock.usage.output": "פלט",
|
||||
"messageBlock.usage.reasoning": "חשיבה",
|
||||
"messageBlock.usage.cacheRead": "קריאת מטמון",
|
||||
"messageBlock.usage.cacheWrite": "כתיבת מטמון",
|
||||
"messageBlock.usage.cost": "עלות",
|
||||
"messageBlock.step.agentLabel": "סוכן: {agent}",
|
||||
"messageBlock.step.modelLabel": "מודל: {model}",
|
||||
"messageBlock.reasoning.thinkingLabel": "חשיבה",
|
||||
"messageBlock.reasoning.expandAriaLabel": "פרוס חשיבה",
|
||||
"messageBlock.reasoning.collapseAriaLabel": "כווץ חשיבה",
|
||||
"messageBlock.reasoning.indicator.hide": "הסתר",
|
||||
"messageBlock.reasoning.indicator.view": "צפה",
|
||||
"messageBlock.reasoning.detailsAriaLabel": "פרטי חשיבה",
|
||||
|
||||
"codeBlockInline.actions.copy": "העתק",
|
||||
"codeBlockInline.actions.copied": "הועתק!",
|
||||
|
||||
"messageItem.speaker.you": "אתה",
|
||||
"messageItem.speaker.assistant": "סוכן",
|
||||
"messageItem.actions.revert": "בטל שינויים",
|
||||
"messageItem.actions.revertTitle": "בטל שינויים עד כאן (מוחק הודעות)",
|
||||
"messageItem.actions.fork": "פצל",
|
||||
"messageItem.actions.forkTitle": "פצל מהודעה זו",
|
||||
"messageItem.actions.copy": "העתק",
|
||||
"messageItem.actions.copyTitle": "העתק הודעה",
|
||||
"messageItem.actions.copied": "הועתק!",
|
||||
"messageItem.actions.deleteMessage": "מחק הודעה (לא מבטל שינויים)",
|
||||
"messageItem.actions.deleteMessagesUpTo": "מחק הודעות עד כאן (לא מבטל שינויים)",
|
||||
"messageItem.actions.deletingMessage": "מוחק...",
|
||||
"messageItem.actions.deleteMessageFailedTitle": "המחיקה נכשלה",
|
||||
"messageItem.actions.deleteMessageFailedMessage": "מחיקת ההודעה נכשלה",
|
||||
|
||||
"messageItem.selection.checkboxAriaLabel": "בחר הודעה למחיקה",
|
||||
|
||||
"messageSection.bulkDelete.toolbarAriaLabel": "פריטים נבחרים ({count})",
|
||||
"messageSection.bulkDelete.deleteSelectedTitle": "מחק פריטים נבחרים",
|
||||
"messageSection.bulkDelete.selectAllTitle": "בחר את כל ההודעות",
|
||||
"messageSection.bulkDelete.moreOptionsTitle": "אפשרויות נוספות",
|
||||
"messageSection.bulkDelete.selectionModeLabel": "בחירה",
|
||||
"messageSection.bulkDelete.selectionModeAll": "הכל",
|
||||
"messageSection.bulkDelete.selectionModeTools": "כלים בלבד",
|
||||
"messageSection.bulkDelete.selectionHint.toggle": "בחר פריט",
|
||||
"messageSection.bulkDelete.selectionHint.range": "בחר טווח",
|
||||
"messageSection.bulkDelete.selectionHint.clear": "נקה בחירה",
|
||||
"messageSection.bulkDelete.cancelTitle": "בטל בחירה",
|
||||
"messageSection.bulkDelete.failedTitle": "המחיקה נכשלה",
|
||||
"messageSection.bulkDelete.failedMessage": "מחיקת הפריטים הנבחרים נכשלה",
|
||||
"messageItem.status.queued": "בתור",
|
||||
"messageItem.status.generating": "מייצר...",
|
||||
"messageItem.status.sending": "שולח...",
|
||||
"messageItem.status.failedToSend": "שליחת ההודעה נכשלה",
|
||||
"messagePart.actions.delete": "מחק חלק",
|
||||
"messagePart.actions.deleting": "מוחק...",
|
||||
"messagePart.actions.deleteTitle": "מחק פריט זה",
|
||||
"messagePart.actions.deleteFailedTitle": "המחיקה נכשלה",
|
||||
"messagePart.actions.deleteFailedMessage": "מחיקת הפריט נכשלה",
|
||||
"messageItem.attachment.defaultName": "קובץ מצורף",
|
||||
"messageItem.attachment.downloadAriaLabel": "הורד {name}",
|
||||
"messageItem.agentMeta.agentLabel": "סוכן: {agent}",
|
||||
"messageItem.agentMeta.modelLabel": "מודל: {model}",
|
||||
"messageItem.errors.authenticationFallback": "שגיאת אימות",
|
||||
"messageItem.errors.outputLengthExceeded": "אורך פלט ההודעה חרג מהמגבלה",
|
||||
"messageItem.errors.requestAborted": "הבקשה בוטלה",
|
||||
"messageItem.errors.unknownFallback": "אירעה שגיאה לא ידועה",
|
||||
|
||||
"attachmentChip.removeAriaLabel": "הסר קובץ מצורף",
|
||||
|
||||
"expandButton.toggleAriaLabel": "שנה גובה תיבת הקלט",
|
||||
|
||||
"promptInput.placeholder.shell": "הפעל פקודת מעטפת (Esc ליציאה)...",
|
||||
"promptInput.placeholder.default": "הקלד הודעה, @file, @agent, או הדבק תמונות וטקסט...",
|
||||
"promptInput.hints.shell.exit": "לצאת ממצב מעטפת",
|
||||
"promptInput.hints.shell.enable": "מצב מעטפת",
|
||||
"promptInput.hints.commands": "פקודות",
|
||||
"promptInput.history.previousAriaLabel": "פקודה קודמת",
|
||||
"promptInput.history.nextAriaLabel": "פקודה הבאה",
|
||||
"promptInput.overlay.newLine": "שורה חדשה",
|
||||
"promptInput.overlay.send": "שלח",
|
||||
"promptInput.overlay.filesAgents": "קבצים/סוכנים",
|
||||
"promptInput.overlay.history": "היסטוריה",
|
||||
"promptInput.overlay.attachments": "• {count} קובץ/ים מצורף/ים",
|
||||
"promptInput.overlay.shellModeActive": "מצב מעטפת פעיל",
|
||||
"promptInput.overlay.press": "לחץ",
|
||||
"promptInput.overlay.againToAbort": "שוב כדי לבטל את הסשן",
|
||||
"promptInput.stopSession.ariaLabel": "עצור סשן",
|
||||
"promptInput.stopSession.title": "עצור סשן",
|
||||
"promptInput.send.ariaLabel": "שלח הודעה",
|
||||
"promptInput.send.errorFallback": "שליחת ההודעה נכשלה",
|
||||
"promptInput.send.errorTitle": "השליחה נכשלה",
|
||||
} as const
|
||||
51
packages/ui/src/lib/i18n/messages/he/remoteAccess.ts
Normal file
51
packages/ui/src/lib/i18n/messages/he/remoteAccess.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export const remoteAccessMessages = {
|
||||
"remoteAccess.eyebrow": "גישה מרוחקת",
|
||||
"remoteAccess.title": "התחבר ל-CodeNomad מרחוק",
|
||||
"remoteAccess.subtitle": "השתמש בכתובות למטה כדי לפתוח את CodeNomad ממכשיר אחר.",
|
||||
"remoteAccess.close": "סגור גישה מרוחקת",
|
||||
"remoteAccess.refresh": "רענן",
|
||||
|
||||
"remoteAccess.sections.listeningMode.label": "מצב האזנה",
|
||||
"remoteAccess.sections.listeningMode.help": "אפשר או הגבל גישה מרוחקת על ידי קישור לכל הממשקים או רק ל-localhost.",
|
||||
"remoteAccess.toggle.on": "פועל",
|
||||
"remoteAccess.toggle.off": "כבוי",
|
||||
"remoteAccess.toggle.title": "אפשר חיבורים מכתובות IP אחרות",
|
||||
"remoteAccess.toggle.caption.all": "מקושר ל-0.0.0.0",
|
||||
"remoteAccess.toggle.caption.local": "מקושר ל-127.0.0.1",
|
||||
"remoteAccess.toggle.note": "שינוי זה דורש הפעלה מחדש ועוצר זמנית את כל המופעים הפעילים. שתף את הכתובות למטה לאחר שהשרת יופעל מחדש.",
|
||||
"remoteAccess.listeningMode.restartConfirm.message": "להפעיל מחדש כדי להחיל מצב האזנה? פעולה זו תעצור את כל המופעים הפעילים.",
|
||||
"remoteAccess.listeningMode.restartConfirm.title.all": "פתוח למכשירים אחרים",
|
||||
"remoteAccess.listeningMode.restartConfirm.title.local": "מוגבל למכשיר זה",
|
||||
"remoteAccess.listeningMode.restartConfirm.confirmLabel": "הפעל מחדש עכשיו",
|
||||
"remoteAccess.listeningMode.restartConfirm.cancelLabel": "ביטול",
|
||||
"remoteAccess.restart.errorManual": "לא ניתן להפעיל מחדש אוטומטית. אנא הפעל מחדש את האפליקציה כדי להחיל את השינוי.",
|
||||
|
||||
"remoteAccess.sections.serverPassword.label": "סיסמת שרת",
|
||||
"remoteAccess.sections.serverPassword.help": "גישה מרוחקת דורשת סיסמה. הגדר סיסמה קלה לזכירה כדי לאפשר כניסות ממכשירים אחרים.",
|
||||
"remoteAccess.authStatus.unavailable": "סטטוס האימות אינו זמין.",
|
||||
"remoteAccess.username": "שם משתמש: {username}",
|
||||
"remoteAccess.password.status.set": "סיסמה מוגדרת לגישה מרוחקת.",
|
||||
"remoteAccess.password.status.unset": "לא הוגדרה סיסמה קלה לזכירה. הגדר סיסמה כדי לאפשר כניסות גישה מרוחקת.",
|
||||
"remoteAccess.password.actions.cancel": "ביטול",
|
||||
"remoteAccess.password.actions.change": "שנה סיסמה",
|
||||
"remoteAccess.password.actions.set": "הגדר סיסמה",
|
||||
"remoteAccess.password.form.newPassword": "סיסמה חדשה",
|
||||
"remoteAccess.password.form.confirmPassword": "אשר סיסמה",
|
||||
"remoteAccess.password.form.placeholder": "לפחות 8 תווים",
|
||||
"remoteAccess.password.error.tooShort": "הסיסמה חייבת להכיל לפחות 8 תווים.",
|
||||
"remoteAccess.password.error.mismatch": "הסיסמאות אינן תואמות.",
|
||||
"remoteAccess.password.save.saving": "שומר…",
|
||||
"remoteAccess.password.save.label": "שמור סיסמה",
|
||||
|
||||
"remoteAccess.sections.addresses.label": "כתובות נגישות",
|
||||
"remoteAccess.sections.addresses.help": "הפעל או סרוק ממכונה אחרת להעברת שליטה.",
|
||||
"remoteAccess.addresses.loading": "טוען כתובות…",
|
||||
"remoteAccess.addresses.none": "אין כתובות זמינות עדיין.",
|
||||
"remoteAccess.address.scope.network": "רשת",
|
||||
"remoteAccess.address.scope.loopback": "לולאה מקומית",
|
||||
"remoteAccess.address.scope.internal": "פנימי",
|
||||
"remoteAccess.address.open": "פתח",
|
||||
"remoteAccess.address.showQr": "הצג QR",
|
||||
"remoteAccess.address.hideQr": "הסתר QR",
|
||||
"remoteAccess.address.qrAlt": "QR עבור {url}",
|
||||
} as const
|
||||
90
packages/ui/src/lib/i18n/messages/he/session.ts
Normal file
90
packages/ui/src/lib/i18n/messages/he/session.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
export const sessionMessages = {
|
||||
"sessionPicker.title": "OpenCode • {folder}",
|
||||
"sessionPicker.empty.noPrevious": "אין סשנים קודמים",
|
||||
"sessionPicker.resume.title": "המשך סשן ({count}):",
|
||||
"sessionPicker.session.untitled": "ללא שם",
|
||||
"sessionPicker.divider.or": "או",
|
||||
"sessionPicker.new.title": "התחל סשן חדש:",
|
||||
"sessionPicker.agents.loading": "טוען סוכנים...",
|
||||
"sessionPicker.actions.creating": "יוצר...",
|
||||
"sessionPicker.actions.createSession": "צור סשן",
|
||||
"sessionPicker.actions.cancel": "ביטול",
|
||||
|
||||
"sessionList.header.title": "סשנים",
|
||||
"sessionList.session.untitled": "ללא שם",
|
||||
"sessionList.status.working": "עובד",
|
||||
"sessionList.status.compacting": "מסכם",
|
||||
"sessionList.status.idle": "מוכן",
|
||||
"sessionList.status.needsPermission": "נדרש אישור",
|
||||
"sessionList.status.needsInput": "נדרש קלט",
|
||||
"sessionList.expand.collapseAriaLabel": "כווץ סשן",
|
||||
"sessionList.expand.expandAriaLabel": "פרוס סשן",
|
||||
"sessionList.expand.collapseTitle": "כווץ",
|
||||
"sessionList.expand.expandTitle": "פרוס",
|
||||
"sessionList.actions.newSession.ariaLabel": "סשן חדש",
|
||||
"sessionList.actions.newSession.title": "סשן חדש",
|
||||
"sessionList.actions.copyId.ariaLabel": "העתק מזהה סשן",
|
||||
"sessionList.actions.copyId.title": "העתק מזהה סשן",
|
||||
"sessionList.actions.rename.ariaLabel": "שנה שם סשן",
|
||||
"sessionList.actions.rename.title": "שנה שם סשן",
|
||||
"sessionList.actions.delete.ariaLabel": "מחק סשן",
|
||||
"sessionList.actions.delete.title": "מחק סשן",
|
||||
"sessionList.copyId.success": "מזהה סשן הועתק",
|
||||
"sessionList.copyId.error": "לא ניתן להעתיק מזהה סשן",
|
||||
"sessionList.delete.error": "לא ניתן למחוק סשן",
|
||||
"sessionList.delete.title": "מחק סשן",
|
||||
"sessionList.delete.confirmMessage": "למחוק את \"{label}\"? לא ניתן לבטל פעולה זו.",
|
||||
"sessionList.delete.confirmLabel": "מחק",
|
||||
"sessionList.delete.cancelLabel": "ביטול",
|
||||
"sessionList.rename.error": "לא ניתן לשנות שם הסשן",
|
||||
|
||||
"sessionList.filter.placeholder": "חפש סשנים…",
|
||||
"sessionList.filter.ariaLabel": "חפש סשנים",
|
||||
"sessionList.selection.selectAllLabel": "בחר הכל",
|
||||
"sessionList.selection.selectAllAriaLabel": "בחר את כל הסשנים",
|
||||
"sessionList.selection.clearLabel": "נקה",
|
||||
"sessionList.selection.clearAriaLabel": "נקה בחירה",
|
||||
"sessionList.selection.checkboxAriaLabel": "בחר סשן",
|
||||
"sessionList.bulkDelete.button": "מחק {count}",
|
||||
"sessionList.bulkDelete.ariaLabel": "מחק {count} סשנים נבחרים",
|
||||
"sessionList.bulkDelete.title": "מחק סשנים",
|
||||
"sessionList.bulkDelete.confirmMessage": "למחוק {count} סשנים נבחרים? לא ניתן לבטל פעולה זו.",
|
||||
"sessionList.bulkDelete.confirmLabel": "מחק",
|
||||
"sessionList.bulkDelete.cancelLabel": "ביטול",
|
||||
"sessionList.bulkDelete.error": "לא ניתן למחוק {count} סשנים",
|
||||
|
||||
"sessionRenameDialog.title": "שנה שם סשן",
|
||||
"sessionRenameDialog.description.withLabel": "עדכן את הכותרת עבור \"{label}\".",
|
||||
"sessionRenameDialog.description.default": "הגדר כותרת חדשה לסשן זה.",
|
||||
"sessionRenameDialog.input.label": "שם סשן",
|
||||
"sessionRenameDialog.input.placeholder": "הזן שם סשן",
|
||||
"sessionRenameDialog.actions.cancel": "ביטול",
|
||||
"sessionRenameDialog.actions.rename": "שנה שם",
|
||||
"sessionRenameDialog.actions.renaming": "משנה שם…",
|
||||
|
||||
"sessionView.fallback.sessionNotFound": "הסשן לא נמצא",
|
||||
"sessionView.alerts.abortFailed.message": "עצירת הסשן נכשלה",
|
||||
"sessionView.alerts.abortFailed.title": "העצירה נכשלה",
|
||||
"sessionView.alerts.revertFailed.message": "החזרה להודעה נכשלה",
|
||||
"sessionView.alerts.revertFailed.title": "החזרה נכשלה",
|
||||
"sessionView.alerts.deleteUpToFailed.message": "מחיקת הודעות נכשלה",
|
||||
"sessionView.alerts.deleteUpToFailed.title": "המחיקה נכשלה",
|
||||
"sessionView.alerts.forkFailed.message": "פיצול הסשן נכשל",
|
||||
"sessionView.alerts.forkFailed.title": "הפיצול נכשל",
|
||||
"sessionView.attachments.expandPastedTextAriaLabel": "פרוס טקסט שהודבק",
|
||||
"sessionView.attachments.insertPastedTextTitle": "הכנס טקסט שהודבק",
|
||||
"sessionView.attachments.removeAriaLabel": "הסר קובץ מצורף",
|
||||
|
||||
"sessionEvents.sessionCompactedToast": "הסשן {label} סוכם",
|
||||
"sessionEvents.sessionError.unknown": "שגיאה לא ידועה",
|
||||
"sessionEvents.sessionError.title": "שגיאת סשן",
|
||||
"sessionEvents.sessionError.message": "שגיאה: {message}",
|
||||
|
||||
"sessionState.cleanup.deepConfirm.message": "ניקוי עמוק זה עשוי להיות איטי, ועלול למחוק סשנים שלא התכוונת למחוק. האם אתה בטוח?",
|
||||
"sessionState.cleanup.deepConfirm.title": "ניקוי עמוק של סשנים",
|
||||
"sessionState.cleanup.deepConfirm.detail": "ניקוי עמוק של סשנים ימחק את כל הסשנים ללא הודעות, יסיר סשני תת-סוכן שסיימו, וינקה פיצולים לא בשימוש של סשן.",
|
||||
"sessionState.cleanup.deepConfirm.confirmLabel": "המשך",
|
||||
"sessionState.cleanup.deepConfirm.cancelLabel": "ביטול",
|
||||
"sessionState.cleanup.toast.one": "נוקה {count} סשן ריק",
|
||||
"sessionState.cleanup.toast.other": "נוקו {count} סשנים ריקים",
|
||||
} as const
|
||||
142
packages/ui/src/lib/i18n/messages/he/settings.ts
Normal file
142
packages/ui/src/lib/i18n/messages/he/settings.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
export const settingsMessages = {
|
||||
"instanceServiceStatus.sections.lsp": "שרתי LSP",
|
||||
"instanceServiceStatus.sections.mcp": "שרתי MCP",
|
||||
"instanceServiceStatus.sections.plugins": "תוספים",
|
||||
"instanceServiceStatus.lsp.loading": "טוען שרתי LSP...",
|
||||
"instanceServiceStatus.lsp.empty": "לא זוהו שרתי LSP.",
|
||||
"instanceServiceStatus.lsp.status.connected": "מחובר",
|
||||
"instanceServiceStatus.lsp.status.error": "שגיאה",
|
||||
"instanceServiceStatus.mcp.loading": "טוען שרתי MCP...",
|
||||
"instanceServiceStatus.mcp.empty": "לא זוהו שרתי MCP.",
|
||||
"instanceServiceStatus.mcp.toggleAriaLabel": "הפעל/כבה שרת MCP {name}",
|
||||
"instanceServiceStatus.plugins.loading": "טוען תוספים...",
|
||||
"instanceServiceStatus.plugins.empty": "לא הוגדרו תוספים.",
|
||||
|
||||
"permissionBanner.pendingRequests.one": "בקשה אחת ממתינה",
|
||||
"permissionBanner.pendingRequests.other": "{count} בקשות ממתינות",
|
||||
"permissionBanner.detail.permission.one": "אישור אחד",
|
||||
"permissionBanner.detail.permission.other": "{count} אישורים",
|
||||
"permissionBanner.detail.question.one": "שאלה אחת",
|
||||
"permissionBanner.detail.question.other": "{count} שאלות",
|
||||
"permissionBanner.detail.wrapper": " ({detail})",
|
||||
|
||||
"agentSelector.placeholder": "בחר סוכן...",
|
||||
"agentSelector.badge.subagent": "תת-סוכן",
|
||||
"agentSelector.none": "ללא",
|
||||
"agentSelector.trigger.primary": "סוכן: {agent}",
|
||||
|
||||
"modelSelector.placeholder.search": "חפש מודלים...",
|
||||
"modelSelector.none": "ללא",
|
||||
"modelSelector.trigger.primary": "מודל: {model}",
|
||||
"modelSelector.favoritesOnly.toggle.ariaLabel": "הצג מועדפים בלבד",
|
||||
"modelSelector.favoritesOnly.showAll": "הצג את כל המודלים",
|
||||
"modelSelector.favorite.add": "הוסף למועדפים",
|
||||
"modelSelector.favorite.remove": "הסר ממועדפים",
|
||||
|
||||
"thinkingSelector.variant.default": "ברירת מחדל",
|
||||
"thinkingSelector.label": "חשיבה: {variant}",
|
||||
|
||||
"envEditor.title": "משתני סביבה",
|
||||
"envEditor.count.one": "(משתנה אחד)",
|
||||
"envEditor.count.other": "({count} משתנים)",
|
||||
"envEditor.fields.name.placeholder": "שם משתנה",
|
||||
"envEditor.fields.name.readOnlyTitle": "שם משתנה (לקריאה בלבד)",
|
||||
"envEditor.fields.value.placeholder": "ערך משתנה",
|
||||
"envEditor.actions.remove.title": "הסר משתנה",
|
||||
"envEditor.actions.add.title": "הוסף משתנה",
|
||||
"envEditor.empty": "לא הוגדרו משתני סביבה. הוסף משתנים למעלה להתאמת סביבת OpenCode.",
|
||||
"envEditor.help": "משתנים אלו יהיו זמינים בסביבת OpenCode בעת הפעלת מופעים.",
|
||||
|
||||
"contextUsagePanel.headings.tokens": "טוקנים",
|
||||
"contextUsagePanel.headings.context": "הקשר",
|
||||
"contextUsagePanel.labels.input": "קלט",
|
||||
"contextUsagePanel.labels.output": "פלט",
|
||||
"contextUsagePanel.labels.cost": "עלות",
|
||||
"contextUsagePanel.labels.used": "בשימוש",
|
||||
"contextUsagePanel.labels.available": "זמין",
|
||||
"contextUsagePanel.unavailable": "--",
|
||||
|
||||
"settings.title": "הגדרות",
|
||||
"settings.navigationAriaLabel": "קטגוריות הגדרות",
|
||||
"settings.close": "סגור הגדרות",
|
||||
"settings.content.eyebrow": "העדפות סביבת עבודה",
|
||||
"settings.open.title": "פתח הגדרות",
|
||||
"settings.open.ariaLabel": "פתח הגדרות",
|
||||
"settings.nav.appearance": "מראה",
|
||||
"settings.nav.notifications": "התראות",
|
||||
"settings.nav.remote": "גישה מרוחקת",
|
||||
"settings.nav.opencode": "OpenCode",
|
||||
"settings.scope.device": "מכשיר זה",
|
||||
"settings.scope.server": "הגדרת שרת",
|
||||
"settings.common.enabled": "מופעל",
|
||||
"settings.common.disabled": "מושבת",
|
||||
"settings.section.appearance.title": "מראה",
|
||||
"settings.section.appearance.subtitle": "שנה כיצד האפליקציה נראית במכשיר זה.",
|
||||
"settings.appearance.theme.title": "ערכת נושא",
|
||||
"settings.appearance.theme.subtitle": "בחר את מצב הצבע שישמש בכל האפליקציה.",
|
||||
"settings.appearance.theme.option.system": "התאם להגדרת מערכת ההפעלה",
|
||||
"settings.appearance.theme.option.light": "השתמש במראה בהיר",
|
||||
"settings.appearance.theme.option.dark": "השתמש במראה כהה",
|
||||
"settings.section.notifications.title": "התראות",
|
||||
"settings.section.notifications.subtitle": "שלוט בהתראות ברמת מערכת ההפעלה עבור פעילות סשן.",
|
||||
"settings.notifications.permission.granted": "ניתן",
|
||||
"settings.notifications.permission.denied": "נדחה",
|
||||
"settings.notifications.permission.default": "לא ניתן",
|
||||
"settings.notifications.permission.unsupported": "לא נתמך",
|
||||
"settings.notifications.messages.unsupportedEnvironment": "התראות מערכת ההפעלה אינן נתמכות בסביבה זו.",
|
||||
"settings.notifications.messages.permissionDenied": "הרשאת התראות נדחתה. הפעל התראות בהגדרות המערכת או הדפדפן.",
|
||||
"settings.notifications.messages.permissionNotGranted": "הרשאת התראות לא ניתנה.",
|
||||
"settings.notifications.messages.unsupportedGeneral": "התראות אינן נתמכות בסביבה זו.",
|
||||
"settings.notifications.messages.permissionGranted": "ההרשאה ניתנה. כעת ניתן להפעיל התראות.",
|
||||
"settings.notifications.messages.permissionRequestDenied": "ההרשאה נדחתה. ייתכן שתצטרך להפעיל התראות בהגדרות המערכת או הדפדפן.",
|
||||
"settings.notifications.sessionStatus.title": "התראות סטטוס סשן",
|
||||
"settings.notifications.sessionStatus.subtitle": "קבל התראות כאשר סשנים דורשים את תשומת לבך.",
|
||||
"settings.notifications.enable.title": "הפעל התראות",
|
||||
"settings.notifications.enable.permission": "הרשאה: {permission}",
|
||||
"settings.notifications.requestPermission.title": "בקש הרשאה",
|
||||
"settings.notifications.requestPermission.subtitle": "אפשר לאפליקציה לשלוח התראות במכשיר זה.",
|
||||
"settings.notifications.requestPermission.action": "בקש",
|
||||
"settings.notifications.allowVisible.title": "התרע כאשר האפליקציה ממוקדת",
|
||||
"settings.notifications.allowVisible.subtitle": "שמור על התראות פעילות גם כאשר חלון זה גלוי.",
|
||||
"settings.notifications.unsupportedNote": "התראות אינן נתמכות בסביבה זו. פקד ההתראות נשאר מושבת.",
|
||||
"settings.notifications.events.title": "התרע אותי כאשר",
|
||||
"settings.notifications.events.subtitle": "בחר אילו אירועי סשן ישלחו התראות.",
|
||||
"settings.notifications.events.needsInput": "הסשן דורש קלט",
|
||||
"settings.notifications.events.idle": "הסשן עובר למצב סרלה",
|
||||
"settings.notifications.status.enabled": "התראות מופעלות",
|
||||
"settings.notifications.status.disabled": "התראות מושבתות",
|
||||
"settings.notifications.status.unsupported": "התראות לא נתמכות",
|
||||
"settings.section.remote.title": "גישה מרוחקת",
|
||||
"settings.section.remote.subtitle": "בדוק כיצד שרת זה חשוף ברשת שלך ואבטח אישורי גישה.",
|
||||
"settings.section.opencode.title": "OpenCode",
|
||||
"settings.section.opencode.subtitle": "בחר את הקובץ הבינארי של OpenCode והסביבה לשימוש במופעים חדשים.",
|
||||
"settings.opencode.runtime.title": "סביבת ריצה",
|
||||
"settings.opencode.runtime.subtitle": "הגדר עם איזה קובץ בינארי של OpenCode מופעים חדשים יופעלו.",
|
||||
|
||||
"settings.appearance.behavior.title": "אינטראקציה",
|
||||
"settings.appearance.behavior.subtitle": "ברירות מחדל להודעות, diff וקלט.",
|
||||
"settings.behavior.keyboardHints.title": "רמזי קיצורי מקלדת",
|
||||
"settings.behavior.keyboardHints.subtitle": "הצג רמזי קיצורי מקלדת בכל הממשק.",
|
||||
"settings.behavior.thinking.title": "קטעי חשיבה",
|
||||
"settings.behavior.thinking.subtitle": "הצג או הסתר קטעי חשיבה של ה-AI בהודעות.",
|
||||
"settings.behavior.thinkingDefault.title": "ברירת מחדל לחשיבה",
|
||||
"settings.behavior.thinkingDefault.subtitle": "בחר האם קטעי חשיבה מתחילים פרוסים או מכווצים.",
|
||||
"settings.behavior.timelineTools.title": "קריאות כלי בציר הזמן",
|
||||
"settings.behavior.timelineTools.subtitle": "הצג או הסתר קריאות כלי בציר הודעות.",
|
||||
"settings.behavior.diffView.title": "תצוגת diff",
|
||||
"settings.behavior.diffView.subtitle": "בחר כיצד מוצגים diff של קריאות כלי.",
|
||||
"settings.behavior.diffView.option.split": "מפוצל",
|
||||
"settings.behavior.diffView.option.unified": "מאוחד",
|
||||
"settings.behavior.toolOutputsDefault.title": "ברירת מחדל לפלטי כלים",
|
||||
"settings.behavior.toolOutputsDefault.subtitle": "בחר האם פלטי כלים מתחילים פרוסים או מכווצים.",
|
||||
"settings.behavior.diagnosticsDefault.title": "ברירת מחדל לאבחון",
|
||||
"settings.behavior.diagnosticsDefault.subtitle": "בחר האם פלט אבחון מתחיל פרוס או מכווץ.",
|
||||
"settings.behavior.toolInputsVisibility.title": "נראות קלטי כלים",
|
||||
"settings.behavior.toolInputsVisibility.subtitle": "הגדר נראות ברירת מחדל לארגומנטים של קריאות כלי.",
|
||||
"settings.behavior.usageMetrics.title": "מדדי שימוש בטוקנים",
|
||||
"settings.behavior.usageMetrics.subtitle": "הצג או הסתר נתוני טוקנים ועלות להודעות הסוכן.",
|
||||
"settings.behavior.autoCleanup.title": "ניקוי אוטומטי של סשנים ריקים",
|
||||
"settings.behavior.autoCleanup.subtitle": "נקה אוטומטית סשנים ריקים בעת יצירת סשנים חדשים.",
|
||||
"settings.behavior.promptSubmit.title": "Enter לשליחה",
|
||||
"settings.behavior.promptSubmit.subtitle": "השתמש ב-Enter לשליחת פקודות; Cmd/Ctrl+Enter מוסיף שורה חדשה.",
|
||||
} as const
|
||||
6
packages/ui/src/lib/i18n/messages/he/time.ts
Normal file
6
packages/ui/src/lib/i18n/messages/he/time.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const timeMessages = {
|
||||
"time.relative.justNow": "עכשיו",
|
||||
"time.relative.daysAgoShort": "לפני {count} ימים",
|
||||
"time.relative.hoursAgoShort": "לפני {count} שעות",
|
||||
"time.relative.minutesAgoShort": "לפני {count} דקות",
|
||||
} as const
|
||||
132
packages/ui/src/lib/i18n/messages/he/toolCall.ts
Normal file
132
packages/ui/src/lib/i18n/messages/he/toolCall.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
export const toolCallMessages = {
|
||||
"toolCall.pending.waitingToRun": "ממתין להרצה...",
|
||||
"toolCall.error.label": "שגיאה:",
|
||||
|
||||
"toolCall.header.copyTitle": "העתק כותרת קריאת כלי",
|
||||
"toolCall.header.copyAriaLabel": "העתק כותרת קריאת כלי",
|
||||
|
||||
"toolCall.header.showInputTitle": "הצג ארגומנטי כלי",
|
||||
"toolCall.header.showInputAriaLabel": "הצג ארגומנטי כלי",
|
||||
"toolCall.header.hideInputTitle": "הסתר ארגומנטי כלי",
|
||||
"toolCall.header.hideInputAriaLabel": "הסתר ארגומנטי כלי",
|
||||
|
||||
"toolCall.io.input": "קלט כלי",
|
||||
"toolCall.io.output": "פלט כלי",
|
||||
|
||||
"toolCall.diff.label": "Diff",
|
||||
"toolCall.diff.label.withPath": "Diff · {path}",
|
||||
"toolCall.diff.viewMode.ariaLabel": "מצב תצוגת diff",
|
||||
"toolCall.diff.viewMode.split": "מפוצל",
|
||||
"toolCall.diff.viewMode.unified": "מאוחד",
|
||||
|
||||
"toolCall.diagnostics.title": "אבחון",
|
||||
"toolCall.diagnostics.ariaLabel": "אבחון",
|
||||
"toolCall.diagnostics.ariaLabel.withLabel": "אבחון {label}",
|
||||
"toolCall.diagnostics.severity.error.short": "שגיאה",
|
||||
"toolCall.diagnostics.severity.warning.short": "אזהרה",
|
||||
"toolCall.diagnostics.severity.info.short": "מידע",
|
||||
|
||||
"toolCall.renderer.toolName.shell": "מעטפת",
|
||||
"toolCall.renderer.toolName.fetch": "Fetch",
|
||||
"toolCall.renderer.toolName.invalid": "לא תקין",
|
||||
"toolCall.renderer.toolName.plan": "תוכנית",
|
||||
"toolCall.renderer.toolName.applyPatch": "החל תיקון",
|
||||
|
||||
"toolCall.renderer.action.working": "עובד...",
|
||||
"toolCall.renderer.action.writingCommand": "כותב פקודה...",
|
||||
"toolCall.renderer.action.preparingEdit": "מכין עריכה...",
|
||||
"toolCall.renderer.action.readingFile": "קורא קובץ...",
|
||||
"toolCall.renderer.action.preparingWrite": "מכין כתיבה...",
|
||||
"toolCall.renderer.action.preparingPatch": "מכין תיקון...",
|
||||
"toolCall.renderer.action.planning": "מתכנן...",
|
||||
"toolCall.renderer.action.fetchingFromWeb": "מאחזר מהאינטרנט...",
|
||||
"toolCall.renderer.action.findingFiles": "מחפש קבצים...",
|
||||
"toolCall.renderer.action.searchingContent": "מחפש תוכן...",
|
||||
"toolCall.renderer.action.listingDirectory": "מפרט ספרייה...",
|
||||
|
||||
"toolCall.renderer.bash.title.timeout": "פסק זמן: {timeout}",
|
||||
"toolCall.renderer.read.detail.offset": "היסט: {offset}",
|
||||
"toolCall.renderer.read.detail.limit": "מגבלה: {limit}",
|
||||
|
||||
"toolCall.renderer.todo.empty": "אין פריטי תוכנית עדיין.",
|
||||
"toolCall.renderer.todo.status.pending": "ממתין",
|
||||
"toolCall.renderer.todo.status.inProgress": "בביצוע",
|
||||
"toolCall.renderer.todo.status.completed": "הושלם",
|
||||
"toolCall.renderer.todo.status.cancelled": "בוטל",
|
||||
"toolCall.renderer.todo.title.plan": "תוכנית",
|
||||
"toolCall.renderer.todo.title.creating": "יוצר תוכנית",
|
||||
"toolCall.renderer.todo.title.completing": "משלים תוכנית",
|
||||
"toolCall.renderer.todo.title.updating": "מעדכן תוכנית",
|
||||
|
||||
"toolCall.permission.status.required": "נדרש אישור",
|
||||
"toolCall.permission.status.queued": "אישור בתור",
|
||||
"toolCall.permission.requestedDiff.label": "diff מבוקש",
|
||||
"toolCall.permission.requestedDiff.withPath": "diff מבוקש · {path}",
|
||||
"toolCall.permission.queuedText": "ממתין לתגובות אישור קודמות.",
|
||||
"toolCall.permission.actions.allowOnce": "אפשר פעם אחת",
|
||||
"toolCall.permission.actions.alwaysAllow": "אפשר תמיד",
|
||||
"toolCall.permission.actions.deny": "דחה",
|
||||
"toolCall.permission.shortcuts.allowOnce": "אפשר פעם אחת",
|
||||
"toolCall.permission.shortcuts.alwaysAllow": "אפשר תמיד",
|
||||
"toolCall.permission.shortcuts.deny": "דחה",
|
||||
"toolCall.permission.errors.unableToUpdate": "לא ניתן לעדכן אישור",
|
||||
|
||||
"permissionApproval.title": "בקשות",
|
||||
"permissionApproval.empty": "אין בקשות ממתינות.",
|
||||
"permissionApproval.kind.permission": "אישור",
|
||||
"permissionApproval.kind.question": "שאלה",
|
||||
"permissionApproval.questionCount.one": "שאלה אחת",
|
||||
"permissionApproval.questionCount.other": "{count} שאלות",
|
||||
"permissionApproval.status.active": "פעיל",
|
||||
"permissionApproval.actions.closeAriaLabel": "סגור",
|
||||
"permissionApproval.actions.goToSession": "עבור לסשן",
|
||||
"permissionApproval.actions.loadingSession": "טוען…",
|
||||
"permissionApproval.actions.loadSession": "טען סשן",
|
||||
"permissionApproval.actions.allowOnce": "אפשר פעם אחת",
|
||||
"permissionApproval.actions.alwaysAllow": "אפשר תמיד",
|
||||
"permissionApproval.actions.deny": "דחה",
|
||||
"permissionApproval.fallbackHint": "טען סשן לקבלת מידע נוסף.",
|
||||
"permissionApproval.errors.unableToUpdatePermission": "לא ניתן לעדכן אישור",
|
||||
|
||||
"toolCall.question.status.required": "נדרשת תשובה",
|
||||
"toolCall.question.status.queued": "שאלה בתור",
|
||||
"toolCall.question.status.questions": "שאלות",
|
||||
"toolCall.question.action.awaitingAnswers": "ממתין לתשובות...",
|
||||
"toolCall.question.title.questions": "שאלות",
|
||||
"toolCall.question.title.askingQuestions": "שואל שאלות",
|
||||
"toolCall.question.type.one": "שאלה",
|
||||
"toolCall.question.type.other": "שאלות",
|
||||
"toolCall.question.number": "ש{number}:",
|
||||
"toolCall.question.multiple": "מרובות",
|
||||
"toolCall.question.custom.title": "הקלד תשובה מותאמת אישית",
|
||||
"toolCall.question.custom.label": "תשובה מותאמת אישית",
|
||||
"toolCall.question.custom.placeholder": "הקלד תשובה משלך",
|
||||
"toolCall.question.actions.submit": "שלח",
|
||||
"toolCall.question.actions.dismiss": "סגור",
|
||||
"toolCall.question.shortcuts.submit": "שלח",
|
||||
"toolCall.question.shortcuts.dismiss": "סגור",
|
||||
"toolCall.question.queuedText": "ממתין לתגובות קודמות.",
|
||||
"toolCall.question.validation.answerAll": "אנא ענה על כל השאלות לפני השליחה.",
|
||||
"toolCall.question.errors.unableToReply": "לא ניתן לשלוח תשובה",
|
||||
"toolCall.question.errors.unableToDismiss": "לא ניתן לסגור",
|
||||
|
||||
"toolCall.task.action.delegating": "מאציל...",
|
||||
"toolCall.task.sections.prompt": "פקודה",
|
||||
"toolCall.task.sections.steps": "שלבים",
|
||||
"toolCall.task.sections.output": "פלט",
|
||||
"toolCall.task.steps.count": "{count} שלבים",
|
||||
"toolCall.task.meta.agentModel": "סוכן: {agent} • מודל: {model}",
|
||||
"toolCall.task.meta.agent": "סוכן: {agent}",
|
||||
"toolCall.task.meta.model": "מודל: {model}",
|
||||
|
||||
"toolCall.status.pending": "ממתין",
|
||||
"toolCall.status.running": "רץ",
|
||||
"toolCall.status.completed": "הושלם",
|
||||
"toolCall.status.error": "שגיאה",
|
||||
"toolCall.status.unknown": "לא ידוע",
|
||||
|
||||
"toolCall.applyPatch.action.preparing": "מכין apply_patch...",
|
||||
"toolCall.applyPatch.title.withFileCount.one": "{tool} (קובץ אחד)",
|
||||
"toolCall.applyPatch.title.withFileCount.other": "{tool} ({count} קבצים)",
|
||||
"toolCall.applyPatch.fileFallback": "קובץ {number}",
|
||||
} as const
|
||||
@@ -90,6 +90,7 @@ export const instanceMessages = {
|
||||
|
||||
"instanceShell.rightPanel.title": "ステータスパネル",
|
||||
"instanceShell.rightPanel.tabs.changes": "変更",
|
||||
"instanceShell.rightPanel.tabs.gitChanges": "Git 変更",
|
||||
"instanceShell.rightPanel.tabs.files": "ファイル",
|
||||
"instanceShell.rightPanel.tabs.status": "ステータス",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "右パネルのタブ",
|
||||
@@ -112,6 +113,10 @@ export const instanceMessages = {
|
||||
"instanceShell.sessionChanges.filesChanged": "{count} 個のファイルが変更されました",
|
||||
"instanceShell.sessionChanges.actions.show": "変更を表示",
|
||||
|
||||
"instanceShell.gitChanges.loading": "Git の変更を読み込み中...",
|
||||
"instanceShell.gitChanges.empty": "Git の変更はまだありません。",
|
||||
"instanceShell.gitChanges.deleted": "削除済み",
|
||||
|
||||
"instanceShell.filesShell.fileListTitle": "ファイル一覧",
|
||||
"instanceShell.filesShell.mobileSelectorLabel": "ファイルを選択",
|
||||
"instanceShell.filesShell.mobileSelectorEmpty": "ファイルを選択してください",
|
||||
|
||||
@@ -90,6 +90,7 @@ export const instanceMessages = {
|
||||
|
||||
"instanceShell.rightPanel.title": "Панель состояния",
|
||||
"instanceShell.rightPanel.tabs.changes": "Изменения",
|
||||
"instanceShell.rightPanel.tabs.gitChanges": "Изменения Git",
|
||||
"instanceShell.rightPanel.tabs.files": "Файлы",
|
||||
"instanceShell.rightPanel.tabs.status": "Статус",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "Вкладки правой панели",
|
||||
@@ -112,6 +113,10 @@ export const instanceMessages = {
|
||||
"instanceShell.sessionChanges.filesChanged": "Изменено файлов: {count}",
|
||||
"instanceShell.sessionChanges.actions.show": "Показать изменения",
|
||||
|
||||
"instanceShell.gitChanges.loading": "Загрузка изменений Git...",
|
||||
"instanceShell.gitChanges.empty": "Изменений Git пока нет.",
|
||||
"instanceShell.gitChanges.deleted": "Удалено",
|
||||
|
||||
"instanceShell.filesShell.fileListTitle": "Список файлов",
|
||||
"instanceShell.filesShell.mobileSelectorLabel": "Выбрать файл",
|
||||
"instanceShell.filesShell.mobileSelectorEmpty": "Выберите файл",
|
||||
|
||||
@@ -90,6 +90,7 @@ export const instanceMessages = {
|
||||
|
||||
"instanceShell.rightPanel.title": "状态面板",
|
||||
"instanceShell.rightPanel.tabs.changes": "更改",
|
||||
"instanceShell.rightPanel.tabs.gitChanges": "Git 更改",
|
||||
"instanceShell.rightPanel.tabs.files": "文件",
|
||||
"instanceShell.rightPanel.tabs.status": "状态",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "右侧面板标签页",
|
||||
@@ -112,6 +113,10 @@ export const instanceMessages = {
|
||||
"instanceShell.sessionChanges.filesChanged": "已更改 {count} 个文件",
|
||||
"instanceShell.sessionChanges.actions.show": "显示更改",
|
||||
|
||||
"instanceShell.gitChanges.loading": "正在加载 Git 更改...",
|
||||
"instanceShell.gitChanges.empty": "暂无 Git 更改。",
|
||||
"instanceShell.gitChanges.deleted": "已删除",
|
||||
|
||||
"instanceShell.filesShell.fileListTitle": "文件列表",
|
||||
"instanceShell.filesShell.mobileSelectorLabel": "选择文件",
|
||||
"instanceShell.filesShell.mobileSelectorEmpty": "请选择文件",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { marked } from "marked"
|
||||
import { createHighlighter, type Highlighter, bundledLanguages } from "shiki/bundle/full"
|
||||
import { getLogger } from "./logger"
|
||||
import { tGlobal } from "./i18n"
|
||||
import type { Highlighter } from "shiki/bundle/full"
|
||||
import { decodeHtmlEntities, escapeHtml } from "./text-render-utils"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
@@ -11,43 +12,8 @@ let currentTheme: "light" | "dark" = "light"
|
||||
let isInitialized = false
|
||||
let highlightSuppressed = false
|
||||
let rendererSetup = false
|
||||
|
||||
const extensionToLanguage: Record<string, string> = {
|
||||
ts: "typescript",
|
||||
tsx: "typescript",
|
||||
js: "javascript",
|
||||
jsx: "javascript",
|
||||
py: "python",
|
||||
sh: "bash",
|
||||
bash: "bash",
|
||||
json: "json",
|
||||
html: "html",
|
||||
css: "css",
|
||||
md: "markdown",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
sql: "sql",
|
||||
rs: "rust",
|
||||
go: "go",
|
||||
cpp: "cpp",
|
||||
cc: "cpp",
|
||||
cxx: "cpp",
|
||||
hpp: "cpp",
|
||||
h: "cpp",
|
||||
c: "c",
|
||||
java: "java",
|
||||
cs: "csharp",
|
||||
php: "php",
|
||||
rb: "ruby",
|
||||
swift: "swift",
|
||||
kt: "kotlin",
|
||||
}
|
||||
|
||||
export function getLanguageFromPath(path?: string | null): string | undefined {
|
||||
if (!path) return undefined
|
||||
const ext = path.split(".").pop()?.toLowerCase()
|
||||
return ext ? extensionToLanguage[ext] : undefined
|
||||
}
|
||||
let shikiModulePromise: Promise<typeof import("shiki/bundle/full")> | null = null
|
||||
let bundledLanguagesCache: typeof import("shiki/bundle/full")["bundledLanguages"] | null = null
|
||||
|
||||
// Track loaded languages and queue for on-demand loading
|
||||
const loadedLanguages = new Set<string>()
|
||||
@@ -89,10 +55,15 @@ async function getOrCreateHighlighter() {
|
||||
return highlighterPromise
|
||||
}
|
||||
|
||||
// Create highlighter with no preloaded languages
|
||||
highlighterPromise = createHighlighter({
|
||||
themes: ["github-light", "github-light-high-contrast", "github-dark"],
|
||||
langs: [],
|
||||
highlighterPromise = (async () => {
|
||||
const shiki = await loadShikiModule()
|
||||
return shiki.createHighlighter({
|
||||
themes: ["github-light", "github-light-high-contrast", "github-dark"],
|
||||
langs: [],
|
||||
})
|
||||
})().catch((error) => {
|
||||
highlighterPromise = null
|
||||
throw error
|
||||
})
|
||||
|
||||
highlighter = await highlighterPromise
|
||||
@@ -100,12 +71,37 @@ async function getOrCreateHighlighter() {
|
||||
return highlighter
|
||||
}
|
||||
|
||||
async function loadShikiModule() {
|
||||
if (!shikiModulePromise) {
|
||||
shikiModulePromise = import("shiki/bundle/full").then((module) => {
|
||||
bundledLanguagesCache = module.bundledLanguages
|
||||
return module
|
||||
})
|
||||
}
|
||||
|
||||
return shikiModulePromise
|
||||
}
|
||||
|
||||
function queueHighlighterWarmup() {
|
||||
if (highlighter || highlighterPromise) {
|
||||
return
|
||||
}
|
||||
|
||||
void getOrCreateHighlighter().catch((error) => {
|
||||
log.warn("Failed to initialize markdown highlighter", error)
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeLanguageToken(token: string): string {
|
||||
return token.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function resolveLanguage(token: string): { canonical: string | null; raw: string } {
|
||||
const normalized = normalizeLanguageToken(token)
|
||||
const bundledLanguages = bundledLanguagesCache
|
||||
if (!bundledLanguages) {
|
||||
return { canonical: null, raw: normalized }
|
||||
}
|
||||
|
||||
// Check if it's a direct key match
|
||||
if (normalized in bundledLanguages) {
|
||||
@@ -148,32 +144,43 @@ async function ensureLanguages(content: string) {
|
||||
|
||||
// Queue language loading tasks
|
||||
for (const token of foundLanguages) {
|
||||
const { canonical, raw } = resolveLanguage(token)
|
||||
const langKey = canonical || raw
|
||||
const rawToken = normalizeLanguageToken(token)
|
||||
if (!rawToken) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip "text" and aliases since Shiki handles plain text already
|
||||
if (langKey === "text" || raw === "text") {
|
||||
if (rawToken === "text") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if already loaded or queued
|
||||
if (loadedLanguages.has(langKey) || queuedLanguages.has(langKey)) {
|
||||
if (loadedLanguages.has(rawToken) || queuedLanguages.has(rawToken)) {
|
||||
continue
|
||||
}
|
||||
|
||||
queuedLanguages.add(langKey)
|
||||
queuedLanguages.add(rawToken)
|
||||
|
||||
// Queue the language loading task
|
||||
languageLoadQueue.push(async () => {
|
||||
try {
|
||||
await loadShikiModule()
|
||||
const { canonical, raw } = resolveLanguage(token)
|
||||
const langKey = canonical || raw
|
||||
|
||||
if (langKey === "text" || raw === "text") {
|
||||
return
|
||||
}
|
||||
|
||||
const h = await getOrCreateHighlighter()
|
||||
await h.loadLanguage(langKey as never)
|
||||
loadedLanguages.add(langKey)
|
||||
loadedLanguages.add(raw)
|
||||
triggerLanguageListeners()
|
||||
} catch {
|
||||
// Quietly ignore errors
|
||||
} finally {
|
||||
queuedLanguages.delete(langKey)
|
||||
queuedLanguages.delete(rawToken)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -184,52 +191,6 @@ async function ensureLanguages(content: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeHtmlEntities(content: string): string {
|
||||
if (!content.includes("&")) {
|
||||
return content
|
||||
}
|
||||
|
||||
const entityPattern = /&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);/g
|
||||
const namedEntities: Record<string, string> = {
|
||||
amp: "&",
|
||||
lt: "<",
|
||||
gt: ">",
|
||||
quot: '"',
|
||||
apos: "'",
|
||||
nbsp: " ",
|
||||
}
|
||||
|
||||
let result = content
|
||||
let previous = ""
|
||||
|
||||
while (result.includes("&") && result !== previous) {
|
||||
previous = result
|
||||
result = result.replace(entityPattern, (match, entity) => {
|
||||
if (!entity) {
|
||||
return match
|
||||
}
|
||||
|
||||
if (entity[0] === "#") {
|
||||
const isHex = entity[1]?.toLowerCase() === "x"
|
||||
const value = isHex ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10)
|
||||
if (!Number.isNaN(value)) {
|
||||
try {
|
||||
return String.fromCodePoint(value)
|
||||
} catch {
|
||||
return match
|
||||
}
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
const decoded = namedEntities[entity.toLowerCase()]
|
||||
return decoded !== undefined ? decoded : match
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function runLanguageLoadQueue() {
|
||||
if (isQueueRunning || languageLoadQueue.length === 0) {
|
||||
return
|
||||
@@ -249,7 +210,6 @@ async function runLanguageLoadQueue() {
|
||||
|
||||
function setupRenderer(isDark: boolean) {
|
||||
currentTheme = isDark ? "dark" : "light"
|
||||
if (!highlighter) return
|
||||
if (rendererSetup) return
|
||||
|
||||
marked.setOptions({
|
||||
@@ -330,8 +290,9 @@ function setupRenderer(isDark: boolean) {
|
||||
}
|
||||
|
||||
export async function initMarkdown(isDark: boolean) {
|
||||
await getOrCreateHighlighter()
|
||||
setupRenderer(isDark)
|
||||
queueHighlighterWarmup()
|
||||
await getOrCreateHighlighter()
|
||||
isInitialized = true
|
||||
}
|
||||
|
||||
@@ -350,15 +311,16 @@ export async function renderMarkdown(
|
||||
},
|
||||
): Promise<string> {
|
||||
if (!isInitialized) {
|
||||
await initMarkdown(currentTheme === "dark")
|
||||
setupRenderer(currentTheme === "dark")
|
||||
isInitialized = true
|
||||
}
|
||||
|
||||
const suppressHighlight = options?.suppressHighlight ?? false
|
||||
const decoded = decodeHtmlEntities(content)
|
||||
|
||||
if (!suppressHighlight) {
|
||||
// Queue language loading but don't wait for it to complete
|
||||
await ensureLanguages(decoded)
|
||||
queueHighlighterWarmup()
|
||||
void ensureLanguages(decoded)
|
||||
}
|
||||
|
||||
const previousSuppressed = highlightSuppressed
|
||||
@@ -375,13 +337,3 @@ export async function renderMarkdown(
|
||||
export async function getSharedHighlighter(): Promise<Highlighter> {
|
||||
return getOrCreateHighlighter()
|
||||
}
|
||||
|
||||
export function escapeHtml(text: string): string {
|
||||
const map: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
}
|
||||
return text.replace(/[&<"']/g, (m) => map[m])
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { runtimeEnv } from "../runtime-env"
|
||||
import { getLogger } from "../logger"
|
||||
const log = getLogger("actions")
|
||||
@@ -15,9 +16,8 @@ export async function restartCli(): Promise<boolean> {
|
||||
}
|
||||
|
||||
if (runtimeEnv.host === "tauri") {
|
||||
const tauri = (window as typeof window & { __TAURI__?: { invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T> } }).__TAURI__
|
||||
if (tauri?.invoke) {
|
||||
await tauri.invoke("cli_restart")
|
||||
if (typeof window.__TAURI__?.core?.invoke === "function") {
|
||||
await invoke("cli_restart")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { listen } from "@tauri-apps/api/event"
|
||||
import { getLogger } from "../logger"
|
||||
import { runtimeEnv } from "../runtime-env"
|
||||
|
||||
@@ -107,13 +108,8 @@ export async function listenForNativeFolderDrops(onDrop: (paths: string[]) => vo
|
||||
return () => {}
|
||||
}
|
||||
|
||||
const eventApi = window.__TAURI__?.event
|
||||
if (!eventApi?.listen) {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
try {
|
||||
const unlisten = await eventApi.listen("desktop:folder-drop", (event) => {
|
||||
const unlisten = await listen("desktop:folder-drop", (event) => {
|
||||
const payload = (event.payload ?? {}) as TauriFolderDropPayload
|
||||
const paths = normalizePathList(payload.paths)
|
||||
if (paths.length > 0) {
|
||||
@@ -134,15 +130,10 @@ export async function listenForNativeFolderDropState(onState: (state: NativeFold
|
||||
return () => {}
|
||||
}
|
||||
|
||||
const eventApi = window.__TAURI__?.event
|
||||
if (!eventApi?.listen) {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
try {
|
||||
const [unlistenEnter, unlistenLeave] = await Promise.all([
|
||||
eventApi.listen("desktop:folder-drag-enter", () => onState("enter")),
|
||||
eventApi.listen("desktop:folder-drag-leave", () => onState("leave")),
|
||||
listen("desktop:folder-drag-enter", () => onState("enter")),
|
||||
listen("desktop:folder-drag-leave", () => onState("leave")),
|
||||
])
|
||||
return () => {
|
||||
unlistenEnter()
|
||||
|
||||
@@ -1,43 +1,21 @@
|
||||
import { open } from "@tauri-apps/plugin-dialog"
|
||||
import type { NativeDialogOptions } from "../native-functions"
|
||||
import { getLogger } from "../../logger"
|
||||
const log = getLogger("actions")
|
||||
|
||||
|
||||
interface TauriDialogModule {
|
||||
open?: (
|
||||
options: {
|
||||
title?: string
|
||||
defaultPath?: string
|
||||
filters?: { name?: string; extensions: string[] }[]
|
||||
directory?: boolean
|
||||
multiple?: boolean
|
||||
},
|
||||
) => Promise<string | string[] | null>
|
||||
}
|
||||
|
||||
interface TauriBridge {
|
||||
dialog?: TauriDialogModule
|
||||
}
|
||||
|
||||
export async function openTauriNativeDialog(options: NativeDialogOptions): Promise<string | null> {
|
||||
if (typeof window === "undefined") {
|
||||
return null
|
||||
}
|
||||
|
||||
const tauriBridge = (window as Window & { __TAURI__?: TauriBridge }).__TAURI__
|
||||
const dialogApi = tauriBridge?.dialog
|
||||
if (!dialogApi?.open) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await dialogApi.open({
|
||||
const response = await open({
|
||||
title: options.title,
|
||||
defaultPath: options.defaultPath,
|
||||
directory: options.mode === "directory",
|
||||
multiple: false,
|
||||
filters: options.filters?.map((filter) => ({
|
||||
name: filter.name,
|
||||
name: filter.name ?? "Files",
|
||||
extensions: filter.extensions,
|
||||
})),
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { runtimeEnv } from "../runtime-env"
|
||||
import { getLogger } from "../logger"
|
||||
|
||||
@@ -60,8 +61,7 @@ function hasAnyWakeLockSupport(): boolean {
|
||||
if (api?.setWakeLock) return true
|
||||
}
|
||||
if (runtimeEnv.host === "tauri") {
|
||||
// We'll attempt dynamic import; treat as potentially supported.
|
||||
return true
|
||||
return typeof window.__TAURI__?.core?.invoke === "function"
|
||||
}
|
||||
return Boolean((navigator as any)?.wakeLock?.request)
|
||||
}
|
||||
@@ -84,21 +84,18 @@ async function setElectronWakeLock(enabled: boolean): Promise<boolean> {
|
||||
|
||||
async function setTauriWakeLock(enabled: boolean): Promise<boolean> {
|
||||
try {
|
||||
const mod = await import("tauri-plugin-keepawake-api")
|
||||
const start = (mod as any).start as ((config?: any) => Promise<void>) | undefined
|
||||
const stop = (mod as any).stop as (() => Promise<void>) | undefined
|
||||
if (!start || !stop) {
|
||||
if (!hasAnyWakeLockSupport()) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
// Plugin config supports toggling display/idle/sleep. Use a conservative
|
||||
// default to keep both system + display awake.
|
||||
await start({ display: true, idle: true, sleep: true })
|
||||
// Match Electron's prevent-display-sleep behavior by keeping the display
|
||||
// awake without blocking explicit system sleep requests.
|
||||
await invoke("wake_lock_start", { config: { display: true, idle: false, sleep: false } })
|
||||
return true
|
||||
}
|
||||
|
||||
await stop()
|
||||
await invoke("wake_lock_stop")
|
||||
return false
|
||||
} catch (error) {
|
||||
log.log("[wake-lock] tauri wake lock failed", error)
|
||||
@@ -137,13 +134,12 @@ export function setWakeLockDesired(nextDesired: boolean): Promise<boolean> {
|
||||
inFlight = (async () => {
|
||||
try {
|
||||
const ok = await applyWakeLock(target)
|
||||
// Treat disable attempts as applied even if the underlying API doesn't exist.
|
||||
applied = target
|
||||
applied = target ? ok : false
|
||||
return ok
|
||||
} finally {
|
||||
inFlight = null
|
||||
// If desired changed while in-flight, re-apply once.
|
||||
if (desired !== applied) {
|
||||
if (desired !== target) {
|
||||
void setWakeLockDesired(desired)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,17 +9,14 @@ export interface RuntimeEnvironment {
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface TauriCoreModule {
|
||||
invoke: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
interface Window {
|
||||
electronAPI?: unknown
|
||||
__TAURI__?: {
|
||||
invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
|
||||
event?: {
|
||||
listen: (event: string, handler: (payload: { payload: unknown }) => void) => Promise<() => void>
|
||||
}
|
||||
dialog?: {
|
||||
open?: (options: Record<string, unknown>) => Promise<string | string[] | null>
|
||||
save?: (options: Record<string, unknown>) => Promise<string | null>
|
||||
}
|
||||
core?: TauriCoreModule
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
92
packages/ui/src/lib/text-render-utils.ts
Normal file
92
packages/ui/src/lib/text-render-utils.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
const extensionToLanguage: Record<string, string> = {
|
||||
ts: "typescript",
|
||||
tsx: "typescript",
|
||||
js: "javascript",
|
||||
jsx: "javascript",
|
||||
py: "python",
|
||||
sh: "bash",
|
||||
bash: "bash",
|
||||
json: "json",
|
||||
html: "html",
|
||||
css: "css",
|
||||
md: "markdown",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
sql: "sql",
|
||||
rs: "rust",
|
||||
go: "go",
|
||||
cpp: "cpp",
|
||||
cc: "cpp",
|
||||
cxx: "cpp",
|
||||
hpp: "cpp",
|
||||
h: "cpp",
|
||||
c: "c",
|
||||
java: "java",
|
||||
cs: "csharp",
|
||||
php: "php",
|
||||
rb: "ruby",
|
||||
swift: "swift",
|
||||
kt: "kotlin",
|
||||
}
|
||||
|
||||
export function getLanguageFromPath(path?: string | null): string | undefined {
|
||||
if (!path) return undefined
|
||||
const ext = path.split(".").pop()?.toLowerCase()
|
||||
return ext ? extensionToLanguage[ext] : undefined
|
||||
}
|
||||
|
||||
export function decodeHtmlEntities(content: string): string {
|
||||
if (!content.includes("&")) {
|
||||
return content
|
||||
}
|
||||
|
||||
const entityPattern = /&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);/g
|
||||
const namedEntities: Record<string, string> = {
|
||||
amp: "&",
|
||||
lt: "<",
|
||||
gt: ">",
|
||||
quot: '"',
|
||||
apos: "'",
|
||||
nbsp: " ",
|
||||
}
|
||||
|
||||
let result = content
|
||||
let previous = ""
|
||||
|
||||
while (result.includes("&") && result !== previous) {
|
||||
previous = result
|
||||
result = result.replace(entityPattern, (match, entity) => {
|
||||
if (!entity) {
|
||||
return match
|
||||
}
|
||||
|
||||
if (entity[0] === "#") {
|
||||
const isHex = entity[1]?.toLowerCase() === "x"
|
||||
const value = isHex ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10)
|
||||
if (!Number.isNaN(value)) {
|
||||
try {
|
||||
return String.fromCodePoint(value)
|
||||
} catch {
|
||||
return match
|
||||
}
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
const decoded = namedEntities[entity.toLowerCase()]
|
||||
return decoded !== undefined ? decoded : match
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function escapeHtml(text: string): string {
|
||||
const map: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
}
|
||||
return text.replace(/[&<"']/g, (match) => map[match])
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { ThemeProvider } from "./lib/theme"
|
||||
import { ConfigProvider } from "./stores/preferences"
|
||||
import { InstanceConfigProvider } from "./stores/instance-config"
|
||||
import { runtimeEnv } from "./lib/runtime-env"
|
||||
import { I18nProvider } from "./lib/i18n"
|
||||
import { I18nProvider, preloadLocaleMessages } from "./lib/i18n"
|
||||
import { storage } from "./lib/storage"
|
||||
import "./index.css"
|
||||
import "@git-diff-view/solid/styles/diff-view-pure.css"
|
||||
@@ -31,15 +31,19 @@ async function bootstrap() {
|
||||
|
||||
try {
|
||||
const uiConfig = await storage.loadConfigOwner("ui")
|
||||
const theme = (uiConfig as any)?.theme ?? "system"
|
||||
const theme = (uiConfig as any)?.theme
|
||||
const locale = typeof (uiConfig as any)?.settings?.locale === "string" ? (uiConfig as any).settings.locale : undefined
|
||||
|
||||
if (theme === "system") {
|
||||
document.documentElement.removeAttribute("data-theme")
|
||||
} else {
|
||||
if (theme === "light" || theme === "dark") {
|
||||
document.documentElement.setAttribute("data-theme", theme)
|
||||
} else {
|
||||
document.documentElement.removeAttribute("data-theme")
|
||||
}
|
||||
|
||||
await preloadLocaleMessages(locale)
|
||||
} catch {
|
||||
// If config fails to load, fall back to CSS defaults.
|
||||
await preloadLocaleMessages()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { listen } from "@tauri-apps/api/event"
|
||||
import { Show, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import iconUrl from "../../images/CodeNomad-Icon.png"
|
||||
@@ -27,13 +29,6 @@ interface CliStatus {
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
interface TauriBridge {
|
||||
invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
|
||||
event?: {
|
||||
listen: (event: string, handler: (payload: { payload: unknown }) => void) => Promise<() => void>
|
||||
}
|
||||
}
|
||||
|
||||
function pickPhraseKey(previous?: PhraseKey) {
|
||||
const filtered = phraseKeys.filter((key) => key !== previous)
|
||||
const source = filtered.length > 0 ? filtered : phraseKeys
|
||||
@@ -46,17 +41,6 @@ function navigateTo(url?: string | null) {
|
||||
window.location.replace(url)
|
||||
}
|
||||
|
||||
function getTauriBridge(): TauriBridge | null {
|
||||
if (typeof window === "undefined") {
|
||||
return null
|
||||
}
|
||||
const bridge = (window as { __TAURI__?: TauriBridge }).__TAURI__
|
||||
if (!bridge || !bridge.event || !bridge.invoke) {
|
||||
return null
|
||||
}
|
||||
return bridge
|
||||
}
|
||||
|
||||
function annotateDocument() {
|
||||
if (typeof document === "undefined") {
|
||||
return
|
||||
@@ -77,25 +61,22 @@ function LoadingApp() {
|
||||
setPhraseKey(pickPhraseKey())
|
||||
const unsubscribers: Array<() => void> = []
|
||||
|
||||
async function bootstrapTauri(tauriBridge: TauriBridge | null) {
|
||||
if (!tauriBridge || !tauriBridge.event || !tauriBridge.invoke) {
|
||||
return
|
||||
}
|
||||
async function bootstrapTauri() {
|
||||
try {
|
||||
const readyUnlisten = await tauriBridge.event.listen("cli:ready", (event) => {
|
||||
const readyUnlisten = await listen("cli:ready", (event) => {
|
||||
const payload = (event?.payload as CliStatus) || {}
|
||||
setError(null)
|
||||
setStatusKey(null)
|
||||
navigateTo(payload.url)
|
||||
})
|
||||
const errorUnlisten = await tauriBridge.event.listen("cli:error", (event) => {
|
||||
const errorUnlisten = await listen("cli:error", (event) => {
|
||||
const payload = (event?.payload as CliStatus) || {}
|
||||
if (payload.error) {
|
||||
setError(payload.error)
|
||||
setStatusKey("loadingScreen.status.issue")
|
||||
}
|
||||
})
|
||||
const statusUnlisten = await tauriBridge.event.listen("cli:status", (event) => {
|
||||
const statusUnlisten = await listen("cli:status", (event) => {
|
||||
const payload = (event?.payload as CliStatus) || {}
|
||||
if (payload.state === "error" && payload.error) {
|
||||
setError(payload.error)
|
||||
@@ -109,7 +90,7 @@ function LoadingApp() {
|
||||
})
|
||||
unsubscribers.push(readyUnlisten, errorUnlisten, statusUnlisten)
|
||||
|
||||
const result = await tauriBridge.invoke<CliStatus>("cli_get_status")
|
||||
const result = await invoke<CliStatus>("cli_get_status")
|
||||
if (result?.state === "ready" && result.url) {
|
||||
navigateTo(result.url)
|
||||
} else if (result?.state === "error" && result.error) {
|
||||
@@ -123,7 +104,7 @@ function LoadingApp() {
|
||||
}
|
||||
|
||||
if (isTauriHost()) {
|
||||
void bootstrapTauri(getTauriBridge())
|
||||
void bootstrapTauri()
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { decodeHtmlEntities } from "../../lib/markdown"
|
||||
import { decodeHtmlEntities } from "../../lib/text-render-utils"
|
||||
|
||||
function decodeTextSegment(segment: any): any {
|
||||
if (typeof segment === "string") {
|
||||
|
||||
@@ -77,6 +77,29 @@ function shouldSendOsNotification(kind: "needsInput" | "idle"): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
function isChildSession(instanceId: string, sessionId: string): boolean | null {
|
||||
const session = sessions().get(instanceId)?.get(sessionId)
|
||||
if (!session) return null
|
||||
return session.parentId !== null && session.parentId !== undefined
|
||||
}
|
||||
|
||||
function shouldSendOsNotificationForSession(
|
||||
kind: "needsInput" | "idle",
|
||||
instanceId: string,
|
||||
sessionId: string | undefined | null,
|
||||
): boolean {
|
||||
if (!shouldSendOsNotification(kind)) return false
|
||||
if (!sessionId) return true
|
||||
|
||||
const child = isChildSession(instanceId, sessionId)
|
||||
|
||||
// Avoid notification spam from spawned child/subagent sessions arriving before hydration.
|
||||
if (child === null) return false
|
||||
if (child) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function getInstanceDisplayName(instanceId: string): string {
|
||||
const instanceFolder = instances().get(instanceId)?.folder ?? instanceId
|
||||
return instanceFolder.split(/[\\/]/).filter(Boolean).pop() ?? instanceFolder
|
||||
@@ -492,7 +515,7 @@ function handleSessionIdle(instanceId: string, event: EventSessionIdle): void {
|
||||
const sessionId = event.properties?.sessionID
|
||||
if (!sessionId) return
|
||||
|
||||
if (shouldSendOsNotification("idle")) {
|
||||
if (shouldSendOsNotificationForSession("idle", instanceId, sessionId)) {
|
||||
const title = getInstanceDisplayName(instanceId)
|
||||
const label = getSessionTitle(instanceId, sessionId)
|
||||
const body = label ? `Session "${label}" is idle` : "Session is idle"
|
||||
@@ -607,9 +630,10 @@ function handlePermissionUpdated(instanceId: string, event: { type: string; prop
|
||||
addPermissionToQueue(instanceId, permission)
|
||||
upsertPermissionV2(instanceId, permission)
|
||||
|
||||
if (shouldSendOsNotification("needsInput")) {
|
||||
const sessionId = getPermissionSessionId(permission)
|
||||
|
||||
if (shouldSendOsNotificationForSession("needsInput", instanceId, sessionId)) {
|
||||
const title = getInstanceDisplayName(instanceId)
|
||||
const sessionId = getPermissionSessionId(permission)
|
||||
const label = getSessionTitle(instanceId, sessionId)
|
||||
const body = label ? `Session "${label}" needs permission` : "Session needs permission"
|
||||
fireOsNotification({ title, body })
|
||||
@@ -634,9 +658,10 @@ function handleQuestionAsked(instanceId: string, event: { type: string; properti
|
||||
addQuestionToQueue(instanceId, request)
|
||||
upsertQuestionV2(instanceId, request)
|
||||
|
||||
if (shouldSendOsNotification("needsInput")) {
|
||||
const sessionId = getQuestionSessionId(request)
|
||||
|
||||
if (shouldSendOsNotificationForSession("needsInput", instanceId, sessionId)) {
|
||||
const title = getInstanceDisplayName(instanceId)
|
||||
const sessionId = getQuestionSessionId(request)
|
||||
const label = getSessionTitle(instanceId, sessionId)
|
||||
const body = label ? `Session "${label}" needs input` : "Session needs input"
|
||||
fireOsNotification({ title, body })
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
|
||||
@@ -40,11 +40,11 @@
|
||||
}
|
||||
|
||||
.selector-trigger-primary--align-left {
|
||||
@apply text-left w-full;
|
||||
@apply text-start w-full;
|
||||
}
|
||||
|
||||
.selector-trigger-secondary {
|
||||
@apply text-xs text-left truncate;
|
||||
@apply text-xs text-start truncate w-full;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
padding: 1.25rem;
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in oklab, var(--surface-secondary) 92%, var(--accent-primary) 8%), var(--surface-secondary));
|
||||
border-right: 1px solid var(--border-base);
|
||||
border-inline-end: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.settings-screen-nav-header {
|
||||
@@ -121,6 +121,9 @@
|
||||
color: var(--text-primary);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
[dir="rtl"] .settings-nav-button[data-selected="true"] {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
|
||||
.settings-nav-button-icon {
|
||||
width: 1rem;
|
||||
@@ -360,7 +363,7 @@
|
||||
border: 1px solid var(--border-base);
|
||||
background: var(--surface-base);
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
transition: border-color 140ms ease, background-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
@@ -418,7 +421,7 @@
|
||||
}
|
||||
|
||||
.settings-choice-check {
|
||||
margin-left: auto;
|
||||
margin-inline-start: auto;
|
||||
color: var(--accent-primary);
|
||||
opacity: 0;
|
||||
}
|
||||
@@ -488,7 +491,7 @@
|
||||
.settings-screen-nav {
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border-right: none;
|
||||
border-inline-end: none;
|
||||
border-bottom: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,21 @@
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Auto-detect text direction per block element for RTL language support (e.g. Hebrew, Arabic) */
|
||||
.markdown-body p,
|
||||
.markdown-body li,
|
||||
.markdown-body h1,
|
||||
.markdown-body h2,
|
||||
.markdown-body h3,
|
||||
.markdown-body h4,
|
||||
.markdown-body h5,
|
||||
.markdown-body h6,
|
||||
.markdown-body blockquote,
|
||||
.markdown-body td,
|
||||
.markdown-body th {
|
||||
unicode-bidi: plaintext;
|
||||
}
|
||||
|
||||
.markdown-body h1,
|
||||
.markdown-body h2,
|
||||
.markdown-body h3,
|
||||
@@ -121,6 +136,7 @@
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
margin: 1rem 0;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.markdown-body pre:not(.shiki) code,
|
||||
@@ -129,16 +145,19 @@
|
||||
}
|
||||
|
||||
.markdown-body blockquote {
|
||||
border-left: 3px solid var(--border-base);
|
||||
border-inline-start: 3px solid var(--border-base);
|
||||
color: var(--text-secondary);
|
||||
background-color: var(--surface-muted);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0 8px 8px 0;
|
||||
border-start-start-radius: 0;
|
||||
border-start-end-radius: 8px;
|
||||
border-end-end-radius: 8px;
|
||||
border-end-start-radius: 0;
|
||||
}
|
||||
|
||||
.markdown-body ul,
|
||||
.markdown-body ol {
|
||||
padding-left: 1.5rem;
|
||||
padding-inline-start: 1.5rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
@@ -166,7 +185,7 @@
|
||||
.markdown-body td {
|
||||
border: 1px solid var(--border-base);
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
color: var(--text-primary);
|
||||
background-color: transparent;
|
||||
}
|
||||
@@ -221,7 +240,7 @@
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease;
|
||||
margin-left: auto;
|
||||
margin-inline-start: auto;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
/* Message error block */
|
||||
.message-error-block {
|
||||
@apply text-sm p-3 rounded border-l-[3px] my-2;
|
||||
@apply text-sm p-3 rounded border-s-[3px] my-2;
|
||||
color: var(--status-error);
|
||||
background-color: var(--message-error-bg);
|
||||
border-color: var(--status-error);
|
||||
|
||||
@@ -132,15 +132,22 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.message-stream-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.message-step-start {
|
||||
background-color: var(--message-assistant-bg);
|
||||
border-left: 4px solid var(--message-assistant-border);
|
||||
border-inline-start: 4px solid var(--message-assistant-border);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.message-step-finish {
|
||||
background-color: var(--message-assistant-bg);
|
||||
border-left: 4px solid var(--message-assistant-border);
|
||||
border-inline-start: 4px solid var(--message-assistant-border);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -165,7 +172,7 @@
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
font-weight: var(--font-weight-medium);
|
||||
margin-right: 0.35rem;
|
||||
margin-inline-end: 0.35rem;
|
||||
}
|
||||
|
||||
.message-step-heading {
|
||||
@@ -182,7 +189,7 @@
|
||||
}
|
||||
|
||||
.message-error-block {
|
||||
@apply text-sm p-3 rounded border-l-[3px] my-2;
|
||||
@apply text-sm p-3 rounded border-s-[3px] my-2;
|
||||
color: var(--status-error);
|
||||
background-color: var(--message-error-bg);
|
||||
border-color: var(--status-error);
|
||||
@@ -251,6 +258,7 @@
|
||||
padding: 8px;
|
||||
background-color: var(--surface-code);
|
||||
border-radius: 4px;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.message-error-part {
|
||||
@@ -328,12 +336,12 @@
|
||||
|
||||
.message-step-start {
|
||||
background-color: var(--message-assistant-bg);
|
||||
border-left: 4px solid var(--message-assistant-border);
|
||||
border-inline-start: 4px solid var(--message-assistant-border);
|
||||
}
|
||||
|
||||
.message-step-finish {
|
||||
background-color: var(--message-assistant-bg);
|
||||
border-left: 4px solid var(--message-assistant-border);
|
||||
border-inline-start: 4px solid var(--message-assistant-border);
|
||||
}
|
||||
|
||||
.message-step-heading {
|
||||
@@ -356,7 +364,7 @@
|
||||
}
|
||||
|
||||
.message-step-time {
|
||||
@apply text-[11px] text-[var(--text-muted)] font-normal ml-auto;
|
||||
@apply text-[11px] text-[var(--text-muted)] font-normal ms-auto;
|
||||
}
|
||||
|
||||
.message-step-meta-inline {
|
||||
@@ -383,7 +391,7 @@
|
||||
.message-reasoning-card {
|
||||
--reasoning-border-color: var(--border-strong, var(--border-base));
|
||||
background-color: var(--message-assistant-bg);
|
||||
border-left: 4px solid var(--message-assistant-border);
|
||||
border-inline-start: 4px solid var(--message-assistant-border);
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
padding: 0;
|
||||
@@ -417,7 +425,7 @@
|
||||
padding: 0.25rem 0.6rem;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
@@ -539,4 +547,5 @@
|
||||
color: var(--text-primary);
|
||||
white-space: pre-wrap;
|
||||
margin: 0;
|
||||
unicode-bidi: plaintext;
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@
|
||||
|
||||
.message-scroll-button-wrapper {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
inset-inline-end: 1rem;
|
||||
bottom: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -274,7 +274,7 @@
|
||||
}
|
||||
|
||||
.message-quote-button + .message-quote-button {
|
||||
border-left: 1px solid var(--list-item-highlight-border);
|
||||
border-inline-start: 1px solid var(--list-item-highlight-border);
|
||||
}
|
||||
|
||||
.message-quote-button:hover {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
.message-select-checkbox {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-right: 0.5rem;
|
||||
margin-inline-end: 0.5rem;
|
||||
cursor: pointer;
|
||||
accent-color: var(--status-error);
|
||||
flex: 0 0 auto;
|
||||
@@ -134,7 +134,7 @@
|
||||
}
|
||||
|
||||
.message-delete-mode-menu {
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
bottom: calc(100% + 6px);
|
||||
min-width: 150px;
|
||||
width: max-content;
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 64px;
|
||||
inset-inline-end: 64px;
|
||||
width: 1px;
|
||||
background-color: var(--border-muted);
|
||||
pointer-events: none;
|
||||
@@ -32,7 +32,7 @@
|
||||
}
|
||||
|
||||
.message-layout--with-timeline::after {
|
||||
right: 40px;
|
||||
inset-inline-end: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,12 +311,12 @@
|
||||
|
||||
/* Tool segments that are part of a group get a left accent border. */
|
||||
.message-timeline-group-child {
|
||||
border-left: 3px solid color-mix(in oklab, var(--accent-primary) 35%, transparent);
|
||||
border-inline-start: 3px solid color-mix(in oklab, var(--accent-primary) 35%, transparent);
|
||||
}
|
||||
|
||||
/* The assistant "parent" at the bottom of a tool group gets the same border. */
|
||||
.message-timeline-group-parent {
|
||||
border-left: 3px solid color-mix(in oklab, var(--accent-primary) 35%, transparent);
|
||||
border-inline-start: 3px solid color-mix(in oklab, var(--accent-primary) 35%, transparent);
|
||||
}
|
||||
|
||||
/* Extra spacing before the first tool in a group to separate from the
|
||||
@@ -346,7 +346,7 @@
|
||||
/* Extend the overlay box into the stream so ribs are not relying on
|
||||
overflow-visible behavior (which is brittle around scroll containers). */
|
||||
--xray-overhang: calc(var(--max-rib-width, 50vw) + 84px);
|
||||
left: calc(-1 * var(--xray-overhang));
|
||||
inset-inline-start: calc(-1 * var(--xray-overhang));
|
||||
width: calc(100% + var(--xray-overhang));
|
||||
overflow: hidden;
|
||||
padding: 0.25rem;
|
||||
@@ -374,10 +374,10 @@
|
||||
|
||||
.message-timeline-xray-token-label {
|
||||
position: absolute;
|
||||
right: 100%;
|
||||
inset-inline-end: 100%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
margin-right: 4px;
|
||||
margin-inline-end: 4px;
|
||||
height: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -403,16 +403,25 @@
|
||||
var(--status-success) calc(100% - var(--segment-weight) * 100%),
|
||||
var(--status-error) calc(var(--segment-weight) * 100%)
|
||||
);
|
||||
border-radius: 3px 0 0 3px;
|
||||
border-start-start-radius: 3px;
|
||||
border-end-start-radius: 3px;
|
||||
border-start-end-radius: 0;
|
||||
border-end-end-radius: 0;
|
||||
transition: width 0.3s ease, background-color 0.3s ease;
|
||||
box-shadow: -2px 0 4px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
[dir="rtl"] .message-timeline-relative-bar {
|
||||
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.message-timeline-absolute-bar {
|
||||
height: 3px;
|
||||
width: calc(var(--segment-weight) * var(--max-rib-width, 50vw));
|
||||
background-color: var(--text-muted);
|
||||
border-radius: 2px 0 0 2px;
|
||||
border-start-start-radius: 2px;
|
||||
border-end-start-radius: 2px;
|
||||
border-start-end-radius: 0;
|
||||
border-end-end-radius: 0;
|
||||
transition: width 0.3s ease;
|
||||
opacity: 0.5;
|
||||
position: relative;
|
||||
@@ -425,7 +434,7 @@
|
||||
.message-timeline-absolute-bar-overflow::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -1px;
|
||||
inset-inline-start: -1px;
|
||||
top: -3px;
|
||||
bottom: -3px;
|
||||
width: 3px;
|
||||
|
||||
@@ -35,7 +35,9 @@
|
||||
}
|
||||
|
||||
.prompt-input {
|
||||
@apply w-full pl-3 pr-10 pt-2.5 border text-sm resize-none outline-none transition-colors;
|
||||
@apply w-full pt-2.5 border text-sm resize-none outline-none transition-colors;
|
||||
padding-inline-start: 2.5rem;
|
||||
padding-inline-end: 0.75rem;
|
||||
font-family: inherit;
|
||||
background-color: var(--surface-base);
|
||||
color: var(--text-primary);
|
||||
@@ -65,8 +67,8 @@
|
||||
.prompt-input-overlay {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
left: 0.75rem;
|
||||
right: 0.75rem;
|
||||
inset-inline-start: 0.75rem;
|
||||
inset-inline-end: 0.75rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
@@ -81,11 +83,13 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Navigation buttons container (expand, prev, next) */
|
||||
/* Navigation buttons container (expand, prev, next).
|
||||
Intentionally at inline-start (left in LTR, right in RTL) so buttons never overlap
|
||||
the scrollbar, which browsers always place at inline-end. */
|
||||
.prompt-nav-buttons {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
right: 0.25rem;
|
||||
inset-inline-start: 0.25rem;
|
||||
bottom: 0.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -160,6 +164,11 @@
|
||||
@apply opacity-60 cursor-not-allowed;
|
||||
}
|
||||
|
||||
/* In RTL: override dir="auto" which defaults to LTR on empty textarea */
|
||||
[dir="rtl"] .prompt-input {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.prompt-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -256,7 +265,7 @@
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 0;
|
||||
inset-inline-start: 0;
|
||||
padding: 8px;
|
||||
background-color: var(--surface-base);
|
||||
border: 1px solid var(--border-base);
|
||||
@@ -335,6 +344,7 @@
|
||||
.prompt-input {
|
||||
min-height: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding-inline-start: 2.5rem; /* preserve space for nav buttons */
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
.tool-call-message {
|
||||
@apply flex flex-col gap-2 p-3 w-full;
|
||||
background-color: var(--message-tool-bg);
|
||||
border-left: 4px solid var(--message-tool-border);
|
||||
border-inline-start: 4px solid var(--message-tool-border);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
}
|
||||
|
||||
.tool-call-header-toggle {
|
||||
@apply flex items-center gap-2 p-2 w-full bg-transparent border-none cursor-pointer text-left;
|
||||
@apply flex items-center gap-2 p-2 w-full bg-transparent border-none cursor-pointer text-start;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 13px;
|
||||
border-radius: 0;
|
||||
@@ -105,7 +105,7 @@
|
||||
.tool-call-header-toggle::before {
|
||||
content: "▶";
|
||||
font-size: 11px;
|
||||
margin-right: 0.35rem;
|
||||
margin-inline-end: 0.35rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
}
|
||||
|
||||
.tool-call-summary {
|
||||
@apply flex-1 text-left inline-flex items-center gap-2;
|
||||
@apply flex-1 text-start inline-flex items-center gap-2;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -168,26 +168,26 @@
|
||||
}
|
||||
|
||||
.tool-call-summary[data-tool-icon=""]::before {
|
||||
margin-right: 0;
|
||||
margin-inline-end: 0;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.tool-call-summary[data-tool-icon]:not([data-tool-icon=""])::before {
|
||||
margin-right: 0.35rem;
|
||||
margin-inline-end: 0.35rem;
|
||||
}
|
||||
|
||||
/* ToolState uses status="completed"; keep "success" as a legacy alias. */
|
||||
.tool-call-status-completed,
|
||||
.tool-call-status-success {
|
||||
border-left: 3px solid var(--status-success);
|
||||
border-inline-start: 3px solid var(--status-success);
|
||||
}
|
||||
|
||||
.tool-call-status-error {
|
||||
border-left: 3px solid var(--status-error);
|
||||
border-inline-start: 3px solid var(--status-error);
|
||||
}
|
||||
|
||||
.tool-call-status-running {
|
||||
border-left: 3px solid var(--status-warning);
|
||||
border-inline-start: 3px solid var(--status-warning);
|
||||
}
|
||||
|
||||
.tool-call-status-running .tool-call-status {
|
||||
@@ -195,7 +195,7 @@
|
||||
}
|
||||
|
||||
.tool-call-status-pending {
|
||||
border-left: 3px solid var(--accent-primary);
|
||||
border-inline-start: 3px solid var(--accent-primary);
|
||||
}
|
||||
|
||||
.tool-call-status-pending .tool-call-summary {
|
||||
@@ -257,7 +257,7 @@
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--tool-call-border-color);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
font-size: 0.875rem;
|
||||
font-weight: normal;
|
||||
color: var(--text-primary);
|
||||
@@ -267,7 +267,7 @@
|
||||
.tool-call-io-toggle::before {
|
||||
content: "▶";
|
||||
font-size: 11px;
|
||||
margin-right: 0.35rem;
|
||||
margin-inline-end: 0.35rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -393,7 +393,7 @@
|
||||
}
|
||||
|
||||
.tool-call-awaiting-permission {
|
||||
border-left-color: var(--status-warning);
|
||||
border-inline-start-color: var(--status-warning);
|
||||
}
|
||||
|
||||
.tool-call-permission {
|
||||
@@ -484,7 +484,7 @@
|
||||
}
|
||||
|
||||
.tool-call-permission-shortcuts .kbd {
|
||||
margin-right: 0.25rem;
|
||||
margin-inline-end: 0.25rem;
|
||||
}
|
||||
|
||||
.tool-call-permission-queued-text {
|
||||
@@ -549,6 +549,7 @@
|
||||
min-height: auto;
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
/* Shiki injects inline background colors; force token surfaces. */
|
||||
@@ -610,7 +611,7 @@
|
||||
}
|
||||
|
||||
.tool-call-diagnostics-heading {
|
||||
@apply flex items-center gap-2 p-2 w-full border-none cursor-pointer text-left;
|
||||
@apply flex items-center gap-2 p-2 w-full border-none cursor-pointer text-start;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
@@ -634,7 +635,7 @@
|
||||
|
||||
|
||||
.tool-call-diagnostics-heading {
|
||||
@apply flex items-center gap-2 p-2 w-full border-none cursor-pointer text-left;
|
||||
@apply flex items-center gap-2 p-2 w-full border-none cursor-pointer text-start;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
@@ -693,8 +694,8 @@
|
||||
gap: var(--space-xs);
|
||||
max-height: calc(4 * var(--tool-call-line-unit, 1.4em));
|
||||
overflow-y: scroll;
|
||||
padding-right: 0;
|
||||
margin-right: 0;
|
||||
padding-inline-end: 0;
|
||||
margin-inline-end: 0;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
@@ -762,6 +763,7 @@
|
||||
overflow-x: auto;
|
||||
max-height: var(--tool-call-max-height-compact, calc(25 * 1.4em));
|
||||
overflow-y: scroll;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.tool-call-section code {
|
||||
@@ -843,7 +845,7 @@
|
||||
|
||||
.tool-call-error-content {
|
||||
background-color: var(--message-error-bg);
|
||||
border-left: 3px solid var(--status-error);
|
||||
border-inline-start: 3px solid var(--status-error);
|
||||
padding: 12px;
|
||||
margin: 8px 0;
|
||||
border-radius: 4px;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user