diff --git a/.github/workflows/close-stale-prs.yml b/.github/workflows/close-stale-prs.yml new file mode 100644 index 0000000000..787ee02e62 --- /dev/null +++ b/.github/workflows/close-stale-prs.yml @@ -0,0 +1,83 @@ +name: Close stale PRs + +on: + workflow_dispatch: + inputs: + dryRun: + description: "Log actions without closing PRs" + type: boolean + default: false + schedule: + - cron: "0 6 * * *" + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + close-stale-prs: + runs-on: ubuntu-latest + steps: + - name: Close inactive PRs + uses: actions/github-script@v8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const DAYS_INACTIVE = 60 + const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000) + const { owner, repo } = context.repo + const dryRun = context.payload.inputs?.dryRun === "true" + const stalePrs = [] + + core.info(`Dry run mode: ${dryRun}`) + + const prs = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: "open", + per_page: 100, + sort: "updated", + direction: "asc", + }) + + for (const pr of prs) { + const lastUpdated = new Date(pr.updated_at) + if (lastUpdated > cutoff) { + core.info(`PR ${pr.number} is fresh`) + continue + } + + stalePrs.push(pr) + } + + if (!stalePrs.length) { + core.info("No stale pull requests found.") + return + } + + 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.user.login}`) + continue + } + + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: closeComment, + }) + + await github.rest.pulls.update({ + owner, + repo, + pull_number: issue_number, + state: "closed", + }) + + core.info(`Closed PR #${issue_number} from ${pr.user.login}`) + } diff --git a/.opencode/command/ai-deps.md b/.opencode/command/ai-deps.md index 4d23c76a4d..83783d5b9b 100644 --- a/.opencode/command/ai-deps.md +++ b/.opencode/command/ai-deps.md @@ -7,7 +7,7 @@ Please read @package.json and @packages/opencode/package.json. Your job is to look into AI SDK dependencies, figure out if they have versions that can be upgraded (minor or patch versions ONLY no major ignore major changes). I want a report of every dependency and the version that can be upgraded to. -What would be even better is if you can give me links to the changelog for each dependency, or at least some reference info so I can see what bugs were fixed or new features were added. +What would be even better is if you can give me brief summary of the changes for each dep and a link to the changelog for each dependency, or at least some reference info so I can see what bugs were fixed or new features were added. Consider using subagents for each dep to save your context window. diff --git a/.opencode/command/learn.md b/.opencode/command/learn.md new file mode 100644 index 0000000000..fe4965a588 --- /dev/null +++ b/.opencode/command/learn.md @@ -0,0 +1,42 @@ +--- +description: Extract non-obvious learnings from session to AGENTS.md files to build codebase understanding +--- + +Analyze this session and extract non-obvious learnings to add to AGENTS.md files. + +AGENTS.md files can exist at any directory level, not just the project root. When an agent reads a file, any AGENTS.md in parent directories are automatically loaded into the context of the tool read. Place learnings as close to the relevant code as possible: + +- Project-wide learnings → root AGENTS.md +- Package/module-specific → packages/foo/AGENTS.md +- Feature-specific → src/auth/AGENTS.md + +What counts as a learning (non-obvious discoveries only): + +- Hidden relationships between files or modules +- Execution paths that differ from how code appears +- Non-obvious configuration, env vars, or flags +- Debugging breakthroughs when error messages were misleading +- API/tool quirks and workarounds +- Build/test commands not in README +- Architectural decisions and constraints +- Files that must change together + +What NOT to include: + +- Obvious facts from documentation +- Standard language/framework behavior +- Things already in an AGENTS.md +- Verbose explanations +- Session-specific details + +Process: + +1. Review session for discoveries, errors that took multiple attempts, unexpected connections +2. Determine scope - what directory does each learning apply to? +3. Read existing AGENTS.md files at relevant levels +4. Create or update AGENTS.md at the appropriate level +5. Keep entries to 1-3 lines per insight + +After updating, summarize which AGENTS.md files were created/updated and how many learnings per file. + +$ARGUMENTS diff --git a/README.ar.md b/README.ar.md index 9ce00c67aa..2abceb300d 100644 --- a/README.ar.md +++ b/README.ar.md @@ -22,6 +22,7 @@ Deutsch | Español | Français | + Italiano | Dansk | 日本語 | Polski | diff --git a/README.br.md b/README.br.md index efa7a9ea74..6a58241c98 100644 --- a/README.br.md +++ b/README.br.md @@ -22,6 +22,7 @@ Deutsch | Español | Français | + Italiano | Dansk | 日本語 | Polski | diff --git a/README.da.md b/README.da.md index a3cc7c4146..7e7dda42a8 100644 --- a/README.da.md +++ b/README.da.md @@ -22,6 +22,7 @@ Deutsch | Español | Français | + Italiano | Dansk | 日本語 | Polski | diff --git a/README.de.md b/README.de.md index 189171ea3c..c949dd00f4 100644 --- a/README.de.md +++ b/README.de.md @@ -22,6 +22,7 @@ Deutsch | Español | Français | + Italiano | Dansk | 日本語 | Polski | diff --git a/README.es.md b/README.es.md index d6530b1dd0..3e3797ed30 100644 --- a/README.es.md +++ b/README.es.md @@ -22,6 +22,7 @@ Deutsch | Español | Français | + Italiano | Dansk | 日本語 | Polski | diff --git a/README.fr.md b/README.fr.md index 520ed3a061..00133b1e9f 100644 --- a/README.fr.md +++ b/README.fr.md @@ -22,6 +22,7 @@ Deutsch | Español | Français | + Italiano | Dansk | 日本語 | Polski | diff --git a/README.it.md b/README.it.md new file mode 100644 index 0000000000..89692a3668 --- /dev/null +++ b/README.it.md @@ -0,0 +1,133 @@ +

+ + + + + Logo OpenCode + + +

+

L’agente di coding AI open source.

+

+ Discord + npm + Build status +

+ +

+ English | + 简体中文 | + 繁體中文 | + 한국어 | + Deutsch | + Español | + Français | + Italiano | + Dansk | + 日本語 | + Polski | + Русский | + العربية | + Norsk | + Português (Brasil) +

