Compare commits
10 Commits
v0.12.3-de
...
upstream/u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50ccae8b27 | ||
|
|
f1ba699f9f | ||
|
|
0cb1c05903 | ||
|
|
b7ed232688 | ||
|
|
a442d53efa | ||
|
|
8c0a82d3a8 | ||
|
|
57efe5def3 | ||
|
|
3710df916f | ||
|
|
6f15ba2051 | ||
|
|
695c3fa954 |
95
.github/workflows/build-and-upload.yml
vendored
95
.github/workflows/build-and-upload.yml
vendored
@@ -28,21 +28,6 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: true
|
default: true
|
||||||
type: boolean
|
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:
|
set_versions:
|
||||||
description: "Run npm version to set workspace versions"
|
description: "Run npm version to set workspace versions"
|
||||||
required: false
|
required: false
|
||||||
@@ -218,15 +203,6 @@ jobs:
|
|||||||
gh release upload "$TAG" "$file" --clobber
|
gh release upload "$TAG" "$file" --clobber
|
||||||
done
|
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:
|
build-windows:
|
||||||
runs-on: windows-2025
|
runs-on: windows-2025
|
||||||
env:
|
env:
|
||||||
@@ -268,15 +244,6 @@ jobs:
|
|||||||
gh release upload $env:TAG $_.FullName --clobber
|
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:
|
build-linux:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
env:
|
env:
|
||||||
@@ -319,15 +286,6 @@ jobs:
|
|||||||
gh release upload "$TAG" "$file" --clobber
|
gh release upload "$TAG" "$file" --clobber
|
||||||
done
|
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:
|
build-tauri-macos:
|
||||||
runs-on: macos-15-intel
|
runs-on: macos-15-intel
|
||||||
env:
|
env:
|
||||||
@@ -381,7 +339,7 @@ jobs:
|
|||||||
run: npm exec -- tauri build
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (macOS)
|
- name: Package Tauri artifacts (macOS)
|
||||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
if: ${{ inputs.upload }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||||
@@ -392,15 +350,6 @@ jobs:
|
|||||||
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
|
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
|
||||||
fi
|
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)
|
- name: Upload Tauri release assets (macOS)
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
@@ -465,7 +414,7 @@ jobs:
|
|||||||
run: npm exec -- tauri build
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (macOS arm64)
|
- name: Package Tauri artifacts (macOS arm64)
|
||||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
if: ${{ inputs.upload }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||||
@@ -476,15 +425,6 @@ jobs:
|
|||||||
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
|
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
|
||||||
fi
|
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)
|
- name: Upload Tauri release assets (macOS arm64)
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
@@ -552,7 +492,7 @@ jobs:
|
|||||||
run: npm exec -- tauri build
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (Windows)
|
- name: Package Tauri artifacts (Windows)
|
||||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
if: ${{ inputs.upload }}
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
$bundleRoot = "packages/tauri-app/target/release/bundle"
|
$bundleRoot = "packages/tauri-app/target/release/bundle"
|
||||||
@@ -565,15 +505,6 @@ jobs:
|
|||||||
Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force
|
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)
|
- name: Upload Tauri release assets (Windows)
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
@@ -651,7 +582,7 @@ jobs:
|
|||||||
run: npm exec -- tauri build
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (Linux)
|
- name: Package Tauri artifacts (Linux)
|
||||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
if: ${{ inputs.upload }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
SEARCH_ROOT="packages/tauri-app/target"
|
SEARCH_ROOT="packages/tauri-app/target"
|
||||||
@@ -677,15 +608,6 @@ jobs:
|
|||||||
cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb"
|
cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb"
|
||||||
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
|
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)
|
- name: Upload Tauri release assets (Linux)
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
@@ -844,12 +766,3 @@ jobs:
|
|||||||
echo "Uploading $file"
|
echo "Uploading $file"
|
||||||
gh release upload "$TAG" "$file" --clobber
|
gh release upload "$TAG" "$file" --clobber
|
||||||
done
|
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
|
|
||||||
|
|||||||
119
.github/workflows/comment-pr-artifacts.yml
vendored
119
.github/workflows/comment-pr-artifacts.yml
vendored
@@ -1,119 +0,0 @@
|
|||||||
name: Comment PR Artifacts
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request_target:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- synchronize
|
|
||||||
- reopened
|
|
||||||
|
|
||||||
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 }}
|
|
||||||
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' }}
|
|
||||||
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}`);
|
|
||||||
4
.github/workflows/pr-build.yml
vendored
4
.github/workflows/pr-build.yml
vendored
@@ -9,7 +9,6 @@ on:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
actions: write
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: pr-build-${{ github.event.pull_request.number }}
|
group: pr-build-${{ github.event.pull_request.number }}
|
||||||
@@ -50,7 +49,4 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
upload: false
|
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
|
set_versions: false
|
||||||
|
|||||||
31
package-lock.json
generated
31
package-lock.json
generated
@@ -10984,36 +10984,6 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -12143,7 +12113,6 @@
|
|||||||
"shiki": "^3.13.0",
|
"shiki": "^3.13.0",
|
||||||
"solid-js": "^1.8.0",
|
"solid-js": "^1.8.0",
|
||||||
"solid-toast": "^0.5.0",
|
"solid-toast": "^0.5.0",
|
||||||
"virtua": "^0.48.8",
|
|
||||||
"yaml": "^2.4.2"
|
"yaml": "^2.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -31,4 +31,4 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.11"
|
"baseline-browser-mapping": "^2.9.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,6 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "1.2.24"
|
"@opencode-ai/plugin": "1.2.25"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,4 +46,4 @@
|
|||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
"core:menu:default",
|
"core:menu:default",
|
||||||
"dialog:allow-open",
|
"dialog:allow-open",
|
||||||
"opener:allow-default-urls",
|
"opener:allow-default-urls",
|
||||||
"opener:allow-open-url",
|
|
||||||
"notification:allow-is-permission-granted",
|
"notification:allow-is-permission-granted",
|
||||||
"notification:allow-request-permission",
|
"notification:allow-request-permission",
|
||||||
"notification:allow-notify",
|
"notification:allow-notify",
|
||||||
|
|||||||
@@ -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","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"]}}
|
{"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"]}}
|
||||||
@@ -32,7 +32,6 @@
|
|||||||
"shiki": "^3.13.0",
|
"shiki": "^3.13.0",
|
||||||
"solid-js": "^1.8.0",
|
"solid-js": "^1.8.0",
|
||||||
"solid-toast": "^0.5.0",
|
"solid-toast": "^0.5.0",
|
||||||
"virtua": "^0.48.8",
|
|
||||||
"yaml": "^2.4.2"
|
"yaml": "^2.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -45,4 +44,4 @@
|
|||||||
"vite-plugin-pwa": "^1.2.0",
|
"vite-plugin-pwa": "^1.2.0",
|
||||||
"vite-plugin-solid": "^2.10.0"
|
"vite-plugin-solid": "^2.10.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,8 @@ import { formatCompactCount } from "../lib/formatters"
|
|||||||
import { useI18n, type Locale } from "../lib/i18n"
|
import { useI18n, type Locale } from "../lib/i18n"
|
||||||
import { showAlertDialog } from "../stores/alerts"
|
import { showAlertDialog } from "../stores/alerts"
|
||||||
import { openSettings, settingsOpen } from "../stores/settings-screen"
|
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 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 {
|
interface FolderSelectionViewProps {
|
||||||
@@ -235,6 +232,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
props.onSelectFolder(path, selectedBinary())
|
props.onSelectFolder(path, selectedBinary())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openExternalLink = (url: string) => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer")
|
||||||
|
}
|
||||||
|
|
||||||
async function handleBrowse() {
|
async function handleBrowse() {
|
||||||
if (isLoading()) return
|
if (isLoading()) return
|
||||||
setFocusMode("new")
|
setFocusMode("new")
|
||||||
@@ -423,7 +425,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||||
<div class="mt-3 flex justify-center gap-2">
|
<div class="mt-3 flex justify-center gap-2">
|
||||||
<a
|
<a
|
||||||
href={GITHUB_URL}
|
href="https://github.com/NeuralNomadsAI/CodeNomad"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||||
@@ -431,13 +433,13 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
title={t("folderSelection.links.github")}
|
title={t("folderSelection.links.github")}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
void openExternalUrl(GITHUB_URL, "folder-selection")
|
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<GitHubMarkIcon class="w-4 h-4" />
|
<GitHubMarkIcon class="w-4 h-4" />
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={GITHUB_URL}
|
href="https://github.com/NeuralNomadsAI/CodeNomad"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
|
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
|
||||||
@@ -445,7 +447,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
title={t("folderSelection.links.githubStars")}
|
title={t("folderSelection.links.githubStars")}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
void openExternalUrl(GITHUB_URL, "folder-selection")
|
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Star class="w-4 h-4" />
|
<Star class="w-4 h-4" />
|
||||||
@@ -454,7 +456,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={DISCORD_URL}
|
href="https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||||
@@ -462,7 +464,9 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
title={t("folderSelection.links.discord")}
|
title={t("folderSelection.links.discord")}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
void openExternalUrl(DISCORD_URL, "folder-selection")
|
openExternalLink(
|
||||||
|
"https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945",
|
||||||
|
)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DiscordSymbolIcon class="w-4 h-4" />
|
<DiscordSymbolIcon class="w-4 h-4" />
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import UnifiedPicker from "./unified-picker"
|
|||||||
import ExpandButton from "./expand-button"
|
import ExpandButton from "./expand-button"
|
||||||
import { clearAttachments, removeAttachment } from "../stores/attachments"
|
import { clearAttachments, removeAttachment } from "../stores/attachments"
|
||||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||||
import { createPastedPlaceholderRegex, pastedDisplayCounterRegex } from "./prompt-input/attachmentPlaceholders"
|
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
import { getActiveInstance } from "../stores/instances"
|
import { getActiveInstance } from "../stores/instances"
|
||||||
import { agents, executeCustomCommand } from "../stores/sessions"
|
import { agents, executeCustomCommand } from "../stores/sessions"
|
||||||
@@ -14,41 +13,12 @@ import { useI18n } from "../lib/i18n"
|
|||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { preferences } from "../stores/preferences"
|
import { preferences } from "../stores/preferences"
|
||||||
import type { ExpandState, PromptInputApi, PromptInputProps, PromptInsertMode, PromptMode } from "./prompt-input/types"
|
import type { ExpandState, PromptInputApi, PromptInputProps, PromptInsertMode, PromptMode } from "./prompt-input/types"
|
||||||
import type { Attachment } from "../types/attachment"
|
|
||||||
import { usePromptState } from "./prompt-input/usePromptState"
|
import { usePromptState } from "./prompt-input/usePromptState"
|
||||||
import { usePromptAttachments } from "./prompt-input/usePromptAttachments"
|
import { usePromptAttachments } from "./prompt-input/usePromptAttachments"
|
||||||
import { usePromptPicker } from "./prompt-input/usePromptPicker"
|
import { usePromptPicker } from "./prompt-input/usePromptPicker"
|
||||||
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
|
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
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) {
|
export default function PromptInput(props: PromptInputProps) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const [, setIsFocused] = createSignal(false)
|
const [, setIsFocused] = createSignal(false)
|
||||||
@@ -276,12 +246,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
commandName.length > 0 &&
|
commandName.length > 0 &&
|
||||||
getCommands(props.instanceId).some((cmd) => cmd.name === commandName)
|
getCommands(props.instanceId).some((cmd) => cmd.name === commandName)
|
||||||
|
|
||||||
const resolvedCommandArgs = isKnownSlashCommand ? resolvePastedPlaceholders(commandArgs, currentAttachments) : ""
|
const resolvedPrompt = isKnownSlashCommand ? text : resolvePastedPlaceholders(text, currentAttachments)
|
||||||
const resolvedPrompt = isKnownSlashCommand
|
|
||||||
? resolvedCommandArgs
|
|
||||||
? `${commandToken} ${resolvedCommandArgs}`
|
|
||||||
: commandToken
|
|
||||||
: resolvePastedPlaceholders(text, currentAttachments)
|
|
||||||
const historyEntry = resolvedPrompt
|
const historyEntry = resolvedPrompt
|
||||||
|
|
||||||
const refreshHistory = () => recordHistoryEntry(historyEntry)
|
const refreshHistory = () => recordHistoryEntry(historyEntry)
|
||||||
@@ -297,10 +262,6 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
syncAttachmentCounters("")
|
syncAttachmentCounters("")
|
||||||
setIgnoredAtPositions(new Set<number>())
|
setIgnoredAtPositions(new Set<number>())
|
||||||
} else {
|
} else {
|
||||||
const consumedIds = getConsumedPastedTextAttachmentIds(commandArgs, currentAttachments)
|
|
||||||
for (const attachmentId of consumedIds) {
|
|
||||||
removeAttachment(props.instanceId, props.sessionId, attachmentId)
|
|
||||||
}
|
|
||||||
syncAttachmentCounters("")
|
syncAttachmentCounters("")
|
||||||
setIgnoredAtPositions(new Set<number>())
|
setIgnoredAtPositions(new Set<number>())
|
||||||
}
|
}
|
||||||
@@ -320,7 +281,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
await props.onSend(resolvedPrompt, [])
|
await props.onSend(resolvedPrompt, [])
|
||||||
}
|
}
|
||||||
} else if (isKnownSlashCommand) {
|
} else if (isKnownSlashCommand) {
|
||||||
await executeCustomCommand(props.instanceId, props.sessionId, commandName, resolvedCommandArgs)
|
await executeCustomCommand(props.instanceId, props.sessionId, commandName, commandArgs)
|
||||||
} else {
|
} else {
|
||||||
await props.onSend(resolvedPrompt, currentAttachments)
|
await props.onSend(resolvedPrompt, currentAttachments)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Show, createEffect, createMemo, createSignal, onCleanup, type Accessor, type JSX, on } from "solid-js"
|
import { Index, Show, createEffect, createMemo, createSignal, onCleanup, type Accessor, type JSX } from "solid-js"
|
||||||
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
|
import VirtualItem, { type VirtualItemHeightChangeMeta } from "./virtual-item"
|
||||||
|
|
||||||
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
|
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||||
const USER_SCROLL_INTENT_WINDOW_MS = 600
|
const USER_SCROLL_INTENT_WINDOW_MS = 600
|
||||||
@@ -122,28 +122,55 @@ export interface VirtualFollowListProps<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function VirtualFollowList<T>(props: 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 [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
|
||||||
const [shellElement, setShellElement] = createSignal<HTMLDivElement | undefined>()
|
const [shellElement, setShellElement] = createSignal<HTMLDivElement | undefined>()
|
||||||
const [virtuaHandle, setVirtuaHandle] = createSignal<VirtualizerHandle | undefined>()
|
const [topSentinel, setTopSentinel] = createSignal<HTMLDivElement | null>(null)
|
||||||
|
const [bottomSentinelSignal, setBottomSentinelSignal] = createSignal<HTMLDivElement | null>(null)
|
||||||
|
const bottomSentinel = () => bottomSentinelSignal()
|
||||||
|
|
||||||
const isActive = () => (props.isActive ? props.isActive() : true)
|
const isActive = () => (props.isActive ? props.isActive() : true)
|
||||||
const scrollToBottomOnActivate = () => (props.scrollToBottomOnActivate ? props.scrollToBottomOnActivate() : true)
|
const scrollToBottomOnActivate = () => (props.scrollToBottomOnActivate ? props.scrollToBottomOnActivate() : true)
|
||||||
const initialScrollToBottom = () => (props.initialScrollToBottom ? props.initialScrollToBottom() : true)
|
const initialScrollToBottom = () => (props.initialScrollToBottom ? props.initialScrollToBottom() : true)
|
||||||
const initialAutoScroll = () => (props.initialAutoScroll ? props.initialAutoScroll() : 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 [autoScroll, setAutoScroll] = createSignal(Boolean(initialAutoScroll()))
|
||||||
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
|
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
|
||||||
const [showScrollBottomButton, setShowScrollBottomButton] = 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 [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))
|
const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0))
|
||||||
|
|
||||||
let userScrollIntentUntil = 0
|
let containerRef: HTMLDivElement | undefined
|
||||||
let lastUserScrollIntentDirection: "up" | "down" | null = null
|
let shellRef: HTMLDivElement | undefined
|
||||||
let detachScrollIntentListeners: (() => void) | undefined
|
let pendingScrollFrame: number | null = null
|
||||||
let lastResetKey: string | number | undefined
|
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 suppressAutoScrollOnce = false
|
let suppressAutoScrollOnce = false
|
||||||
let pendingInitialScroll = true
|
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 = {
|
const state: VirtualFollowListState = {
|
||||||
autoScroll,
|
autoScroll,
|
||||||
@@ -154,7 +181,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function markUserScrollIntent(direction?: "up" | "down" | null) {
|
function markUserScrollIntent(direction?: "up" | "down" | null) {
|
||||||
const now = performance.now()
|
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||||
userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS
|
userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS
|
||||||
if (direction) {
|
if (direction) {
|
||||||
lastUserScrollIntentDirection = direction
|
lastUserScrollIntentDirection = direction
|
||||||
@@ -162,7 +189,8 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hasUserScrollIntent() {
|
function hasUserScrollIntent() {
|
||||||
return performance.now() <= userScrollIntentUntil
|
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||||
|
return now <= userScrollIntentUntil
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachScrollIntentListeners(element: HTMLDivElement | undefined) {
|
function attachScrollIntentListeners(element: HTMLDivElement | undefined) {
|
||||||
@@ -203,189 +231,670 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateScrollButtons() {
|
function updateScrollIndicatorsFromVisibility() {
|
||||||
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 hasItems = props.items().length > 0
|
||||||
setShowScrollBottomButton(hasItems && !atBottom)
|
const bottomVisible = bottomSentinelVisible()
|
||||||
setShowScrollTopButton(hasItems && !atTop)
|
const topVisible = topSentinelVisible()
|
||||||
|
setShowScrollBottomButton(hasItems && !bottomVisible)
|
||||||
|
setShowScrollTopButton(hasItems && !topVisible)
|
||||||
|
}
|
||||||
|
|
||||||
// Sync autoScroll state based on scroll position if it was a user scroll
|
function clearScrollToBottomFrames() {
|
||||||
if (hasUserScrollIntent()) {
|
if (scrollToBottomFrame !== null) {
|
||||||
if (atBottom && !autoScroll()) {
|
cancelAnimationFrame(scrollToBottomFrame)
|
||||||
setAutoScroll(true)
|
scrollToBottomFrame = null
|
||||||
} else if (!atBottom && autoScroll()) {
|
}
|
||||||
setAutoScroll(false)
|
if (scrollToBottomDelayedFrame !== null) {
|
||||||
}
|
cancelAnimationFrame(scrollToBottomDelayedFrame)
|
||||||
|
scrollToBottomDelayedFrame = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToBottom(immediate = true, options?: { suppressAutoAnchor?: boolean }) {
|
function scrollToBottom(immediate = false, options?: { suppressAutoAnchor?: boolean }) {
|
||||||
const handle = virtuaHandle()
|
if (!containerRef) return
|
||||||
if (!handle) return
|
if (anchorLock()) {
|
||||||
if (options?.suppressAutoAnchor ?? !immediate) {
|
clearAnchorLock()
|
||||||
|
}
|
||||||
|
const sentinel = bottomSentinel()
|
||||||
|
const behavior: ScrollBehavior = immediate ? "auto" : "smooth"
|
||||||
|
const suppressAutoAnchor = options?.suppressAutoAnchor ?? !immediate
|
||||||
|
if (suppressAutoAnchor) {
|
||||||
suppressAutoScrollOnce = true
|
suppressAutoScrollOnce = true
|
||||||
}
|
}
|
||||||
handle.scrollToIndex(props.items().length - 1, { align: "end", smooth: !immediate })
|
sentinel?.scrollIntoView({ block: "end", inline: "nearest", behavior })
|
||||||
setAutoScroll(true)
|
setAutoScroll(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToTop(immediate = true) {
|
function requestScrollToBottom(immediate = true) {
|
||||||
const handle = virtuaHandle()
|
if (!isActive()) {
|
||||||
if (!handle) return
|
pendingActiveScroll = true
|
||||||
handle.scrollToIndex(0, { align: "start", smooth: !immediate })
|
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()
|
||||||
|
}
|
||||||
setAutoScroll(false)
|
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() {
|
function handleScroll() {
|
||||||
const isUserScroll = hasUserScrollIntent()
|
if (!containerRef) return
|
||||||
if (isUserScroll) {
|
if (pendingScrollFrame !== null) {
|
||||||
if (lastUserScrollIntentDirection === "up" && autoScroll()) {
|
cancelAnimationFrame(pendingScrollFrame)
|
||||||
setAutoScroll(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
updateScrollButtons()
|
const isUserScroll = hasUserScrollIntent()
|
||||||
props.onScroll?.()
|
pendingScrollFrame = requestAnimationFrame(() => {
|
||||||
|
pendingScrollFrame = null
|
||||||
|
if (!containerRef) return
|
||||||
|
const previousScrollTop = lastKnownScrollTop
|
||||||
|
const currentScrollTop = containerRef.scrollTop
|
||||||
|
const deltaScrollTop = currentScrollTop - previousScrollTop
|
||||||
|
if (currentScrollTop !== lastKnownScrollTop) {
|
||||||
|
lastKnownScrollTop = currentScrollTop
|
||||||
|
}
|
||||||
|
const atBottom = bottomSentinelVisible()
|
||||||
|
|
||||||
// Find active key (roughly the first visible item)
|
const beforeAutoScroll = autoScroll()
|
||||||
const handle = virtuaHandle()
|
|
||||||
if (handle) {
|
const inferredDirection: "up" | "down" | null =
|
||||||
const start = handle.findItemIndex(handle.scrollOffset)
|
lastUserScrollIntentDirection ?? (deltaScrollTop < 0 ? "up" : deltaScrollTop > 0 ? "down" : null)
|
||||||
const items = props.items()
|
|
||||||
if (items[start]) {
|
// If the user scrolls manually, exit key-anchored mode.
|
||||||
const key = props.getKey(items[start], start)
|
if (isUserScroll && anchorLock()) {
|
||||||
if (key !== activeKey()) {
|
clearAnchorLock()
|
||||||
setActiveKey(key)
|
}
|
||||||
props.onActiveKeyChange?.(key)
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = {
|
const api: VirtualFollowListApi = {
|
||||||
scrollToTop: (opts) => scrollToTop(opts?.immediate ?? true),
|
scrollToTop: (opts) => scrollToTop(Boolean(opts?.immediate)),
|
||||||
scrollToBottom: (opts) => scrollToBottom(opts?.immediate ?? true, { suppressAutoAnchor: opts?.suppressAutoAnchor }),
|
scrollToBottom: (opts) => scrollToBottom(Boolean(opts?.immediate), { suppressAutoAnchor: opts?.suppressAutoAnchor }),
|
||||||
scrollToKey: (key, opts) => {
|
scrollToKey: (key, opts) => {
|
||||||
const index = props.items().findIndex((item, i) => props.getKey(item, i) === key)
|
if (typeof document === "undefined") return
|
||||||
if (index === -1) return
|
const anchorId = getAnchorId(key)
|
||||||
|
const behavior = opts?.behavior ?? "smooth"
|
||||||
|
const block = opts?.block ?? "start"
|
||||||
const nextAutoScroll = opts?.setAutoScroll ?? false
|
const nextAutoScroll = opts?.setAutoScroll ?? false
|
||||||
setAutoScroll(nextAutoScroll)
|
setAutoScroll(nextAutoScroll)
|
||||||
virtuaHandle()?.scrollToIndex(index, { align: opts?.block ?? "start", smooth: opts?.behavior === "smooth" })
|
if (!nextAutoScroll) {
|
||||||
},
|
if (anchorLock()) {
|
||||||
notifyContentRendered: () => {
|
clearAnchorLock()
|
||||||
if (autoScroll()) {
|
}
|
||||||
scrollToBottom(true)
|
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 })
|
||||||
|
})
|
||||||
},
|
},
|
||||||
|
notifyContentRendered: () => handleContentRendered(),
|
||||||
setAutoScroll: (enabled) => setAutoScroll(Boolean(enabled)),
|
setAutoScroll: (enabled) => setAutoScroll(Boolean(enabled)),
|
||||||
getAutoScroll: () => autoScroll(),
|
getAutoScroll: () => autoScroll(),
|
||||||
getScrollElement: () => scrollElement(),
|
getScrollElement: () => scrollElement(),
|
||||||
getShellElement: () => shellElement(),
|
getShellElement: () => shellElement(),
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => props.registerApi?.(api))
|
createEffect(() => {
|
||||||
createEffect(() => props.registerState?.(state))
|
props.registerApi?.(api)
|
||||||
|
})
|
||||||
|
|
||||||
// Handle autoScroll (Follow) on items change
|
createEffect(() => {
|
||||||
createEffect(on(() => props.items().length, (len, prevLen) => {
|
props.registerState?.(state)
|
||||||
if (len > (prevLen ?? 0) && autoScroll() && !suppressAutoScrollOnce) {
|
})
|
||||||
requestAnimationFrame(() => scrollToBottom(true))
|
|
||||||
|
createEffect(() => {
|
||||||
|
const nextKey = props.resetKey?.()
|
||||||
|
if (nextKey === undefined) return
|
||||||
|
if (lastResetKey === undefined) {
|
||||||
|
lastResetKey = nextKey
|
||||||
|
return
|
||||||
}
|
}
|
||||||
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
|
if (nextKey === lastResetKey) return
|
||||||
lastResetKey = nextKey
|
lastResetKey = nextKey
|
||||||
setAutoScroll(initialAutoScroll())
|
|
||||||
pendingInitialScroll = true
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Initial scroll and session activation
|
// 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
|
||||||
|
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
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const active = isActive()
|
const active = isActive()
|
||||||
if (!active) return
|
if (active) {
|
||||||
if (pendingInitialScroll && props.items().length > 0) {
|
resolvePendingActiveScroll()
|
||||||
pendingInitialScroll = false
|
if (!lastActiveState && autoScroll() && scrollToBottomOnActivate()) {
|
||||||
if (initialScrollToBottom()) {
|
requestScrollToBottom(true)
|
||||||
scrollToBottom(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()
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} else if (autoScroll() && scrollToBottomOnActivate()) {
|
} else if (autoScroll() && scrollToBottomOnActivate()) {
|
||||||
scrollToBottom(true)
|
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()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
// Drop anchor lock if the anchored key is removed.
|
||||||
<div class="virtual-follow-list-shell" ref={shellElement => {
|
createEffect(() => {
|
||||||
setShellElement(shellElement)
|
const lock = anchorLock()
|
||||||
props.onShellElementChange?.(shellElement)
|
if (!lock) return
|
||||||
}}>
|
const keys = props.items().map((item, idx) => props.getKey(item, idx))
|
||||||
<div
|
if (!keys.includes(lock.key)) {
|
||||||
class="message-stream"
|
clearAnchorLock()
|
||||||
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>
|
|
||||||
|
|
||||||
<Show when={props.renderOverlay}>
|
createEffect(() => {
|
||||||
<div class="virtual-follow-list-overlay">{props.renderOverlay!()}</div>
|
if (props.items().length === 0) {
|
||||||
</Show>
|
setShowScrollTopButton(false)
|
||||||
|
setShowScrollBottomButton(false)
|
||||||
|
setAutoScroll(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateScrollIndicatorsFromVisibility()
|
||||||
|
})
|
||||||
|
|
||||||
<Show when={props.renderControls}>
|
createEffect(() => {
|
||||||
<div class="virtual-follow-list-controls-container">{props.renderControls!(state, api)}</div>
|
const container = scrollElement()
|
||||||
</Show>
|
const topTarget = topSentinel()
|
||||||
|
const bottomTarget = bottomSentinel()
|
||||||
|
if (!container || !topTarget || !bottomTarget) return
|
||||||
|
if (typeof IntersectionObserver === "undefined") return
|
||||||
|
|
||||||
<Show
|
const margin = props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX
|
||||||
when={
|
|
||||||
!props.renderControls &&
|
const observer = new IntersectionObserver(
|
||||||
(showScrollTopButton() || showScrollBottomButton()) &&
|
(entries) => {
|
||||||
props.scrollToTopAriaLabel &&
|
let visibilityChanged = false
|
||||||
props.scrollToBottomAriaLabel
|
for (const entry of entries) {
|
||||||
|
if (entry.target === topTarget) {
|
||||||
|
setTopSentinelVisible(entry.isIntersecting)
|
||||||
|
visibilityChanged = true
|
||||||
|
} else if (entry.target === bottomTarget) {
|
||||||
|
setBottomSentinelVisible(entry.isIntersecting)
|
||||||
|
visibilityChanged = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
>
|
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">
|
<div class="message-scroll-button-wrapper">
|
||||||
<Show when={showScrollTopButton()}>
|
<Show when={showScrollTopButton()}>
|
||||||
<button type="button" class="message-scroll-button" onClick={() => scrollToTop()} aria-label={props.scrollToTopAriaLabel!()}>
|
<button type="button" class="message-scroll-button" onClick={() => scrollToTop()} aria-label={labelTop}>
|
||||||
<span class="message-scroll-icon" aria-hidden="true">
|
<span class="message-scroll-icon" aria-hidden="true">
|
||||||
↑
|
↑
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={showScrollBottomButton()}>
|
<Show when={showScrollBottomButton()}>
|
||||||
<button type="button" class="message-scroll-button" onClick={() => scrollToBottom()} aria-label={props.scrollToBottomAriaLabel!()}>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message-scroll-button"
|
||||||
|
onClick={() => scrollToBottom(false, { suppressAutoAnchor: false })}
|
||||||
|
aria-label={labelBottom}
|
||||||
|
>
|
||||||
<span class="message-scroll-icon" aria-hidden="true">
|
<span class="message-scroll-icon" aria-hidden="true">
|
||||||
↓
|
↓
|
||||||
</span>
|
</span>
|
||||||
@@ -393,6 +902,71 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
492
packages/ui/src/components/virtual-item.tsx
Normal file
492
packages/ui/src/components/virtual-item.tsx
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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,11 +2,6 @@ import { createContext, createEffect, createMemo, createSignal, onCleanup, onMou
|
|||||||
import type { ParentComponent } from "solid-js"
|
import type { ParentComponent } from "solid-js"
|
||||||
import { useConfig } from "../../stores/preferences"
|
import { useConfig } from "../../stores/preferences"
|
||||||
import { enMessages } from "./messages/en"
|
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>
|
type Messages = Record<string, string>
|
||||||
|
|
||||||
@@ -15,14 +10,18 @@ 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"
|
||||||
|
|
||||||
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"] as const
|
||||||
|
const SUPPORTED_LOCALES_BY_LOWER = new Map(SUPPORTED_LOCALES.map((locale) => [locale.toLowerCase(), locale]))
|
||||||
|
|
||||||
const messagesByLocale: Record<Locale, Messages> = {
|
const localeMessagesCache = new Map<Locale, Messages>([["en", enMessages]])
|
||||||
en: enMessages,
|
const localeMessagesPromises = new Map<Locale, Promise<Messages>>()
|
||||||
es: esMessages,
|
|
||||||
fr: frMessages,
|
const localeLoaders: Record<Locale, () => Promise<Messages>> = {
|
||||||
ru: ruMessages,
|
en: async () => enMessages,
|
||||||
ja: jaMessages,
|
es: async () => (await import("./messages/es")).esMessages,
|
||||||
"zh-Hans": zhHansMessages,
|
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeLocaleTag(value: string): string {
|
function normalizeLocaleTag(value: string): string {
|
||||||
@@ -34,8 +33,7 @@ function matchSupportedLocale(value: string | undefined): Locale | null {
|
|||||||
|
|
||||||
const normalized = normalizeLocaleTag(value)
|
const normalized = normalizeLocaleTag(value)
|
||||||
const lower = normalized.toLowerCase()
|
const lower = normalized.toLowerCase()
|
||||||
const supportedLower = new Map(SUPPORTED_LOCALES.map((locale) => [locale.toLowerCase(), locale]))
|
const exact = SUPPORTED_LOCALES_BY_LOWER.get(lower)
|
||||||
const exact = supportedLower.get(lower)
|
|
||||||
if (exact) return exact
|
if (exact) return exact
|
||||||
|
|
||||||
const parts = lower.split("-")
|
const parts = lower.split("-")
|
||||||
@@ -43,11 +41,11 @@ function matchSupportedLocale(value: string | undefined): Locale | null {
|
|||||||
if (!base) return null
|
if (!base) return null
|
||||||
|
|
||||||
if (base === "zh") {
|
if (base === "zh") {
|
||||||
const zhHans = supportedLower.get("zh-hans")
|
const zhHans = SUPPORTED_LOCALES_BY_LOWER.get("zh-hans")
|
||||||
return zhHans ?? null
|
return zhHans ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseMatch = supportedLower.get(base)
|
const baseMatch = SUPPORTED_LOCALES_BY_LOWER.get(base)
|
||||||
return baseMatch ?? null
|
return baseMatch ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,8 +82,54 @@ function translateFrom(messages: Messages, key: string, params?: TranslateParams
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [globalRevision, setGlobalRevision] = createSignal(0)
|
const [globalRevision, setGlobalRevision] = createSignal(0)
|
||||||
const initialGlobalLocale: Locale = detectNavigatorLocale() ?? "en"
|
let globalMessages: Messages = enMessages
|
||||||
let globalMessages: Messages = messagesByLocale[initialGlobalLocale]
|
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 {
|
export function tGlobal(key: string, params?: TranslateParams): string {
|
||||||
globalRevision()
|
globalRevision()
|
||||||
@@ -101,9 +145,10 @@ const I18nContext = createContext<I18nContextValue>()
|
|||||||
|
|
||||||
export const I18nProvider: ParentComponent = (props) => {
|
export const I18nProvider: ParentComponent = (props) => {
|
||||||
const { preferences } = useConfig()
|
const { preferences } = useConfig()
|
||||||
const [detectedLocale, setDetectedLocale] = createSignal<Locale>("en")
|
const [detectedLocale, setDetectedLocale] = createSignal<Locale>(globalLocale)
|
||||||
|
const [resolvedLocale, setResolvedLocale] = createSignal<Locale>(globalLocale)
|
||||||
const previousMessages = globalMessages
|
const previousGlobalMessages = globalMessages
|
||||||
|
const previousGlobalLocale = globalLocale
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const detected = detectNavigatorLocale()
|
const detected = detectNavigatorLocale()
|
||||||
@@ -115,19 +160,44 @@ export const I18nProvider: ParentComponent = (props) => {
|
|||||||
return configured ?? detectedLocale() ?? "en"
|
return configured ?? detectedLocale() ?? "en"
|
||||||
})
|
})
|
||||||
|
|
||||||
const messages = createMemo<Messages>(() => messagesByLocale[locale()])
|
const messages = createMemo<Messages>(() => getMessagesForLocale(resolvedLocale()))
|
||||||
|
|
||||||
function t(key: string, params?: TranslateParams): string {
|
function t(key: string, params?: TranslateParams): string {
|
||||||
return translateFrom(messages(), key, params)
|
return translateFrom(messages(), key, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
globalMessages = messages()
|
const nextLocale = locale()
|
||||||
setGlobalRevision((value) => value + 1)
|
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
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
globalMessages = previousMessages
|
globalMessages = previousGlobalMessages
|
||||||
|
globalLocale = previousGlobalLocale
|
||||||
setGlobalRevision((value) => value + 1)
|
setGlobalRevision((value) => value + 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ThemeProvider } from "./lib/theme"
|
|||||||
import { ConfigProvider } from "./stores/preferences"
|
import { ConfigProvider } from "./stores/preferences"
|
||||||
import { InstanceConfigProvider } from "./stores/instance-config"
|
import { InstanceConfigProvider } from "./stores/instance-config"
|
||||||
import { runtimeEnv } from "./lib/runtime-env"
|
import { runtimeEnv } from "./lib/runtime-env"
|
||||||
import { I18nProvider } from "./lib/i18n"
|
import { I18nProvider, preloadLocaleMessages } from "./lib/i18n"
|
||||||
import { storage } from "./lib/storage"
|
import { storage } from "./lib/storage"
|
||||||
import "./index.css"
|
import "./index.css"
|
||||||
import "@git-diff-view/solid/styles/diff-view-pure.css"
|
import "@git-diff-view/solid/styles/diff-view-pure.css"
|
||||||
@@ -31,15 +31,19 @@ async function bootstrap() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const uiConfig = await storage.loadConfigOwner("ui")
|
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") {
|
if (theme === "light" || theme === "dark") {
|
||||||
document.documentElement.removeAttribute("data-theme")
|
|
||||||
} else {
|
|
||||||
document.documentElement.setAttribute("data-theme", theme)
|
document.documentElement.setAttribute("data-theme", theme)
|
||||||
|
} else {
|
||||||
|
document.documentElement.removeAttribute("data-theme")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await preloadLocaleMessages(locale)
|
||||||
} catch {
|
} catch {
|
||||||
// If config fails to load, fall back to CSS defaults.
|
// If config fails to load, fall back to CSS defaults.
|
||||||
|
await preloadLocaleMessages()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,29 +77,6 @@ function shouldSendOsNotification(kind: "needsInput" | "idle"): boolean {
|
|||||||
return false
|
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 {
|
function getInstanceDisplayName(instanceId: string): string {
|
||||||
const instanceFolder = instances().get(instanceId)?.folder ?? instanceId
|
const instanceFolder = instances().get(instanceId)?.folder ?? instanceId
|
||||||
return instanceFolder.split(/[\\/]/).filter(Boolean).pop() ?? instanceFolder
|
return instanceFolder.split(/[\\/]/).filter(Boolean).pop() ?? instanceFolder
|
||||||
@@ -515,7 +492,7 @@ function handleSessionIdle(instanceId: string, event: EventSessionIdle): void {
|
|||||||
const sessionId = event.properties?.sessionID
|
const sessionId = event.properties?.sessionID
|
||||||
if (!sessionId) return
|
if (!sessionId) return
|
||||||
|
|
||||||
if (shouldSendOsNotificationForSession("idle", instanceId, sessionId)) {
|
if (shouldSendOsNotification("idle")) {
|
||||||
const title = getInstanceDisplayName(instanceId)
|
const title = getInstanceDisplayName(instanceId)
|
||||||
const label = getSessionTitle(instanceId, sessionId)
|
const label = getSessionTitle(instanceId, sessionId)
|
||||||
const body = label ? `Session "${label}" is idle` : "Session is idle"
|
const body = label ? `Session "${label}" is idle` : "Session is idle"
|
||||||
@@ -630,10 +607,9 @@ function handlePermissionUpdated(instanceId: string, event: { type: string; prop
|
|||||||
addPermissionToQueue(instanceId, permission)
|
addPermissionToQueue(instanceId, permission)
|
||||||
upsertPermissionV2(instanceId, permission)
|
upsertPermissionV2(instanceId, permission)
|
||||||
|
|
||||||
const sessionId = getPermissionSessionId(permission)
|
if (shouldSendOsNotification("needsInput")) {
|
||||||
|
|
||||||
if (shouldSendOsNotificationForSession("needsInput", instanceId, sessionId)) {
|
|
||||||
const title = getInstanceDisplayName(instanceId)
|
const title = getInstanceDisplayName(instanceId)
|
||||||
|
const sessionId = getPermissionSessionId(permission)
|
||||||
const label = getSessionTitle(instanceId, sessionId)
|
const label = getSessionTitle(instanceId, sessionId)
|
||||||
const body = label ? `Session "${label}" needs permission` : "Session needs permission"
|
const body = label ? `Session "${label}" needs permission` : "Session needs permission"
|
||||||
fireOsNotification({ title, body })
|
fireOsNotification({ title, body })
|
||||||
@@ -658,10 +634,9 @@ function handleQuestionAsked(instanceId: string, event: { type: string; properti
|
|||||||
addQuestionToQueue(instanceId, request)
|
addQuestionToQueue(instanceId, request)
|
||||||
upsertQuestionV2(instanceId, request)
|
upsertQuestionV2(instanceId, request)
|
||||||
|
|
||||||
const sessionId = getQuestionSessionId(request)
|
if (shouldSendOsNotification("needsInput")) {
|
||||||
|
|
||||||
if (shouldSendOsNotificationForSession("needsInput", instanceId, sessionId)) {
|
|
||||||
const title = getInstanceDisplayName(instanceId)
|
const title = getInstanceDisplayName(instanceId)
|
||||||
|
const sessionId = getQuestionSessionId(request)
|
||||||
const label = getSessionTitle(instanceId, sessionId)
|
const label = getSessionTitle(instanceId, sessionId)
|
||||||
const body = label ? `Session "${label}" needs input` : "Session needs input"
|
const body = label ? `Session "${label}" needs input` : "Session needs input"
|
||||||
fireOsNotification({ title, body })
|
fireOsNotification({ title, body })
|
||||||
|
|||||||
@@ -1,58 +1,39 @@
|
|||||||
.virtual-follow-list-shell {
|
.message-stream {
|
||||||
|
@apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-0.5;
|
||||||
|
background-color: var(--surface-base);
|
||||||
|
color: inherit;
|
||||||
|
/* Prevent browser scroll anchoring fighting our virtualization compensation. */
|
||||||
|
overflow-anchor: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-stream-block {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1;
|
gap: 0.0625rem;
|
||||||
min-height: 0;
|
|
||||||
position: relative;
|
contain: layout paint style;
|
||||||
|
}
|
||||||
|
|
||||||
|
.virtual-item-wrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-stream {
|
.virtual-item-placeholder,
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
background-color: var(--surface-base);
|
|
||||||
color: inherit;
|
|
||||||
|
|
||||||
/* Scrolling optimizations */
|
|
||||||
overscroll-behavior-y: contain;
|
|
||||||
/* Prevents scroll chaining to parent elements */
|
|
||||||
will-change: scroll-position;
|
|
||||||
/* GPU acceleration hint for smoother scrolling */
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
/* Momentum scrolling on iOS */
|
|
||||||
|
|
||||||
/* Prevent browser scroll anchoring fighting our virtualization compensation. */
|
|
||||||
overflow-anchor: none;
|
|
||||||
|
|
||||||
/* Scrollbar styling */
|
|
||||||
scrollbar-gutter: stable;
|
|
||||||
}
|
|
||||||
|
|
||||||
.virtual-follow-list-overlay {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 10;
|
|
||||||
/* Ensure it doesn't affect layout at all */
|
|
||||||
height: 0;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.virtual-follow-list-overlay > * {
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.virtual-follow-list-controls-container {
|
|
||||||
position: absolute;
|
|
||||||
bottom: calc(var(--space-md) + env(safe-area-inset-bottom, 0px));
|
|
||||||
right: var(--space-md);
|
|
||||||
z-index: 20;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-stream-placeholder {
|
.message-stream-placeholder {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.virtual-item-content {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.virtual-item-content-hidden {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -77,23 +77,23 @@ export default defineConfig({
|
|||||||
theme_color: "#1a1a1a",
|
theme_color: "#1a1a1a",
|
||||||
},
|
},
|
||||||
workbox: {
|
workbox: {
|
||||||
// Workbox defaults to 2 MiB; our main bundle can slightly exceed that.
|
// Workbox defaults to 2 MiB; our main bundle can slightly exceed that.
|
||||||
// This is a build-time limit for the precache manifest, not a hard runtime cap.
|
// This is a build-time limit for the precache manifest, not a hard runtime cap.
|
||||||
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024,
|
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024,
|
||||||
// Preserve server-side auth redirects (e.g., /login) instead of serving cached index.html.
|
// Preserve server-side auth redirects (e.g., /login) instead of serving cached index.html.
|
||||||
navigateFallback: null,
|
navigateFallback: null,
|
||||||
// Only precache static assets (avoid caching HTML documents / routes).
|
// Only precache static assets (avoid caching HTML documents / routes).
|
||||||
globPatterns: ["**/*.{js,css,png,jpg,jpeg,svg,webp,ico,woff,woff2,ttf,eot,json,webmanifest}"],
|
globPatterns: ["**/*.{js,css,png,jpg,jpeg,svg,webp,ico,woff,woff2,ttf,eot,json,webmanifest}"],
|
||||||
// Monaco assets can be large; cache them at runtime instead.
|
// Monaco assets can be large; cache them at runtime instead.
|
||||||
globIgnores: [
|
globIgnores: [
|
||||||
"**/*.html",
|
"**/*.html",
|
||||||
"**/assets/*worker-*.js",
|
"**/assets/*worker-*.js",
|
||||||
"**/assets/editor.api-*.js",
|
"**/assets/editor.api-*.js",
|
||||||
"**/monaco/vs/**/*",
|
"**/monaco/vs/**/*",
|
||||||
],
|
],
|
||||||
// Only cache static UI assets; never cache API traffic.
|
// Only cache static UI assets; never cache API traffic.
|
||||||
runtimeCaching: [
|
runtimeCaching: [
|
||||||
{
|
{
|
||||||
urlPattern: ({ url, request }) => {
|
urlPattern: ({ url, request }) => {
|
||||||
if (url.pathname.startsWith("/api/")) return false
|
if (url.pathname.startsWith("/api/")) return false
|
||||||
if (request.destination === "document") return false
|
if (request.destination === "document") return false
|
||||||
|
|||||||
Reference in New Issue
Block a user