mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-21 03:15:11 +00:00
Merge branch 'dev' into feat/canceled-prompts-in-history
This commit is contained in:
132
.github/workflows/close-stale-prs.yml
vendored
132
.github/workflows/close-stale-prs.yml
vendored
@@ -18,6 +18,7 @@ permissions:
|
||||
jobs:
|
||||
close-stale-prs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Close inactive PRs
|
||||
uses: actions/github-script@v8
|
||||
@@ -25,6 +26,15 @@ jobs:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const DAYS_INACTIVE = 60
|
||||
const MAX_RETRIES = 3
|
||||
|
||||
// Adaptive delay: fast for small batches, slower for large to respect
|
||||
// GitHub's 80 content-generating requests/minute limit
|
||||
const SMALL_BATCH_THRESHOLD = 10
|
||||
const SMALL_BATCH_DELAY_MS = 1000 // 1s for daily operations (≤10 PRs)
|
||||
const LARGE_BATCH_DELAY_MS = 2000 // 2s for backlog (>10 PRs) = ~30 ops/min, well under 80 limit
|
||||
|
||||
const startTime = Date.now()
|
||||
const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000)
|
||||
const { owner, repo } = context.repo
|
||||
const dryRun = context.payload.inputs?.dryRun === "true"
|
||||
@@ -32,6 +42,42 @@ jobs:
|
||||
core.info(`Dry run mode: ${dryRun}`)
|
||||
core.info(`Cutoff date: ${cutoff.toISOString()}`)
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function withRetry(fn, description = 'API call') {
|
||||
let lastError
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const result = await fn()
|
||||
return result
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
const isRateLimited = error.status === 403 &&
|
||||
(error.message?.includes('rate limit') || error.message?.includes('secondary'))
|
||||
|
||||
if (!isRateLimited) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// Parse retry-after header, default to 60 seconds
|
||||
const retryAfter = error.response?.headers?.['retry-after']
|
||||
? parseInt(error.response.headers['retry-after'])
|
||||
: 60
|
||||
|
||||
// Exponential backoff: retryAfter * 2^attempt
|
||||
const backoffMs = retryAfter * 1000 * Math.pow(2, attempt)
|
||||
|
||||
core.warning(`${description}: Rate limited (attempt ${attempt + 1}/${MAX_RETRIES}). Waiting ${backoffMs / 1000}s before retry...`)
|
||||
|
||||
await sleep(backoffMs)
|
||||
}
|
||||
}
|
||||
core.error(`${description}: Max retries (${MAX_RETRIES}) exceeded`)
|
||||
throw lastError
|
||||
}
|
||||
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!, $cursor: String) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
@@ -73,17 +119,27 @@ jobs:
|
||||
const allPrs = []
|
||||
let cursor = null
|
||||
let hasNextPage = true
|
||||
let pageCount = 0
|
||||
|
||||
while (hasNextPage) {
|
||||
const result = await github.graphql(query, {
|
||||
owner,
|
||||
repo,
|
||||
cursor,
|
||||
})
|
||||
pageCount++
|
||||
core.info(`Fetching page ${pageCount} of open PRs...`)
|
||||
|
||||
const result = await withRetry(
|
||||
() => github.graphql(query, { owner, repo, cursor }),
|
||||
`GraphQL page ${pageCount}`
|
||||
)
|
||||
|
||||
allPrs.push(...result.repository.pullRequests.nodes)
|
||||
hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage
|
||||
cursor = result.repository.pullRequests.pageInfo.endCursor
|
||||
|
||||
core.info(`Page ${pageCount}: fetched ${result.repository.pullRequests.nodes.length} PRs (total: ${allPrs.length})`)
|
||||
|
||||
// Delay between pagination requests (use small batch delay for reads)
|
||||
if (hasNextPage) {
|
||||
await sleep(SMALL_BATCH_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
core.info(`Found ${allPrs.length} open pull requests`)
|
||||
@@ -114,28 +170,66 @@ jobs:
|
||||
|
||||
core.info(`Found ${stalePrs.length} stale pull requests`)
|
||||
|
||||
// ============================================
|
||||
// Close stale PRs
|
||||
// ============================================
|
||||
const requestDelayMs = stalePrs.length > SMALL_BATCH_THRESHOLD
|
||||
? LARGE_BATCH_DELAY_MS
|
||||
: SMALL_BATCH_DELAY_MS
|
||||
|
||||
core.info(`Using ${requestDelayMs}ms delay between operations (${stalePrs.length > SMALL_BATCH_THRESHOLD ? 'large' : 'small'} batch mode)`)
|
||||
|
||||
let closedCount = 0
|
||||
let skippedCount = 0
|
||||
|
||||
for (const pr of stalePrs) {
|
||||
const issue_number = pr.number
|
||||
const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.`
|
||||
|
||||
if (dryRun) {
|
||||
core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author.login}: ${pr.title}`)
|
||||
core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
|
||||
continue
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body: closeComment,
|
||||
})
|
||||
try {
|
||||
// Add comment
|
||||
await withRetry(
|
||||
() => github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body: closeComment,
|
||||
}),
|
||||
`Comment on PR #${issue_number}`
|
||||
)
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: issue_number,
|
||||
state: "closed",
|
||||
})
|
||||
// Close PR
|
||||
await withRetry(
|
||||
() => github.rest.pulls.update({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: issue_number,
|
||||
state: "closed",
|
||||
}),
|
||||
`Close PR #${issue_number}`
|
||||
)
|
||||
|
||||
core.info(`Closed PR #${issue_number} from ${pr.author.login}: ${pr.title}`)
|
||||
closedCount++
|
||||
core.info(`Closed PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
|
||||
|
||||
// Delay before processing next PR
|
||||
await sleep(requestDelayMs)
|
||||
} catch (error) {
|
||||
skippedCount++
|
||||
core.error(`Failed to close PR #${issue_number}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const elapsed = Math.round((Date.now() - startTime) / 1000)
|
||||
core.info(`\n========== Summary ==========`)
|
||||
core.info(`Total open PRs found: ${allPrs.length}`)
|
||||
core.info(`Stale PRs identified: ${stalePrs.length}`)
|
||||
core.info(`PRs closed: ${closedCount}`)
|
||||
core.info(`PRs skipped (errors): ${skippedCount}`)
|
||||
core.info(`Elapsed time: ${elapsed}s`)
|
||||
core.info(`=============================`)
|
||||
|
||||
2
.github/workflows/typecheck.yml
vendored
2
.github/workflows/typecheck.yml
vendored
@@ -1,6 +1,8 @@
|
||||
name: typecheck
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
pull_request:
|
||||
branches: [dev]
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
sst-env.d.ts
|
||||
sst-env.d.ts
|
||||
desktop/src/bindings.ts
|
||||
|
||||
96
AGENTS.md
96
AGENTS.md
@@ -5,8 +5,9 @@
|
||||
|
||||
## Style Guide
|
||||
|
||||
### General Principles
|
||||
|
||||
- Keep things in one function unless composable or reusable
|
||||
- Avoid unnecessary destructuring. Instead of `const { a, b } = obj`, use `obj.a` and `obj.b` to preserve context
|
||||
- Avoid `try`/`catch` where possible
|
||||
- Avoid using the `any` type
|
||||
- Prefer single word variable names where possible
|
||||
@@ -14,70 +15,97 @@
|
||||
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
|
||||
- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
|
||||
|
||||
### Avoid let statements
|
||||
### Naming
|
||||
|
||||
We don't like `let` statements, especially combined with if/else statements.
|
||||
Prefer `const`.
|
||||
|
||||
Good:
|
||||
Prefer single word names for variables and functions. Only use multiple words if necessary.
|
||||
|
||||
```ts
|
||||
const foo = condition ? 1 : 2
|
||||
// Good
|
||||
const foo = 1
|
||||
function journal(dir: string) {}
|
||||
|
||||
// Bad
|
||||
const fooBar = 1
|
||||
function prepareJournal(dir: string) {}
|
||||
```
|
||||
|
||||
Bad:
|
||||
Reduce total variable count by inlining when a value is only used once.
|
||||
|
||||
```ts
|
||||
let foo
|
||||
// Good
|
||||
const journal = await Bun.file(path.join(dir, "journal.json")).json()
|
||||
|
||||
// Bad
|
||||
const journalPath = path.join(dir, "journal.json")
|
||||
const journal = await Bun.file(journalPath).json()
|
||||
```
|
||||
|
||||
### Destructuring
|
||||
|
||||
Avoid unnecessary destructuring. Use dot notation to preserve context.
|
||||
|
||||
```ts
|
||||
// Good
|
||||
obj.a
|
||||
obj.b
|
||||
|
||||
// Bad
|
||||
const { a, b } = obj
|
||||
```
|
||||
|
||||
### Variables
|
||||
|
||||
Prefer `const` over `let`. Use ternaries or early returns instead of reassignment.
|
||||
|
||||
```ts
|
||||
// Good
|
||||
const foo = condition ? 1 : 2
|
||||
|
||||
// Bad
|
||||
let foo
|
||||
if (condition) foo = 1
|
||||
else foo = 2
|
||||
```
|
||||
|
||||
### Avoid else statements
|
||||
### Control Flow
|
||||
|
||||
Prefer early returns or using an `iife` to avoid else statements.
|
||||
|
||||
Good:
|
||||
Avoid `else` statements. Prefer early returns.
|
||||
|
||||
```ts
|
||||
// Good
|
||||
function foo() {
|
||||
if (condition) return 1
|
||||
return 2
|
||||
}
|
||||
```
|
||||
|
||||
Bad:
|
||||
|
||||
```ts
|
||||
// Bad
|
||||
function foo() {
|
||||
if (condition) return 1
|
||||
else return 2
|
||||
}
|
||||
```
|
||||
|
||||
### Prefer single word naming
|
||||
### Schema Definitions (Drizzle)
|
||||
|
||||
Try your best to find a single word name for your variables, functions, etc.
|
||||
Only use multiple words if you cannot.
|
||||
|
||||
Good:
|
||||
Use snake_case for field names so column names don't need to be redefined as strings.
|
||||
|
||||
```ts
|
||||
const foo = 1
|
||||
const bar = 2
|
||||
const baz = 3
|
||||
```
|
||||
// Good
|
||||
const table = sqliteTable("session", {
|
||||
id: text().primaryKey(),
|
||||
project_id: text().notNull(),
|
||||
created_at: integer().notNull(),
|
||||
})
|
||||
|
||||
Bad:
|
||||
|
||||
```ts
|
||||
const fooBar = 1
|
||||
const barBaz = 2
|
||||
const bazFoo = 3
|
||||
// Bad
|
||||
const table = sqliteTable("session", {
|
||||
id: text("id").primaryKey(),
|
||||
projectID: text("project_id").notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
})
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
You MUST avoid using `mocks` as much as possible.
|
||||
Tests MUST test actual implementation, do not duplicate logic into a test.
|
||||
- Avoid mocks as much as possible
|
||||
- Test actual implementation, do not duplicate logic into tests
|
||||
|
||||
24
bun.lock
24
bun.lock
@@ -286,7 +286,7 @@
|
||||
"@ai-sdk/vercel": "1.0.33",
|
||||
"@ai-sdk/xai": "2.0.56",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@gitlab/gitlab-ai-provider": "3.3.1",
|
||||
"@gitlab/gitlab-ai-provider": "3.4.0",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
@@ -298,8 +298,8 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||
"@opentui/core": "0.1.75",
|
||||
"@opentui/solid": "0.1.75",
|
||||
"@opentui/core": "0.1.77",
|
||||
"@opentui/solid": "0.1.77",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
@@ -925,7 +925,7 @@
|
||||
|
||||
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
|
||||
|
||||
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.3.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-J4/LfVcxOKbR2gfoBWRKp1BpWppprC2Cz/Ff5E0B/0lS341CDtZwzkgWvHfkM/XU6q83JRs059dS0cR8VOODOQ=="],
|
||||
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.4.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-1fEZgqjSZ0WLesftw/J5UtFuJCYFDvCZCHhTH5PZAmpDEmCwllJBoe84L3+vIk38V2FGDMTW128iKTB2mVzr3A=="],
|
||||
|
||||
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
|
||||
|
||||
@@ -1227,21 +1227,21 @@
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.1.75", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.75", "@opentui/core-darwin-x64": "0.1.75", "@opentui/core-linux-arm64": "0.1.75", "@opentui/core-linux-x64": "0.1.75", "@opentui/core-win32-arm64": "0.1.75", "@opentui/core-win32-x64": "0.1.75", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-8ARRZxSG+BXkJmEVtM2DQ4se7DAF1ZCKD07d+AklgTr2mxCzmdxxPbOwRzboSQ6FM7qGuTVPVbV4O2W9DpUmoA=="],
|
||||
"@opentui/core": ["@opentui/core@0.1.77", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.77", "@opentui/core-darwin-x64": "0.1.77", "@opentui/core-linux-arm64": "0.1.77", "@opentui/core-linux-x64": "0.1.77", "@opentui/core-win32-arm64": "0.1.77", "@opentui/core-win32-x64": "0.1.77", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-lE3kabm6jdqK3AuBq+O0zZrXdxt6ulmibTc57sf+AsPny6cmwYHnWI4wD6hcreFiYoQVNVvdiJchVgPtowMlEg=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.75", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gGaGZjkFpqcXJk6321JzhRl66pM2VxBlI470L8W4DQUW4S6iDT1R9L7awSzGB4Cn9toUl7DTV8BemaXZYXV4SA=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.77", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SNqmygCMEsPCW7xWjzCZ5caBf36xaprwVdAnFijGDOuIzLA4iaDa6um8cj3TJh7awenN3NTRsuRc7OuH42UH+g=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.75", "", { "os": "darwin", "cpu": "x64" }, "sha512-tPlvqQI0whZ76amHydpJs5kN+QeWAIcFbI8RAtlAo9baj2EbxTDC+JGwgb9Fnt0/YQx831humbtaNDhV2Jt1bw=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.77", "", { "os": "darwin", "cpu": "x64" }, "sha512-/8fsa03swEHTQt/9NrGm98kemlU+VuTURI/OFZiH53vPDRrOYIYoa4Jyga/H7ZMcG+iFhkq97zIe+0Kw95LGmA=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.75", "", { "os": "linux", "cpu": "arm64" }, "sha512-nVxIQ4Hqf84uBergDpWiVzU6pzpjy6tqBHRQpySxZ2flkJ/U6/aMEizVrQ1jcgIdxZtvqWDETZhzxhG0yDx+cw=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.77", "", { "os": "linux", "cpu": "arm64" }, "sha512-QfUXZJPc69OvqoMu+AlLgjqXrwu4IeqcBuUWYMuH8nPTeLsVUc3CBbXdV2lv9UDxWzxzrxdS4ALPaxvmEv9lsQ=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.75", "", { "os": "linux", "cpu": "x64" }, "sha512-1CnApef4kxA+ORyLfbuCLgZfEjp4wr3HjFnt7FAfOb73kIZH82cb7JYixeqRyy9eOcKfKqxLmBYy3o8IDkc4Rg=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.77", "", { "os": "linux", "cpu": "x64" }, "sha512-Kmfx0yUKnPj67AoXYIgL7qQo0QVsUG5Iw8aRtv6XFzXqa5SzBPhaKkKZ9yHPjOmTalZquUs+9zcCRNKpYYuL7A=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.75", "", { "os": "win32", "cpu": "arm64" }, "sha512-j0UB95nmkYGNzmOrs6GqaddO1S90R0YC6IhbKnbKBdjchFPNVLz9JpexAs6MBDXPZwdKAywMxtwG2h3aTJtxng=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.77", "", { "os": "win32", "cpu": "arm64" }, "sha512-HGTscPXc7gdd23Nh1DbzUNjog1I+5IZp95XPtLftGTpjrWs60VcetXcyJqK2rQcXNxewJK5yDyaa5QyMjfEhCQ=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.75", "", { "os": "win32", "cpu": "x64" }, "sha512-ESpVZVGewe3JkB2TwrG3VRbkxT909iPdtvgNT7xTCIYH2VB4jqZomJfvERPTE0tvqAZJm19mHECzJFI8asSJgQ=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.77", "", { "os": "win32", "cpu": "x64" }, "sha512-c7GijsbvVgnlzd2murIbwuwrGbcv76KdUw6WlVv7a0vex50z6xJCpv1keGzpe0QfxrZ/6fFEFX7JnwGLno0wjA=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.1.75", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.75", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-WjKsZIfrm29znfRlcD9w3uUn/+uvoy2MmeoDwTvg1YOa0OjCTCmjZ43L9imp0m9S4HmVU8ma6o2bR4COzcyDdg=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.1.77", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.77", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-JY+hUbXVV+XCk6bC8dvcwawWCEmC3Gid6GDs23AJWBgHZ3TU2kRKrgwTdltm45DOq2cZXrYCt690/yE8bP+Gxg=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-06Otz3loT4vn0578VDxUqVudtzQvV7oM3EIzjZnsejo=",
|
||||
"aarch64-linux": "sha256-88Qai5RkSenCZkakOg52b6xU2ok+h/Ns4/5L3+55sFY=",
|
||||
"aarch64-darwin": "sha256-x8dgCF0CJBWi2dZLDHMGdlTqys1X755ok0PM6x0HAGo=",
|
||||
"x86_64-darwin": "sha256-FkLDqorfIfOw+tB7SW5vgyhOIoI0IV9lqPW1iEmvUiI="
|
||||
"x86_64-linux": "sha256-yIrljJgOR1GZCAXi5bx+YvrIAjSkTAMTSzlhLFY/ufE=",
|
||||
"aarch64-linux": "sha256-Xa3BgqbuD5Cx5OpyVSN1v7Klge449hPqR1GY9E9cAX0=",
|
||||
"aarch64-darwin": "sha256-Q3FKm7+4Jr3PL+TnQngrTtv/xdek2st5HmgeoEOHUis=",
|
||||
"x86_64-darwin": "sha256-asJ8DBvIgkqh8HhrN48M/L4xj1kwv+uyQMy9bN2HxuM="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { createMemo } from "solid-js"
|
||||
import { createMemo, createResource, createSignal } from "solid-js"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import type { ListRef } from "@opencode-ai/ui/list"
|
||||
|
||||
interface DialogSelectDirectoryProps {
|
||||
title?: string
|
||||
@@ -15,18 +16,47 @@ interface DialogSelectDirectoryProps {
|
||||
onSelect: (result: string | string[] | null) => void
|
||||
}
|
||||
|
||||
type Row = {
|
||||
absolute: string
|
||||
search: string
|
||||
}
|
||||
|
||||
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
const sync = useGlobalSync()
|
||||
const sdk = useGlobalSDK()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
|
||||
const home = createMemo(() => sync.data.path.home)
|
||||
const [filter, setFilter] = createSignal("")
|
||||
|
||||
const start = createMemo(() => sync.data.path.home || sync.data.path.directory)
|
||||
let list: ListRef | undefined
|
||||
|
||||
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
||||
|
||||
const [fallbackPath] = createResource(
|
||||
() => (missingBase() ? true : undefined),
|
||||
async () => {
|
||||
return sdk.client.path
|
||||
.get()
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
},
|
||||
{ initialValue: undefined },
|
||||
)
|
||||
|
||||
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
||||
|
||||
const start = createMemo(
|
||||
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
|
||||
)
|
||||
|
||||
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
|
||||
|
||||
const clean = (value: string) => {
|
||||
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
|
||||
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
|
||||
}
|
||||
|
||||
function normalize(input: string) {
|
||||
const v = input.replaceAll("\\", "/")
|
||||
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
|
||||
@@ -64,24 +94,67 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
return ""
|
||||
}
|
||||
|
||||
function display(path: string) {
|
||||
function parentOf(input: string) {
|
||||
const v = trimTrailing(input)
|
||||
if (v === "/") return v
|
||||
if (v === "//") return v
|
||||
if (/^[A-Za-z]:\/$/.test(v)) return v
|
||||
|
||||
const i = v.lastIndexOf("/")
|
||||
if (i <= 0) return "/"
|
||||
if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
|
||||
return v.slice(0, i)
|
||||
}
|
||||
|
||||
function modeOf(input: string) {
|
||||
const raw = normalizeDriveRoot(input.trim())
|
||||
if (!raw) return "relative" as const
|
||||
if (raw.startsWith("~")) return "tilde" as const
|
||||
if (rootOf(raw)) return "absolute" as const
|
||||
return "relative" as const
|
||||
}
|
||||
|
||||
function display(path: string, input: string) {
|
||||
const full = trimTrailing(path)
|
||||
if (modeOf(input) === "absolute") return full
|
||||
|
||||
return tildeOf(full) || full
|
||||
}
|
||||
|
||||
function tildeOf(absolute: string) {
|
||||
const full = trimTrailing(absolute)
|
||||
const h = home()
|
||||
if (!h) return full
|
||||
if (!h) return ""
|
||||
|
||||
const hn = trimTrailing(h)
|
||||
const lc = full.toLowerCase()
|
||||
const hc = hn.toLowerCase()
|
||||
if (lc === hc) return "~"
|
||||
if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
|
||||
return full
|
||||
return ""
|
||||
}
|
||||
|
||||
function scoped(filter: string) {
|
||||
function row(absolute: string): Row {
|
||||
const full = trimTrailing(absolute)
|
||||
const tilde = tildeOf(full)
|
||||
|
||||
const withSlash = (value: string) => {
|
||||
if (!value) return ""
|
||||
if (value.endsWith("/")) return value
|
||||
return value + "/"
|
||||
}
|
||||
|
||||
const search = Array.from(
|
||||
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
|
||||
).join("\n")
|
||||
return { absolute: full, search }
|
||||
}
|
||||
|
||||
function scoped(value: string) {
|
||||
const base = start()
|
||||
if (!base) return
|
||||
|
||||
const raw = normalizeDriveRoot(filter.trim())
|
||||
const raw = normalizeDriveRoot(value)
|
||||
if (!raw) return { directory: trimTrailing(base), path: "" }
|
||||
|
||||
const h = home()
|
||||
@@ -122,21 +195,25 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
}
|
||||
|
||||
const directories = async (filter: string) => {
|
||||
const input = scoped(filter)
|
||||
if (!input) return [] as string[]
|
||||
const value = clean(filter)
|
||||
const scopedInput = scoped(value)
|
||||
if (!scopedInput) return [] as string[]
|
||||
|
||||
const raw = normalizeDriveRoot(filter.trim())
|
||||
const raw = normalizeDriveRoot(value)
|
||||
const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")
|
||||
|
||||
const query = normalizeDriveRoot(input.path)
|
||||
const query = normalizeDriveRoot(scopedInput.path)
|
||||
|
||||
if (!isPath) {
|
||||
const results = await sdk.client.find
|
||||
.files({ directory: input.directory, query, type: "directory", limit: 50 })
|
||||
const find = () =>
|
||||
sdk.client.find
|
||||
.files({ directory: scopedInput.directory, query, type: "directory", limit: 50 })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => [])
|
||||
|
||||
return results.map((rel) => join(input.directory, rel)).slice(0, 50)
|
||||
if (!isPath) {
|
||||
const results = await find()
|
||||
|
||||
return results.map((rel) => join(scopedInput.directory, rel)).slice(0, 50)
|
||||
}
|
||||
|
||||
const segments = query.replace(/^\/+/, "").split("/")
|
||||
@@ -145,17 +222,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
|
||||
const cap = 12
|
||||
const branch = 4
|
||||
let paths = [input.directory]
|
||||
let paths = [scopedInput.directory]
|
||||
for (const part of head) {
|
||||
if (part === "..") {
|
||||
paths = paths.map((p) => {
|
||||
const v = trimTrailing(p)
|
||||
if (v === "/") return v
|
||||
if (/^[A-Za-z]:\/$/.test(v)) return v
|
||||
const i = v.lastIndexOf("/")
|
||||
if (i <= 0) return "/"
|
||||
return v.slice(0, i)
|
||||
})
|
||||
paths = paths.map(parentOf)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -165,7 +235,27 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
}
|
||||
|
||||
const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
|
||||
return Array.from(new Set(out)).slice(0, 50)
|
||||
const deduped = Array.from(new Set(out))
|
||||
const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : ""
|
||||
const expand = !raw.endsWith("/")
|
||||
if (!expand || !tail) {
|
||||
const items = base ? Array.from(new Set([base, ...deduped])) : deduped
|
||||
return items.slice(0, 50)
|
||||
}
|
||||
|
||||
const needle = tail.toLowerCase()
|
||||
const exact = deduped.filter((p) => getFilename(p).toLowerCase() === needle)
|
||||
const target = exact[0]
|
||||
if (!target) return deduped.slice(0, 50)
|
||||
|
||||
const children = await match(target, "", 30)
|
||||
const items = Array.from(new Set([...deduped, ...children]))
|
||||
return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50)
|
||||
}
|
||||
|
||||
const items = async (value: string) => {
|
||||
const results = await directories(value)
|
||||
return results.map(row)
|
||||
}
|
||||
|
||||
function resolve(absolute: string) {
|
||||
@@ -179,24 +269,52 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }}
|
||||
emptyMessage={language.t("dialog.directory.empty")}
|
||||
loadingMessage={language.t("common.loading")}
|
||||
items={directories}
|
||||
key={(x) => x}
|
||||
items={items}
|
||||
key={(x) => x.absolute}
|
||||
filterKeys={["search"]}
|
||||
ref={(r) => (list = r)}
|
||||
onFilter={(value) => setFilter(clean(value))}
|
||||
onKeyEvent={(e, item) => {
|
||||
if (e.key !== "Tab") return
|
||||
if (e.shiftKey) return
|
||||
if (!item) return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const value = display(item.absolute, filter())
|
||||
list?.setFilter(value.endsWith("/") ? value : value + "/")
|
||||
}}
|
||||
onSelect={(path) => {
|
||||
if (!path) return
|
||||
resolve(path)
|
||||
resolve(path.absolute)
|
||||
}}
|
||||
>
|
||||
{(absolute) => {
|
||||
const path = display(absolute)
|
||||
{(item) => {
|
||||
const path = display(item.absolute, filter())
|
||||
if (path === "~") {
|
||||
return (
|
||||
<div class="w-full flex items-center justify-between rounded-md">
|
||||
<div class="flex items-center gap-x-3 grow min-w-0">
|
||||
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
|
||||
<div class="flex items-center text-14-regular min-w-0">
|
||||
<span class="text-text-strong whitespace-nowrap">~</span>
|
||||
<span class="text-text-weak whitespace-nowrap">/</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div class="w-full flex items-center justify-between rounded-md">
|
||||
<div class="flex items-center gap-x-3 grow min-w-0">
|
||||
<FileIcon node={{ path: absolute, type: "directory" }} class="shrink-0 size-4" />
|
||||
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
|
||||
<div class="flex items-center text-14-regular min-w-0">
|
||||
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
||||
{getDirectory(path)}
|
||||
</span>
|
||||
<span class="text-text-strong whitespace-nowrap">{getFilename(path)}</span>
|
||||
<span class="text-text-weak whitespace-nowrap">/</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -90,10 +90,9 @@ const ModelList: Component<{
|
||||
|
||||
export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
|
||||
provider?: string
|
||||
children?: JSX.Element | ((open: boolean) => JSX.Element)
|
||||
children?: JSX.Element
|
||||
triggerAs?: T
|
||||
triggerProps?: ComponentProps<T>
|
||||
gutter?: number
|
||||
}) {
|
||||
const [store, setStore] = createStore<{
|
||||
open: boolean
|
||||
@@ -176,14 +175,14 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
|
||||
}}
|
||||
modal={false}
|
||||
placement="top-start"
|
||||
gutter={props.gutter ?? 8}
|
||||
gutter={8}
|
||||
>
|
||||
<Kobalte.Trigger
|
||||
ref={(el) => setStore("trigger", el)}
|
||||
as={props.triggerAs ?? "div"}
|
||||
{...(props.triggerProps as any)}
|
||||
>
|
||||
{typeof props.children === "function" ? props.children(store.open) : props.children}
|
||||
{props.children}
|
||||
</Kobalte.Trigger>
|
||||
<Kobalte.Portal>
|
||||
<Kobalte.Content
|
||||
|
||||
@@ -130,10 +130,57 @@ export default function FileTree(props: {
|
||||
const nodes = file.tree.children(props.path)
|
||||
const current = filter()
|
||||
if (!current) return nodes
|
||||
return nodes.filter((node) => {
|
||||
|
||||
const parent = (path: string) => {
|
||||
const idx = path.lastIndexOf("/")
|
||||
if (idx === -1) return ""
|
||||
return path.slice(0, idx)
|
||||
}
|
||||
|
||||
const leaf = (path: string) => {
|
||||
const idx = path.lastIndexOf("/")
|
||||
return idx === -1 ? path : path.slice(idx + 1)
|
||||
}
|
||||
|
||||
const out = nodes.filter((node) => {
|
||||
if (node.type === "file") return current.files.has(node.path)
|
||||
return current.dirs.has(node.path)
|
||||
})
|
||||
|
||||
const seen = new Set(out.map((node) => node.path))
|
||||
|
||||
for (const dir of current.dirs) {
|
||||
if (parent(dir) !== props.path) continue
|
||||
if (seen.has(dir)) continue
|
||||
out.push({
|
||||
name: leaf(dir),
|
||||
path: dir,
|
||||
absolute: dir,
|
||||
type: "directory",
|
||||
ignored: false,
|
||||
})
|
||||
seen.add(dir)
|
||||
}
|
||||
|
||||
for (const item of current.files) {
|
||||
if (parent(item) !== props.path) continue
|
||||
if (seen.has(item)) continue
|
||||
out.push({
|
||||
name: leaf(item),
|
||||
path: item,
|
||||
absolute: item,
|
||||
type: "file",
|
||||
ignored: false,
|
||||
})
|
||||
seen.add(item)
|
||||
}
|
||||
|
||||
return out.toSorted((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
return a.type === "directory" ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
})
|
||||
|
||||
const Node = (
|
||||
|
||||
@@ -32,9 +32,7 @@ import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { MorphChevron } from "@opencode-ai/ui/morph-chevron"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { CycleLabel } from "@opencode-ai/ui/cycle-label"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
||||
@@ -44,7 +42,6 @@ import { Select } from "@opencode-ai/ui/select"
|
||||
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||
import { ReasoningIcon } from "@opencode-ai/ui/reasoning-icon"
|
||||
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
@@ -1257,7 +1254,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
clearInput()
|
||||
client.session
|
||||
.shell({
|
||||
sessionID: session?.id || "",
|
||||
sessionID: session.id,
|
||||
agent,
|
||||
model,
|
||||
command: text,
|
||||
@@ -1280,7 +1277,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
clearInput()
|
||||
client.session
|
||||
.command({
|
||||
sessionID: session?.id || "",
|
||||
sessionID: session.id,
|
||||
command: commandName,
|
||||
arguments: args.join(" "),
|
||||
agent,
|
||||
@@ -1436,13 +1433,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const optimisticParts = requestParts.map((part) => ({
|
||||
...part,
|
||||
sessionID: session?.id || "",
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
})) as unknown as Part[]
|
||||
|
||||
const optimisticMessage: Message = {
|
||||
id: messageID,
|
||||
sessionID: session?.id || "",
|
||||
sessionID: session.id,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent,
|
||||
@@ -1453,9 +1450,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const messages = draft.message[session?.id || ""]
|
||||
const messages = draft.message[session.id]
|
||||
if (!messages) {
|
||||
draft.message[session?.id || ""] = [optimisticMessage]
|
||||
draft.message[session.id] = [optimisticMessage]
|
||||
} else {
|
||||
const result = Binary.search(messages, messageID, (m) => m.id)
|
||||
messages.splice(result.index, 0, optimisticMessage)
|
||||
@@ -1463,7 +1460,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
draft.part[messageID] = optimisticParts
|
||||
.filter((p) => !!p?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||
}),
|
||||
)
|
||||
return
|
||||
@@ -1471,9 +1468,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
globalSync.child(sessionDirectory)[1](
|
||||
produce((draft) => {
|
||||
const messages = draft.message[session?.id || ""]
|
||||
const messages = draft.message[session.id]
|
||||
if (!messages) {
|
||||
draft.message[session?.id || ""] = [optimisticMessage]
|
||||
draft.message[session.id] = [optimisticMessage]
|
||||
} else {
|
||||
const result = Binary.search(messages, messageID, (m) => m.id)
|
||||
messages.splice(result.index, 0, optimisticMessage)
|
||||
@@ -1481,7 +1478,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
draft.part[messageID] = optimisticParts
|
||||
.filter((p) => !!p?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1490,7 +1487,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const messages = draft.message[session?.id || ""]
|
||||
const messages = draft.message[session.id]
|
||||
if (messages) {
|
||||
const result = Binary.search(messages, messageID, (m) => m.id)
|
||||
if (result.found) messages.splice(result.index, 1)
|
||||
@@ -1503,7 +1500,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
globalSync.child(sessionDirectory)[1](
|
||||
produce((draft) => {
|
||||
const messages = draft.message[session?.id || ""]
|
||||
const messages = draft.message[session.id]
|
||||
if (messages) {
|
||||
const result = Binary.search(messages, messageID, (m) => m.id)
|
||||
if (result.found) messages.splice(result.index, 1)
|
||||
@@ -1524,15 +1521,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const worktree = WorktreeState.get(sessionDirectory)
|
||||
if (!worktree || worktree.status !== "pending") return true
|
||||
|
||||
if (sessionDirectory === projectDirectory && session?.id) {
|
||||
sync.set("session_status", session?.id, { type: "busy" })
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync.set("session_status", session.id, { type: "busy" })
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
|
||||
const cleanup = () => {
|
||||
if (sessionDirectory === projectDirectory && session?.id) {
|
||||
sync.set("session_status", session?.id, { type: "idle" })
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync.set("session_status", session.id, { type: "idle" })
|
||||
}
|
||||
removeOptimisticMessage()
|
||||
for (const item of commentItems) {
|
||||
@@ -1549,7 +1546,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
restoreInput()
|
||||
}
|
||||
|
||||
pending.set(session?.id || "", { abort: controller, cleanup })
|
||||
pending.set(session.id, { abort: controller, cleanup })
|
||||
|
||||
const abort = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||
if (controller.signal.aborted) {
|
||||
@@ -1577,7 +1574,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
if (timer.id === undefined) return
|
||||
clearTimeout(timer.id)
|
||||
})
|
||||
pending.delete(session?.id || "")
|
||||
pending.delete(session.id)
|
||||
if (controller.signal.aborted) return false
|
||||
if (result.status === "failed") throw new Error(result.message)
|
||||
return true
|
||||
@@ -1587,7 +1584,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const ok = await waitForWorktree()
|
||||
if (!ok) return
|
||||
await client.session.prompt({
|
||||
sessionID: session?.id || "",
|
||||
sessionID: session.id,
|
||||
agent,
|
||||
model,
|
||||
messageID,
|
||||
@@ -1597,9 +1594,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
|
||||
void send().catch((err) => {
|
||||
pending.delete(session?.id || "")
|
||||
if (sessionDirectory === projectDirectory && session?.id) {
|
||||
sync.set("session_status", session?.id, { type: "idle" })
|
||||
pending.delete(session.id)
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync.set("session_status", session.id, { type: "idle" })
|
||||
}
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
@@ -1621,28 +1618,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
})
|
||||
}
|
||||
|
||||
const currrentModelVariant = createMemo(() => {
|
||||
const modelVariant = local.model.variant.current() ?? ""
|
||||
return modelVariant === "xhigh"
|
||||
? "xHigh"
|
||||
: modelVariant.length > 0
|
||||
? modelVariant[0].toUpperCase() + modelVariant.slice(1)
|
||||
: "Default"
|
||||
})
|
||||
|
||||
const reasoningPercentage = createMemo(() => {
|
||||
const variants = local.model.variant.list()
|
||||
const current = local.model.variant.current()
|
||||
const totalEntries = variants.length + 1
|
||||
|
||||
if (totalEntries <= 2 || current === "Default") {
|
||||
return 0
|
||||
}
|
||||
|
||||
const currentIndex = current ? variants.indexOf(current) + 1 : 0
|
||||
return ((currentIndex + 1) / totalEntries) * 100
|
||||
}, [local.model.variant])
|
||||
|
||||
return (
|
||||
<div class="relative size-full _max-h-[320px] flex flex-col gap-3">
|
||||
<Show when={store.popover}>
|
||||
@@ -1695,7 +1670,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Icon name="brain" size="normal" class="text-icon-info-active shrink-0" />
|
||||
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
|
||||
<span class="text-14-regular text-text-strong whitespace-nowrap">
|
||||
@{(item as { type: "agent"; name: string }).name}
|
||||
</span>
|
||||
@@ -1760,9 +1735,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}}
|
||||
>
|
||||
<Show when={store.dragging}>
|
||||
<div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 mr-1 pointer-events-none">
|
||||
<div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
|
||||
<div class="flex flex-col items-center gap-2 text-text-weak">
|
||||
<Icon name="photo" size={18} class="text-icon-base stroke-1.5" />
|
||||
<Icon name="photo" class="size-8" />
|
||||
<span class="text-14-regular">{language.t("prompt.dropzone.label")}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1801,7 +1776,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-7" />
|
||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
|
||||
<div class="flex items-center text-11-regular min-w-0 font-medium">
|
||||
<span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
|
||||
<Show when={item.selection}>
|
||||
@@ -1818,7 +1793,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
type="button"
|
||||
icon="close-small"
|
||||
variant="ghost"
|
||||
class="ml-auto size-7 opacity-0 group-hover:opacity-100 transition-all"
|
||||
class="ml-auto size-3.5 opacity-0 group-hover:opacity-100 transition-all"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (item.commentID) comments.remove(item.path, item.commentID)
|
||||
@@ -1848,7 +1823,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
when={attachment.mime.startsWith("image/")}
|
||||
fallback={
|
||||
<div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
|
||||
<Icon name="folder" size="normal" class="size-6 text-text-base" />
|
||||
<Icon name="folder" class="size-6 text-text-weak" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -1921,8 +1896,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="relative p-3 flex items-center justify-between">
|
||||
<div class="flex items-center justify-start gap-2">
|
||||
<div class="relative p-3 flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<Switch>
|
||||
<Match when={store.mode === "shell"}>
|
||||
<div class="flex items-center gap-2 px-2 h-6">
|
||||
@@ -1934,6 +1909,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Match when={store.mode === "normal"}>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={8}
|
||||
title={language.t("command.agent.cycle")}
|
||||
keybind={command.keybind("agent.cycle")}
|
||||
>
|
||||
@@ -1941,9 +1917,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
options={local.agent.list().map((agent) => agent.name)}
|
||||
current={local.agent.current()?.name ?? ""}
|
||||
onSelect={local.agent.set}
|
||||
class="capitalize"
|
||||
class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-[80px]" : "max-w-[120px]"}`}
|
||||
valueClass="truncate"
|
||||
variant="ghost"
|
||||
gutter={12}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
<Show
|
||||
@@ -1951,66 +1927,68 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
fallback={
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={8}
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybind("model.choose")}
|
||||
>
|
||||
<Button
|
||||
as="div"
|
||||
variant="ghost"
|
||||
class="px-2"
|
||||
class="px-2 min-w-0 max-w-[140px]"
|
||||
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
|
||||
</Show>
|
||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
<MorphChevron
|
||||
expanded={!!dialog.active?.id && dialog.active.id.startsWith("select-model-unpaid")}
|
||||
/>
|
||||
<span class="truncate max-w-[100px]">
|
||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={8}
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybind("model.choose")}
|
||||
>
|
||||
<ModelSelectorPopover triggerAs={Button} triggerProps={{ variant: "ghost" }} gutter={12}>
|
||||
{(open) => (
|
||||
<>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
|
||||
</Show>
|
||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
<MorphChevron expanded={open} class="text-text-weak" />
|
||||
</>
|
||||
)}
|
||||
<ModelSelectorPopover
|
||||
triggerAs={Button}
|
||||
triggerProps={{ variant: "ghost", class: "min-w-0 max-w-[140px]" }}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
|
||||
</Show>
|
||||
<span class="truncate max-w-[100px]">
|
||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
</ModelSelectorPopover>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
<Show when={local.model.variant.list().length > 0}>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={8}
|
||||
title={language.t("command.model.variant.cycle")}
|
||||
keybind={command.keybind("model.variant.cycle")}
|
||||
>
|
||||
<Button
|
||||
data-action="model-variant-cycle"
|
||||
variant="ghost"
|
||||
class="text-text-strong text-12-regular"
|
||||
class="text-text-base _hidden group-hover/prompt-input:inline-block capitalize text-12-regular"
|
||||
onClick={() => local.model.variant.cycle()}
|
||||
>
|
||||
<Show when={local.model.variant.list().length > 1}>
|
||||
<ReasoningIcon percentage={reasoningPercentage()} size={16} strokeWidth={1.25} />
|
||||
</Show>
|
||||
<CycleLabel value={currrentModelVariant()} />
|
||||
{local.model.variant.current() ?? language.t("common.default")}
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
<Show when={permission.permissionsEnabled() && params.id}>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={8}
|
||||
title={language.t("command.permissions.autoaccept.enable")}
|
||||
keybind={command.keybind("permissions.autoaccept")}
|
||||
>
|
||||
@@ -2018,7 +1996,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
variant="ghost"
|
||||
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
|
||||
classList={{
|
||||
"_hidden group-hover/prompt-input:flex items-center justify-center": true,
|
||||
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
|
||||
"text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
|
||||
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
|
||||
}}
|
||||
@@ -2040,7 +2018,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 absolute right-3 bottom-3">
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -2052,19 +2030,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
e.currentTarget.value = ""
|
||||
}}
|
||||
/>
|
||||
<div class="flex items-center gap-1.5 mr-1.5">
|
||||
<div class="flex items-center gap-1 mr-1">
|
||||
<SessionContextUsage />
|
||||
<Show when={store.mode === "normal"}>
|
||||
<Tooltip placement="top" value={language.t("prompt.action.attachFile")}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
class="px-1"
|
||||
class="size-6 px-1"
|
||||
onClick={() => fileInputRef.click()}
|
||||
aria-label={language.t("prompt.action.attachFile")}
|
||||
>
|
||||
<Icon name="photo" class="size-6 text-icon-base" />
|
||||
<Icon name="photo" class="size-4.5" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
@@ -2083,7 +2060,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Match when={true}>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{language.t("prompt.action.send")}</span>
|
||||
<Icon name="enter" size="normal" class="text-icon-base" />
|
||||
<Icon name="enter" size="small" class="text-icon-base" />
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
@@ -2094,7 +2071,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
disabled={!prompt.dirty() && !working()}
|
||||
icon={working() ? "stop" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="h-6 w-5.5"
|
||||
class="h-6 w-4.5"
|
||||
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -64,7 +64,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
}
|
||||
|
||||
const circle = () => (
|
||||
<div class="p-1">
|
||||
<div class="flex items-center justify-center">
|
||||
<ProgressCircle size={16} strokeWidth={2} percentage={context()?.percentage ?? 0} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -3,11 +3,12 @@ import type { JSX } from "solid-js"
|
||||
import { createSortable } from "@thisbeyond/solid-dnd"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { useFile } from "@/context/file"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useCommand } from "@/context/command"
|
||||
|
||||
export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
|
||||
return (
|
||||
@@ -27,6 +28,7 @@ export function FileVisual(props: { path: string; active?: boolean }): JSX.Eleme
|
||||
export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element {
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const sortable = createSortable(props.tab)
|
||||
const path = createMemo(() => file.pathFromTab(props.tab))
|
||||
return (
|
||||
@@ -36,7 +38,11 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
|
||||
<Tabs.Trigger
|
||||
value={props.tab}
|
||||
closeButton={
|
||||
<Tooltip value={language.t("common.closeTab")} placement="bottom">
|
||||
<TooltipKeybind
|
||||
title={language.t("common.closeTab")}
|
||||
keybind={command.keybind("tab.close")}
|
||||
placement="bottom"
|
||||
>
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
variant="ghost"
|
||||
@@ -44,7 +50,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
|
||||
onClick={() => props.onTabClose(props.tab)}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
hideCloseButton
|
||||
onMiddleClick={() => props.onTabClose(props.tab)}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Select } from "@opencode-ai/ui/select"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings, monoFontFamily } from "@/context/settings"
|
||||
@@ -131,12 +130,7 @@ export const SettingsGeneral: Component = () => {
|
||||
const soundOptions = [...SOUND_OPTIONS]
|
||||
|
||||
return (
|
||||
<ScrollFade
|
||||
direction="vertical"
|
||||
fadeStartSize={0}
|
||||
fadeEndSize={16}
|
||||
class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
|
||||
>
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
<div class="flex flex-col gap-1 pt-6 pb-8">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.tab.general")}</h2>
|
||||
@@ -232,7 +226,7 @@ export const SettingsGeneral: Component = () => {
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "field-sizing": "content" }}
|
||||
triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }}
|
||||
>
|
||||
{(option) => (
|
||||
<span style={{ "font-family": monoFontFamily(option?.value) }}>
|
||||
@@ -417,7 +411,7 @@ export const SettingsGeneral: Component = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollFade>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -353,12 +352,7 @@ export const SettingsKeybinds: Component = () => {
|
||||
})
|
||||
|
||||
return (
|
||||
<ScrollFade
|
||||
direction="vertical"
|
||||
fadeStartSize={0}
|
||||
fadeEndSize={16}
|
||||
class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
|
||||
>
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
@@ -436,6 +430,6 @@ export const SettingsKeybinds: Component = () => {
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</ScrollFade>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useModels } from "@/context/models"
|
||||
import { popularProviders } from "@/hooks/use-providers"
|
||||
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
|
||||
|
||||
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
|
||||
|
||||
@@ -40,12 +39,7 @@ export const SettingsModels: Component = () => {
|
||||
})
|
||||
|
||||
return (
|
||||
<ScrollFade
|
||||
direction="vertical"
|
||||
fadeStartSize={0}
|
||||
fadeEndSize={16}
|
||||
class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
|
||||
>
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
|
||||
@@ -131,6 +125,6 @@ export const SettingsModels: Component = () => {
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</ScrollFade>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import { useGlobalSync } from "@/context/global-sync"
|
||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
|
||||
|
||||
type ProviderSource = "env" | "api" | "config" | "custom"
|
||||
type ProviderMeta = { source?: ProviderSource }
|
||||
@@ -116,12 +115,7 @@ export const SettingsProviders: Component = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollFade
|
||||
direction="vertical"
|
||||
fadeStartSize={0}
|
||||
fadeEndSize={16}
|
||||
class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
|
||||
>
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
<div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2>
|
||||
@@ -267,6 +261,6 @@ export const SettingsProviders: Component = () => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollFade>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ export function Titlebar() {
|
||||
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
|
||||
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
|
||||
const web = createMemo(() => platform.platform === "web")
|
||||
const zoom = () => platform.webviewZoom?.() ?? 1
|
||||
const minHeight = () => (mac() ? `${40 / zoom()}px` : undefined)
|
||||
|
||||
const [history, setHistory] = createStore({
|
||||
stack: [] as string[],
|
||||
@@ -134,6 +136,7 @@ export function Titlebar() {
|
||||
return (
|
||||
<header
|
||||
class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
|
||||
style={{ "min-height": minHeight() }}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div
|
||||
@@ -145,7 +148,7 @@ export function Titlebar() {
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<Show when={mac()}>
|
||||
<div class="w-[72px] h-full shrink-0" data-tauri-drag-region />
|
||||
<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} data-tauri-drag-region />
|
||||
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
|
||||
<IconButton
|
||||
icon="menu"
|
||||
|
||||
@@ -119,6 +119,8 @@ type ChildOptions = {
|
||||
bootstrap?: boolean
|
||||
}
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
|
||||
function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
|
||||
return {
|
||||
...input,
|
||||
@@ -297,7 +299,7 @@ function createGlobalSync() {
|
||||
const aUpdated = sessionUpdatedAt(a)
|
||||
const bUpdated = sessionUpdatedAt(b)
|
||||
if (aUpdated !== bUpdated) return bUpdated - aUpdated
|
||||
return a.id.localeCompare(b.id)
|
||||
return cmp(a.id, b.id)
|
||||
}
|
||||
|
||||
function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) {
|
||||
@@ -325,7 +327,7 @@ function createGlobalSync() {
|
||||
const all = input
|
||||
.filter((s) => !!s?.id)
|
||||
.filter((s) => !s.time?.archived)
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
|
||||
const roots = all.filter((s) => !s.parentID)
|
||||
const children = all.filter((s) => !!s.parentID)
|
||||
@@ -342,7 +344,7 @@ function createGlobalSync() {
|
||||
return sessionUpdatedAt(s) > cutoff
|
||||
})
|
||||
|
||||
return [...keepRoots, ...keepChildren].sort((a, b) => a.id.localeCompare(b.id))
|
||||
return [...keepRoots, ...keepChildren].sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
|
||||
function ensureChild(directory: string) {
|
||||
@@ -457,7 +459,7 @@ function createGlobalSync() {
|
||||
const nonArchived = (x.data ?? [])
|
||||
.filter((s) => !!s?.id)
|
||||
.filter((s) => !s.time?.archived)
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
|
||||
// Read the current limit at resolve-time so callers that bump the limit while
|
||||
// a request is in-flight still get the expanded result.
|
||||
@@ -559,7 +561,7 @@ function createGlobalSync() {
|
||||
"permission",
|
||||
sessionID,
|
||||
reconcile(
|
||||
permissions.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id)),
|
||||
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
@@ -588,7 +590,7 @@ function createGlobalSync() {
|
||||
"question",
|
||||
sessionID,
|
||||
reconcile(
|
||||
questions.filter((q) => !!q?.id).sort((a, b) => a.id.localeCompare(b.id)),
|
||||
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
@@ -986,7 +988,7 @@ function createGlobalSync() {
|
||||
.filter((p) => !!p?.id)
|
||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
setGlobalStore("project", projects)
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
|
||||
import type { Accessor } from "solid-js"
|
||||
|
||||
export type Platform = {
|
||||
/** Platform discriminator */
|
||||
@@ -55,6 +56,9 @@ export type Platform = {
|
||||
|
||||
/** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */
|
||||
parseMarkdown?(markdown: string): Promise<string>
|
||||
|
||||
/** Webview zoom level (desktop only) */
|
||||
webviewZoom?: Accessor<number>
|
||||
}
|
||||
|
||||
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
|
||||
|
||||
@@ -9,6 +9,8 @@ import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
const keyFor = (directory: string, id: string) => `${directory}\n${id}`
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
|
||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
name: "Sync",
|
||||
init: () => {
|
||||
@@ -59,7 +61,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const next = items
|
||||
.map((x) => x.info)
|
||||
.filter((m) => !!m?.id)
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
|
||||
batch(() => {
|
||||
input.setStore("message", input.sessionID, reconcile(next, { key: "id" }))
|
||||
@@ -69,7 +71,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
"part",
|
||||
message.info.id,
|
||||
reconcile(
|
||||
message.parts.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id)),
|
||||
message.parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
@@ -129,7 +131,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const result = Binary.search(messages, input.messageID, (m) => m.id)
|
||||
messages.splice(result.index, 0, message)
|
||||
}
|
||||
draft.part[input.messageID] = input.parts.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id))
|
||||
draft.part[input.messageID] = input.parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id))
|
||||
}),
|
||||
)
|
||||
},
|
||||
@@ -271,7 +273,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
await client.session.list().then((x) => {
|
||||
const sessions = (x.data ?? [])
|
||||
.filter((s) => !!s?.id)
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
.slice(0, store.limit)
|
||||
setStore("session", reconcile(sessions, { key: "id" }))
|
||||
})
|
||||
|
||||
@@ -28,6 +28,8 @@ export const dict = {
|
||||
"command.settings.open": "فتح الإعدادات",
|
||||
"command.session.previous": "الجلسة السابقة",
|
||||
"command.session.next": "الجلسة التالية",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.session.archive": "أرشفة الجلسة",
|
||||
|
||||
"command.palette": "لوحة الأوامر",
|
||||
@@ -68,6 +70,7 @@ export const dict = {
|
||||
"command.model.variant.cycle.description": "التبديل إلى مستوى الجهد التالي",
|
||||
"command.permissions.autoaccept.enable": "قبول التعديلات تلقائيًا",
|
||||
"command.permissions.autoaccept.disable": "إيقاف قبول التعديلات تلقائيًا",
|
||||
"command.workspace.toggle": "تبديل مساحات العمل",
|
||||
"command.session.undo": "تراجع",
|
||||
"command.session.undo.description": "تراجع عن الرسالة الأخيرة",
|
||||
"command.session.redo": "إعادة",
|
||||
@@ -346,6 +349,11 @@ export const dict = {
|
||||
"toast.permissions.autoaccept.off.title": "توقف قبول التعديلات تلقائيًا",
|
||||
"toast.permissions.autoaccept.off.description": "ستتطلب أذونات التحرير والكتابة موافقة",
|
||||
|
||||
"toast.workspace.enabled.title": "تم تمكين مساحات العمل",
|
||||
"toast.workspace.enabled.description": "الآن يتم عرض عدة worktrees في الشريط الجانبي",
|
||||
"toast.workspace.disabled.title": "تم تعطيل مساحات العمل",
|
||||
"toast.workspace.disabled.description": "يتم عرض worktree الرئيسي فقط في الشريط الجانبي",
|
||||
|
||||
"toast.model.none.title": "لم يتم تحديد نموذج",
|
||||
"toast.model.none.description": "قم بتوصيل موفر لتلخيص هذه الجلسة",
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ export const dict = {
|
||||
"command.settings.open": "Abrir configurações",
|
||||
"command.session.previous": "Sessão anterior",
|
||||
"command.session.next": "Próxima sessão",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.session.archive": "Arquivar sessão",
|
||||
|
||||
"command.palette": "Paleta de comandos",
|
||||
@@ -68,6 +70,7 @@ export const dict = {
|
||||
"command.model.variant.cycle.description": "Mudar para o próximo nível de esforço",
|
||||
"command.permissions.autoaccept.enable": "Aceitar edições automaticamente",
|
||||
"command.permissions.autoaccept.disable": "Parar de aceitar edições automaticamente",
|
||||
"command.workspace.toggle": "Alternar espaços de trabalho",
|
||||
"command.session.undo": "Desfazer",
|
||||
"command.session.undo.description": "Desfazer a última mensagem",
|
||||
"command.session.redo": "Refazer",
|
||||
@@ -345,6 +348,11 @@ export const dict = {
|
||||
"toast.permissions.autoaccept.off.title": "Parou de aceitar edições automaticamente",
|
||||
"toast.permissions.autoaccept.off.description": "Permissões de edição e escrita exigirão aprovação",
|
||||
|
||||
"toast.workspace.enabled.title": "Espaços de trabalho ativados",
|
||||
"toast.workspace.enabled.description": "Várias worktrees agora são exibidas na barra lateral",
|
||||
"toast.workspace.disabled.title": "Espaços de trabalho desativados",
|
||||
"toast.workspace.disabled.description": "Apenas a worktree principal é exibida na barra lateral",
|
||||
|
||||
"toast.model.none.title": "Nenhum modelo selecionado",
|
||||
"toast.model.none.description": "Conecte um provedor para resumir esta sessão",
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ export const dict = {
|
||||
"command.settings.open": "Åbn indstillinger",
|
||||
"command.session.previous": "Forrige session",
|
||||
"command.session.next": "Næste session",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.session.archive": "Arkivér session",
|
||||
|
||||
"command.palette": "Kommandopalette",
|
||||
@@ -68,6 +70,7 @@ export const dict = {
|
||||
"command.model.variant.cycle.description": "Skift til næste indsatsniveau",
|
||||
"command.permissions.autoaccept.enable": "Accepter ændringer automatisk",
|
||||
"command.permissions.autoaccept.disable": "Stop automatisk accept af ændringer",
|
||||
"command.workspace.toggle": "Skift arbejdsområder",
|
||||
"command.session.undo": "Fortryd",
|
||||
"command.session.undo.description": "Fortryd den sidste besked",
|
||||
"command.session.redo": "Omgør",
|
||||
@@ -347,6 +350,11 @@ export const dict = {
|
||||
"toast.permissions.autoaccept.off.title": "Stoppede automatisk accept af ændringer",
|
||||
"toast.permissions.autoaccept.off.description": "Redigerings- og skrivetilladelser vil kræve godkendelse",
|
||||
|
||||
"toast.workspace.enabled.title": "Arbejdsområder aktiveret",
|
||||
"toast.workspace.enabled.description": "Flere worktrees vises nu i sidepanelet",
|
||||
"toast.workspace.disabled.title": "Arbejdsområder deaktiveret",
|
||||
"toast.workspace.disabled.description": "Kun hoved-worktree vises i sidepanelet",
|
||||
|
||||
"toast.model.none.title": "Ingen model valgt",
|
||||
"toast.model.none.description": "Forbind en udbyder for at opsummere denne session",
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ export const dict = {
|
||||
"command.settings.open": "Einstellungen öffnen",
|
||||
"command.session.previous": "Vorherige Sitzung",
|
||||
"command.session.next": "Nächste Sitzung",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.session.archive": "Sitzung archivieren",
|
||||
|
||||
"command.palette": "Befehlspalette",
|
||||
@@ -72,6 +74,7 @@ export const dict = {
|
||||
"command.model.variant.cycle.description": "Zum nächsten Aufwandslevel wechseln",
|
||||
"command.permissions.autoaccept.enable": "Änderungen automatisch akzeptieren",
|
||||
"command.permissions.autoaccept.disable": "Automatische Annahme von Änderungen stoppen",
|
||||
"command.workspace.toggle": "Arbeitsbereiche umschalten",
|
||||
"command.session.undo": "Rückgängig",
|
||||
"command.session.undo.description": "Letzte Nachricht rückgängig machen",
|
||||
"command.session.redo": "Wiederherstellen",
|
||||
|
||||
@@ -28,6 +28,8 @@ export const dict = {
|
||||
"command.settings.open": "Open settings",
|
||||
"command.session.previous": "Previous session",
|
||||
"command.session.next": "Next session",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.session.archive": "Archive session",
|
||||
|
||||
"command.palette": "Command palette",
|
||||
@@ -43,6 +45,7 @@ export const dict = {
|
||||
"command.session.new": "New session",
|
||||
"command.file.open": "Open file",
|
||||
"command.file.open.description": "Search files and commands",
|
||||
"command.tab.close": "Close tab",
|
||||
"command.context.addSelection": "Add selection to context",
|
||||
"command.context.addSelection.description": "Add selected lines from the current file",
|
||||
"command.terminal.toggle": "Toggle terminal",
|
||||
@@ -68,6 +71,8 @@ export const dict = {
|
||||
"command.model.variant.cycle.description": "Switch to the next effort level",
|
||||
"command.permissions.autoaccept.enable": "Auto-accept edits",
|
||||
"command.permissions.autoaccept.disable": "Stop auto-accepting edits",
|
||||
"command.workspace.toggle": "Toggle workspaces",
|
||||
"command.workspace.toggle.description": "Enable or disable multiple workspaces in the sidebar",
|
||||
"command.session.undo": "Undo",
|
||||
"command.session.undo.description": "Undo the last message",
|
||||
"command.session.redo": "Redo",
|
||||
@@ -347,6 +352,11 @@ export const dict = {
|
||||
"toast.theme.title": "Theme switched",
|
||||
"toast.scheme.title": "Color scheme",
|
||||
|
||||
"toast.workspace.enabled.title": "Workspaces enabled",
|
||||
"toast.workspace.enabled.description": "Multiple worktrees are now shown in the sidebar",
|
||||
"toast.workspace.disabled.title": "Workspaces disabled",
|
||||
"toast.workspace.disabled.description": "Only the main worktree is shown in the sidebar",
|
||||
|
||||
"toast.permissions.autoaccept.on.title": "Auto-accepting edits",
|
||||
"toast.permissions.autoaccept.on.description": "Edit and write permissions will be automatically approved",
|
||||
"toast.permissions.autoaccept.off.title": "Stopped auto-accepting edits",
|
||||
|
||||
@@ -28,6 +28,8 @@ export const dict = {
|
||||
"command.settings.open": "Abrir ajustes",
|
||||
"command.session.previous": "Sesión anterior",
|
||||
"command.session.next": "Siguiente sesión",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.session.archive": "Archivar sesión",
|
||||
|
||||
"command.palette": "Paleta de comandos",
|
||||
@@ -68,6 +70,7 @@ export const dict = {
|
||||
"command.model.variant.cycle.description": "Cambiar al siguiente nivel de esfuerzo",
|
||||
"command.permissions.autoaccept.enable": "Aceptar ediciones automáticamente",
|
||||
"command.permissions.autoaccept.disable": "Dejar de aceptar ediciones automáticamente",
|
||||
"command.workspace.toggle": "Alternar espacios de trabajo",
|
||||
"command.session.undo": "Deshacer",
|
||||
"command.session.undo.description": "Deshacer el último mensaje",
|
||||
"command.session.redo": "Rehacer",
|
||||
@@ -348,6 +351,11 @@ export const dict = {
|
||||
"toast.permissions.autoaccept.off.title": "Se dejó de aceptar ediciones automáticamente",
|
||||
"toast.permissions.autoaccept.off.description": "Los permisos de edición y escritura requerirán aprobación",
|
||||
|
||||
"toast.workspace.enabled.title": "Espacios de trabajo habilitados",
|
||||
"toast.workspace.enabled.description": "Ahora se muestran varios worktrees en la barra lateral",
|
||||
"toast.workspace.disabled.title": "Espacios de trabajo deshabilitados",
|
||||
"toast.workspace.disabled.description": "Solo se muestra el worktree principal en la barra lateral",
|
||||
|
||||
"toast.model.none.title": "Ningún modelo seleccionado",
|
||||
"toast.model.none.description": "Conecta un proveedor para resumir esta sesión",
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ export const dict = {
|
||||
"command.settings.open": "Ouvrir les paramètres",
|
||||
"command.session.previous": "Session précédente",
|
||||
"command.session.next": "Session suivante",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.session.archive": "Archiver la session",
|
||||
|
||||
"command.palette": "Palette de commandes",
|
||||
@@ -68,6 +70,7 @@ export const dict = {
|
||||
"command.model.variant.cycle.description": "Passer au niveau d'effort suivant",
|
||||
"command.permissions.autoaccept.enable": "Accepter automatiquement les modifications",
|
||||
"command.permissions.autoaccept.disable": "Arrêter l'acceptation automatique des modifications",
|
||||
"command.workspace.toggle": "Basculer les espaces de travail",
|
||||
"command.session.undo": "Annuler",
|
||||
"command.session.undo.description": "Annuler le dernier message",
|
||||
"command.session.redo": "Rétablir",
|
||||
@@ -350,6 +353,11 @@ export const dict = {
|
||||
"toast.permissions.autoaccept.off.description":
|
||||
"Les permissions de modification et d'écriture nécessiteront une approbation",
|
||||
|
||||
"toast.workspace.enabled.title": "Espaces de travail activés",
|
||||
"toast.workspace.enabled.description": "Plusieurs worktrees sont désormais affichés dans la barre latérale",
|
||||
"toast.workspace.disabled.title": "Espaces de travail désactivés",
|
||||
"toast.workspace.disabled.description": "Seul le worktree principal est affiché dans la barre latérale",
|
||||
|
||||
"toast.model.none.title": "Aucun modèle sélectionné",
|
||||
"toast.model.none.description": "Connectez un fournisseur pour résumer cette session",
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ export const dict = {
|
||||
"command.settings.open": "設定を開く",
|
||||
"command.session.previous": "前のセッション",
|
||||
"command.session.next": "次のセッション",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.session.archive": "セッションをアーカイブ",
|
||||
|
||||
"command.palette": "コマンドパレット",
|
||||
@@ -68,6 +70,7 @@ export const dict = {
|
||||
"command.model.variant.cycle.description": "次の思考レベルに切り替え",
|
||||
"command.permissions.autoaccept.enable": "編集を自動承認",
|
||||
"command.permissions.autoaccept.disable": "編集の自動承認を停止",
|
||||
"command.workspace.toggle": "ワークスペースを切り替え",
|
||||
"command.session.undo": "元に戻す",
|
||||
"command.session.undo.description": "最後のメッセージを元に戻す",
|
||||
"command.session.redo": "やり直す",
|
||||
|
||||
@@ -32,6 +32,8 @@ export const dict = {
|
||||
"command.settings.open": "설정 열기",
|
||||
"command.session.previous": "이전 세션",
|
||||
"command.session.next": "다음 세션",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.session.archive": "세션 보관",
|
||||
|
||||
"command.palette": "명령 팔레트",
|
||||
@@ -72,6 +74,7 @@ export const dict = {
|
||||
"command.model.variant.cycle.description": "다음 생각 수준으로 전환",
|
||||
"command.permissions.autoaccept.enable": "편집 자동 수락",
|
||||
"command.permissions.autoaccept.disable": "편집 자동 수락 중지",
|
||||
"command.workspace.toggle": "작업 공간 전환",
|
||||
"command.session.undo": "실행 취소",
|
||||
"command.session.undo.description": "마지막 메시지 실행 취소",
|
||||
"command.session.redo": "다시 실행",
|
||||
@@ -349,6 +352,11 @@ export const dict = {
|
||||
"toast.permissions.autoaccept.off.title": "편집 자동 수락 중지됨",
|
||||
"toast.permissions.autoaccept.off.description": "편집 및 쓰기 권한 승인이 필요합니다",
|
||||
|
||||
"toast.workspace.enabled.title": "작업 공간 활성화됨",
|
||||
"toast.workspace.enabled.description": "이제 사이드바에 여러 작업 트리가 표시됩니다",
|
||||
"toast.workspace.disabled.title": "작업 공간 비활성화됨",
|
||||
"toast.workspace.disabled.description": "사이드바에 메인 작업 트리만 표시됩니다",
|
||||
|
||||
"toast.model.none.title": "선택된 모델 없음",
|
||||
"toast.model.none.description": "이 세션을 요약하려면 공급자를 연결하세요",
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ export const dict = {
|
||||
"command.settings.open": "Åpne innstillinger",
|
||||
"command.session.previous": "Forrige sesjon",
|
||||
"command.session.next": "Neste sesjon",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.session.archive": "Arkiver sesjon",
|
||||
|
||||
"command.palette": "Kommandopalett",
|
||||
@@ -71,6 +73,7 @@ export const dict = {
|
||||
"command.model.variant.cycle.description": "Bytt til neste innsatsnivå",
|
||||
"command.permissions.autoaccept.enable": "Godta endringer automatisk",
|
||||
"command.permissions.autoaccept.disable": "Slutt å godta endringer automatisk",
|
||||
"command.workspace.toggle": "Veksle arbeidsområder",
|
||||
"command.session.undo": "Angre",
|
||||
"command.session.undo.description": "Angre siste melding",
|
||||
"command.session.redo": "Gjør om",
|
||||
@@ -349,6 +352,11 @@ export const dict = {
|
||||
"toast.permissions.autoaccept.off.title": "Sluttet å godta endringer automatisk",
|
||||
"toast.permissions.autoaccept.off.description": "Redigerings- og skrivetillatelser vil kreve godkjenning",
|
||||
|
||||
"toast.workspace.enabled.title": "Arbeidsområder aktivert",
|
||||
"toast.workspace.enabled.description": "Flere worktrees vises nå i sidefeltet",
|
||||
"toast.workspace.disabled.title": "Arbeidsområder deaktivert",
|
||||
"toast.workspace.disabled.description": "Kun hoved-worktree vises i sidefeltet",
|
||||
|
||||
"toast.model.none.title": "Ingen modell valgt",
|
||||
"toast.model.none.description": "Koble til en leverandør for å oppsummere denne sesjonen",
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ export const dict = {
|
||||
"command.settings.open": "Otwórz ustawienia",
|
||||
"command.session.previous": "Poprzednia sesja",
|
||||
"command.session.next": "Następna sesja",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.session.archive": "Zarchiwizuj sesję",
|
||||
|
||||
"command.palette": "Paleta poleceń",
|
||||
@@ -68,6 +70,7 @@ export const dict = {
|
||||
"command.model.variant.cycle.description": "Przełącz na następny poziom wysiłku",
|
||||
"command.permissions.autoaccept.enable": "Automatyczne akceptowanie edycji",
|
||||
"command.permissions.autoaccept.disable": "Zatrzymaj automatyczne akceptowanie edycji",
|
||||
"command.workspace.toggle": "Przełącz przestrzenie robocze",
|
||||
"command.session.undo": "Cofnij",
|
||||
"command.session.undo.description": "Cofnij ostatnią wiadomość",
|
||||
"command.session.redo": "Ponów",
|
||||
@@ -347,6 +350,11 @@ export const dict = {
|
||||
"toast.permissions.autoaccept.off.title": "Zatrzymano automatyczne akceptowanie edycji",
|
||||
"toast.permissions.autoaccept.off.description": "Uprawnienia do edycji i zapisu będą wymagały zatwierdzenia",
|
||||
|
||||
"toast.workspace.enabled.title": "Przestrzenie robocze włączone",
|
||||
"toast.workspace.enabled.description": "Kilka worktree jest teraz wyświetlanych na pasku bocznym",
|
||||
"toast.workspace.disabled.title": "Przestrzenie robocze wyłączone",
|
||||
"toast.workspace.disabled.description": "Tylko główny worktree jest wyświetlany na pasku bocznym",
|
||||
|
||||
"toast.model.none.title": "Nie wybrano modelu",
|
||||
"toast.model.none.description": "Połącz dostawcę, aby podsumować tę sesję",
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ export const dict = {
|
||||
"command.settings.open": "Открыть настройки",
|
||||
"command.session.previous": "Предыдущая сессия",
|
||||
"command.session.next": "Следующая сессия",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.session.archive": "Архивировать сессию",
|
||||
|
||||
"command.palette": "Палитра команд",
|
||||
@@ -68,6 +70,7 @@ export const dict = {
|
||||
"command.model.variant.cycle.description": "Переключиться к следующему уровню усилий",
|
||||
"command.permissions.autoaccept.enable": "Авто-принятие изменений",
|
||||
"command.permissions.autoaccept.disable": "Прекратить авто-принятие изменений",
|
||||
"command.workspace.toggle": "Переключить рабочие пространства",
|
||||
"command.session.undo": "Отменить",
|
||||
"command.session.undo.description": "Отменить последнее сообщение",
|
||||
"command.session.redo": "Повторить",
|
||||
@@ -348,6 +351,11 @@ export const dict = {
|
||||
"toast.permissions.autoaccept.off.title": "Авто-принятие остановлено",
|
||||
"toast.permissions.autoaccept.off.description": "Редактирование и запись потребуют подтверждения",
|
||||
|
||||
"toast.workspace.enabled.title": "Рабочие пространства включены",
|
||||
"toast.workspace.enabled.description": "В боковой панели теперь отображаются несколько рабочих деревьев",
|
||||
"toast.workspace.disabled.title": "Рабочие пространства отключены",
|
||||
"toast.workspace.disabled.description": "В боковой панели отображается только главное рабочее дерево",
|
||||
|
||||
"toast.model.none.title": "Модель не выбрана",
|
||||
"toast.model.none.description": "Подключите провайдера для суммаризации сессии",
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ export const dict = {
|
||||
"command.settings.open": "เปิดการตั้งค่า",
|
||||
"command.session.previous": "เซสชันก่อนหน้า",
|
||||
"command.session.next": "เซสชันถัดไป",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.session.archive": "จัดเก็บเซสชัน",
|
||||
|
||||
"command.palette": "คำสั่งค้นหา",
|
||||
@@ -68,6 +70,7 @@ export const dict = {
|
||||
"command.model.variant.cycle.description": "สลับไปยังระดับความพยายามถัดไป",
|
||||
"command.permissions.autoaccept.enable": "ยอมรับการแก้ไขโดยอัตโนมัติ",
|
||||
"command.permissions.autoaccept.disable": "หยุดยอมรับการแก้ไขโดยอัตโนมัติ",
|
||||
"command.workspace.toggle": "สลับพื้นที่ทำงาน",
|
||||
"command.session.undo": "ยกเลิก",
|
||||
"command.session.undo.description": "ยกเลิกข้อความล่าสุด",
|
||||
"command.session.redo": "ทำซ้ำ",
|
||||
@@ -347,10 +350,15 @@ export const dict = {
|
||||
"toast.scheme.title": "โทนสี",
|
||||
|
||||
"toast.permissions.autoaccept.on.title": "กำลังยอมรับการแก้ไขโดยอัตโนมัติ",
|
||||
"toast.permissions.autoaccept.on.description": "สิทธิ์การแก้ไขและเขียนจะได้รับการอนุมัติโดยอัตโนมัติ",
|
||||
"toast.permissions.autoaccept.on.description": "สิทธิ์การแก้ไขและจะได้รับเขียนการอนุมัติโดยอัตโนมัติ",
|
||||
"toast.permissions.autoaccept.off.title": "หยุดยอมรับการแก้ไขโดยอัตโนมัติ",
|
||||
"toast.permissions.autoaccept.off.description": "สิทธิ์การแก้ไขและเขียนจะต้องได้รับการอนุมัติ",
|
||||
|
||||
"toast.workspace.enabled.title": "เปิดใช้งานพื้นที่ทำงานแล้ว",
|
||||
"toast.workspace.enabled.description": "ตอนนี้จะแสดง worktree หลายรายการในแถบด้านข้าง",
|
||||
"toast.workspace.disabled.title": "ปิดใช้งานพื้นที่ทำงานแล้ว",
|
||||
"toast.workspace.disabled.description": "จะแสดงเฉพาะ worktree หลักในแถบด้านข้าง",
|
||||
|
||||
"toast.model.none.title": "ไม่ได้เลือกโมเดล",
|
||||
"toast.model.none.description": "เชื่อมต่อผู้ให้บริการเพื่อสรุปเซสชันนี้",
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ export const dict = {
|
||||
"command.settings.open": "打开设置",
|
||||
"command.session.previous": "上一个会话",
|
||||
"command.session.next": "下一个会话",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.session.archive": "归档会话",
|
||||
|
||||
"command.palette": "命令面板",
|
||||
@@ -72,6 +74,7 @@ export const dict = {
|
||||
"command.model.variant.cycle.description": "切换到下一个强度等级",
|
||||
"command.permissions.autoaccept.enable": "自动接受编辑",
|
||||
"command.permissions.autoaccept.disable": "停止自动接受编辑",
|
||||
"command.workspace.toggle": "切换工作区",
|
||||
"command.session.undo": "撤销",
|
||||
"command.session.undo.description": "撤销上一条消息",
|
||||
"command.session.redo": "重做",
|
||||
@@ -342,7 +345,12 @@ export const dict = {
|
||||
"toast.language.description": "已切换到{{language}}",
|
||||
|
||||
"toast.theme.title": "主题已切换",
|
||||
"toast.scheme.title": "配色方案",
|
||||
"toast.scheme.title": "颜色方案",
|
||||
|
||||
"toast.workspace.enabled.title": "工作区已启用",
|
||||
"toast.workspace.enabled.description": "侧边栏现在显示多个工作树",
|
||||
"toast.workspace.disabled.title": "工作区已禁用",
|
||||
"toast.workspace.disabled.description": "侧边栏只显示主工作树",
|
||||
|
||||
"toast.permissions.autoaccept.on.title": "自动接受编辑",
|
||||
"toast.permissions.autoaccept.on.description": "编辑和写入权限将自动获批",
|
||||
|
||||
@@ -32,6 +32,8 @@ export const dict = {
|
||||
"command.settings.open": "開啟設定",
|
||||
"command.session.previous": "上一個工作階段",
|
||||
"command.session.next": "下一個工作階段",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.session.archive": "封存工作階段",
|
||||
|
||||
"command.palette": "命令面板",
|
||||
@@ -72,6 +74,7 @@ export const dict = {
|
||||
"command.model.variant.cycle.description": "切換到下一個強度等級",
|
||||
"command.permissions.autoaccept.enable": "自動接受編輯",
|
||||
"command.permissions.autoaccept.disable": "停止自動接受編輯",
|
||||
"command.workspace.toggle": "切換工作區",
|
||||
"command.session.undo": "復原",
|
||||
"command.session.undo.description": "復原上一則訊息",
|
||||
"command.session.redo": "重做",
|
||||
@@ -339,7 +342,12 @@ export const dict = {
|
||||
"toast.language.description": "已切換到 {{language}}",
|
||||
|
||||
"toast.theme.title": "主題已切換",
|
||||
"toast.scheme.title": "配色方案",
|
||||
"toast.scheme.title": "顏色方案",
|
||||
|
||||
"toast.workspace.enabled.title": "工作區已啟用",
|
||||
"toast.workspace.enabled.description": "側邊欄現在顯示多個工作樹",
|
||||
"toast.workspace.disabled.title": "工作區已停用",
|
||||
"toast.workspace.disabled.description": "側邊欄只顯示主工作樹",
|
||||
|
||||
"toast.permissions.autoaccept.on.title": "自動接受編輯",
|
||||
"toast.permissions.autoaccept.on.description": "編輯和寫入權限將自動獲准",
|
||||
|
||||
@@ -27,10 +27,12 @@ import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { List, type ListRef } from "@opencode-ai/ui/list"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { HoverCard } from "@opencode-ai/ui/hover-card"
|
||||
import { MessageNav } from "@opencode-ai/ui/message-nav"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { ContextMenu } from "@opencode-ai/ui/context-menu"
|
||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
@@ -108,7 +110,7 @@ export default function Layout(props: ParentProps) {
|
||||
const command = useCommand()
|
||||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
const initialDir = params.dir
|
||||
const initialDirectory = decode64(params.dir)
|
||||
const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
|
||||
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
|
||||
const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
|
||||
@@ -119,7 +121,7 @@ export default function Layout(props: ParentProps) {
|
||||
const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
|
||||
|
||||
const [state, setState] = createStore({
|
||||
autoselect: !params.dir,
|
||||
autoselect: !initialDirectory,
|
||||
busyWorkspaces: new Set<string>(),
|
||||
hoverSession: undefined as string | undefined,
|
||||
hoverProject: undefined as string | undefined,
|
||||
@@ -179,13 +181,21 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
const autoselecting = createMemo(() => {
|
||||
if (params.dir) return false
|
||||
if (initialDir) return false
|
||||
if (!state.autoselect) return false
|
||||
if (!pageReady()) return true
|
||||
if (!layoutReady()) return true
|
||||
const list = layout.projects.list()
|
||||
if (list.length === 0) return false
|
||||
return true
|
||||
if (list.length > 0) return true
|
||||
return !!server.projects.last()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!state.autoselect) return
|
||||
const dir = params.dir
|
||||
if (!dir) return
|
||||
const directory = decode64(dir)
|
||||
if (!directory) return
|
||||
setState("autoselect", false)
|
||||
})
|
||||
|
||||
const editorOpen = (id: string) => editor.active === id
|
||||
@@ -498,7 +508,7 @@ export default function Layout(props: ParentProps) {
|
||||
const bUpdated = b.time.updated ?? b.time.created
|
||||
const aRecent = aUpdated > oneMinuteAgo
|
||||
const bRecent = bUpdated > oneMinuteAgo
|
||||
if (aRecent && bRecent) return a.id.localeCompare(b.id)
|
||||
if (aRecent && bRecent) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
|
||||
if (aRecent && !bRecent) return -1
|
||||
if (!aRecent && bRecent) return 1
|
||||
return bUpdated - aUpdated
|
||||
@@ -565,11 +575,18 @@ export default function Layout(props: ParentProps) {
|
||||
if (!value.ready) return
|
||||
if (!value.layoutReady) return
|
||||
if (!state.autoselect) return
|
||||
if (initialDir) return
|
||||
if (value.dir) return
|
||||
if (value.list.length === 0) return
|
||||
|
||||
const last = server.projects.last()
|
||||
|
||||
if (value.list.length === 0) {
|
||||
if (!last) return
|
||||
setState("autoselect", false)
|
||||
openProject(last, false)
|
||||
navigateToProject(last)
|
||||
return
|
||||
}
|
||||
|
||||
const next = value.list.find((project) => project.worktree === last) ?? value.list[0]
|
||||
if (!next) return
|
||||
setState("autoselect", false)
|
||||
@@ -738,7 +755,7 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
|
||||
async function prefetchMessages(directory: string, sessionID: string, token: number) {
|
||||
const [, setStore] = globalSync.child(directory, { bootstrap: false })
|
||||
const [store, setStore] = globalSync.child(directory, { bootstrap: false })
|
||||
|
||||
return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
|
||||
.then((messages) => {
|
||||
@@ -749,23 +766,49 @@ export default function Layout(props: ParentProps) {
|
||||
.map((x) => x.info)
|
||||
.filter((m) => !!m?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||
|
||||
const current = store.message[sessionID] ?? []
|
||||
const merged = (() => {
|
||||
if (current.length === 0) return next
|
||||
|
||||
const map = new Map<string, Message>()
|
||||
for (const item of current) {
|
||||
if (!item?.id) continue
|
||||
map.set(item.id, item)
|
||||
}
|
||||
for (const item of next) {
|
||||
map.set(item.id, item)
|
||||
}
|
||||
return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||
})()
|
||||
|
||||
batch(() => {
|
||||
setStore("message", sessionID, reconcile(next, { key: "id" }))
|
||||
setStore("message", sessionID, reconcile(merged, { key: "id" }))
|
||||
|
||||
for (const message of items) {
|
||||
setStore(
|
||||
"part",
|
||||
message.info.id,
|
||||
reconcile(
|
||||
message.parts
|
||||
const currentParts = store.part[message.info.id] ?? []
|
||||
const mergedParts = (() => {
|
||||
if (currentParts.length === 0) {
|
||||
return message.parts
|
||||
.filter((p) => !!p?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||
}
|
||||
|
||||
const map = new Map<string, (typeof currentParts)[number]>()
|
||||
for (const item of currentParts) {
|
||||
if (!item?.id) continue
|
||||
map.set(item.id, item)
|
||||
}
|
||||
for (const item of message.parts) {
|
||||
if (!item?.id) continue
|
||||
map.set(item.id, item)
|
||||
}
|
||||
return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||
})()
|
||||
|
||||
setStore("part", message.info.id, reconcile(mergedParts, { key: "id" }))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -886,6 +929,52 @@ export default function Layout(props: ParentProps) {
|
||||
queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
|
||||
}
|
||||
|
||||
function navigateSessionByUnseen(offset: number) {
|
||||
const sessions = currentSessions()
|
||||
if (sessions.length === 0) return
|
||||
|
||||
const hasUnseen = sessions.some((session) => notification.session.unseen(session.id).length > 0)
|
||||
if (!hasUnseen) return
|
||||
|
||||
const activeIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1
|
||||
const start = activeIndex === -1 ? (offset > 0 ? -1 : 0) : activeIndex
|
||||
|
||||
for (let i = 1; i <= sessions.length; i++) {
|
||||
const index = offset > 0 ? (start + i) % sessions.length : (start - i + sessions.length) % sessions.length
|
||||
const session = sessions[index]
|
||||
if (!session) continue
|
||||
if (notification.session.unseen(session.id).length === 0) continue
|
||||
|
||||
prefetchSession(session, "high")
|
||||
|
||||
const next = sessions[(index + 1) % sessions.length]
|
||||
const prev = sessions[(index - 1 + sessions.length) % sessions.length]
|
||||
|
||||
if (offset > 0) {
|
||||
if (next) prefetchSession(next, "high")
|
||||
if (prev) prefetchSession(prev)
|
||||
}
|
||||
|
||||
if (offset < 0) {
|
||||
if (prev) prefetchSession(prev, "high")
|
||||
if (next) prefetchSession(next)
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
navStart({
|
||||
dir: base64Encode(session.directory),
|
||||
from: params.id,
|
||||
to: session.id,
|
||||
trigger: offset > 0 ? "shift+alt+arrowdown" : "shift+alt+arrowup",
|
||||
})
|
||||
}
|
||||
|
||||
navigateToSession(session)
|
||||
queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveSession(session: Session) {
|
||||
const [store, setStore] = globalSync.child(session.directory)
|
||||
const sessions = store.session ?? []
|
||||
@@ -1024,6 +1113,20 @@ export default function Layout(props: ParentProps) {
|
||||
keybind: "alt+arrowdown",
|
||||
onSelect: () => navigateSessionByOffset(1),
|
||||
},
|
||||
{
|
||||
id: "session.previous.unseen",
|
||||
title: language.t("command.session.previous.unseen"),
|
||||
category: language.t("command.category.session"),
|
||||
keybind: "shift+alt+arrowup",
|
||||
onSelect: () => navigateSessionByUnseen(-1),
|
||||
},
|
||||
{
|
||||
id: "session.next.unseen",
|
||||
title: language.t("command.session.next.unseen"),
|
||||
category: language.t("command.category.session"),
|
||||
keybind: "shift+alt+arrowdown",
|
||||
onSelect: () => navigateSessionByUnseen(1),
|
||||
},
|
||||
{
|
||||
id: "session.archive",
|
||||
title: language.t("command.session.archive"),
|
||||
@@ -1035,6 +1138,29 @@ export default function Layout(props: ParentProps) {
|
||||
if (session) archiveSession(session)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace.toggle",
|
||||
title: language.t("command.workspace.toggle"),
|
||||
description: language.t("command.workspace.toggle.description"),
|
||||
category: language.t("command.category.workspace"),
|
||||
slash: "workspace",
|
||||
disabled: !currentProject() || currentProject()?.vcs !== "git",
|
||||
onSelect: () => {
|
||||
const project = currentProject()
|
||||
if (!project) return
|
||||
if (project.vcs !== "git") return
|
||||
const wasEnabled = layout.sidebar.workspaces(project.worktree)()
|
||||
layout.sidebar.toggleWorkspaces(project.worktree)
|
||||
showToast({
|
||||
title: wasEnabled
|
||||
? language.t("toast.workspace.disabled.title")
|
||||
: language.t("toast.workspace.enabled.title"),
|
||||
description: wasEnabled
|
||||
? language.t("toast.workspace.disabled.description")
|
||||
: language.t("toast.workspace.enabled.description"),
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "theme.cycle",
|
||||
title: language.t("command.theme.cycle"),
|
||||
@@ -2250,10 +2376,13 @@ export default function Layout(props: ParentProps) {
|
||||
() => props.project.vcs === "git" && layout.sidebar.workspaces(props.project.worktree)(),
|
||||
)
|
||||
const [open, setOpen] = createSignal(false)
|
||||
const [menu, setMenu] = createSignal(false)
|
||||
|
||||
const preview = createMemo(() => !props.mobile && layout.sidebar.opened())
|
||||
const overlay = createMemo(() => !props.mobile && !layout.sidebar.opened())
|
||||
const active = createMemo(() => (preview() ? open() : overlay() && state.hoverProject === props.project.worktree))
|
||||
const active = createMemo(
|
||||
() => menu() || (preview() ? open() : overlay() && state.hoverProject === props.project.worktree),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (preview()) return
|
||||
@@ -2291,50 +2420,95 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
|
||||
const projectName = () => props.project.name || getFilename(props.project.worktree)
|
||||
const trigger = (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={projectName()}
|
||||
data-action="project-switch"
|
||||
data-project={base64Encode(props.project.worktree)}
|
||||
classList={{
|
||||
"flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
|
||||
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(),
|
||||
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
|
||||
!selected() && !active(),
|
||||
"bg-surface-base-hover border border-border-weak-base": !selected() && active(),
|
||||
const Trigger = () => (
|
||||
<ContextMenu
|
||||
modal={!sidebarHovering()}
|
||||
onOpenChange={(value) => {
|
||||
setMenu(value)
|
||||
if (value) setOpen(false)
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (!overlay()) return
|
||||
globalSync.child(props.project.worktree)
|
||||
setState("hoverProject", props.project.worktree)
|
||||
setState("hoverSession", undefined)
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!overlay()) return
|
||||
globalSync.child(props.project.worktree)
|
||||
setState("hoverProject", props.project.worktree)
|
||||
setState("hoverSession", undefined)
|
||||
}}
|
||||
onClick={() => navigateToProject(props.project.worktree)}
|
||||
onBlur={() => setOpen(false)}
|
||||
>
|
||||
<ProjectIcon project={props.project} notify />
|
||||
</button>
|
||||
<ContextMenu.Trigger
|
||||
as="button"
|
||||
type="button"
|
||||
aria-label={projectName()}
|
||||
data-action="project-switch"
|
||||
data-project={base64Encode(props.project.worktree)}
|
||||
classList={{
|
||||
"flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
|
||||
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(),
|
||||
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
|
||||
!selected() && !active(),
|
||||
"bg-surface-base-hover border border-border-weak-base": !selected() && active(),
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (!overlay()) return
|
||||
globalSync.child(props.project.worktree)
|
||||
setState("hoverProject", props.project.worktree)
|
||||
setState("hoverSession", undefined)
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!overlay()) return
|
||||
globalSync.child(props.project.worktree)
|
||||
setState("hoverProject", props.project.worktree)
|
||||
setState("hoverSession", undefined)
|
||||
}}
|
||||
onClick={() => navigateToProject(props.project.worktree)}
|
||||
onBlur={() => setOpen(false)}
|
||||
>
|
||||
<ProjectIcon project={props.project} notify />
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal mount={!props.mobile ? state.nav : undefined}>
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={props.project} />)}>
|
||||
<ContextMenu.ItemLabel>{language.t("common.edit")}</ContextMenu.ItemLabel>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item
|
||||
data-action="project-workspaces-toggle"
|
||||
data-project={base64Encode(props.project.worktree)}
|
||||
disabled={props.project.vcs !== "git" && !layout.sidebar.workspaces(props.project.worktree)()}
|
||||
onSelect={() => {
|
||||
const enabled = layout.sidebar.workspaces(props.project.worktree)()
|
||||
if (enabled) {
|
||||
layout.sidebar.toggleWorkspaces(props.project.worktree)
|
||||
return
|
||||
}
|
||||
if (props.project.vcs !== "git") return
|
||||
layout.sidebar.toggleWorkspaces(props.project.worktree)
|
||||
}}
|
||||
>
|
||||
<ContextMenu.ItemLabel>
|
||||
{layout.sidebar.workspaces(props.project.worktree)()
|
||||
? language.t("sidebar.workspaces.disable")
|
||||
: language.t("sidebar.workspaces.enable")}
|
||||
</ContextMenu.ItemLabel>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item
|
||||
data-action="project-close-menu"
|
||||
data-project={base64Encode(props.project.worktree)}
|
||||
onSelect={() => closeProject(props.project.worktree)}
|
||||
>
|
||||
<ContextMenu.ItemLabel>{language.t("common.close")}</ContextMenu.ItemLabel>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu>
|
||||
)
|
||||
|
||||
return (
|
||||
// @ts-ignore
|
||||
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
||||
<Show when={preview()} fallback={trigger}>
|
||||
<Show when={preview()} fallback={<Trigger />}>
|
||||
<HoverCard
|
||||
open={open()}
|
||||
open={open() && !menu()}
|
||||
openDelay={0}
|
||||
closeDelay={0}
|
||||
placement="right-start"
|
||||
gutter={6}
|
||||
trigger={trigger}
|
||||
trigger={<Trigger />}
|
||||
onOpenChange={(value) => {
|
||||
if (menu()) return
|
||||
setOpen(value)
|
||||
if (value) setState("hoverSession", undefined)
|
||||
}}
|
||||
@@ -2532,6 +2706,14 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
|
||||
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => {
|
||||
type SearchItem = {
|
||||
id: string
|
||||
title: string
|
||||
directory: string
|
||||
label: string
|
||||
archived?: number
|
||||
}
|
||||
|
||||
const projectName = createMemo(() => {
|
||||
const project = panelProps.project
|
||||
if (!project) return ""
|
||||
@@ -2547,6 +2729,107 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
const homedir = createMemo(() => globalSync.data.path.home)
|
||||
|
||||
const [search, setSearch] = createStore({
|
||||
value: "",
|
||||
})
|
||||
const searching = createMemo(() => search.value.trim().length > 0)
|
||||
let searchRef: HTMLInputElement | undefined
|
||||
let listRef: ListRef | undefined
|
||||
|
||||
const token = { value: 0 }
|
||||
let inflight: Promise<SearchItem[]> | undefined
|
||||
let all: SearchItem[] | undefined
|
||||
|
||||
const reset = () => {
|
||||
token.value += 1
|
||||
inflight = undefined
|
||||
all = undefined
|
||||
setSearch({ value: "" })
|
||||
listRef = undefined
|
||||
}
|
||||
|
||||
const open = (item: SearchItem | undefined) => {
|
||||
if (!item) return
|
||||
|
||||
const href = `/${base64Encode(item.directory)}/session/${item.id}`
|
||||
if (!layout.sidebar.opened()) {
|
||||
setState("hoverSession", undefined)
|
||||
setState("hoverProject", undefined)
|
||||
}
|
||||
reset()
|
||||
navigate(href)
|
||||
layout.mobileSidebar.hide()
|
||||
}
|
||||
|
||||
const items = (filter: string) => {
|
||||
const query = filter.trim()
|
||||
if (!query) {
|
||||
token.value += 1
|
||||
inflight = undefined
|
||||
all = undefined
|
||||
return [] as SearchItem[]
|
||||
}
|
||||
|
||||
const project = panelProps.project
|
||||
if (!project) return [] as SearchItem[]
|
||||
if (all) return all
|
||||
if (inflight) return inflight
|
||||
|
||||
const current = token.value
|
||||
const dirs = workspaceIds(project)
|
||||
inflight = Promise.all(
|
||||
dirs.map((input) => {
|
||||
const directory = workspaceKey(input)
|
||||
const [workspaceStore] = globalSync.child(directory, { bootstrap: false })
|
||||
const kind =
|
||||
directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
|
||||
const name = workspaceLabel(directory, workspaceStore.vcs?.branch, project.id)
|
||||
const label = `${kind} : ${name}`
|
||||
return globalSDK.client.session
|
||||
.list({ directory, roots: true })
|
||||
.then((x) =>
|
||||
(x.data ?? [])
|
||||
.filter((s) => !!s?.id)
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
title: s.title ?? language.t("command.session.new"),
|
||||
directory,
|
||||
label,
|
||||
archived: s.time?.archived,
|
||||
})),
|
||||
)
|
||||
.catch(() => [] as SearchItem[])
|
||||
}),
|
||||
)
|
||||
.then((results) => {
|
||||
if (token.value !== current) return [] as SearchItem[]
|
||||
|
||||
const seen = new Set<string>()
|
||||
const next = results.flat().filter((item) => {
|
||||
const key = `${item.directory}:${item.id}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
all = next
|
||||
return next
|
||||
})
|
||||
.catch(() => [] as SearchItem[])
|
||||
.finally(() => {
|
||||
inflight = undefined
|
||||
})
|
||||
|
||||
return inflight
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => panelProps.project?.worktree,
|
||||
() => reset(),
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
classList={{
|
||||
@@ -2555,7 +2838,7 @@ export default function Layout(props: ParentProps) {
|
||||
}}
|
||||
style={{ width: panelProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }}
|
||||
>
|
||||
<Show when={panelProps.project} keyed>
|
||||
<Show when={panelProps.project}>
|
||||
{(p) => (
|
||||
<>
|
||||
<div class="shrink-0 px-2 py-1">
|
||||
@@ -2564,7 +2847,7 @@ export default function Layout(props: ParentProps) {
|
||||
<InlineEditor
|
||||
id={`project:${projectId()}`}
|
||||
value={projectName}
|
||||
onSave={(next) => renameProject(p, next)}
|
||||
onSave={(next) => renameProject(p(), next)}
|
||||
class="text-16-medium text-text-strong truncate"
|
||||
displayClass="text-16-medium text-text-strong truncate"
|
||||
stopPropagation
|
||||
@@ -2573,7 +2856,7 @@ export default function Layout(props: ParentProps) {
|
||||
<Tooltip
|
||||
placement="bottom"
|
||||
gutter={2}
|
||||
value={p.worktree}
|
||||
value={p().worktree}
|
||||
class="shrink-0"
|
||||
contentStyle={{
|
||||
"max-width": "640px",
|
||||
@@ -2581,7 +2864,7 @@ export default function Layout(props: ParentProps) {
|
||||
}}
|
||||
>
|
||||
<span class="text-12-regular text-text-base truncate select-text">
|
||||
{p.worktree.replace(homedir(), "~")}
|
||||
{p().worktree.replace(homedir(), "~")}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -2592,31 +2875,31 @@ export default function Layout(props: ParentProps) {
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
data-action="project-menu"
|
||||
data-project={base64Encode(p.worktree)}
|
||||
data-project={base64Encode(p().worktree)}
|
||||
class="shrink-0 size-6 rounded-md opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100 data-[expanded]:bg-surface-base-active"
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
<DropdownMenu.Portal mount={!panelProps.mobile ? state.nav : undefined}>
|
||||
<DropdownMenu.Content class="mt-1">
|
||||
<DropdownMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={p} />)}>
|
||||
<DropdownMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={p()} />)}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
data-action="project-workspaces-toggle"
|
||||
data-project={base64Encode(p.worktree)}
|
||||
disabled={p.vcs !== "git" && !layout.sidebar.workspaces(p.worktree)()}
|
||||
data-project={base64Encode(p().worktree)}
|
||||
disabled={p().vcs !== "git" && !layout.sidebar.workspaces(p().worktree)()}
|
||||
onSelect={() => {
|
||||
const enabled = layout.sidebar.workspaces(p.worktree)()
|
||||
const enabled = layout.sidebar.workspaces(p().worktree)()
|
||||
if (enabled) {
|
||||
layout.sidebar.toggleWorkspaces(p.worktree)
|
||||
layout.sidebar.toggleWorkspaces(p().worktree)
|
||||
return
|
||||
}
|
||||
if (p.vcs !== "git") return
|
||||
layout.sidebar.toggleWorkspaces(p.worktree)
|
||||
if (p().vcs !== "git") return
|
||||
layout.sidebar.toggleWorkspaces(p().worktree)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{layout.sidebar.workspaces(p.worktree)()
|
||||
{layout.sidebar.workspaces(p().worktree)()
|
||||
? language.t("sidebar.workspaces.disable")
|
||||
: language.t("sidebar.workspaces.enable")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
@@ -2624,8 +2907,8 @@ export default function Layout(props: ParentProps) {
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
data-action="project-close-menu"
|
||||
data-project={base64Encode(p.worktree)}
|
||||
onSelect={() => closeProject(p.worktree)}
|
||||
data-project={base64Encode(p().worktree)}
|
||||
onSelect={() => closeProject(p().worktree)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
@@ -2635,103 +2918,207 @@ export default function Layout(props: ParentProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={workspacesEnabled()}
|
||||
fallback={
|
||||
<div class="shrink-0 px-2 pt-2">
|
||||
<div
|
||||
class="flex items-center gap-2 p-2 rounded-md bg-surface-base shadow-xs-border-base focus-within:shadow-xs-border-select"
|
||||
onPointerDown={(event) => {
|
||||
const target = event.target
|
||||
if (!(target instanceof Element)) return
|
||||
if (target.closest("input, textarea, [contenteditable='true']")) return
|
||||
searchRef?.focus()
|
||||
}}
|
||||
>
|
||||
<Icon name="magnifying-glass" />
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
searchRef = el
|
||||
}}
|
||||
class="flex-1 min-w-0 text-14-regular text-text-strong placeholder:text-text-weak"
|
||||
style={{ "box-shadow": "none" }}
|
||||
value={search.value}
|
||||
onInput={(event) => setSearch("value", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
setSearch("value", "")
|
||||
queueMicrotask(() => searchRef?.focus())
|
||||
return
|
||||
}
|
||||
|
||||
if (!searching()) return
|
||||
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter") {
|
||||
const ref = listRef
|
||||
if (!ref) return
|
||||
event.stopPropagation()
|
||||
ref.onKeyDown(event)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
|
||||
if (event.key === "n" || event.key === "p") {
|
||||
const ref = listRef
|
||||
if (!ref) return
|
||||
event.stopPropagation()
|
||||
ref.onKeyDown(event)
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder={language.t("session.header.search.placeholder", { project: projectName() })}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
/>
|
||||
<Show when={search.value}>
|
||||
<IconButton
|
||||
icon="circle-x"
|
||||
variant="ghost"
|
||||
class="size-5"
|
||||
aria-label={language.t("common.close")}
|
||||
onClick={() => {
|
||||
setSearch("value", "")
|
||||
queueMicrotask(() => searchRef?.focus())
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={searching()}>
|
||||
<List
|
||||
class="flex-1 min-h-0 pb-2 pt-2 !px-2 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0"
|
||||
items={items}
|
||||
filter={search.value}
|
||||
filterKeys={["title", "label", "id"]}
|
||||
key={(item) => `${item.directory}:${item.id}`}
|
||||
onSelect={open}
|
||||
ref={(ref) => {
|
||||
listRef = ref
|
||||
}}
|
||||
>
|
||||
{(item) => (
|
||||
<div class="flex flex-col gap-0.5 min-w-0 pr-2 text-left">
|
||||
<span
|
||||
class="text-14-medium text-text-strong truncate"
|
||||
classList={{ "opacity-70": !!item.archived }}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
<span
|
||||
class="text-12-regular text-text-weak truncate"
|
||||
classList={{ "opacity-70": !!item.archived }}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
</Show>
|
||||
|
||||
<div class="flex-1 min-h-0 flex flex-col" classList={{ hidden: searching() }}>
|
||||
<Show
|
||||
when={workspacesEnabled()}
|
||||
fallback={
|
||||
<>
|
||||
<div class="shrink-0 py-4 px-3">
|
||||
<TooltipKeybind
|
||||
title={language.t("command.session.new")}
|
||||
keybind={command.keybind("session.new")}
|
||||
placement="top"
|
||||
>
|
||||
<Button
|
||||
size="large"
|
||||
icon="plus-small"
|
||||
class="w-full"
|
||||
onClick={() => {
|
||||
if (!layout.sidebar.opened()) {
|
||||
setState("hoverSession", undefined)
|
||||
setState("hoverProject", undefined)
|
||||
}
|
||||
navigate(`/${base64Encode(p().worktree)}/session`)
|
||||
layout.mobileSidebar.hide()
|
||||
}}
|
||||
>
|
||||
{language.t("command.session.new")}
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0">
|
||||
<LocalWorkspace project={p()} mobile={panelProps.mobile} />
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<>
|
||||
<div class="py-4 px-3">
|
||||
<div class="shrink-0 py-4 px-3">
|
||||
<TooltipKeybind
|
||||
title={language.t("command.session.new")}
|
||||
keybind={command.keybind("session.new")}
|
||||
title={language.t("workspace.new")}
|
||||
keybind={command.keybind("workspace.new")}
|
||||
placement="top"
|
||||
>
|
||||
<Button
|
||||
size="large"
|
||||
icon="plus-small"
|
||||
class="w-full"
|
||||
onClick={() => {
|
||||
if (!layout.sidebar.opened()) {
|
||||
setState("hoverSession", undefined)
|
||||
setState("hoverProject", undefined)
|
||||
}
|
||||
navigate(`/${base64Encode(p.worktree)}/session`)
|
||||
layout.mobileSidebar.hide()
|
||||
}}
|
||||
>
|
||||
{language.t("command.session.new")}
|
||||
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p())}>
|
||||
{language.t("workspace.new")}
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0">
|
||||
<LocalWorkspace project={p} mobile={panelProps.mobile} />
|
||||
<div class="relative flex-1 min-h-0">
|
||||
<DragDropProvider
|
||||
onDragStart={handleWorkspaceDragStart}
|
||||
onDragEnd={handleWorkspaceDragEnd}
|
||||
onDragOver={handleWorkspaceDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragXAxis />
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (!panelProps.mobile) scrollContainerRef = el
|
||||
}}
|
||||
class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
|
||||
>
|
||||
<SortableProvider ids={workspaces()}>
|
||||
<For each={workspaces()}>
|
||||
{(directory) => (
|
||||
<SortableWorkspace directory={directory} project={p()} mobile={panelProps.mobile} />
|
||||
)}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
</div>
|
||||
<DragOverlay>
|
||||
<WorkspaceDragOverlay />
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<>
|
||||
<div class="py-4 px-3">
|
||||
<TooltipKeybind
|
||||
title={language.t("workspace.new")}
|
||||
keybind={command.keybind("workspace.new")}
|
||||
placement="top"
|
||||
>
|
||||
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p)}>
|
||||
{language.t("workspace.new")}
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<div class="relative flex-1 min-h-0">
|
||||
<DragDropProvider
|
||||
onDragStart={handleWorkspaceDragStart}
|
||||
onDragEnd={handleWorkspaceDragEnd}
|
||||
onDragOver={handleWorkspaceDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragXAxis />
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (!panelProps.mobile) scrollContainerRef = el
|
||||
}}
|
||||
class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
|
||||
>
|
||||
<SortableProvider ids={workspaces()}>
|
||||
<For each={workspaces()}>
|
||||
{(directory) => (
|
||||
<SortableWorkspace directory={directory} project={p} mobile={panelProps.mobile} />
|
||||
)}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
</div>
|
||||
<DragOverlay>
|
||||
<WorkspaceDragOverlay />
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
</div>
|
||||
</>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={providers.all().length > 0 && providers.paid().length === 0}>
|
||||
<div class="shrink-0 px-2 py-3 border-t border-border-weak-base">
|
||||
<div class="rounded-md bg-background-base shadow-xs-border-base">
|
||||
<div class="p-3 flex flex-col gap-2">
|
||||
<div class="text-12-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
|
||||
<div class="text-text-base">{language.t("sidebar.gettingStarted.line1")}</div>
|
||||
<div class="text-text-base">{language.t("sidebar.gettingStarted.line2")}</div>
|
||||
</div>
|
||||
<Button
|
||||
class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-md rounded-t-none shadow-none border-t border-border-weak-base px-3"
|
||||
size="large"
|
||||
icon="plus"
|
||||
onClick={connectProvider}
|
||||
>
|
||||
{language.t("command.provider.connect")}
|
||||
</Button>
|
||||
|
||||
<div
|
||||
class="shrink-0 px-2 py-3 border-t border-border-weak-base"
|
||||
classList={{
|
||||
hidden: searching() || !(providers.all().length > 0 && providers.paid().length === 0),
|
||||
}}
|
||||
>
|
||||
<div class="rounded-md bg-background-base shadow-xs-border-base">
|
||||
<div class="p-3 flex flex-col gap-2">
|
||||
<div class="text-12-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
|
||||
<div class="text-text-base">{language.t("sidebar.gettingStarted.line1")}</div>
|
||||
<div class="text-text-base">{language.t("sidebar.gettingStarted.line2")}</div>
|
||||
</div>
|
||||
<Button
|
||||
class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-md rounded-t-none shadow-none border-t border-border-weak-base px-3"
|
||||
size="large"
|
||||
icon="plus"
|
||||
onClick={connectProvider}
|
||||
>
|
||||
{language.t("command.provider.connect")}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -500,9 +500,7 @@ export default function Page() {
|
||||
const out = new Map<string, "add" | "del" | "mix">()
|
||||
for (const diff of diffs()) {
|
||||
const file = normalize(diff.file)
|
||||
const add = diff.additions > 0
|
||||
const del = diff.deletions > 0
|
||||
const kind = add && del ? "mix" : add ? "add" : del ? "del" : "mix"
|
||||
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
|
||||
|
||||
out.set(file, kind)
|
||||
|
||||
@@ -689,6 +687,18 @@ export default function Page() {
|
||||
slash: "open",
|
||||
onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={() => showAllFiles()} />),
|
||||
},
|
||||
{
|
||||
id: "tab.close",
|
||||
title: language.t("command.tab.close"),
|
||||
category: language.t("command.category.file"),
|
||||
keybind: "mod+w",
|
||||
disabled: !tabs().active(),
|
||||
onSelect: () => {
|
||||
const active = tabs().active()
|
||||
if (!active) return
|
||||
tabs().close(active)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "context.addSelection",
|
||||
title: language.t("command.context.addSelection"),
|
||||
@@ -1940,7 +1950,8 @@ export default function Page() {
|
||||
"sticky top-0 z-30 bg-background-stronger": true,
|
||||
"w-full": true,
|
||||
"px-4 md:px-6": true,
|
||||
"md:max-w-200 md:mx-auto": centered(),
|
||||
"md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 3xl:mx-auto 4xl:max-w-[1600px] 4xl:mx-auto 5xl:max-w-[1900px] 5xl:mx-auto":
|
||||
centered(),
|
||||
}}
|
||||
>
|
||||
<div class="h-10 flex items-center gap-1">
|
||||
@@ -1968,7 +1979,8 @@ export default function Page() {
|
||||
class="flex flex-col gap-32 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]"
|
||||
classList={{
|
||||
"w-full": true,
|
||||
"md:max-w-200 md:mx-auto": centered(),
|
||||
"md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 3xl:mx-auto 4xl:max-w-[1600px] 4xl:mx-auto 5xl:max-w-[1900px] 5xl:mx-auto":
|
||||
centered(),
|
||||
"mt-0.5": centered(),
|
||||
"mt-0": !centered(),
|
||||
}}
|
||||
@@ -2021,7 +2033,7 @@ export default function Page() {
|
||||
data-message-id={message.id}
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-200": centered(),
|
||||
"md:max-w-200 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]": centered(),
|
||||
}}
|
||||
>
|
||||
<SessionTurn
|
||||
@@ -2078,7 +2090,7 @@ export default function Page() {
|
||||
<div
|
||||
classList={{
|
||||
"w-full px-4 pointer-events-auto": true,
|
||||
"md:max-w-200 md:mx-auto": centered(),
|
||||
"md:max-w-200 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]": centered(),
|
||||
}}
|
||||
>
|
||||
<Show when={request()} keyed>
|
||||
|
||||
@@ -9,8 +9,8 @@ export const config = {
|
||||
github: {
|
||||
repoUrl: "https://github.com/anomalyco/opencode",
|
||||
starsFormatted: {
|
||||
compact: "80K",
|
||||
full: "80,000",
|
||||
compact: "95K",
|
||||
full: "95,000",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -22,8 +22,8 @@ export const config = {
|
||||
|
||||
// Static stats (used on landing page)
|
||||
stats: {
|
||||
contributors: "600",
|
||||
commits: "7,500",
|
||||
monthlyUsers: "1.5M",
|
||||
contributors: "650",
|
||||
commits: "8,500",
|
||||
monthlyUsers: "2.5M",
|
||||
},
|
||||
} as const
|
||||
|
||||
@@ -2,13 +2,17 @@ import { Database, eq, and, sql, inArray } from "@opencode-ai/console-core/drizz
|
||||
import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
|
||||
import { RateLimitError } from "./error"
|
||||
import { logger } from "./logger"
|
||||
import { ZenData } from "@opencode-ai/console-core/model.js"
|
||||
|
||||
export function createRateLimiter(limit: number | undefined, rawIp: string) {
|
||||
export function createRateLimiter(limit: ZenData.RateLimit | undefined, rawIp: string) {
|
||||
if (!limit) return
|
||||
|
||||
const ip = !rawIp.length ? "unknown" : rawIp
|
||||
const now = Date.now()
|
||||
const intervals = [buildYYYYMMDDHH(now), buildYYYYMMDDHH(now - 3_600_000), buildYYYYMMDDHH(now - 7_200_000)]
|
||||
const intervals =
|
||||
limit.period === "day"
|
||||
? [buildYYYYMMDD(now)]
|
||||
: [buildYYYYMMDDHH(now), buildYYYYMMDDHH(now - 3_600_000), buildYYYYMMDDHH(now - 7_200_000)]
|
||||
|
||||
return {
|
||||
track: async () => {
|
||||
@@ -28,11 +32,18 @@ export function createRateLimiter(limit: number | undefined, rawIp: string) {
|
||||
)
|
||||
const total = rows.reduce((sum, r) => sum + r.count, 0)
|
||||
logger.debug(`rate limit total: ${total}`)
|
||||
if (total >= limit) throw new RateLimitError(`Rate limit exceeded. Please try again later.`)
|
||||
if (total >= limit.value) throw new RateLimitError(`Rate limit exceeded. Please try again later.`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function buildYYYYMMDD(timestamp: number) {
|
||||
return new Date(timestamp)
|
||||
.toISOString()
|
||||
.replace(/[^0-9]/g, "")
|
||||
.substring(0, 8)
|
||||
}
|
||||
|
||||
function buildYYYYMMDDHH(timestamp: number) {
|
||||
return new Date(timestamp)
|
||||
.toISOString()
|
||||
|
||||
@@ -18,8 +18,13 @@ export namespace ZenData {
|
||||
}),
|
||||
),
|
||||
})
|
||||
const RateLimitSchema = z.object({
|
||||
period: z.enum(["day", "rolling"]),
|
||||
value: z.number().int(),
|
||||
})
|
||||
export type Format = z.infer<typeof FormatSchema>
|
||||
export type Trial = z.infer<typeof TrialSchema>
|
||||
export type RateLimit = z.infer<typeof RateLimitSchema>
|
||||
|
||||
const ModelCostSchema = z.object({
|
||||
input: z.number(),
|
||||
@@ -37,7 +42,7 @@ export namespace ZenData {
|
||||
byokProvider: z.enum(["openai", "anthropic", "google"]).optional(),
|
||||
stickyProvider: z.enum(["strict", "prefer"]).optional(),
|
||||
trial: TrialSchema.optional(),
|
||||
rateLimit: z.number().optional(),
|
||||
rateLimit: RateLimitSchema.optional(),
|
||||
fallbackProvider: z.string().optional(),
|
||||
providers: z.array(
|
||||
z.object({
|
||||
|
||||
247
packages/desktop/src-tauri/Cargo.lock
generated
247
packages/desktop/src-tauri/Cargo.lock
generated
@@ -2,6 +2,12 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "Inflector"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3"
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
@@ -1994,9 +2000,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ico"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98"
|
||||
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"png 0.17.16",
|
||||
@@ -3065,12 +3071,14 @@ dependencies = [
|
||||
"listeners",
|
||||
"objc2 0.6.3",
|
||||
"objc2-web-kit",
|
||||
"reqwest",
|
||||
"reqwest 0.12.24",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"specta-typescript",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-build 2.5.2",
|
||||
"tauri-plugin-clipboard-manager",
|
||||
"tauri-plugin-decorum",
|
||||
"tauri-plugin-deep-link",
|
||||
@@ -3085,6 +3093,7 @@ dependencies = [
|
||||
"tauri-plugin-store",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-window-state",
|
||||
"tauri-specta",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"webkit2gtk",
|
||||
@@ -3221,6 +3230,12 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pathdiff"
|
||||
version = "0.2.3"
|
||||
@@ -3947,6 +3962,40 @@ dependencies = [
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.15.4"
|
||||
@@ -4497,6 +4546,44 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "specta"
|
||||
version = "2.0.0-rc.22"
|
||||
source = "git+https://github.com/specta-rs/specta?rev=106425eac4964d8ff34d3a02f1612e33117b08bb#106425eac4964d8ff34d3a02f1612e33117b08bb"
|
||||
dependencies = [
|
||||
"paste",
|
||||
"rustc_version",
|
||||
"specta-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "specta-macros"
|
||||
version = "2.0.0-rc.18"
|
||||
source = "git+https://github.com/specta-rs/specta?rev=106425eac4964d8ff34d3a02f1612e33117b08bb#106425eac4964d8ff34d3a02f1612e33117b08bb"
|
||||
dependencies = [
|
||||
"Inflector",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "specta-serde"
|
||||
version = "0.0.9"
|
||||
source = "git+https://github.com/specta-rs/specta?rev=106425eac4964d8ff34d3a02f1612e33117b08bb#106425eac4964d8ff34d3a02f1612e33117b08bb"
|
||||
dependencies = [
|
||||
"specta",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "specta-typescript"
|
||||
version = "0.0.9"
|
||||
source = "git+https://github.com/specta-rs/specta?rev=106425eac4964d8ff34d3a02f1612e33117b08bb#106425eac4964d8ff34d3a02f1612e33117b08bb"
|
||||
dependencies = [
|
||||
"specta",
|
||||
"specta-serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@@ -4712,9 +4799,8 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
||||
|
||||
[[package]]
|
||||
name = "tauri"
|
||||
version = "2.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e492485dd390b35f7497401f67694f46161a2a00ffd800938d5dd3c898fb9d8"
|
||||
version = "2.9.5"
|
||||
source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -4740,17 +4826,18 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"plist",
|
||||
"raw-window-handle",
|
||||
"reqwest",
|
||||
"reqwest 0.13.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"serialize-to-javascript",
|
||||
"specta",
|
||||
"swift-rs",
|
||||
"tauri-build",
|
||||
"tauri-build 2.5.3",
|
||||
"tauri-macros",
|
||||
"tauri-runtime",
|
||||
"tauri-runtime-wry",
|
||||
"tauri-utils",
|
||||
"tauri-utils 2.8.1",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tray-icon",
|
||||
@@ -4777,7 +4864,28 @@ dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri-utils",
|
||||
"tauri-utils 2.8.0",
|
||||
"tauri-winres",
|
||||
"toml 0.9.8",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-build"
|
||||
version = "2.5.3"
|
||||
source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cargo_toml",
|
||||
"dirs",
|
||||
"glob",
|
||||
"heck 0.5.0",
|
||||
"json-patch",
|
||||
"schemars 0.8.22",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri-utils 2.8.1",
|
||||
"tauri-winres",
|
||||
"toml 0.9.8",
|
||||
"walkdir",
|
||||
@@ -4785,9 +4893,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-codegen"
|
||||
version = "2.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7ef707148f0755110ca54377560ab891d722de4d53297595380a748026f139f"
|
||||
version = "2.5.2"
|
||||
source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"brotli",
|
||||
@@ -4802,7 +4909,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"syn 2.0.110",
|
||||
"tauri-utils",
|
||||
"tauri-utils 2.8.1",
|
||||
"thiserror 2.0.17",
|
||||
"time",
|
||||
"url",
|
||||
@@ -4812,16 +4919,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-macros"
|
||||
version = "2.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71664fd715ee6e382c05345ad258d6d1d50f90cf1b58c0aa726638b33c2a075d"
|
||||
version = "2.5.2"
|
||||
source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
"tauri-codegen",
|
||||
"tauri-utils",
|
||||
"tauri-utils 2.8.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4836,7 +4942,7 @@ dependencies = [
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri-utils",
|
||||
"tauri-utils 2.8.0",
|
||||
"toml 0.9.8",
|
||||
"walkdir",
|
||||
]
|
||||
@@ -4886,7 +4992,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"tauri-utils 2.8.0",
|
||||
"thiserror 2.0.17",
|
||||
"tracing",
|
||||
"url",
|
||||
@@ -4928,7 +5034,7 @@ dependencies = [
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"tauri-utils 2.8.0",
|
||||
"thiserror 2.0.17",
|
||||
"toml 0.9.8",
|
||||
"url",
|
||||
@@ -4945,7 +5051,7 @@ dependencies = [
|
||||
"data-url",
|
||||
"http",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"reqwest 0.12.24",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5096,7 +5202,7 @@ dependencies = [
|
||||
"minisign-verify",
|
||||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"reqwest 0.12.24",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5129,9 +5235,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9368f09358496f2229313fccb37682ad116b7f46fa76981efe116994a0628926"
|
||||
version = "2.9.2"
|
||||
source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee"
|
||||
dependencies = [
|
||||
"cookie",
|
||||
"dpi",
|
||||
@@ -5144,7 +5249,7 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri-utils",
|
||||
"tauri-utils 2.8.1",
|
||||
"thiserror 2.0.17",
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
@@ -5154,9 +5259,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime-wry"
|
||||
version = "2.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "929f5df216f5c02a9e894554401bcdab6eec3e39ec6a4a7731c7067fc8688a93"
|
||||
version = "2.9.3"
|
||||
source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
@@ -5171,7 +5275,7 @@ dependencies = [
|
||||
"softbuffer",
|
||||
"tao",
|
||||
"tauri-runtime",
|
||||
"tauri-utils",
|
||||
"tauri-utils 2.8.1",
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
@@ -5179,11 +5283,74 @@ dependencies = [
|
||||
"wry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-specta"
|
||||
version = "2.0.0-rc.21"
|
||||
source = "git+https://github.com/specta-rs/tauri-specta?rev=6720b2848eff9a3e40af54c48d65f6d56b640c0b#6720b2848eff9a3e40af54c48d65f6d56b640c0b"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"specta-typescript",
|
||||
"tauri",
|
||||
"tauri-specta-macros",
|
||||
"thiserror 2.0.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-specta-macros"
|
||||
version = "2.0.0-rc.16"
|
||||
source = "git+https://github.com/specta-rs/tauri-specta?rev=6720b2848eff9a3e40af54c48d65f6d56b640c0b#6720b2848eff9a3e40af54c48d65f6d56b640c0b"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-utils"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6b8bbe426abdbf52d050e52ed693130dbd68375b9ad82a3fb17efb4c8d85673"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cargo_metadata",
|
||||
"ctor",
|
||||
"dunce",
|
||||
"glob",
|
||||
"html5ever",
|
||||
"http",
|
||||
"infer",
|
||||
"json-patch",
|
||||
"kuchikiki",
|
||||
"log",
|
||||
"memchr",
|
||||
"phf 0.11.3",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"schemars 0.8.22",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde-untagged",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
"swift-rs",
|
||||
"thiserror 2.0.17",
|
||||
"toml 0.9.8",
|
||||
"url",
|
||||
"urlpattern",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-utils"
|
||||
version = "2.8.1"
|
||||
source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"brotli",
|
||||
@@ -5547,9 +5714,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.6.6"
|
||||
version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
|
||||
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"bytes",
|
||||
@@ -6034,9 +6201,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webkit2gtk"
|
||||
version = "2.0.1"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a"
|
||||
checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"cairo-rs",
|
||||
@@ -6058,9 +6225,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webkit2gtk-sys"
|
||||
version = "2.0.1"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c"
|
||||
checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"cairo-sys-rs",
|
||||
@@ -6719,9 +6886,9 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "wry"
|
||||
version = "0.53.5"
|
||||
version = "0.54.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "728b7d4c8ec8d81cab295e0b5b8a4c263c0d41a785fb8f8c4df284e5411140a2"
|
||||
checksum = "5ed1a195b0375491dd15a7066a10251be217ce743cf4bbbbdcf5391d6473bee0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"block2 0.6.2",
|
||||
|
||||
@@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["macos-private-api", "devtools"] }
|
||||
tauri = { version = "2.9.5", features = ["macos-private-api", "devtools"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-deep-link = "2.4.6"
|
||||
tauri-plugin-shell = "2"
|
||||
@@ -43,10 +43,13 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"
|
||||
uuid = { version = "1.19.0", features = ["v4"] }
|
||||
tauri-plugin-decorum = "1.1.1"
|
||||
comrak = { version = "0.50", default-features = false }
|
||||
specta = "=2.0.0-rc.22"
|
||||
specta-typescript = "0.0.9"
|
||||
tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gtk = "0.18.2"
|
||||
webkit2gtk = "=2.0.1"
|
||||
webkit2gtk = "=2.0.2"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.6"
|
||||
@@ -59,3 +62,10 @@ windows = { version = "0.61", features = [
|
||||
"Win32_System_Threading",
|
||||
"Win32_Security"
|
||||
] }
|
||||
|
||||
[patch.crates-io]
|
||||
specta = { git = "https://github.com/specta-rs/specta", rev = "106425eac4964d8ff34d3a02f1612e33117b08bb" }
|
||||
specta-typescript = { git = "https://github.com/specta-rs/specta", rev = "106425eac4964d8ff34d3a02f1612e33117b08bb" }
|
||||
tauri-specta = { git = "https://github.com/specta-rs/tauri-specta", rev = "6720b2848eff9a3e40af54c48d65f6d56b640c0b" }
|
||||
# TODO: https://github.com/tauri-apps/tauri/pull/14812
|
||||
tauri = { git = "https://github.com/tauri-apps/tauri", rev = "4d5d78daf636feaac20c5bc48a6071491c4291ee" }
|
||||
|
||||
@@ -51,6 +51,7 @@ fn is_cli_installed() -> bool {
|
||||
const INSTALL_SCRIPT: &str = include_str!("../../../../install");
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn install_cli(app: tauri::AppHandle) -> Result<String, String> {
|
||||
if cfg!(not(unix)) {
|
||||
return Err("CLI installation is only supported on macOS & Linux".to_string());
|
||||
|
||||
@@ -16,21 +16,26 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tauri::{AppHandle, LogicalSize, Manager, RunEvent, State, WebviewWindowBuilder};
|
||||
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
#[cfg(windows)]
|
||||
use tauri_plugin_decorum::WebviewWindowExt;
|
||||
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
|
||||
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use tokio::sync::oneshot;
|
||||
use tauri_plugin_window_state::{AppHandleExt, StateFlags};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
|
||||
use crate::window_customizer::PinchZoomDisablePlugin;
|
||||
|
||||
const SETTINGS_STORE: &str = "opencode.settings.dat";
|
||||
const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl";
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
fn window_state_flags() -> StateFlags {
|
||||
StateFlags::all() - StateFlags::DECORATIONS
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize, specta::Type)]
|
||||
struct ServerReadyData {
|
||||
url: String,
|
||||
password: Option<String>,
|
||||
@@ -64,6 +69,7 @@ struct LogState(Arc<Mutex<VecDeque<String>>>);
|
||||
const MAX_LOG_ENTRIES: usize = 200;
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
fn kill_sidecar(app: AppHandle) {
|
||||
let Some(server_state) = app.try_state::<ServerState>() else {
|
||||
println!("Server not running");
|
||||
@@ -97,6 +103,7 @@ async fn get_logs(app: AppHandle) -> Result<String, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn ensure_server_ready(state: State<'_, ServerState>) -> Result<ServerReadyData, String> {
|
||||
state
|
||||
.status
|
||||
@@ -106,6 +113,7 @@ async fn ensure_server_ready(state: State<'_, ServerState>) -> Result<ServerRead
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String> {
|
||||
let store = app
|
||||
.store(SETTINGS_STORE)
|
||||
@@ -119,6 +127,7 @@ fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn set_default_server_url(app: AppHandle, url: Option<String>) -> Result<(), String> {
|
||||
let store = app
|
||||
.store(SETTINGS_STORE)
|
||||
@@ -252,6 +261,26 @@ async fn check_server_health(url: &str, password: Option<&str>) -> bool {
|
||||
pub fn run() {
|
||||
let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
|
||||
|
||||
let builder = tauri_specta::Builder::<tauri::Wry>::new()
|
||||
// Then register them (separated by a comma)
|
||||
.commands(tauri_specta::collect_commands![
|
||||
kill_sidecar,
|
||||
install_cli,
|
||||
ensure_server_ready,
|
||||
get_default_server_url,
|
||||
set_default_server_url,
|
||||
markdown::parse_markdown_command
|
||||
])
|
||||
.error_handling(tauri_specta::ErrorHandlingMode::Throw);
|
||||
|
||||
#[cfg(debug_assertions)] // <- Only export on non-release builds
|
||||
builder
|
||||
.export(
|
||||
specta_typescript::Typescript::default(),
|
||||
"../src/bindings.ts",
|
||||
)
|
||||
.expect("Failed to export typescript bindings");
|
||||
|
||||
#[cfg(all(target_os = "macos", not(debug_assertions)))]
|
||||
let _ = std::process::Command::new("killall")
|
||||
.arg("opencode-cli")
|
||||
@@ -269,10 +298,7 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_os::init())
|
||||
.plugin(
|
||||
tauri_plugin_window_state::Builder::new()
|
||||
.with_state_flags(
|
||||
tauri_plugin_window_state::StateFlags::all()
|
||||
- tauri_plugin_window_state::StateFlags::DECORATIONS,
|
||||
)
|
||||
.with_state_flags(window_state_flags())
|
||||
.build(),
|
||||
)
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
@@ -285,15 +311,10 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(PinchZoomDisablePlugin)
|
||||
.plugin(tauri_plugin_decorum::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
kill_sidecar,
|
||||
install_cli,
|
||||
ensure_server_ready,
|
||||
get_default_server_url,
|
||||
set_default_server_url,
|
||||
markdown::parse_markdown_command
|
||||
])
|
||||
.invoke_handler(builder.invoke_handler())
|
||||
.setup(move |app| {
|
||||
builder.mount_events(app);
|
||||
|
||||
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
|
||||
app.deep_link().register_all().ok();
|
||||
|
||||
@@ -346,6 +367,8 @@ pub fn run() {
|
||||
|
||||
let window = window_builder.build().expect("Failed to create window");
|
||||
|
||||
setup_window_state_listener(&app, &window);
|
||||
|
||||
#[cfg(windows)]
|
||||
let _ = window.create_overlay_titlebar();
|
||||
|
||||
@@ -526,6 +549,7 @@ async fn spawn_local_server(
|
||||
let timestamp = Instant::now();
|
||||
loop {
|
||||
if timestamp.elapsed() > Duration::from_secs(30) {
|
||||
let _ = child.kill();
|
||||
break Err(format!(
|
||||
"Failed to spawn OpenCode Server. Logs:\n{}",
|
||||
get_logs(app.clone()).await.unwrap()
|
||||
@@ -540,3 +564,35 @@ async fn spawn_local_server(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_window_state_listener(app: &tauri::AppHandle, window: &tauri::WebviewWindow) {
|
||||
let (tx, mut rx) = mpsc::channel::<()>(1);
|
||||
|
||||
window.on_window_event(move |event| {
|
||||
use tauri::WindowEvent;
|
||||
if !matches!(event, WindowEvent::Moved(_) | WindowEvent::Resized(_)) {
|
||||
return;
|
||||
}
|
||||
let _ = tx.try_send(());
|
||||
});
|
||||
|
||||
tauri::async_runtime::spawn({
|
||||
let app = app.clone();
|
||||
|
||||
async move {
|
||||
let save = || {
|
||||
let handle = app.clone();
|
||||
let app = app.clone();
|
||||
let _ = handle.run_on_main_thread(move || {
|
||||
let _ = app.save_window_state(window_state_flags());
|
||||
});
|
||||
};
|
||||
|
||||
while rx.recv().await.is_some() {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
save();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use comrak::{create_formatter, parse_document, Arena, Options, html::ChildRendering, nodes::NodeValue};
|
||||
use comrak::{
|
||||
Arena, Options, create_formatter, html::ChildRendering, nodes::NodeValue, parse_document,
|
||||
};
|
||||
use std::fmt::Write;
|
||||
|
||||
create_formatter!(ExternalLinkFormatter, {
|
||||
@@ -55,6 +57,7 @@ pub fn parse_markdown(input: &str) -> String {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn parse_markdown_command(markdown: String) -> Result<String, String> {
|
||||
Ok(parse_markdown(&markdown))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use tauri::{plugin::Plugin, Manager, Runtime, Window};
|
||||
use tauri::{Manager, Runtime, Window, plugin::Plugin};
|
||||
|
||||
pub struct PinchZoomDisablePlugin;
|
||||
|
||||
@@ -21,8 +21,8 @@ impl<R: Runtime> Plugin<R> for PinchZoomDisablePlugin {
|
||||
let _ = webview_window.with_webview(|_webview| {
|
||||
#[cfg(target_os = "linux")]
|
||||
unsafe {
|
||||
use gtk::glib::ObjectExt;
|
||||
use gtk::GestureZoom;
|
||||
use gtk::glib::ObjectExt;
|
||||
use webkit2gtk::glib::gobject_ffi;
|
||||
|
||||
if let Some(data) = _webview.inner().data::<GestureZoom>("wk-view-zoom-gesture") {
|
||||
|
||||
19
packages/desktop/src/bindings.ts
Normal file
19
packages/desktop/src/bindings.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// This file has been generated by Tauri Specta. Do not edit this file manually.
|
||||
|
||||
import { invoke as __TAURI_INVOKE, Channel } from "@tauri-apps/api/core"
|
||||
|
||||
/** Commands */
|
||||
export const commands = {
|
||||
killSidecar: () => __TAURI_INVOKE<void>("kill_sidecar"),
|
||||
installCli: () => __TAURI_INVOKE<string>("install_cli"),
|
||||
ensureServerReady: () => __TAURI_INVOKE<ServerReadyData>("ensure_server_ready"),
|
||||
getDefaultServerUrl: () => __TAURI_INVOKE<string | null>("get_default_server_url"),
|
||||
setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE<null>("set_default_server_url", { url }),
|
||||
parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE<string>("parse_markdown_command", { markdown }),
|
||||
}
|
||||
|
||||
/* Types */
|
||||
export type ServerReadyData = {
|
||||
url: string
|
||||
password: string | null
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { message } from "@tauri-apps/plugin-dialog"
|
||||
|
||||
import { initI18n, t } from "./i18n"
|
||||
import { commands } from "./bindings"
|
||||
|
||||
export async function installCli(): Promise<void> {
|
||||
await initI18n()
|
||||
|
||||
try {
|
||||
const path = await invoke<string>("install_cli")
|
||||
const path = await commands.installCli()
|
||||
await message(t("desktop.cli.installed.message", { path }), { title: t("desktop.cli.installed.title") })
|
||||
} catch (e) {
|
||||
await message(t("desktop.cli.failed.message", { error: String(e) }), { title: t("desktop.cli.failed.title") })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @refresh reload
|
||||
import "./webview-zoom"
|
||||
import { webviewZoom } from "./webview-zoom"
|
||||
import { render } from "solid-js/web"
|
||||
import { AppBaseProviders, AppInterface, PlatformProvider, Platform } from "@opencode-ai/app"
|
||||
import { open, save } from "@tauri-apps/plugin-dialog"
|
||||
@@ -7,7 +7,6 @@ import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"
|
||||
import { open as shellOpen } from "@tauri-apps/plugin-shell"
|
||||
import { type as ostype } from "@tauri-apps/plugin-os"
|
||||
import { check, Update } from "@tauri-apps/plugin-updater"
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window"
|
||||
import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
|
||||
import { relaunch } from "@tauri-apps/plugin-process"
|
||||
@@ -22,6 +21,7 @@ import { createMenu } from "./menu"
|
||||
import { initI18n, t } from "./i18n"
|
||||
import pkg from "../package.json"
|
||||
import "./styles.css"
|
||||
import { commands } from "./bindings"
|
||||
|
||||
const root = document.getElementById("root")
|
||||
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
||||
@@ -274,12 +274,12 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
|
||||
|
||||
update: async () => {
|
||||
if (!UPDATER_ENABLED || !update) return
|
||||
if (ostype() === "windows") await invoke("kill_sidecar").catch(() => undefined)
|
||||
if (ostype() === "windows") await commands.killSidecar().catch(() => undefined)
|
||||
await update.install().catch(() => undefined)
|
||||
},
|
||||
|
||||
restart: async () => {
|
||||
await invoke("kill_sidecar").catch(() => undefined)
|
||||
await commands.killSidecar().catch(() => undefined)
|
||||
await relaunch()
|
||||
},
|
||||
|
||||
@@ -335,17 +335,17 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
|
||||
},
|
||||
|
||||
getDefaultServerUrl: async () => {
|
||||
const result = await invoke<string | null>("get_default_server_url").catch(() => null)
|
||||
const result = await commands.getDefaultServerUrl().catch(() => null)
|
||||
return result
|
||||
},
|
||||
|
||||
setDefaultServerUrl: async (url: string | null) => {
|
||||
await invoke("set_default_server_url", { url })
|
||||
await commands.setDefaultServerUrl(url)
|
||||
},
|
||||
|
||||
parseMarkdown: async (markdown: string) => {
|
||||
return invoke<string>("parse_markdown_command", { markdown })
|
||||
},
|
||||
parseMarkdown: (markdown: string) => commands.parseMarkdownCommand(markdown),
|
||||
|
||||
webviewZoom,
|
||||
})
|
||||
|
||||
createMenu()
|
||||
@@ -391,11 +391,7 @@ type ServerReadyData = { url: string; password: string | null }
|
||||
|
||||
// Gate component that waits for the server to be ready
|
||||
function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.Element }) {
|
||||
const [serverData] = createResource<ServerReadyData>(() =>
|
||||
invoke("ensure_server_ready").then((v) => {
|
||||
return new Promise((res) => setTimeout(() => res(v as ServerReadyData), 2000))
|
||||
}),
|
||||
)
|
||||
const [serverData] = createResource(() => commands.ensureServerReady())
|
||||
|
||||
const errorMessage = () => {
|
||||
const error = serverData.error
|
||||
@@ -406,7 +402,7 @@ function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.
|
||||
}
|
||||
|
||||
const restartApp = async () => {
|
||||
await invoke("kill_sidecar").catch(() => undefined)
|
||||
await commands.killSidecar().catch(() => undefined)
|
||||
await relaunch().catch(() => undefined)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Menu, MenuItem, PredefinedMenuItem, Submenu } from "@tauri-apps/api/menu"
|
||||
import { type as ostype } from "@tauri-apps/plugin-os"
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { relaunch } from "@tauri-apps/plugin-process"
|
||||
|
||||
import { runUpdater, UPDATER_ENABLED } from "./updater"
|
||||
import { installCli } from "./cli"
|
||||
import { initI18n, t } from "./i18n"
|
||||
import { commands } from "./bindings"
|
||||
|
||||
export async function createMenu() {
|
||||
if (ostype() !== "macos") return
|
||||
@@ -35,7 +35,7 @@ export async function createMenu() {
|
||||
}),
|
||||
await MenuItem.new({
|
||||
action: async () => {
|
||||
await invoke("kill_sidecar").catch(() => undefined)
|
||||
await commands.killSidecar().catch(() => undefined)
|
||||
await relaunch().catch(() => undefined)
|
||||
},
|
||||
text: t("desktop.menu.restart"),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { check } from "@tauri-apps/plugin-updater"
|
||||
import { relaunch } from "@tauri-apps/plugin-process"
|
||||
import { ask, message } from "@tauri-apps/plugin-dialog"
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { type as ostype } from "@tauri-apps/plugin-os"
|
||||
|
||||
import { initI18n, t } from "./i18n"
|
||||
import { commands } from "./bindings"
|
||||
|
||||
export const UPDATER_ENABLED = window.__OPENCODE__?.updaterEnabled ?? false
|
||||
|
||||
@@ -39,13 +39,13 @@ export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) {
|
||||
if (!shouldUpdate) return
|
||||
|
||||
try {
|
||||
if (ostype() === "windows") await invoke("kill_sidecar")
|
||||
if (ostype() === "windows") await commands.killSidecar()
|
||||
await update.install()
|
||||
} catch {
|
||||
await message(t("desktop.updater.installFailed.message"), { title: t("desktop.updater.installFailed.title") })
|
||||
return
|
||||
}
|
||||
|
||||
await invoke("kill_sidecar")
|
||||
await commands.killSidecar()
|
||||
await relaunch()
|
||||
}
|
||||
|
||||
@@ -4,28 +4,34 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { type as ostype } from "@tauri-apps/plugin-os"
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
const OS_NAME = ostype()
|
||||
|
||||
let zoomLevel = 1
|
||||
const [webviewZoom, setWebviewZoom] = createSignal(1)
|
||||
|
||||
const MAX_ZOOM_LEVEL = 10
|
||||
const MIN_ZOOM_LEVEL = 0.2
|
||||
|
||||
const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL)
|
||||
|
||||
const applyZoom = (next: number) => {
|
||||
setWebviewZoom(next)
|
||||
invoke("plugin:webview|set_webview_zoom", {
|
||||
value: next,
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (OS_NAME === "macos" ? event.metaKey : event.ctrlKey) {
|
||||
if (event.key === "-") {
|
||||
zoomLevel -= 0.2
|
||||
} else if (event.key === "=" || event.key === "+") {
|
||||
zoomLevel += 0.2
|
||||
} else if (event.key === "0") {
|
||||
zoomLevel = 1
|
||||
} else {
|
||||
return
|
||||
}
|
||||
zoomLevel = Math.min(Math.max(zoomLevel, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL)
|
||||
invoke("plugin:webview|set_webview_zoom", {
|
||||
value: zoomLevel,
|
||||
})
|
||||
}
|
||||
if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return
|
||||
|
||||
let newZoom = webviewZoom()
|
||||
|
||||
if (event.key === "-") newZoom -= 0.2
|
||||
if (event.key === "=" || event.key === "+") newZoom += 0.2
|
||||
if (event.key === "0") newZoom = 1
|
||||
|
||||
applyZoom(clamp(newZoom))
|
||||
})
|
||||
|
||||
export { webviewZoom }
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
"@ai-sdk/vercel": "1.0.33",
|
||||
"@ai-sdk/xai": "2.0.56",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@gitlab/gitlab-ai-provider": "3.3.1",
|
||||
"@gitlab/gitlab-ai-provider": "3.4.0",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
@@ -82,8 +82,8 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||
"@opentui/core": "0.1.75",
|
||||
"@opentui/solid": "0.1.75",
|
||||
"@opentui/core": "0.1.77",
|
||||
"@opentui/solid": "0.1.77",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
|
||||
@@ -55,7 +55,6 @@ export namespace Agent {
|
||||
doom_loop: "ask",
|
||||
external_directory: {
|
||||
"*": "ask",
|
||||
[Truncate.DIR]: "allow",
|
||||
[Truncate.GLOB]: "allow",
|
||||
},
|
||||
question: "deny",
|
||||
@@ -140,7 +139,6 @@ export namespace Agent {
|
||||
codesearch: "allow",
|
||||
read: "allow",
|
||||
external_directory: {
|
||||
[Truncate.DIR]: "allow",
|
||||
[Truncate.GLOB]: "allow",
|
||||
},
|
||||
}),
|
||||
@@ -229,19 +227,19 @@ export namespace Agent {
|
||||
item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {}))
|
||||
}
|
||||
|
||||
// Ensure Truncate.DIR is allowed unless explicitly configured
|
||||
// Ensure Truncate.GLOB is allowed unless explicitly configured
|
||||
for (const name in result) {
|
||||
const agent = result[name]
|
||||
const explicit = agent.permission.some((r) => {
|
||||
if (r.permission !== "external_directory") return false
|
||||
if (r.action !== "deny") return false
|
||||
return r.pattern === Truncate.DIR || r.pattern === Truncate.GLOB
|
||||
return r.pattern === Truncate.GLOB
|
||||
})
|
||||
if (explicit) continue
|
||||
|
||||
result[name].permission = PermissionNext.merge(
|
||||
result[name].permission,
|
||||
PermissionNext.fromConfig({ external_directory: { [Truncate.DIR]: "allow", [Truncate.GLOB]: "allow" } }),
|
||||
PermissionNext.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,10 @@ import path from "path"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { readableStreamToText } from "bun"
|
||||
import { createRequire } from "module"
|
||||
import { Lock } from "../util/lock"
|
||||
|
||||
export namespace BunProc {
|
||||
const log = Log.create({ service: "bun" })
|
||||
const req = createRequire(import.meta.url)
|
||||
|
||||
export async function run(cmd: string[], options?: Bun.SpawnOptions.OptionsObject<any, any, any>) {
|
||||
log.info("running", {
|
||||
|
||||
@@ -169,6 +169,7 @@ export function tui(input: {
|
||||
gatherStats: false,
|
||||
exitOnCtrlC: false,
|
||||
useKittyKeyboard: {},
|
||||
autoFocus: false,
|
||||
consoleOptions: {
|
||||
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
|
||||
onCopySelection: (text) => {
|
||||
@@ -186,6 +187,7 @@ function App() {
|
||||
const route = useRoute()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
Clipboard.setRenderer(renderer)
|
||||
renderer.disableStdoutInterception()
|
||||
const dialog = useDialog()
|
||||
const local = useLocal()
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useSDK } from "../context/sdk"
|
||||
import { DialogSessionRename } from "./dialog-session-rename"
|
||||
import { useKV } from "../context/kv"
|
||||
import { createDebouncedSignal } from "../util/signal"
|
||||
import "opentui-spinner/solid"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
export function DialogSessionList() {
|
||||
const dialog = useDialog()
|
||||
@@ -32,8 +32,6 @@ export function DialogSessionList() {
|
||||
|
||||
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
||||
|
||||
const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||
|
||||
const sessions = createMemo(() => searchResults() ?? sync.data.session)
|
||||
|
||||
const options = createMemo(() => {
|
||||
@@ -56,11 +54,7 @@ export function DialogSessionList() {
|
||||
value: x.id,
|
||||
category,
|
||||
footer: Locale.time(x.time.updated),
|
||||
gutter: isWorking ? (
|
||||
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
|
||||
<spinner frames={spinnerFrames} interval={80} color={theme.primary} />
|
||||
</Show>
|
||||
) : undefined,
|
||||
gutter: isWorking ? <Spinner /> : undefined,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -129,6 +129,16 @@ export function Autocomplete(props: {
|
||||
return props.input().getTextRange(store.index + 1, props.input().cursorOffset)
|
||||
})
|
||||
|
||||
// filter() reads reactive props.value plus non-reactive cursor/text state.
|
||||
// On keypress those can be briefly out of sync, so filter() may return an empty/partial string.
|
||||
// Copy it into search in an effect because effects run after reactive updates have been rendered and painted
|
||||
// so the input has settled and all consumers read the same stable value.
|
||||
const [search, setSearch] = createSignal("")
|
||||
createEffect(() => {
|
||||
const next = filter()
|
||||
setSearch(next ? next : "")
|
||||
})
|
||||
|
||||
// When the filter changes due to how TUI works, the mousemove might still be triggered
|
||||
// via a synthetic event as the layout moves underneath the cursor. This is a workaround to make sure the input mode remains keyboard so
|
||||
// that the mouseover event doesn't trigger when filtering.
|
||||
@@ -208,7 +218,7 @@ export function Autocomplete(props: {
|
||||
}
|
||||
|
||||
const [files] = createResource(
|
||||
() => filter(),
|
||||
() => search(),
|
||||
async (query) => {
|
||||
if (!store.visible || store.visible === "/") return []
|
||||
|
||||
@@ -378,9 +388,9 @@ export function Autocomplete(props: {
|
||||
const mixed: AutocompleteOption[] =
|
||||
store.visible === "@" ? [...agentsValue, ...(filesValue || []), ...mcpResources()] : [...commandsValue]
|
||||
|
||||
const currentFilter = filter()
|
||||
const searchValue = search()
|
||||
|
||||
if (!currentFilter) {
|
||||
if (!searchValue) {
|
||||
return mixed
|
||||
}
|
||||
|
||||
@@ -388,7 +398,7 @@ export function Autocomplete(props: {
|
||||
return prev
|
||||
}
|
||||
|
||||
const result = fuzzysort.go(removeLineRange(currentFilter), mixed, {
|
||||
const result = fuzzysort.go(removeLineRange(searchValue), mixed, {
|
||||
keys: [
|
||||
(obj) => removeLineRange((obj.value ?? obj.display).trimEnd()),
|
||||
"description",
|
||||
@@ -398,7 +408,7 @@ export function Autocomplete(props: {
|
||||
scoreFn: (objResults) => {
|
||||
const displayResult = objResults[0]
|
||||
let score = objResults.score
|
||||
if (displayResult && displayResult.target.startsWith(store.visible + currentFilter)) {
|
||||
if (displayResult && displayResult.target.startsWith(store.visible + searchValue)) {
|
||||
score *= 2
|
||||
}
|
||||
const frecencyScore = objResults.obj.path ? frecency.getFrecency(objResults.obj.path) : 0
|
||||
|
||||
24
packages/opencode/src/cli/cmd/tui/component/spinner.tsx
Normal file
24
packages/opencode/src/cli/cmd/tui/component/spinner.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Show } from "solid-js"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useKV } from "../context/kv"
|
||||
import type { JSX } from "@opentui/solid"
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import "opentui-spinner/solid"
|
||||
|
||||
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||
|
||||
export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
|
||||
const { theme } = useTheme()
|
||||
const kv = useKV()
|
||||
const color = () => props.color ?? theme.textMuted
|
||||
return (
|
||||
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={color()}>⋯ {props.children}</text>}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<spinner frames={frames} interval={80} color={color()} />
|
||||
<Show when={props.children}>
|
||||
<text fg={color()}>{props.children}</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +1,52 @@
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { FormatError, FormatUnknownError } from "@/cli/error"
|
||||
type Exit = ((reason?: unknown) => Promise<void>) & {
|
||||
message: {
|
||||
set: (value?: string) => () => void
|
||||
clear: () => void
|
||||
get: () => string | undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
||||
name: "Exit",
|
||||
init: (input: { onExit?: () => Promise<void> }) => {
|
||||
const renderer = useRenderer()
|
||||
return async (reason?: any) => {
|
||||
// Reset window title before destroying renderer
|
||||
renderer.setTerminalTitle("")
|
||||
renderer.destroy()
|
||||
await input.onExit?.()
|
||||
if (reason) {
|
||||
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
|
||||
if (formatted) {
|
||||
process.stderr.write(formatted + "\n")
|
||||
let message: string | undefined
|
||||
const store = {
|
||||
set: (value?: string) => {
|
||||
const prev = message
|
||||
message = value
|
||||
return () => {
|
||||
message = prev
|
||||
}
|
||||
}
|
||||
process.exit(0)
|
||||
},
|
||||
clear: () => {
|
||||
message = undefined
|
||||
},
|
||||
get: () => message,
|
||||
}
|
||||
const exit: Exit = Object.assign(
|
||||
async (reason?: unknown) => {
|
||||
// Reset window title before destroying renderer
|
||||
renderer.setTerminalTitle("")
|
||||
renderer.destroy()
|
||||
await input.onExit?.()
|
||||
if (reason) {
|
||||
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
|
||||
if (formatted) {
|
||||
process.stderr.write(formatted + "\n")
|
||||
}
|
||||
}
|
||||
const text = store.get()
|
||||
if (text) process.stdout.write(text + "\n")
|
||||
process.exit(0)
|
||||
},
|
||||
{
|
||||
message: store,
|
||||
},
|
||||
)
|
||||
return exit
|
||||
},
|
||||
})
|
||||
|
||||
@@ -41,7 +41,6 @@ import { useRenderer } from "@opentui/solid"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Global } from "@/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { useSDK } from "./sdk"
|
||||
|
||||
type ThemeColors = {
|
||||
primary: RGBA
|
||||
@@ -429,6 +428,7 @@ export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA {
|
||||
function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
|
||||
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
|
||||
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
|
||||
const transparent = RGBA.fromInts(0, 0, 0, 0)
|
||||
const isDark = mode == "dark"
|
||||
|
||||
const col = (i: number) => {
|
||||
@@ -479,8 +479,8 @@ function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJs
|
||||
textMuted,
|
||||
selectedListItemText: bg,
|
||||
|
||||
// Background colors
|
||||
background: bg,
|
||||
// Background colors - use transparent to respect terminal transparency
|
||||
background: transparent,
|
||||
backgroundPanel: grays[2],
|
||||
backgroundElement: grays[3],
|
||||
backgroundMenu: grays[3],
|
||||
|
||||
@@ -16,6 +16,7 @@ import path from "path"
|
||||
import { useRoute, useRouteData } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { SplitBorder } from "@tui/component/border"
|
||||
import { Spinner } from "@tui/component/spinner"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import {
|
||||
BoxRenderable,
|
||||
@@ -76,6 +77,7 @@ import { PermissionPrompt } from "./permission"
|
||||
import { QuestionPrompt } from "./question"
|
||||
import { DialogExportOptions } from "../../ui/dialog-export-options"
|
||||
import { formatTranscript } from "../../util/transcript"
|
||||
import { UI } from "@/cli/ui.ts"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
|
||||
@@ -221,6 +223,19 @@ export function Session() {
|
||||
|
||||
// Allow exit when in child session (prompt is hidden)
|
||||
const exit = useExit()
|
||||
|
||||
createEffect(() => {
|
||||
const title = Locale.truncate(session()?.title ?? "", 50)
|
||||
return exit.message.set(
|
||||
[
|
||||
``,
|
||||
` █▀▀█ ${UI.Style.TEXT_DIM}${title}${UI.Style.TEXT_NORMAL}`,
|
||||
` █ █ ${UI.Style.TEXT_DIM}opencode -s ${session()?.id}${UI.Style.TEXT_NORMAL}`,
|
||||
` ▀▀▀▀ `,
|
||||
].join("\n"),
|
||||
)
|
||||
})
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (!session()?.parentID) return
|
||||
if (keybind.match("app_exit", evt)) {
|
||||
@@ -1559,7 +1574,13 @@ function InlineTool(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function BlockTool(props: { title: string; children: JSX.Element; onClick?: () => void; part?: ToolPart }) {
|
||||
function BlockTool(props: {
|
||||
title: string
|
||||
children: JSX.Element
|
||||
onClick?: () => void
|
||||
part?: ToolPart
|
||||
spinner?: boolean
|
||||
}) {
|
||||
const { theme } = useTheme()
|
||||
const renderer = useRenderer()
|
||||
const [hover, setHover] = createSignal(false)
|
||||
@@ -1582,9 +1603,16 @@ function BlockTool(props: { title: string; children: JSX.Element; onClick?: () =
|
||||
props.onClick?.()
|
||||
}}
|
||||
>
|
||||
<text paddingLeft={3} fg={theme.textMuted}>
|
||||
{props.title}
|
||||
</text>
|
||||
<Show
|
||||
when={props.spinner}
|
||||
fallback={
|
||||
<text paddingLeft={3} fg={theme.textMuted}>
|
||||
{props.title}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<Spinner color={theme.textMuted}>{props.title.replace(/^# /, "")}</Spinner>
|
||||
</Show>
|
||||
{props.children}
|
||||
<Show when={error()}>
|
||||
<text fg={theme.error}>{error()}</text>
|
||||
@@ -1813,6 +1841,8 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
||||
|
||||
const current = createMemo(() => tools().findLast((x) => x.state.status !== "pending"))
|
||||
|
||||
const isRunning = createMemo(() => props.part.state.status === "running")
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.input.description || props.input.subagent_type}>
|
||||
@@ -1824,6 +1854,7 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
||||
: undefined
|
||||
}
|
||||
part={props.part}
|
||||
spinner={isRunning()}
|
||||
>
|
||||
<box>
|
||||
<text style={{ fg: theme.textMuted }}>
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
import { $ } from "bun"
|
||||
import type { CliRenderer } from "@opentui/core"
|
||||
import { platform, release } from "os"
|
||||
import clipboardy from "clipboardy"
|
||||
import { lazy } from "../../../../util/lazy.js"
|
||||
import { tmpdir } from "os"
|
||||
import path from "path"
|
||||
|
||||
/**
|
||||
* Writes text to clipboard via OSC 52 escape sequence.
|
||||
* This allows clipboard operations to work over SSH by having
|
||||
* the terminal emulator handle the clipboard locally.
|
||||
*/
|
||||
function writeOsc52(text: string): void {
|
||||
if (!process.stdout.isTTY) return
|
||||
const base64 = Buffer.from(text).toString("base64")
|
||||
const osc52 = `\x1b]52;c;${base64}\x07`
|
||||
// tmux and screen require DCS passthrough wrapping
|
||||
const passthrough = process.env["TMUX"] || process.env["STY"]
|
||||
const sequence = passthrough ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
|
||||
process.stdout.write(sequence)
|
||||
}
|
||||
const rendererRef = { current: undefined as CliRenderer | undefined }
|
||||
|
||||
export namespace Clipboard {
|
||||
export interface Content {
|
||||
@@ -26,6 +14,10 @@ export namespace Clipboard {
|
||||
mime: string
|
||||
}
|
||||
|
||||
export function setRenderer(renderer: CliRenderer | undefined): void {
|
||||
rendererRef.current = renderer
|
||||
}
|
||||
|
||||
export async function read(): Promise<Content | undefined> {
|
||||
const os = platform()
|
||||
|
||||
@@ -154,7 +146,11 @@ export namespace Clipboard {
|
||||
})
|
||||
|
||||
export async function copy(text: string): Promise<void> {
|
||||
writeOsc52(text)
|
||||
const renderer = rendererRef.current
|
||||
if (renderer) {
|
||||
const copied = renderer.copyToClipboardOSC52(text)
|
||||
if (copied) return
|
||||
}
|
||||
await getCopyMethod()(text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export const WebCommand = cmd({
|
||||
UI.println(
|
||||
UI.Style.TEXT_INFO_BOLD + " mDNS: ",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`opencode.local:${server.port}`,
|
||||
`${opts.mdnsDomain}:${server.port}`,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,11 @@ const options = {
|
||||
describe: "enable mDNS service discovery (defaults hostname to 0.0.0.0)",
|
||||
default: false,
|
||||
},
|
||||
"mdns-domain": {
|
||||
type: "string" as const,
|
||||
describe: "custom domain name for mDNS service (default: opencode.local)",
|
||||
default: "opencode.local",
|
||||
},
|
||||
cors: {
|
||||
type: "string" as const,
|
||||
array: true,
|
||||
@@ -36,9 +41,11 @@ export async function resolveNetworkOptions(args: NetworkOptions) {
|
||||
const portExplicitlySet = process.argv.includes("--port")
|
||||
const hostnameExplicitlySet = process.argv.includes("--hostname")
|
||||
const mdnsExplicitlySet = process.argv.includes("--mdns")
|
||||
const mdnsDomainExplicitlySet = process.argv.includes("--mdns-domain")
|
||||
const corsExplicitlySet = process.argv.includes("--cors")
|
||||
|
||||
const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns)
|
||||
const mdnsDomain = mdnsDomainExplicitlySet ? args["mdns-domain"] : (config?.server?.mdnsDomain ?? args["mdns-domain"])
|
||||
const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port)
|
||||
const hostname = hostnameExplicitlySet
|
||||
? args.hostname
|
||||
@@ -49,5 +56,5 @@ export async function resolveNetworkOptions(args: NetworkOptions) {
|
||||
const argsCors = Array.isArray(args.cors) ? args.cors : args.cors ? [args.cors] : []
|
||||
const cors = [...configCors, ...argsCors]
|
||||
|
||||
return { hostname, port, mdns, cors }
|
||||
return { hostname, port, mdns, mdnsDomain, cors }
|
||||
}
|
||||
|
||||
@@ -62,8 +62,14 @@ export namespace Config {
|
||||
export const state = Instance.state(async () => {
|
||||
const auth = await Auth.all()
|
||||
|
||||
// Load remote/well-known config first as the base layer (lowest precedence)
|
||||
// This allows organizations to provide default configs that users can override
|
||||
// Config loading order (low -> high precedence): https://opencode.ai/docs/config#precedence-order
|
||||
// 1) Remote .well-known/opencode (org defaults)
|
||||
// 2) Global config (~/.config/opencode/opencode.json{,c})
|
||||
// 3) Custom config (OPENCODE_CONFIG)
|
||||
// 4) Project config (opencode.json{,c})
|
||||
// 5) .opencode directories (.opencode/agents/, .opencode/commands/, .opencode/plugins/, .opencode/opencode.json{,c})
|
||||
// 6) Inline config (OPENCODE_CONFIG_CONTENT)
|
||||
// Managed config directory is enterprise-only and always overrides everything above.
|
||||
let result: Info = {}
|
||||
for (const [key, value] of Object.entries(auth)) {
|
||||
if (value.type === "wellknown") {
|
||||
@@ -85,16 +91,16 @@ export namespace Config {
|
||||
}
|
||||
}
|
||||
|
||||
// Global user config overrides remote config
|
||||
// Global user config overrides remote config.
|
||||
result = mergeConfigConcatArrays(result, await global())
|
||||
|
||||
// Custom config path overrides global
|
||||
// Custom config path overrides global config.
|
||||
if (Flag.OPENCODE_CONFIG) {
|
||||
result = mergeConfigConcatArrays(result, await loadFile(Flag.OPENCODE_CONFIG))
|
||||
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
|
||||
}
|
||||
|
||||
// Project config has highest precedence (overrides global and remote)
|
||||
// Project config overrides global and remote config.
|
||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
||||
const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
|
||||
@@ -104,12 +110,6 @@ export namespace Config {
|
||||
}
|
||||
}
|
||||
|
||||
// Inline config content has highest precedence
|
||||
if (Flag.OPENCODE_CONFIG_CONTENT) {
|
||||
result = mergeConfigConcatArrays(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT))
|
||||
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
|
||||
}
|
||||
|
||||
result.agent = result.agent || {}
|
||||
result.mode = result.mode || {}
|
||||
result.plugin = result.plugin || []
|
||||
@@ -136,6 +136,7 @@ export namespace Config {
|
||||
)),
|
||||
]
|
||||
|
||||
// .opencode directory config overrides (project and global) config sources.
|
||||
if (Flag.OPENCODE_CONFIG_DIR) {
|
||||
directories.push(Flag.OPENCODE_CONFIG_DIR)
|
||||
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
|
||||
@@ -163,6 +164,12 @@ export namespace Config {
|
||||
result.plugin.push(...(await loadPlugin(dir)))
|
||||
}
|
||||
|
||||
// Inline config content overrides all non-managed config sources.
|
||||
if (Flag.OPENCODE_CONFIG_CONTENT) {
|
||||
result = mergeConfigConcatArrays(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT))
|
||||
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
|
||||
}
|
||||
|
||||
// Load managed config files last (highest priority) - enterprise admin-controlled
|
||||
// Kept separate from directories array to avoid write operations when installing plugins
|
||||
// which would fail on system directories requiring elevated permissions
|
||||
@@ -853,6 +860,7 @@ export namespace Config {
|
||||
port: z.number().int().positive().optional().describe("Port to listen on"),
|
||||
hostname: z.string().optional().describe("Hostname to listen on"),
|
||||
mdns: z.boolean().optional().describe("Enable mDNS service discovery"),
|
||||
mdnsDomain: z.string().optional().describe("Custom domain name for mDNS service (default: opencode.local)"),
|
||||
cors: z.array(z.string()).optional().describe("Additional domains to allow for CORS"),
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -275,100 +275,56 @@ export namespace Ripgrep {
|
||||
log.info("tree", input)
|
||||
const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd, signal: input.signal }))
|
||||
interface Node {
|
||||
path: string[]
|
||||
children: Node[]
|
||||
name: string
|
||||
children: Map<string, Node>
|
||||
}
|
||||
|
||||
function getPath(node: Node, parts: string[], create: boolean) {
|
||||
if (parts.length === 0) return node
|
||||
let current = node
|
||||
for (const part of parts) {
|
||||
let existing = current.children.find((x) => x.path.at(-1) === part)
|
||||
if (!existing) {
|
||||
if (!create) return
|
||||
existing = {
|
||||
path: current.path.concat(part),
|
||||
children: [],
|
||||
}
|
||||
current.children.push(existing)
|
||||
}
|
||||
current = existing
|
||||
}
|
||||
return current
|
||||
function dir(node: Node, name: string) {
|
||||
const existing = node.children.get(name)
|
||||
if (existing) return existing
|
||||
const next = { name, children: new Map() }
|
||||
node.children.set(name, next)
|
||||
return next
|
||||
}
|
||||
|
||||
const root: Node = {
|
||||
path: [],
|
||||
children: [],
|
||||
}
|
||||
const root: Node = { name: "", children: new Map() }
|
||||
for (const file of files) {
|
||||
if (file.includes(".opencode")) continue
|
||||
const parts = file.split(path.sep)
|
||||
getPath(root, parts, true)
|
||||
}
|
||||
|
||||
function sort(node: Node) {
|
||||
node.children.sort((a, b) => {
|
||||
if (!a.children.length && b.children.length) return 1
|
||||
if (!b.children.length && a.children.length) return -1
|
||||
return a.path.at(-1)!.localeCompare(b.path.at(-1)!)
|
||||
})
|
||||
for (const child of node.children) {
|
||||
sort(child)
|
||||
if (parts.length < 2) continue
|
||||
let node = root
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
node = dir(node, part)
|
||||
}
|
||||
}
|
||||
sort(root)
|
||||
|
||||
let current = [root]
|
||||
const result: Node = {
|
||||
path: [],
|
||||
children: [],
|
||||
}
|
||||
|
||||
let processed = 0
|
||||
const limit = input.limit ?? 50
|
||||
while (current.length > 0) {
|
||||
const next = []
|
||||
for (const node of current) {
|
||||
if (node.children.length) next.push(...node.children)
|
||||
}
|
||||
const max = Math.max(...current.map((x) => x.children.length))
|
||||
for (let i = 0; i < max && processed < limit; i++) {
|
||||
for (const node of current) {
|
||||
const child = node.children[i]
|
||||
if (!child) continue
|
||||
getPath(result, child.path, true)
|
||||
processed++
|
||||
if (processed >= limit) break
|
||||
}
|
||||
}
|
||||
if (processed >= limit) {
|
||||
for (const node of [...current, ...next]) {
|
||||
const compare = getPath(result, node.path, false)
|
||||
if (!compare) continue
|
||||
if (compare?.children.length !== node.children.length) {
|
||||
const diff = node.children.length - compare.children.length
|
||||
compare.children.push({
|
||||
path: compare.path.concat(`[${diff} truncated]`),
|
||||
children: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
current = next
|
||||
function count(node: Node): number {
|
||||
let total = 0
|
||||
for (const child of node.children.values()) {
|
||||
total += 1 + count(child)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
const total = count(root)
|
||||
const limit = input.limit ?? total
|
||||
const lines: string[] = []
|
||||
const queue: { node: Node; path: string }[] = []
|
||||
for (const child of Array.from(root.children.values()).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
queue.push({ node: child, path: child.name })
|
||||
}
|
||||
|
||||
function render(node: Node, depth: number) {
|
||||
const indent = "\t".repeat(depth)
|
||||
lines.push(indent + node.path.at(-1) + (node.children.length ? "/" : ""))
|
||||
for (const child of node.children) {
|
||||
render(child, depth + 1)
|
||||
let used = 0
|
||||
for (let i = 0; i < queue.length && used < limit; i++) {
|
||||
const { node, path } = queue[i]
|
||||
lines.push(path)
|
||||
used++
|
||||
for (const child of Array.from(node.children.values()).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
queue.push({ node: child, path: `${path}/${child.name}` })
|
||||
}
|
||||
}
|
||||
result.children.map((x) => render(x, 0))
|
||||
|
||||
if (total > used) lines.push(`[${total - used} truncated]`)
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
@@ -355,3 +355,12 @@ export const pint: Info = {
|
||||
return false
|
||||
},
|
||||
}
|
||||
|
||||
export const ormolu: Info = {
|
||||
name: "ormolu",
|
||||
command: ["ormolu", "-i", "$FILE"],
|
||||
extensions: [".hs"],
|
||||
async enabled() {
|
||||
return Bun.which("ormolu") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import z from "zod"
|
||||
import os from "os"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { Config } from "../config/config"
|
||||
import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
|
||||
@@ -35,8 +36,9 @@ import { createGateway } from "@ai-sdk/gateway"
|
||||
import { createTogetherAI } from "@ai-sdk/togetherai"
|
||||
import { createPerplexity } from "@ai-sdk/perplexity"
|
||||
import { createVercel } from "@ai-sdk/vercel"
|
||||
import { createGitLab } from "@gitlab/gitlab-ai-provider"
|
||||
import { createGitLab, VERSION as GITLAB_PROVIDER_VERSION } from "@gitlab/gitlab-ai-provider"
|
||||
import { ProviderTransform } from "./transform"
|
||||
import { Installation } from "../installation"
|
||||
|
||||
export namespace Provider {
|
||||
const log = Log.create({ service: "provider" })
|
||||
@@ -424,11 +426,17 @@ export namespace Provider {
|
||||
const config = await Config.get()
|
||||
const providerConfig = config.provider?.["gitlab"]
|
||||
|
||||
const aiGatewayHeaders = {
|
||||
"User-Agent": `opencode/${Installation.VERSION} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
|
||||
...(providerConfig?.options?.aiGatewayHeaders || {}),
|
||||
}
|
||||
|
||||
return {
|
||||
autoload: !!apiKey,
|
||||
options: {
|
||||
instanceUrl,
|
||||
apiKey,
|
||||
aiGatewayHeaders,
|
||||
featureFlags: {
|
||||
duo_agent_platform_agentic_chat: true,
|
||||
duo_agent_platform: true,
|
||||
@@ -437,6 +445,7 @@ export namespace Provider {
|
||||
},
|
||||
async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string) {
|
||||
return sdk.agenticChat(modelID, {
|
||||
aiGatewayHeaders,
|
||||
featureFlags: {
|
||||
duo_agent_platform_agentic_chat: true,
|
||||
duo_agent_platform: true,
|
||||
|
||||
@@ -18,12 +18,7 @@ export function convertToOpenAICompatibleChatMessages(prompt: LanguageModelV2Pro
|
||||
case "system": {
|
||||
messages.push({
|
||||
role: "system",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: content,
|
||||
},
|
||||
],
|
||||
content: content,
|
||||
...metadata,
|
||||
})
|
||||
break
|
||||
|
||||
@@ -658,11 +658,18 @@ export namespace ProviderTransform {
|
||||
}
|
||||
|
||||
export function smallOptions(model: Provider.Model) {
|
||||
if (model.providerID === "openai" || model.api.id.includes("gpt-5")) {
|
||||
if (model.api.id.includes("5.")) {
|
||||
return { reasoningEffort: "low" }
|
||||
if (
|
||||
model.providerID === "openai" ||
|
||||
model.api.npm === "@ai-sdk/openai" ||
|
||||
model.api.npm === "@ai-sdk/github-copilot"
|
||||
) {
|
||||
if (model.api.id.includes("gpt-5")) {
|
||||
if (model.api.id.includes("5.")) {
|
||||
return { store: false, reasoningEffort: "low" }
|
||||
}
|
||||
return { store: false, reasoningEffort: "minimal" }
|
||||
}
|
||||
return { reasoningEffort: "minimal" }
|
||||
return { store: false }
|
||||
}
|
||||
if (model.providerID === "google") {
|
||||
// gemini-3 uses thinkingLevel, gemini-2.5 uses thinkingBudget
|
||||
|
||||
@@ -7,17 +7,18 @@ export namespace MDNS {
|
||||
let bonjour: Bonjour | undefined
|
||||
let currentPort: number | undefined
|
||||
|
||||
export function publish(port: number) {
|
||||
export function publish(port: number, domain?: string) {
|
||||
if (currentPort === port) return
|
||||
if (bonjour) unpublish()
|
||||
|
||||
try {
|
||||
const host = domain ?? "opencode.local"
|
||||
const name = `opencode-${port}`
|
||||
bonjour = new Bonjour()
|
||||
const service = bonjour.publish({
|
||||
name,
|
||||
type: "http",
|
||||
host: "opencode.local",
|
||||
host,
|
||||
port,
|
||||
txt: { path: "/" },
|
||||
})
|
||||
|
||||
@@ -563,7 +563,13 @@ export namespace Server {
|
||||
return result
|
||||
}
|
||||
|
||||
export function listen(opts: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
|
||||
export function listen(opts: {
|
||||
port: number
|
||||
hostname: string
|
||||
mdns?: boolean
|
||||
mdnsDomain?: string
|
||||
cors?: string[]
|
||||
}) {
|
||||
_corsWhitelist = opts.cors ?? []
|
||||
|
||||
const args = {
|
||||
@@ -591,7 +597,7 @@ export namespace Server {
|
||||
opts.hostname !== "localhost" &&
|
||||
opts.hostname !== "::1"
|
||||
if (shouldPublishMDNS) {
|
||||
MDNS.publish(server.port!)
|
||||
MDNS.publish(server.port!, opts.mdnsDomain)
|
||||
} else if (opts.mdns) {
|
||||
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
||||
}
|
||||
|
||||
@@ -172,14 +172,6 @@ export namespace SessionProcessor {
|
||||
case "tool-result": {
|
||||
const match = toolcalls[value.toolCallId]
|
||||
if (match && match.state.status === "running") {
|
||||
const attachments = value.output.attachments?.map(
|
||||
(attachment: Omit<MessageV2.FilePart, "id" | "messageID" | "sessionID">) => ({
|
||||
...attachment,
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: match.messageID,
|
||||
sessionID: match.sessionID,
|
||||
}),
|
||||
)
|
||||
await Session.updatePart({
|
||||
...match,
|
||||
state: {
|
||||
@@ -192,7 +184,7 @@ export namespace SessionProcessor {
|
||||
start: match.state.time.start,
|
||||
end: Date.now(),
|
||||
},
|
||||
attachments,
|
||||
attachments: value.output.attachments,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -187,17 +187,13 @@ export namespace SessionPrompt {
|
||||
text: template,
|
||||
},
|
||||
]
|
||||
const matches = ConfigMarkdown.files(template)
|
||||
const files = ConfigMarkdown.files(template)
|
||||
const seen = new Set<string>()
|
||||
const names = matches
|
||||
.map((match) => match[1])
|
||||
.filter((name) => {
|
||||
if (seen.has(name)) return false
|
||||
await Promise.all(
|
||||
files.map(async (match) => {
|
||||
const name = match[1]
|
||||
if (seen.has(name)) return
|
||||
seen.add(name)
|
||||
return true
|
||||
})
|
||||
const resolved = await Promise.all(
|
||||
names.map(async (name) => {
|
||||
const filepath = name.startsWith("~/")
|
||||
? path.join(os.homedir(), name.slice(2))
|
||||
: path.resolve(Instance.worktree, name)
|
||||
@@ -205,34 +201,33 @@ export namespace SessionPrompt {
|
||||
const stats = await fs.stat(filepath).catch(() => undefined)
|
||||
if (!stats) {
|
||||
const agent = await Agent.get(name)
|
||||
if (!agent) return undefined
|
||||
return {
|
||||
type: "agent",
|
||||
name: agent.name,
|
||||
} satisfies PromptInput["parts"][number]
|
||||
if (agent) {
|
||||
parts.push({
|
||||
type: "agent",
|
||||
name: agent.name,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
return {
|
||||
parts.push({
|
||||
type: "file",
|
||||
url: `file://${filepath}`,
|
||||
filename: name,
|
||||
mime: "application/x-directory",
|
||||
} satisfies PromptInput["parts"][number]
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
parts.push({
|
||||
type: "file",
|
||||
url: `file://${filepath}`,
|
||||
filename: name,
|
||||
mime: "text/plain",
|
||||
} satisfies PromptInput["parts"][number]
|
||||
})
|
||||
}),
|
||||
)
|
||||
for (const item of resolved) {
|
||||
if (!item) continue
|
||||
parts.push(item)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
@@ -432,12 +427,6 @@ export namespace SessionPrompt {
|
||||
assistantMessage.time.completed = Date.now()
|
||||
await Session.updateMessage(assistantMessage)
|
||||
if (result && part.state.status === "running") {
|
||||
const attachments = result.attachments?.map((attachment) => ({
|
||||
...attachment,
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: assistantMessage.id,
|
||||
sessionID: assistantMessage.sessionID,
|
||||
}))
|
||||
await Session.updatePart({
|
||||
...part,
|
||||
state: {
|
||||
@@ -446,7 +435,7 @@ export namespace SessionPrompt {
|
||||
title: result.title,
|
||||
metadata: result.metadata,
|
||||
output: result.output,
|
||||
attachments,
|
||||
attachments: result.attachments,
|
||||
time: {
|
||||
...part.state.time,
|
||||
end: Date.now(),
|
||||
@@ -785,13 +774,16 @@ export namespace SessionPrompt {
|
||||
)
|
||||
|
||||
const textParts: string[] = []
|
||||
const attachments: Omit<MessageV2.FilePart, "id" | "messageID" | "sessionID">[] = []
|
||||
const attachments: MessageV2.FilePart[] = []
|
||||
|
||||
for (const contentItem of result.content) {
|
||||
if (contentItem.type === "text") {
|
||||
textParts.push(contentItem.text)
|
||||
} else if (contentItem.type === "image") {
|
||||
attachments.push({
|
||||
id: Identifier.ascending("part"),
|
||||
sessionID: input.session.id,
|
||||
messageID: input.processor.message.id,
|
||||
type: "file",
|
||||
mime: contentItem.mimeType,
|
||||
url: `data:${contentItem.mimeType};base64,${contentItem.data}`,
|
||||
@@ -803,6 +795,9 @@ export namespace SessionPrompt {
|
||||
}
|
||||
if (resource.blob) {
|
||||
attachments.push({
|
||||
id: Identifier.ascending("part"),
|
||||
sessionID: input.session.id,
|
||||
messageID: input.processor.message.id,
|
||||
type: "file",
|
||||
mime: resource.mimeType ?? "application/octet-stream",
|
||||
url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`,
|
||||
@@ -1051,7 +1046,6 @@ export namespace SessionPrompt {
|
||||
pieces.push(
|
||||
...result.attachments.map((attachment) => ({
|
||||
...attachment,
|
||||
id: Identifier.ascending("part"),
|
||||
synthetic: true,
|
||||
filename: attachment.filename ?? part.filename,
|
||||
messageID: info.id,
|
||||
@@ -1189,18 +1183,7 @@ export namespace SessionPrompt {
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
.then((x) => x.flat())
|
||||
.then((drafts) =>
|
||||
drafts.map(
|
||||
(part): MessageV2.Part => ({
|
||||
...part,
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: info.id,
|
||||
sessionID: input.sessionID,
|
||||
}),
|
||||
),
|
||||
)
|
||||
).then((x) => x.flat())
|
||||
|
||||
await Plugin.trigger(
|
||||
"chat.message",
|
||||
|
||||
@@ -36,16 +36,16 @@ export namespace SystemPrompt {
|
||||
` Platform: ${process.platform}`,
|
||||
` Today's date: ${new Date().toDateString()}`,
|
||||
`</env>`,
|
||||
`<files>`,
|
||||
`<directories>`,
|
||||
` ${
|
||||
project.vcs === "git" && false
|
||||
? await Ripgrep.tree({
|
||||
cwd: Instance.directory,
|
||||
limit: 200,
|
||||
limit: 50,
|
||||
})
|
||||
: ""
|
||||
}`,
|
||||
`</files>`,
|
||||
`</directories>`,
|
||||
].join("\n"),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -188,6 +188,7 @@ export namespace Snapshot {
|
||||
after: z.string(),
|
||||
additions: z.number(),
|
||||
deletions: z.number(),
|
||||
status: z.enum(["added", "deleted", "modified"]).optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "FileDiff",
|
||||
@@ -196,6 +197,23 @@ export namespace Snapshot {
|
||||
export async function diffFull(from: string, to: string): Promise<FileDiff[]> {
|
||||
const git = gitdir()
|
||||
const result: FileDiff[] = []
|
||||
const status = new Map<string, "added" | "deleted" | "modified">()
|
||||
|
||||
const statuses =
|
||||
await $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-status --no-renames ${from} ${to} -- .`
|
||||
.quiet()
|
||||
.cwd(Instance.directory)
|
||||
.nothrow()
|
||||
.text()
|
||||
|
||||
for (const line of statuses.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const [code, file] = line.split("\t")
|
||||
if (!code || !file) continue
|
||||
const kind = code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified"
|
||||
status.set(file, kind)
|
||||
}
|
||||
|
||||
for await (const line of $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .`
|
||||
.quiet()
|
||||
.cwd(Instance.directory)
|
||||
@@ -224,6 +242,7 @@ export namespace Snapshot {
|
||||
after,
|
||||
additions: Number.isFinite(added) ? added : 0,
|
||||
deletions: Number.isFinite(deleted) ? deleted : 0,
|
||||
status: status.get(file) ?? "modified",
|
||||
})
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -128,7 +128,10 @@ export const BashTool = Tool.define("bash", async () => {
|
||||
process.platform === "win32" && resolved.match(/^\/[a-z]\//)
|
||||
? resolved.replace(/^\/([a-z])\//, (_, drive) => `${drive.toUpperCase()}:\\`).replace(/\//g, "\\")
|
||||
: resolved
|
||||
if (!Instance.containsPath(normalized)) directories.add(normalized)
|
||||
if (!Instance.containsPath(normalized)) {
|
||||
const dir = (await Filesystem.isDir(normalized)) ? normalized : path.dirname(normalized)
|
||||
directories.add(dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,10 +144,11 @@ export const BashTool = Tool.define("bash", async () => {
|
||||
}
|
||||
|
||||
if (directories.size > 0) {
|
||||
const globs = Array.from(directories).map((dir) => path.join(dir, "*"))
|
||||
await ctx.ask({
|
||||
permission: "external_directory",
|
||||
patterns: Array.from(directories),
|
||||
always: Array.from(directories).map((x) => path.dirname(x) + "*"),
|
||||
patterns: globs,
|
||||
always: globs,
|
||||
metadata: {},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -77,12 +77,6 @@ export const BatchTool = Tool.define("batch", async () => {
|
||||
})
|
||||
|
||||
const result = await tool.execute(validatedParams, { ...ctx, callID: partID })
|
||||
const attachments = result.attachments?.map((attachment) => ({
|
||||
...attachment,
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: ctx.messageID,
|
||||
sessionID: ctx.sessionID,
|
||||
}))
|
||||
|
||||
await Session.updatePart({
|
||||
id: partID,
|
||||
@@ -97,7 +91,7 @@ export const BatchTool = Tool.define("batch", async () => {
|
||||
output: result.output,
|
||||
title: result.title,
|
||||
metadata: result.metadata,
|
||||
attachments,
|
||||
attachments: result.attachments,
|
||||
time: {
|
||||
start: callStartTime,
|
||||
end: Date.now(),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { LSP } from "../lsp"
|
||||
import { FileTime } from "../file/time"
|
||||
import DESCRIPTION from "./read.txt"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Identifier } from "../id/id"
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
import { InstructionPrompt } from "../session/instruction"
|
||||
|
||||
@@ -78,6 +79,9 @@ export const ReadTool = Tool.define("read", {
|
||||
},
|
||||
attachments: [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
sessionID: ctx.sessionID,
|
||||
messageID: ctx.messageID,
|
||||
type: "file",
|
||||
mime,
|
||||
url: `data:${mime};base64,${Buffer.from(await file.bytes()).toString("base64")}`,
|
||||
|
||||
@@ -36,7 +36,7 @@ export namespace Tool {
|
||||
title: string
|
||||
metadata: M
|
||||
output: string
|
||||
attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]
|
||||
attachments?: MessageV2.FilePart[]
|
||||
}>
|
||||
formatValidationError?(error: z.ZodError): string
|
||||
}>
|
||||
|
||||
@@ -447,7 +447,7 @@ test("legacy tools config maps write/edit/patch/multiedit to edit permission", a
|
||||
})
|
||||
})
|
||||
|
||||
test("Truncate.DIR is allowed even when user denies external_directory globally", async () => {
|
||||
test("Truncate.GLOB is allowed even when user denies external_directory globally", async () => {
|
||||
const { Truncate } = await import("../../src/tool/truncation")
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
@@ -460,14 +460,14 @@ test("Truncate.DIR is allowed even when user denies external_directory globally"
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
|
||||
expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("Truncate.DIR is allowed even when user denies external_directory per-agent", async () => {
|
||||
test("Truncate.GLOB is allowed even when user denies external_directory per-agent", async () => {
|
||||
const { Truncate } = await import("../../src/tool/truncation")
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
@@ -484,21 +484,21 @@ test("Truncate.DIR is allowed even when user denies external_directory per-agent
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
|
||||
expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("explicit Truncate.DIR deny is respected", async () => {
|
||||
test("explicit Truncate.GLOB deny is respected", async () => {
|
||||
const { Truncate } = await import("../../src/tool/truncation")
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
permission: {
|
||||
external_directory: {
|
||||
"*": "deny",
|
||||
[Truncate.DIR]: "deny",
|
||||
[Truncate.GLOB]: "deny",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -507,8 +507,8 @@ test("explicit Truncate.DIR deny is respected", async () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
|
||||
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny")
|
||||
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
import { convertToOpenAICompatibleChatMessages as convertToCopilotMessages } from "@/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages"
|
||||
import { describe, test, expect } from "bun:test"
|
||||
|
||||
describe("system messages", () => {
|
||||
test("should convert system message content to string", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "system",
|
||||
content: "You are a helpful assistant with AGENTS.md instructions.",
|
||||
},
|
||||
])
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: "system",
|
||||
content: "You are a helpful assistant with AGENTS.md instructions.",
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("user messages", () => {
|
||||
test("should convert messages with only a text part to a string content", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Session } from "../../src/session"
|
||||
import { SessionPrompt } from "../../src/session/prompt"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Log } from "../../src/util/log"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
describe("SessionPrompt ordering", () => {
|
||||
test("keeps @file order with read output parts", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "a.txt"), "28\n")
|
||||
await Bun.write(path.join(dir, "b.txt"), "42\n")
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const template = "What numbers are written in files @a.txt and @b.txt ?"
|
||||
const parts = await SessionPrompt.resolvePromptParts(template)
|
||||
const fileParts = parts.filter((part) => part.type === "file")
|
||||
|
||||
expect(fileParts.map((part) => part.filename)).toStrictEqual(["a.txt", "b.txt"])
|
||||
|
||||
const message = await SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
parts,
|
||||
noReply: true,
|
||||
})
|
||||
const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id })
|
||||
const items = stored.parts
|
||||
const aPath = path.join(tmp.path, "a.txt")
|
||||
const bPath = path.join(tmp.path, "b.txt")
|
||||
const sequence = items.flatMap((part) => {
|
||||
if (part.type === "text") {
|
||||
if (part.text.includes(aPath)) return ["input:a"]
|
||||
if (part.text.includes(bPath)) return ["input:b"]
|
||||
if (part.text.includes("00001| 28")) return ["output:a"]
|
||||
if (part.text.includes("00001| 42")) return ["output:b"]
|
||||
return []
|
||||
}
|
||||
if (part.type === "file") {
|
||||
if (part.filename === "a.txt") return ["file:a"]
|
||||
if (part.filename === "b.txt") return ["file:b"]
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
expect(sequence).toStrictEqual(["input:a", "output:a", "file:a", "input:b", "output:b", "file:b"])
|
||||
|
||||
await Session.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -749,6 +749,52 @@ test("revert preserves file that existed in snapshot when deleted then recreated
|
||||
})
|
||||
})
|
||||
|
||||
test("diffFull sets status based on git change type", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await Bun.write(`${tmp.path}/grow.txt`, "one\n")
|
||||
await Bun.write(`${tmp.path}/trim.txt`, "line1\nline2\n")
|
||||
await Bun.write(`${tmp.path}/delete.txt`, "gone")
|
||||
|
||||
const before = await Snapshot.track()
|
||||
expect(before).toBeTruthy()
|
||||
|
||||
await Bun.write(`${tmp.path}/grow.txt`, "one\ntwo\n")
|
||||
await Bun.write(`${tmp.path}/trim.txt`, "line1\n")
|
||||
await $`rm ${tmp.path}/delete.txt`.quiet()
|
||||
await Bun.write(`${tmp.path}/added.txt`, "new")
|
||||
|
||||
const after = await Snapshot.track()
|
||||
expect(after).toBeTruthy()
|
||||
|
||||
const diffs = await Snapshot.diffFull(before!, after!)
|
||||
expect(diffs.length).toBe(4)
|
||||
|
||||
const added = diffs.find((d) => d.file === "added.txt")
|
||||
expect(added).toBeDefined()
|
||||
expect(added!.status).toBe("added")
|
||||
|
||||
const deleted = diffs.find((d) => d.file === "delete.txt")
|
||||
expect(deleted).toBeDefined()
|
||||
expect(deleted!.status).toBe("deleted")
|
||||
|
||||
const grow = diffs.find((d) => d.file === "grow.txt")
|
||||
expect(grow).toBeDefined()
|
||||
expect(grow!.status).toBe("modified")
|
||||
expect(grow!.additions).toBeGreaterThan(0)
|
||||
expect(grow!.deletions).toBe(0)
|
||||
|
||||
const trim = diffs.find((d) => d.file === "trim.txt")
|
||||
expect(trim).toBeDefined()
|
||||
expect(trim!.status).toBe("modified")
|
||||
expect(trim!.additions).toBe(0)
|
||||
expect(trim!.deletions).toBeGreaterThan(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("diffFull with new file additions", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
|
||||
@@ -144,7 +144,42 @@ describe("tool.bash permissions", () => {
|
||||
)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
expect(extDirReq).toBeDefined()
|
||||
expect(extDirReq!.patterns).toContain("/tmp")
|
||||
expect(extDirReq!.patterns).toContain("/tmp/*")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("asks for external_directory permission when file arg is outside project", async () => {
|
||||
await using outerTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "outside.txt"), "x")
|
||||
},
|
||||
})
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||
const testCtx = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
const filepath = path.join(outerTmp.path, "outside.txt")
|
||||
await bash.execute(
|
||||
{
|
||||
command: `cat ${filepath}`,
|
||||
description: "Read external file",
|
||||
},
|
||||
testCtx,
|
||||
)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
const expected = path.join(outerTmp.path, "*")
|
||||
expect(extDirReq).toBeDefined()
|
||||
expect(extDirReq!.patterns).toContain(expected)
|
||||
expect(extDirReq!.always).toContain(expected)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,14 +9,8 @@
|
||||
"build": "tsc"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./tool": {
|
||||
"types": "./dist/tool.d.ts",
|
||||
"import": "./dist/tool.js"
|
||||
}
|
||||
".": "./src/index.ts",
|
||||
"./tool": "./src/tool.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -96,6 +96,7 @@ export type FileDiff = {
|
||||
after: string
|
||||
additions: number
|
||||
deletions: number
|
||||
status?: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
||||
export type UserMessage = {
|
||||
@@ -1331,6 +1332,10 @@ export type ServerConfig = {
|
||||
* Enable mDNS service discovery
|
||||
*/
|
||||
mdns?: boolean
|
||||
/**
|
||||
* Custom domain name for mDNS service (default: opencode.local)
|
||||
*/
|
||||
mdnsDomain?: string
|
||||
/**
|
||||
* Additional domains to allow for CORS
|
||||
*/
|
||||
|
||||
@@ -6087,6 +6087,10 @@
|
||||
},
|
||||
"deletions": {
|
||||
"type": "number"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["added", "deleted", "modified"]
|
||||
}
|
||||
},
|
||||
"required": ["file", "before", "after", "additions", "deletions"]
|
||||
@@ -8906,6 +8910,10 @@
|
||||
"description": "Enable mDNS service discovery",
|
||||
"type": "boolean"
|
||||
},
|
||||
"mdnsDomain": {
|
||||
"description": "Custom domain name for mDNS service (default: opencode.local)",
|
||||
"type": "string"
|
||||
},
|
||||
"cors": {
|
||||
"description": "Additional domains to allow for CORS",
|
||||
"type": "array",
|
||||
|
||||
@@ -9,13 +9,7 @@
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
outline: none;
|
||||
padding: 4px 8px;
|
||||
white-space: nowrap;
|
||||
transition-property: background-color, border-color, color, box-shadow, opacity;
|
||||
transition-duration: var(--transition-duration);
|
||||
transition-timing-function: var(--transition-easing);
|
||||
outline: none;
|
||||
line-height: 20px;
|
||||
|
||||
&[data-variant="primary"] {
|
||||
background-color: var(--button-primary-base);
|
||||
@@ -100,6 +94,7 @@
|
||||
&:active:not(:disabled) {
|
||||
background-color: var(--button-secondary-base);
|
||||
scale: 0.99;
|
||||
transition: all 150ms ease-out;
|
||||
}
|
||||
&:disabled {
|
||||
border-color: var(--border-disabled);
|
||||
@@ -114,31 +109,34 @@
|
||||
}
|
||||
|
||||
&[data-size="small"] {
|
||||
padding: 4px 8px;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
&[data-icon] {
|
||||
padding: 4px 12px 4px 4px;
|
||||
padding: 0 12px 0 4px;
|
||||
}
|
||||
|
||||
font-size: var(--font-size-small);
|
||||
line-height: var(--line-height-large);
|
||||
gap: 4px;
|
||||
|
||||
/* text-12-medium */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base);
|
||||
font-size: var(--font-size-small);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large); /* 166.667% */
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
}
|
||||
|
||||
&[data-size="normal"] {
|
||||
padding: 4px 6px;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
padding: 0 6px;
|
||||
&[data-icon] {
|
||||
padding: 4px 12px 4px 4px;
|
||||
}
|
||||
|
||||
&[aria-haspopup] {
|
||||
padding: 4px 6px 4px 8px;
|
||||
padding: 0 12px 0 4px;
|
||||
}
|
||||
|
||||
font-size: var(--font-size-small);
|
||||
gap: 6px;
|
||||
|
||||
/* text-12-medium */
|
||||
@@ -150,6 +148,7 @@
|
||||
}
|
||||
|
||||
&[data-size="large"] {
|
||||
height: 32px;
|
||||
padding: 6px 12px;
|
||||
|
||||
&[data-icon] {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Icon, IconProps } from "./icon"
|
||||
|
||||
export interface ButtonProps
|
||||
extends ComponentProps<typeof Kobalte>,
|
||||
Pick<ComponentProps<"button">, "class" | "classList" | "children" | "style"> {
|
||||
Pick<ComponentProps<"button">, "class" | "classList" | "children"> {
|
||||
size?: "small" | "normal" | "large"
|
||||
variant?: "primary" | "secondary" | "ghost"
|
||||
icon?: IconProps["name"]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { type FileContents, File, FileOptions, LineAnnotation, type SelectedLineRange } from "@pierre/diffs"
|
||||
import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, splitProps } from "solid-js"
|
||||
import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
|
||||
import { createDefaultOptions, styleVariables } from "../pierre"
|
||||
import { getWorkerPool } from "../pierre/worker"
|
||||
import { Icon } from "./icon"
|
||||
|
||||
type SelectionSide = "additions" | "deletions"
|
||||
|
||||
@@ -46,8 +47,89 @@ function findSide(node: Node | null): SelectionSide | undefined {
|
||||
return "additions"
|
||||
}
|
||||
|
||||
type FindHost = {
|
||||
element: () => HTMLElement | undefined
|
||||
open: () => void
|
||||
close: () => void
|
||||
next: (dir: 1 | -1) => void
|
||||
isOpen: () => boolean
|
||||
}
|
||||
|
||||
const findHosts = new Set<FindHost>()
|
||||
let findTarget: FindHost | undefined
|
||||
let findCurrent: FindHost | undefined
|
||||
let findInstalled = false
|
||||
|
||||
function isEditable(node: unknown): boolean {
|
||||
if (!(node instanceof HTMLElement)) return false
|
||||
if (node.closest("[data-prevent-autofocus]")) return true
|
||||
if (node.isContentEditable) return true
|
||||
return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(node.tagName)
|
||||
}
|
||||
|
||||
function hostForNode(node: unknown): FindHost | undefined {
|
||||
if (!(node instanceof Node)) return
|
||||
for (const host of findHosts) {
|
||||
const el = host.element()
|
||||
if (el && el.isConnected && el.contains(node)) return host
|
||||
}
|
||||
}
|
||||
|
||||
function installFindShortcuts() {
|
||||
if (findInstalled) return
|
||||
if (typeof window === "undefined") return
|
||||
findInstalled = true
|
||||
|
||||
window.addEventListener(
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (event.defaultPrevented) return
|
||||
|
||||
const mod = event.metaKey || event.ctrlKey
|
||||
if (!mod) return
|
||||
|
||||
const key = event.key.toLowerCase()
|
||||
|
||||
if (key === "g") {
|
||||
const host = findCurrent
|
||||
if (!host || !host.isOpen()) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
host.next(event.shiftKey ? -1 : 1)
|
||||
return
|
||||
}
|
||||
|
||||
if (key !== "f") return
|
||||
|
||||
const current = findCurrent
|
||||
if (current && current.isOpen()) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
current.open()
|
||||
return
|
||||
}
|
||||
|
||||
const host =
|
||||
hostForNode(document.activeElement) ?? hostForNode(event.target) ?? findTarget ?? Array.from(findHosts)[0]
|
||||
if (!host) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
host.open()
|
||||
},
|
||||
{ capture: true },
|
||||
)
|
||||
}
|
||||
|
||||
export function Code<T>(props: CodeProps<T>) {
|
||||
let wrapper!: HTMLDivElement
|
||||
let container!: HTMLDivElement
|
||||
let findInput: HTMLInputElement | undefined
|
||||
let findBar: HTMLDivElement | undefined
|
||||
let findOverlay!: HTMLDivElement
|
||||
let findOverlayFrame: number | undefined
|
||||
let findOverlayScroll: HTMLElement[] = []
|
||||
let findScroll: HTMLElement | undefined
|
||||
let observer: MutationObserver | undefined
|
||||
let renderToken = 0
|
||||
let selectionFrame: number | undefined
|
||||
@@ -70,6 +152,13 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
|
||||
const [rendered, setRendered] = createSignal(0)
|
||||
|
||||
const [findOpen, setFindOpen] = createSignal(false)
|
||||
const [findQuery, setFindQuery] = createSignal("")
|
||||
const [findIndex, setFindIndex] = createSignal(0)
|
||||
const [findCount, setFindCount] = createSignal(0)
|
||||
let findMode: "highlights" | "overlay" = "overlay"
|
||||
let findHits: Range[] = []
|
||||
|
||||
const file = createMemo(
|
||||
() =>
|
||||
new File<T>(
|
||||
@@ -104,6 +193,342 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
host.removeAttribute("data-color-scheme")
|
||||
}
|
||||
|
||||
const supportsHighlights = () => {
|
||||
const g = globalThis as unknown as { CSS?: { highlights?: unknown }; Highlight?: unknown }
|
||||
return typeof g.Highlight === "function" && g.CSS?.highlights != null
|
||||
}
|
||||
|
||||
const clearHighlightFind = () => {
|
||||
const api = (globalThis as { CSS?: { highlights?: { delete: (name: string) => void } } }).CSS?.highlights
|
||||
if (!api) return
|
||||
api.delete("opencode-find")
|
||||
api.delete("opencode-find-current")
|
||||
}
|
||||
|
||||
const clearOverlayScroll = () => {
|
||||
for (const el of findOverlayScroll) el.removeEventListener("scroll", scheduleOverlay)
|
||||
findOverlayScroll = []
|
||||
}
|
||||
|
||||
const clearOverlay = () => {
|
||||
if (findOverlayFrame !== undefined) {
|
||||
cancelAnimationFrame(findOverlayFrame)
|
||||
findOverlayFrame = undefined
|
||||
}
|
||||
findOverlay.innerHTML = ""
|
||||
}
|
||||
|
||||
const renderOverlay = () => {
|
||||
if (findMode !== "overlay") {
|
||||
clearOverlay()
|
||||
return
|
||||
}
|
||||
|
||||
clearOverlay()
|
||||
if (findHits.length === 0) return
|
||||
|
||||
const base = wrapper.getBoundingClientRect()
|
||||
const current = findIndex()
|
||||
|
||||
const frag = document.createDocumentFragment()
|
||||
for (let i = 0; i < findHits.length; i++) {
|
||||
const range = findHits[i]
|
||||
const active = i === current
|
||||
|
||||
for (const rect of Array.from(range.getClientRects())) {
|
||||
if (!rect.width || !rect.height) continue
|
||||
|
||||
const el = document.createElement("div")
|
||||
el.style.position = "absolute"
|
||||
el.style.left = `${Math.round(rect.left - base.left)}px`
|
||||
el.style.top = `${Math.round(rect.top - base.top)}px`
|
||||
el.style.width = `${Math.round(rect.width)}px`
|
||||
el.style.height = `${Math.round(rect.height)}px`
|
||||
el.style.borderRadius = "2px"
|
||||
el.style.backgroundColor = active ? "var(--surface-warning-strong)" : "var(--surface-warning-base)"
|
||||
el.style.opacity = active ? "0.55" : "0.35"
|
||||
if (active) el.style.boxShadow = "inset 0 0 0 1px var(--border-warning-base)"
|
||||
frag.appendChild(el)
|
||||
}
|
||||
}
|
||||
|
||||
findOverlay.appendChild(frag)
|
||||
}
|
||||
|
||||
function scheduleOverlay() {
|
||||
if (findMode !== "overlay") return
|
||||
if (!findOpen()) return
|
||||
if (findOverlayFrame !== undefined) return
|
||||
|
||||
findOverlayFrame = requestAnimationFrame(() => {
|
||||
findOverlayFrame = undefined
|
||||
renderOverlay()
|
||||
})
|
||||
}
|
||||
|
||||
const syncOverlayScroll = () => {
|
||||
if (findMode !== "overlay") return
|
||||
const root = getRoot()
|
||||
|
||||
const next = root
|
||||
? Array.from(root.querySelectorAll("[data-code]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
: []
|
||||
if (next.length === findOverlayScroll.length && next.every((el, i) => el === findOverlayScroll[i])) return
|
||||
|
||||
clearOverlayScroll()
|
||||
findOverlayScroll = next
|
||||
for (const el of findOverlayScroll) el.addEventListener("scroll", scheduleOverlay, { passive: true })
|
||||
}
|
||||
|
||||
const clearFind = () => {
|
||||
clearHighlightFind()
|
||||
clearOverlay()
|
||||
clearOverlayScroll()
|
||||
findHits = []
|
||||
setFindCount(0)
|
||||
setFindIndex(0)
|
||||
}
|
||||
|
||||
const getScrollParent = (el: HTMLElement): HTMLElement | null => {
|
||||
let parent = el.parentElement
|
||||
while (parent) {
|
||||
const style = getComputedStyle(parent)
|
||||
if (style.overflowY === "auto" || style.overflowY === "scroll") return parent
|
||||
parent = parent.parentElement
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const positionFindBar = () => {
|
||||
if (!findBar || !wrapper) return
|
||||
const scrollTop = findScroll ? findScroll.scrollTop : window.scrollY
|
||||
findBar.style.position = "absolute"
|
||||
findBar.style.top = `${scrollTop + 8}px`
|
||||
findBar.style.right = "8px"
|
||||
findBar.style.left = ""
|
||||
}
|
||||
|
||||
const scanFind = (root: ShadowRoot, query: string) => {
|
||||
const needle = query.toLowerCase()
|
||||
const out: Range[] = []
|
||||
|
||||
const cols = Array.from(root.querySelectorAll("[data-column-content]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
|
||||
for (const col of cols) {
|
||||
const text = col.textContent
|
||||
if (!text) continue
|
||||
|
||||
const hay = text.toLowerCase()
|
||||
let idx = hay.indexOf(needle)
|
||||
if (idx === -1) continue
|
||||
|
||||
const nodes: Text[] = []
|
||||
const ends: number[] = []
|
||||
const walker = document.createTreeWalker(col, NodeFilter.SHOW_TEXT)
|
||||
let node = walker.nextNode()
|
||||
let pos = 0
|
||||
|
||||
while (node) {
|
||||
if (node instanceof Text) {
|
||||
pos += node.data.length
|
||||
nodes.push(node)
|
||||
ends.push(pos)
|
||||
}
|
||||
node = walker.nextNode()
|
||||
}
|
||||
|
||||
if (nodes.length === 0) continue
|
||||
|
||||
const locate = (at: number) => {
|
||||
let lo = 0
|
||||
let hi = ends.length - 1
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (ends[mid] >= at) hi = mid
|
||||
else lo = mid + 1
|
||||
}
|
||||
const prev = lo === 0 ? 0 : ends[lo - 1]
|
||||
return { node: nodes[lo], offset: at - prev }
|
||||
}
|
||||
|
||||
while (idx !== -1) {
|
||||
const start = locate(idx)
|
||||
const end = locate(idx + query.length)
|
||||
const range = document.createRange()
|
||||
range.setStart(start.node, start.offset)
|
||||
range.setEnd(end.node, end.offset)
|
||||
out.push(range)
|
||||
idx = hay.indexOf(needle, idx + query.length)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
const scrollToRange = (range: Range) => {
|
||||
const start = range.startContainer
|
||||
const el = start instanceof Element ? start : start.parentElement
|
||||
el?.scrollIntoView({ block: "center", inline: "center" })
|
||||
}
|
||||
|
||||
const setHighlights = (ranges: Range[], index: number) => {
|
||||
const api = (globalThis as unknown as { CSS?: { highlights?: any }; Highlight?: any }).CSS?.highlights
|
||||
const Highlight = (globalThis as unknown as { Highlight?: any }).Highlight
|
||||
if (!api || typeof Highlight !== "function") return false
|
||||
|
||||
api.delete("opencode-find")
|
||||
api.delete("opencode-find-current")
|
||||
|
||||
const active = ranges[index]
|
||||
if (active) api.set("opencode-find-current", new Highlight(active))
|
||||
|
||||
const rest = ranges.filter((_, i) => i !== index)
|
||||
if (rest.length > 0) api.set("opencode-find", new Highlight(...rest))
|
||||
return true
|
||||
}
|
||||
|
||||
const applyFind = (opts?: { reset?: boolean; scroll?: boolean }) => {
|
||||
if (!findOpen()) return
|
||||
|
||||
const query = findQuery().trim()
|
||||
if (!query) {
|
||||
clearFind()
|
||||
return
|
||||
}
|
||||
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
|
||||
findMode = supportsHighlights() ? "highlights" : "overlay"
|
||||
|
||||
const ranges = scanFind(root, query)
|
||||
const total = ranges.length
|
||||
const desired = opts?.reset ? 0 : findIndex()
|
||||
const index = total ? Math.min(desired, total - 1) : 0
|
||||
|
||||
findHits = ranges
|
||||
setFindCount(total)
|
||||
setFindIndex(index)
|
||||
|
||||
const active = ranges[index]
|
||||
if (findMode === "highlights") {
|
||||
clearOverlay()
|
||||
clearOverlayScroll()
|
||||
if (!setHighlights(ranges, index)) {
|
||||
findMode = "overlay"
|
||||
clearHighlightFind()
|
||||
syncOverlayScroll()
|
||||
scheduleOverlay()
|
||||
}
|
||||
if (opts?.scroll && active) {
|
||||
scrollToRange(active)
|
||||
positionFindBar()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
clearHighlightFind()
|
||||
syncOverlayScroll()
|
||||
if (opts?.scroll && active) {
|
||||
scrollToRange(active)
|
||||
positionFindBar()
|
||||
}
|
||||
scheduleOverlay()
|
||||
}
|
||||
|
||||
const closeFind = () => {
|
||||
setFindOpen(false)
|
||||
clearFind()
|
||||
if (findCurrent === host) findCurrent = undefined
|
||||
}
|
||||
|
||||
const stepFind = (dir: 1 | -1) => {
|
||||
if (!findOpen()) return
|
||||
const total = findCount()
|
||||
if (total <= 0) return
|
||||
|
||||
const index = (findIndex() + dir + total) % total
|
||||
setFindIndex(index)
|
||||
|
||||
const active = findHits[index]
|
||||
if (!active) return
|
||||
|
||||
if (findMode === "highlights") {
|
||||
if (!setHighlights(findHits, index)) {
|
||||
findMode = "overlay"
|
||||
applyFind({ reset: true, scroll: true })
|
||||
return
|
||||
}
|
||||
scrollToRange(active)
|
||||
positionFindBar()
|
||||
return
|
||||
}
|
||||
|
||||
clearHighlightFind()
|
||||
syncOverlayScroll()
|
||||
scrollToRange(active)
|
||||
positionFindBar()
|
||||
scheduleOverlay()
|
||||
}
|
||||
|
||||
const host: FindHost = {
|
||||
element: () => wrapper,
|
||||
isOpen: () => findOpen(),
|
||||
next: stepFind,
|
||||
open: () => {
|
||||
if (findCurrent && findCurrent !== host) findCurrent.close()
|
||||
findCurrent = host
|
||||
findTarget = host
|
||||
|
||||
findScroll = getScrollParent(wrapper) ?? undefined
|
||||
if (!findOpen()) setFindOpen(true)
|
||||
requestAnimationFrame(() => {
|
||||
applyFind({ scroll: true })
|
||||
positionFindBar()
|
||||
findInput?.focus()
|
||||
findInput?.select()
|
||||
})
|
||||
},
|
||||
close: closeFind,
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
findMode = supportsHighlights() ? "highlights" : "overlay"
|
||||
installFindShortcuts()
|
||||
findHosts.add(host)
|
||||
if (!findTarget) findTarget = host
|
||||
|
||||
onCleanup(() => {
|
||||
findHosts.delete(host)
|
||||
if (findCurrent === host) {
|
||||
findCurrent = undefined
|
||||
clearHighlightFind()
|
||||
}
|
||||
if (findTarget === host) findTarget = undefined
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!findOpen()) return
|
||||
findScroll = getScrollParent(wrapper) ?? undefined
|
||||
const target = findScroll ?? window
|
||||
|
||||
const handler = () => positionFindBar()
|
||||
target.addEventListener("scroll", handler, { passive: true })
|
||||
window.addEventListener("resize", handler, { passive: true })
|
||||
handler()
|
||||
|
||||
onCleanup(() => {
|
||||
target.removeEventListener("scroll", handler)
|
||||
window.removeEventListener("resize", handler)
|
||||
findScroll = undefined
|
||||
})
|
||||
})
|
||||
|
||||
const applyCommentedLines = (ranges: SelectedLineRange[]) => {
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
@@ -189,6 +614,7 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
requestAnimationFrame(() => {
|
||||
if (token !== renderToken) return
|
||||
applySelection(lastSelection)
|
||||
applyFind({ reset: true })
|
||||
local.onRendered?.()
|
||||
})
|
||||
}
|
||||
@@ -466,6 +892,13 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
onCleanup(() => {
|
||||
observer?.disconnect()
|
||||
|
||||
clearOverlayScroll()
|
||||
clearOverlay()
|
||||
if (findCurrent === host) {
|
||||
findCurrent = undefined
|
||||
clearHighlightFind()
|
||||
}
|
||||
|
||||
if (selectionFrame !== undefined) {
|
||||
cancelAnimationFrame(selectionFrame)
|
||||
selectionFrame = undefined
|
||||
@@ -487,11 +920,84 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
<div
|
||||
data-component="code"
|
||||
style={styleVariables}
|
||||
class="relative outline-none"
|
||||
classList={{
|
||||
...(local.classList || {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
ref={container}
|
||||
/>
|
||||
ref={wrapper}
|
||||
tabIndex={0}
|
||||
onPointerDown={() => {
|
||||
findTarget = host
|
||||
wrapper.focus({ preventScroll: true })
|
||||
}}
|
||||
onFocus={() => {
|
||||
findTarget = host
|
||||
}}
|
||||
>
|
||||
<Show when={findOpen()}>
|
||||
<div
|
||||
ref={findBar}
|
||||
class="z-50 flex h-8 items-center gap-2 rounded-md border border-border-base bg-background-base px-3 shadow-md"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Icon name="magnifying-glass" size="small" class="text-text-weak shrink-0" />
|
||||
<input
|
||||
ref={findInput}
|
||||
placeholder="Find"
|
||||
value={findQuery()}
|
||||
class="w-40 bg-transparent outline-none text-14-regular text-text-strong placeholder:text-text-weak"
|
||||
onInput={(e) => {
|
||||
setFindQuery(e.currentTarget.value)
|
||||
setFindIndex(0)
|
||||
applyFind({ reset: true, scroll: true })
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
closeFind()
|
||||
return
|
||||
}
|
||||
if (e.key !== "Enter") return
|
||||
e.preventDefault()
|
||||
stepFind(e.shiftKey ? -1 : 1)
|
||||
}}
|
||||
/>
|
||||
<div class="shrink-0 text-12-regular text-text-weak tabular-nums text-right" style={{ width: "10ch" }}>
|
||||
{findCount() ? `${findIndex() + 1}/${findCount()}` : "0/0"}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
|
||||
disabled={findCount() === 0}
|
||||
aria-label="Previous match"
|
||||
onClick={() => stepFind(-1)}
|
||||
>
|
||||
<Icon name="chevron-down" size="small" class="rotate-180" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
|
||||
disabled={findCount() === 0}
|
||||
aria-label="Next match"
|
||||
onClick={() => stepFind(1)}
|
||||
>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong"
|
||||
aria-label="Close search"
|
||||
onClick={closeFind}
|
||||
>
|
||||
<Icon name="close-small" size="small" />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<div ref={container} />
|
||||
<div ref={findOverlay} class="pointer-events-none absolute inset-0 z-0" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
134
packages/ui/src/components/context-menu.css
Normal file
134
packages/ui/src/components/context-menu.css
Normal file
@@ -0,0 +1,134 @@
|
||||
[data-component="context-menu-content"],
|
||||
[data-component="context-menu-sub-content"] {
|
||||
min-width: 8rem;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-xs-border);
|
||||
background-clip: padding-box;
|
||||
background-color: var(--surface-raised-stronger-non-alpha);
|
||||
padding: 4px;
|
||||
z-index: 100;
|
||||
transform-origin: var(--kb-menu-content-transform-origin);
|
||||
|
||||
&:focus-within,
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
animation: contextMenuContentHide var(--transition-duration) var(--transition-easing) forwards;
|
||||
|
||||
@starting-style {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
&[data-expanded] {
|
||||
pointer-events: auto;
|
||||
animation: contextMenuContentShow var(--transition-duration) var(--transition-easing) forwards;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="context-menu-content"],
|
||||
[data-component="context-menu-sub-content"] {
|
||||
[data-slot="context-menu-item"],
|
||||
[data-slot="context-menu-checkbox-item"],
|
||||
[data-slot="context-menu-radio-item"],
|
||||
[data-slot="context-menu-sub-trigger"] {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: default;
|
||||
outline: none;
|
||||
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-strong);
|
||||
|
||||
transition-property: background-color, color;
|
||||
transition-duration: var(--transition-duration);
|
||||
transition-timing-function: var(--transition-easing);
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--surface-raised-base-hover);
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
color: var(--text-weak);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="context-menu-sub-trigger"] {
|
||||
&[data-expanded] {
|
||||
background: var(--surface-raised-base-hover);
|
||||
outline: none;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="context-menu-item-indicator"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
[data-slot="context-menu-item-label"] {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
[data-slot="context-menu-item-description"] {
|
||||
font-size: var(--font-size-x-small);
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
[data-slot="context-menu-separator"] {
|
||||
height: 1px;
|
||||
margin: 4px -4px;
|
||||
border-top-color: var(--border-weak-base);
|
||||
}
|
||||
|
||||
[data-slot="context-menu-group-label"] {
|
||||
padding: 4px 8px;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-x-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
[data-slot="context-menu-arrow"] {
|
||||
fill: var(--surface-raised-stronger-non-alpha);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contextMenuContentShow {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scaleY(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contextMenuContentHide {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scaleY(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scaleY(0.95);
|
||||
}
|
||||
}
|
||||
308
packages/ui/src/components/context-menu.tsx
Normal file
308
packages/ui/src/components/context-menu.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import { ContextMenu as Kobalte } from "@kobalte/core/context-menu"
|
||||
import { splitProps } from "solid-js"
|
||||
import type { ComponentProps, ParentProps } from "solid-js"
|
||||
|
||||
export interface ContextMenuProps extends ComponentProps<typeof Kobalte> {}
|
||||
export interface ContextMenuTriggerProps extends ComponentProps<typeof Kobalte.Trigger> {}
|
||||
export interface ContextMenuIconProps extends ComponentProps<typeof Kobalte.Icon> {}
|
||||
export interface ContextMenuPortalProps extends ComponentProps<typeof Kobalte.Portal> {}
|
||||
export interface ContextMenuContentProps extends ComponentProps<typeof Kobalte.Content> {}
|
||||
export interface ContextMenuArrowProps extends ComponentProps<typeof Kobalte.Arrow> {}
|
||||
export interface ContextMenuSeparatorProps extends ComponentProps<typeof Kobalte.Separator> {}
|
||||
export interface ContextMenuGroupProps extends ComponentProps<typeof Kobalte.Group> {}
|
||||
export interface ContextMenuGroupLabelProps extends ComponentProps<typeof Kobalte.GroupLabel> {}
|
||||
export interface ContextMenuItemProps extends ComponentProps<typeof Kobalte.Item> {}
|
||||
export interface ContextMenuItemLabelProps extends ComponentProps<typeof Kobalte.ItemLabel> {}
|
||||
export interface ContextMenuItemDescriptionProps extends ComponentProps<typeof Kobalte.ItemDescription> {}
|
||||
export interface ContextMenuItemIndicatorProps extends ComponentProps<typeof Kobalte.ItemIndicator> {}
|
||||
export interface ContextMenuRadioGroupProps extends ComponentProps<typeof Kobalte.RadioGroup> {}
|
||||
export interface ContextMenuRadioItemProps extends ComponentProps<typeof Kobalte.RadioItem> {}
|
||||
export interface ContextMenuCheckboxItemProps extends ComponentProps<typeof Kobalte.CheckboxItem> {}
|
||||
export interface ContextMenuSubProps extends ComponentProps<typeof Kobalte.Sub> {}
|
||||
export interface ContextMenuSubTriggerProps extends ComponentProps<typeof Kobalte.SubTrigger> {}
|
||||
export interface ContextMenuSubContentProps extends ComponentProps<typeof Kobalte.SubContent> {}
|
||||
|
||||
function ContextMenuRoot(props: ContextMenuProps) {
|
||||
return <Kobalte {...props} data-component="context-menu" />
|
||||
}
|
||||
|
||||
function ContextMenuTrigger(props: ParentProps<ContextMenuTriggerProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.Trigger
|
||||
{...rest}
|
||||
data-slot="context-menu-trigger"
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuIcon(props: ParentProps<ContextMenuIconProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.Icon
|
||||
{...rest}
|
||||
data-slot="context-menu-icon"
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.Icon>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuPortal(props: ContextMenuPortalProps) {
|
||||
return <Kobalte.Portal {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuContent(props: ParentProps<ContextMenuContentProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.Content
|
||||
{...rest}
|
||||
data-component="context-menu-content"
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.Content>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuArrow(props: ContextMenuArrowProps) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList"])
|
||||
return (
|
||||
<Kobalte.Arrow
|
||||
{...rest}
|
||||
data-slot="context-menu-arrow"
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSeparator(props: ContextMenuSeparatorProps) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList"])
|
||||
return (
|
||||
<Kobalte.Separator
|
||||
{...rest}
|
||||
data-slot="context-menu-separator"
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuGroup(props: ParentProps<ContextMenuGroupProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.Group
|
||||
{...rest}
|
||||
data-slot="context-menu-group"
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.Group>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuGroupLabel(props: ParentProps<ContextMenuGroupLabelProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.GroupLabel
|
||||
{...rest}
|
||||
data-slot="context-menu-group-label"
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.GroupLabel>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuItem(props: ParentProps<ContextMenuItemProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.Item
|
||||
{...rest}
|
||||
data-slot="context-menu-item"
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuItemLabel(props: ParentProps<ContextMenuItemLabelProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.ItemLabel
|
||||
{...rest}
|
||||
data-slot="context-menu-item-label"
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.ItemLabel>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuItemDescription(props: ParentProps<ContextMenuItemDescriptionProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.ItemDescription
|
||||
{...rest}
|
||||
data-slot="context-menu-item-description"
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.ItemDescription>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuItemIndicator(props: ParentProps<ContextMenuItemIndicatorProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.ItemIndicator
|
||||
{...rest}
|
||||
data-slot="context-menu-item-indicator"
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.ItemIndicator>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup(props: ParentProps<ContextMenuRadioGroupProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.RadioGroup
|
||||
{...rest}
|
||||
data-slot="context-menu-radio-group"
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.RadioGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuRadioItem(props: ParentProps<ContextMenuRadioItemProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.RadioItem
|
||||
{...rest}
|
||||
data-slot="context-menu-radio-item"
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem(props: ParentProps<ContextMenuCheckboxItemProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.CheckboxItem
|
||||
{...rest}
|
||||
data-slot="context-menu-checkbox-item"
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSub(props: ContextMenuSubProps) {
|
||||
return <Kobalte.Sub {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger(props: ParentProps<ContextMenuSubTriggerProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.SubTrigger
|
||||
{...rest}
|
||||
data-slot="context-menu-sub-trigger"
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubContent(props: ParentProps<ContextMenuSubContentProps>) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<Kobalte.SubContent
|
||||
{...rest}
|
||||
data-component="context-menu-sub-content"
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte.SubContent>
|
||||
)
|
||||
}
|
||||
|
||||
export const ContextMenu = Object.assign(ContextMenuRoot, {
|
||||
Trigger: ContextMenuTrigger,
|
||||
Icon: ContextMenuIcon,
|
||||
Portal: ContextMenuPortal,
|
||||
Content: ContextMenuContent,
|
||||
Arrow: ContextMenuArrow,
|
||||
Separator: ContextMenuSeparator,
|
||||
Group: ContextMenuGroup,
|
||||
GroupLabel: ContextMenuGroupLabel,
|
||||
Item: ContextMenuItem,
|
||||
ItemLabel: ContextMenuItemLabel,
|
||||
ItemDescription: ContextMenuItemDescription,
|
||||
ItemIndicator: ContextMenuItemIndicator,
|
||||
RadioGroup: ContextMenuRadioGroup,
|
||||
RadioItem: ContextMenuRadioItem,
|
||||
CheckboxItem: ContextMenuCheckboxItem,
|
||||
Sub: ContextMenuSub,
|
||||
SubTrigger: ContextMenuSubTrigger,
|
||||
SubContent: ContextMenuSubContent,
|
||||
})
|
||||
@@ -1,49 +0,0 @@
|
||||
.cycle-label {
|
||||
--c-duration: 200ms;
|
||||
--c-stagger: 30ms;
|
||||
--c-opacity-start: 0;
|
||||
--c-opacity-end: 1;
|
||||
--c-blur-start: 0px;
|
||||
--c-blur-end: 0px;
|
||||
--c-skew: 10deg;
|
||||
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
|
||||
transform-style: preserve-3d;
|
||||
perspective: 500px;
|
||||
transition: width var(--transition-duration) var(--transition-easing);
|
||||
will-change: width;
|
||||
overflow: hidden;
|
||||
|
||||
.cycle-char {
|
||||
display: inline-block;
|
||||
transform-style: preserve-3d;
|
||||
min-width: 0.25em;
|
||||
backface-visibility: hidden;
|
||||
|
||||
transition-property: transform, opacity, filter;
|
||||
transition-duration: var(--transition-duration);
|
||||
transition-timing-function: var(--transition-easing);
|
||||
transition-delay: calc(var(--i, 0) * var(--c-stagger));
|
||||
|
||||
&.enter {
|
||||
opacity: var(--c-opacity-end);
|
||||
filter: blur(var(--c-blur-end));
|
||||
transform: translateY(0) rotateX(0) skewX(0);
|
||||
}
|
||||
|
||||
&.exit {
|
||||
opacity: var(--c-opacity-start);
|
||||
filter: blur(var(--c-blur-start));
|
||||
transform: translateY(50%) rotateX(90deg) skewX(var(--c-skew));
|
||||
}
|
||||
|
||||
&.pre {
|
||||
opacity: var(--c-opacity-start);
|
||||
filter: blur(var(--c-blur-start));
|
||||
transition: none;
|
||||
transform: translateY(-50%) rotateX(-90deg) skewX(calc(var(--c-skew) * -1));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import "./cycle-label.css"
|
||||
import { createEffect, createSignal, JSX, on } from "solid-js"
|
||||
|
||||
export interface CycleLabelProps extends JSX.HTMLAttributes<HTMLSpanElement> {
|
||||
value: string
|
||||
onValueChange?: (value: string) => void
|
||||
duration?: number | ((value: string) => number)
|
||||
stagger?: number
|
||||
opacity?: [number, number]
|
||||
blur?: [number, number]
|
||||
skewX?: number
|
||||
onAnimationStart?: () => void
|
||||
onAnimationEnd?: () => void
|
||||
}
|
||||
|
||||
const segmenter =
|
||||
typeof Intl !== "undefined" && Intl.Segmenter ? new Intl.Segmenter("en", { granularity: "grapheme" }) : null
|
||||
|
||||
const getChars = (text: string): string[] =>
|
||||
segmenter ? Array.from(segmenter.segment(text), (s) => s.segment) : text.split("")
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
export function CycleLabel(props: CycleLabelProps) {
|
||||
const getDuration = (text: string) => {
|
||||
const d =
|
||||
props.duration ??
|
||||
Number(getComputedStyle(document.documentElement).getPropertyValue("--transition-duration")) ??
|
||||
200
|
||||
return typeof d === "function" ? d(text) : d
|
||||
}
|
||||
const stagger = () => props?.stagger ?? 30
|
||||
const opacity = () => props?.opacity ?? [0, 1]
|
||||
const blur = () => props?.blur ?? [0, 0]
|
||||
const skewX = () => props?.skewX ?? 10
|
||||
|
||||
let containerRef: HTMLSpanElement | undefined
|
||||
let isAnimating = false
|
||||
const [currentText, setCurrentText] = createSignal(props.value)
|
||||
|
||||
const setChars = (el: HTMLElement, text: string, state: "enter" | "exit" | "pre" = "enter") => {
|
||||
el.innerHTML = ""
|
||||
const chars = getChars(text)
|
||||
chars.forEach((char, i) => {
|
||||
const span = document.createElement("span")
|
||||
span.textContent = char === " " ? "\u00A0" : char
|
||||
span.className = `cycle-char ${state}`
|
||||
span.style.setProperty("--i", String(i))
|
||||
el.appendChild(span)
|
||||
})
|
||||
}
|
||||
|
||||
const animateToText = async (newText: string) => {
|
||||
if (!containerRef || isAnimating) return
|
||||
if (newText === currentText()) return
|
||||
|
||||
isAnimating = true
|
||||
props.onAnimationStart?.()
|
||||
|
||||
const dur = getDuration(newText)
|
||||
const stag = stagger()
|
||||
|
||||
containerRef.style.width = containerRef.offsetWidth + "px"
|
||||
|
||||
const oldChars = containerRef.querySelectorAll(".cycle-char")
|
||||
oldChars.forEach((c) => c.classList.replace("enter", "exit"))
|
||||
|
||||
const clone = containerRef.cloneNode(false) as HTMLElement
|
||||
Object.assign(clone.style, {
|
||||
position: "absolute",
|
||||
visibility: "hidden",
|
||||
width: "auto",
|
||||
transition: "none",
|
||||
})
|
||||
setChars(clone, newText)
|
||||
document.body.appendChild(clone)
|
||||
const nextWidth = clone.offsetWidth
|
||||
clone.remove()
|
||||
|
||||
const exitTime = oldChars.length * stag + dur
|
||||
await wait(exitTime * 0.3)
|
||||
|
||||
containerRef.style.width = nextWidth + "px"
|
||||
|
||||
const widthDur = 200
|
||||
await wait(widthDur * 0.3)
|
||||
|
||||
setChars(containerRef, newText, "pre")
|
||||
containerRef.offsetWidth
|
||||
|
||||
Array.from(containerRef.children).forEach((c) => (c.className = "cycle-char enter"))
|
||||
setCurrentText(newText)
|
||||
props.onValueChange?.(newText)
|
||||
|
||||
const enterTime = getChars(newText).length * stag + dur
|
||||
await wait(enterTime)
|
||||
|
||||
containerRef.style.width = ""
|
||||
isAnimating = false
|
||||
props.onAnimationEnd?.()
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.value,
|
||||
(newValue) => {
|
||||
if (newValue !== currentText()) {
|
||||
animateToText(newValue)
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
const initRef = (el: HTMLSpanElement) => {
|
||||
containerRef = el
|
||||
setChars(el, props.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={initRef}
|
||||
class={`cycle-label ${props.class ?? ""}`}
|
||||
style={{
|
||||
"--c-duration": `${getDuration(currentText())}ms`,
|
||||
"--c-stagger": `${stagger()}ms`,
|
||||
"--c-opacity-start": opacity()[0],
|
||||
"--c-opacity-end": opacity()[1],
|
||||
"--c-blur-start": `${blur()[0]}px`,
|
||||
"--c-blur-end": `${blur()[1]}px`,
|
||||
"--c-skew": `${skewX()}deg`,
|
||||
...(typeof props.style === "object" ? props.style : {}),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -2,29 +2,26 @@
|
||||
[data-component="dropdown-menu-sub-content"] {
|
||||
min-width: 8rem;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-xs-border);
|
||||
border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent);
|
||||
background-clip: padding-box;
|
||||
background-color: var(--surface-raised-stronger-non-alpha);
|
||||
padding: 4px;
|
||||
z-index: 100;
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 50;
|
||||
transform-origin: var(--kb-menu-content-transform-origin);
|
||||
|
||||
&:focus-within,
|
||||
&:focus {
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
animation: dropdownMenuContentHide var(--transition-duration) var(--transition-easing) forwards;
|
||||
|
||||
@starting-style {
|
||||
animation: none;
|
||||
&[data-closed] {
|
||||
animation: dropdown-menu-close 0.15s ease-out;
|
||||
}
|
||||
|
||||
&[data-expanded] {
|
||||
pointer-events: auto;
|
||||
animation: dropdownMenuContentShow var(--transition-duration) var(--transition-easing) forwards;
|
||||
animation: dropdown-menu-open 0.15s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,22 +38,18 @@
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
outline: none;
|
||||
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-strong);
|
||||
|
||||
transition-property: background-color, color;
|
||||
transition-duration: var(--transition-duration);
|
||||
transition-timing-function: var(--transition-easing);
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--surface-raised-base-hover);
|
||||
&[data-highlighted] {
|
||||
background: var(--surface-raised-base-hover);
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
@@ -68,8 +61,6 @@
|
||||
[data-slot="dropdown-menu-sub-trigger"] {
|
||||
&[data-expanded] {
|
||||
background: var(--surface-raised-base-hover);
|
||||
outline: none;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,24 +102,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dropdownMenuContentShow {
|
||||
@keyframes dropdown-menu-open {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scaleY(0.95);
|
||||
transform: scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scaleY(1);
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dropdownMenuContentHide {
|
||||
@keyframes dropdown-menu-close {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scaleY(1);
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scaleY(0.95);
|
||||
transform: scale(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,16 +80,13 @@ const icons = {
|
||||
|
||||
export interface IconProps extends ComponentProps<"svg"> {
|
||||
name: keyof typeof icons
|
||||
size?: "small" | "normal" | "medium" | "large" | number
|
||||
size?: "small" | "normal" | "medium" | "large"
|
||||
}
|
||||
|
||||
export function Icon(props: IconProps) {
|
||||
const [local, others] = splitProps(props, ["name", "size", "class", "classList"])
|
||||
return (
|
||||
<div
|
||||
data-component="icon"
|
||||
data-size={typeof local.size !== "number" ? local.size || "normal" : `size-[${local.size}px]`}
|
||||
>
|
||||
<div data-component="icon" data-size={local.size || "normal"}>
|
||||
<svg
|
||||
data-slot="icon-svg"
|
||||
classList={{
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
@property --bottom-fade {
|
||||
syntax: "<length>";
|
||||
inherits: false;
|
||||
initial-value: 0px;
|
||||
}
|
||||
|
||||
@keyframes scroll {
|
||||
0% {
|
||||
--bottom-fade: 20px;
|
||||
}
|
||||
90% {
|
||||
--bottom-fade: 20px;
|
||||
}
|
||||
100% {
|
||||
--bottom-fade: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="list"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
padding: 0 12px;
|
||||
|
||||
@@ -19,9 +37,7 @@
|
||||
flex-shrink: 0;
|
||||
background-color: transparent;
|
||||
opacity: 0.5;
|
||||
transition-property: opacity;
|
||||
transition-duration: var(--transition-duration);
|
||||
transition-timing-function: var(--transition-easing);
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus-visible:not(:disabled),
|
||||
@@ -72,9 +88,7 @@
|
||||
height: 20px;
|
||||
background-color: transparent;
|
||||
opacity: 0.5;
|
||||
transition-property: opacity;
|
||||
transition-duration: var(--transition-duration);
|
||||
transition-timing-function: var(--transition-easing);
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus-visible:not(:disabled),
|
||||
@@ -117,6 +131,15 @@
|
||||
gap: 12px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
mask: linear-gradient(to bottom, #ffff calc(100% - var(--bottom-fade)), #0000);
|
||||
animation: scroll;
|
||||
animation-timeline: --scroll;
|
||||
scroll-timeline: --scroll y;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-slot="list-empty-state"] {
|
||||
display: flex;
|
||||
@@ -192,9 +215,7 @@
|
||||
background: linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha), transparent);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition-property: opacity;
|
||||
transition-duration: var(--transition-duration);
|
||||
transition-timing-function: var(--transition-easing);
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
&[data-stuck="true"]::after {
|
||||
@@ -230,22 +251,17 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
aspect-ratio: 1 / 1;
|
||||
aspect-ratio: 1/1;
|
||||
[data-component="icon"] {
|
||||
color: var(--icon-strong-base);
|
||||
}
|
||||
}
|
||||
|
||||
[name="check"] {
|
||||
color: var(--icon-strong-base);
|
||||
}
|
||||
|
||||
[data-slot="list-item-active-icon"] {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
aspect-ratio: 1 / 1;
|
||||
aspect-ratio: 1/1;
|
||||
[data-component="icon"] {
|
||||
color: var(--icon-strong-base);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useI18n } from "../context/i18n"
|
||||
import { Icon, type IconProps } from "./icon"
|
||||
import { IconButton } from "./icon-button"
|
||||
import { TextField } from "./text-field"
|
||||
import { ScrollFade } from "./scroll-fade"
|
||||
|
||||
function findByKey(container: HTMLElement, key: string) {
|
||||
const nodes = container.querySelectorAll<HTMLElement>('[data-slot="list-item"][data-key]')
|
||||
@@ -51,12 +50,14 @@ export interface ListProps<T> extends FilteredListProps<T> {
|
||||
export interface ListRef {
|
||||
onKeyDown: (e: KeyboardEvent) => void
|
||||
setScrollRef: (el: HTMLDivElement | undefined) => void
|
||||
setFilter: (value: string) => void
|
||||
}
|
||||
|
||||
export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void }) {
|
||||
const i18n = useI18n()
|
||||
const [scrollRef, setScrollRef] = createSignal<HTMLDivElement | undefined>(undefined)
|
||||
const [internalFilter, setInternalFilter] = createSignal("")
|
||||
let inputRef: HTMLInputElement | HTMLTextAreaElement | undefined
|
||||
const [store, setStore] = createStore({
|
||||
mouseActive: false,
|
||||
})
|
||||
@@ -80,7 +81,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
|
||||
container.scrollTop = Math.max(0, Math.min(target, max))
|
||||
}
|
||||
|
||||
const { filter, grouped, flat, active, setActive, onKeyDown, onInput } = useFilteredList<T>(props)
|
||||
const { filter, grouped, flat, active, setActive, onKeyDown, onInput, refetch } = useFilteredList<T>(props)
|
||||
|
||||
const searchProps = () => (typeof props.search === "object" ? props.search : {})
|
||||
const searchAction = () => searchProps().action
|
||||
@@ -89,21 +90,29 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
|
||||
|
||||
const moved = (event: MouseEvent) => event.movementX !== 0 || event.movementY !== 0
|
||||
|
||||
createEffect(() => {
|
||||
if (props.filter !== undefined) {
|
||||
onInput(props.filter)
|
||||
}
|
||||
})
|
||||
const applyFilter = (value: string, options?: { ref?: boolean }) => {
|
||||
const prev = filter()
|
||||
setInternalFilter(value)
|
||||
onInput(value)
|
||||
props.onFilter?.(value)
|
||||
|
||||
createEffect((prev) => {
|
||||
if (!props.search) return
|
||||
const current = internalFilter()
|
||||
if (prev !== current) {
|
||||
onInput(current)
|
||||
props.onFilter?.(current)
|
||||
if (!options?.ref) return
|
||||
|
||||
// Force a refetch even if the value is unchanged.
|
||||
// This is important for programmatic changes like Tab completion.
|
||||
if (prev === value) {
|
||||
refetch()
|
||||
return
|
||||
}
|
||||
return current
|
||||
}, "")
|
||||
queueMicrotask(() => refetch())
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (props.filter === undefined) return
|
||||
if (props.filter === internalFilter()) return
|
||||
setInternalFilter(props.filter)
|
||||
onInput(props.filter)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
@@ -163,9 +172,19 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
|
||||
const index = selected ? all.indexOf(selected) : -1
|
||||
props.onKeyEvent?.(e, selected)
|
||||
|
||||
if (e.defaultPrevented) return
|
||||
|
||||
if (e.key === "Enter" && !e.isComposing) {
|
||||
e.preventDefault()
|
||||
if (selected) handleSelect(selected, index)
|
||||
} else if (props.search) {
|
||||
if (e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && (e.key === "n" || e.key === "p")) {
|
||||
onKeyDown(e)
|
||||
return
|
||||
}
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
onKeyDown(e)
|
||||
}
|
||||
} else {
|
||||
onKeyDown(e)
|
||||
}
|
||||
@@ -174,6 +193,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
|
||||
props.ref?.({
|
||||
onKeyDown: handleKey,
|
||||
setScrollRef,
|
||||
setFilter: (value) => applyFilter(value, { ref: true }),
|
||||
})
|
||||
|
||||
const renderAdd = () => {
|
||||
@@ -236,7 +256,21 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
|
||||
<div data-component="list" classList={{ [props.class ?? ""]: !!props.class }}>
|
||||
<Show when={!!props.search}>
|
||||
<div data-slot="list-search-wrapper">
|
||||
<div data-slot="list-search" classList={{ [searchProps().class ?? ""]: !!searchProps().class }}>
|
||||
<div
|
||||
data-slot="list-search"
|
||||
classList={{ [searchProps().class ?? ""]: !!searchProps().class }}
|
||||
onPointerDown={(event) => {
|
||||
const container = event.currentTarget
|
||||
if (!(container instanceof HTMLElement)) return
|
||||
|
||||
const node = container.querySelector("input, textarea")
|
||||
const input = node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement ? node : inputRef
|
||||
input?.focus()
|
||||
|
||||
// Prevent global listeners (e.g. dnd sensors) from cancelling focus.
|
||||
event.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<div data-slot="list-search-container">
|
||||
<Show when={!searchProps().hideIcon}>
|
||||
<Icon name="magnifying-glass" />
|
||||
@@ -246,8 +280,11 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
|
||||
variant="ghost"
|
||||
data-slot="list-search-input"
|
||||
type="text"
|
||||
ref={(el: HTMLInputElement | HTMLTextAreaElement) => {
|
||||
inputRef = el
|
||||
}}
|
||||
value={internalFilter()}
|
||||
onChange={setInternalFilter}
|
||||
onChange={(value) => applyFilter(value)}
|
||||
onKeyDown={handleKey}
|
||||
placeholder={searchProps().placeholder}
|
||||
spellcheck={false}
|
||||
@@ -260,7 +297,10 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
|
||||
<IconButton
|
||||
icon="circle-x"
|
||||
variant="ghost"
|
||||
onClick={() => setInternalFilter("")}
|
||||
onClick={() => {
|
||||
setInternalFilter("")
|
||||
queueMicrotask(() => inputRef?.focus())
|
||||
}}
|
||||
aria-label={i18n.t("ui.list.clearFilter")}
|
||||
/>
|
||||
</Show>
|
||||
@@ -268,7 +308,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
|
||||
{searchAction()}
|
||||
</div>
|
||||
</Show>
|
||||
<ScrollFade ref={setScrollRef} direction="vertical" fadeStartSize={0} fadeEndSize={20} data-slot="list-scroll">
|
||||
<div ref={setScrollRef} data-slot="list-scroll">
|
||||
<Show
|
||||
when={flat().length > 0 || showAdd()}
|
||||
fallback={
|
||||
@@ -295,6 +335,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
|
||||
data-active={props.key(item) === active()}
|
||||
data-selected={item === props.current}
|
||||
onClick={() => handleSelect(item, i())}
|
||||
onKeyDown={handleKey}
|
||||
type="button"
|
||||
onMouseMove={(event) => {
|
||||
if (!moved(event)) return
|
||||
@@ -340,7 +381,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</ScrollFade>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user