+ +[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) + +--- + +### Installazione + +```bash +# YOLO +curl -fsSL https://opencode.ai/install | bash + +# Package manager +npm i -g opencode-ai@latest # oppure bun/pnpm/yarn +scoop install opencode # Windows +choco install opencode # Windows +brew install anomalyco/tap/opencode # macOS e Linux (consigliato, sempre aggiornato) +brew install opencode # macOS e Linux (formula brew ufficiale, aggiornata meno spesso) +paru -S opencode-bin # Arch Linux +mise use -g opencode # Qualsiasi OS +nix run nixpkgs#opencode # oppure github:anomalyco/opencode per l’ultima branch di sviluppo +``` + +> [!TIP] +> Rimuovi le versioni precedenti alla 0.1.x prima di installare. + +### App Desktop (BETA) + +OpenCode è disponibile anche come applicazione desktop. Puoi scaricarla direttamente dalla [pagina delle release](https://github.com/anomalyco/opencode/releases) oppure da [opencode.ai/download](https://opencode.ai/download). + +| Piattaforma | Download | +| --------------------- | ------------------------------------- | +| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | +| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | +| Windows | `opencode-desktop-windows-x64.exe` | +| Linux | `.deb`, `.rpm`, oppure AppImage | + +```bash +# macOS (Homebrew) +brew install --cask opencode-desktop +# Windows (Scoop) +scoop bucket add extras; scoop install extras/opencode-desktop +``` + +#### Directory di installazione + +Lo script di installazione rispetta il seguente ordine di priorità per il percorso di installazione: + +1. `$OPENCODE_INSTALL_DIR` – Directory di installazione personalizzata +2. `$XDG_BIN_DIR` – Percorso conforme alla XDG Base Directory Specification +3. `$HOME/bin` – Directory binaria standard dell’utente (se esiste o può essere creata) +4. `$HOME/.opencode/bin` – Fallback predefinito + +```bash +# Esempi +OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash +XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash +``` + +### Agenti + +OpenCode include due agenti integrati tra cui puoi passare usando il tasto `Tab`. + +- **build** – Predefinito, agente con accesso completo per il lavoro di sviluppo +- **plan** – Agente in sola lettura per analisi ed esplorazione del codice + - Nega le modifiche ai file per impostazione predefinita + - Chiede il permesso prima di eseguire comandi bash + - Ideale per esplorare codebase sconosciute o pianificare modifiche + +È inoltre incluso un sotto-agente **general** per ricerche complesse e attività multi-step. +Viene utilizzato internamente e può essere invocato usando `@general` nei messaggi. + +Scopri di più sugli [agenti](https://opencode.ai/docs/agents). + +### Documentazione + +Per maggiori informazioni su come configurare OpenCode, [**consulta la nostra documentazione**](https://opencode.ai/docs). + +### Contribuire + +Se sei interessato a contribuire a OpenCode, leggi la nostra [guida alla contribuzione](./CONTRIBUTING.md) prima di inviare una pull request. + +### Costruire su OpenCode + +Se stai lavorando a un progetto correlato a OpenCode e che utilizza “opencode” come parte del nome (ad esempio “opencode-dashboard” o “opencode-mobile”), aggiungi una nota nel tuo README per chiarire che non è sviluppato dal team OpenCode e che non è affiliato in alcun modo con noi. + +### FAQ + +#### In cosa è diverso da Claude Code? + +È molto simile a Claude Code in termini di funzionalità. Ecco le principali differenze: + +- 100% open source +- Non è legato a nessun provider. Anche se consigliamo i modelli forniti tramite [OpenCode Zen](https://opencode.ai/zen), OpenCode può essere utilizzato con Claude, OpenAI, Google o persino modelli locali. Con l’evoluzione dei modelli, le differenze tra di essi si ridurranno e i prezzi scenderanno, quindi essere indipendenti dal provider è importante. +- Supporto LSP pronto all’uso +- Forte attenzione alla TUI. OpenCode è sviluppato da utenti neovim e dai creatori di [terminal.shop](https://terminal.shop); spingeremo al limite ciò che è possibile fare nel terminale. +- Architettura client/server. Questo, ad esempio, permette a OpenCode di girare sul tuo computer mentre lo controlli da remoto tramite un’app mobile. La frontend TUI è quindi solo uno dei possibili client. + +--- + +**Unisciti alla nostra community** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) diff --git a/README.ja.md b/README.ja.md index 271bcc4e1f..5f3a9e189e 100644 --- a/README.ja.md +++ b/README.ja.md @@ -22,6 +22,7 @@ Deutsch | Español | Français | + Italiano | Dansk | 日本語 | Polski | diff --git a/README.ko.md b/README.ko.md index a5e299400f..213f46bfe7 100644 --- a/README.ko.md +++ b/README.ko.md @@ -22,6 +22,7 @@ Deutsch | Español | Français | + Italiano | Dansk | 日本語 | Polski | diff --git a/README.md b/README.md index 1ee5f26975..d0acb758d9 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Deutsch | Español | Français | + Italiano | Dansk | 日本語 | Polski | diff --git a/README.no.md b/README.no.md index 43f23b8894..44371df5ed 100644 --- a/README.no.md +++ b/README.no.md @@ -22,6 +22,7 @@ Deutsch | Español | Français | + Italiano | Dansk | 日本語 | Polski | diff --git a/README.pl.md b/README.pl.md index 6465879b15..b183cd6245 100644 --- a/README.pl.md +++ b/README.pl.md @@ -22,6 +22,7 @@ Deutsch | Español | Français | + Italiano | Dansk | 日本語 | Polski | diff --git a/README.ru.md b/README.ru.md index 5ace29b849..c192036b54 100644 --- a/README.ru.md +++ b/README.ru.md @@ -22,6 +22,7 @@ Deutsch | Español | Français | + Italiano | Dansk | 日本語 | Polski | diff --git a/README.zh.md b/README.zh.md index 9908e8ffb1..9ebbe8ce93 100644 --- a/README.zh.md +++ b/README.zh.md @@ -22,6 +22,7 @@ Deutsch | Español | Français | + Italiano | Dansk | 日本語 | Polski | diff --git a/README.zht.md b/README.zht.md index e06d681aa8..298b5b35ac 100644 --- a/README.zht.md +++ b/README.zht.md @@ -22,6 +22,7 @@ Deutsch | Español | Français | + Italiano | Dansk | 日本語 | Polski | diff --git a/STATS.md b/STATS.md index 4aa9a37b82..f00ebd72a3 100644 --- a/STATS.md +++ b/STATS.md @@ -212,3 +212,4 @@ | 2026-01-24 | 6,371,019 (+274,783) | 2,156,870 (+60,635) | 8,527,889 (+335,418) | | 2026-01-25 | 6,639,082 (+268,063) | 2,187,853 (+30,983) | 8,826,935 (+299,046) | | 2026-01-26 | 6,941,620 (+302,538) | 2,232,115 (+44,262) | 9,173,735 (+346,800) | +| 2026-01-27 | 7,208,093 (+266,473) | 2,280,762 (+48,647) | 9,488,855 (+315,120) | diff --git a/bun.lock b/bun.lock index 25724edd9e..96c3f952be 100644 --- a/bun.lock +++ b/bun.lock @@ -186,6 +186,7 @@ "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@solid-primitives/i18n": "2.2.1", "@solid-primitives/storage": "catalog:", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "~2", @@ -296,8 +297,8 @@ "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", "@openrouter/ai-sdk-provider": "1.5.2", - "@opentui/core": "0.1.74", - "@opentui/solid": "0.1.74", + "@opentui/core": "0.1.75", + "@opentui/solid": "0.1.75", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", @@ -1246,21 +1247,21 @@ "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], - "@opentui/core": ["@opentui/core@0.1.74", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.74", "@opentui/core-darwin-x64": "0.1.74", "@opentui/core-linux-arm64": "0.1.74", "@opentui/core-linux-x64": "0.1.74", "@opentui/core-win32-arm64": "0.1.74", "@opentui/core-win32-x64": "0.1.74", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-g4W16ymv12JdgZ+9B4t7mpIICvzWy2+eHERfmDf80ALduOQCUedKQdULcBFhVCYUXIkDRtIy6CID5thMAah3FA=="], + "@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-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.74", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rfmlDLtm/u17CnuhJgCxPeYMvOST+A2MOdVOk46IurtHO849bdYqK6iudKNlFRs1FOrymgSKF9GlWBHAOKeRjg=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.75", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gGaGZjkFpqcXJk6321JzhRl66pM2VxBlI470L8W4DQUW4S6iDT1R9L7awSzGB4Cn9toUl7DTV8BemaXZYXV4SA=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.74", "", { "os": "darwin", "cpu": "x64" }, "sha512-WAD8orsDV0ZdW/5GwjOOB4FY96772xbkz+rcV7WRzEFUVaqoBaC04IuqYzS9d5s+cjkbT5Cpj47hrVYkkVQKng=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.75", "", { "os": "darwin", "cpu": "x64" }, "sha512-tPlvqQI0whZ76amHydpJs5kN+QeWAIcFbI8RAtlAo9baj2EbxTDC+JGwgb9Fnt0/YQx831humbtaNDhV2Jt1bw=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.74", "", { "os": "linux", "cpu": "arm64" }, "sha512-lgmHzrzLy4e+rgBS+lhtsMLLgIMLbtLNMm6EzVPyYVDlLDGjM7+ulXMem7AtpaRrWrUUl4REiG9BoQUsCFDwYA=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.75", "", { "os": "linux", "cpu": "arm64" }, "sha512-nVxIQ4Hqf84uBergDpWiVzU6pzpjy6tqBHRQpySxZ2flkJ/U6/aMEizVrQ1jcgIdxZtvqWDETZhzxhG0yDx+cw=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.74", "", { "os": "linux", "cpu": "x64" }, "sha512-8Mn2WbdBQ29xCThuPZezjDhd1N3+fXwKkGvCBOdTI0le6h2A/vCNbfUVjwfr/EGZSRXxCG+Yapol34BAULGpOA=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.75", "", { "os": "linux", "cpu": "x64" }, "sha512-1CnApef4kxA+ORyLfbuCLgZfEjp4wr3HjFnt7FAfOb73kIZH82cb7JYixeqRyy9eOcKfKqxLmBYy3o8IDkc4Rg=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.74", "", { "os": "win32", "cpu": "arm64" }, "sha512-dvYUXz03avnI6ZluyLp00HPmR0UT/IE/6QS97XBsgJlUTtpnbKkBtB5jD1NHwWkElaRj1Qv2QP36ngFoJqbl9g=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.75", "", { "os": "win32", "cpu": "arm64" }, "sha512-j0UB95nmkYGNzmOrs6GqaddO1S90R0YC6IhbKnbKBdjchFPNVLz9JpexAs6MBDXPZwdKAywMxtwG2h3aTJtxng=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.74", "", { "os": "win32", "cpu": "x64" }, "sha512-3wfWXaAKOIlDQz6ZZIESf2M+YGZ7uFHijjTEM8w/STRlLw8Y6+QyGYi1myHSM4d6RSO+/s2EMDxvjDf899W9vQ=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.75", "", { "os": "win32", "cpu": "x64" }, "sha512-ESpVZVGewe3JkB2TwrG3VRbkxT909iPdtvgNT7xTCIYH2VB4jqZomJfvERPTE0tvqAZJm19mHECzJFI8asSJgQ=="], - "@opentui/solid": ["@opentui/solid@0.1.74", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.74", "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-Vz82cI8T9YeJjGsVg4ULp6ral4N+xyt1j9A6Tbu3aaQgEKiB74LW03EXREehfjPr1irOFxtKfWPbx5NKH0Upag=="], + "@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=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], diff --git a/nix/hashes.json b/nix/hashes.json index af22d7711a..0b735b35d6 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-EAvkIGLyoGd5XJAvrp5Zox5q5hzeIYqnLqqFiXZXCh4=", - "aarch64-linux": "sha256-LCCWwwJPgm0PPkqujjWZDf3L4pMo2epvmT2HUjwp2RA=", - "aarch64-darwin": "sha256-K3tUTy7hR2mYyq+mWQPcnnAVQgZF32FgrbHoqX58h8o=", - "x86_64-darwin": "sha256-F/C2dezX+sKH0ZcmUGSxEXkEXWI6YGhOeVdF3wxF94M=" + "x86_64-linux": "sha256-9oI1gekRbjY6L8VwlkLdPty/9rCxC20EJlESkazEX8Y=", + "aarch64-linux": "sha256-vn+eCVanOSNfjyqHRJn4VdqbpdMoBFm49REuIkByAio=", + "aarch64-darwin": "sha256-0dMP5WbqDq3qdLRrKfmCjXz2kUDjTttGTqD3v6PDbkg=", + "x86_64-darwin": "sha256-9dEWluRXY7RTPdSEhhPsDJeGo+qa3V8dqh6n6WsLeGw=" } } diff --git a/packages/app/e2e/file-tree.spec.ts b/packages/app/e2e/file-tree.spec.ts new file mode 100644 index 0000000000..12ea7a081f --- /dev/null +++ b/packages/app/e2e/file-tree.spec.ts @@ -0,0 +1,36 @@ +import { test, expect } from "./fixtures" + +test("file tree can expand folders and open a file", async ({ page, gotoSession }) => { + await gotoSession() + + await page.getByRole("button", { name: "Toggle file tree" }).click() + + const treeTabs = page.locator('[data-component="tabs"][data-variant="pill"][data-scope="filetree"]') + await expect(treeTabs).toBeVisible() + + await treeTabs.locator('[data-slot="tabs-trigger"]').nth(1).click() + + const node = (name: string) => treeTabs.getByRole("button", { name, exact: true }) + + await expect(node("packages")).toBeVisible() + await node("packages").click() + + await expect(node("app")).toBeVisible() + await node("app").click() + + await expect(node("src")).toBeVisible() + await node("src").click() + + await expect(node("components")).toBeVisible() + await node("components").click() + + await expect(node("file-tree.tsx")).toBeVisible() + await node("file-tree.tsx").click() + + const tab = page.getByRole("tab", { name: "file-tree.tsx" }) + await expect(tab).toBeVisible() + await tab.click() + + const code = page.locator('[data-component="code"]').first() + await expect(code.getByText("export default function FileTree")).toBeVisible() +}) diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index 721d60049c..c5315ff194 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -1,5 +1,5 @@ import { test as base, expect } from "@playwright/test" -import { createSdk, dirSlug, getWorktree, promptSelector, sessionPath } from "./utils" +import { createSdk, dirSlug, getWorktree, promptSelector, serverUrl, sessionPath } from "./utils" type TestFixtures = { sdk: ReturnType @@ -29,6 +29,55 @@ export const test = base.extend({ await use(createSdk(directory)) }, gotoSession: async ({ page, directory }, use) => { + await page.addInitScript( + (input: { directory: string; serverUrl: string }) => { + const key = "opencode.global.dat:server" + const raw = localStorage.getItem(key) + const parsed = (() => { + if (!raw) return undefined + try { + return JSON.parse(raw) as unknown + } catch { + return undefined + } + })() + + const store = parsed && typeof parsed === "object" ? (parsed as Record) : {} + const list = Array.isArray(store.list) ? store.list : [] + const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {} + const projects = store.projects && typeof store.projects === "object" ? store.projects : {} + const nextProjects = { ...(projects as Record) } + + const add = (origin: string) => { + const current = nextProjects[origin] + const items = Array.isArray(current) ? current : [] + const existing = items.filter( + (p): p is { worktree: string; expanded?: boolean } => + !!p && + typeof p === "object" && + "worktree" in p && + typeof (p as { worktree?: unknown }).worktree === "string", + ) + + if (existing.some((p) => p.worktree === input.directory)) return + nextProjects[origin] = [{ worktree: input.directory, expanded: true }, ...existing] + } + + add("local") + add(input.serverUrl) + + localStorage.setItem( + key, + JSON.stringify({ + list, + projects: nextProjects, + lastProject, + }), + ) + }, + { directory, serverUrl }, + ) + const gotoSession = async (sessionID?: string) => { await page.goto(sessionPath(directory, sessionID)) await expect(page.locator(promptSelector)).toBeVisible() diff --git a/packages/app/e2e/models-visibility.spec.ts b/packages/app/e2e/models-visibility.spec.ts new file mode 100644 index 0000000000..680ba96a31 --- /dev/null +++ b/packages/app/e2e/models-visibility.spec.ts @@ -0,0 +1,86 @@ +import { test, expect } from "./fixtures" +import { modKey, promptSelector } from "./utils" + +test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => { + await gotoSession() + + await page.locator(promptSelector).click() + await page.keyboard.type("/model") + + const command = page.locator('[data-slash-id="model.choose"]') + await expect(command).toBeVisible() + await command.hover() + await page.keyboard.press("Enter") + + const picker = page.getByRole("dialog") + await expect(picker).toBeVisible() + + const target = picker.locator('[data-slot="list-item"]').first() + await expect(target).toBeVisible() + + const key = await target.getAttribute("data-key") + if (!key) throw new Error("Failed to resolve model key from list item") + + const name = (await target.locator("span").first().innerText()).trim() + if (!name) throw new Error("Failed to resolve model name from list item") + + await page.keyboard.press("Escape") + await expect(picker).toHaveCount(0) + + const settings = page.getByRole("dialog") + + await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined) + const opened = await settings + .waitFor({ state: "visible", timeout: 3000 }) + .then(() => true) + .catch(() => false) + + if (!opened) { + await page.getByRole("button", { name: "Settings" }).first().click() + await expect(settings).toBeVisible() + } + + await settings.getByRole("tab", { name: "Models" }).click() + const search = settings.getByPlaceholder("Search models") + await expect(search).toBeVisible() + await search.fill(name) + + const toggle = settings.locator('[data-component="switch"]').filter({ hasText: name }).first() + const input = toggle.locator('[data-slot="switch-input"]') + await expect(toggle).toBeVisible() + await expect(input).toHaveAttribute("aria-checked", "true") + await toggle.locator('[data-slot="switch-control"]').click() + await expect(input).toHaveAttribute("aria-checked", "false") + + await page.keyboard.press("Escape") + const closed = await settings + .waitFor({ state: "detached", timeout: 1500 }) + .then(() => true) + .catch(() => false) + if (!closed) { + await page.keyboard.press("Escape") + const closedSecond = await settings + .waitFor({ state: "detached", timeout: 1500 }) + .then(() => true) + .catch(() => false) + if (!closedSecond) { + await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } }) + await expect(settings).toHaveCount(0) + } + } + + await page.locator(promptSelector).click() + await page.keyboard.type("/model") + await expect(command).toBeVisible() + await command.hover() + await page.keyboard.press("Enter") + + const pickerAgain = page.getByRole("dialog") + await expect(pickerAgain).toBeVisible() + await expect(pickerAgain.locator('[data-slot="list-item"]').first()).toBeVisible() + + await expect(pickerAgain.locator(`[data-slot="list-item"][data-key="${key}"]`)).toHaveCount(0) + + await page.keyboard.press("Escape") + await expect(pickerAgain).toHaveCount(0) +}) diff --git a/packages/app/e2e/server-default.spec.ts b/packages/app/e2e/server-default.spec.ts new file mode 100644 index 0000000000..b6b16f0bcc --- /dev/null +++ b/packages/app/e2e/server-default.spec.ts @@ -0,0 +1,67 @@ +import { test, expect } from "./fixtures" +import { serverName, serverUrl } from "./utils" + +const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl" + +test("can set a default server on web", async ({ page, gotoSession }) => { + await page.addInitScript((key: string) => { + try { + localStorage.removeItem(key) + } catch { + return + } + }, DEFAULT_SERVER_URL_KEY) + + await gotoSession() + + const status = page.getByRole("button", { name: "Status" }) + await expect(status).toBeVisible() + const popover = page.locator('[data-component="popover-content"]').filter({ hasText: "Manage servers" }) + + const ensurePopoverOpen = async () => { + if (await popover.isVisible()) return + await status.click() + await expect(popover).toBeVisible() + } + + await ensurePopoverOpen() + await popover.getByRole("button", { name: "Manage servers" }).click() + + const dialog = page.getByRole("dialog") + await expect(dialog).toBeVisible() + + const row = dialog.locator('[data-slot="list-item"]').filter({ hasText: serverName }).first() + await expect(row).toBeVisible() + + const menu = row.locator('[data-component="icon-button"]').last() + await menu.click() + await page.getByRole("menuitem", { name: "Set as default" }).click() + + await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)).toBe(serverUrl) + await expect(row.getByText("Default", { exact: true })).toBeVisible() + + await page.keyboard.press("Escape") + const closed = await dialog + .waitFor({ state: "detached", timeout: 1500 }) + .then(() => true) + .catch(() => false) + + if (!closed) { + await page.keyboard.press("Escape") + const closedSecond = await dialog + .waitFor({ state: "detached", timeout: 1500 }) + .then(() => true) + .catch(() => false) + + if (!closedSecond) { + await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } }) + await expect(dialog).toHaveCount(0) + } + } + + await ensurePopoverOpen() + + const serverRow = popover.locator("button").filter({ hasText: serverName }).first() + await expect(serverRow).toBeVisible() + await expect(serverRow.getByText("Default", { exact: true })).toBeVisible() +}) diff --git a/packages/app/e2e/settings-providers.spec.ts b/packages/app/e2e/settings-providers.spec.ts new file mode 100644 index 0000000000..326a9fad1d --- /dev/null +++ b/packages/app/e2e/settings-providers.spec.ts @@ -0,0 +1,56 @@ +import { test, expect } from "./fixtures" +import { modKey, promptSelector } from "./utils" + +test("smoke providers settings opens provider selector", async ({ page, gotoSession }) => { + await gotoSession() + + const dialog = page.getByRole("dialog") + + await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined) + + const opened = await dialog + .waitFor({ state: "visible", timeout: 3000 }) + .then(() => true) + .catch(() => false) + + if (!opened) { + await page.getByRole("button", { name: "Settings" }).first().click() + await expect(dialog).toBeVisible() + } + + await dialog.getByRole("tab", { name: "Providers" }).click() + await expect(dialog.getByText("Connected providers", { exact: true })).toBeVisible() + await expect(dialog.getByText("Popular providers", { exact: true })).toBeVisible() + + await dialog.getByRole("button", { name: "Show more providers" }).click() + + const providerDialog = page.getByRole("dialog").filter({ has: page.getByPlaceholder("Search providers") }) + + await expect(providerDialog).toBeVisible() + await expect(providerDialog.getByPlaceholder("Search providers")).toBeVisible() + await expect(providerDialog.locator('[data-slot="list-item"]').first()).toBeVisible() + + await page.keyboard.press("Escape") + await expect(providerDialog).toHaveCount(0) + await expect(page.locator(promptSelector)).toBeVisible() + + const stillOpen = await dialog.isVisible().catch(() => false) + if (!stillOpen) return + + await page.keyboard.press("Escape") + const closed = await dialog + .waitFor({ state: "detached", timeout: 1500 }) + .then(() => true) + .catch(() => false) + if (closed) return + + await page.keyboard.press("Escape") + const closedSecond = await dialog + .waitFor({ state: "detached", timeout: 1500 }) + .then(() => true) + .catch(() => false) + if (closedSecond) return + + await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } }) + await expect(dialog).toHaveCount(0) +}) diff --git a/packages/app/e2e/sidebar-session-links.spec.ts b/packages/app/e2e/sidebar-session-links.spec.ts new file mode 100644 index 0000000000..fab64736e2 --- /dev/null +++ b/packages/app/e2e/sidebar-session-links.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from "./fixtures" +import { modKey, promptSelector } from "./utils" + +type Locator = { + first: () => Locator + getAttribute: (name: string) => Promise + scrollIntoViewIfNeeded: () => Promise + click: () => Promise +} + +type Page = { + locator: (selector: string) => Locator + keyboard: { + press: (key: string) => Promise + } +} + +type Fixtures = { + page: Page + slug: string + sdk: { + session: { + create: (input: { title: string }) => Promise<{ data?: { id?: string } }> + delete: (input: { sessionID: string }) => Promise + } + } + gotoSession: (sessionID?: string) => Promise +} + +test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }: Fixtures) => { + const stamp = Date.now() + + const one = await sdk.session.create({ title: `e2e sidebar nav 1 ${stamp}` }).then((r) => r.data) + const two = await sdk.session.create({ title: `e2e sidebar nav 2 ${stamp}` }).then((r) => r.data) + + if (!one?.id) throw new Error("Session create did not return an id") + if (!two?.id) throw new Error("Session create did not return an id") + + try { + await gotoSession(one.id) + + const main = page.locator("main") + const collapsed = ((await main.getAttribute("class")) ?? "").includes("xl:border-l") + if (collapsed) { + await page.keyboard.press(`${modKey}+B`) + await expect(main).not.toHaveClass(/xl:border-l/) + } + + const target = page.locator(`[data-session-id="${two.id}"] a`).first() + await expect(target).toBeVisible() + await target.scrollIntoViewIfNeeded() + await target.click() + + await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`)) + await expect(page.locator(promptSelector)).toBeVisible() + await expect(page.locator(`[data-session-id="${two.id}"] a`).first()).toHaveClass(/\bactive\b/) + } finally { + await sdk.session.delete({ sessionID: one.id }).catch(() => undefined) + await sdk.session.delete({ sessionID: two.id }).catch(() => undefined) + } +}) diff --git a/packages/app/e2e/titlebar-history.spec.ts b/packages/app/e2e/titlebar-history.spec.ts new file mode 100644 index 0000000000..b8141b9829 --- /dev/null +++ b/packages/app/e2e/titlebar-history.spec.ts @@ -0,0 +1,52 @@ +import { test, expect } from "./fixtures" +import { modKey, promptSelector } from "./utils" + +test("titlebar back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + + const stamp = Date.now() + const one = await sdk.session.create({ title: `e2e titlebar history 1 ${stamp}` }).then((r) => r.data) + const two = await sdk.session.create({ title: `e2e titlebar history 2 ${stamp}` }).then((r) => r.data) + + if (!one?.id) throw new Error("Session create did not return an id") + if (!two?.id) throw new Error("Session create did not return an id") + + try { + await gotoSession(one.id) + + const main = page.locator("main") + const collapsed = ((await main.getAttribute("class")) ?? "").includes("xl:border-l") + if (collapsed) { + await page.keyboard.press(`${modKey}+B`) + await expect(main).not.toHaveClass(/xl:border-l/) + } + + const link = page.locator(`[data-session-id="${two.id}"] a`).first() + await expect(link).toBeVisible() + await link.scrollIntoViewIfNeeded() + await link.click() + + await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`)) + await expect(page.locator(promptSelector)).toBeVisible() + + const back = page.getByRole("button", { name: "Go back" }) + const forward = page.getByRole("button", { name: "Go forward" }) + + await expect(back).toBeVisible() + await expect(back).toBeEnabled() + await back.click() + + await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`)) + await expect(page.locator(promptSelector)).toBeVisible() + + await expect(forward).toBeVisible() + await expect(forward).toBeEnabled() + await forward.click() + + await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`)) + await expect(page.locator(promptSelector)).toBeVisible() + } finally { + await sdk.session.delete({ sessionID: one.id }).catch(() => undefined) + await sdk.session.delete({ sessionID: two.id }).catch(() => undefined) + } +}) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index d5009c8d1d..ba0d1e7aa4 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -14,22 +14,22 @@ import { GlobalSyncProvider } from "@/context/global-sync" import { PermissionProvider } from "@/context/permission" import { LayoutProvider } from "@/context/layout" import { GlobalSDKProvider } from "@/context/global-sdk" -import { ServerProvider, useServer } from "@/context/server" +import { normalizeServerUrl, ServerProvider, useServer } from "@/context/server" import { SettingsProvider } from "@/context/settings" import { TerminalProvider } from "@/context/terminal" import { PromptProvider } from "@/context/prompt" import { FileProvider } from "@/context/file" import { CommentsProvider } from "@/context/comments" import { NotificationProvider } from "@/context/notification" +import { ModelsProvider } from "@/context/models" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { CommandProvider } from "@/context/command" import { LanguageProvider, useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" -import { Logo } from "@opencode-ai/ui/logo" +import { HighlightsProvider } from "@/context/highlights" import Layout from "@/pages/layout" import DirectoryLayout from "@/pages/directory-layout" import { ErrorPage } from "./pages/error" -import { iife } from "@opencode-ai/util/iife" import { Suspense } from "solid-js" const Home = lazy(() => import("@/pages/home")) @@ -85,8 +85,19 @@ function ServerKey(props: ParentProps) { } export function AppInterface(props: { defaultUrl?: string }) { + const platform = usePlatform() + + const stored = (() => { + if (platform.platform !== "web") return + const result = platform.getDefaultServerUrl?.() + if (result instanceof Promise) return + if (!result) return + return normalizeServerUrl(result) + })() + const defaultServerUrl = () => { if (props.defaultUrl) return props.defaultUrl + if (stored) return stored if (location.hostname.includes("opencode.ai")) return "http://localhost:4096" if (import.meta.env.DEV) return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` @@ -105,9 +116,13 @@ export function AppInterface(props: { defaultUrl?: string }) { - - {props.children} - + + + + {props.children} + + + diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 5c762ee9de..65e322b434 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -27,6 +27,17 @@ export function DialogConnectProvider(props: { provider: string }) { const globalSDK = useGlobalSDK() const platform = usePlatform() const language = useLanguage() + + const alive = { value: true } + const timer = { current: undefined as ReturnType | undefined } + + onCleanup(() => { + alive.value = false + if (timer.current === undefined) return + clearTimeout(timer.current) + timer.current = undefined + }) + const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!) const methods = createMemo( () => @@ -53,6 +64,11 @@ export function DialogConnectProvider(props: { provider: string }) { } async function selectMethod(index: number) { + if (timer.current !== undefined) { + clearTimeout(timer.current) + timer.current = undefined + } + const method = methods()[index] setStore( produce((draft) => { @@ -75,11 +91,15 @@ export function DialogConnectProvider(props: { provider: string }) { { throwOnError: true }, ) .then((x) => { + if (!alive.value) return const elapsed = Date.now() - start const delay = 1000 - elapsed if (delay > 0) { - setTimeout(() => { + if (timer.current !== undefined) clearTimeout(timer.current) + timer.current = setTimeout(() => { + timer.current = undefined + if (!alive.value) return setStore("state", "complete") setStore("authorization", x.data!) }, delay) @@ -89,6 +109,7 @@ export function DialogConnectProvider(props: { provider: string }) { setStore("authorization", x.data!) }) .catch((e) => { + if (!alive.value) return setStore("state", "error") setStore("error", String(e)) }) @@ -372,26 +393,33 @@ export function DialogConnectProvider(props: { provider: string }) { return instructions }) - onMount(async () => { - if (store.authorization?.url) { - platform.openLink(store.authorization.url) - } - const result = await globalSDK.client.provider.oauth - .callback({ - providerID: props.provider, - method: store.methodIndex, - }) - .then((value) => - value.error ? { ok: false as const, error: value.error } : { ok: true as const }, - ) - .catch((error) => ({ ok: false as const, error })) - if (!result.ok) { - const message = result.error instanceof Error ? result.error.message : String(result.error) - setStore("state", "error") - setStore("error", message) - return - } - await complete() + onMount(() => { + void (async () => { + if (store.authorization?.url) { + platform.openLink(store.authorization.url) + } + + const result = await globalSDK.client.provider.oauth + .callback({ + providerID: props.provider, + method: store.methodIndex, + }) + .then((value) => + value.error ? { ok: false as const, error: value.error } : { ok: true as const }, + ) + .catch((error) => ({ ok: false as const, error })) + + if (!alive.value) return + + if (!result.ok) { + const message = result.error instanceof Error ? result.error.message : String(result.error) + setStore("state", "error") + setStore("error", message) + return + } + + await complete() + })() }) return ( diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index 9e2bddc6be..490753e622 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -3,7 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { TextField } from "@opencode-ai/ui/text-field" import { Icon } from "@opencode-ai/ui/icon" -import { createMemo, createSignal, For, Show } from "solid-js" +import { createMemo, For, Show } from "solid-js" import { createStore } from "solid-js/store" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" @@ -29,35 +29,34 @@ export function DialogEditProject(props: { project: LocalProject }) { iconUrl: props.project.icon?.override || "", startup: props.project.commands?.start ?? "", saving: false, + dragOver: false, + iconHover: false, }) - const [dragOver, setDragOver] = createSignal(false) - const [iconHover, setIconHover] = createSignal(false) - function handleFileSelect(file: File) { if (!file.type.startsWith("image/")) return const reader = new FileReader() reader.onload = (e) => { setStore("iconUrl", e.target?.result as string) - setIconHover(false) + setStore("iconHover", false) } reader.readAsDataURL(file) } function handleDrop(e: DragEvent) { e.preventDefault() - setDragOver(false) + setStore("dragOver", false) const file = e.dataTransfer?.files[0] if (file) handleFileSelect(file) } function handleDragOver(e: DragEvent) { e.preventDefault() - setDragOver(true) + setStore("dragOver", true) } function handleDragLeave() { - setDragOver(false) + setStore("dragOver", false) } function handleInputChange(e: Event) { @@ -116,19 +115,23 @@ export function DialogEditProject(props: { project: LocalProject }) {
-
setIconHover(true)} onMouseLeave={() => setIconHover(false)}> +
setStore("iconHover", true)} + onMouseLeave={() => setStore("iconHover", false)} + >
{ - if (store.iconUrl && iconHover()) { + if (store.iconUrl && store.iconHover) { clearIcon() } else { document.getElementById("icon-upload")?.click() @@ -142,8 +145,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
} @@ -156,39 +158,19 @@ export function DialogEditProject(props: { project: LocalProject }) {
diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index 17782f5ab8..09d62021f2 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -90,12 +90,8 @@ export const DialogFork: Component = () => { > {(item) => (
- - {item.text} - - - {item.time} - + {item.text} + {item.time}
)} diff --git a/packages/app/src/components/dialog-release-notes.tsx b/packages/app/src/components/dialog-release-notes.tsx new file mode 100644 index 0000000000..c6f2f3930e --- /dev/null +++ b/packages/app/src/components/dialog-release-notes.tsx @@ -0,0 +1,158 @@ +import { createSignal, createEffect, onMount, onCleanup } from "solid-js" +import { Dialog } from "@opencode-ai/ui/dialog" +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useSettings } from "@/context/settings" + +export type Highlight = { + title: string + description: string + media?: { + type: "image" | "video" + src: string + alt?: string + } +} + +export function DialogReleaseNotes(props: { highlights: Highlight[] }) { + const dialog = useDialog() + const settings = useSettings() + const [index, setIndex] = createSignal(0) + + const total = () => props.highlights.length + const last = () => Math.max(0, total() - 1) + const feature = () => props.highlights[index()] ?? props.highlights[last()] + const isFirst = () => index() === 0 + const isLast = () => index() >= last() + const paged = () => total() > 1 + + function handleNext() { + if (isLast()) return + setIndex(index() + 1) + } + + function handleClose() { + dialog.close() + } + + function handleDisable() { + settings.general.setReleaseNotes(false) + handleClose() + } + + let focusTrap: HTMLDivElement | undefined + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + e.preventDefault() + handleClose() + return + } + + if (!paged()) return + if (e.key === "ArrowLeft" && !isFirst()) { + e.preventDefault() + setIndex(index() - 1) + } + if (e.key === "ArrowRight" && !isLast()) { + e.preventDefault() + setIndex(index() + 1) + } + } + + onMount(() => { + focusTrap?.focus() + document.addEventListener("keydown", handleKeyDown) + onCleanup(() => document.removeEventListener("keydown", handleKeyDown)) + }) + + // Refocus the trap when index changes to ensure escape always works + createEffect(() => { + index() // track index + focusTrap?.focus() + }) + + return ( + + {/* Hidden element to capture initial focus and handle escape */} +
+
+ {/* Left side - Text content */} +
+ {/* Top section - feature content (fixed position from top) */} +
+
+

{feature()?.title ?? ""}

+
+

{feature()?.description ?? ""}

+
+ + {/* Spacer to push buttons to bottom */} +
+ + {/* Bottom section - buttons and indicators (fixed position) */} +
+
+ {isLast() ? ( + + ) : ( + + )} + + +
+ + {paged() && ( +
+ {props.highlights.map((_, i) => ( + + ))} +
+ )} +
+
+ + {/* Right side - Media content (edge to edge) */} + {feature()?.media && ( +
+ {feature()!.media!.type === "image" ? ( + {feature()!.media!.alt + ) : ( +
+ )} +
+
+ ) +} diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 1bb95c68aa..5c58725c75 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -24,16 +24,18 @@ type Entry = { path?: string } -export function DialogSelectFile() { +type DialogSelectFileMode = "all" | "files" + +export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) { const command = useCommand() const language = useLanguage() const layout = useLayout() const file = useFile() const dialog = useDialog() const params = useParams() + const filesOnly = () => props.mode === "files" const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey)) - const view = createMemo(() => layout.view(sessionKey)) const state = { cleanup: undefined as (() => void) | void, committed: false } const [grouped, setGrouped] = createSignal(false) const common = [ @@ -42,15 +44,16 @@ export function DialogSelectFile() { "session.previous", "session.next", "terminal.toggle", - "review.toggle", + "fileTree.toggle", ] const limit = 5 - const allowed = createMemo(() => - command.options.filter( + const allowed = createMemo(() => { + if (filesOnly()) return [] + return command.options.filter( (option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open", - ), - ) + ) + }) const commandItem = (option: CommandOption): Entry => ({ id: "command:" + option.id, @@ -99,10 +102,50 @@ export function DialogSelectFile() { return items.slice(0, limit) }) - const items = async (filter: string) => { - const query = filter.trim() + const root = createMemo(() => { + const nodes = file.tree.children("") + const paths = nodes + .filter((node) => node.type === "file") + .map((node) => node.path) + .sort((a, b) => a.localeCompare(b)) + return paths.slice(0, limit).map(fileItem) + }) + + const unique = (items: Entry[]) => { + const seen = new Set() + const out: Entry[] = [] + for (const item of items) { + if (seen.has(item.id)) continue + seen.add(item.id) + out.push(item) + } + return out + } + + const items = async (text: string) => { + const query = text.trim() setGrouped(query.length > 0) + + if (!query && filesOnly()) { + const loaded = file.tree.state("")?.loaded + const pending = loaded ? Promise.resolve() : file.tree.list("") + const next = unique([...recent(), ...root()]) + + if (loaded || next.length > 0) { + void pending + return next + } + + await pending + return unique([...recent(), ...root()]) + } + if (!query) return [...picks(), ...recent()] + + if (filesOnly()) { + const files = await file.searchFiles(query) + return files.map(fileItem) + } const files = await file.searchFiles(query) const entries = files.map(fileItem) return [...list(), ...entries] @@ -119,7 +162,8 @@ export function DialogSelectFile() { const value = file.tab(path) tabs().open(value) file.load(path) - view().reviewPanel.open() + layout.fileTree.setTab("all") + props.onOpenFile?.(path) } const handleSelect = (item: Entry | undefined) => { @@ -143,10 +187,12 @@ export function DialogSelectFile() { }) return ( - + (props: { setStore("open", false) dialog.show(() => ) } + + const handleConnectProvider = () => { + setStore("open", false) + dialog.show(() => ) + } const language = useLanguage() createEffect(() => { @@ -207,15 +213,28 @@ export function ModelSelectorPopover(props: { onSelect={() => setStore("open", false)} class="p-1" action={ - +
+ + + + + + +
} /> diff --git a/packages/app/src/components/dialog-select-provider.tsx b/packages/app/src/components/dialog-select-provider.tsx index 2fd360d051..5933bff197 100644 --- a/packages/app/src/components/dialog-select-provider.tsx +++ b/packages/app/src/components/dialog-select-provider.tsx @@ -18,7 +18,7 @@ export const DialogSelectProvider: Component = () => { const otherGroup = () => language.t("dialog.provider.group.other") return ( - + ): Promise { + const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000) const sdk = createOpencodeClient({ baseUrl: url, fetch: platform.fetch, - signal: AbortSignal.timeout(3000), + signal, }) return sdk.global .health() @@ -57,18 +59,16 @@ function AddRow(props: AddRowProps) {
{ // Position relative to input-wrapper requestAnimationFrame(() => { const wrapper = el.parentElement?.querySelector('[data-slot="input-wrapper"]') if (wrapper instanceof HTMLElement) { - wrapper.style.position = "relative" wrapper.appendChild(el) } }) @@ -149,13 +149,22 @@ export function DialogSelectServer() { }) const [defaultUrl, defaultUrlActions] = createResource( async () => { - const url = await platform.getDefaultServerUrl?.() - if (!url) return null - return normalizeServerUrl(url) ?? null + try { + const url = await platform.getDefaultServerUrl?.() + if (!url) return null + return normalizeServerUrl(url) ?? null + } catch (err) { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) + return null + } }, { initialValue: null }, ) - const isDesktop = platform.platform === "desktop" + const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl) const looksComplete = (value: string) => { const normalized = normalizeServerUrl(value) @@ -505,11 +514,19 @@ export function DialogSelectServer() { > {language.t("dialog.server.menu.edit")} - + { - await platform.setDefaultServerUrl?.(i) - defaultUrlActions.mutate(i) + try { + await platform.setDefaultServerUrl?.(i) + defaultUrlActions.mutate(i) + } catch (err) { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) + } }} > @@ -517,11 +534,19 @@ export function DialogSelectServer() { - + { - await platform.setDefaultServerUrl?.(null) - defaultUrlActions.mutate(null) + try { + await platform.setDefaultServerUrl?.(null) + defaultUrlActions.mutate(null) + } catch (err) { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) + } }} > diff --git a/packages/app/src/components/dialog-settings.tsx b/packages/app/src/components/dialog-settings.tsx index 9dd6efd688..f8892ebbdc 100644 --- a/packages/app/src/components/dialog-settings.tsx +++ b/packages/app/src/components/dialog-settings.tsx @@ -6,19 +6,15 @@ import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { SettingsGeneral } from "./settings-general" import { SettingsKeybinds } from "./settings-keybinds" -import { SettingsPermissions } from "./settings-permissions" import { SettingsProviders } from "./settings-providers" import { SettingsModels } from "./settings-models" -import { SettingsAgents } from "./settings-agents" -import { SettingsCommands } from "./settings-commands" -import { SettingsMcp } from "./settings-mcp" export const DialogSettings: Component = () => { const language = useLanguage() const platform = usePlatform() return ( - +
@@ -42,15 +38,19 @@ export const DialogSettings: Component = () => { {language.t("settings.section.server")}
- + {language.t("settings.providers.title")} + + + {language.t("settings.models.title")} +
- OpenCode Desktop + {language.t("app.name.desktop")} v{platform.version}
@@ -64,9 +64,9 @@ export const DialogSettings: Component = () => { - {/* */} - {/* */} - {/* */} + + + {/* */} {/* */} {/* */} diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index 3439d366ce..d43310b195 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -1,111 +1,373 @@ -import { useLocal, type LocalFile } from "@/context/local" +import { useFile } from "@/context/file" import { Collapsible } from "@opencode-ai/ui/collapsible" import { FileIcon } from "@opencode-ai/ui/file-icon" +import { Icon } from "@opencode-ai/ui/icon" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { For, Match, Switch, type ComponentProps, type ParentProps } from "solid-js" +import { + createEffect, + createMemo, + For, + Match, + Show, + splitProps, + Switch, + untrack, + type ComponentProps, + type ParentProps, +} from "solid-js" import { Dynamic } from "solid-js/web" +import type { FileNode } from "@opencode-ai/sdk/v2" + +type Kind = "add" | "del" | "mix" + +type Filter = { + files: Set + dirs: Set +} export default function FileTree(props: { path: string class?: string nodeClass?: string + active?: string level?: number - onFileClick?: (file: LocalFile) => void + allowed?: readonly string[] + modified?: readonly string[] + kinds?: ReadonlyMap + draggable?: boolean + tooltip?: boolean + onFileClick?: (file: FileNode) => void + + _filter?: Filter + _marks?: Set + _deeps?: Map + _kinds?: ReadonlyMap }) { - const local = useLocal() + const file = useFile() const level = props.level ?? 0 + const draggable = () => props.draggable ?? true + const tooltip = () => props.tooltip ?? true - const Node = (p: ParentProps & ComponentProps<"div"> & { node: LocalFile; as?: "div" | "button" }) => ( - { - const evt = e as globalThis.DragEvent - evt.dataTransfer!.effectAllowed = "copy" - evt.dataTransfer!.setData("text/plain", `file:${p.node.path}`) + const filter = createMemo(() => { + if (props._filter) return props._filter - // Create custom drag image without margins - const dragImage = document.createElement("div") - dragImage.className = - "flex items-center gap-x-2 px-2 py-1 bg-background-element rounded-md border border-border-1" - dragImage.style.position = "absolute" - dragImage.style.top = "-1000px" + const allowed = props.allowed + if (!allowed) return - // Copy only the icon and text content without padding - const icon = e.currentTarget.querySelector("svg") - const text = e.currentTarget.querySelector("span") - if (icon && text) { - dragImage.innerHTML = icon.outerHTML + text.outerHTML - } + const files = new Set(allowed) + const dirs = new Set() - document.body.appendChild(dragImage) - evt.dataTransfer!.setDragImage(dragImage, 0, 12) - setTimeout(() => document.body.removeChild(dragImage), 0) - }} - {...p} - > - {p.children} - { + if (props._marks) return props._marks + + const out = new Set() + for (const item of props.modified ?? []) out.add(item) + for (const item of props.kinds?.keys() ?? []) out.add(item) + if (out.size === 0) return + return out + }) + + const kinds = createMemo(() => { + if (props._kinds) return props._kinds + return props.kinds + }) + + const deeps = createMemo(() => { + if (props._deeps) return props._deeps + + const out = new Map() + + const visit = (dir: string, lvl: number): number => { + const expanded = file.tree.state(dir)?.expanded ?? false + if (!expanded) return -1 + + const nodes = file.tree.children(dir) + const max = nodes.reduce((max, node) => { + if (node.type !== "directory") return max + const open = file.tree.state(node.path)?.expanded ?? false + if (!open) return max + return Math.max(max, visit(node.path, lvl + 1)) + }, lvl) + + out.set(dir, max) + return max + } + + visit(props.path, level - 1) + return out + }) + + createEffect(() => { + const current = filter() + if (!current) return + if (level !== 0) return + + for (const dir of current.dirs) { + const expanded = untrack(() => file.tree.state(dir)?.expanded) ?? false + if (expanded) continue + file.tree.expand(dir) + } + }) + + createEffect(() => { + const path = props.path + untrack(() => void file.tree.list(path)) + }) + + const nodes = createMemo(() => { + const nodes = file.tree.children(props.path) + const current = filter() + if (!current) return nodes + return nodes.filter((node) => { + if (node.type === "file") return current.files.has(node.path) + return current.dirs.has(node.path) + }) + }) + + const Node = ( + p: ParentProps & + ComponentProps<"div"> & + ComponentProps<"button"> & { + node: FileNode + as?: "div" | "button" + }, + ) => { + const [local, rest] = splitProps(p, ["node", "as", "children", "class", "classList"]) + return ( + { + if (!draggable()) return + e.dataTransfer?.setData("text/plain", `file:${local.node.path}`) + e.dataTransfer?.setData("text/uri-list", `file://${local.node.path}`) + if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy" + + const dragImage = document.createElement("div") + dragImage.className = + "flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong" + dragImage.style.position = "absolute" + dragImage.style.top = "-1000px" + + const icon = + (e.currentTarget as HTMLElement).querySelector('[data-component="file-icon"]') ?? + (e.currentTarget as HTMLElement).querySelector("svg") + const text = (e.currentTarget as HTMLElement).querySelector("span") + if (icon && text) { + dragImage.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML + } + + document.body.appendChild(dragImage) + e.dataTransfer?.setDragImage(dragImage, 0, 12) + setTimeout(() => document.body.removeChild(dragImage), 0) + }} + {...rest} > - {p.node.name} - - {/* */} - {/* */} - {/* */} - - ) + {local.children} + {(() => { + const kind = kinds()?.get(local.node.path) + const marked = marks()?.has(local.node.path) ?? false + const active = !!kind && marked && !local.node.ignored + const color = + kind === "add" + ? "color: var(--icon-diff-add-base)" + : kind === "del" + ? "color: var(--icon-diff-delete-base)" + : kind === "mix" + ? "color: var(--icon-diff-modified-base)" + : undefined + return ( + + {local.node.name} + + ) + })()} + {(() => { + const kind = kinds()?.get(local.node.path) + if (!kind) return null + if (!marks()?.has(local.node.path)) return null + + if (local.node.type === "file") { + const text = kind === "add" ? "A" : kind === "del" ? "D" : "M" + const color = + kind === "add" + ? "color: var(--icon-diff-add-base)" + : kind === "del" + ? "color: var(--icon-diff-delete-base)" + : "color: var(--icon-diff-modified-base)" + + return ( + + {text} + + ) + } + + if (local.node.type === "directory") { + const color = + kind === "add" + ? "background-color: var(--icon-diff-add-base)" + : kind === "del" + ? "background-color: var(--icon-diff-delete-base)" + : "background-color: var(--icon-diff-modified-base)" + + return
+ } + + return null + })()} + + ) + } return ( -
- - {(node) => ( - +
+ + {(node) => { + const expanded = () => file.tree.state(node.path)?.expanded ?? false + const deep = () => deeps().get(node.path) ?? -1 + const Wrapper = (p: ParentProps) => { + if (!tooltip()) return p.children + + const parts = node.path.split("/") + const leaf = parts[parts.length - 1] ?? node.path + const head = parts.slice(0, -1).join("/") + const prefix = head ? `${head}/` : "" + + const kind = () => kinds()?.get(node.path) + const label = () => { + const k = kind() + if (!k) return + if (k === "add") return "Additions" + if (k === "del") return "Deletions" + return "Modifications" + } + + const ignored = () => node.type === "directory" && node.ignored + + return ( + + + {prefix} + + {leaf} + + {(t: () => string) => ( + <> + + {t()} + + )} + + + <> + + Ignored + + +
+ } + > + {p.children} +
+ ) + } + + return ( (open ? local.file.expand(node.path) : local.file.collapse(node.path))} + open={expanded()} + onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))} > - - - - + + +
+ +
+
+
- - + +
+ - props.onFileClick?.(node)}> -
- - + + props.onFileClick?.(node)}> +
+ + + - - )} + ) + }}
) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 5ec0eb1ea0..9f038b6e83 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -171,7 +171,6 @@ export const PromptInput: Component = (props) => { const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey)) - const view = createMemo(() => layout.view(sessionKey)) const commentInReview = (path: string) => { const sessionID = params.id @@ -187,20 +186,15 @@ export const PromptInput: Component = (props) => { const focus = { file: item.path, id: item.commentID } comments.setActive(focus) - view().reviewPanel.open() - if (item.commentOrigin === "review") { - tabs().open("review") - requestAnimationFrame(() => comments.setFocus(focus)) - return - } - - if (item.commentOrigin !== "file" && commentInReview(item.path)) { - tabs().open("review") + const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path)) + if (wantsReview) { + layout.fileTree.setTab("changes") requestAnimationFrame(() => comments.setFocus(focus)) return } + layout.fileTree.setTab("all") const tab = files.tab(item.path) tabs().open(tab) files.load(item.path) @@ -1563,13 +1557,17 @@ export const PromptInput: Component = (props) => { }) const timeoutMs = 5 * 60 * 1000 + const timer = { id: undefined as number | undefined } const timeout = new Promise>>((resolve) => { - setTimeout(() => { - resolve({ status: "failed", message: "Workspace is still preparing" }) + timer.id = window.setTimeout(() => { + resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") }) }, timeoutMs) }) - const result = await Promise.race([WorktreeState.wait(sessionDirectory), abort, timeout]) + const result = await Promise.race([WorktreeState.wait(sessionDirectory), abort, timeout]).finally(() => { + if (timer.id === undefined) return + clearTimeout(timer.id) + }) pending.delete(session.id) if (controller.signal.aborted) return false if (result.status === "failed") throw new Error(result.message) @@ -1746,10 +1744,7 @@ export const PromptInput: Component = (props) => { - + {getDirectory(item.path)} {getFilename(item.path)} @@ -1772,10 +1767,7 @@ export const PromptInput: Component = (props) => { >
-
+
{getFilenameTruncated(item.path, 14)} {(sel) => ( @@ -1863,9 +1855,9 @@ export const PromptInput: Component = (props) => { store.mode === "shell" ? language.t("prompt.placeholder.shell") : commentCount() > 1 - ? "Summarize comments…" + ? language.t("prompt.placeholder.summarizeComments") : commentCount() === 1 - ? "Summarize comment…" + ? language.t("prompt.placeholder.summarizeComment") : language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) }) } contenteditable="true" @@ -1887,9 +1879,9 @@ export const PromptInput: Component = (props) => { {store.mode === "shell" ? language.t("prompt.placeholder.shell") : commentCount() > 1 - ? "Summarize comments…" + ? language.t("prompt.placeholder.summarizeComments") : commentCount() === 1 - ? "Summarize comment…" + ? language.t("prompt.placeholder.summarizeComment") : language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) })}
diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index 4dbb9e0489..afdb18bb09 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -23,7 +23,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) { const variant = createMemo(() => props.variant ?? "button") const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey)) - const view = createMemo(() => layout.view(sessionKey)) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) const usd = createMemo( @@ -58,7 +57,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) { const openContext = () => { if (!params.id) return - view().reviewPanel.open() + layout.fileTree.setTab("all") tabs().open("context") tabs().setActive("context") } diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index e4a64b3390..9fddb4507c 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -9,7 +9,7 @@ import { usePlatform } from "@/context/platform" import { useSync } from "@/context/sync" import { useGlobalSDK } from "@/context/global-sdk" import { getFilename } from "@opencode-ai/util/path" -import { base64Decode } from "@opencode-ai/util/encode" +import { decode64 } from "@/utils/base64" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -29,7 +29,7 @@ export function SessionHeader() { const platform = usePlatform() const language = useLanguage() - const projectDirectory = createMemo(() => base64Decode(params.dir ?? "")) + const projectDirectory = createMemo(() => decode64(params.dir) ?? "") const project = createMemo(() => { const directory = projectDirectory() if (!directory) return @@ -45,7 +45,6 @@ export function SessionHeader() { const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id)) const shareEnabled = createMemo(() => sync.data.config.share !== "disabled") const showShare = createMemo(() => shareEnabled() && !!currentSession()) - const showReview = createMemo(() => !!currentSession()) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const view = createMemo(() => layout.view(sessionKey)) @@ -131,7 +130,7 @@ export function SessionHeader() {