diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bfaf1df --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,100 @@ +name: CI + +# Quality gate: runs on every push to main and on pull requests so regressions +# are caught before they reach a release tag. +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + go: + name: Go (build · vet · test · lint) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + # Full history so golangci-lint's only-new-issues can diff against base. + fetch-depth: 0 + + - uses: actions/setup-go@v6 + with: + go-version-file: 'go.mod' + + # build-web (vite) is needed because internal/web embeds dist/* via + # //go:embed — without it the whole module fails to compile. + - uses: actions/setup-node@v5 + with: + node-version: '22' + - name: Install pnpm + run: npm install -g pnpm + + # registry_generated.go + the theme TS/CSS are generated (gitignored), and + # internal/web/dist must exist for the go:embed. `make generate build-web` + # produces all three. model generate fetches models.dev; theme is offline. + - name: Generate code + build frontend + run: make generate build-web + + - name: Build + run: go build ./... + + - name: Vet + run: go vet ./... + + - name: Test + run: go test ./... + + - name: golangci-lint + uses: golangci/golangci-lint-action@v7 + with: + version: latest + # Gate regressions without forcing a flag-day cleanup of the existing + # lint debt (mostly upstream adk.AgentMiddleware deprecations). New + # issues introduced by a PR still fail. + only-new-issues: true + + web: + name: Web (type-check · build) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + # Go is needed only to run the theme generator, which writes + # web/src/composables/themes.generated.ts + styles/tokens.generated.css + # (gitignored) — without them vue-tsc can't resolve ./themes.generated. + - uses: actions/setup-go@v6 + with: + go-version-file: 'go.mod' + + - uses: actions/setup-node@v5 + with: + node-version: '22' + + - name: Install pnpm + run: npm install -g pnpm + + - name: Generate theme assets + run: go generate ./internal/theme/... + + - name: Install deps + working-directory: web + run: pnpm install --frozen-lockfile + + - name: Type-check + working-directory: web + run: pnpm type-check + + - name: Lint + working-directory: web + run: npx oxlint . + + - name: Build + working-directory: web + run: npx vite build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 29b477d..680deb2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -118,7 +118,7 @@ jobs: else VERSION="${GITHUB_REF#refs/tags/}" fi - echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Build binary env: @@ -148,8 +148,229 @@ jobs: name: binaries-${{ matrix.goos }}-${{ matrix.goarch }} path: dist/jcode-* + # Desktop (Tauri) app bundles. Each platform builds on a native runner because + # Tauri produces OS-native installers (.dmg / .msi / .deb / AppImage). The Go + # binary is compiled as the Tauri "sidecar" (binaries/jcode-) and + # embedded into the bundle; at runtime it serves the same web UI on loopback. + desktop: + needs: prepare + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + # macOS Apple Silicon (M-series) + - os: macos-latest + triple: aarch64-apple-darwin + ext: '' + cgo: 1 + bundles: dmg + label: macos-arm64 + # macOS Intel + - os: macos-15-intel + triple: x86_64-apple-darwin + ext: '' + cgo: 1 + bundles: dmg + label: macos-x64 + # Windows x64 + - os: windows-latest + triple: x86_64-pc-windows-msvc + ext: '.exe' + cgo: 0 + bundles: 'msi,nsis' + label: windows-x64 + # Linux x64 + - os: ubuntu-22.04 + triple: x86_64-unknown-linux-gnu + ext: '' + cgo: 0 + bundles: 'deb,appimage' + label: linux-x64 + + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: 'go.mod' + + - name: Set up Node.js + uses: actions/setup-node@v5 + with: + node-version: '22' + + - name: Install pnpm + run: npm install -g pnpm + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.triple }} + + - name: Cache Rust + uses: swatinem/rust-cache@v2 + with: + workspaces: desktop/src-tauri + + - name: Install Linux dependencies + if: matrix.os == 'ubuntu-22.04' + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + libssl-dev \ + libxdo-dev \ + libfuse2 \ + patchelf \ + file + + - name: Download prepared artifacts + uses: actions/download-artifact@v5 + with: + name: prepared + path: internal/ + + - name: Get version + id: get_version + shell: bash + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ github.event.inputs.version }}" + else + VERSION="${GITHUB_REF#refs/tags/}" + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Sync bundle version + shell: bash + run: | + VER="${{ steps.get_version.outputs.version }}" + VER="${VER#v}" + node -e "const fs=require('fs');const f='desktop/src-tauri/tauri.conf.json';const j=JSON.parse(fs.readFileSync(f));j.version='${VER}';fs.writeFileSync(f, JSON.stringify(j,null,2)+'\n')" + echo "Set desktop bundle version to ${VER}" + + - name: Build sidecar + shell: bash + env: + CGO_ENABLED: ${{ matrix.cgo }} + run: | + VERSION="${{ steps.get_version.outputs.version }}" + COMMIT="$(git rev-parse --short HEAD)" + mkdir -p desktop/src-tauri/binaries + go build -trimpath \ + -ldflags "-s -w -X github.com/cnjack/jcode/internal/command.Version=${VERSION} -X github.com/cnjack/jcode/internal/command.GitCommit=${COMMIT}" \ + -o "desktop/src-tauri/binaries/jcode-${{ matrix.triple }}${{ matrix.ext }}" \ + ./cmd/jcode/ + + # macOS signing + notarization. Everything here is optional: with no secrets + # the build still succeeds and produces an UNSIGNED bundle (Gatekeeper warns + # on first launch). When the secrets are present, the Tauri CLI imports the + # cert into a temp keychain, signs with Developer ID, then notarizes. + # + # We resolve the credentials into $GITHUB_ENV here (rather than as a static + # env block on the build step) for two reasons: the notarization .p8 is a + # FILE that must be written to disk from a base64 secret, and the two notary + # auth methods are mutually exclusive — exporting only the configured one + # avoids an empty APPLE_ID accidentally selecting the Apple-ID path. + - name: Set up macOS signing & notarization + if: runner.os == 'macOS' + shell: bash + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + # Notarization — App Store Connect API key (.p8), preferred for CI. + APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + # Notarization — Apple ID (alternative; use one method, not both). + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + # Code-signing certificate (Tauri imports it from these env vars). + if [ -n "$APPLE_CERTIFICATE" ]; then + { + echo "APPLE_CERTIFICATE=$APPLE_CERTIFICATE" + echo "APPLE_CERTIFICATE_PASSWORD=$APPLE_CERTIFICATE_PASSWORD" + } >> "$GITHUB_ENV" + [ -n "$APPLE_SIGNING_IDENTITY" ] && echo "APPLE_SIGNING_IDENTITY=$APPLE_SIGNING_IDENTITY" >> "$GITHUB_ENV" + echo "code-signing: enabled" + else + echo "code-signing: DISABLED (APPLE_CERTIFICATE unset) — bundle will be unsigned" + fi + + # Notarization auth — pick exactly one method. + if [ -n "$APPLE_API_KEY_P8_BASE64" ]; then + # APPLE_API_KEY is the Key ID STRING; the .p8 key material is a file + # referenced by APPLE_API_KEY_PATH. Decode the base64 secret to disk. + KEY_PATH="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8" + echo -n "$APPLE_API_KEY_P8_BASE64" | base64 --decode > "$KEY_PATH" + { + echo "APPLE_API_ISSUER=$APPLE_API_ISSUER" + echo "APPLE_API_KEY=$APPLE_API_KEY_ID" + echo "APPLE_API_KEY_PATH=$KEY_PATH" + } >> "$GITHUB_ENV" + echo "notarization: App Store Connect API key" + elif [ -n "$APPLE_ID" ]; then + { + echo "APPLE_ID=$APPLE_ID" + echo "APPLE_PASSWORD=$APPLE_PASSWORD" + echo "APPLE_TEAM_ID=$APPLE_TEAM_ID" + } >> "$GITHUB_ENV" + echo "notarization: Apple ID" + else + echo "notarization: DISABLED (no API key or Apple ID secrets)" + fi + + - name: Build desktop bundle + shell: bash + working-directory: desktop + env: + # Lets linuxdeploy build the AppImage on runners without FUSE mounted. + # The macOS signing/notarization vars arrive via $GITHUB_ENV (above). + APPIMAGE_EXTRACT_AND_RUN: 1 + run: | + pnpm install --frozen-lockfile + pnpm tauri build --bundles ${{ matrix.bundles }} + + - name: Collect bundles + shell: bash + run: | + mkdir -p dist-desktop + B="desktop/src-tauri/target/release/bundle" + shopt -s nullglob 2>/dev/null || true + for f in "$B"/dmg/*.dmg "$B"/msi/*.msi "$B"/nsis/*-setup.exe "$B"/deb/*.deb "$B"/appimage/*.AppImage; do + [ -f "$f" ] && cp "$f" dist-desktop/ + done + cd dist-desktop + for f in *; do + [ -f "$f" ] || continue + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$f" > "$f.sha256" + else + shasum -a 256 "$f" > "$f.sha256" + fi + done + ls -la + + - name: Upload desktop bundles + uses: actions/upload-artifact@v5 + with: + name: desktop-${{ matrix.label }} + path: dist-desktop/* + if-no-files-found: error + release: - needs: build + needs: [build, desktop] runs-on: ubuntu-latest permissions: contents: write @@ -168,18 +389,26 @@ jobs: else VERSION="${GITHUB_REF#refs/tags/}" fi - echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - name: Download all artifacts + - name: Download CLI binaries uses: actions/download-artifact@v5 with: path: dist pattern: binaries-* merge-multiple: true + - name: Download desktop bundles + uses: actions/download-artifact@v5 + with: + path: dist-desktop + pattern: desktop-* + merge-multiple: true + - name: List artifacts run: | - ls -la dist/ + echo "── CLI binaries ──"; ls -la dist/ + echo "── Desktop bundles ──"; ls -la dist-desktop/ - name: Create Release uses: softprops/action-gh-release@v2 @@ -190,6 +419,7 @@ jobs: prerelease: ${{ contains(steps.get_version.outputs.version, 'alpha') || contains(steps.get_version.outputs.version, 'beta') || contains(steps.get_version.outputs.version, 'rc') }} files: | dist/jcode-* + dist-desktop/* generate_release_notes: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Makefile b/Makefile index 4be3270..bca8dab 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ LDFLAGS := -s -w \ export GOFLAGS := -buildvcs=false -.PHONY: build build-binary run doctor version install clean build-web fmt lint lint-go lint-web generate setup-hooks +.PHONY: build build-binary run doctor version install clean build-web fmt lint lint-go lint-web generate setup-hooks desktop-icons desktop-sidecar desktop-dev desktop-build desktop-clean fmt: @echo "Formatting Go..." @@ -65,3 +65,36 @@ clean: setup-hooks: @git config core.hooksPath .githooks @echo "Git hooks installed (core.hooksPath = .githooks)" + +# ─── Desktop app (Tauri) ─── +# The desktop app embeds the same jcode binary as a sidecar: Tauri renders the +# UI and provides native system integration, while the Go server (with the web +# UI baked in) runs on a loopback port. See docs/desktop.md. +DESKTOP_DIR := desktop +SIDECAR_DIR := $(DESKTOP_DIR)/src-tauri/binaries +RUST_TARGET := $(shell rustc -vV 2>/dev/null | sed -n 's/^host: //p') +# Tauri's externalBin resolver requires the OS executable suffix, so Windows +# sidecars must be jcode-.exe. +SIDECAR_EXE := $(if $(findstring windows,$(RUST_TARGET)),.exe,) + +# Regenerate the app icon set from the brand mark. +desktop-icons: + cd $(DESKTOP_DIR) && npx --yes @tauri-apps/cli@2 icon ../web/public/icon.svg -o src-tauri/icons + +# Build the sidecar binary (frontend embedded) named for the host target triple, +# which is what Tauri's externalBin resolver expects. +desktop-sidecar: generate build-web + @echo "Building jcode sidecar for $(RUST_TARGET)..." + @mkdir -p $(SIDECAR_DIR) + go build -ldflags "$(LDFLAGS)" -o $(SIDECAR_DIR)/jcode-$(RUST_TARGET)$(SIDECAR_EXE) $(PKG) + +# Run the desktop app in development (hot window; rebuilds the sidecar first). +desktop-dev: desktop-sidecar + cd $(DESKTOP_DIR) && (pnpm install 2>/dev/null || npm install) && pnpm tauri dev + +# Produce a distributable bundle (.app/.dmg on macOS, .msi on Windows, etc.). +desktop-build: desktop-sidecar + cd $(DESKTOP_DIR) && (pnpm install 2>/dev/null || npm install) && pnpm tauri build + +desktop-clean: + rm -rf $(SIDECAR_DIR) $(DESKTOP_DIR)/src-tauri/target diff --git a/README.md b/README.md index 50a64a3..d7a0a5d 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,17 @@ Start a browser-based UI with `jcode web`. Chat interface, file browser, built-i jcode Web UI

+### 🖥 Desktop App + +A native desktop app (built with [Tauri](https://tauri.app)) wraps the same web UI in a real OS window with native integration: OS notifications, a menu-bar tray, close-to-tray, single-instance focus, window-state memory, and a native folder picker. The Go backend runs as an embedded sidecar — no separate server to start. + +```bash +make desktop-dev # run the app in development +make desktop-build # build a distributable bundle (.app/.dmg/.msi) +``` + +See the [Desktop App guide](https://cnjack.github.io/jcode/desktop) for the architecture and details. + ### 🧭 Context Awareness At startup the agent automatically detects: diff --git a/desktop/.gitignore b/desktop/.gitignore new file mode 100644 index 0000000..f06235c --- /dev/null +++ b/desktop/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/desktop/package.json b/desktop/package.json new file mode 100644 index 0000000..950e7d6 --- /dev/null +++ b/desktop/package.json @@ -0,0 +1,16 @@ +{ + "name": "jcode-desktop", + "private": true, + "version": "0.5.3", + "type": "module", + "description": "Tauri desktop shell for jcode. The real UI is served by the embedded Go sidecar; this package only drives the Tauri CLI.", + "scripts": { + "tauri": "tauri", + "dev": "tauri dev", + "build": "tauri build", + "icon": "tauri icon ../../web/public/icon.svg -o src-tauri/icons" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.11.3" + } +} diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml new file mode 100644 index 0000000..ed89ddf --- /dev/null +++ b/desktop/pnpm-lock.yaml @@ -0,0 +1,140 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@tauri-apps/cli': + specifier: ^2.11.3 + version: 2.11.3 + +packages: + + '@tauri-apps/cli-darwin-arm64@2.11.3': + resolution: {integrity: sha512-BxpaM8bsCoXs3wd4WKYhas/G1gs7+r7B+e4WnyRk2GEoVOouJB1hoL6E6YLXZDXbYci6VFdrNnobQwd2uVL4ew==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tauri-apps/cli-darwin-x64@2.11.3': + resolution: {integrity: sha512-DbZYuPB1ZEzcAHYeyCvo3ltzM27+aXwPloCrtexPnmgPgulYJm3TOq6aC4S+wPhSXteddg8zImtNkvx/gQzmwg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.3': + resolution: {integrity: sha512-741NduqBmz1XkdU8yz3OI/kBZtqHbvxo9F9ytIeWYU69/Ba9dcZEbqOU++Dp0G/XU8vAI0TfTywEl+p+BbLvaA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tauri-apps/cli-linux-arm64-gnu@2.11.3': + resolution: {integrity: sha512-RWAXT8pTqIczXcoic+LXlo6uEbAXGB0cgh6Pg7Y9xVnEbzryQ1JHtRGj9SxzrKSemBIDBH6Qc24kK2G69i8ofA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-arm64-musl@2.11.3': + resolution: {integrity: sha512-qomqYS+yAkd0gXMRmhguWXc7RfVN+XKKXaEwbf5QmKURwydLFOTldd6F8/WoZDSsBMrV8dpNxz0YneGLmobiSA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tauri-apps/cli-linux-riscv64-gnu@2.11.3': + resolution: {integrity: sha512-jOCXbDqeDj5XcclsOBAaXjtTgwZCVg8zEZ+dbPUCoADOgljFgL0rOkYTc96vUYgOrYEfuHYihWMxIDGaD6GwJw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-x64-gnu@2.11.3': + resolution: {integrity: sha512-+u3HO/F3gHwL48t9gWN/urqZvpaEJzBFmTaq5eSIhvy8TOvnhb+LgJr3Q3BG+5JxuBrCUjqtOEz6gMttdJFSBA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-x64-musl@2.11.3': + resolution: {integrity: sha512-spr5Jpr6KF/vehkLwJ0YmdGv8QwpWU+uw7J8bgijO0sox6ZCYsSNMbcsQjTqPi4xl+p0woIYpWXgChgHYpAc8g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tauri-apps/cli-win32-arm64-msvc@2.11.3': + resolution: {integrity: sha512-abkoRQih5xBa3vz2spWaex0kP/MzVzVPQHom2f8jnCq46R/luOD6Uy85EMU9/bfzf6ZzdorWJsgO+OMX90Fx2w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tauri-apps/cli-win32-ia32-msvc@2.11.3': + resolution: {integrity: sha512-Vy6AvzFm1G40hg3r+OYDB3jkuu7R4wnMzbQBKuun9v6Cgg8IierpLL7toMzrZKs/8NlG8Sg4x1iLFR52oknyHg==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@tauri-apps/cli-win32-x64-msvc@2.11.3': + resolution: {integrity: sha512-GlciF75GdbseajOyib2aCHwE3BXIqZ1liGKWLFRvCdN5wm8h8hFssEVKQ/6E+2jsMLg9v7LCTb983YFnn0QSww==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tauri-apps/cli@2.11.3': + resolution: {integrity: sha512-EElQe8z8uD7Pi5++tJ/UfEwWuK08rd3oCDYdeIbJAb6pZRrxlqmoF5gh5H5YvzmUPhS4IRCaLSsQhvWkrfK+GQ==} + engines: {node: '>= 10'} + hasBin: true + +snapshots: + + '@tauri-apps/cli-darwin-arm64@2.11.3': + optional: true + + '@tauri-apps/cli-darwin-x64@2.11.3': + optional: true + + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.3': + optional: true + + '@tauri-apps/cli-linux-arm64-gnu@2.11.3': + optional: true + + '@tauri-apps/cli-linux-arm64-musl@2.11.3': + optional: true + + '@tauri-apps/cli-linux-riscv64-gnu@2.11.3': + optional: true + + '@tauri-apps/cli-linux-x64-gnu@2.11.3': + optional: true + + '@tauri-apps/cli-linux-x64-musl@2.11.3': + optional: true + + '@tauri-apps/cli-win32-arm64-msvc@2.11.3': + optional: true + + '@tauri-apps/cli-win32-ia32-msvc@2.11.3': + optional: true + + '@tauri-apps/cli-win32-x64-msvc@2.11.3': + optional: true + + '@tauri-apps/cli@2.11.3': + optionalDependencies: + '@tauri-apps/cli-darwin-arm64': 2.11.3 + '@tauri-apps/cli-darwin-x64': 2.11.3 + '@tauri-apps/cli-linux-arm-gnueabihf': 2.11.3 + '@tauri-apps/cli-linux-arm64-gnu': 2.11.3 + '@tauri-apps/cli-linux-arm64-musl': 2.11.3 + '@tauri-apps/cli-linux-riscv64-gnu': 2.11.3 + '@tauri-apps/cli-linux-x64-gnu': 2.11.3 + '@tauri-apps/cli-linux-x64-musl': 2.11.3 + '@tauri-apps/cli-win32-arm64-msvc': 2.11.3 + '@tauri-apps/cli-win32-ia32-msvc': 2.11.3 + '@tauri-apps/cli-win32-x64-msvc': 2.11.3 diff --git a/desktop/pnpm-workspace.yaml b/desktop/pnpm-workspace.yaml new file mode 100644 index 0000000..dd8f46e --- /dev/null +++ b/desktop/pnpm-workspace.yaml @@ -0,0 +1,13 @@ +minimumReleaseAgeExclude: + - '@tauri-apps/cli-darwin-arm64@2.11.3' + - '@tauri-apps/cli-darwin-x64@2.11.3' + - '@tauri-apps/cli-linux-arm-gnueabihf@2.11.3' + - '@tauri-apps/cli-linux-arm64-gnu@2.11.3' + - '@tauri-apps/cli-linux-arm64-musl@2.11.3' + - '@tauri-apps/cli-linux-riscv64-gnu@2.11.3' + - '@tauri-apps/cli-linux-x64-gnu@2.11.3' + - '@tauri-apps/cli-linux-x64-musl@2.11.3' + - '@tauri-apps/cli-win32-arm64-msvc@2.11.3' + - '@tauri-apps/cli-win32-ia32-msvc@2.11.3' + - '@tauri-apps/cli-win32-x64-msvc@2.11.3' + - '@tauri-apps/cli@2.11.3' diff --git a/desktop/splash/index.html b/desktop/splash/index.html new file mode 100644 index 0000000..235058f --- /dev/null +++ b/desktop/splash/index.html @@ -0,0 +1,84 @@ + + + + + + jcode + + + +
+ +
jcode
+
正在启动本地服务…
+
+ + diff --git a/desktop/src-tauri/.gitignore b/desktop/src-tauri/.gitignore new file mode 100644 index 0000000..bf8d3fb --- /dev/null +++ b/desktop/src-tauri/.gitignore @@ -0,0 +1,8 @@ +# Rust / Cargo build output +/target + +# Bundled sidecar binaries (built from the Go source by `make desktop-sidecar`) +/binaries + +# Tauri-generated schemas and platform projects +/gen/schemas diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock new file mode 100644 index 0000000..e6fd6c8 --- /dev/null +++ b/desktop/src-tauri/Cargo.lock @@ -0,0 +1,5380 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ce8d3bd5823c7504d3f579f13e7b2f3da252fcb938c594d5680ee508bf846f" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.0", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.0", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.118", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.2+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "global-hotkey" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2", + "objc2-app-kit", + "once_cell", + "serde", + "thiserror 2.0.18", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jcode-desktop" +version = "0.5.3" +dependencies = [ + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-global-shortcut", + "tauri-plugin-notification", + "tauri-plugin-opener", + "tauri-plugin-shell", + "tauri-plugin-single-instance", + "tauri-plugin-window-state", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "js-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2", + "objc2-foundation", + "time", + "uuid", +] + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.0", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" +dependencies = [ + "dunce", + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml 0.39.4", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +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.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.118", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.0", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shared_child" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7" +dependencies = [ + "libc", + "sigchld", + "windows-sys 0.60.2", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "sigchld" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1" +dependencies = [ + "libc", + "os_pipe", + "signal-hook", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.0", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2616f96cb644bf2c5c456d9de4d5d5100e592d7424c74d8b55c5cb96e359e93" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "image", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.118", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-plugin-global-shortcut" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4dd9f4c5136c09cd962da0c86dc4accd4666db2ea591cf16e6597435843bd2b" +dependencies = [ + "global-hotkey", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + +[[package]] +name = "tauri-plugin-notification" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +dependencies = [ + "log", + "notify-rust", + "rand", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", + "url", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "url", + "windows", + "zbus", +] + +[[package]] +name = "tauri-plugin-shell" +version = "2.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b" +dependencies = [ + "encoding_rs", + "log", + "open", + "os_pipe", + "regex", + "schemars 0.8.22", + "serde", + "serde_json", + "shared_child", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.18", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + +[[package]] +name = "tauri-plugin-window-state" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704" +dependencies = [ + "bitflags 2.13.0", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe41e015bf8fc4d6477ff4926a0ef769dc64ff34c7b0038b6f7cacae892acb5c" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.2+spec-1.1.0", +] + +[[package]] +name = "tauri-winrt-notification" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +dependencies = [ + "quick-xml 0.37.5", + "thiserror 2.0.18", + "windows", + "windows-version", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "time" +version = "0.3.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.118", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.3", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow 1.0.3", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.3", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.118", + "winnow 1.0.3", +] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml new file mode 100644 index 0000000..8c9a294 --- /dev/null +++ b/desktop/src-tauri/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "jcode-desktop" +version = "0.5.3" +description = "jcode desktop — native shell around the embedded jcode web app" +authors = ["cnjack"] +edition = "2021" +rust-version = "1.77" + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = ["tray-icon", "image-png"] } +tauri-plugin-shell = "2" +tauri-plugin-notification = "2" +tauri-plugin-opener = "2" +tauri-plugin-dialog = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# Desktop-only plugins (no Android/iOS support). +# NOTE: the updater plugin is intentionally absent — it panics at startup unless +# `plugins.updater` (endpoints + pubkey) is configured, which requires a signed +# release feed. Add it back together with that config. Autostart is likewise +# deferred until there's a settings toggle for it. +[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] +tauri-plugin-single-instance = "2" +tauri-plugin-window-state = "2" +tauri-plugin-global-shortcut = "2" + +[profile.release] +panic = "abort" +codegen-units = 1 +lto = true +opt-level = "s" +strip = true diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/desktop/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json new file mode 100644 index 0000000..a7e00c6 --- /dev/null +++ b/desktop/src-tauri/capabilities/default.json @@ -0,0 +1,24 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Capabilities for the main jcode window. The window loads the bundled jcode web server over loopback, so the Tauri JS APIs are granted to localhost origins via `remote`.", + "windows": ["main"], + "remote": { + "urls": ["http://127.0.0.1:*"] + }, + "permissions": [ + "core:default", + "core:window:allow-show", + "core:window:allow-hide", + "core:window:allow-set-focus", + "core:window:allow-minimize", + "core:window:allow-unminimize", + "core:window:allow-start-dragging", + "core:window:allow-close", + "core:app:allow-version", + "core:event:default", + "notification:default", + "opener:default", + "dialog:default" + ] +} diff --git a/desktop/src-tauri/icons/128x128.png b/desktop/src-tauri/icons/128x128.png new file mode 100644 index 0000000..62e7471 Binary files /dev/null and b/desktop/src-tauri/icons/128x128.png differ diff --git a/desktop/src-tauri/icons/128x128@2x.png b/desktop/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..1240ff7 Binary files /dev/null and b/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/desktop/src-tauri/icons/32x32.png b/desktop/src-tauri/icons/32x32.png new file mode 100644 index 0000000..dbab144 Binary files /dev/null and b/desktop/src-tauri/icons/32x32.png differ diff --git a/desktop/src-tauri/icons/64x64.png b/desktop/src-tauri/icons/64x64.png new file mode 100644 index 0000000..5c871d2 Binary files /dev/null and b/desktop/src-tauri/icons/64x64.png differ diff --git a/desktop/src-tauri/icons/Square107x107Logo.png b/desktop/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000..6863dbe Binary files /dev/null and b/desktop/src-tauri/icons/Square107x107Logo.png differ diff --git a/desktop/src-tauri/icons/Square142x142Logo.png b/desktop/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000..618193e Binary files /dev/null and b/desktop/src-tauri/icons/Square142x142Logo.png differ diff --git a/desktop/src-tauri/icons/Square150x150Logo.png b/desktop/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000..a10b2f4 Binary files /dev/null and b/desktop/src-tauri/icons/Square150x150Logo.png differ diff --git a/desktop/src-tauri/icons/Square284x284Logo.png b/desktop/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000..7f42d60 Binary files /dev/null and b/desktop/src-tauri/icons/Square284x284Logo.png differ diff --git a/desktop/src-tauri/icons/Square30x30Logo.png b/desktop/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000..ce55f01 Binary files /dev/null and b/desktop/src-tauri/icons/Square30x30Logo.png differ diff --git a/desktop/src-tauri/icons/Square310x310Logo.png b/desktop/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000..d1876c0 Binary files /dev/null and b/desktop/src-tauri/icons/Square310x310Logo.png differ diff --git a/desktop/src-tauri/icons/Square44x44Logo.png b/desktop/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000..544fcb3 Binary files /dev/null and b/desktop/src-tauri/icons/Square44x44Logo.png differ diff --git a/desktop/src-tauri/icons/Square71x71Logo.png b/desktop/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000..8d22ebc Binary files /dev/null and b/desktop/src-tauri/icons/Square71x71Logo.png differ diff --git a/desktop/src-tauri/icons/Square89x89Logo.png b/desktop/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000..01008f2 Binary files /dev/null and b/desktop/src-tauri/icons/Square89x89Logo.png differ diff --git a/desktop/src-tauri/icons/StoreLogo.png b/desktop/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000..020e823 Binary files /dev/null and b/desktop/src-tauri/icons/StoreLogo.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml b/desktop/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..2ffbf24 --- /dev/null +++ b/desktop/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..b66c449 Binary files /dev/null and b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..cd40909 Binary files /dev/null and b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..fb93e5b Binary files /dev/null and b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..a2d367e Binary files /dev/null and b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..1b2c344 Binary files /dev/null and b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..4dbc543 Binary files /dev/null and b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..9481608 Binary files /dev/null and b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..58aa4c8 Binary files /dev/null and b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..6d75c5e Binary files /dev/null and b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..12a2533 Binary files /dev/null and b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..e199ac2 Binary files /dev/null and b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..a612334 Binary files /dev/null and b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..147ed1e Binary files /dev/null and b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..117b887 Binary files /dev/null and b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..b2ab90c Binary files /dev/null and b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/desktop/src-tauri/icons/android/values/ic_launcher_background.xml b/desktop/src-tauri/icons/android/values/ic_launcher_background.xml new file mode 100644 index 0000000..ea9c223 --- /dev/null +++ b/desktop/src-tauri/icons/android/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #fff + \ No newline at end of file diff --git a/desktop/src-tauri/icons/icon.icns b/desktop/src-tauri/icons/icon.icns new file mode 100644 index 0000000..673b3f4 Binary files /dev/null and b/desktop/src-tauri/icons/icon.icns differ diff --git a/desktop/src-tauri/icons/icon.ico b/desktop/src-tauri/icons/icon.ico new file mode 100644 index 0000000..ec32956 Binary files /dev/null and b/desktop/src-tauri/icons/icon.ico differ diff --git a/desktop/src-tauri/icons/icon.png b/desktop/src-tauri/icons/icon.png new file mode 100644 index 0000000..549d775 Binary files /dev/null and b/desktop/src-tauri/icons/icon.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png b/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png new file mode 100644 index 0000000..5a6813e Binary files /dev/null and b/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000..cfae1d9 Binary files /dev/null and b/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png b/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png new file mode 100644 index 0000000..cfae1d9 Binary files /dev/null and b/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png b/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png new file mode 100644 index 0000000..e869354 Binary files /dev/null and b/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png b/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png new file mode 100644 index 0000000..eec3f02 Binary files /dev/null and b/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000..5697fa5 Binary files /dev/null and b/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png b/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png new file mode 100644 index 0000000..5697fa5 Binary files /dev/null and b/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png b/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png new file mode 100644 index 0000000..e76d9fa Binary files /dev/null and b/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png b/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png new file mode 100644 index 0000000..cfae1d9 Binary files /dev/null and b/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png new file mode 100644 index 0000000..2a636ab Binary files /dev/null and b/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png b/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png new file mode 100644 index 0000000..2a636ab Binary files /dev/null and b/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png b/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png new file mode 100644 index 0000000..271f1fc Binary files /dev/null and b/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-512@2x.png b/desktop/src-tauri/icons/ios/AppIcon-512@2x.png new file mode 100644 index 0000000..98983ad Binary files /dev/null and b/desktop/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png b/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png new file mode 100644 index 0000000..271f1fc Binary files /dev/null and b/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png b/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png new file mode 100644 index 0000000..8cae339 Binary files /dev/null and b/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png b/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png new file mode 100644 index 0000000..84853ce Binary files /dev/null and b/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png b/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png new file mode 100644 index 0000000..d9f6870 Binary files /dev/null and b/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png new file mode 100644 index 0000000..a2e584e Binary files /dev/null and b/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs new file mode 100644 index 0000000..fc30cb7 --- /dev/null +++ b/desktop/src-tauri/src/main.rs @@ -0,0 +1,164 @@ +// Prevents a stray console window on Windows in release builds. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +mod sidecar; +mod tray; + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; + +use tauri::{AppHandle, Manager, RunEvent, WindowEvent}; +use tauri_plugin_shell::process::CommandChild; + +/// Holds the running jcode sidecar so we can terminate it explicitly on exit. +/// Tauri already best-effort kills spawned children, but a background of +/// loopback servers is exactly where a leaked process is most annoying, so we +/// own the lifecycle outright. +#[derive(Default)] +pub struct SidecarHandle(pub Mutex>); + +/// Cross-cutting desktop state. `tray` records whether the menu-bar tray was +/// actually created — close-to-tray must only swallow the window's close when +/// there is a tray to reopen from, or the user would be stranded (e.g. on a +/// Linux desktop with no StatusNotifier host). +#[derive(Default)] +pub struct DesktopState { + pub tray: AtomicBool, +} + +/// Bring the main window to the foreground (used by the tray and the +/// single-instance guard when a second launch is attempted). +pub fn show_main(app: &AppHandle) { + if let Some(w) = app.get_webview_window("main") { + let _ = w.show(); + let _ = w.unminimize(); + let _ = w.set_focus(); + } +} + +/// Toggle window visibility — the tray icon's left-click behaviour. +pub fn toggle_main(app: &AppHandle) { + if let Some(w) = app.get_webview_window("main") { + if w.is_visible().unwrap_or(false) { + let _ = w.hide(); + } else { + let _ = w.show(); + let _ = w.set_focus(); + } + } +} + +fn kill_sidecar(app: &AppHandle) { + if let Some(state) = app.try_state::() { + if let Ok(mut guard) = state.0.lock() { + if let Some(child) = guard.take() { + let _ = child.kill(); + } + } + } +} + +fn main() { + let mut builder = tauri::Builder::default(); + + // Single-instance must be the FIRST plugin so a second launch is short- + // circuited before any window/sidecar work happens. + #[cfg(desktop)] + { + builder = builder.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { + show_main(app); + })); + } + + builder = builder + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_notification::init()) + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_dialog::init()); + + #[cfg(desktop)] + { + builder = builder + .plugin(tauri_plugin_window_state::Builder::default().build()) + // Registered without a shortcut here; the accelerator is bound in + // setup() so a hotkey conflict logs instead of crashing the app. + .plugin(tauri_plugin_global_shortcut::Builder::new().build()); + } + + let app = builder + .manage(SidecarHandle::default()) + .manage(DesktopState::default()) + .setup(|app| { + // Start the backend FIRST so a (possibly cosmetic) tray failure can + // never prevent the server — and thus the whole app — from coming up. + if let Err(e) = sidecar::start(app.handle()) { + eprintln!("[jcode] failed to start sidecar: {e}"); + use tauri_plugin_dialog::DialogExt; + app.handle() + .dialog() + .message(format!("jcode could not start its local server:\n{e}")) + .title("jcode") + .blocking_show(); + app.handle().exit(1); + return Ok(()); + } + + // The tray is best-effort. On a Linux desktop without a tray host it + // may fail; we log and fall back to "closing the window quits". + match tray::create(app.handle()) { + Ok(()) => { + if let Some(state) = app.try_state::() { + state.tray.store(true, Ordering::Relaxed); + } + } + Err(e) => eprintln!("[jcode] tray unavailable, close will quit: {e}"), + } + + // Global hotkey is a convenience; a conflict must not crash the app. + #[cfg(desktop)] + { + use tauri_plugin_global_shortcut::{ + Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState, + }; + // ⌘/⊞ + Shift + J toggles the window from anywhere. + let toggle = Shortcut::new(Some(Modifiers::SUPER | Modifiers::SHIFT), Code::KeyJ); + if let Err(e) = app.global_shortcut().on_shortcut(toggle, |app, _sc, event| { + if event.state == ShortcutState::Pressed { + toggle_main(app); + } + }) { + eprintln!("[jcode] global shortcut not registered: {e}"); + } + } + + Ok(()) + }) + .on_window_event(|window, event| { + // Close-to-tray: when a tray icon exists, the main window's close + // button hides it instead of quitting, so a long-running agent keeps + // working in the background — reopen from the tray, the global + // hotkey, or relaunching. With no tray, closing quits normally. A + // true exit is always available via the tray "Quit" item, the macOS + // app menu (Cmd+Q), or Alt+F4 / closing when there is no tray. + if let WindowEvent::CloseRequested { api, .. } = event { + if window.label() == "main" { + let tray_active = window + .app_handle() + .try_state::() + .map(|s| s.tray.load(Ordering::Relaxed)) + .unwrap_or(false); + if tray_active { + let _ = window.hide(); + api.prevent_close(); + } + } + } + }) + .build(tauri::generate_context!()) + .expect("error while building jcode desktop"); + + app.run(|app_handle, event| match event { + RunEvent::ExitRequested { .. } | RunEvent::Exit => kill_sidecar(app_handle), + _ => {} + }); +} diff --git a/desktop/src-tauri/src/sidecar.rs b/desktop/src-tauri/src/sidecar.rs new file mode 100644 index 0000000..4c22585 --- /dev/null +++ b/desktop/src-tauri/src/sidecar.rs @@ -0,0 +1,145 @@ +//! Launches the bundled `jcode` binary as a Tauri sidecar and points the main +//! window at it once it is accepting connections. +//! +//! The Go binary already embeds the entire web UI (frontend + REST + WebSocket), +//! so the desktop app reuses that server verbatim: Rust just picks a free +//! loopback port, spawns `jcode web` on it, waits until the port is live, then +//! navigates the (initially hidden, splash-showing) window to the server. + +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::time::Duration; + +use tauri::{AppHandle, Manager, Url}; +use tauri_plugin_shell::process::CommandEvent; +use tauri_plugin_shell::ShellExt; + +use crate::SidecarHandle; + +/// Ask the OS for an unused loopback port. There is a tiny TOCTOU window +/// between dropping this listener and the sidecar binding it, which is +/// acceptable for a local developer tool; the health poll below tolerates a +/// slow or failed bind. +fn pick_free_port() -> u16 { + TcpListener::bind("127.0.0.1:0") + .and_then(|l| l.local_addr()) + .map(|a| a.port()) + .unwrap_or(8799) +} + +pub fn start(app: &AppHandle) -> Result<(), Box> { + let port = pick_free_port(); + let url = format!("http://127.0.0.1:{port}"); + + // Run the server from the user's home directory; the in-app workspace + // picker takes over project selection from there. + let workdir = app + .path() + .home_dir() + .unwrap_or_else(|_| std::env::temp_dir()); + + let (mut rx, child) = app + .shell() + .sidecar("jcode")? + .args([ + "web", + "--port", + &port.to_string(), + "--host", + "127.0.0.1", + "--open=false", + ]) + .current_dir(workdir) + .spawn()?; + + if let Some(state) = app.try_state::() { + if let Ok(mut guard) = state.0.lock() { + *guard = Some(child); + } + } + + // Pump the sidecar's stdout/stderr into the desktop log so `jcode web` + // diagnostics are still reachable when running headless inside the app. + tauri::async_runtime::spawn(async move { + while let Some(event) = rx.recv().await { + match event { + CommandEvent::Stdout(bytes) => { + eprintln!("[jcode] {}", String::from_utf8_lossy(&bytes).trim_end()); + } + CommandEvent::Stderr(bytes) => { + eprintln!("[jcode] {}", String::from_utf8_lossy(&bytes).trim_end()); + } + CommandEvent::Terminated(payload) => { + eprintln!("[jcode] sidecar exited: {payload:?}"); + } + _ => {} + } + } + }); + + // Health-poll the port on a background thread, then reveal the window. We + // verify the /api/health response (not just a bare TCP connect) so that if + // another process grabbed the port in the moment between pick_free_port and + // the sidecar binding it, we don't navigate the window to a foreign server. + let app = app.clone(); + let addr: SocketAddr = ([127, 0, 0, 1], port).into(); + std::thread::spawn(move || { + for _ in 0..400 { + if health_ok(&addr, port) { + if let Some(w) = app.get_webview_window("main") { + if let Ok(parsed) = Url::parse(&url) { + let _ = w.navigate(parsed); + } + let _ = w.show(); + let _ = w.set_focus(); + } + return; + } + std::thread::sleep(Duration::from_millis(150)); + } + // Give up waiting after ~60s but still show the window (splash) so the + // user sees the failure instead of an app that never appears. + if let Some(w) = app.get_webview_window("main") { + let _ = w.show(); + } + }); + + Ok(()) +} + +/// Probe GET /api/health and confirm it's actually our jcode server: a 200 +/// response whose body carries the health JSON ("status" field). A foreign +/// listener that happened to grab the port won't satisfy both. +fn health_ok(addr: &SocketAddr, port: u16) -> bool { + use std::io::{Read, Write}; + + let Ok(mut stream) = TcpStream::connect_timeout(addr, Duration::from_millis(300)) else { + return false; + }; + let _ = stream.set_read_timeout(Some(Duration::from_millis(600))); + let _ = stream.set_write_timeout(Some(Duration::from_millis(600))); + + let req = format!( + "GET /api/health HTTP/1.0\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n" + ); + if stream.write_all(req.as_bytes()).is_err() { + return false; + } + + let mut buf = Vec::with_capacity(2048); + let mut chunk = [0u8; 2048]; + loop { + match stream.read(&mut chunk) { + Ok(0) => break, + Ok(n) => { + buf.extend_from_slice(&chunk[..n]); + if buf.len() > 8192 { + break; + } + } + Err(_) => break, + } + } + + let resp = String::from_utf8_lossy(&buf); + resp.starts_with("HTTP/1.") && resp.contains(" 200 ") && resp.contains("\"status\"") +} diff --git a/desktop/src-tauri/src/tray.rs b/desktop/src-tauri/src/tray.rs new file mode 100644 index 0000000..6ee047b --- /dev/null +++ b/desktop/src-tauri/src/tray.rs @@ -0,0 +1,51 @@ +//! System-tray icon: left-click toggles the window, right-click opens a menu +//! with show / hide / quit. The tray is what keeps jcode reachable after the +//! window is "closed" (hidden) to the background. + +use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; +use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; +use tauri::{AppHandle, Manager}; + +use crate::{show_main, toggle_main}; + +pub fn create(app: &AppHandle) -> Result<(), Box> { + let show = MenuItem::with_id(app, "show", "显示 jcode", true, None::<&str>)?; + let hide = MenuItem::with_id(app, "hide", "隐藏窗口", true, None::<&str>)?; + let sep = PredefinedMenuItem::separator(app)?; + let quit = MenuItem::with_id(app, "quit", "退出 jcode", true, None::<&str>)?; + let menu = Menu::with_items(app, &[&show, &hide, &sep, &quit])?; + + let icon = app + .default_window_icon() + .cloned() + .ok_or("default window icon missing from bundle config")?; + + TrayIconBuilder::with_id("main") + .icon(icon) + .tooltip("jcode") + .menu(&menu) + .show_menu_on_left_click(false) + .on_menu_event(|app, event| match event.id.as_ref() { + "show" => show_main(app), + "hide" => { + if let Some(w) = app.get_webview_window("main") { + let _ = w.hide(); + } + } + "quit" => app.exit(0), + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + toggle_main(tray.app_handle()); + } + }) + .build(app)?; + + Ok(()) +} diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json new file mode 100644 index 0000000..c3bb98b --- /dev/null +++ b/desktop/src-tauri/tauri.conf.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "jcode", + "version": "0.5.3", + "identifier": "com.cnjack.jcode", + "build": { + "frontendDist": "../splash" + }, + "app": { + "withGlobalTauri": true, + "windows": [ + { + "label": "main", + "title": "jcode", + "url": "index.html", + "width": 1280, + "height": 840, + "minWidth": 760, + "minHeight": 480, + "titleBarStyle": "Overlay", + "hiddenTitle": true, + "visible": false, + "center": true + } + ], + "security": { + "csp": null, + "capabilities": ["default"] + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "externalBin": ["binaries/jcode"], + "category": "DeveloperTool", + "shortDescription": "jcode desktop", + "longDescription": "Native desktop shell for jcode — the embedded Go backend runs as a sidecar and Tauri renders the web UI with native system integration.", + "copyright": "© cnjack" + }, + "plugins": {} +} diff --git a/docs/changelog.md b/docs/changelog.md index 22b027e..526aa40 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,6 +1,6 @@ --- title: Changelog -nav_order: 10 +nav_order: 11 --- # Changelog @@ -11,7 +11,9 @@ For the **full changelog**, see [CHANGELOG.md](../CHANGELOG.md) in the repositor ## Unreleased -_Nothing yet._ +#### Added +- **CI quality gate** (`.github/workflows/ci.yml`): Go `build`/`vet`/`test` + golangci-lint (new-issue gating) and web type-check/lint/build run on every push to `main` and every pull request. +- **Desktop bundles in the release pipeline.** `release.yml` now builds the Tauri desktop app for **macOS (Intel + Apple Silicon), Windows, and Linux** on native runners and attaches the `.dmg` / `.msi` / `.exe` / `.deb` / `.AppImage` installers (with checksums) to each GitHub Release alongside the CLI binaries. macOS code-signing/notarization is wired through optional `APPLE_*` secrets (unset → unsigned build). See [Release & CI](release.html). --- diff --git a/docs/commands.md b/docs/commands.md index bbcbb58..43788b9 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1,6 +1,6 @@ --- title: Commands & Shortcuts -nav_order: 7 +nav_order: 8 --- # Commands & Shortcuts diff --git a/docs/configuration.md b/docs/configuration.md index be30b0f..6bd946f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,6 +1,6 @@ --- title: Configuration -nav_order: 6 +nav_order: 7 --- # Configuration diff --git a/docs/desktop.md b/docs/desktop.md new file mode 100644 index 0000000..d2636c6 --- /dev/null +++ b/docs/desktop.md @@ -0,0 +1,154 @@ +--- +title: Desktop App +nav_order: 6 +--- + +# Desktop App + +jcode ships a native desktop application built with [Tauri](https://tauri.app). It +wraps the exact same web UI you get from `jcode web` in a real OS window and adds +native system integration — notifications, a menu-bar tray, single-instance +focus, window-state memory, and a native folder picker. + +It is **not** a second implementation: the Go backend (with the web UI already +embedded) runs as a bundled *sidecar* process, and Tauri renders it. Everything +you can do in the browser UI works identically here. + +## Architecture + +``` +┌─────────────────────────── jcode.app (Tauri) ───────────────────────────┐ +│ │ +│ Rust shell (src-tauri) │ +│ ├─ picks a free loopback port │ +│ ├─ spawns the jcode sidecar: jcode web --port N --host 127.0.0.1 │ +│ ├─ health-polls the port, then navigates the window to it │ +│ ├─ system tray · single-instance · window-state · close-to-tray │ +│ └─ kills the sidecar on exit │ +│ │ +│ WebView ──HTTP/WS──▶ http://127.0.0.1:N (the jcode Go server) │ +│ ▲ ├─ embedded Vue web UI │ +│ │ ├─ REST /api/* │ +│ native APIs └─ WebSocket /ws │ +│ (notification, opener, │ +│ dialog) via Tauri IPC │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +Why a sidecar instead of rewriting the backend in Rust: + +- **Zero divergence.** The desktop app and `jcode web` serve byte-for-byte the + same frontend, REST API, and WebSocket stream. A fix in one is a fix in both. +- **Same-origin simplicity.** The Vue app's relative `/api` and `/ws` URLs just + work because the WebView loads the server's own origin. +- **Native where it counts.** The frontend feature-detects Tauri and routes + notifications, external links, and the folder picker through native plugins, + falling back to web APIs in a plain browser. + +The localhost origin is granted the Tauri JS APIs through a capability `remote` +entry (`http://localhost:*`, `http://127.0.0.1:*`) in +`desktop/src-tauri/capabilities/default.json`. + +## Native capabilities + +| Capability | What it does | +| --- | --- | +| **Notifications** | Native OS notifications on task-finished / approval-needed, fired only when the window isn't focused. Falls back to the web Notification API in the browser. | +| **System tray** | Menu-bar icon: left-click toggles the window; right-click → Show / Hide / Quit. | +| **Close-to-tray** | Closing the window hides it (the agent keeps running in the background). Quit from the tray or ⌘Q to exit for real. | +| **Global shortcut** | ⌘/⊞ + Shift + J toggles the window from anywhere. | +| **Single instance** | Launching jcode again focuses the existing window instead of starting a second copy. | +| **Window state** | Window size and position are remembered across launches. | +| **Native folder picker** | "Open folder" in the workspace switcher uses the OS dialog on desktop. | +| **External links** | Links open in the system browser via the opener plugin. | +| **Overlay title bar** | On macOS the traffic-light buttons sit in a slim draggable strip above the shell. | + +## Security model + +The desktop app keeps the jcode server running in the background (close-to-tray), +so the loopback server's exposure matters. The server drives an agent with shell +and file tools, so reaching its API equals running commands as you. + +- **Loopback only.** The sidecar binds `127.0.0.1` — never LAN-reachable. +- **Random per-launch port.** Not a security control, but raises the bar. +- **Cross-origin gate.** The WebSocket handshake and the REST CORS layer reject + requests whose `Origin` is a foreign website. Same-origin, loopback origins + (covering the Vite dev proxy), and empty-Origin clients are allowed; a page on + `https://evil.com` cannot open `ws://127.0.0.1:` or drive the agent. See + `isAllowedWebOrigin` in `internal/web/server.go` (unit-tested in `cors_test.go`). +- **Native APIs scoped to 127.0.0.1.** The Tauri capability grants notification / + opener / dialog only to the loopback origin the app actually loads. + +Hardening still worth doing (tracked as follow-ups): a per-launch **bearer token** +the sidecar hands the UI (to also stop *local* processes on a shared machine from +reaching the port), and a **Content-Security-Policy** response header from the Go +server as defense-in-depth around the markdown sink (already DOMPurify-sanitized). + +## Building & running + +Prerequisites: the [Rust toolchain](https://rustup.rs) plus the usual jcode web +toolchain (Go, Node, pnpm). On Linux you also need the Tauri system +dependencies (WebKitGTK etc. — see the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/)). + +```bash +# Develop — opens the app with a hot window (rebuilds the sidecar first) +make desktop-dev + +# Build a distributable bundle (.app/.dmg on macOS, .msi on Windows, …) +make desktop-build + +# Regenerate app icons from the brand mark (web/public/icon.svg) +make desktop-icons +``` + +Under the hood `make desktop-sidecar` compiles the Go binary with the host +target-triple suffix Tauri expects +(`desktop/src-tauri/binaries/jcode-`, plus `.exe` on Windows) — +the web UI is embedded into that binary, so the bundle is self-contained. The +finished artifacts land in `desktop/src-tauri/target/release/bundle/`. + +Always build through `make` — it rebuilds the sidecar first. Invoking +`pnpm tauri build` / `pnpm tauri dev` directly does **not** rebuild the sidecar +and will bundle whatever stale binary is in `binaries/`; run `make desktop-sidecar` +first if you must use the Tauri CLI directly. (In this headless build environment +the DMG step fails because it needs Finder/AppleScript; the `.app` itself builds +fine, and the DMG bundles normally on a desktop session.) + +For CI/CD — how tagged releases build and publish these bundles for every platform, +and which macOS signing certificates/secrets are required — see [Release & CI](release.html). + +## Layout + +``` +desktop/ +├─ package.json # drives the Tauri CLI +├─ splash/index.html # shown while the sidecar boots +└─ src-tauri/ + ├─ tauri.conf.json # window, bundle, externalBin (the sidecar) + ├─ capabilities/default.json # permissions + remote localhost grant + ├─ binaries/ # jcode- sidecar (built by make) + ├─ icons/ # generated from web/public/icon.svg + └─ src/ + ├─ main.rs # plugins, tray, close-to-tray, sidecar cleanup + ├─ sidecar.rs # port pick · spawn · health-poll · navigate + └─ tray.rs # menu-bar icon + menu +``` + +The desktop bridge on the frontend side lives in +`web/src/composables/useDesktop.ts` — every export is feature-detected, so the +same web bundle runs unchanged in a browser and inside the desktop shell. + +## Troubleshooting + +- **Window never appears.** The shell waits for the sidecar to accept + connections (up to ~60s) before showing the window; after that it shows the + splash anyway. Check the sidecar's output — it is forwarded to the desktop log + with a `[jcode]` prefix. +- **Native notifications don't fire.** Confirm OS notification permission was + granted, and that the localhost origin is listed under `remote.urls` in the + capability file (this is what enables Tauri JS APIs on the loopback origin). +- **Auto-update.** The updater plugin is intentionally not bundled yet: it + panics at startup unless `plugins.updater` (endpoints + pubkey) is configured, + which needs a signed release feed. To enable it, re-add `tauri-plugin-updater` + (Cargo + `main.rs` + the `updater:default` capability), set + `bundle.createUpdaterArtifacts: true`, and add the `plugins.updater` config. diff --git a/docs/goal.md b/docs/goal.md index 772d1ad..c1bcc1c 100644 --- a/docs/goal.md +++ b/docs/goal.md @@ -1,6 +1,6 @@ --- title: Goals -nav_order: 8 +nav_order: 9 --- # Goals diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..af13c4d --- /dev/null +++ b/docs/release.md @@ -0,0 +1,158 @@ +--- +title: Release & CI +nav_order: 7 +--- + +# Release & CI + +jcode ships through two GitHub Actions workflows: + +| Workflow | File | Trigger | Purpose | +| --- | --- | --- | --- | +| **CI** | [`.github/workflows/ci.yml`](https://github.com/cnjack/jcode/blob/main/.github/workflows/ci.yml) | every push to `main`, every pull request | Quality gate — Go `build`/`vet`/`test` + golangci-lint, web type-check + lint + build. | +| **Release** | [`.github/workflows/release.yml`](https://github.com/cnjack/jcode/blob/main/.github/workflows/release.yml) | push a `v*` tag (or manual `workflow_dispatch`) | Builds the CLI binaries **and** the desktop app bundles for every platform, then publishes them to a GitHub Release. | + +## What gets released + +A single `vX.Y.Z` tag produces, in one GitHub Release: + +### CLI binaries + +| OS | Architectures | +| --- | --- | +| Linux | `amd64`, `arm64` | +| macOS | `amd64` (Intel), `arm64` (Apple Silicon) | +| Windows | `amd64`, `arm64` | + +Each binary ships with a `.sha256` checksum. + +### Desktop app bundles + +Built on native runners because Tauri emits OS-native installers. The Go binary is +compiled as the Tauri **sidecar** and embedded into the bundle (see +[Desktop App](desktop.html)). + +| Platform | Runner | Target triple | Artifact | +| --- | --- | --- | --- | +| macOS Apple Silicon | `macos-latest` | `aarch64-apple-darwin` | `.dmg` | +| macOS Intel | `macos-15-intel` | `x86_64-apple-darwin` | `.dmg` | +| Windows x64 | `windows-latest` | `x86_64-pc-windows-msvc` | `.msi` + `.exe` (NSIS) | +| Linux x64 | `ubuntu-22.04` | `x86_64-unknown-linux-gnu` | `.deb` + `.AppImage` | + +> Yes — the release covers **macOS (Intel + Apple Silicon), Windows, and Linux**. +> Each desktop bundle also ships with a `.sha256` checksum. + +## Cutting a release + +```bash +# Bump the version, then tag and push: +git tag v0.6.0 +git push origin v0.6.0 +``` + +Or trigger manually from the Actions tab → **Release** → *Run workflow*, supplying the +version (e.g. `v0.6.0`). The tag's version is written into the desktop bundle metadata +automatically, so `tauri.conf.json` does not need to be bumped by hand. + +`alpha` / `beta` / `rc` tags are published as **pre-releases** automatically. + +## macOS code signing & notarization + +**The release builds without any signing secrets.** With nothing configured, the +macOS `.dmg` is produced **unsigned** — it works, but Gatekeeper warns on first launch +and users must right-click → **Open** (or run `xattr -dr com.apple.quarantine /Applications/jcode.app`). + +To ship a **signed + notarized** app that launches cleanly, add the secrets below. A +dedicated `Set up macOS signing & notarization` step on the macOS runners resolves +them — importing the certificate into a temporary keychain and writing the API key to +disk — then `tauri build` signs with Developer ID and notarizes automatically. Add +every secret under **Repo → Settings → Secrets and variables → Actions → New repository secret**. + +### Prerequisite + +A paid **[Apple Developer Program](https://developer.apple.com/programs/)** membership +(USD $99/year). This is required to issue a *Developer ID Application* certificate and +to notarize — there is no free path to a Gatekeeper-clean macOS app. + +### Part 1 — Signing certificate (always required) + +Notarization can only run on an app that is **already signed** with a Developer ID +Application certificate, so these are needed no matter which notarization method you pick: + +| Secret | What it is | How to get it | +| --- | --- | --- | +| `APPLE_CERTIFICATE` | Base64 of your **Developer ID Application** certificate exported as `.p12` | Steps below | +| `APPLE_CERTIFICATE_PASSWORD` | The password you set when exporting the `.p12` | You choose it at export time | +| `APPLE_SIGNING_IDENTITY` | _(optional)_ The certificate's full name, e.g. `Developer ID Application: Your Name (TEAMID)` — inferred from the cert if omitted | `security find-identity -v -p codesigning` | + +1. Open **Keychain Access** → *Certificate Assistant* → *Request a Certificate From a Certificate Authority* and save the CSR to disk. +2. At [developer.apple.com/account/resources/certificates](https://developer.apple.com/account/resources/certificates) → **+** → choose **Developer ID Application** → upload the CSR → download the `.cer`, then double-click to install it into Keychain Access. +3. In Keychain Access, find the **Developer ID Application** entry, expand it to include its private key, right-click → **Export** → save as `certificate.p12` and set a password (this becomes `APPLE_CERTIFICATE_PASSWORD`). +4. Base64-encode the `.p12` for the secret value (single line — note the `-A`): + ```bash + openssl base64 -A -in certificate.p12 | pbcopy # value for APPLE_CERTIFICATE + ``` + +### Part 2 — Notarization (pick ONE method) + +The workflow auto-selects whichever method's secrets you provide. **The App Store +Connect API key (Option A) is recommended for CI** — it uses a downloadable key file +rather than a personal Apple ID + password, and survives password/2FA changes. + +#### Option A — App Store Connect API key (recommended) + +| Secret | What it is | How to get it | +| --- | --- | --- | +| `APPLE_API_KEY_P8_BASE64` | Base64 of the **`.p8`** private-key file (this secret carries the actual key material) | Steps below | +| `APPLE_API_KEY_ID` | The **Key ID** string, e.g. `ABCD1234XY`, from the Key ID column | Steps below | +| `APPLE_API_ISSUER` | The **Issuer ID** (a UUID) shown above the keys table | Steps below | + +1. In [App Store Connect](https://appstoreconnect.apple.com/access/integrations/api) → **Users and Access** → **Integrations** → **App Store Connect API** → **Team Keys** → **Generate API Key** (you must be Account Holder / Admin). +2. Name it and assign the **Developer** role (sufficient for notarization), then **Generate**. +3. On the new key's row click **Download API Key** — the **`.p8` file can be downloaded only once**. Save it. +4. Copy the **Issuer ID** (above the table → `APPLE_API_ISSUER`) and the **Key ID** (the row's `Key ID` column → `APPLE_API_KEY_ID`). +5. Base64-encode the `.p8` for the secret value: + ```bash + base64 -i AuthKey_ABCD1234XY.p8 | pbcopy # macOS → value for APPLE_API_KEY_P8_BASE64 + # base64 -w0 AuthKey_ABCD1234XY.p8 # Linux equivalent + ``` + +> **Naming gotcha:** Tauri's own `APPLE_API_KEY` env var holds the **Key ID** — not the +> file and not its contents. You don't set `APPLE_API_KEY` / `APPLE_API_KEY_PATH` +> directly; the workflow sets `APPLE_API_KEY` from your `APPLE_API_KEY_ID` secret, +> decodes `APPLE_API_KEY_P8_BASE64` to `$RUNNER_TEMP/AuthKey_.p8`, and points +> `APPLE_API_KEY_PATH` at that file. You only manage the three secrets above. + +#### Option B — Apple ID + app-specific password + +| Secret | What it is | How to get it | +| --- | --- | --- | +| `APPLE_ID` | The Apple ID email used for notarization | Your developer-account email | +| `APPLE_PASSWORD` | An **app-specific password** (not your real Apple ID password) | [appleid.apple.com](https://appleid.apple.com) → Sign-In and Security → App-Specific Passwords → **+** | +| `APPLE_TEAM_ID` | Your 10-character Apple Team ID | [developer.apple.com/account](https://developer.apple.com/account) → Membership | + +Once **Part 1** plus **one** option of **Part 2** are set, the next tagged release +produces a signed, notarized `.dmg` with no workflow changes needed. (Set only one +notarization method — if both are present the workflow uses Option A.) + +## Windows signing (optional) + +Windows `.msi` / `.exe` bundles are **unsigned** by default — they run, but SmartScreen +shows a publisher warning. To sign them you need a code-signing certificate (OV or EV) +and to wire it into the desktop job (e.g. via Tauri's `signCommand` or +`certificateThumbprint`). This is not configured yet; track it as future work if a +verified publisher is required. + +## Linux + +Linux `.deb` / `.AppImage` bundles are not code-signed (the platform has no equivalent +Gatekeeper gate). No secrets are required. + +## Notes + +- The desktop **auto-updater is not enabled**, so **no** `TAURI_SIGNING_PRIVATE_KEY` / + `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` secrets are needed. +- If a single platform's desktop build fails, the matrix uses `fail-fast: false` so the + others still publish; re-run the failed job afterward. + +See also: [Desktop App](desktop.html). diff --git a/docs/themes.md b/docs/themes.md index 1d27a6b..04b764f 100644 --- a/docs/themes.md +++ b/docs/themes.md @@ -1,6 +1,6 @@ --- title: Themes -nav_order: 9 +nav_order: 10 --- # Themes diff --git a/docs/web-interface.md b/docs/web-interface.md index 88a39e0..d64851a 100644 --- a/docs/web-interface.md +++ b/docs/web-interface.md @@ -7,6 +7,8 @@ nav_order: 5 jcode includes a browser-based interface for users who prefer a visual UI over the terminal. The web UI provides chat, file browsing, terminal access, and full agent control. +> Prefer a native window with OS notifications and a menu-bar tray? The same UI ships as a [Desktop App](desktop). + ![jcode Web UI](asset/web-screenshot.png) ## Starting the Web Server diff --git a/docs/web-task-architecture.md b/docs/web-task-architecture.md new file mode 100644 index 0000000..22a9653 --- /dev/null +++ b/docs/web-task-architecture.md @@ -0,0 +1,103 @@ +# jcode Web 任务化架构设计(任务型 + 并行 + 多项目) + +> 状态:草案(已与维护者讨论定稿核心决策,待实现) +> 对标形态:ZCode 桌面端 —— 会话即"任务",多任务并行,跨项目同时查看/运行。 + +## 目标 + +把 jcode 的 **web 前端 + web 服务端** 从「单会话 / 单项目 / 一次一个 agent」演进为: + +- **任务型(task-centric)**:会话即任务,可置顶 / 归档 / 未读。 +- **并行**:多个任务可同时运行各自的 agent。 +- **多项目**:侧边栏一眼看到所有项目及其最近任务,并能跨项目并行运行。 + +## 已定决策 + +1. **改在 web 编排层(方案 B)**:一个任务 = 一份"引擎实例";并行**只发生在 web 包内**。内核(`session` / `handler` / `runner`)与 TUI / ACP / CLI **一律不动**——它们仍各用一份单跑引擎。 +2. **术语(三层,对齐 ZCode)**: + - **Workspace(工作区)** = 侧边栏容器,装所有 Project。 + - **Project(项目)** = 仓库/文件夹(tpm、jcode…)。← UI 这层明确叫 Project。 + - **Task(任务)** = 某个 Project 下的一次对话/工作线程;置顶/归档/未读/并行都作用在这层。`New chat` → `New task`。 + - 存储与代码保持 `session`(JSONL 转写不变)。只改文案,不改底层。 +3. **并发**:**不设上限**、不排队。jcode 是本地单人工具,不是多人服务,没必要限制。 +4. **项目模型**:每个任务**自带 pwd**(创建时绑定)。去掉"全局当前项目"这一权威态;UI 的"当前项目"只是**新建任务的默认值 + 列表筛选器**。 +5. **资源切分**: + - **每任务一份**:`agent` / `history` / `recorder` / `pwd` / `ctx+cancel` / 审批计数器 / 终端(pty) + - **共享一份**:MCP 连接(工具执行时带任务自己的 cwd)、技能 loader、模型配置 —— v1 先共享,后续按需 per-task + - **审批**:per-task;后台任务需要审批时走**托盘/通知**冒出来,不抢当前任务焦点 + +## 核心模型 + +``` +Server (编排层,只剩传输/HTTP/WS + 共享资源) + ├── tasks map[taskID]*Engine // 每个活跃任务一份引擎 + ├── projects map[path]*ProjectCtx // 按项目缓存 env / 项目技能 + ├── wsBroker (带按 task_id 的订阅过滤) + └── 共享: skillLoader, mcpManager, modelConfig, ptyMgr(按 task 分桶) + +Engine // = 今天 Server 的那些单例字段,按任务实例化一份 + ├── taskID, projectPath(pwd) + ├── agent, history + ├── recorder (该任务的 JSONL) + ├── ctx, cancel + ├── handler: WebHandler(taskID) // 发事件时盖上自己的 task_id + └── approvalCounter, todo/goal 快照 + +Task (持久层/元数据,对用户即"任务") + ├── id(=sessionUUID), projectPath, title + ├── pinned, archived, unread, status(idle/running/done/error) + └── createdAt, updatedAt + 一份 session JSONL 转写 +``` + +关键点:**`AgentEventHandler` 接口不变**——每个任务给一个自己的 `WebHandler` 实例,由它在 emit 时盖 `task_id`,所以 TUI/ACP 共用的接口零改动。 + +## 服务端改造 + +- 从 `internal/web/server.go` 把单例字段(`running / agent / history / recorder / pwd / runCancel`)抽进 `Engine`,`Server` 改持 `map[taskID]*Engine`。 +- 去掉 `handleChat` 的 `CompareAndSwap` 单跑门;`POST /api/chat` 带 `task_id`(缺省则建新任务)。 +- `handleStop` → `/api/stop`(带 `task_id`),查表取对应 `cancel`。 +- per-task 审批:审批计数器/待办表下沉到 `Engine`,审批 ID 带 `task_id`,回传也带。 +- 切项目不再全局拆重建:任务自带 pwd,文件/exec/diff/git 命令都用 `task.pwd`。 + +## WS 协议 + +- 每个事件加 `task_id` 字段。 +- 客户端 `subscribe { task_ids }` / `unsubscribe`;`WSBroker.Broadcast` 按订阅过滤(现在是全广播,多任务会风暴)。 +- 旧客户端忽略未知 `task_id` 字段,平滑兼容。 + +## 持久化与元数据 + +- `SessionMeta` 增加:`Pinned / Archived / Unread / Status / UpdatedAt`。 +- 任务创建即落盘元数据(现在是首条消息才建文件)。 +- 索引文件 `session.json` 加 `version` 字段,做向后兼容迁移。 +- `ListAllSessions()` **已存在但从未被调用**(死代码)→ 接 `GET /api/tasks`(跨项目只读列表),点亮侧边栏多项目树。 + +## Git(走技能,不做手动 git UI) + +- **暴露状态**:`envinfo.GitBranch` 其实已在内存里,只是没 API 返回 → 新增 `GET /api/workspace`(或扩 `/api/status`)返回 `branch / dirty / diff 统计`,修掉 `TopBar.vue` 写死的 `null`。 +- **提交/推送/开 PR**:一个 `submit-pr` 技能(+ 一个 commit/pr 工具)。你一句话触发 agent 跑它,**不做手动 git 按钮**。 + +## 分期落地 + +### Phase 0 — 零架构改动、并行无关的价值(先做) +- [ ] 暴露 git branch/status(`/api/workspace`)→ 修 TopBar 的 null 分支 +- [ ] 接 `ListAllSessions` → `GET /api/tasks` 跨项目只读 → 侧边栏**多项目任务树** +- [ ] `SessionMeta` 加 `pinned/archived/unread/status/updatedAt` + 端点 → 右键菜单 + 未读 +- [ ] `submit-pr` 技能(+ 工具) +- [ ] 命令面板 ⌘K、输入框 `$` 技能 / `#` 关联、浏览器通知(纯前端) + +### Phase 1 — 任务键化(后端,仍串行) +把单例抽成 `Engine`、按 `task_id` 路由、WS 带 `task_id`、前端 store 改 `Map`,并发先按 1 跑通——把"拆单例"和"真并行"解耦验证。 + +### Phase 2 — 真并行(无上限) +放开并发、per-task 审批路由、WS 订阅过滤、任务自带 pwd、注意内存/句柄回收。 + +### Phase 3 — 并行 UX +运行中任务托盘、per-task 未读/通知、并行审批界面。 + +## 最大风险 + +真并行会放大三件事,Phase 1 先串行键化就是为了在没有并发压力时把它们打磨好: +1. **审批串扰** —— 必须 per-task 路由。 +2. **WS 广播风暴** —— 必须订阅过滤。 +3. **资源占用** —— 不设硬上限,但要做内存/文件句柄/PTY 的回收。 diff --git a/internal/command/ssh.go b/internal/command/ssh.go index 34d11ab..9fd6795 100644 --- a/internal/command/ssh.go +++ b/internal/command/ssh.go @@ -10,11 +10,15 @@ import ( einomodel "github.com/cloudwego/eino/components/model" "github.com/cnjack/jcode/internal/prompts" + "github.com/cnjack/jcode/internal/remote" "github.com/cnjack/jcode/internal/tools" "github.com/cnjack/jcode/internal/tui" ) -// handleSSHConnect connects to a remote machine via SSH and reconfigures the env. +// HandleSSHConnect connects to a remote machine via SSH and reconfigures the +// env. The pure connection + directory-listing logic lives in internal/remote +// so the web server can reuse it; this function keeps only the TUI glue +// (p.Send of status/dir messages). func HandleSSHConnect( ctx context.Context, env *tools.Env, @@ -33,23 +37,18 @@ func HandleSSHConnect( host = parts[1] } - executor, err := tools.NewSSHExecutor(host, user, tools.BuildSSHAuthMethods()) + executor, err := remote.Connect(remote.SSHOptions{Host: host, User: user}) if err != nil { p.Send(tui.SSHStatusMsg{Success: false, Err: err}) return } - // Temporarily set the executor so handleSSHListDir can use it during + // Temporarily set the executor so HandleSSHListDir can use it during // interactive path selection. env.SetSSH(executor, "/root") if path == "?" { - remotePwd := "/root" - if stdout, _, execErr := executor.Exec(ctx, "pwd", "", 5*1e9); execErr == nil { - if trimmed := strings.TrimSpace(stdout); trimmed != "" { - remotePwd = trimmed - } - } + remotePwd := remote.DiscoverPwd(ctx, executor, "/root") HandleSSHListDir(ctx, env, remotePwd, p) return // Do not initialize agent yet } @@ -58,11 +57,7 @@ func HandleSSHConnect( if path != "" { remotePwd = path } else { - if stdout, _, execErr := executor.Exec(ctx, "pwd", "", 5*1e9); execErr == nil { - if trimmed := strings.TrimSpace(stdout); trimmed != "" { - remotePwd = trimmed - } - } + remotePwd = remote.DiscoverPwd(ctx, executor, "/root") } env.SetSSH(executor, remotePwd) @@ -76,41 +71,17 @@ func HandleSSHConnect( p.Send(tui.SSHStatusMsg{ Success: true, - Label: fmt.Sprintf("%s@%s (pwd: %s)", user, host, remotePwd), + Label: envLabel, }) } -// handleSSHListDir runs `ls` on the remote host and sends the results to the +// HandleSSHListDir runs `ls` on the remote host and sends the results to the // TUI directory picker. func HandleSSHListDir(ctx context.Context, env *tools.Env, path string, p *tea.Program) { - cmd := fmt.Sprintf("ls -F -1 %s", tools.ShellQuote(path)) - stdout, stderr, err := env.Exec.Exec(ctx, cmd, "", 10*1e9) + dirs, err := remote.ListDirs(ctx, env.Exec, path) if err != nil { - p.Send(tui.SSHDirResultsMsg{Err: fmt.Errorf("ls failed: %v\nstderr: %s", err, truncate(stderr, 100))}) + p.Send(tui.SSHDirResultsMsg{Err: err}) return } - - lines := strings.Split(strings.TrimSpace(stdout), "\n") - var dirs []string - if path != "/" { - dirs = append(dirs, "..") - } - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - if strings.HasSuffix(line, "/") { - dirs = append(dirs, line[:len(line)-1]) - } - } - p.Send(tui.SSHDirResultsMsg{Path: path, Items: dirs}) } - -func truncate(s string, l int) string { - if len(s) > l { - return s[:l] + "..." - } - return s -} diff --git a/internal/command/web.go b/internal/command/web.go index ed6ac44..2a597e4 100644 --- a/internal/command/web.go +++ b/internal/command/web.go @@ -23,6 +23,7 @@ import ( internalmodel "github.com/cnjack/jcode/internal/model" weixin "github.com/cnjack/jcode/internal/pkg/weixin" "github.com/cnjack/jcode/internal/prompts" + "github.com/cnjack/jcode/internal/remote" "github.com/cnjack/jcode/internal/runner" "github.com/cnjack/jcode/internal/session" "github.com/cnjack/jcode/internal/skills" @@ -337,6 +338,11 @@ func runWebServer(port int, host string, openBrowser bool) error { } switchProject := func(newPwd string) (*adk.ChatModelAgent, *session.Recorder, error) { + // 0. Close any live remote SSH connection we're switching away from. + if prev, ok := env.Exec.(*tools.SSHExecutor); ok { + defer func() { _ = prev.Close() }() + } + // 1. Update env working directory (all tools share the same *Env). env.ResetToLocal(newPwd, platform) @@ -371,6 +377,50 @@ func runWebServer(port int, host string, openBrowser bool) error { return newAg, newRec, nil } + // switchToRemote mirrors switchProject but binds the shared env to a remote + // SSH executor instead of a local path. It reuses the SAME agent/recorder + // rebuild sequence so the agent, system prompt and session recorder stay + // consistent with the local switch path. + switchToRemote := func(executor *tools.SSHExecutor, remotePwd string) (*adk.ChatModelAgent, *session.Recorder, error) { + // 0. Close the previous live remote SSH connection (if switching + // remote→remote); switching from local has nothing to close. + if prev, ok := env.Exec.(*tools.SSHExecutor); ok && prev != executor { + defer func() { _ = prev.Close() }() + } + + // 1. Point the shared env at the remote SSH executor. + env.SetSSH(executor, remotePwd) + remotePlatform := executor.Platform() + + // 2. Approval state now governs the remote working directory. + approvalState.SetWorkpath(remotePwd) + + // 3. Re-render the system prompt with the remote env label + platform. + // Project skills are scanned from the LOCAL fs, so keep the existing + // descriptions rather than rescanning against the remote path. + envLabel := fmt.Sprintf("%s (pwd: %s)", executor.Label(), remotePwd) + systemPrompt = prompts.GetSystemPrompt(remotePlatform, remotePwd, envLabel, nil, skillLoader.Descriptions()) + + // 4. Update the captured pwd (env already points at the remote target). + pwd = remotePwd + + // 5. Recorder scoped to a host-qualified project key so a remote path + // does not collide with a local path of the same name in the tree. + projectKey := remote.ProjectLabel(executor, remotePwd) + if rec != nil { + rec.Close() + } + newRec, _ := session.NewRecorder(projectKey, providerName, modelName) + rec = newRec + + // 6. Rebuild the agent with the updated remote prompt. + newAg, err := createAgent(providerName, modelName) + if err != nil { + return nil, nil, err + } + return newAg, newRec, nil + } + srv := web.NewServer(&web.ServerConfig{ Port: port, Host: host, @@ -382,6 +432,7 @@ func runWebServer(port int, host string, openBrowser bool) error { RebuildForMode: rebuildForMode, InitialMode: startupMode.String(), SwitchProject: switchProject, + SwitchToRemote: switchToRemote, TodoStore: env.TodoStore, Recorder: rec, Tracer: langfuseTracer, diff --git a/internal/remote/ssh.go b/internal/remote/ssh.go new file mode 100644 index 0000000..89e393d --- /dev/null +++ b/internal/remote/ssh.go @@ -0,0 +1,182 @@ +// Package remote provides UI-agnostic helpers for connecting to and inspecting +// remote execution targets (currently SSH). The connection + directory-listing +// logic here was extracted from the TUI command layer so that both the TUI and +// the web server can drive the same flow without depending on bubbletea. +package remote + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "golang.org/x/crypto/ssh" + + "github.com/cnjack/jcode/internal/tools" +) + +// SSHOptions describes how to reach and authenticate with a remote host. +// +// Host may be "host", "host:port" or "user@host"; User and Port fill in the +// pieces a form provides separately. When neither Password nor KeyPath is set, +// authentication falls back to the SSH agent + the default ~/.ssh keys (the same +// behavior as tools.BuildSSHAuthMethods, used by the TUI). +type SSHOptions struct { + Host string + Port int + User string + Password string // password auth + KeyPath string // explicit private key file (~ is expanded) + Passphrase string // passphrase for an encrypted private key +} + +// resolveTarget splits Host into a dial address ("host:port") and a username, +// honoring an embedded "user@" prefix and an explicit Port. +func resolveTarget(opts SSHOptions) (addr, user string) { + user = strings.TrimSpace(opts.User) + host := strings.TrimSpace(opts.Host) + if at := strings.SplitN(host, "@", 2); len(at) == 2 { + if user == "" { + user = at[0] + } + host = at[1] + } + if user == "" { + user = "root" + } + // Apply an explicit port only when the host doesn't already carry one. + if opts.Port > 0 && !strings.Contains(host, ":") { + host = fmt.Sprintf("%s:%d", host, opts.Port) + } + return host, user +} + +// BuildAuthMethods assembles the SSH auth methods for the given options. It +// returns an error if an explicit key cannot be read or parsed, and falls back +// to the agent + default keys when no explicit credentials are supplied. +func BuildAuthMethods(opts SSHOptions) ([]ssh.AuthMethod, error) { + var methods []ssh.AuthMethod + + if opts.Password != "" { + methods = append(methods, ssh.Password(opts.Password)) + } + + if kp := strings.TrimSpace(opts.KeyPath); kp != "" { + key, err := os.ReadFile(expandHome(kp)) + if err != nil { + return nil, fmt.Errorf("read private key: %w", err) + } + var signer ssh.Signer + if opts.Passphrase != "" { + signer, err = ssh.ParsePrivateKeyWithPassphrase(key, []byte(opts.Passphrase)) + } else { + signer, err = ssh.ParsePrivateKey(key) + } + if err != nil { + return nil, fmt.Errorf("parse private key %s: %w", kp, err) + } + methods = append(methods, ssh.PublicKeys(signer)) + } + + // Fall back to agent + default keys when nothing explicit was provided. + if len(methods) == 0 { + methods = tools.BuildSSHAuthMethods() + } + if len(methods) == 0 { + return nil, fmt.Errorf("no SSH credentials available: provide a password or key, or load keys into the SSH agent") + } + return methods, nil +} + +// Connect dials the remote host described by opts and returns a live executor. +func Connect(opts SSHOptions) (*tools.SSHExecutor, error) { + addr, user := resolveTarget(opts) + methods, err := BuildAuthMethods(opts) + if err != nil { + return nil, err + } + return tools.NewSSHExecutor(addr, user, methods) +} + +// DiscoverPwd returns the remote default working directory (best effort), +// falling back to the provided default when `pwd` cannot be determined. +func DiscoverPwd(ctx context.Context, exec tools.Executor, fallback string) string { + if fallback == "" { + fallback = "/root" + } + if stdout, _, err := exec.Exec(ctx, "pwd", "", 5*time.Second); err == nil { + if trimmed := strings.TrimSpace(stdout); trimmed != "" { + return trimmed + } + } + return fallback +} + +// ListDirs lists the sub-directories of path on the remote target using the +// executor. ".." is prepended (unless already at the filesystem root) so callers +// can render an "up" entry in a directory picker. +func ListDirs(ctx context.Context, exec tools.Executor, path string) ([]string, error) { + cmd := fmt.Sprintf("ls -F -1 %s", tools.ShellQuote(path)) + stdout, stderr, err := exec.Exec(ctx, cmd, "", 10*time.Second) + if err != nil { + return nil, fmt.Errorf("ls %s failed: %v: %s", path, err, truncate(stderr, 100)) + } + + var dirs []string + if path != "/" { + dirs = append(dirs, "..") + } + for _, line := range strings.Split(strings.TrimSpace(stdout), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if strings.HasSuffix(line, "/") { + dirs = append(dirs, strings.TrimSuffix(line, "/")) + } + } + return dirs, nil +} + +// ProjectLabel returns a stable, host-qualified identifier for a remote working +// directory. It is used as the session "project" key so remote sessions are +// grouped separately from local paths of the same name. Form: +// ssh://user@host:port/remote/path +func ProjectLabel(exec *tools.SSHExecutor, remotePwd string) string { + return fmt.Sprintf("ssh://%s@%s%s", exec.User(), exec.Host(), normalizeRemotePath(remotePwd)) +} + +// normalizeRemotePath ensures the path is absolute (leading slash) for use in a +// project label. +func normalizeRemotePath(p string) string { + if !strings.HasPrefix(p, "/") { + return "/" + p + } + return p +} + +// expandHome expands a leading ~ or ~/ to the current user's home directory. +func expandHome(path string) string { + if path == "~" { + if home, err := os.UserHomeDir(); err == nil { + return home + } + return path + } + if strings.HasPrefix(path, "~/") { + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, path[2:]) + } + } + return path +} + +func truncate(s string, n int) string { + s = strings.TrimSpace(s) + if len(s) > n { + return s[:n] + "..." + } + return s +} diff --git a/internal/remote/ssh_test.go b/internal/remote/ssh_test.go new file mode 100644 index 0000000..2a934b4 --- /dev/null +++ b/internal/remote/ssh_test.go @@ -0,0 +1,106 @@ +package remote + +import ( + "context" + "os" + "testing" + "time" + + "github.com/cnjack/jcode/internal/tools" +) + +func TestResolveTarget(t *testing.T) { + cases := []struct { + name string + opts SSHOptions + wantAddr string + wantUser string + }{ + {"host+user+port", SSHOptions{Host: "1.2.3.4", User: "root", Port: 2222}, "1.2.3.4:2222", "root"}, + {"host only defaults user", SSHOptions{Host: "1.2.3.4"}, "1.2.3.4", "root"}, + {"user@host embedded", SSHOptions{Host: "deploy@example.com", Port: 22}, "example.com:22", "deploy"}, + {"explicit user beats embedded", SSHOptions{Host: "deploy@example.com", User: "root"}, "example.com", "root"}, + {"host already has port ignores port opt", SSHOptions{Host: "1.2.3.4:2200", Port: 22, User: "u"}, "1.2.3.4:2200", "u"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + addr, user := resolveTarget(c.opts) + if addr != c.wantAddr || user != c.wantUser { + t.Fatalf("resolveTarget(%+v) = (%q,%q), want (%q,%q)", c.opts, addr, user, c.wantUser, c.wantAddr) + } + }) + } +} + +// fakeExecutor implements tools.Executor for ListDirs / DiscoverPwd tests. +type fakeExecutor struct { + out string + err error +} + +func (f *fakeExecutor) ReadFile(context.Context, string) ([]byte, error) { return nil, nil } +func (f *fakeExecutor) WriteFile(context.Context, string, []byte, os.FileMode) error { + return nil +} +func (f *fakeExecutor) MkdirAll(context.Context, string, os.FileMode) error { return nil } +func (f *fakeExecutor) Stat(context.Context, string) (*tools.FileInfo, error) { + return &tools.FileInfo{Exists: true, IsDir: true}, nil +} +func (f *fakeExecutor) Exec(context.Context, string, string, time.Duration) (string, string, error) { + return f.out, "", f.err +} +func (f *fakeExecutor) Platform() string { return "linux/amd64" } +func (f *fakeExecutor) Label() string { return "fake" } + +func TestListDirs(t *testing.T) { + // `ls -F -1` output: dirs end with "/", executables with "*", files plain. + exec := &fakeExecutor{out: "bin/\nREADME.md\nsrc/\nrun.sh*\n.git/\n"} + dirs, err := ListDirs(context.Background(), exec, "/home/app") + if err != nil { + t.Fatalf("ListDirs error: %v", err) + } + want := []string{"..", "bin", "src", ".git"} + if len(dirs) != len(want) { + t.Fatalf("ListDirs = %v, want %v", dirs, want) + } + for i := range want { + if dirs[i] != want[i] { + t.Fatalf("ListDirs[%d] = %q, want %q (full: %v)", i, dirs[i], want[i], dirs) + } + } +} + +func TestListDirsRootHasNoParent(t *testing.T) { + exec := &fakeExecutor{out: "etc/\nusr/\n"} + dirs, err := ListDirs(context.Background(), exec, "/") + if err != nil { + t.Fatalf("ListDirs error: %v", err) + } + if len(dirs) == 0 || dirs[0] == ".." { + t.Fatalf("root listing must not include '..': %v", dirs) + } +} + +func TestDiscoverPwdFallback(t *testing.T) { + exec := &fakeExecutor{out: " /opt/work \n"} + if got := DiscoverPwd(context.Background(), exec, "/root"); got != "/opt/work" { + t.Fatalf("DiscoverPwd = %q, want /opt/work", got) + } + bad := &fakeExecutor{out: "", err: context.DeadlineExceeded} + if got := DiscoverPwd(context.Background(), bad, "/root"); got != "/root" { + t.Fatalf("DiscoverPwd fallback = %q, want /root", got) + } +} + +func TestProjectLabel(t *testing.T) { + // ProjectLabel needs a *tools.SSHExecutor; build a zero executor isn't + // possible (unexported fields), so assert the format via a helper on a + // constructed value would require reflection. Instead, verify the path + // normalization branch indirectly through label-shaped expectations. + if got := normalizeRemotePath("home/app"); got != "/home/app" { + t.Fatalf("normalizeRemotePath = %q, want /home/app", got) + } + if got := normalizeRemotePath("/srv"); got != "/srv" { + t.Fatalf("normalizeRemotePath = %q, want /srv", got) + } +} diff --git a/internal/session/index_test.go b/internal/session/index_test.go new file mode 100644 index 0000000..d094a0f --- /dev/null +++ b/internal/session/index_test.go @@ -0,0 +1,41 @@ +package session + +import "testing" + +// TestRecorderIndexingRequiresContent locks the contract the web server's +// todo/goal OnUpdate guard relies on: a recorder that has written nothing is +// NOT listed (so ambient todo/goal updates, which the server now skips while +// HasRecording() is false, can never create a phantom empty session), and the +// session only appears once a real user message has been recorded. +func TestRecorderIndexingRequiresContent(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + const project = "/proj/elves" + + rec, err := NewRecorder(project, "zhipu", "glm-5.2") + if err != nil { + t.Fatalf("NewRecorder: %v", err) + } + + // A fresh recorder has no file and must not be indexed. + if rec.HasRecording() { + t.Fatal("fresh recorder should report HasRecording() == false") + } + if metas, _ := ListSessions(project); len(metas) != 0 { + t.Fatalf("a recorder that wrote nothing must not be indexed; got %d sessions", len(metas)) + } + + // The first user message creates the file and indexes the session. + rec.RecordUser("hello") + if !rec.HasRecording() { + t.Fatal("recorder should report HasRecording() == true after a user message") + } + metas, err := ListSessions(project) + if err != nil { + t.Fatalf("ListSessions: %v", err) + } + if len(metas) != 1 || metas[0].UUID != rec.UUID() { + t.Fatalf("expected the session indexed after a user message, got %+v", metas) + } + rec.Close() +} diff --git a/internal/session/session.go b/internal/session/session.go index 339e120..634f1c2 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -107,6 +107,13 @@ type SessionMeta struct { Model string `json:"model"` StartTime string `json:"start_time"` // RFC3339 Title string `json:"title,omitempty"` + // Task metadata. Additive — legacy index files simply lack these keys, which + // unmarshal to zero values (not pinned / not archived / read). + Pinned bool `json:"pinned,omitempty"` + Archived bool `json:"archived,omitempty"` + Unread bool `json:"unread,omitempty"` + Status string `json:"status,omitempty"` // idle/running/done/error (set by the web layer) + UpdatedAt string `json:"updated_at,omitempty"` // RFC3339 } // sessionIndex is the on-disk structure of session.json. @@ -657,6 +664,64 @@ func DeleteSession(project, uuid string) error { return nil } +// DeleteSessionByUUID removes a session (index entry + JSONL file) located by +// uuid across ALL projects. The web task tree can delete a task that does not +// belong to the active project, so we must not assume a single project key. +// Returns false if no session with that uuid exists. The JSONL file is only +// removed when the uuid was actually found in the index, which also prevents a +// crafted uuid from deleting an arbitrary file. +func DeleteSessionByUUID(uuid string) (bool, error) { + indexPath, err := config.SessionsIndexPath() + if err != nil { + return false, err + } + data, err := os.ReadFile(indexPath) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + var idx sessionIndex + if err := json.Unmarshal(data, &idx); err != nil { + return false, err + } + found := false + for project, metas := range idx.Sessions { + filtered := make([]SessionMeta, 0, len(metas)) + for _, m := range metas { + if m.UUID == uuid { + found = true + continue + } + filtered = append(filtered, m) + } + idx.Sessions[project] = filtered + } + if !found { + return false, nil + } + newData, err := json.MarshalIndent(&idx, "", " ") + if err != nil { + return true, err + } + tmpPath := indexPath + ".tmp" + if err := os.WriteFile(tmpPath, newData, 0644); err != nil { + return true, err + } + if err := os.Rename(tmpPath, indexPath); err != nil { + return true, err + } + dir, err := config.SessionsDir() + if err != nil { + return true, err + } + if rmErr := os.Remove(filepath.Join(dir, uuid+".json")); rmErr != nil && !os.IsNotExist(rmErr) { + return true, fmt.Errorf("delete session file: %w", rmErr) + } + return true, nil +} + func removeFromIndex(project, uuid string) error { indexPath, err := config.SessionsIndexPath() if err != nil { @@ -712,6 +777,50 @@ func ListSessions(project string) ([]SessionMeta, error) { return idx.Sessions[project], nil } +// UpdateSessionMeta finds a session by uuid across all projects, applies mutate +// to its metadata, and persists the index atomically. Returns the updated meta, +// or (nil, nil) if no session with that uuid exists. uuid is only compared in +// memory (never used as a path), so no path validation is required here. +func UpdateSessionMeta(uuid string, mutate func(*SessionMeta)) (*SessionMeta, error) { + indexPath, err := config.SessionsIndexPath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(indexPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var idx sessionIndex + if err := json.Unmarshal(data, &idx); err != nil { + return nil, err + } + for project, metas := range idx.Sessions { + for i := range metas { + if metas[i].UUID == uuid { + mutate(&metas[i]) + idx.Sessions[project] = metas + newData, err := json.MarshalIndent(&idx, "", " ") + if err != nil { + return nil, err + } + tmpPath := indexPath + ".tmp" + if err := os.WriteFile(tmpPath, newData, 0644); err != nil { + return nil, err + } + if err := os.Rename(tmpPath, indexPath); err != nil { + return nil, err + } + updated := metas[i] + return &updated, nil + } + } + } + return nil, nil +} + // ListAllSessions returns all sessions across all projects, keyed by project path. func ListAllSessions() (map[string][]SessionMeta, error) { indexPath, err := config.SessionsIndexPath() diff --git a/internal/skills/builtin/submit-pr/SKILL.md b/internal/skills/builtin/submit-pr/SKILL.md new file mode 100644 index 0000000..d4188fa --- /dev/null +++ b/internal/skills/builtin/submit-pr/SKILL.md @@ -0,0 +1,38 @@ +--- +name: submit-pr +description: Commit the current changes, push the branch, and open a GitHub pull request +slash: /submit-pr +--- + +# Submit PR Skill + +You commit the working changes, push the branch, and open a GitHub pull request. +This is the way the user submits work — there is no manual git UI, so do it +carefully and report the PR URL at the end. + +## Steps + +1. **Check state.** Run `git status --short` and `git diff --stat`. If there is + nothing to submit (no staged, unstaged, or unpushed commits), tell the user + and stop. +2. **Pick a branch.** Run `git rev-parse --abbrev-ref HEAD`. If you are on the + default branch (`main`/`master`), create a feature branch first: + `git checkout -b ` named after the change. +3. **Review before committing.** Read `git status` and the diff so you do not + commit secrets, build artifacts, or unrelated files. Stage intentionally + (`git add `), not blindly with `git add -A` unless you have confirmed + the whole tree is intended. +4. **Commit** with a clear Conventional Commit message (`feat:`/`fix:`/`docs:`…) + summarizing the work. +5. **Push:** `git push -u origin `. +6. **Open the PR:** `gh pr create --title "" --body "<body>"`. The body + should summarize what changed and why, and list the testing/verification done. +7. **Report the PR URL** returned by `gh`. + +## Rules + +- Never force-push, and never push directly to the default branch. +- If the diff is large or ambiguous, summarize it and confirm intent before committing. +- If `gh` is not authenticated, instruct the user to run `gh auth login`. +- Keep the commit message and PR title concise and conventional. +- If a PR already exists for the branch, update it (push) rather than creating a duplicate. diff --git a/internal/skills/skills_test.go b/internal/skills/skills_test.go new file mode 100644 index 0000000..cfb4aa6 --- /dev/null +++ b/internal/skills/skills_test.go @@ -0,0 +1,31 @@ +package skills + +import "testing" + +// The submit-pr builtin skill must be discoverable so the agent can commit, +// push and open a PR when the user triggers it by query (the "Git via skill" +// flow — there is no manual git UI). +func TestSubmitPRSkillLoaded(t *testing.T) { + l := NewLoader() + + sk := l.Get("submit-pr") + if sk == nil { + t.Fatal("submit-pr builtin skill not loaded") + } + if !sk.Builtin { + t.Errorf("submit-pr should be a builtin skill") + } + if sk.Description == "" { + t.Errorf("submit-pr should have a description (used in the system prompt)") + } + + // Reachable via its slash trigger. + if bySlash := l.GetBySlash("/submit-pr"); bySlash == nil || bySlash.Name != "submit-pr" { + t.Fatalf("submit-pr not reachable via /submit-pr slash command") + } + + // Body content is non-empty (load_skill returns it to the agent). + if l.GetContent("submit-pr") == "" { + t.Errorf("submit-pr GetContent returned empty body") + } +} diff --git a/internal/tools/env.go b/internal/tools/env.go index f24556b..2edea14 100644 --- a/internal/tools/env.go +++ b/internal/tools/env.go @@ -361,6 +361,12 @@ func (s *SSHExecutor) Exec(ctx context.Context, command, workDir string, timeout func (s *SSHExecutor) Platform() string { return s.platform } +// User returns the SSH username. +func (s *SSHExecutor) User() string { return s.user } + +// Host returns the dialed host (includes the port, e.g. "1.2.3.4:22"). +func (s *SSHExecutor) Host() string { return s.host } + func (s *SSHExecutor) Label() string { return fmt.Sprintf("%s@%s", s.user, s.host) } diff --git a/internal/web/cors_test.go b/internal/web/cors_test.go new file mode 100644 index 0000000..4370d3f --- /dev/null +++ b/internal/web/cors_test.go @@ -0,0 +1,43 @@ +package web + +import ( + "net/http/httptest" + "testing" +) + +// TestIsAllowedWebOrigin locks the cross-origin gate that protects the +// loopback agent-control API + WebSocket. The legitimate flows (same-origin, +// loopback, the Vite dev proxy, LAN access) must pass; a cross-origin website +// must be rejected. +func TestIsAllowedWebOrigin(t *testing.T) { + cases := []struct { + name string + host string // the server's own Host (request target) + origin string // the browser-set Origin header + want bool + }{ + {"empty origin (curl / native client)", "127.0.0.1:8080", "", true}, + {"same-origin loopback (desktop webview / local browser)", "127.0.0.1:53913", "http://127.0.0.1:53913", true}, + {"same-origin LAN (--host 0.0.0.0)", "192.168.1.5:8080", "http://192.168.1.5:8080", true}, + {"vite dev proxy localhost:5173 -> 127.0.0.1:8091", "127.0.0.1:8091", "http://localhost:5173", true}, + {"loopback origin, different port", "127.0.0.1:8080", "http://127.0.0.1:9999", true}, + {"localhost origin to 127.0.0.1 backend", "127.0.0.1:8080", "http://localhost:8080", true}, + {"cross-origin website", "127.0.0.1:53913", "https://evil.com", false}, + {"cross-origin website targeting LAN ip", "192.168.1.5:8080", "https://evil.example", false}, + {"non-loopback private ip, different origin", "127.0.0.1:8080", "http://10.0.0.9:8080", false}, + {"malformed origin", "127.0.0.1:8080", "://nonsense", false}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := httptest.NewRequest("GET", "/api/health", nil) + r.Host = c.host + if c.origin != "" { + r.Header.Set("Origin", c.origin) + } + if got := isAllowedWebOrigin(r); got != c.want { + t.Errorf("isAllowedWebOrigin(host=%q, origin=%q) = %v, want %v", c.host, c.origin, got, c.want) + } + }) + } +} diff --git a/internal/web/git.go b/internal/web/git.go new file mode 100644 index 0000000..0fe9b85 --- /dev/null +++ b/internal/web/git.go @@ -0,0 +1,108 @@ +package web + +import ( + "encoding/json" + "io" + "net/http" + "os/exec" + "strings" +) + +// handleGitBranches lists local branch names (most-recently-committed first) +// plus the current branch, for the composer's branch picker. A non-git +// directory or any git error yields an empty list rather than an error, +// mirroring handleWorkspace so the UI degrades gracefully. +func (s *Server) handleGitBranches(w http.ResponseWriter, r *http.Request) { + listCmd := exec.CommandContext(r.Context(), "git", "for-each-ref", + "--format=%(refname:short)", "--sort=-committerdate", "refs/heads") + listCmd.Dir = s.pwd + out, err := listCmd.Output() + if err != nil { + writeJSON(w, http.StatusOK, map[string]any{"current": "", "branches": []string{}}) + return + } + + // `branch --show-current` reports the unborn branch of a fresh repo (e.g. + // "main"), where `rev-parse --abbrev-ref HEAD` would just say "HEAD". + curCmd := exec.CommandContext(r.Context(), "git", "branch", "--show-current") + curCmd.Dir = s.pwd + curOut, _ := curCmd.Output() + current := strings.TrimSpace(string(curOut)) + + branches := make([]string, 0, 16) + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if b := strings.TrimSpace(line); b != "" { + branches = append(branches, b) + } + } + // A freshly initialised repo's current branch has no ref yet, so + // for-each-ref omits it — surface it anyway so the picker shows the branch + // you're actually on (with its checkmark). + if current != "" { + found := false + for _, b := range branches { + if b == current { + found = true + break + } + } + if !found { + branches = append([]string{current}, branches...) + } + } + writeJSON(w, http.StatusOK, map[string]any{ + "current": current, + "branches": branches, + }) +} + +// handleGitCheckout switches to an existing branch, or creates and checks out a +// new branch when create=true. It refuses while the agent is running (a branch +// switch rewrites the working tree under a live task) and surfaces git's own +// error verbatim (e.g. "Your local changes would be overwritten") rather than +// forcing a destructive checkout — the user decides how to resolve a dirty tree. +func (s *Server) handleGitCheckout(w http.ResponseWriter, r *http.Request) { + if s.running.Load() { + writeJSON(w, http.StatusConflict, map[string]string{ + "error": "agent is running — stop it before switching branch", + }) + return + } + + var req struct { + Branch string `json:"branch"` + Create bool `json:"create"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + branch := strings.TrimSpace(req.Branch) + if branch == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "branch is required"}) + return + } + + args := []string{"checkout"} + if req.Create { + args = append(args, "-b") + } + args = append(args, branch) + + cmd := exec.CommandContext(r.Context(), "git", args...) + cmd.Dir = s.pwd + out, err := cmd.CombinedOutput() + if err != nil { + msg := strings.TrimSpace(string(out)) + if msg == "" { + msg = err.Error() + } + writeJSON(w, http.StatusConflict, map[string]string{"error": msg}) + return + } + + curCmd := exec.CommandContext(r.Context(), "git", "branch", "--show-current") + curCmd.Dir = s.pwd + curOut, _ := curCmd.Output() + writeJSON(w, http.StatusOK, map[string]any{"branch": strings.TrimSpace(string(curOut))}) +} diff --git a/internal/web/remote.go b/internal/web/remote.go new file mode 100644 index 0000000..6c8dcd9 --- /dev/null +++ b/internal/web/remote.go @@ -0,0 +1,314 @@ +package web + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + pathpkg "path" + "strings" + "sync" + "time" + + "github.com/google/uuid" + + "github.com/cnjack/jcode/internal/config" + "github.com/cnjack/jcode/internal/remote" + "github.com/cnjack/jcode/internal/tools" +) + +// pendingConnTTL bounds how long an established-but-unbound SSH connection is +// kept alive while the user works through the wizard's directory picker. +const pendingConnTTL = 10 * time.Minute + +// pendingConn is an SSH connection created by the remote-connect wizard that has +// not yet been bound to the live env. +type pendingConn struct { + exec *tools.SSHExecutor + host string // host:port as dialed + user string + port int // originally requested port (for reconnect prefill) + createdAt time.Time +} + +// remoteConnRegistry holds pending SSH connections keyed by connection id. +type remoteConnRegistry struct { + mu sync.Mutex + conns map[string]*pendingConn +} + +func newRemoteConnRegistry() *remoteConnRegistry { + return &remoteConnRegistry{conns: make(map[string]*pendingConn)} +} + +func (rg *remoteConnRegistry) add(pc *pendingConn) string { + id := uuid.New().String() + rg.mu.Lock() + defer rg.mu.Unlock() + rg.sweepLocked() + rg.conns[id] = pc + return id +} + +func (rg *remoteConnRegistry) get(id string) *pendingConn { + rg.mu.Lock() + defer rg.mu.Unlock() + return rg.conns[id] +} + +// take removes a connection WITHOUT closing it: ownership transfers to the +// caller (e.g. the live env after a successful bind). +func (rg *remoteConnRegistry) take(id string) *pendingConn { + rg.mu.Lock() + defer rg.mu.Unlock() + pc := rg.conns[id] + delete(rg.conns, id) + return pc +} + +// drop removes and closes a pending connection (cancel / abandon). +func (rg *remoteConnRegistry) drop(id string) { + rg.mu.Lock() + pc := rg.conns[id] + delete(rg.conns, id) + rg.mu.Unlock() + if pc != nil && pc.exec != nil { + _ = pc.exec.Close() + } +} + +// sweepLocked closes and removes connections older than the TTL. Caller holds mu. +func (rg *remoteConnRegistry) sweepLocked() { + now := time.Now() + for id, pc := range rg.conns { + if now.Sub(pc.createdAt) > pendingConnTTL { + if pc.exec != nil { + _ = pc.exec.Close() + } + delete(rg.conns, id) + } + } +} + +// handleRemoteConnect establishes an SSH connection from the wizard's config +// step and parks it in the pending registry, returning a connection id the +// client uses to browse remote directories and ultimately bind. +func (s *Server) handleRemoteConnect(w http.ResponseWriter, r *http.Request) { + if s.needsSetup { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "setup required: please configure a provider first"}) + return + } + var req struct { + Type string `json:"type"` // "ssh" (docker reserved for later) + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` + AuthMethod string `json:"auth_method"` // "password" | "key" + Password string `json:"password"` + KeyPath string `json:"key_path"` + Passphrase string `json:"passphrase"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + if req.Type != "" && req.Type != "ssh" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "only ssh connections are supported"}) + return + } + if strings.TrimSpace(req.Host) == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "host is required"}) + return + } + + opts := remote.SSHOptions{Host: req.Host, Port: req.Port, User: req.User} + if req.AuthMethod == "password" { + opts.Password = req.Password + } else { + opts.KeyPath = req.KeyPath + opts.Passphrase = req.Passphrase + } + + exec, err := remote.Connect(opts) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + + remotePwd := remote.DiscoverPwd(r.Context(), exec, "/root") + id := s.remoteConns.add(&pendingConn{ + exec: exec, + host: exec.Host(), + user: exec.User(), + port: req.Port, + createdAt: time.Now(), + }) + + writeJSON(w, http.StatusOK, map[string]any{ + "connection_id": id, + "remote_pwd": remotePwd, + "platform": exec.Platform(), + "user": exec.User(), + "host": exec.Host(), + }) +} + +// handleRemoteListDir lists sub-directories of a path on a pending connection, +// driving the wizard's remote directory picker. +func (s *Server) handleRemoteListDir(w http.ResponseWriter, r *http.Request) { + var req struct { + ConnectionID string `json:"connection_id"` + Path string `json:"path"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + pc := s.remoteConns.get(req.ConnectionID) + if pc == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "connection expired or not found"}) + return + } + path := strings.TrimSpace(req.Path) + if path == "" { + path = remote.DiscoverPwd(r.Context(), pc.exec, "/root") + } + dirs, err := remote.ListDirs(r.Context(), pc.exec, path) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"path": path, "dirs": dirs}) +} + +// handleRemoteBind commits a pending connection: it binds the shared env to the +// remote executor at the chosen directory and rebuilds the agent (same path as +// a local project switch). +func (s *Server) handleRemoteBind(w http.ResponseWriter, r *http.Request) { + if s.running.Load() { + writeJSON(w, http.StatusConflict, map[string]string{"error": "agent is running, cannot switch workspace"}) + return + } + if s.switchToRemote == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "remote workspaces are not supported"}) + return + } + var req struct { + ConnectionID string `json:"connection_id"` + Path string `json:"path"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + pc := s.remoteConns.get(req.ConnectionID) + if pc == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "connection expired or not found"}) + return + } + remotePwd := strings.TrimSpace(req.Path) + if remotePwd == "" { + remotePwd = remote.DiscoverPwd(r.Context(), pc.exec, "/root") + } + + // Tear down local PTYs (they belonged to the previous workspace). + s.ptyMgr.closeAll() + + ag, rec, err := s.switchToRemote(pc.exec, remotePwd) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": fmt.Sprintf("failed to bind remote workspace: %v", err)}) + return + } + + label := remote.ProjectLabel(pc.exec, remotePwd) + + s.mu.Lock() + s.pwd = remotePwd + s.agent = ag + s.recorder = rec + s.history = nil + s.mu.Unlock() + + s.todoStore.Update(nil) + + // Ownership of the executor has transferred to the live env; remove the + // pending entry WITHOUT closing it. + s.remoteConns.take(req.ConnectionID) + + s.wsBroker.Broadcast(WSEvent{ + Type: "project_switched", + Data: map[string]string{"pwd": remotePwd, "label": label}, + }) + + writeJSON(w, http.StatusOK, map[string]any{ + "status": "ok", + "pwd": remotePwd, + "label": label, + "name": pathpkg.Base(remotePwd), + "host": pc.host, + "user": pc.user, + "port": pc.port, + "remote_path": remotePwd, + }) +} + +// handleRemoteCancel closes and discards a pending connection the user +// abandoned mid-wizard. +func (s *Server) handleRemoteCancel(w http.ResponseWriter, r *http.Request) { + var req struct { + ConnectionID string `json:"connection_id"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + s.remoteConns.drop(req.ConnectionID) + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// handleRemoteSaveAlias upserts a saved SSH alias (name/addr/path) into config so +// it appears in GET /api/ssh for one-click reconnects. Secrets are never stored. +func (s *Server) handleRemoteSaveAlias(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Addr string `json:"addr"` // user@host + Path string `json:"path"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Addr) == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name and addr are required"}) + return + } + + // Mutate + persist under the lock (the config save must be atomic with the + // in-memory edit), but release it before writing the HTTP response so a slow + // client cannot stall other handlers — mirrors handleCreateMCP/handleToggleSkill. + s.mu.Lock() + if s.cfg == nil { + s.mu.Unlock() + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "config unavailable"}) + return + } + updated := false + for i := range s.cfg.SSHAliases { + if s.cfg.SSHAliases[i].Name == req.Name { + s.cfg.SSHAliases[i].Addr = req.Addr + s.cfg.SSHAliases[i].Path = req.Path + updated = true + break + } + } + if !updated { + s.cfg.SSHAliases = append(s.cfg.SSHAliases, config.SSHAlias{Name: req.Name, Addr: req.Addr, Path: req.Path}) + } + err := config.SaveConfig(s.cfg) + s.mu.Unlock() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} diff --git a/internal/web/server.go b/internal/web/server.go index d9cf2de..82101df 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -10,6 +10,7 @@ import ( "io" "net" "net/http" + "net/url" "os" "os/exec" "path/filepath" @@ -80,6 +81,14 @@ type Server struct { // switchProject changes the working directory and rebuilds the agent. switchProject func(newPwd string) (*adk.ChatModelAgent, *session.Recorder, error) + // switchToRemote binds the shared env to a remote SSH executor and rebuilds + // the agent (mirrors switchProject for remote targets). + switchToRemote func(executor *tools.SSHExecutor, remotePwd string) (*adk.ChatModelAgent, *session.Recorder, error) + + // remoteConns holds SSH connections established by the remote-connect wizard + // that have not yet been bound to the live env (keyed by connection id). + remoteConns *remoteConnRegistry + // PTY manager for terminal sessions. ptyMgr *ptyManager @@ -134,6 +143,7 @@ type ServerConfig struct { RebuildForMode func(planMode bool) (*adk.ChatModelAgent, error) InitialMode string // unified startup mode string ("ask"/"plan"/"autopilot") SwitchProject func(newPwd string) (*adk.ChatModelAgent, *session.Recorder, error) + SwitchToRemote func(executor *tools.SSHExecutor, remotePwd string) (*adk.ChatModelAgent, *session.Recorder, error) TodoStore *tools.TodoStore Recorder *session.Recorder Tracer *telemetry.LangfuseTracer @@ -175,6 +185,8 @@ func NewServer(cfg *ServerConfig) *Server { createAgent: cfg.CreateAgent, rebuildForMode: cfg.RebuildForMode, switchProject: cfg.SwitchProject, + switchToRemote: cfg.SwitchToRemote, + remoteConns: newRemoteConnRegistry(), todoStore: cfg.TodoStore, recorder: cfg.Recorder, tracer: cfg.Tracer, @@ -210,7 +222,11 @@ func NewServer(cfg *ServerConfig) *Server { s.mu.RLock() r := s.recorder s.mu.RUnlock() - if r != nil { + // Only record into a session that already has real content (a user + // message created the file). Otherwise an ambient todo reset — e.g. + // clearing the previous session's todos when starting fresh — would + // be the first write, creating + indexing a phantom empty session. + if r != nil && r.HasRecording() { snapItems := make([]session.TodoSnapshotItem, len(items)) for i, it := range items { snapItems[i] = session.TodoSnapshotItem{ @@ -228,7 +244,13 @@ func NewServer(cfg *ServerConfig) *Server { s.mu.RLock() r := s.recorder s.mu.RUnlock() - tools.GoalRecorderHook(r)(g) + // Same guard as the todo hook: a goal change must not be the first + // write that creates + indexes an otherwise-empty session (e.g. + // clearing the previous session's goal on reset). Always emit to the + // UI, but only persist once the session has real content. + if r != nil && r.HasRecording() { + tools.GoalRecorderHook(r)(g) + } if s.handler != nil { s.handler.Emit("goal_update", g) } @@ -268,6 +290,11 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("GET /api/files", s.handleListFiles) mux.HandleFunc("GET /api/files/content", s.handleReadFile) mux.HandleFunc("GET /api/status", s.handleStatus) + mux.HandleFunc("GET /api/workspace", s.handleWorkspace) + mux.HandleFunc("GET /api/git/branches", s.handleGitBranches) + mux.HandleFunc("POST /api/git/checkout", s.handleGitCheckout) + mux.HandleFunc("GET /api/tasks", s.handleListAllTasks) + mux.HandleFunc("PATCH /api/tasks/{id}", s.handleUpdateTask) mux.HandleFunc("GET /api/models", s.handleListModels) mux.HandleFunc("POST /api/model", s.handleSwitchModel) mux.HandleFunc("POST /api/mode", s.handleSwitchMode) @@ -281,6 +308,11 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("POST /api/mcp/{name}/login", s.handleMCPLogin) mux.HandleFunc("GET /api/mcp/{name}/login/status", s.handleMCPLoginStatus) mux.HandleFunc("GET /api/ssh", s.handleListSSH) + mux.HandleFunc("POST /api/remote/connect", s.handleRemoteConnect) + mux.HandleFunc("POST /api/remote/list-dir", s.handleRemoteListDir) + mux.HandleFunc("POST /api/remote/bind", s.handleRemoteBind) + mux.HandleFunc("POST /api/remote/cancel", s.handleRemoteCancel) + mux.HandleFunc("POST /api/remote/save-alias", s.handleRemoteSaveAlias) mux.HandleFunc("GET /api/skills", s.handleListSkills) mux.HandleFunc("POST /api/skills/{name}/toggle", s.handleToggleSkill) mux.HandleFunc("GET /api/slash-commands", s.handleSlashCommands) @@ -441,6 +473,31 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { }) } +// handleWorkspace returns lightweight git workspace info (branch + dirty) for +// the current project so the web UI can show the real branch name. Diff stats +// are fetched separately via /api/diff. Empty branch = not a git repo. +func (s *Server) handleWorkspace(w http.ResponseWriter, r *http.Request) { + // Use the request context so the git commands are cancelled if the client + // disconnects (CodeRabbit review feedback on PR #82). + // `branch --show-current` (not `rev-parse --abbrev-ref HEAD`) so a freshly + // initialised repo with no commits still reports its unborn branch (e.g. + // "main") instead of the literal "HEAD". + branchCmd := exec.CommandContext(r.Context(), "git", "branch", "--show-current") + branchCmd.Dir = s.pwd + branchOut, _ := branchCmd.Output() + branch := strings.TrimSpace(string(branchOut)) + + statusCmd := exec.CommandContext(r.Context(), "git", "status", "--porcelain") + statusCmd.Dir = s.pwd + statusOut, _ := statusCmd.Output() + dirty := strings.TrimSpace(string(statusOut)) != "" + + writeJSON(w, http.StatusOK, map[string]any{ + "branch": branch, + "dirty": dirty, + }) +} + func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { if s.needsSetup { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "setup required: please configure a provider first"}) @@ -645,6 +702,87 @@ func (s *Server) submitMessage(message, mode, source, sessionID string, images [ return recorder.UUID() } +// handleListAllTasks returns every session across all projects (flat list, +// each tagged with its project path) so the web sidebar can render a +// Workspace > Project > Task tree without switching the active project. +func (s *Server) handleListAllTasks(w http.ResponseWriter, r *http.Request) { + all, err := session.ListAllSessions() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + type taskItem struct { + UUID string `json:"uuid"` + Project string `json:"project"` + CreatedAt string `json:"created_at"` + Provider string `json:"provider"` + Model string `json:"model"` + Title string `json:"title,omitempty"` + Pinned bool `json:"pinned"` + Archived bool `json:"archived"` + Unread bool `json:"unread"` + Status string `json:"status,omitempty"` + } + items := make([]taskItem, 0) + for project, metas := range all { + for _, m := range metas { + items = append(items, taskItem{ + UUID: m.UUID, + Project: project, + CreatedAt: m.StartTime, + Provider: m.Provider, + Model: m.Model, + Title: m.Title, + Pinned: m.Pinned, + Archived: m.Archived, + Unread: m.Unread, + Status: m.Status, + }) + } + } + writeJSON(w, http.StatusOK, items) +} + +// handleUpdateTask applies a partial metadata update (pin/archive/unread/title) +// to a task by uuid across all projects. +func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + var req struct { + Pinned *bool `json:"pinned"` + Archived *bool `json:"archived"` + Unread *bool `json:"unread"` + Title *string `json:"title"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + meta, err := session.UpdateSessionMeta(id, func(m *session.SessionMeta) { + if req.Pinned != nil { + m.Pinned = *req.Pinned + } + if req.Archived != nil { + m.Archived = *req.Archived + } + if req.Unread != nil { + m.Unread = *req.Unread + } + if req.Title != nil { + m.Title = *req.Title + } + m.UpdatedAt = time.Now().Format(time.RFC3339) + }) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if meta == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"}) + return + } + writeJSON(w, http.StatusOK, meta) +} + func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) { metas, err := session.ListSessions(s.pwd) if err != nil { @@ -690,7 +828,9 @@ func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "session id is required"}) return } - if err := session.DeleteSession(s.pwd, id); err != nil { + // Resolve the owning project across all projects: a task deleted from the + // sidebar tree may not belong to the active project (s.pwd). + if _, err := session.DeleteSessionByUUID(id); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } @@ -2094,8 +2234,11 @@ func (s *Server) handleSetApprovalMode(w http.ResponseWriter, r *http.Request) { // --- WebSocket handler --- +// CheckOrigin rejects cross-origin WebSocket handshakes from untrusted web +// pages (see isAllowedWebOrigin); without this any website could open a socket +// to the loopback server and read the agent's live event stream. var wsUpgrader = websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { return true }, + CheckOrigin: isAllowedWebOrigin, } func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { @@ -2776,11 +2919,50 @@ func writeJSON(w http.ResponseWriter, status int, data any) { _ = json.NewEncoder(w).Encode(data) } +// isAllowedWebOrigin decides whether a browser request's Origin is trusted. +// +// The server (especially the always-on desktop sidecar) exposes agent control — +// i.e. shell/file tools — over loopback with no auth token, so an unconditional +// `Access-Control-Allow-Origin: *` plus a WebSocket CheckOrigin that returns +// true would let any website the user visits drive the agent or read its live +// event stream via ws://127.0.0.1:<port>. We gate on Origin instead: +// +// - empty Origin (curl, native client, same-origin navigations) → allow +// - Origin equal to the request's own Host (same-origin) → allow; this covers +// local-browser, the desktop webview, and LAN access via `--host 0.0.0.0` +// - Origin whose host is loopback → allow; this covers the Vite dev proxy +// (localhost:5173 → 127.0.0.1:<port>) +// +// A page on https://evil.com cannot forge its Origin, so it falls through to +// false and is blocked. This is intentionally not a full auth solution (a local +// process can still reach the port); it closes the cross-origin *website* vector. +func isAllowedWebOrigin(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return true + } + u, err := url.Parse(origin) + if err != nil || u.Host == "" { + return false + } + if u.Host == r.Host { + return true + } + switch u.Hostname() { + case "127.0.0.1", "localhost", "::1": + return true + } + return false +} + func corsMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") - if origin != "" { + // Only reflect CORS headers for trusted origins; a disallowed cross-origin + // request gets none, so the browser blocks the response (and its preflight). + if origin != "" && isAllowedWebOrigin(r) { w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") w.Header().Set("Access-Control-Allow-Credentials", "true") diff --git a/internal/web/tasks_test.go b/internal/web/tasks_test.go new file mode 100644 index 0000000..6d2a19a --- /dev/null +++ b/internal/web/tasks_test.go @@ -0,0 +1,185 @@ +package web + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/cnjack/jcode/internal/session" +) + +// seedIndex points HOME at a temp dir and writes a sessions index with the +// given project→metas map, so the cross-project task handlers can be tested +// in-process without touching the real ~/.jcode. +func seedIndex(t *testing.T, sessions map[string][]session.SessionMeta) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + dir := filepath.Join(home, ".jcode", "sessions") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(map[string]any{"sessions": sessions}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "session.json"), data, 0o644); err != nil { + t.Fatal(err) + } +} + +// P0-1: GET /api/workspace on a non-git directory returns empty branch + not dirty. +func TestWorkspaceNonGit(t *testing.T) { + s := &Server{ctx: context.Background(), pwd: t.TempDir()} + rec := httptest.NewRecorder() + s.handleWorkspace(rec, httptest.NewRequest(http.MethodGet, "/api/workspace", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("code=%d body=%q", rec.Code, rec.Body.String()) + } + var ws struct { + Branch string `json:"branch"` + Dirty bool `json:"dirty"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &ws); err != nil { + t.Fatalf("not JSON: %v", err) + } + if ws.Branch != "" || ws.Dirty { + t.Fatalf("non-git dir: want empty branch + not dirty, got %+v", ws) + } +} + +// P0-2: GET /api/tasks with no index returns an empty array (not null). +func TestListAllTasksEmpty(t *testing.T) { + seedIndex(t, map[string][]session.SessionMeta{}) + s := &Server{} + rec := httptest.NewRecorder() + s.handleListAllTasks(rec, httptest.NewRequest(http.MethodGet, "/api/tasks", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("code=%d", rec.Code) + } + if got := strings.TrimSpace(rec.Body.String()); got != "[]" { + t.Fatalf("want [], got %q", got) + } +} + +// P0-2: GET /api/tasks returns sessions across ALL projects, each tagged with +// its project path. +func TestListAllTasksMultiProject(t *testing.T) { + seedIndex(t, map[string][]session.SessionMeta{ + "/work/tpm": {{UUID: "u-a", Project: "/work/tpm", Title: "task A", Model: "glm-5.2", StartTime: "2026-06-16T10:00:00Z"}}, + "/work/jcode": {{UUID: "u-b", Project: "/work/jcode", Title: "task B"}, {UUID: "u-c", Project: "/work/jcode", Pinned: true}}, + }) + s := &Server{} + rec := httptest.NewRecorder() + s.handleListAllTasks(rec, httptest.NewRequest(http.MethodGet, "/api/tasks", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("code=%d", rec.Code) + } + var items []struct { + UUID string `json:"uuid"` + Project string `json:"project"` + Title string `json:"title"` + Pinned bool `json:"pinned"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { + t.Fatalf("not JSON: %v", err) + } + if len(items) != 3 { + t.Fatalf("want 3 tasks across projects, got %d: %+v", len(items), items) + } + byID := map[string]struct { + project string + pinned bool + }{} + for _, it := range items { + byID[it.UUID] = struct { + project string + pinned bool + }{it.Project, it.Pinned} + } + if byID["u-a"].project != "/work/tpm" { + t.Fatalf("u-a project = %q", byID["u-a"].project) + } + if byID["u-c"].project != "/work/jcode" || !byID["u-c"].pinned { + t.Fatalf("u-c should be pinned in /work/jcode, got %+v", byID["u-c"]) + } +} + +// P0-3: PATCH /api/tasks/{id} updates pin/archive/unread/title and persists. +func TestUpdateTaskMeta(t *testing.T) { + seedIndex(t, map[string][]session.SessionMeta{ + "/work/tpm": {{UUID: "u-a", Project: "/work/tpm", Title: "orig"}}, + }) + s := &Server{} + + patch := func(body string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPatch, "/api/tasks/u-a", strings.NewReader(body)) + req.SetPathValue("id", "u-a") + s.handleUpdateTask(rec, req) + return rec + } + + // pin + if rec := patch(`{"pinned":true}`); rec.Code != http.StatusOK { + t.Fatalf("pin: code=%d body=%q", rec.Code, rec.Body.String()) + } + // archive + unread + rename + if rec := patch(`{"archived":true,"unread":true,"title":"renamed"}`); rec.Code != http.StatusOK { + t.Fatalf("multi: code=%d", rec.Code) + } + + // Verify persisted via a fresh list. + all, err := session.ListAllSessions() + if err != nil { + t.Fatal(err) + } + m := all["/work/tpm"][0] + if !m.Pinned || !m.Archived || !m.Unread || m.Title != "renamed" { + t.Fatalf("metadata not persisted: %+v", m) + } + + // Unknown id -> 404. + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPatch, "/api/tasks/missing", strings.NewReader(`{"pinned":true}`)) + req.SetPathValue("id", "missing") + s.handleUpdateTask(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("unknown id should be 404, got %d", rec.Code) + } +} + +// Regression: deleting a task that belongs to a project OTHER than the active +// one (s.pwd) must still remove it from the index — the sidebar tree can delete +// across projects. Previously this silently no-op'd, leaving a ghost task. +func TestDeleteTaskCrossProject(t *testing.T) { + seedIndex(t, map[string][]session.SessionMeta{ + "/work/active": {{UUID: "act-1", Project: "/work/active"}}, + "/work/other": {{UUID: "oth-1", Project: "/work/other"}, {UUID: "oth-2", Project: "/work/other"}}, + }) + // Active project is /work/active; delete a task in /work/other. + s := &Server{pwd: "/work/active"} + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodDelete, "/api/sessions/oth-1", nil) + req.SetPathValue("id", "oth-1") + s.handleDeleteSession(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("delete: code=%d body=%q", rec.Code, rec.Body.String()) + } + + all, err := session.ListAllSessions() + if err != nil { + t.Fatal(err) + } + if len(all["/work/other"]) != 1 || all["/work/other"][0].UUID != "oth-2" { + t.Fatalf("oth-1 should be deleted and oth-2 kept, got %+v", all["/work/other"]) + } + if len(all["/work/active"]) != 1 { + t.Fatalf("active project tasks should be untouched, got %+v", all["/work/active"]) + } +} diff --git a/web/package.json b/web/package.json index fa240be..fcd1e75 100644 --- a/web/package.json +++ b/web/package.json @@ -19,6 +19,10 @@ "@headlessui/vue": "^1.7.23", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.2.2", + "@tauri-apps/api": "^2.9.0", + "@tauri-apps/plugin-dialog": "^2.4.0", + "@tauri-apps/plugin-notification": "^2.3.1", + "@tauri-apps/plugin-opener": "^2.5.0", "@types/dompurify": "^3.2.0", "@types/qrcode": "^1.5.6", "@xterm/addon-fit": "^0.11.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index ee67f2c..18abec7 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -20,6 +20,18 @@ importers: '@tailwindcss/vite': specifier: ^4.2.2 version: 4.2.2(vite@8.0.8(@types/node@24.12.2)(jiti@2.6.1)(yaml@2.8.3)) + '@tauri-apps/api': + specifier: ^2.9.0 + version: 2.11.1 + '@tauri-apps/plugin-dialog': + specifier: ^2.4.0 + version: 2.7.1 + '@tauri-apps/plugin-notification': + specifier: ^2.3.1 + version: 2.3.3 + '@tauri-apps/plugin-opener': + specifier: ^2.5.0 + version: 2.5.4 '@types/dompurify': specifier: ^3.2.0 version: 3.2.0 @@ -420,48 +432,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-arm64-musl@0.42.0': resolution: {integrity: sha512-+JA0YMlSdDqmacygGi2REp57c3fN+tzARD8nwsukx9pkCHK+6DkbAA9ojS4lNKsiBjIW8WWa0pBrBWhdZEqfuw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxfmt/binding-linux-ppc64-gnu@0.42.0': resolution: {integrity: sha512-VfnET0j4Y5mdfCzh5gBt0NK28lgn5DKx+8WgSMLYYeSooHhohdbzwAStLki9pNuGy51y4I7IoW8bqwAaCMiJQg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-riscv64-gnu@0.42.0': resolution: {integrity: sha512-gVlCbmBkB0fxBWbhBj9rcxezPydsQHf4MFKeHoTSPicOQ+8oGeTQgQ8EeesSybWeiFPVRx3bgdt4IJnH6nOjAA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-riscv64-musl@0.42.0': resolution: {integrity: sha512-zN5OfstL0avgt/IgvRu0zjQzVh/EPkcLzs33E9LMAzpqlLWiPWeMDZyMGFlSRGOdDjuNmlZBCgj0pFnK5u32TQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxfmt/binding-linux-s390x-gnu@0.42.0': resolution: {integrity: sha512-9X6+H2L0qMc2sCAgO9HS03bkGLMKvOFjmEdchaFlany3vNZOjnVui//D8k/xZAtQv2vaCs1reD5KAgPoIU4msA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-x64-gnu@0.42.0': resolution: {integrity: sha512-BajxJ6KQvMMdpXGPWhBGyjb2Jvx4uec0w+wi6TJZ6Tv7+MzPwe0pO8g5h1U0jyFgoaF7mDl6yKPW3ykWcbUJRw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-x64-musl@0.42.0': resolution: {integrity: sha512-0wV284I6vc5f0AqAhgAbHU2935B4bVpncPoe5n/WzVZY/KnHgqxC8iSFGeSyLWEgstFboIcWkOPck7tqbdHkzA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxfmt/binding-openharmony-arm64@0.42.0': resolution: {integrity: sha512-p4BG6HpGnhfgHk1rzZfyR6zcWkE7iLrWxyehHfXUy4Qa5j3e0roglFOdP/Nj5cJJ58MA3isQ5dlfkW2nNEpolw==} @@ -534,48 +554,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-arm64-musl@1.57.0': resolution: {integrity: sha512-i66WyEPVEvq9bxRUCJ/MP5EBfnTDN3nhwEdFZFTO5MmLLvzngfWEG3NSdXQzTT3vk5B9i6C2XSIYBh+aG6uqyg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxlint/binding-linux-ppc64-gnu@1.57.0': resolution: {integrity: sha512-oMZDCwz4NobclZU3pH+V1/upVlJZiZvne4jQP+zhJwt+lmio4XXr4qG47CehvrW1Lx2YZiIHuxM2D4YpkG3KVA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-gnu@1.57.0': resolution: {integrity: sha512-uoBnjJ3MMEBbfnWC1jSFr7/nSCkcQYa72NYoNtLl1imshDnWSolYCjzb8LVCwYCCfLJXD+0gBLD7fyC14c0+0g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-musl@1.57.0': resolution: {integrity: sha512-BdrwD7haPZ8a9KrZhKJRSj6jwCor+Z8tHFZ3PT89Y3Jq5v3LfMfEePeAmD0LOTWpiTmzSzdmyw9ijneapiVHKQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxlint/binding-linux-s390x-gnu@1.57.0': resolution: {integrity: sha512-BNs+7ZNsRstVg2tpNxAXfMX/Iv5oZh204dVyb8Z37+/gCh+yZqNTlg6YwCLIMPSk5wLWIGOaQjT0GUOahKYImw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-gnu@1.57.0': resolution: {integrity: sha512-AghS18w+XcENcAX0+BQGLiqjpqpaxKJa4cWWP0OWNLacs27vHBxu7TYkv9LUSGe5w8lOJHeMxcYfZNOAPqw2bg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-musl@1.57.0': resolution: {integrity: sha512-E/FV3GB8phu/Rpkhz5T96hAiJlGzn91qX5yj5gU754P5cmVGXY1Jw/VSjDSlZBCY3VHjsVLdzgdkJaomEmcNOg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxlint/binding-openharmony-arm64@1.57.0': resolution: {integrity: sha512-xvZ2yZt0nUVfU14iuGv3V25jpr9pov5N0Wr28RXnHFxHCRxNDMtYPHV61gGLhN9IlXM96gI4pyYpLSJC5ClLCQ==} @@ -639,36 +667,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15': resolution: {integrity: sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15': resolution: {integrity: sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15': resolution: {integrity: sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15': resolution: {integrity: sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.15': resolution: {integrity: sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.15': resolution: {integrity: sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==} @@ -737,24 +771,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.2': resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.2': resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.2': resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.2': resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} @@ -802,6 +840,18 @@ packages: peerDependencies: vue: ^2.7.0 || ^3.0.0 + '@tauri-apps/api@2.11.1': + resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==} + + '@tauri-apps/plugin-dialog@2.7.1': + resolution: {integrity: sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==} + + '@tauri-apps/plugin-notification@2.3.3': + resolution: {integrity: sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==} + + '@tauri-apps/plugin-opener@2.5.4': + resolution: {integrity: sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==} + '@tsconfig/node24@24.0.4': resolution: {integrity: sha512-2A933l5P5oCbv6qSxHs7ckKwobs8BDAe9SJ/Xr2Hy+nDlwmLE1GhFh/g/vXGRZWgxBg9nX/5piDtHR9Dkw/XuA==} @@ -1513,24 +1563,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -1565,6 +1619,7 @@ packages: lucide-vue-next@1.0.0: resolution: {integrity: sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==} + deprecated: Package deprecated. Please use @lucide/vue instead. peerDependencies: vue: '>=3.0.1' @@ -2649,6 +2704,20 @@ snapshots: '@tanstack/virtual-core': 3.13.23 vue: 3.5.32(typescript@6.0.2) + '@tauri-apps/api@2.11.1': {} + + '@tauri-apps/plugin-dialog@2.7.1': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-notification@2.3.3': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-opener@2.5.4': + dependencies: + '@tauri-apps/api': 2.11.1 + '@tsconfig/node24@24.0.4': {} '@tybys/wasm-util@0.10.1': diff --git a/web/src/App.vue b/web/src/App.vue index fabf900..9f0a8fe 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -1,10 +1,12 @@ <script setup lang="ts"> -import { ref, onMounted, nextTick, watch, onUnmounted } from 'vue' +import { ref, onMounted, nextTick, watch, onUnmounted, provide } from 'vue' import { normalizeMode } from '@/types/api' +import type { RemoteMeta } from '@/types/api' import { useChatStore } from '@/stores/chat' import { useProjectStore } from '@/stores/project' import { useWebSocket } from '@/composables/ws' import { useTheme } from '@/composables/useTheme' +import { useBranch } from '@/composables/useBranch' import ChatMessageVue from '@/components/ChatMessage.vue' import ToolCallCard from '@/components/ToolCallCard.vue' import ApprovalBanner from '@/components/ApprovalBanner.vue' @@ -13,20 +15,59 @@ import ChatInput from '@/components/ChatInput.vue' import Sidebar from '@/components/Sidebar.vue' import SettingsDialog from '@/components/SettingsDialog.vue' import ProjectSwitcher from '@/components/ProjectSwitcher.vue' +import RemoteConnectWizard from '@/components/RemoteConnectWizard.vue' import TerminalPanel from '@/components/TerminalPanel.vue' import RightPanel from '@/components/RightPanel.vue' import SetupView from '@/components/SetupView.vue' import TopBar from '@/components/TopBar.vue' +import CommandPalette from '@/components/CommandPalette.vue' +import { useNotifications } from '@/composables/notifications' const store = useChatStore() const projectStore = useProjectStore() const { resolvedTheme, toggleTheme } = useTheme() +const { refresh: refreshBranch } = useBranch() +const { ensurePermission, notify } = useNotifications() const messagesEl = ref<HTMLDivElement | null>(null) const settingsOpen = ref(false) const projectsOpen = ref(false) -const sidebarCollapsed = ref(false) +const paletteOpen = ref(false) + +// Remote-connect (SSH) wizard. `openRemoteConnect` is provided to descendants +// (WorkspacePicker, ProjectSwitcher, Sidebar) so any of them can launch or +// prefill it for a reconnect. +const remoteWizardOpen = ref(false) +const remotePrefill = ref<(RemoteMeta & { loadTaskUuid?: string }) | null>(null) +function openRemoteConnect(prefill?: RemoteMeta & { loadTaskUuid?: string }) { + remotePrefill.value = prefill ?? null + remoteWizardOpen.value = true +} +provide('openRemoteConnect', openRemoteConnect) + +// When the wizard is launched from Settings it stacks ON TOP of the Settings +// overlay. headlessui treats a click inside the wizard as an "outside" click for +// Settings and would dismiss it (dropping the user back to the workspace), so we +// ignore Settings' close requests while the wizard is open. Settings then closes +// only via its own Back button, or programmatically when a remote bind succeeds. +function onSettingsClose() { + if (remoteWizardOpen.value) return + settingsOpen.value = false +} const needsSetup = ref(false) +// Honor reduced-motion for the new-task ↔ conversation composer transition. +const reduceMotion = ref( + typeof window !== 'undefined' && window.matchMedia + ? window.matchMedia('(prefers-reduced-motion: reduce)').matches + : false, +) + +function onPaletteAction(name: 'settings' | 'projects' | 'theme') { + if (name === 'settings') settingsOpen.value = true + else if (name === 'projects') projectsOpen.value = true + else if (name === 'theme') toggleTheme() +} + const bottomPanel = ref<'none' | 'terminal'>('none') const bottomPanelHeight = ref(260) const isResizingPanel = ref(false) @@ -60,10 +101,16 @@ const { connected } = useWebSocket({ onToolCall: (data) => store.addToolCall(data.name, data.args, data.tool_call_id, data.display_info), onToolResult: (data) => store.resolveToolCall(data.name, data.output, data.tool_call_id, data.error, data.display_output), onTokenUpdate: (data) => { store.tokenInfo = data }, - onAgentDone: (data) => store.agentDone(data?.error), + onAgentDone: (data) => { + store.agentDone(data?.error) + notify(data?.error ? 'jcode — task failed' : 'jcode — task finished', data?.error || 'The agent finished its run.') + }, onTodoUpdate: () => store.fetchTodos(), onGoalUpdate: (data) => { store.goal = data }, - onApprovalRequest: (data) => store.addApprovalRequest(data), + onApprovalRequest: (data) => { + store.addApprovalRequest(data) + notify('jcode — approval needed', 'The agent is waiting for your approval.') + }, onAskUserRequest: (data) => store.attachAskUserRequest(data.id, data.questions), onSessionReset: () => store.clearChat(), onModelChanged: (data) => { @@ -102,6 +149,11 @@ watch( // Global keyboard shortcuts function handleGlobalKeydown(e: KeyboardEvent) { + if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) { + e.preventDefault() + paletteOpen.value = !paletteOpen.value + return + } if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'N') { e.preventDefault() store.newSession() @@ -137,15 +189,11 @@ function handleGlobalKeydown(e: KeyboardEvent) { togglePanel('plan') return } - if ((e.ctrlKey || e.metaKey) && e.key === 'b') { - e.preventDefault() - sidebarCollapsed.value = !sidebarCollapsed.value - return - } } onMounted(async () => { document.addEventListener('keydown', handleGlobalKeydown) + ensurePermission() const health = await store.fetchHealth() // Check if setup is needed — health returns needs_setup status if (health?.needs_setup) { @@ -155,6 +203,7 @@ onMounted(async () => { store.fetchConfig() store.fetchTodos() store.fetchGoal() + refreshBranch() store.fetchModels() store.fetchModelState() store.fetchSessions() @@ -199,6 +248,13 @@ function togglePanel(panel: 'terminal' | 'files' | 'changes' | 'plan') { const planAutoOpened = ref(false) // Elapsed-time counter for the "Thinking…" footer; ticks once per second while // the agent runs, resets on each new run. Appears in the UI only after 2s. +// Re-read the git branch whenever the active workspace changes (every switch +// path — the composer's WorkspacePicker, the projects modal, opening a task in +// another project, a remote connect — funnels through fetchHealth, which updates +// store.pwd). Without this, switching from a non-git workspace to a git one left +// the branch picker blank. +watch(() => store.pwd, (pwd) => { if (pwd) refreshBranch() }) + const elapsed = ref(0) let runTimer: ReturnType<typeof setInterval> | null = null watch(() => store.isRunning, (running) => { @@ -225,6 +281,7 @@ async function onProjectSwitched() { store.clearChat() store.fetchTodos() store.fetchGoal() + refreshBranch() store.fetchSessions() // Restore the current session for the new project await store.restoreCurrentSession() @@ -236,6 +293,7 @@ function onSetupComplete() { store.fetchConfig() store.fetchTodos() store.fetchGoal() + refreshBranch() store.fetchModels() store.fetchModelState() store.fetchSessions() @@ -267,130 +325,175 @@ function startResize(e: MouseEvent) { </script> <template> - <div class="flex h-[100dvh] overflow-hidden transition-colors duration-300" style="background: var(--color-background); color: var(--color-foreground);"> - <!-- Sidebar --> - <transition - enter-active-class="transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]" - enter-from-class="-translate-x-full opacity-0" - enter-to-class="translate-x-0 opacity-100" - leave-active-class="transition-all duration-200 ease-[cubic-bezier(0.7,0,0.84,0)]" - leave-from-class="translate-x-0 opacity-100" - leave-to-class="-translate-x-full opacity-0" - > - <Sidebar - v-show="!sidebarCollapsed" - @open-file="openFile" - @open-settings="settingsOpen = true" - @open-projects="projectsOpen = true" - @toggle-theme="toggleTheme" - :resolved-theme="resolvedTheme" - /> - </transition> + <!-- One continuous surface: sidebar, top bar and chat area share a single + background with no hard dividers, so the regions read as one enclosed + space (包裹感) rather than separately-bordered panels. --> + <div class="app-shell relative flex h-[100dvh] overflow-hidden transition-colors duration-300" style="background: var(--color-background); color: var(--color-foreground);"> + <!-- Native title-bar drag strip. Only rendered (via CSS) inside the macOS + desktop shell, where the window uses an overlay title bar: it reserves + the band the traffic-light buttons float over and lets the user drag + the window. A no-op empty element in the browser. --> + <div class="titlebar-drag" data-tauri-drag-region aria-hidden="true" /> + + <!-- The single top-right control (panel menu + connection dot), floated into + the title-bar zone — there is no separate top bar anymore. --> + <TopBar + :is-running="store.isRunning" + :ws-connected="store.wsConnected" + :active-panel="rightPanelOpen ? rightPanelTab : 'none'" + :terminal-open="bottomPanel === 'terminal'" + @toggle-panel="togglePanel" + /> + + <!-- Sidebar — always visible (no collapse toggle on the desktop shell). --> + <Sidebar + @open-file="openFile" + @open-settings="settingsOpen = true" + @open-projects="projectsOpen = true" + @toggle-theme="toggleTheme" + :resolved-theme="resolvedTheme" + /> - <!-- Main content --> + <!-- Main content — shell tone (same as sidebar); the conversation lives in + an inset surface panel below, so it reads as one continuous shell that + wraps a distinct chat canvas (包裹感). --> <main class="flex-1 flex flex-col min-w-0 relative"> - <!-- Top Bar --> - <TopBar - :project-name="store.projectName || 'jcode'" - :pwd="store.pwd || ''" - :is-running="store.isRunning" - :ws-connected="store.wsConnected" - :active-panel="rightPanelOpen ? rightPanelTab : 'none'" - :terminal-open="bottomPanel === 'terminal'" - @toggle-sidebar="sidebarCollapsed = !sidebarCollapsed" - @toggle-panel="togglePanel" - /> + <!-- Chat area — inset surface panel: distinct tone, rounded, wrapped with + breathing room so it reads as a distinct chat canvas (包裹感). --> - <!-- Chat area --> - <div class="flex-1 flex flex-col min-h-0"> + <div class="chat-panel flex-1 flex flex-col min-h-0 relative"> + <!-- Smoothly hand off between the centered new-task composer and the + docked conversation composer: the welcome slides down + fades out and + the conversation rises + fades in, so the composer reads as settling + at the bottom. No `appear`: an `appear` + `mode="out-in"` initial + enter could stick at opacity-0 and leave the whole new-task screen + invisible. The welcome now renders at full opacity on first load; + the welcome↔conversation crossfade still plays on message send. --> + <transition + :css="!reduceMotion" + enter-active-class="transition-all duration-300 ease-out" + enter-from-class="opacity-0 translate-y-3" + enter-to-class="opacity-100 translate-y-0" + leave-active-class="transition-all duration-200 ease-in" + leave-from-class="opacity-100 translate-y-0" + leave-to-class="opacity-0 translate-y-4" + mode="out-in" + > + <!-- New-task welcome: the composer lives in the CENTER of the canvas + (with its workspace chip on top) until the first message is sent. --> <div - ref="messagesEl" - class="flex-1 overflow-y-auto scroll-smooth" - @scroll="checkScrollPosition" + v-if="!store.hasMessages" + key="welcome" + class="welcome flex-1 flex flex-col items-center px-6 overflow-y-auto" > - <!-- Welcome --> - <div v-if="!store.hasMessages" class="flex flex-col items-center justify-center h-full text-center px-8 animate-fade-in"> - <div class="flex items-center gap-0 mb-5 select-none" style="font-family: var(--font-mono); font-size: 28px; font-weight: 700; letter-spacing: normal;"> - <span style="color: var(--color-muted-foreground)">[</span><span style="color: var(--color-primary);">J</span><span style="color: var(--color-foreground)">CODE</span><span style="color: var(--color-muted-foreground)">]</span> + <!-- Soft brand-tinted aura gives the empty canvas a focal point. --> + <div class="welcome-aura" aria-hidden="true" /> + + <!-- Top half: the hero floats just above the centered composer. --> + <div class="welcome-hero flex-1 min-h-0 flex flex-col items-center justify-end pb-10"> + <div class="welcome-logo select-none"> + <span class="wl-dim">[</span><span class="wl-j">J</span><span class="wl-fg">CODE</span><span class="wl-dim">]</span> </div> - <h2 class="text-lg font-semibold mb-1.5" style="font-family: var(--font-sans); color: var(--color-foreground)">What would you like to build?</h2> - <p class="text-sm max-w-sm" style="color: var(--color-muted-foreground)">Send a message to start a conversation with jcode. Use <kbd class="px-1.5 py-0.5 text-[10px] font-mono rounded border" style="background: var(--color-muted); border-color: var(--color-border)">/</kbd> for commands.</p> + <h2 class="welcome-title"> + Start a new task in <span class="welcome-project">{{ store.projectName || 'jcode' }}</span> + </h2> + <p class="welcome-sub"> + Pick a workspace, then send a message. Use <kbd class="welcome-kbd">/</kbd> for commands. + </p> </div> - <!-- Timeline --> - <div v-else class="max-w-4xl mx-auto px-5 py-6 space-y-0.5"> - <template v-for="item in store.timeline" :key="item.seq"> - <ChatMessageVue - v-if="item.kind === 'message'" - :message="item.data" - :can-retry="item.data.role === 'assistant' && !store.isRunning" - :can-edit="item.data.role === 'user' && !store.isRunning" - class="animate-fade-up" - @retry="store.retryFromMessage(item.data.id)" - @edit="(text) => store.editAndResend(item.data.id, text)" - /> - <ToolCallCard v-else-if="item.kind === 'tool'" :tool="item.data" class="animate-fade-up pl-9" /> - <ApprovalBanner v-else-if="item.kind === 'approval'" :approval="item.data" class="animate-fade-up" /> - </template> - - <!-- Thinking footer: single source of truth for "agent is working". - Trails the last timeline item, rides existing auto-scroll, and - stays visible the whole run (initial wait AND while content - accumulates) — not only when the timeline is empty. --> - <transition - enter-active-class="transition-opacity duration-300 ease-out" - enter-from-class="opacity-0" - enter-to-class="opacity-100" - leave-active-class="transition-opacity duration-150 ease-in" - leave-from-class="opacity-100" - leave-to-class="opacity-0" - > - <div - v-if="store.isRunning" - class="flex items-center gap-2.5 py-3 pl-9 select-none" - role="status" - aria-live="polite" - aria-label="Thinking" - > - <span class="flex gap-1" aria-hidden="true"> - <span class="w-1.5 h-1.5 rounded-full animate-dot-pulse" style="background: var(--color-primary); animation-delay: 0ms" /> - <span class="w-1.5 h-1.5 rounded-full animate-dot-pulse" style="background: var(--color-primary); animation-delay: 160ms" /> - <span class="w-1.5 h-1.5 rounded-full animate-dot-pulse" style="background: var(--color-primary); animation-delay: 320ms" /> - </span> - <span class="thinking-label text-[13px]" style="font-family: var(--font-sans)">Thinking…</span> - <span - v-if="elapsed >= 2" - class="text-xs tabular-nums" - style="font-family: var(--font-mono); color: var(--color-muted-foreground); opacity: 0.6" - >{{ elapsed }}s</span> - </div> - </transition> + <!-- Composer sits on the vertical centerline. Its pickers open + downward into the empty lower half (room above is tighter). --> + <div class="welcome-composer w-full max-w-2xl"> + <ChatInput picker-placement="bottom" /> </div> + + <!-- Bottom half balances the center. --> + <div class="flex-1 min-h-0" aria-hidden="true" /> </div> - <!-- Scroll-to-bottom button --> - <transition - enter-active-class="transition-all duration-200 ease-out" - enter-from-class="opacity-0 translate-y-2" - enter-to-class="opacity-100 translate-y-0" - leave-active-class="transition-all duration-150 ease-in" - leave-from-class="opacity-100 translate-y-0" - leave-to-class="opacity-0 translate-y-2" - > - <button - v-if="showScrollBtn" - class="absolute bottom-40 left-1/2 -translate-x-1/2 z-10 w-8 h-8 flex items-center justify-center rounded-full shadow-lg cursor-pointer transition-colors" - style="background: var(--color-surface); border: 1px solid var(--color-border); color: var(--color-muted-foreground)" - @click="scrollToBottom()" + <!-- Active conversation: scrollable timeline + bottom composer. --> + <div v-else key="convo" class="flex-1 flex flex-col min-h-0"> + <div + ref="messagesEl" + class="flex-1 overflow-y-auto scroll-smooth rounded-t-[13px]" + @scroll="checkScrollPosition" > - <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> - <path d="M12 5v14M5 12l7 7 7-7" /> - </svg> - </button> - </transition> + <div class="max-w-4xl mx-auto px-5 py-6 space-y-0.5"> + <template v-for="item in store.timeline" :key="item.seq"> + <ChatMessageVue + v-if="item.kind === 'message'" + :message="item.data" + :can-retry="item.data.role === 'assistant' && !store.isRunning" + :can-edit="item.data.role === 'user' && !store.isRunning" + class="animate-fade-up" + @retry="store.retryFromMessage(item.data.id)" + @edit="(text) => store.editAndResend(item.data.id, text)" + /> + <ToolCallCard v-else-if="item.kind === 'tool'" :tool="item.data" class="animate-fade-up pl-9" /> + <ApprovalBanner v-else-if="item.kind === 'approval'" :approval="item.data" class="animate-fade-up" /> + </template> - <GoalBanner /> - <ChatInput /> + <!-- Thinking footer: single source of truth for "agent is working". + Trails the last timeline item, rides existing auto-scroll, and + stays visible the whole run (initial wait AND while content + accumulates) — not only when the timeline is empty. --> + <transition + enter-active-class="transition-opacity duration-300 ease-out" + enter-from-class="opacity-0" + enter-to-class="opacity-100" + leave-active-class="transition-opacity duration-150 ease-in" + leave-from-class="opacity-100" + leave-to-class="opacity-0" + > + <div + v-if="store.isRunning" + class="flex items-center gap-2.5 py-3 pl-9 select-none" + role="status" + aria-live="polite" + aria-label="Thinking" + > + <span class="flex gap-1" aria-hidden="true"> + <span class="w-1.5 h-1.5 rounded-full animate-dot-pulse" style="background: var(--color-primary); animation-delay: 0ms" /> + <span class="w-1.5 h-1.5 rounded-full animate-dot-pulse" style="background: var(--color-primary); animation-delay: 160ms" /> + <span class="w-1.5 h-1.5 rounded-full animate-dot-pulse" style="background: var(--color-primary); animation-delay: 320ms" /> + </span> + <span class="thinking-label text-[13px]" style="font-family: var(--font-sans)">Thinking…</span> + <span + v-if="elapsed >= 2" + class="text-xs tabular-nums" + style="font-family: var(--font-mono); color: var(--color-muted-foreground); opacity: 0.6" + >{{ elapsed }}s</span> + </div> + </transition> + </div> + </div> + + <!-- Scroll-to-bottom button --> + <transition + enter-active-class="transition-all duration-200 ease-out" + enter-from-class="opacity-0 translate-y-2" + enter-to-class="opacity-100 translate-y-0" + leave-active-class="transition-all duration-150 ease-in" + leave-from-class="opacity-100 translate-y-0" + leave-to-class="opacity-0 translate-y-2" + > + <button + v-if="showScrollBtn" + class="absolute bottom-40 left-1/2 -translate-x-1/2 z-10 w-8 h-8 flex items-center justify-center rounded-full shadow-lg cursor-pointer transition-colors" + style="background: var(--color-surface); border: 1px solid var(--color-border); color: var(--color-muted-foreground)" + @click="scrollToBottom()" + > + <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> + <path d="M12 5v14M5 12l7 7 7-7" /> + </svg> + </button> + </transition> + + <GoalBanner /> + <ChatInput /> + </div> + </transition> </div> <!-- Bottom panel --> @@ -428,10 +531,122 @@ function startResize(e: MouseEvent) { /> </transition> - <SettingsDialog :open="settingsOpen" @close="settingsOpen = false" /> + <SettingsDialog :open="settingsOpen" @close="onSettingsClose" /> <ProjectSwitcher :open="projectsOpen" @close="projectsOpen = false" @project-switched="onProjectSwitched" /> + <RemoteConnectWizard + :open="remoteWizardOpen" + :prefill="remotePrefill" + @close="remoteWizardOpen = false" + @bound="remoteWizardOpen = false; settingsOpen = false" + /> + <CommandPalette :open="paletteOpen" @close="paletteOpen = false" @action="onPaletteAction" /> <!-- Setup overlay — shown when no providers are configured --> <SetupView v-if="needsSetup" @complete="onSetupComplete" /> </div> </template> + +<style scoped> +/* The titlebar-drag strip + macOS top inset live in the GLOBAL style.css + (Vue's scoped compiler mangles `:global(...) .child` selectors). */ + +/* The conversation + composer live in one inset surface panel so the chat + canvas reads as distinct from the sidebar shell, wrapped with breathing room + above (below the top bar) and below (above the window edge) — 包裹感. */ +.chat-panel { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-2xl); + /* Top margin clears the floating panel control at the top-right, so it sits + in a band above the surface instead of overlapping its corner. On the + desktop shell the 28px title-bar strip already provides most of it (see the + is-tauri-macos override in style.css). */ + margin: 40px 14px 14px; + /* NOT overflow:hidden — that would clip the composer's upward model/slash + menus on short viewports. The scroll area rounds its own top corners + (rounded-t) and the composer is inset, so the panel corners stay clean. */ + box-shadow: var(--shadow-sm); +} + +/* ─── New-task welcome ─── composer on the vertical centerline, hero floating + above it, a soft brand-tinted aura behind for focus, and small accents (the + glowing J, the orange project name) so the empty state reads as designed. */ +.welcome { + position: relative; +} +.welcome-aura { + position: absolute; + z-index: 0; + top: 40%; + left: 50%; + width: min(640px, 78%); + height: 420px; + transform: translate(-50%, -50%); + pointer-events: none; + background: radial-gradient( + ellipse at center, + color-mix(in srgb, var(--color-primary) 13%, transparent), + transparent 70% + ); + filter: blur(6px); +} +.welcome-hero, +.welcome-composer { + position: relative; + z-index: 1; +} +.welcome-logo { + display: flex; + align-items: center; + font-family: var(--font-mono); + font-size: 26px; + font-weight: 700; + letter-spacing: 0.06em; + margin-bottom: 26px; +} +.welcome-logo .wl-dim { + color: var(--color-text-muted, var(--color-muted-foreground)); +} +.welcome-logo .wl-j { + color: var(--color-primary); + text-shadow: 0 0 22px color-mix(in srgb, var(--color-primary) 50%, transparent); +} +.welcome-logo .wl-fg { + color: var(--color-foreground); +} +.welcome-title { + font-family: var(--font-sans); + font-size: 30px; + font-weight: 600; + line-height: 1.1; + letter-spacing: -0.025em; + color: var(--color-foreground); + text-align: center; + margin-bottom: 12px; + text-wrap: balance; +} +.welcome-project { + color: var(--color-primary); +} +.welcome-sub { + max-width: 24rem; + font-size: 13.5px; + line-height: 1.6; + color: var(--color-muted-foreground); + text-align: center; +} +.welcome-kbd { + padding: 1px 6px; + font-family: var(--font-mono); + font-size: 10px; + border-radius: var(--radius-sm); + background: var(--color-muted); + border: 1px solid var(--color-border); +} + +@media (max-width: 640px) { + .chat-panel { + margin: 2px 8px 8px; + } +} +</style> diff --git a/web/src/components/BranchPicker.vue b/web/src/components/BranchPicker.vue new file mode 100644 index 0000000..d56fb9a --- /dev/null +++ b/web/src/components/BranchPicker.vue @@ -0,0 +1,392 @@ +<script setup lang="ts"> +import { ref, computed, nextTick } from 'vue' +import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue' +import { GitBranch, Search, ChevronDown, Check, Plus } from 'lucide-vue-next' +import { useBranch } from '@/composables/useBranch' + +withDefaults(defineProps<{ + // Open upward by default; the composer sits low in the viewport. + placement?: 'top' | 'bottom' +}>(), { placement: 'top' }) + +const { current, branches, switching, error, checkout } = useBranch() + +const query = ref('') +const creating = ref(false) +const newName = ref('') +const newInput = ref<HTMLInputElement | null>(null) + +const filtered = computed(() => { + const q = query.value.trim().toLowerCase() + if (!q) return branches.value + return branches.value.filter((b) => b.toLowerCase().includes(q)) +}) + +async function pick(branch: string, close: () => void) { + if (branch === current.value) { + reset() + close() + return + } + const ok = await checkout(branch, false) + if (ok) { + reset() + close() + } + // On failure keep the panel open so the error message stays visible. +} + +function startCreate() { + creating.value = true + newName.value = query.value.trim() + nextTick(() => newInput.value?.focus()) +} + +async function confirmCreate(close: () => void) { + const name = newName.value.trim() + if (!name) return + const ok = await checkout(name, true) + if (ok) { + reset() + close() + } +} + +function reset() { + query.value = '' + creating.value = false + newName.value = '' + error.value = '' +} +</script> + +<template> + <!-- Only shown for git workspaces (no current branch ⇒ not a repo). --> + <div v-if="current" class="bp-bar"> + <Popover class="bp-popover" style="position: relative"> + <PopoverButton as="template" :disabled="switching"> + <button class="bp-pill" :disabled="switching" :title="'Branch: ' + current"> + <GitBranch :size="13" class="bp-pill-icon" /> + <span class="bp-name">{{ current }}</span> + <ChevronDown :size="12" class="bp-caret" /> + </button> + </PopoverButton> + + <transition + enter-active-class="pop-enter-active" + enter-from-class="pop-enter-from" + leave-active-class="pop-leave-active" + leave-to-class="pop-leave-to" + > + <PopoverPanel + v-slot="{ close }" + class="bp-panel" + :class="placement === 'top' ? 'place-top' : 'place-bottom'" + > + <div class="bp-search"> + <Search :size="13" class="bp-search-icon" /> + <input v-model="query" class="bp-search-input" placeholder="Search branches" /> + </div> + + <div class="bp-section">Branches</div> + <div class="bp-list"> + <div v-if="filtered.length === 0" class="bp-hint">No branches</div> + <button + v-for="b in filtered" + :key="b" + class="bp-row" + :class="{ active: b === current }" + :disabled="switching" + @click="pick(b, close)" + > + <GitBranch :size="13" class="bp-row-icon" /> + <span class="bp-row-name">{{ b }}</span> + <Check v-if="b === current" :size="14" class="bp-check" /> + </button> + </div> + + <div v-if="error" class="bp-error">{{ error }}</div> + + <div class="bp-actions"> + <button v-if="!creating" class="bp-action" @click="startCreate"> + <Plus :size="14" /> <span>Create & checkout new branch</span> + </button> + <div v-else class="bp-create"> + <input + ref="newInput" + v-model="newName" + class="bp-create-input" + placeholder="new-branch-name" + @keydown.enter="confirmCreate(close)" + @keydown.esc="creating = false" + /> + <button + class="bp-create-btn" + :disabled="!newName.trim() || switching" + @click="confirmCreate(close)" + > + Create + </button> + </div> + </div> + </PopoverPanel> + </transition> + </Popover> + </div> +</template> + +<style scoped> +.bp-bar { + display: inline-flex; + align-items: center; + min-width: 0; +} +.bp-popover { + display: inline-flex; + min-width: 0; +} + +.bp-pill { + display: inline-flex; + align-items: center; + gap: 5px; + height: 28px; + max-width: 200px; + padding: 0 8px; + border: 1px solid transparent; + border-radius: var(--radius-lg); + background: transparent; + color: var(--color-muted-foreground); + cursor: pointer; + transition: background 0.15s, transform 0.06s ease; +} +.bp-pill:hover:not(:disabled) { + background: var(--color-muted); +} +.bp-pill:active:not(:disabled) { + transform: translateY(0.5px); +} +.bp-pill:disabled { + opacity: 0.55; + cursor: not-allowed; +} +.bp-pill-icon { + flex-shrink: 0; +} +.bp-name { + font-family: var(--font-mono); + font-size: 11.5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.bp-caret { + flex-shrink: 0; + margin-left: 1px; + opacity: 0.7; +} + +.bp-panel { + position: absolute; + left: 0; + z-index: 40; + width: 300px; + max-width: 84vw; + padding: 6px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-lg); +} +.bp-panel.place-top { + bottom: calc(100% + 6px); +} +.bp-panel.place-bottom { + top: calc(100% + 6px); +} + +.bp-search { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 8px; + margin-bottom: 4px; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-background); +} +.bp-search-icon { + color: var(--color-muted-foreground); + flex-shrink: 0; +} +.bp-search-input { + flex: 1; + min-width: 0; + border: none; + outline: none; + background: transparent; + font-size: 12.5px; + color: var(--color-foreground); +} +.bp-search-input::placeholder { + color: var(--color-muted-foreground); +} + +.bp-section { + padding: 4px 8px 2px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-muted-foreground); +} + +.bp-list { + max-height: 240px; + overflow-y: auto; + padding: 2px; +} +.bp-row { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 7px 8px; + border: none; + background: transparent; + border-radius: var(--radius-md); + cursor: pointer; + text-align: left; + color: var(--color-foreground); + font-size: 12.5px; + font-family: var(--font-mono); + transition: background 0.12s; +} +.bp-row:hover:not(:disabled) { + background: var(--color-muted); +} +.bp-row.active { + background: var(--accent-wash-soft); +} +.bp-row:disabled { + opacity: 0.6; + cursor: default; +} +.bp-row-icon { + color: var(--color-muted-foreground); + flex-shrink: 0; +} +.bp-row.active .bp-row-icon { + color: var(--color-primary); +} +.bp-row-name { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.bp-check { + color: var(--color-primary); + flex-shrink: 0; +} +.bp-hint { + padding: 14px 8px; + text-align: center; + font-size: 11.5px; + color: var(--color-muted-foreground); +} + +.bp-error { + margin: 4px 2px 2px; + padding: 7px 9px; + border-radius: var(--radius-md); + background: var(--color-error-bg); + color: var(--color-error-fg); + font-size: 11.5px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} + +.bp-actions { + margin-top: 4px; + padding-top: 4px; + border-top: 1px solid var(--color-border); +} +.bp-action { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px; + border: none; + background: transparent; + border-radius: var(--radius-md); + cursor: pointer; + font-size: 12.5px; + color: var(--color-foreground); + transition: background 0.12s; +} +.bp-action:hover { + background: var(--color-muted); +} +.bp-action svg { + color: var(--color-muted-foreground); + flex-shrink: 0; +} + +.bp-create { + display: flex; + align-items: center; + gap: 6px; + padding: 4px; +} +.bp-create-input { + flex: 1; + min-width: 0; + height: 30px; + padding: 0 9px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-background); + color: var(--color-foreground); + font-family: var(--font-mono); + font-size: 12px; + outline: none; +} +.bp-create-input:focus { + border-color: var(--color-primary); +} +.bp-create-btn { + flex-shrink: 0; + height: 30px; + padding: 0 12px; + border: none; + border-radius: var(--radius-md); + background: var(--color-primary); + color: var(--color-on-primary, #fff); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: opacity 0.15s; +} +.bp-create-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.pop-enter-active, +.pop-leave-active { + transition: opacity 0.12s ease, transform 0.12s ease; +} +.pop-enter-from, +.pop-leave-to { + opacity: 0; + transform: translateY(4px); +} +.bp-panel.place-bottom.pop-enter-from, +.bp-panel.place-bottom.pop-leave-to { + transform: translateY(-4px); +} +</style> diff --git a/web/src/components/ChatInput.vue b/web/src/components/ChatInput.vue index 4c7480b..3f6b760 100644 --- a/web/src/components/ChatInput.vue +++ b/web/src/components/ChatInput.vue @@ -3,12 +3,23 @@ import { ref, nextTick, watch, computed, onMounted, onUnmounted } from 'vue' import { useChatStore } from '@/stores/chat' import { api } from '@/composables/api' import type { SlashCommandInfo, ChatImage } from '@/types/api' +import WorkspacePicker from '@/components/WorkspacePicker.vue' +import BranchPicker from '@/components/BranchPicker.vue' +import { MessageSquare, ClipboardList, Zap, Target, Plus, Paperclip, Slash, X } from 'lucide-vue-next' + +// Which way the workspace/branch pickers open. The docked composer opens them +// upward (default); the centered welcome composer has more empty room below, so +// it opens them downward to avoid clipping against the top of the canvas. +withDefaults(defineProps<{ pickerPlacement?: 'top' | 'bottom' }>(), { + pickerPlacement: 'top', +}) const store = useChatStore() const input = ref('') const textarea = ref<HTMLTextAreaElement | null>(null) const showModelPicker = ref(false) const showModePicker = ref(false) +const showAddMenu = ref(false) const showManageModels = ref(false) const modelFilter = ref('') const containerRef = ref<HTMLDivElement | null>(null) @@ -24,9 +35,9 @@ const pendingImagePreviews = ref<string[]>([]) const fileInput = ref<HTMLInputElement | null>(null) const modes = [ - { value: 'ask' as const, label: 'Ask', icon: '💬' }, - { value: 'plan' as const, label: 'Plan', icon: '📋' }, - { value: 'autopilot' as const, label: 'Autopilot', icon: '🚀' }, + { value: 'ask' as const, label: 'Ask', icon: MessageSquare }, + { value: 'plan' as const, label: 'Plan', icon: ClipboardList }, + { value: 'autopilot' as const, label: 'Autopilot', icon: Zap }, ] const filteredSlashCommands = computed(() => { @@ -189,6 +200,22 @@ function triggerImageUpload() { fileInput.value?.click() } +// Insert a trigger character at the cursor from the "+" menu. For "/" at the +// start of an empty box this also opens the slash-command menu via handleInput. +function insertToken(char: string) { + showAddMenu.value = false + const el = textarea.value + const start = el ? el.selectionStart : input.value.length + const end = el ? el.selectionEnd : input.value.length + input.value = input.value.slice(0, start) + char + input.value.slice(end) + nextTick(() => { + el?.focus() + const pos = start + char.length + el?.setSelectionRange(pos, pos) + handleInput() + }) +} + function handleImageSelect(e: Event) { const target = e.target as HTMLInputElement const files = target.files @@ -230,6 +257,7 @@ function handleClickOutside(e: MouseEvent) { if (containerRef.value && !containerRef.value.contains(e.target as Node)) { showModelPicker.value = false showModePicker.value = false + showAddMenu.value = false showSlashMenu.value = false if (showManageModels.value) { showManageModels.value = false @@ -279,7 +307,7 @@ watch(() => store.isRunning, (running) => { <template> <div ref="containerRef" class="chat-input-wrapper"> - <div class="chat-input-card"> + <div class="chat-input-card" :class="{ 'composer-elevated': !store.hasMessages }"> <!-- Slash command menu --> <div v-if="showSlashMenu && filteredSlashCommands.length > 0" @@ -298,6 +326,15 @@ watch(() => store.isRunning, (running) => { </button> </div> + <!-- Workspace selector — pick the workspace for this task directly on the + composer (replaces opening the projects modal from the sidebar). On + the centered new-task screen its menu opens downward; once docked at + the bottom during a conversation it opens upward. --> + <div class="composer-top"> + <WorkspacePicker :placement="pickerPlacement" /> + <BranchPicker :placement="pickerPlacement" /> + </div> + <div class="chat-input-inner"> <!-- Textarea area --> <div class="textarea-area"> @@ -334,26 +371,48 @@ watch(() => store.isRunning, (running) => { <!-- Toolbar --> <div class="toolbar"> <div class="toolbar-left"> - <!-- Paperclip / attach --> - <button - class="tool-btn attach-btn" - :class="{ disabled: !store.imageSupport, 'has-images': pendingImages.length > 0 }" - :title="!store.imageSupport ? 'Current model does not support images' : 'Attach images'" - :disabled="!store.imageSupport" - @click="store.imageSupport && triggerImageUpload()" - > - <svg class="w-4 h-4" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"> - <path d="M15.621 4.379a3.5 3.5 0 00-4.95 0L4.05 11a2.5 2.5 0 003.536 3.536l6.621-6.621a1.5 1.5 0 00-2.121-2.121l-6.622 6.621" stroke-linecap="round" stroke-linejoin="round" /> - </svg> - <span v-if="pendingImages.length > 0" class="attach-badge">{{ pendingImages.length }}</span> - </button> + <!-- "+" menu: attach files, slash command, and Goal (only the items + that have real functionality). --> + <div class="relative"> + <button + class="add-btn" + :class="{ open: showAddMenu }" + title="Add" + @click.stop="showAddMenu = !showAddMenu; showModelPicker = false; showModePicker = false" + > + <Plus :size="18" /> + </button> + <div v-if="showAddMenu" class="dropdown-menu add-menu"> + <button + class="dropdown-item" + :class="{ disabled: !store.imageSupport }" + :disabled="!store.imageSupport" + :title="!store.imageSupport ? 'Current model does not support images' : ''" + @click="triggerImageUpload(); showAddMenu = false" + > + <Paperclip :size="15" class="dmi-icon" /> <span>Attach files</span> + <span v-if="pendingImages.length > 0" class="dmi-badge">{{ pendingImages.length }}</span> + </button> + <button class="dropdown-item" @click="insertToken('/')"> + <Slash :size="15" class="dmi-icon" /> <span>Command</span> + </button> + <button + class="dropdown-item" + :class="{ active: store.goalArmed }" + :title="store.goal ? 'Setting a new goal replaces the current one' : 'Next message becomes the session goal'" + @click="store.goalArmed = !store.goalArmed; showAddMenu = false" + > + <Target :size="15" class="dmi-icon" /> <span>Goal</span> + </button> + </div> + </div> - <!-- Mode selector (Ask/Plan/Autopilot) --> + <!-- Mode selector (Ask/Plan/Autopilot) — stays visible on the toolbar. --> <div class="relative"> <button class="tool-btn dropdown-btn" :class="{ highlighted: store.mode !== 'ask' }" - @click.stop="showModePicker = !showModePicker; showModelPicker = false" + @click.stop="showModePicker = !showModePicker; showModelPicker = false; showAddMenu = false" > {{ modeLabel(store.mode) }} <svg class="w-3 h-3 opacity-60" viewBox="0 0 20 20" fill="currentColor"> @@ -368,35 +427,44 @@ watch(() => store.isRunning, (running) => { :class="{ active: store.mode === m.value }" @click="selectMode(m.value)" > - {{ m.icon }} {{ m.label }} + <component :is="m.icon" :size="14" /> {{ m.label }} </button> </div> </div> - <!-- Goal toggle: arm the prompt box so the next message sets the session goal --> - <button - class="tool-btn dropdown-btn" - :class="{ highlighted: store.goalArmed }" - :title="store.goal ? 'Setting a new goal replaces the current one' : 'Next message becomes the session goal'" - @click="store.goalArmed = !store.goalArmed" - > - 🎯 Goal - </button> + <!-- Goal chip — appears once Goal is armed (from the + menu); its × + disarms it. Mirrors the Codex "目标" pill. --> + <template v-if="store.goalArmed"> + <span class="tb-divider" aria-hidden="true" /> + <div class="goal-chip" :title="store.goal ? 'Next message replaces the current goal' : 'Next message becomes the session goal'"> + <button class="goal-chip-x" title="Remove goal" @click="store.goalArmed = false"> + <X :size="11" /> + </button> + <Target :size="13" /> + <span>Goal</span> + </div> + </template> + </div> - <!-- Model selector --> + <div class="toolbar-right"> + <span v-if="store.tokenInfo" class="token-count"> + {{ store.tokenInfo.total_tokens.toLocaleString() }} tokens + </span> + + <!-- Model selector (moved to the right, near Send) --> <div class="relative"> <button class="tool-btn dropdown-btn" - @click.stop="showModelPicker = !showModelPicker; showModePicker = false" + @click.stop="showModelPicker = !showModelPicker; showModePicker = false; showAddMenu = false" > - {{ store.modelName || 'model' }} + {{ store.modelName ? getModelDisplayName(store.providerName, store.modelName) : 'model' }} <svg class="w-3 h-3 opacity-60" viewBox="0 0 20 20" fill="currentColor"> <path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd" /> </svg> </button> <div v-if="showModelPicker" - class="dropdown-menu model-menu" + class="dropdown-menu model-menu align-right" > <!-- Favorites section --> <template v-if="store.recentModels.length > 0 && store.favoriteModels.size > 0"> @@ -407,7 +475,7 @@ watch(() => store.isRunning, (running) => { class="dropdown-item" @click="selectModel(r.provider, r.model)" > - <span class="text-amber-400 mr-1">★</span>{{ getModelDisplayName(r.provider, r.model) }} + <span class="mr-1" style="color: var(--color-primary)">★</span>{{ getModelDisplayName(r.provider, r.model) }} </button> </template> @@ -450,14 +518,6 @@ watch(() => store.isRunning, (running) => { </div> </div> - <!-- Auto-approve is now expressed by the Autopilot mode above. --> - </div> - - <div class="toolbar-right"> - <span v-if="store.tokenInfo" class="token-count"> - {{ store.tokenInfo.total_tokens.toLocaleString() }} tokens - </span> - <!-- Channel toggle (inline) --> <button v-if="store.channelAvailable" @@ -504,7 +564,7 @@ watch(() => store.isRunning, (running) => { <Teleport to="body"> <div v-if="showManageModels" - class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" + class="fixed inset-0 z-50 flex items-center justify-center bg-[var(--backdrop)] backdrop-blur-sm" @click="showManageModels = false; modelFilter = ''" > <div class="w-full max-w-lg max-h-[70vh] flex flex-col mx-4 rounded-lg shadow-xl" style="background: var(--color-surface); border: 1px solid var(--color-border)" @click.stop> @@ -546,7 +606,7 @@ watch(() => store.isRunning, (running) => { @change="store.toggleModelEnabled(p.id, m.id, ($event.target as HTMLInputElement).checked)" /> <span class="text-xs flex-1 truncate" style="color: var(--color-foreground)">{{ m.name || m.id }}</span> - <span v-if="m.recommended" class="text-[9px] px-1.5 py-0.5 rounded font-medium shrink-0" style="background: rgba(255,132,0,0.1); color: var(--color-primary)">recommended</span> + <span v-if="m.recommended" class="text-[9px] px-1.5 py-0.5 rounded font-medium shrink-0" style="background: var(--accent-wash); color: var(--color-primary)">recommended</span> </label> </template> </div> @@ -560,29 +620,58 @@ watch(() => store.isRunning, (running) => { <style scoped> .chat-input-wrapper { - padding: 12px 24px 16px; - background: var(--color-background); + padding: 8px 16px 14px; + /* Sits inside the surface chat panel — transparent so it blends with it. */ + background: transparent; position: relative; } .chat-input-card { margin: 0 auto; - border-radius: 12px; - padding: 6px; - background: #F6F7F4; + border-radius: var(--radius-xl); + padding: 4px 6px 10px; + background: transparent; position: relative; display: flex; flex-direction: column; } -.dark .chat-input-card { - background: var(--color-background); +.composer-top { + display: flex; + align-items: center; + gap: 6px; + padding: 0 2px 5px; + min-width: 0; } -.chat-input-inner { +/* On the new-task screen the composer is the centerpiece: lift the whole thing + (workspace pills + input + toolbar) into one cohesive elevated card with a + soft, background-tinted shadow. The docked conversation composer keeps the + recessed, frameless look. */ +.composer-elevated { background: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 9px; + border-radius: var(--radius-2xl); + /* Tighter at the top (workspace row), a touch more white below the toolbar. */ + padding: 6px 12px 12px; + box-shadow: + 0 1px 2px rgba(15, 18, 24, 0.04), + 0 18px 44px -24px rgba(15, 18, 24, 0.28); +} +.composer-elevated .composer-top { + padding: 2px 4px 9px; +} +.composer-elevated .chat-input-inner { + border-radius: var(--radius-xl); +} + +.chat-input-inner { + /* Recessed: always one step darker than the surface panel, derived from it so + the depth cue holds on every theme (some light themes have a background + token that is lighter than surface, which would otherwise invert it). */ + background: color-mix(in srgb, var(--color-surface) 90%, #000); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); padding: 14px 16px 0; transition: border-color 0.2s; } @@ -632,7 +721,7 @@ watch(() => store.isRunning, (running) => { width: 56px; height: 56px; object-fit: cover; - border-radius: 6px; + border-radius: var(--radius-md); border: 1px solid var(--color-border); } @@ -687,7 +776,7 @@ watch(() => store.isRunning, (running) => { padding: 4px 8px; border: none; background: transparent; - border-radius: 6px; + border-radius: var(--radius-md); font-size: 12px; color: var(--color-muted-foreground); cursor: pointer; @@ -701,7 +790,7 @@ watch(() => store.isRunning, (running) => { } .tool-btn.highlighted { - background: rgba(255, 132, 0, 0.1); + background: var(--accent-wash); color: var(--color-primary); } @@ -746,8 +835,8 @@ watch(() => store.isRunning, (running) => { padding: 4px 0; background: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 10px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); } .dropdown-menu.model-menu { @@ -777,7 +866,7 @@ watch(() => store.isRunning, (running) => { .dropdown-item.active { color: var(--color-primary); - background: rgba(255, 132, 0, 0.1); + background: var(--accent-wash); } .dropdown-item.disabled { @@ -801,6 +890,88 @@ watch(() => store.isRunning, (running) => { margin-top: 0; } +/* "+" add menu button + items */ +.add-btn { + display: grid; + place-items: center; + width: 30px; + height: 30px; + border: none; + background: var(--color-muted); + border-radius: var(--radius-md); + color: var(--color-muted-foreground); + cursor: pointer; + transition: background 0.15s, color 0.15s; +} +.add-btn:hover, +.add-btn.open { + background: var(--color-secondary); + color: var(--color-foreground); +} +.add-menu { + min-width: 188px; +} +.add-menu .dropdown-item { + gap: 9px; + padding: 7px 12px; + color: var(--color-foreground); +} +.dmi-icon { + color: var(--color-muted-foreground); + flex-shrink: 0; +} +.dropdown-item.active .dmi-icon { + color: var(--color-primary); +} +.dmi-badge { + margin-left: auto; + font-size: 10px; + font-family: var(--font-mono); + color: var(--color-primary); +} + +/* Right-anchored dropdown (the model picker now lives on the toolbar's right) */ +.dropdown-menu.align-right { + left: auto; + right: 0; +} + +/* Subtle divider + the armed-Goal chip (Codex-style "× Goal" pill) */ +.tb-divider { + width: 1px; + height: 16px; + background: var(--color-border); + margin: 0 2px; + flex-shrink: 0; +} +.goal-chip { + display: inline-flex; + align-items: center; + gap: 5px; + height: 26px; + padding: 0 9px 0 4px; + border-radius: var(--radius-md); + background: var(--accent-wash); + color: var(--color-primary); + font-size: 12px; + font-weight: 500; +} +.goal-chip-x { + display: grid; + place-items: center; + width: 16px; + height: 16px; + border: none; + border-radius: var(--radius-pill); + background: color-mix(in srgb, var(--color-primary) 18%, transparent); + color: var(--color-primary); + cursor: pointer; + transition: background 0.15s; +} +.goal-chip-x:hover { + background: color-mix(in srgb, var(--color-primary) 34%, transparent); +} + .dropdown-footer { padding: 4px 12px; border-top: 1px solid var(--color-border); @@ -820,8 +991,8 @@ watch(() => store.isRunning, (running) => { .recommend-badge { font-size: 9px; padding: 1px 5px; - border-radius: 4px; - background: rgba(255, 132, 0, 0.1); + border-radius: var(--radius-sm); + background: var(--accent-wash); color: var(--color-primary); font-weight: 500; margin-left: 4px; @@ -840,7 +1011,7 @@ watch(() => store.isRunning, (running) => { .fav-star.is-fav { opacity: 1; - color: #fbbf24; + color: var(--color-primary); } .dropdown-item:hover .fav-star { @@ -861,13 +1032,13 @@ watch(() => store.isRunning, (running) => { gap: 5px; padding: 5px 12px; border: none; - border-radius: 8px; + border-radius: var(--radius-lg); background: var(--color-primary); - color: white; + color: var(--color-on-primary, #fff); font-size: 12px; font-weight: 500; cursor: pointer; - transition: opacity 0.15s; + transition: opacity 0.15s, transform 0.08s var(--ease-out); white-space: nowrap; } @@ -877,7 +1048,11 @@ watch(() => store.isRunning, (running) => { } .send-btn:not(:disabled):hover { - opacity: 0.9; + opacity: 0.92; +} + +.send-btn:not(:disabled):active { + transform: translateY(0.5px); } .stop-btn { @@ -886,7 +1061,7 @@ watch(() => store.isRunning, (running) => { gap: 5px; padding: 5px 12px; border: none; - border-radius: 8px; + border-radius: var(--radius-lg); background: var(--color-destructive); color: white; font-size: 12px; @@ -908,8 +1083,8 @@ watch(() => store.isRunning, (running) => { overflow-y: auto; background: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 10px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); } .slash-item { @@ -954,7 +1129,7 @@ watch(() => store.isRunning, (running) => { justify-content: center; border: none; background: transparent; - border-radius: 6px; + border-radius: var(--radius-md); color: var(--color-muted-foreground); cursor: pointer; transition: color 0.15s, background 0.15s; @@ -966,7 +1141,7 @@ watch(() => store.isRunning, (running) => { .channel-btn.active { color: var(--color-primary); - background: rgba(255, 132, 0, 0.1); + background: var(--accent-wash); } .hidden { diff --git a/web/src/components/ChatMessage.vue b/web/src/components/ChatMessage.vue index 5bf36b3..34de194 100644 --- a/web/src/components/ChatMessage.vue +++ b/web/src/components/ChatMessage.vue @@ -104,7 +104,7 @@ function handleEditKeyDown(e: KeyboardEvent) { <div class="flex items-center gap-0.5 ml-1 opacity-0 group-hover/msg:opacity-100 group-focus-within/msg:opacity-100 transition-opacity duration-150"> <!-- Copy button --> <button - class="w-5 h-5 flex items-center justify-center rounded transition-colors cursor-pointer" + class="w-5 h-5 flex items-center justify-center rounded-[var(--radius-sm)] transition-all cursor-pointer hover:bg-[var(--color-secondary)] active:scale-90" style="color: var(--color-muted-foreground)" :title="copied ? 'Copied!' : 'Copy'" @click="copyContent" @@ -113,7 +113,7 @@ function handleEditKeyDown(e: KeyboardEvent) { <rect x="9" y="9" width="13" height="13" rx="2" /> <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /> </svg> - <svg v-else class="w-3 h-3" style="color: var(--color-primary)" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> + <svg v-else class="w-3 h-3" style="color: var(--color-primary)" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <polyline points="20 6 9 17 4 12" /> </svg> </button> @@ -121,7 +121,7 @@ function handleEditKeyDown(e: KeyboardEvent) { <!-- Retry button (assistant messages) --> <button v-if="canRetry" - class="w-5 h-5 flex items-center justify-center rounded transition-colors cursor-pointer" + class="w-5 h-5 flex items-center justify-center rounded-[var(--radius-sm)] transition-all cursor-pointer hover:bg-[var(--color-secondary)] active:scale-90" style="color: var(--color-muted-foreground)" title="Retry" @click="emit('retry')" @@ -135,7 +135,7 @@ function handleEditKeyDown(e: KeyboardEvent) { <!-- Edit button (user messages) --> <button v-if="canEdit && !editing" - class="w-5 h-5 flex items-center justify-center rounded transition-colors cursor-pointer" + class="w-5 h-5 flex items-center justify-center rounded-[var(--radius-sm)] transition-all cursor-pointer hover:bg-[var(--color-secondary)] active:scale-90" style="color: var(--color-muted-foreground)" title="Edit" @click="startEdit" @@ -174,14 +174,14 @@ function handleEditKeyDown(e: KeyboardEvent) { /> <div class="flex items-center gap-2 mt-2"> <button - class="px-3 py-1 text-xs font-medium rounded text-white transition-colors cursor-pointer" - :style="{ background: 'var(--color-primary)', borderRadius: 'var(--radius-md)' }" + class="px-3 py-1 text-xs font-semibold transition-all cursor-pointer active:scale-95" + :style="{ background: 'var(--color-primary)', color: 'var(--color-on-primary, #fff)', borderRadius: 'var(--radius-md)' }" @click="confirmEdit" > Send </button> <button - class="px-3 py-1 text-xs font-medium rounded transition-colors cursor-pointer" + class="px-3 py-1 text-xs font-medium transition-all cursor-pointer active:scale-95" :style="{ background: 'var(--color-secondary)', color: 'var(--color-foreground)', borderRadius: 'var(--radius-md)' }" @click="cancelEdit" > diff --git a/web/src/components/CommandPalette.vue b/web/src/components/CommandPalette.vue new file mode 100644 index 0000000..390b0eb --- /dev/null +++ b/web/src/components/CommandPalette.vue @@ -0,0 +1,272 @@ +<script setup lang="ts"> +import { ref, computed, watch, nextTick } from 'vue' +import { Dialog, DialogPanel, TransitionRoot, TransitionChild } from '@headlessui/vue' +import { Plus, Settings, FolderOpen, SunMoon, MessageSquare, CornerDownLeft } from 'lucide-vue-next' +import { useChatStore } from '@/stores/chat' +import { useProjectStore } from '@/stores/project' +import type { TaskItem } from '@/types/api' + +const props = defineProps<{ open: boolean }>() +const emit = defineEmits<{ + close: [] + action: [name: 'settings' | 'projects' | 'theme'] +}>() + +const store = useChatStore() +const projectStore = useProjectStore() + +const query = ref('') +const selectedIdx = ref(0) +const inputEl = ref<HTMLInputElement | null>(null) + +interface PaletteItem { + id: string + group: string + label: string + hint?: string + icon: unknown + run: () => void | Promise<void> +} + +async function openTask(task: TaskItem) { + emit('close') + if (task.unread) projectStore.updateTaskMeta(task.uuid, { unread: false }) + const cur = projectStore.activeProject?.path || store.pwd + if (cur !== task.project) { + const ok = await projectStore.openProject(task.project) + if (!ok) return + await store.fetchHealth() + } + await store.loadSession(task.uuid) +} + +const actions = computed<PaletteItem[]>(() => [ + { id: 'a-new', group: 'Actions', label: 'New task', icon: Plus, run: () => { emit('close'); store.newSession() } }, + { id: 'a-proj', group: 'Actions', label: 'Open project…', icon: FolderOpen, run: () => { emit('close'); emit('action', 'projects') } }, + { id: 'a-settings', group: 'Actions', label: 'Open settings', icon: Settings, run: () => { emit('close'); emit('action', 'settings') } }, + { id: 'a-theme', group: 'Actions', label: 'Toggle theme', icon: SunMoon, run: () => { emit('action', 'theme') } }, +]) + +const taskItems = computed<PaletteItem[]>(() => + projectStore.allTasks + .filter((t) => !t.archived) + .map((t) => ({ + id: 't-' + t.uuid, + group: 'Tasks', + label: t.title || t.uuid.slice(0, 8) + '…', + hint: projectStore.nameForPath(t.project), + icon: MessageSquare, + run: () => openTask(t), + })), +) + +const results = computed<PaletteItem[]>(() => { + const q = query.value.trim().toLowerCase() + const all = [...actions.value, ...taskItems.value] + if (!q) return [...actions.value, ...taskItems.value.slice(0, 8)] + return all.filter((i) => i.label.toLowerCase().includes(q) || (i.hint || '').toLowerCase().includes(q)) +}) + +// Group results in display order while keeping a flat index for keyboard nav. +const groups = computed(() => { + const order: string[] = [] + const map: Record<string, PaletteItem[]> = {} + results.value.forEach((item) => { + if (!map[item.group]) { + map[item.group] = [] + order.push(item.group) + } + map[item.group]!.push(item) + }) + return order.map((g) => ({ name: g, items: map[g]! })) +}) + +function flatIndex(item: PaletteItem): number { + return results.value.findIndex((i) => i.id === item.id) +} + +watch(() => props.open, async (o) => { + if (o) { + query.value = '' + selectedIdx.value = 0 + projectStore.fetchAllTasks() + await nextTick() + inputEl.value?.focus() + } +}) +watch(results, () => { selectedIdx.value = 0 }) + +function move(delta: number) { + const n = results.value.length + if (n === 0) return + selectedIdx.value = (selectedIdx.value + delta + n) % n +} +function runSelected() { + results.value[selectedIdx.value]?.run() +} +function onKeydown(e: KeyboardEvent) { + if (e.key === 'ArrowDown') { e.preventDefault(); move(1) } + else if (e.key === 'ArrowUp') { e.preventDefault(); move(-1) } + else if (e.key === 'Enter') { e.preventDefault(); runSelected() } +} +</script> + +<template> + <TransitionRoot :show="open" as="template"> + <Dialog @close="emit('close')" class="relative" style="z-index: var(--z-modal)"> + <TransitionChild + enter="ease-out duration-150" enter-from="opacity-0" enter-to="opacity-100" + leave="ease-in duration-100" leave-from="opacity-100" leave-to="opacity-0"> + <div class="fixed inset-0" style="background: var(--backdrop); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px)" /> + </TransitionChild> + + <div class="fixed inset-0 flex items-start justify-center pt-[12vh] px-4"> + <TransitionChild + enter="ease-out duration-150" enter-from="opacity-0 scale-[0.98] -translate-y-1" + enter-to="opacity-100 scale-100 translate-y-0" + leave="ease-in duration-100" leave-from="opacity-100 scale-100" + leave-to="opacity-0 scale-[0.98] -translate-y-1"> + <DialogPanel class="cp-panel"> + <div class="cp-input-row"> + <svg class="cp-search-icon" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.7"> + <circle cx="9" cy="9" r="6" /><path d="M14 14l3.5 3.5" stroke-linecap="round" /> + </svg> + <input + ref="inputEl" + v-model="query" + class="cp-input" + placeholder="Search tasks or run a command…" + @keydown="onKeydown" + /> + <kbd class="cp-esc">Esc</kbd> + </div> + + <div class="cp-results"> + <div v-if="results.length === 0" class="cp-empty">No results</div> + <template v-for="g in groups" :key="g.name"> + <div class="cp-group-label">{{ g.name }}</div> + <button + v-for="item in g.items" + :key="item.id" + class="cp-item" + :class="{ sel: flatIndex(item) === selectedIdx }" + @click="item.run()" + @mousemove="selectedIdx = flatIndex(item)" + > + <component :is="item.icon" :size="15" class="cp-item-icon" /> + <span class="cp-item-label">{{ item.label }}</span> + <span v-if="item.hint" class="cp-item-hint">{{ item.hint }}</span> + <CornerDownLeft v-if="flatIndex(item) === selectedIdx" :size="13" class="cp-enter" /> + </button> + </template> + </div> + </DialogPanel> + </TransitionChild> + </div> + </Dialog> + </TransitionRoot> +</template> + +<style scoped> +.cp-panel { + width: min(560px, 94vw); + max-height: 60vh; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-lg); +} +.cp-input-row { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px; + border-bottom: 1px solid var(--color-border); +} +.cp-search-icon { + width: 16px; + height: 16px; + color: var(--color-muted-foreground); + flex-shrink: 0; +} +.cp-input { + flex: 1; + background: transparent; + border: none; + outline: none; + font-size: 14px; + color: var(--color-foreground); + font-family: var(--font-sans); +} +.cp-input::placeholder { + color: var(--color-muted-foreground); +} +.cp-esc { + font-size: 10px; + font-family: var(--font-mono); + padding: 2px 6px; + border-radius: var(--radius-sm); + background: var(--color-secondary); + border: 1px solid var(--color-border); + color: var(--color-muted-foreground); +} +.cp-results { + overflow-y: auto; + padding: 6px; +} +.cp-empty { + text-align: center; + font-size: 13px; + color: var(--color-muted-foreground); + padding: 24px 0; +} +.cp-group-label { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-muted-foreground); + padding: 8px 8px 4px; +} +.cp-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 10px; + border: none; + background: transparent; + border-radius: var(--radius-md); + cursor: pointer; + text-align: left; +} +.cp-item.sel { + background: var(--color-muted); +} +.cp-item-icon { + color: var(--color-muted-foreground); + flex-shrink: 0; +} +.cp-item-label { + flex: 1; + min-width: 0; + font-size: 13px; + color: var(--color-foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.cp-item-hint { + font-size: 11px; + font-family: var(--font-mono); + color: var(--color-muted-foreground); + flex-shrink: 0; +} +.cp-enter { + color: var(--color-muted-foreground); + flex-shrink: 0; +} +</style> diff --git a/web/src/components/DiffViewer.vue b/web/src/components/DiffViewer.vue index 563706c..d98bdf2 100644 --- a/web/src/components/DiffViewer.vue +++ b/web/src/components/DiffViewer.vue @@ -40,11 +40,11 @@ const totalChanges = computed(() => ({ deletions: entries.value.reduce((sum, e) => sum + e.deletions, 0), })) -function statusBadge(status: string) { +function statusBadge(status: string): { label: string; style: Record<string, string> } { switch (status) { - case 'A': return { label: 'A', cls: 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-400' } - case 'D': return { label: 'D', cls: 'bg-red-100 dark:bg-red-500/15 text-red-700 dark:text-red-400' } - default: return { label: 'M', cls: 'bg-amber-100 dark:bg-amber-500/15 text-amber-700 dark:text-amber-400' } + case 'A': return { label: 'A', style: { background: 'var(--color-success-bg)', color: 'var(--color-success-fg)' } } + case 'D': return { label: 'D', style: { background: 'var(--color-error-bg)', color: 'var(--color-error-fg)' } } + default: return { label: 'M', style: { background: 'var(--color-warning-bg)', color: 'var(--color-warning-fg)' } } } } @@ -68,19 +68,17 @@ onMounted(fetchDiff) </script> <template> - <div class="flex flex-col h-full bg-zinc-50 dark:bg-zinc-900"> + <div class="flex flex-col h-full" style="background: var(--color-background)"> <!-- Header --> - <div class="flex items-center justify-between px-3 py-1.5 border-b border-zinc-200 dark:border-zinc-800 bg-zinc-100/80 dark:bg-zinc-800/80"> + <div class="flex items-center justify-between px-3 py-1.5" style="border-bottom: 1px solid var(--color-border); background: var(--color-sidebar-bg)"> <div class="flex items-center gap-2"> - <span class="text-[11px] font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Changes</span> + <span class="text-[11px] font-semibold uppercase tracking-wider" style="color: var(--color-muted-foreground)">Changes</span> <div class="flex gap-0.5"> <button v-for="m in modes" :key="m.value" - class="px-1.5 py-0.5 text-[10px] rounded cursor-pointer transition-colors font-medium" - :class="mode === m.value - ? 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-400' - : 'text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700'" + class="dv-mode px-1.5 py-0.5 text-[10px] rounded cursor-pointer transition-colors font-medium" + :class="{ active: mode === m.value }" @click="mode = m.value; fetchDiff()" > {{ m.label }} @@ -89,12 +87,12 @@ onMounted(fetchDiff) </div> <div class="flex items-center gap-2"> <span v-if="totalChanges.additions || totalChanges.deletions" class="text-[10px] font-mono"> - <span class="text-emerald-600 dark:text-emerald-400">+{{ totalChanges.additions }}</span> - <span class="text-zinc-300 dark:text-zinc-600 mx-0.5">/</span> - <span class="text-red-500 dark:text-red-400">-{{ totalChanges.deletions }}</span> + <span style="color: var(--color-success-fg)">+{{ totalChanges.additions }}</span> + <span class="mx-0.5" style="color: var(--color-border)">/</span> + <span style="color: var(--color-error-fg)">-{{ totalChanges.deletions }}</span> </span> <button - class="text-[10px] text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300 cursor-pointer transition-colors font-medium" + class="dv-mute text-[10px] cursor-pointer transition-colors font-medium" @click="fetchDiff" > ↻ Refresh @@ -104,61 +102,54 @@ onMounted(fetchDiff) <div class="flex flex-col flex-1 min-h-0"> <!-- File list --> - <div class="border-b border-zinc-200 dark:border-zinc-800 overflow-y-auto shrink-0 max-h-[30%]"> - <div v-if="entries.length === 0 && !loading" class="text-center text-[11px] text-zinc-400 dark:text-zinc-500 py-6"> + <div class="overflow-y-auto shrink-0 max-h-[30%]" style="border-bottom: 1px solid var(--color-border)"> + <div v-if="entries.length === 0 && !loading" class="text-center text-[11px] py-6" style="color: var(--color-muted-foreground)"> No changes </div> - <div v-if="loading" class="text-center text-[11px] text-zinc-400 dark:text-zinc-500 py-6 animate-pulse"> + <div v-if="loading" class="text-center text-[11px] py-6 animate-pulse" style="color: var(--color-muted-foreground)"> Loading... </div> <button v-for="entry in entries" :key="entry.file" - class="w-full flex items-center gap-1.5 px-2 py-1.5 text-left cursor-pointer transition-colors" - :class="selectedFile === entry.file - ? 'bg-emerald-50 dark:bg-emerald-500/10 text-zinc-700 dark:text-zinc-200' - : 'text-zinc-500 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800'" + class="dv-file w-full flex items-center gap-1.5 px-2 py-1.5 text-left cursor-pointer transition-colors" + :class="{ active: selectedFile === entry.file }" @click="selectedFile = entry.file" > <span class="text-[9px] font-bold rounded px-1 py-px shrink-0" - :class="statusBadge(entry.status).cls" + :style="statusBadge(entry.status).style" > {{ statusBadge(entry.status).label }} </span> <span class="text-[11px] font-mono truncate">{{ entry.file.split('/').pop() }}</span> <span class="text-[9px] font-mono ml-auto shrink-0"> - <span class="text-emerald-600 dark:text-emerald-400">+{{ entry.additions }}</span> - <span class="text-red-500 dark:text-red-400 ml-0.5">-{{ entry.deletions }}</span> + <span style="color: var(--color-success-fg)">+{{ entry.additions }}</span> + <span class="ml-0.5" style="color: var(--color-error-fg)">-{{ entry.deletions }}</span> </span> </button> </div> <!-- Diff content --> <div class="flex-1 overflow-auto"> - <div v-if="!selectedEntry" class="text-center text-[11px] text-zinc-400 dark:text-zinc-500 py-8"> + <div v-if="!selectedEntry" class="text-center text-[11px] py-8" style="color: var(--color-muted-foreground)"> Select a file to view changes </div> <div v-else> - <div class="px-3 py-1.5 border-b border-zinc-200 dark:border-zinc-800 bg-zinc-100/50 dark:bg-zinc-800/50"> - <span class="text-[11px] font-mono text-zinc-600 dark:text-zinc-300">{{ selectedEntry.file }}</span> + <div class="px-3 py-1.5" style="border-bottom: 1px solid var(--color-border); background: var(--color-muted)"> + <span class="text-[11px] font-mono" style="color: var(--color-foreground)">{{ selectedEntry.file }}</span> <span class="text-[10px] font-mono ml-2"> - <span class="text-emerald-600 dark:text-emerald-400">+{{ selectedEntry.additions }}</span> - <span class="text-zinc-300 dark:text-zinc-600 mx-0.5">/</span> - <span class="text-red-500 dark:text-red-400">-{{ selectedEntry.deletions }}</span> + <span style="color: var(--color-success-fg)">+{{ selectedEntry.additions }}</span> + <span class="mx-0.5" style="color: var(--color-border)">/</span> + <span style="color: var(--color-error-fg)">-{{ selectedEntry.deletions }}</span> </span> </div> <div class="font-mono text-[11px] leading-5"> <div v-for="(line, i) in parsePatchLines(selectedEntry.patch)" :key="i" - class="px-3 border-l-2" - :class="{ - 'bg-emerald-50 dark:bg-emerald-500/10 border-emerald-400 dark:border-emerald-500/40 text-emerald-800 dark:text-emerald-300': line.type === 'add', - 'bg-red-50 dark:bg-red-500/10 border-red-400 dark:border-red-500/40 text-red-800 dark:text-red-300': line.type === 'del', - 'bg-blue-50 dark:bg-blue-500/10 border-blue-300 dark:border-blue-500/40 text-blue-600 dark:text-blue-300': line.type === 'hunk', - 'border-transparent text-zinc-500 dark:text-zinc-400': line.type === 'ctx', - }" + class="dv-line px-3 border-l-2" + :class="`dv-${line.type}`" > <pre class="whitespace-pre-wrap">{{ line.text }}</pre> </div> @@ -168,3 +159,57 @@ onMounted(fetchDiff) </div> </div> </template> + +<style scoped> +.dv-mode { + color: var(--color-muted-foreground); +} +.dv-mode:hover { + color: var(--color-foreground); + background: var(--color-muted); +} +.dv-mode.active { + background: var(--accent-wash); + color: var(--color-primary); +} + +.dv-mute { + color: var(--color-muted-foreground); +} +.dv-mute:hover { + color: var(--color-foreground); +} + +.dv-file { + color: var(--color-muted-foreground); +} +.dv-file:hover { + background: var(--color-muted); +} +.dv-file.active { + background: var(--accent-wash-soft); + color: var(--color-foreground); +} + +.dv-line { + border-color: transparent; +} +.dv-add { + background: var(--color-success-bg); + border-color: var(--color-success-fg); + color: var(--color-success-fg); +} +.dv-del { + background: var(--color-error-bg); + border-color: var(--color-error-fg); + color: var(--color-error-fg); +} +.dv-hunk { + background: var(--color-info-bg); + border-color: var(--color-info-fg); + color: var(--color-info-fg); +} +.dv-ctx { + color: var(--color-muted-foreground); +} +</style> diff --git a/web/src/components/FileTreePanel.vue b/web/src/components/FileTreePanel.vue index 00930f4..774a534 100644 --- a/web/src/components/FileTreePanel.vue +++ b/web/src/components/FileTreePanel.vue @@ -168,7 +168,7 @@ watch(() => props.rootPath, () => { height: 22px; border: none; background: transparent; - border-radius: 4px; + border-radius: var(--radius-sm); color: var(--color-muted-foreground); cursor: pointer; flex-shrink: 0; @@ -193,7 +193,7 @@ watch(() => props.rootPath, () => { background: transparent; border: none; padding: 2px 4px; - border-radius: 3px; + border-radius: var(--radius-xs); cursor: pointer; white-space: nowrap; } diff --git a/web/src/components/FileViewer.vue b/web/src/components/FileViewer.vue index 13415ac..14dcc38 100644 --- a/web/src/components/FileViewer.vue +++ b/web/src/components/FileViewer.vue @@ -25,19 +25,85 @@ function highlighted(): string { </script> <template> - <div class="fixed inset-0 bg-black/40 dark:bg-black/60 z-50 flex items-center justify-center p-8 backdrop-blur-sm animate-fade-in" @click.self="emit('close')"> - <div class="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded flex flex-col w-full max-w-5xl max-h-[85vh] overflow-hidden shadow-2xl"> - <div class="flex items-center justify-between px-4 py-2.5 border-b border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-800/80"> - <span class="font-mono text-xs text-zinc-500 dark:text-zinc-400 truncate">{{ path }}</span> - <button - class="text-xs text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300 cursor-pointer transition-colors font-medium px-2 py-0.5 rounded hover:bg-zinc-100 dark:hover:bg-zinc-700" - @click="emit('close')"> - Close - </button> + <div class="fv-overlay animate-fade-in" @click.self="emit('close')"> + <div class="fv-modal"> + <div class="fv-head"> + <span class="fv-path">{{ path }}</span> + <button class="fv-close" @click="emit('close')">Close</button> </div> - <div class="flex-1 overflow-auto p-4 bg-white dark:bg-zinc-900"> - <pre class="text-xs leading-relaxed"><code class="hljs" v-html="highlighted()" /></pre> + <div class="fv-body"> + <pre class="fv-pre"><code class="hljs" v-html="highlighted()" /></pre> </div> </div> </div> </template> + +<style scoped> +.fv-overlay { + position: fixed; + inset: 0; + z-index: var(--z-modal); + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + background: var(--backdrop); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); +} +.fv-modal { + display: flex; + flex-direction: column; + width: 100%; + max-width: 64rem; + max-height: 85vh; + overflow: hidden; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-lg); +} +.fv-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + border-bottom: 1px solid var(--color-border); + background: var(--color-muted); +} +.fv-path { + font-family: var(--font-mono); + font-size: 12px; + color: var(--color-muted-foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.fv-close { + flex-shrink: 0; + padding: 3px 8px; + font-size: 12px; + font-weight: 500; + color: var(--color-muted-foreground); + background: transparent; + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background 0.15s, color 0.15s; +} +.fv-close:hover { + color: var(--color-foreground); + background: var(--color-secondary); +} +.fv-body { + flex: 1; + overflow: auto; + padding: 18px; + background: var(--color-surface); +} +.fv-pre { + font-size: 12px; + line-height: 1.65; + margin: 0; +} +</style> diff --git a/web/src/components/GoalBanner.vue b/web/src/components/GoalBanner.vue index 4bb63e3..cc18c26 100644 --- a/web/src/components/GoalBanner.vue +++ b/web/src/components/GoalBanner.vue @@ -1,5 +1,6 @@ <script setup lang="ts"> import { computed } from 'vue' +import { Target } from 'lucide-vue-next' import { useChatStore } from '@/stores/chat' const store = useChatStore() @@ -26,14 +27,14 @@ const tokensLabel = computed(() => { </script> <template> - <!-- Active goal display. Goals are set from the prompt box (🎯 Goal toggle + <!-- Active goal display. Goals are set from the prompt box (Goal toggle in the input toolbar, or the /goal command). --> <div v-if="goal" class="mx-3 mt-2 rounded-md border px-3 py-2 flex items-start gap-2" :style="{ borderColor: 'var(--color-border)', backgroundColor: 'var(--color-secondary)' }" > - <span class="text-base leading-none" title="Session goal">🎯</span> + <Target :size="15" class="shrink-0 mt-0.5" :style="{ color: statusColor }" /> <div class="flex-1 min-w-0"> <div class="flex items-center gap-2"> <span class="text-[10px] uppercase tracking-wide font-semibold" :style="{ color: statusColor }"> diff --git a/web/src/components/MCPPanel.vue b/web/src/components/MCPPanel.vue index f2cfdd1..230fd77 100644 --- a/web/src/components/MCPPanel.vue +++ b/web/src/components/MCPPanel.vue @@ -1,5 +1,6 @@ <script setup lang="ts"> import { ref, onMounted } from 'vue' +import { Globe, Zap, RefreshCw } from 'lucide-vue-next' import { api } from '@/composables/api' import type { MCPServerInfo } from '@/types/api' @@ -29,69 +30,171 @@ async function toggleServer(name: string, enabled: boolean) { } } -function serverIcon(type: string) { - return type === 'sse' || type === 'http' ? '🌐' : '⚡' +function isNetworked(type: string) { + return type === 'sse' || type === 'http' } onMounted(fetchMCP) </script> <template> - <div class="flex flex-col h-full"> + <div class="mcp-panel"> <!-- Header --> - <div class="flex items-center justify-between px-4 py-3 border-b border-zinc-200 dark:border-zinc-800"> - <span class="text-sm font-medium text-zinc-700 dark:text-zinc-200">MCP Servers</span> - <button - class="text-[11px] text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300 cursor-pointer transition-colors font-medium" - @click="fetchMCP" - > - ↻ Refresh + <div class="mcp-head"> + <span class="mcp-title">MCP Servers</span> + <button class="mcp-refresh" @click="fetchMCP"> + <RefreshCw :size="13" /> Refresh </button> </div> <!-- Server list --> - <div class="flex-1 overflow-y-auto p-3 space-y-2"> - <div v-if="loading" class="text-center text-xs text-zinc-400 dark:text-zinc-500 py-6 animate-pulse"> - Loading... - </div> + <div class="mcp-list"> + <div v-if="loading" class="mcp-state animate-pulse">Loading…</div> - <div v-else-if="Object.keys(servers).length === 0" class="text-center py-8"> - <div class="text-zinc-400 dark:text-zinc-500 text-xs mb-1">No MCP servers configured</div> - <div class="text-[10px] text-zinc-400 dark:text-zinc-600"> + <div v-else-if="Object.keys(servers).length === 0" class="mcp-empty"> + <div class="mcp-empty-title">No MCP servers configured</div> + <div class="mcp-empty-hint"> Add servers to <span class="font-mono">~/.jcode/config.json</span> </div> </div> - <div - v-for="(info, name) in servers" - :key="name" - class="flex items-center gap-3 px-3 py-2.5 rounded-md border border-zinc-200 dark:border-zinc-700/60 bg-white dark:bg-zinc-800/60 hover:border-zinc-300 dark:hover:border-zinc-600 transition-colors" - > - <span class="text-base">{{ serverIcon(info.type) }}</span> + <div v-for="(info, name) in servers" :key="name" class="mcp-card"> + <component :is="isNetworked(info.type) ? Globe : Zap" :size="16" class="mcp-icon" /> <div class="flex-1 min-w-0"> - <div class="text-sm font-medium text-zinc-700 dark:text-zinc-200">{{ name }}</div> - <div class="text-[10px] text-zinc-400 dark:text-zinc-500 font-mono truncate"> - {{ info.type === 'sse' || info.type === 'http' ? info.url : info.command }} + <div class="mcp-name">{{ name }}</div> + <div class="mcp-detail"> + {{ isNetworked(info.type) ? info.url : info.command }} </div> </div> <div class="flex items-center gap-2 shrink-0"> - <span - class="text-[10px] px-1.5 py-0.5 rounded-full" - :class="info.status === 'configured' - ? 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-400' - : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400'" - > + <span class="mcp-badge" :class="info.status === 'configured' ? 'ok' : 'muted'"> {{ info.status }} </span> - <button - class="w-8 h-4 rounded-full relative cursor-pointer transition-colors bg-emerald-500" - @click="toggleServer(String(name), false)" - title="Disable server" - > - <span class="absolute top-0.5 right-0.5 w-3 h-3 rounded-full bg-white shadow transition-transform" /> + <button class="mcp-toggle" @click="toggleServer(String(name), false)" title="Disable server"> + <span class="mcp-knob" /> </button> </div> </div> </div> </div> </template> + +<style scoped> +.mcp-panel { + display: flex; + flex-direction: column; + height: 100%; +} +.mcp-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--color-border); +} +.mcp-title { + font-size: 13px; + font-weight: 600; + color: var(--color-foreground); +} +.mcp-refresh { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + font-weight: 500; + color: var(--color-muted-foreground); + background: transparent; + border: none; + cursor: pointer; + transition: color 0.15s; +} +.mcp-refresh:hover { + color: var(--color-foreground); +} +.mcp-list { + flex: 1; + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} +.mcp-state, +.mcp-empty { + text-align: center; + font-size: 12px; + color: var(--color-muted-foreground); + padding: 24px 0; +} +.mcp-empty-title { + margin-bottom: 4px; +} +.mcp-empty-hint { + font-size: 10px; + color: var(--color-text-muted, var(--color-muted-foreground)); +} +.mcp-card { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); + background: var(--color-surface); + transition: border-color 0.15s; +} +.mcp-card:hover { + border-color: color-mix(in srgb, var(--color-foreground) 24%, transparent); +} +.mcp-icon { + color: var(--color-muted-foreground); + flex-shrink: 0; +} +.mcp-name { + font-size: 13px; + font-weight: 500; + color: var(--color-foreground); +} +.mcp-detail { + font-size: 10px; + font-family: var(--font-mono); + color: var(--color-muted-foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.mcp-badge { + font-size: 10px; + padding: 2px 7px; + border-radius: var(--radius-pill); +} +.mcp-badge.ok { + background: var(--color-success-bg); + color: var(--color-success-fg); +} +.mcp-badge.muted { + background: var(--color-muted); + color: var(--color-muted-foreground); +} +.mcp-toggle { + width: 32px; + height: 18px; + border-radius: var(--radius-pill); + position: relative; + cursor: pointer; + border: none; + background: var(--color-success); + transition: background 0.15s; +} +.mcp-knob { + position: absolute; + top: 2px; + right: 2px; + width: 14px; + height: 14px; + border-radius: 50%; + background: #fff; + box-shadow: var(--shadow-sm); +} +</style> diff --git a/web/src/components/ProjectSwitcher.vue b/web/src/components/ProjectSwitcher.vue index 33c1f81..daa7f55 100644 --- a/web/src/components/ProjectSwitcher.vue +++ b/web/src/components/ProjectSwitcher.vue @@ -1,8 +1,8 @@ <script setup lang="ts"> -import { ref, watch, computed } from 'vue' -import { useProjectStore } from '@/stores/project' -import { api } from '@/composables/api' -import type { BrowseFolder } from '@/types/api' +import { watch, computed, inject } from 'vue' +import { useProjectStore, parseRemoteLabel } from '@/stores/project' +import { useFolderBrowser } from '@/composables/useFolderBrowser' +import type { RemoteMeta } from '@/types/api' import { Dialog, DialogPanel, @@ -10,6 +10,7 @@ import { TransitionRoot, TransitionChild, } from '@headlessui/vue' +import { Folder } from 'lucide-vue-next' const props = defineProps<{ open: boolean @@ -21,69 +22,28 @@ const emit = defineEmits<{ }>() const projectStore = useProjectStore() -const showBrowser = ref(false) -const browsePath = ref('') -const browseFolders = ref<BrowseFolder[]>([]) -const browseLoading = ref(false) +const openRemoteConnect = inject<(prefill?: RemoteMeta) => void>('openRemoteConnect') -const pathInput = ref('') +const { + showBrowser, + browsePath, + browseFolders, + browseLoading, + pathInput, + loadFolders, + openBrowser, + goUp, + handlePathSubmit, + resetBrowser, +} = useFolderBrowser() const displayPath = computed(() => browsePath.value || '~') +// Reset the folder browser each time the dialog opens. watch(() => props.open, (isOpen) => { - if (isOpen) { - showBrowser.value = false - browsePath.value = '' - pathInput.value = '' - browseFolders.value = [] - } + if (isOpen) resetBrowser() }) -async function loadFolders(path?: string) { - browseLoading.value = true - try { - const result = await api.browse(path) - browsePath.value = result.current - pathInput.value = result.current - browseFolders.value = result.folders - } catch (err: unknown) { - console.error('Browse failed:', err) - browseFolders.value = [] - } finally { - browseLoading.value = false - } -} - -function openBrowser() { - showBrowser.value = true - loadFolders() -} - -function navigateTo(folder: BrowseFolder) { - loadFolders(folder.path) -} - -function goUp() { - if (!browsePath.value) return - const parts = browsePath.value.split('/') - parts.pop() - const parent = parts.join('/') || '/' - loadFolders(parent) -} - -function handlePathSubmit() { - const path = pathInput.value.trim() - if (path) { - loadFolders(path) - } -} - -function handlePathKeyDown(e: KeyboardEvent) { - if (e.key === 'Enter') { - handlePathSubmit() - } -} - function selectCurrentPath() { if (!browsePath.value) return projectStore.openProject(browsePath.value).then((ok) => { @@ -96,6 +56,13 @@ function selectCurrentPath() { } async function selectProject(id: string) { + const project = projectStore.projects.find((p) => p.id === id) + // Remote workspaces are reconnected through the SSH wizard, not a local switch. + if (project?.remote) { + emit('close') + openRemoteConnect?.(parseRemoteLabel(project.path) ?? undefined) + return + } const ok = await projectStore.switchToProject(id) if (ok) { emit('close') @@ -103,6 +70,11 @@ async function selectProject(id: string) { } } +function openRemote() { + emit('close') + openRemoteConnect?.() +} + function deleteProject(id: string) { projectStore.removeProject(id) } @@ -119,7 +91,7 @@ function deleteProject(id: string) { leave-from="opacity-100" leave-to="opacity-0" > - <div class="fixed inset-0 bg-black/40 dark:bg-black/60 backdrop-blur-sm" /> + <div class="fixed inset-0" style="background: var(--backdrop); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px)" /> </TransitionChild> <div class="fixed inset-0 flex items-start justify-center pt-16 px-4"> @@ -131,10 +103,10 @@ function deleteProject(id: string) { leave-from="opacity-100 translate-y-0" leave-to="opacity-0 translate-y-2" > - <DialogPanel class="w-full max-w-lg bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded shadow-2xl overflow-hidden"> + <DialogPanel class="w-full max-w-lg overflow-hidden" style="background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-xl); box-shadow: var(--shadow-lg)"> <!-- Header --> <div class="px-5 pt-4 pb-2"> - <DialogTitle class="text-sm font-semibold text-zinc-800 dark:text-zinc-100" style="font-family: var(--font-sans)"> + <DialogTitle class="text-sm font-semibold" style="font-family: var(--font-sans); color: var(--color-foreground)"> {{ showBrowser ? 'Open Folder' : 'Projects' }} </DialogTitle> </div> @@ -145,58 +117,62 @@ function deleteProject(id: string) { <input v-model="pathInput" type="text" - class="flex-1 px-3 py-1.5 text-sm font-mono rounded-md border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-200 outline-none focus:border-emerald-400 dark:focus:border-emerald-500/60 transition-colors" + class="ps-input flex-1 px-3 py-1.5 text-sm font-mono rounded-md outline-none" placeholder="/path/to/folder" - @keydown="handlePathKeyDown" + @keydown.enter="handlePathSubmit" /> <button - class="px-3 py-1.5 text-xs font-medium bg-emerald-500 hover:bg-emerald-600 text-white rounded-md cursor-pointer transition-colors" + class="px-3 py-1.5 text-xs font-medium rounded-md cursor-pointer transition-opacity hover:opacity-90" + style="background: var(--color-primary); color: var(--color-on-primary, #fff)" @click="handlePathSubmit" > OK </button> <button - class="px-2 py-1.5 text-xs text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300 cursor-pointer transition-colors" + class="ps-muted-btn px-2 py-1.5 text-xs cursor-pointer transition-colors" @click="showBrowser = false" > Back </button> </div> - <div class="border border-zinc-200 dark:border-zinc-700 rounded-md overflow-hidden max-h-80 overflow-y-auto bg-zinc-50 dark:bg-zinc-800/60"> + <div class="rounded-md overflow-hidden max-h-80 overflow-y-auto" style="border: 1px solid var(--color-border); background: var(--color-background)"> <button v-if="browsePath && browsePath !== '/'" - class="w-full flex items-center gap-2 px-3 py-2 text-sm text-zinc-500 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700 cursor-pointer transition-colors border-b border-zinc-100 dark:border-zinc-700/60" + class="ps-folder w-full flex items-center gap-2 px-3 py-2 text-sm cursor-pointer transition-colors" + style="color: var(--color-muted-foreground); border-bottom: 1px solid var(--color-border)" @click="goUp" > - <span class="text-zinc-400 dark:text-zinc-500">..</span> + <span style="color: var(--color-muted-foreground)">..</span> </button> - <div v-if="browseLoading" class="px-3 py-6 text-center text-xs text-zinc-400 dark:text-zinc-500 animate-pulse"> + <div v-if="browseLoading" class="px-3 py-6 text-center text-xs animate-pulse" style="color: var(--color-muted-foreground)"> Loading... </div> - <div v-else-if="browseFolders.length === 0" class="px-3 py-6 text-center text-xs text-zinc-400 dark:text-zinc-500"> + <div v-else-if="browseFolders.length === 0" class="px-3 py-6 text-center text-xs" style="color: var(--color-muted-foreground)"> No folders found </div> <button v-for="folder in browseFolders" :key="folder.path" - class="w-full flex items-center gap-2 px-3 py-2 text-sm text-left text-zinc-700 dark:text-zinc-300 hover:bg-emerald-50 dark:hover:bg-emerald-500/10 hover:text-emerald-700 dark:hover:text-emerald-400 cursor-pointer transition-colors border-b border-zinc-100 dark:border-zinc-700/60 last:border-0" - @click="navigateTo(folder)" + class="ps-folder w-full flex items-center gap-2 px-3 py-2 text-sm text-left cursor-pointer transition-colors" + style="color: var(--color-foreground); border-bottom: 1px solid var(--color-border)" + @click="loadFolders(folder.path)" > - <span class="text-zinc-400 dark:text-zinc-500 shrink-0">📁</span> + <Folder :size="14" class="shrink-0" style="color: var(--color-muted-foreground)" /> <span class="truncate">{{ folder.name }}</span> </button> </div> <div class="mt-3 flex items-center justify-between"> - <div class="text-[11px] text-zinc-400 dark:text-zinc-500 font-mono truncate flex-1 mr-2"> + <div class="text-[11px] font-mono truncate flex-1 mr-2" style="color: var(--color-muted-foreground)"> {{ displayPath }} </div> <button - class="px-4 py-1.5 text-xs font-medium bg-emerald-500 hover:bg-emerald-600 text-white rounded-md cursor-pointer transition-colors shrink-0" + class="px-4 py-1.5 text-xs font-medium rounded-md cursor-pointer transition-opacity hover:opacity-90 shrink-0" + style="background: var(--color-primary); color: var(--color-on-primary, #fff)" @click="selectCurrentPath" > Open Folder @@ -207,42 +183,40 @@ function deleteProject(id: string) { <!-- Project list mode --> <div v-else> <div v-if="projectStore.switchError" class="px-5 py-2"> - <div class="text-xs text-red-500 dark:text-red-400 bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 rounded-md px-3 py-2"> + <div class="text-xs rounded-md px-3 py-2" style="color: var(--color-error-fg); background: var(--color-error-bg); border: 1px solid var(--color-error-fg)"> {{ projectStore.switchError }} </div> </div> - <div v-if="projectStore.switching" class="px-3 py-6 text-center text-xs text-zinc-400 dark:text-zinc-500 animate-pulse"> + <div v-if="projectStore.switching" class="px-3 py-6 text-center text-xs animate-pulse" style="color: var(--color-muted-foreground)"> Switching project... </div> <div v-else class="px-3 pb-2 max-h-72 overflow-y-auto"> - <div v-if="projectStore.projects.length === 0" class="text-xs text-zinc-400 dark:text-zinc-500 py-6 text-center"> + <div v-if="projectStore.projects.length === 0" class="text-xs py-6 text-center" style="color: var(--color-muted-foreground)"> No projects yet </div> <div v-for="p in projectStore.projects" :key="p.id" - class="group flex items-center gap-2 px-3 py-2.5 rounded-md cursor-pointer transition-colors" - :class="projectStore.activeId === p.id - ? 'bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20' - : 'hover:bg-zinc-50 dark:hover:bg-zinc-800 border border-transparent'" + class="ps-project group flex items-center gap-2 px-3 py-2.5 rounded-md cursor-pointer transition-colors" + :class="{ active: projectStore.activeId === p.id }" @click="selectProject(p.id)" > <div class="w-7 h-7 rounded-md flex items-center justify-center text-xs font-bold shrink-0" - :class="projectStore.activeId === p.id - ? 'bg-emerald-100 dark:bg-emerald-500/20 text-emerald-700 dark:text-emerald-400' - : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400'" + :style="projectStore.activeId === p.id + ? { background: 'var(--accent-wash-strong)', color: 'var(--color-primary)' } + : { background: 'var(--color-muted)', color: 'var(--color-muted-foreground)' }" > {{ projectStore.projectName(p).charAt(0).toUpperCase() }} </div> <div class="min-w-0 flex-1"> - <div class="text-sm text-zinc-700 dark:text-zinc-200 truncate">{{ projectStore.projectName(p) }}</div> - <div class="text-[10px] text-zinc-400 dark:text-zinc-500 font-mono truncate">{{ p.path }}</div> + <div class="text-sm truncate" style="color: var(--color-foreground)">{{ projectStore.projectName(p) }}</div> + <div class="text-[10px] font-mono truncate" style="color: var(--color-muted-foreground)">{{ p.path }}</div> </div> <button - class="opacity-0 group-hover:opacity-100 p-1 text-zinc-400 dark:text-zinc-500 hover:text-red-500 dark:hover:text-red-400 cursor-pointer transition-all" + class="ps-delete opacity-0 group-hover:opacity-100 p-1 cursor-pointer transition-all" @click.stop="deleteProject(p.id)" title="Remove project" > @@ -253,15 +227,25 @@ function deleteProject(id: string) { </div> </div> - <div class="px-5 py-3 border-t border-zinc-100 dark:border-zinc-800 flex justify-between items-center"> - <button - class="text-xs text-emerald-600 dark:text-emerald-400 hover:text-emerald-700 dark:hover:text-emerald-300 cursor-pointer transition-colors font-medium" - @click="openBrowser" - > - + Open Folder - </button> + <div class="px-5 py-3 flex justify-between items-center" style="border-top: 1px solid var(--color-border)"> + <div class="flex items-center gap-4"> + <button + class="text-xs cursor-pointer transition-opacity hover:opacity-80 font-medium" + style="color: var(--color-primary)" + @click="openBrowser" + > + + Open Folder + </button> + <button + class="text-xs cursor-pointer transition-opacity hover:opacity-80 font-medium" + style="color: var(--color-primary)" + @click="openRemote" + > + Remote connect + </button> + </div> <button - class="px-3 py-1 text-xs text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 cursor-pointer transition-colors" + class="ps-muted-btn px-3 py-1 text-xs cursor-pointer transition-colors" @click="emit('close')" > Close @@ -274,3 +258,51 @@ function deleteProject(id: string) { </Dialog> </TransitionRoot> </template> + +<style scoped> +.ps-input { + background: var(--color-background); + border: 1px solid var(--color-border); + color: var(--color-foreground); + transition: border-color 0.15s; +} +.ps-input::placeholder { + color: var(--color-muted-foreground); +} +.ps-input:focus { + border-color: var(--color-primary); +} + +.ps-muted-btn { + color: var(--color-muted-foreground); +} +.ps-muted-btn:hover { + color: var(--color-foreground); +} + +.ps-folder:last-child { + border-bottom: none !important; +} +.ps-folder:hover { + background: var(--accent-wash-soft); + color: var(--color-primary) !important; +} + +.ps-project { + border: 1px solid transparent; +} +.ps-project:hover { + background: var(--color-muted); +} +.ps-project.active { + background: var(--accent-wash-soft); + border-color: var(--accent-border); +} + +.ps-delete { + color: var(--color-muted-foreground); +} +.ps-delete:hover { + color: var(--color-destructive); +} +</style> diff --git a/web/src/components/RemoteConnectWizard.vue b/web/src/components/RemoteConnectWizard.vue new file mode 100644 index 0000000..4c302ac --- /dev/null +++ b/web/src/components/RemoteConnectWizard.vue @@ -0,0 +1,828 @@ +<script setup lang="ts"> +import { ref, reactive, watch, computed } from 'vue' +import { + Dialog, + DialogPanel, + TransitionRoot, + TransitionChild, +} from '@headlessui/vue' +import { + Server, + Container, + X, + ArrowLeft, + Folder, + Loader2, + Check, + ChevronRight, +} from 'lucide-vue-next' +import { useChatStore } from '@/stores/chat' +import { useProjectStore } from '@/stores/project' +import { api } from '@/composables/api' +import type { RemoteMeta, SSHAlias, RemoteAuthMethod } from '@/types/api' + +type Prefill = RemoteMeta & { loadTaskUuid?: string } + +const props = defineProps<{ + open: boolean + prefill?: Prefill | null +}>() + +const emit = defineEmits<{ + close: [] + bound: [] +}>() + +const store = useChatStore() +const projectStore = useProjectStore() + +type Step = 'method' | 'config' | 'connecting' | 'dir' +const step = ref<Step>('method') +const method = ref<'ssh' | 'docker'>('ssh') + +const form = reactive({ + host: '', + port: 22, + user: 'root', + authMethod: 'key' as RemoteAuthMethod, + password: '', + keyPath: '~/.ssh/id_rsa', + passphrase: '', +}) + +const aliases = ref<SSHAlias[]>([]) +const selectedAlias = ref('') + +const error = ref('') +const connectionId = ref('') +const bound = ref(false) + +// Directory picker state. +const currentDir = ref('') +const dirs = ref<string[]>([]) +const dirLoading = ref(false) + +// Save-as-alias. +const saveAlias = ref(false) +const aliasName = ref('') + +const steps: { key: Step; label: string }[] = [ + { key: 'method', label: 'Choose method' }, + { key: 'config', label: 'Configure' }, + { key: 'connecting', label: 'Connecting' }, + { key: 'dir', label: 'Select directory' }, +] +const stepIndex = computed(() => steps.findIndex((s) => s.key === step.value)) + +watch(() => props.open, (isOpen) => { + if (!isOpen) return + resetState() + void loadAliases() + if (props.prefill) { + // A prefill that carries a known remote path is a *reconnect* (reopening a + // remote workspace/task that was connected before). Since we never persist + // the SSH secret, the link must be re-established — but for key/agent auth + // (the common case) we can do it silently instead of making the user re-fill + // the form. Fall back to the form only if the key isn't accepted. + if (props.prefill.remotePath) { + void autoReconnect(props.prefill) + } else { + applyPrefill(props.prefill) + } + } +}) + +function resetState() { + step.value = 'method' + method.value = 'ssh' + form.host = '' + form.port = 22 + form.user = 'root' + form.authMethod = 'key' + form.password = '' + form.keyPath = '~/.ssh/id_rsa' + form.passphrase = '' + selectedAlias.value = '' + error.value = '' + connectionId.value = '' + bound.value = false + currentDir.value = '' + dirs.value = [] + saveAlias.value = false + aliasName.value = '' +} + +function applyPrefill(p: Prefill) { + // host may carry a port (e.g. "1.2.3.4:22"); split it out for the form. + const colon = p.host.lastIndexOf(':') + form.host = colon >= 0 ? p.host.slice(0, colon) : p.host + form.port = p.port || 22 + form.user = p.user || 'root' + // Jump straight to the config step for a reconnect. + step.value = 'config' +} + +// Seamless reconnect: try key/agent auth to the known remote path and bind +// straight to it (loading the task if one was requested), so reopening a remote +// workspace doesn't make the user walk the wizard again. Any failure (password +// auth, non-default key, passphrase, missing path) drops to the prefilled form. +async function autoReconnect(p: Prefill) { + applyPrefill(p) + step.value = 'connecting' + try { + const res = await api.remoteConnect({ + type: 'ssh', + host: form.host.trim(), + port: form.port || 22, + user: form.user.trim() || 'root', + auth_method: 'key', + key_path: form.keyPath.trim(), + }) + connectionId.value = res.connection_id + currentDir.value = p.remotePath && p.remotePath !== '/' ? p.remotePath : res.remote_pwd + await bindHere() + // bindHere closes on success; if it failed, let the user pick a directory. + if (!bound.value) { + if (connectionId.value) { + await listDir(currentDir.value) + step.value = 'dir' + } else { + step.value = 'config' + } + } + } catch { + // Key/agent auth not accepted — fall back to the prefilled form (no scary + // error; the user just chooses auth + connects manually). + error.value = '' + step.value = 'config' + } +} + +async function loadAliases() { + try { + const res = await api.sshList() + aliases.value = res.aliases || [] + } catch { + aliases.value = [] + } +} + +function applyAlias(name: string) { + selectedAlias.value = name + const a = aliases.value.find((x) => x.name === name) + if (!a) return + // addr is "user@host" (host may include :port). + const at = a.addr.indexOf('@') + const user = at >= 0 ? a.addr.slice(0, at) : '' + let host = at >= 0 ? a.addr.slice(at + 1) : a.addr + const colon = host.lastIndexOf(':') + if (colon >= 0) { + form.port = parseInt(host.slice(colon + 1), 10) || 22 + host = host.slice(0, colon) + } + if (user) form.user = user + form.host = host +} + +function chooseMethod(m: 'ssh' | 'docker') { + if (m === 'docker') return // disabled placeholder + method.value = m +} + +// discardConnection releases an established-but-unbound SSH connection so it +// doesn't linger in the backend's pending registry (up to its TTL). +async function discardConnection() { + if (connectionId.value && !bound.value) { + try { await api.remoteCancel(connectionId.value) } catch { /* ignore */ } + } + connectionId.value = '' +} + +function backToConfig() { + void discardConnection() + step.value = 'config' +} + +async function connect() { + if (!form.host.trim()) { + error.value = 'Host is required' + return + } + // Drop any prior pending connection (e.g. user went back and reconnected). + await discardConnection() + error.value = '' + step.value = 'connecting' + try { + const res = await api.remoteConnect({ + type: 'ssh', + host: form.host.trim(), + port: form.port || 22, + user: form.user.trim() || 'root', + auth_method: form.authMethod, + password: form.authMethod === 'password' ? form.password : undefined, + key_path: form.authMethod === 'key' ? form.keyPath.trim() : undefined, + passphrase: form.authMethod === 'key' ? form.passphrase : undefined, + }) + connectionId.value = res.connection_id + await listDir(res.remote_pwd) + step.value = 'dir' + } catch (e: unknown) { + error.value = e instanceof Error ? e.message : 'Connection failed' + step.value = 'config' + } +} + +async function listDir(path: string) { + if (!connectionId.value) return + dirLoading.value = true + error.value = '' + try { + const res = await api.remoteListDir(connectionId.value, path) + currentDir.value = res.path + dirs.value = res.dirs + } catch (e: unknown) { + error.value = e instanceof Error ? e.message : 'Failed to list directory' + } finally { + dirLoading.value = false + } +} + +function navigate(name: string) { + if (name === '..') { + const parts = currentDir.value.split('/') + parts.pop() + listDir(parts.join('/') || '/') + return + } + const base = currentDir.value.endsWith('/') ? currentDir.value : currentDir.value + '/' + listDir(base + name) +} + +const binding = ref(false) +async function bindHere() { + if (!connectionId.value || binding.value) return + binding.value = true + error.value = '' + try { + const res = await api.remoteBind(connectionId.value, currentDir.value) + const proj = projectStore.upsertRemoteProject(res.label, { + host: res.host, + user: res.user, + port: res.port, + remotePath: res.remote_path, + }) + projectStore.setActive(proj.id) + bound.value = true + connectionId.value = '' // ownership transferred; do not cancel on close + + if (saveAlias.value && aliasName.value.trim()) { + const addr = `${res.user}@${res.host}` + try { + await api.remoteSaveAlias(aliasName.value.trim(), addr, res.remote_path) + } catch (e: unknown) { + console.error('Failed to save SSH alias:', e) + } + } + + await store.resetToWelcomeAfterSwitch() + if (props.prefill?.loadTaskUuid) { + await store.loadSession(props.prefill.loadTaskUuid) + } + emit('bound') + emit('close') + } catch (e: unknown) { + error.value = e instanceof Error ? e.message : 'Failed to bind workspace' + } finally { + binding.value = false + } +} + +function close() { + void discardConnection() + emit('close') +} +</script> + +<template> + <TransitionRoot :show="open" as="template"> + <Dialog class="relative z-50" @close="close"> + <TransitionChild + enter="ease-out duration-150" enter-from="opacity-0" enter-to="opacity-100" + leave="ease-in duration-100" leave-from="opacity-100" leave-to="opacity-0" + > + <div class="fixed inset-0" style="background: var(--backdrop); backdrop-filter: blur(6px)" /> + </TransitionChild> + + <div class="fixed inset-0 flex items-center justify-center p-4"> + <TransitionChild + enter="ease-out duration-150" enter-from="opacity-0 translate-y-2" enter-to="opacity-100 translate-y-0" + leave="ease-in duration-100" leave-from="opacity-100 translate-y-0" leave-to="opacity-0 translate-y-2" + > + <DialogPanel class="rcw"> + <!-- Step rail --> + <div class="rcw-rail"> + <div class="rcw-rail-title">Remote connect</div> + <ol class="rcw-steps"> + <li + v-for="(s, i) in steps" + :key="s.key" + class="rcw-step" + :class="{ done: i < stepIndex, current: i === stepIndex }" + > + <span class="rcw-step-dot"> + <Check v-if="i < stepIndex" :size="13" /> + <span v-else>{{ i + 1 }}</span> + </span> + <span class="rcw-step-label">{{ s.label }}</span> + </li> + </ol> + </div> + + <!-- Content --> + <div class="rcw-body"> + <button class="rcw-close" @click="close"><X :size="18" /></button> + + <!-- Step 1: method --> + <template v-if="step === 'method'"> + <h3 class="rcw-h">Choose a connection method</h3> + <p class="rcw-sub">Pick how to enter the remote workspace, then fill in the details.</p> + <div class="rcw-methods"> + <button class="rcw-method" :class="{ active: method === 'ssh' }" @click="chooseMethod('ssh')"> + <Server :size="22" /> + <span class="rcw-method-name">SSH</span> + <span class="rcw-method-desc">Remote host</span> + </button> + <button class="rcw-method disabled" disabled title="Coming soon"> + <Container :size="22" /> + <span class="rcw-method-name">Docker</span> + <span class="rcw-method-desc">Coming soon</span> + </button> + </div> + <div class="rcw-foot"> + <span /> + <button class="rcw-primary" @click="step = 'config'">Next <ChevronRight :size="15" /></button> + </div> + </template> + + <!-- Step 2: config --> + <template v-else-if="step === 'config' || step === 'connecting'"> + <h3 class="rcw-h">SSH connection</h3> + <p class="rcw-sub">Key/agent auth uses your local SSH keys; password & passphrase are supported.</p> + + <div v-if="error" class="rcw-error">{{ error }}</div> + + <div v-if="aliases.length > 0" class="rcw-field"> + <label>Saved alias (optional)</label> + <select :value="selectedAlias" class="rcw-input" @change="applyAlias(($event.target as HTMLSelectElement).value)"> + <option value="">Don't use an alias</option> + <option v-for="a in aliases" :key="a.name" :value="a.name">{{ a.name }} — {{ a.addr }}</option> + </select> + </div> + + <div class="rcw-row"> + <div class="rcw-field grow"> + <label>Host</label> + <input v-model="form.host" class="rcw-input" placeholder="1.2.3.4 or example.com" :disabled="step === 'connecting'" /> + </div> + <div class="rcw-field port"> + <label>Port</label> + <input v-model.number="form.port" type="number" class="rcw-input" :disabled="step === 'connecting'" /> + </div> + </div> + + <div class="rcw-row"> + <div class="rcw-field grow"> + <label>User</label> + <input v-model="form.user" class="rcw-input" placeholder="root" :disabled="step === 'connecting'" /> + </div> + <div class="rcw-field"> + <label>Auth</label> + <div class="rcw-seg"> + <button :class="{ on: form.authMethod === 'password' }" :disabled="step === 'connecting'" @click="form.authMethod = 'password'">Password</button> + <button :class="{ on: form.authMethod === 'key' }" :disabled="step === 'connecting'" @click="form.authMethod = 'key'">Key</button> + </div> + </div> + </div> + + <template v-if="form.authMethod === 'password'"> + <div class="rcw-field"> + <label>Password</label> + <input v-model="form.password" type="password" class="rcw-input" :disabled="step === 'connecting'" /> + </div> + </template> + <template v-else> + <div class="rcw-field"> + <label>Private key path</label> + <input v-model="form.keyPath" class="rcw-input mono" placeholder="~/.ssh/id_rsa" :disabled="step === 'connecting'" /> + </div> + <div class="rcw-field"> + <label>Passphrase (optional)</label> + <input v-model="form.passphrase" type="password" class="rcw-input" :disabled="step === 'connecting'" /> + </div> + </template> + + <div class="rcw-foot"> + <button class="rcw-ghost" :disabled="step === 'connecting'" @click="step = 'method'">Back</button> + <button class="rcw-primary" :disabled="step === 'connecting'" @click="connect"> + <Loader2 v-if="step === 'connecting'" :size="15" class="spin" /> + {{ step === 'connecting' ? 'Connecting…' : 'Connect' }} + </button> + </div> + </template> + + <!-- Step 4: directory --> + <template v-else-if="step === 'dir'"> + <h3 class="rcw-h">Select a directory</h3> + <p class="rcw-sub">Choose the working directory for this remote workspace.</p> + + <div v-if="error" class="rcw-error">{{ error }}</div> + + <div class="rcw-dirbar"> + <button class="rcw-back" title="Back to config" @click="backToConfig"><ArrowLeft :size="14" /></button> + <span class="rcw-dir-path">{{ currentDir || '/' }}</span> + </div> + + <div class="rcw-dirlist"> + <div v-if="dirLoading" class="rcw-hint"><Loader2 :size="15" class="spin" /> Loading…</div> + <template v-else> + <button + v-for="d in dirs" + :key="d" + class="rcw-dir-row" + @click="navigate(d)" + > + <Folder :size="14" /> + <span>{{ d }}</span> + </button> + <div v-if="dirs.length === 0" class="rcw-hint">No sub-directories</div> + </template> + </div> + + <label class="rcw-save"> + <input v-model="saveAlias" type="checkbox" /> + <span>Save as alias</span> + <input v-if="saveAlias" v-model="aliasName" class="rcw-input mini" placeholder="name" /> + </label> + + <div class="rcw-foot"> + <button class="rcw-ghost" :disabled="binding" @click="close">Cancel</button> + <button class="rcw-primary" :disabled="binding" @click="bindHere"> + <Loader2 v-if="binding" :size="15" class="spin" /> + Use this directory + </button> + </div> + </template> + </div> + </DialogPanel> + </TransitionChild> + </div> + </Dialog> + </TransitionRoot> +</template> + +<style scoped> +.rcw { + display: flex; + width: 100%; + max-width: 760px; + height: 520px; + max-height: 86vh; + overflow: hidden; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-xl, 16px); + box-shadow: var(--shadow-lg); +} + +/* Rail */ +.rcw-rail { + width: 220px; + flex-shrink: 0; + padding: 22px 18px; + background: var(--color-background); + border-right: 1px solid var(--color-border); +} +.rcw-rail-title { + font-size: 12px; + font-weight: 600; + color: var(--color-muted-foreground); + margin-bottom: 22px; +} +.rcw-steps { + display: flex; + flex-direction: column; + gap: 6px; + list-style: none; + padding: 0; + margin: 0; +} +.rcw-step { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 10px; + border-radius: var(--radius-lg); + color: var(--color-muted-foreground); +} +.rcw-step.current { + background: var(--color-surface); + color: var(--color-foreground); +} +.rcw-step-dot { + display: grid; + place-items: center; + width: 24px; + height: 24px; + flex-shrink: 0; + border-radius: 50%; + border: 1px solid var(--color-border); + font-size: 12px; + font-weight: 600; +} +.rcw-step.current .rcw-step-dot { + background: var(--color-foreground); + color: var(--color-background); + border-color: var(--color-foreground); +} +.rcw-step.done .rcw-step-dot { + background: var(--color-success); + border-color: var(--color-success); + color: #fff; +} +.rcw-step-label { + font-size: 13px; +} + +/* Body */ +.rcw-body { + flex: 1; + min-width: 0; + position: relative; + padding: 24px 26px; + overflow-y: auto; +} +.rcw-close { + position: absolute; + top: 18px; + right: 18px; + border: none; + background: transparent; + color: var(--color-muted-foreground); + cursor: pointer; +} +.rcw-close:hover { + color: var(--color-foreground); +} +.rcw-h { + font-size: 18px; + font-weight: 600; + color: var(--color-foreground); +} +.rcw-sub { + margin-top: 6px; + margin-bottom: 18px; + font-size: 12.5px; + color: var(--color-muted-foreground); +} +.rcw-error { + margin-bottom: 14px; + padding: 8px 12px; + font-size: 12px; + border-radius: var(--radius-lg); + color: var(--color-error-fg, #b91c1c); + background: var(--color-error-bg, rgba(220, 38, 38, 0.08)); + border: 1px solid var(--color-error-fg, rgba(220, 38, 38, 0.3)); + word-break: break-word; +} + +/* Methods */ +.rcw-methods { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} +.rcw-method { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; + padding: 20px; + border: 1px solid var(--color-border); + border-radius: var(--radius-xl); + background: var(--color-background); + cursor: pointer; + color: var(--color-foreground); + transition: border-color 0.15s, background 0.15s; +} +.rcw-method:hover:not(.disabled) { + border-color: color-mix(in srgb, var(--color-foreground) 30%, transparent); +} +.rcw-method.active { + border-color: var(--color-foreground); + background: var(--color-surface); +} +.rcw-method.disabled { + opacity: 0.5; + cursor: not-allowed; +} +.rcw-method-name { + font-size: 15px; + font-weight: 600; +} +.rcw-method-desc { + font-size: 12px; + color: var(--color-muted-foreground); +} + +/* Form */ +.rcw-row { + display: flex; + gap: 12px; +} +.rcw-field { + display: flex; + flex-direction: column; + gap: 5px; + margin-bottom: 14px; +} +.rcw-field.grow { + flex: 1; + min-width: 0; +} +.rcw-field.port { + width: 96px; + flex-shrink: 0; +} +.rcw-field label { + font-size: 12px; + color: var(--color-muted-foreground); +} +.rcw-input { + width: 100%; + padding: 9px 11px; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-background); + color: var(--color-foreground); + font-size: 13px; + outline: none; + transition: border-color 0.15s; +} +.rcw-input:focus { + border-color: var(--color-primary); +} +.rcw-input.mono { + font-family: var(--font-mono); + font-size: 12px; +} +.rcw-input.mini { + width: 120px; + padding: 5px 9px; + font-size: 12px; +} +.rcw-seg { + display: flex; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + overflow: hidden; +} +.rcw-seg button { + padding: 8px 14px; + border: none; + background: var(--color-background); + color: var(--color-muted-foreground); + font-size: 12.5px; + cursor: pointer; +} +.rcw-seg button.on { + background: var(--color-foreground); + color: var(--color-background); +} + +/* Directory list */ +.rcw-dirbar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + margin-bottom: 8px; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-background); +} +.rcw-back { + display: grid; + place-items: center; + border: none; + background: transparent; + color: var(--color-muted-foreground); + cursor: pointer; + flex-shrink: 0; +} +.rcw-back:hover { + color: var(--color-foreground); +} +.rcw-dir-path { + font-family: var(--font-mono); + font-size: 12px; + color: var(--color-foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.rcw-dirlist { + height: 190px; + overflow-y: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-background); + padding: 4px; +} +.rcw-dir-row { + display: flex; + align-items: center; + gap: 9px; + width: 100%; + padding: 8px 10px; + border: none; + background: transparent; + border-radius: var(--radius-md); + color: var(--color-foreground); + font-size: 13px; + cursor: pointer; + text-align: left; +} +.rcw-dir-row:hover { + background: var(--color-muted); +} +.rcw-dir-row svg { + color: var(--color-muted-foreground); + flex-shrink: 0; +} +.rcw-hint { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 24px; + font-size: 12px; + color: var(--color-muted-foreground); +} +.rcw-save { + display: flex; + align-items: center; + gap: 8px; + margin-top: 12px; + font-size: 12.5px; + color: var(--color-muted-foreground); + cursor: pointer; +} + +/* Footer */ +.rcw-foot { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 22px; +} +.rcw-primary { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 9px 18px; + border: none; + border-radius: var(--radius-lg); + background: var(--color-primary); + color: var(--color-on-primary, #fff); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: opacity 0.15s; +} +.rcw-primary:hover:not(:disabled) { + opacity: 0.9; +} +.rcw-primary:disabled { + opacity: 0.6; + cursor: not-allowed; +} +.rcw-ghost { + padding: 9px 16px; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: transparent; + color: var(--color-foreground); + font-size: 13px; + cursor: pointer; +} +.rcw-ghost:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.spin { + animation: rcw-spin 0.8s linear infinite; +} +@keyframes rcw-spin { + to { transform: rotate(360deg); } +} +</style> diff --git a/web/src/components/RightPanel.vue b/web/src/components/RightPanel.vue index c2bfef5..ac5140f 100644 --- a/web/src/components/RightPanel.vue +++ b/web/src/components/RightPanel.vue @@ -125,7 +125,7 @@ function startResize(e: MouseEvent) { padding: 4px 10px; border: none; background: transparent; - border-radius: 6px; + border-radius: var(--radius-md); color: var(--color-muted-foreground); cursor: pointer; transition: background 0.15s, color 0.15s; @@ -148,7 +148,7 @@ function startResize(e: MouseEvent) { height: 24px; border: none; background: transparent; - border-radius: 4px; + border-radius: var(--radius-sm); color: var(--color-muted-foreground); cursor: pointer; } @@ -202,7 +202,7 @@ function startResize(e: MouseEvent) { .plan-track { flex: 1; height: 5px; - border-radius: 9999px; + border-radius: var(--radius-pill); background: var(--color-border); overflow: hidden; } @@ -210,7 +210,7 @@ function startResize(e: MouseEvent) { .plan-fill { height: 100%; background: var(--color-primary); - border-radius: 9999px; + border-radius: var(--radius-pill); transition: width var(--duration-slow) var(--ease-out); } diff --git a/web/src/components/SettingsDialog.vue b/web/src/components/SettingsDialog.vue index f40dca6..bc97c75 100644 --- a/web/src/components/SettingsDialog.vue +++ b/web/src/components/SettingsDialog.vue @@ -1,9 +1,9 @@ <script setup lang="ts"> -import { ref, reactive, computed, watch, nextTick, onUnmounted } from 'vue' +import { ref, reactive, computed, watch, nextTick, onUnmounted, inject } from 'vue' import { useChatStore } from '@/stores/chat' import { useTheme } from '@/composables/useTheme' import { api } from '@/composables/api' -import type { MCPServerInfo, MCPServerRequest, SkillInfo, SSHAlias, SetupProvider, SetupModel, ProviderDetail } from '@/types/api' +import type { MCPServerInfo, MCPServerRequest, SkillInfo, SSHAlias, SetupProvider, SetupModel, ProviderDetail, RemoteMeta } from '@/types/api' import QRCode from 'qrcode' import { Dialog, @@ -12,6 +12,7 @@ import { TransitionRoot, TransitionChild, } from '@headlessui/vue' +import { Globe, Zap, MessageSquare, Monitor, Key, Server, ChevronRight, Plus } from 'lucide-vue-next' const props = defineProps<{ open: boolean @@ -22,6 +23,40 @@ const emit = defineEmits<{ }>() const store = useChatStore() + +// Launch the Remote-connect wizard from the SSH tab (provided by App). We close +// Settings first so the wizard opens in the workspace context rather than +// stacking on top of the full-page settings overlay. +const openRemoteConnect = inject<(prefill?: RemoteMeta & { loadTaskUuid?: string }) => void>('openRemoteConnect') + +// Open the wizard ON TOP of Settings (it stacks above via DOM order), rather +// than closing Settings first — that dumped the user back to the workspace +// (welcome) behind the wizard. Cancelling the wizard now returns to Settings; +// only a successful bind closes Settings (App handles @bound → go to the new +// remote workspace). +function launchWizard(prefill?: RemoteMeta) { + openRemoteConnect?.(prefill) +} + +function openRemoteWizard() { + launchWizard() +} + +function connectToAlias(alias: SSHAlias) { + // addr is "user@host" where host may include ":port" (mirrors the wizard's + // applyAlias parsing) → build a prefill the wizard jumps straight into. + const at = alias.addr.indexOf('@') + const user = at >= 0 ? alias.addr.slice(0, at) : 'root' + let host = at >= 0 ? alias.addr.slice(at + 1) : alias.addr + let port = 22 + const colon = host.lastIndexOf(':') + if (colon >= 0) { + port = parseInt(host.slice(colon + 1), 10) || 22 + host = host.slice(0, colon) + } + launchWizard({ host, port, user, remotePath: alias.path || '' }) +} + const { themeChoice, setTheme, themes } = useTheme() const darkThemes = computed(() => themes.filter((t) => t.appearance === 'dark')) const lightThemes = computed(() => themes.filter((t) => t.appearance === 'light')) @@ -107,7 +142,7 @@ async function toggleMCP(name: string, enabled: boolean) { } function serverIcon(type: string) { - return type === 'sse' || type === 'http' ? '🌐' : '⚡' + return type === 'sse' || type === 'http' ? Globe : Zap } function mcpStatusLabel(info: MCPServerInfo): string { @@ -500,7 +535,8 @@ const addProviderInfo = () => addProviderList.value.find(p => p.id === addSelect <template> <TransitionRoot :show="open" as="template"> - <Dialog @close="emit('close')" class="relative z-50"> + <Dialog @close="emit('close')" class="relative" style="z-index: var(--z-modal)"> + <!-- Opaque page background: settings is a full page, not a floating modal. --> <TransitionChild enter="ease-out duration-150" enter-from="opacity-0" @@ -508,42 +544,33 @@ const addProviderInfo = () => addProviderList.value.find(p => p.id === addSelect leave="ease-in duration-100" leave-from="opacity-100" leave-to="opacity-0"> - <div class="fixed inset-0" style="background: rgba(8,8,8,0.5); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px)" /> + <div class="fixed inset-0" style="background: var(--color-background)" /> </TransitionChild> - <div class="fixed inset-0 flex items-center justify-center p-4 sm:p-6"> + <!-- Edge-to-edge full page. --> + <div class="fixed inset-0 flex"> <TransitionChild - enter="ease-out duration-150" - enter-from="opacity-0 scale-[0.98] translate-y-1" - enter-to="opacity-100 scale-100 translate-y-0" + class="w-full h-full" + enter="ease-out duration-200" + enter-from="opacity-0 scale-[0.995]" + enter-to="opacity-100 scale-100" leave="ease-in duration-100" - leave-from="opacity-100 scale-100 translate-y-0" - leave-to="opacity-0 scale-[0.98] translate-y-1"> - <!-- Fixed footprint: h-[min(...)] (NOT max-h) so the panel never resizes or re-centers between tabs. --> + leave-from="opacity-100 scale-100" + leave-to="opacity-0 scale-[0.995]"> + <!-- Mirrors the chat page shell: full-height left rail + right column + with a transparent top bar and an inset surface content panel. --> <DialogPanel - class="flex flex-col w-[min(880px,94vw)] h-[min(620px,86vh)] overflow-hidden" - style="border-radius: var(--radius-xl); background-color: var(--color-surface); border: 1px solid var(--color-border); box-shadow: var(--shadow-lg)" + class="settings-shell relative flex w-full h-full overflow-hidden" + style="background-color: var(--color-background)" > - <!-- Header --> - <div class="flex items-center gap-2.5 px-5 h-14 shrink-0" style="border-bottom: 1px solid var(--color-border); background-color: var(--color-sidebar-bg)"> - <span class="w-[5px] h-[5px] rounded-[1px]" style="background-color: var(--color-primary)" /> - <DialogTitle class="text-[13px] font-semibold tracking-tight" style="font-family: var(--font-sans); color: var(--color-foreground)">Settings</DialogTitle> - <button - class="ml-auto grid place-items-center w-7 h-7 rounded-md transition-colors cursor-pointer hover:bg-[var(--color-secondary)]" - style="color: var(--color-muted-foreground)" - aria-label="Close" - @click="emit('close')" - > - <svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8"> - <path d="M5 5l10 10M15 5L5 15" stroke-linecap="round" /> - </svg> - </button> - </div> - - <!-- Body: nav rail + single scrolling content column. min-h-0 on both levels is required for the scroll fix. --> - <div class="flex flex-1 min-h-0"> - <!-- Nav rail --> - <nav class="shrink-0 w-[200px] py-2 px-2 overflow-y-auto flex flex-col gap-0.5" style="border-right: 1px solid var(--color-border); background-color: var(--color-sidebar-bg)"> + <!-- Native macOS title-bar drag strip, matching the workspace shell + so Settings has the same top inset / draggable region. --> + <div class="titlebar-drag" data-tauri-drag-region aria-hidden="true" /> + + <!-- Left rail (shell tone, like the sidebar): back-to-workspace at + the top, then the section nav. --> + <nav class="settings-rail shrink-0 flex flex-col"> + <div class="flex flex-col gap-0.5"> <button v-for="tab in (['general', 'appearance', 'providers', 'mcp', 'skills', 'ssh', 'channels', 'shortcuts'] as const)" :key="tab" @@ -557,11 +584,32 @@ const addProviderInfo = () => addProviderList.value.find(p => p.id === addSelect <svg class="w-3.5 h-3.5 shrink-0" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" v-html="iconFor[tab]" /> <span class="truncate">{{ tabLabel[tab] }}</span> </button> - </nav> - - <!-- Content frame (surface). Only the inner div scrolls. --> - <div class="flex-1 min-w-0 flex flex-col min-h-0" style="background-color: var(--color-surface)"> - <div class="flex-1 min-h-0 overflow-y-auto px-6 py-5"> + </div> + <!-- A second "Back to workspace" pinned to the bottom of the rail: + settings is opened from the sidebar's bottom gear, so returning + shouldn't require traveling all the way back to the top. --> + <button + class="settings-back group mt-auto flex items-center gap-1.5 h-9 px-2.5 rounded-md text-[13px] font-medium transition-colors cursor-pointer" + @click="emit('close')" + > + <svg class="w-4 h-4 transition-transform group-hover:-translate-x-0.5" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8"> + <path d="M11.5 5L6.5 10l5 5M6.5 10H16" stroke-linecap="round" stroke-linejoin="round" /> + </svg> + Back to workspace + </button> + </nav> + + <!-- Right column: just the inset surface content panel — no header + bar. The active section is shown by the rail; close via the + rail's "Back to workspace" or Esc. A visually-hidden title keeps + the dialog accessible. --> + <div class="flex flex-col flex-1 min-w-0"> + <DialogTitle class="sr-only">Settings · {{ tabLabel[activeTab] }}</DialogTitle> + + <!-- Inset content panel — matches .chat-panel. Only the inner div + scrolls; each tab block is centered and width-capped. --> + <div class="settings-panel flex flex-col flex-1 min-h-0"> + <div class="flex-1 min-h-0 overflow-y-auto px-8 py-7 [&>div]:max-w-3xl [&>div]:mx-auto"> <!-- General tab --> <div v-if="activeTab === 'general'" class="space-y-5"> <div class="flex items-center gap-2"> @@ -623,16 +671,16 @@ const addProviderInfo = () => addProviderList.value.find(p => p.id === addSelect <button class="w-full flex items-center gap-3 px-3 py-2.5 rounded-md cursor-pointer transition-colors text-left" :style="themeChoice === 'system' - ? { border: '1px solid var(--color-primary)', backgroundColor: 'rgba(255,132,0,0.08)' } + ? { border: '1px solid var(--color-primary)', backgroundColor: 'var(--accent-wash-soft)' } : { border: '1px solid var(--color-border)', backgroundColor: 'var(--color-surface)' }" @click="setTheme('system')" > - <span class="text-sm">🖥</span> + <Monitor :size="15" style="color: var(--color-muted-foreground)" /> <div class="flex-1 min-w-0"> <div class="text-xs font-medium" style="color: var(--color-foreground)">System</div> <div class="text-[10px]" style="color: var(--color-muted-foreground)">Follow your OS light / dark setting</div> </div> - <span v-if="themeChoice === 'system'" class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0" style="background-color: rgba(255,132,0,0.12); color: var(--color-primary)">active</span> + <span v-if="themeChoice === 'system'" class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0" style="background-color: var(--accent-wash); color: var(--color-primary)">active</span> </button> <!-- Dark themes --> @@ -710,8 +758,8 @@ const addProviderInfo = () => addProviderList.value.find(p => p.id === addSelect <span class="text-[11px] font-mono" style="color: var(--color-muted-foreground)">{{ configuredProviders.length }}</span> </div> <button - class="h-7 px-2.5 text-[11px] font-medium rounded-md cursor-pointer transition-colors hover:bg-[rgba(255,132,0,0.16)]" - style="color: var(--color-primary); background-color: rgba(255,132,0,0.1)" + class="h-7 px-2.5 text-[11px] font-medium rounded-md cursor-pointer transition-colors hover:bg-[var(--accent-wash-strong)]" + style="color: var(--color-primary); background-color: var(--accent-wash)" @click="startAddProvider" > + Add provider @@ -802,7 +850,7 @@ const addProviderInfo = () => addProviderList.value.find(p => p.id === addSelect class="flex items-center gap-3 px-3 py-2.5 rounded-md" style="border: 1px solid var(--color-border); background-color: var(--color-surface)" > - <span class="text-sm">🔑</span> + <Key :size="15" style="color: var(--color-muted-foreground)" /> <div class="flex-1 min-w-0"> <div class="text-xs font-medium font-mono" style="color: var(--color-foreground)">{{ p.id }}</div> <div class="text-[10px] font-mono truncate" style="color: var(--color-muted-foreground)"> @@ -813,7 +861,7 @@ const addProviderInfo = () => addProviderList.value.find(p => p.id === addSelect <span v-if="store.providerName === p.id" class="text-[10px] px-1.5 py-0.5 rounded-full" - style="background-color: rgba(255,132,0,0.1); color: var(--color-primary)" + style="background-color: var(--accent-wash); color: var(--color-primary)" > active </span> @@ -843,8 +891,8 @@ const addProviderInfo = () => addProviderList.value.find(p => p.id === addSelect </div> <button v-if="mcpEditing === null" - class="h-7 px-2.5 text-[11px] font-medium rounded-md cursor-pointer transition-colors hover:bg-[rgba(255,132,0,0.16)]" - style="color: var(--color-primary); background-color: rgba(255,132,0,0.1)" + class="h-7 px-2.5 text-[11px] font-medium rounded-md cursor-pointer transition-colors hover:bg-[var(--accent-wash-strong)]" + style="color: var(--color-primary); background-color: var(--accent-wash)" @click="openAddMCP" >+ Add server</button> </div> @@ -1014,7 +1062,7 @@ const addProviderInfo = () => addProviderList.value.find(p => p.id === addSelect }" > <div class="flex items-center gap-3"> - <span class="text-sm">{{ serverIcon(info.type) }}</span> + <component :is="serverIcon(info.type)" :size="15" style="color: var(--color-muted-foreground)" /> <div class="flex-1 min-w-0"> <div class="flex items-center gap-2"> <span class="text-xs font-medium" style="color: var(--color-foreground)">{{ name }}</span> @@ -1126,11 +1174,20 @@ const addProviderInfo = () => addProviderList.value.find(p => p.id === addSelect <!-- SSH tab --> <div v-if="activeTab === 'ssh'"> - <h3 class="text-[13px] font-semibold tracking-tight mb-4" style="color: var(--color-foreground)">SSH Environments</h3> + <div class="flex items-center justify-between mb-4"> + <h3 class="text-[13px] font-semibold tracking-tight" style="color: var(--color-foreground)">SSH Environments</h3> + <button + class="inline-flex items-center gap-1.5 px-3 h-8 rounded-md text-[12.5px] font-medium cursor-pointer transition-colors hover:bg-[var(--color-secondary)]" + style="border: 1px solid var(--color-border); color: var(--color-foreground)" + @click="openRemoteWizard" + > + <Plus :size="14" /> Connect to remote host + </button> + </div> <div class="mb-3"> <div class="text-[11px] font-medium mb-1" style="color: var(--color-muted-foreground)">Current Environment</div> - <div class="inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium" style="background-color: rgba(255,132,0,0.1); color: var(--color-primary)"> + <div class="inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium" style="background-color: var(--accent-wash); color: var(--color-primary)"> <span class="w-1.5 h-1.5 rounded-full" style="background-color: var(--color-primary)" /> {{ sshCurrent }} </div> @@ -1140,19 +1197,21 @@ const addProviderInfo = () => addProviderList.value.find(p => p.id === addSelect <div class="w-9 h-9 grid place-items-center rounded-lg" style="background-color: var(--color-secondary); color: var(--color-muted-foreground)"> <svg class="w-4 h-4" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" v-html="iconFor.ssh" /> </div> - <div class="text-[13px] font-medium" style="color: var(--color-foreground)">No SSH aliases configured</div> - <div class="text-[11px] leading-relaxed max-w-[240px]" style="color: var(--color-muted-foreground)"> - Add aliases to <span class="font-mono">~/.jcode/config.json</span>. + <div class="text-[13px] font-medium" style="color: var(--color-foreground)">No saved hosts</div> + <div class="text-[11px] leading-relaxed max-w-[270px]" style="color: var(--color-muted-foreground)"> + Use <span style="color: var(--color-foreground)">Connect to remote host</span> to add one, or pre-configure hosts in <span class="font-mono">~/.jcode/config.json</span>. </div> </div> <div v-else class="space-y-2"> - <div + <button v-for="alias in sshAliases" :key="alias.name" - class="flex items-center gap-3 px-3 py-2.5 rounded-md" + class="group w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-left cursor-pointer transition-colors hover:bg-[var(--color-secondary)]" style="border: 1px solid var(--color-border); background-color: var(--color-surface)" + :title="`Connect to ${alias.name}`" + @click="connectToAlias(alias)" > - <span class="text-sm">🖥</span> + <Server :size="15" style="color: var(--color-muted-foreground)" /> <div class="flex-1 min-w-0"> <div class="text-xs font-medium" style="color: var(--color-foreground)">{{ alias.name }}</div> <div class="text-[10px] font-mono truncate" style="color: var(--color-muted-foreground)"> @@ -1163,11 +1222,12 @@ const addProviderInfo = () => addProviderList.value.find(p => p.id === addSelect <span v-if="sshCurrent === alias.name" class="text-[10px] px-1.5 py-0.5 rounded-full" - style="background-color: rgba(255,132,0,0.1); color: var(--color-primary)" + style="background-color: var(--accent-wash); color: var(--color-primary)" > active </span> - </div> + <ChevronRight :size="15" class="opacity-0 group-hover:opacity-100 transition-opacity" style="color: var(--color-muted-foreground)" /> + </button> </div> </div> @@ -1189,7 +1249,7 @@ const addProviderInfo = () => addProviderList.value.find(p => p.id === addSelect <div class="px-4 py-3 rounded-md" style="border: 1px solid var(--color-border); background-color: var(--color-surface)"> <div class="flex items-center justify-between mb-3"> <div class="flex items-center gap-2"> - <span class="text-base">💬</span> + <MessageSquare :size="16" style="color: var(--color-muted-foreground)" /> <div> <div class="text-xs font-medium" style="color: var(--color-foreground)">WeChat</div> <div class="text-[10px]" style="color: var(--color-muted-foreground)">iLink Bot integration</div> @@ -1284,24 +1344,47 @@ const addProviderInfo = () => addProviderList.value.find(p => p.id === addSelect </div> </div> </div> - - <!-- Footer: keyboard hint + low-chroma Done --> - <div class="flex items-center px-5 h-12 shrink-0" style="border-top: 1px solid var(--color-border); background-color: var(--color-sidebar-bg)"> - <span class="text-[11px] flex items-center gap-1.5" style="color: var(--color-muted-foreground)"> - Press - <kbd class="px-1.5 py-0.5 text-[10px] font-mono rounded" style="background-color: var(--color-secondary); border: 1px solid var(--color-border); color: var(--color-muted-foreground)">Esc</kbd> - to close - </span> - <button - class="ml-auto h-7 px-3 text-xs font-medium rounded-md cursor-pointer transition-colors hover:bg-[rgba(255,132,0,0.16)]" - style="color: var(--color-primary); background-color: rgba(255,132,0,0.1)" - @click="emit('close')"> - Done - </button> - </div> </DialogPanel> </TransitionChild> </div> </Dialog> </TransitionRoot> </template> + +<style scoped> +/* Left rail mirrors the chat sidebar: same width + shell tone, no border. */ +.settings-rail { + width: var(--sidebar-width); + padding: 12px; + overflow-y: auto; + background: var(--color-background); +} + +.settings-back { + color: var(--color-muted-foreground); +} +.settings-back:hover { + background: var(--color-secondary); + color: var(--color-foreground); +} + +/* Inset content panel — identical treatment to App.vue's .chat-panel so the two + pages read as the same layout. */ +.settings-panel { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-2xl); + margin: 4px 14px 14px; + overflow: hidden; + box-shadow: var(--shadow-sm); +} + +@media (max-width: 640px) { + .settings-rail { + width: 100%; + } + .settings-panel { + margin: 2px 8px 8px; + } +} +</style> diff --git a/web/src/components/SetupView.vue b/web/src/components/SetupView.vue index 8f44ea1..c783410 100644 --- a/web/src/components/SetupView.vue +++ b/web/src/components/SetupView.vue @@ -146,233 +146,310 @@ function finish() { </script> <template> - <div class="fixed inset-0 bg-zinc-50 dark:bg-zinc-950 flex items-center justify-center z-50"> - <div class="w-full max-w-lg mx-auto px-6"> - <!-- Logo --> - <div class="flex items-center justify-center gap-0 mb-8 select-none" style="font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, 'Roboto Mono', Menlo, Monaco, monospace; font-size: 32px; font-weight: 700;"> - <span class="text-zinc-400 dark:text-zinc-500">[</span><span style="color: #FF8400;">J</span><span class="text-zinc-900 dark:text-zinc-300">CODE</span><span class="text-zinc-400 dark:text-zinc-500">]</span> - </div> - - <!-- Done state --> - <div v-if="step === 'done'" class="text-center animate-fade-in"> - <div class="w-16 h-16 rounded-full bg-emerald-100 dark:bg-emerald-500/15 flex items-center justify-center mx-auto mb-5"> - <svg class="w-8 h-8 text-emerald-600 dark:text-emerald-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> - <path d="M5 13l4 4L19 7" /> - </svg> + <!-- Onboarding is a full-page, framed window so first-run matches the app's + enclosed look and brand palette (orange), not a one-off green theme. --> + <div class="setup-viewport" style="z-index: var(--z-modal)"> + <div class="setup-frame"> + <div class="w-full max-w-lg mx-auto px-6"> + <!-- Logo --> + <div class="flex items-center justify-center gap-0 mb-8 select-none" style="font-family: var(--font-mono); font-size: 32px; font-weight: 700;"> + <span style="color: var(--color-muted-foreground)">[</span><span style="color: var(--color-primary)">J</span><span style="color: var(--color-foreground)">CODE</span><span style="color: var(--color-muted-foreground)">]</span> </div> - <h2 class="text-xl font-semibold text-zinc-800 dark:text-zinc-100 mb-2" style="font-family: var(--font-sans)">You're all set!</h2> - <p class="text-sm text-zinc-500 dark:text-zinc-400 mb-6"> - Using <span class="font-mono text-zinc-700 dark:text-zinc-300">{{ selectedModel }}</span> via <span class="font-mono text-zinc-700 dark:text-zinc-300">{{ selectedProviderInfo?.name || selectedProvider }}</span> - </p> - <button - class="px-6 py-2.5 bg-emerald-500 hover:bg-emerald-600 text-white rounded-lg text-sm font-medium transition-colors cursor-pointer shadow-sm" - @click="finish" - > - Start coding - </button> - </div> - <!-- Setup steps --> - <div v-else class="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-sm overflow-hidden"> - <!-- Step indicator --> - <div class="flex items-center gap-2 px-6 pt-5 pb-3"> - <div class="flex items-center gap-1.5"> - <div - v-for="s in (['provider', 'model', 'apikey'] as const)" - :key="s" - class="w-2 h-2 rounded-full transition-colors" - :class="step === s ? 'bg-emerald-500' : ['provider', 'model', 'apikey'].indexOf(step) > ['provider', 'model', 'apikey'].indexOf(s) ? 'bg-emerald-400' : 'bg-zinc-300 dark:bg-zinc-600'" - /> + <!-- Done state --> + <div v-if="step === 'done'" class="text-center animate-fade-in"> + <div class="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-5" style="background: var(--color-success-bg)"> + <svg class="w-8 h-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="color: var(--color-success-fg)"> + <path d="M5 13l4 4L19 7" /> + </svg> </div> - <span class="text-[10px] text-zinc-400 dark:text-zinc-500 uppercase tracking-wider ml-auto"> - {{ step === 'provider' ? 'Step 1: Choose Provider' : step === 'model' ? 'Step 2: Choose Model' : 'Step 3: API Key' }} - </span> + <h2 class="text-xl font-semibold mb-2" style="font-family: var(--font-sans); color: var(--color-foreground)">You're all set!</h2> + <p class="text-sm mb-6" style="color: var(--color-muted-foreground)"> + Using <span class="font-mono" style="color: var(--color-foreground)">{{ selectedModel }}</span> via <span class="font-mono" style="color: var(--color-foreground)">{{ selectedProviderInfo?.name || selectedProvider }}</span> + </p> + <button + class="px-6 py-2.5 rounded-lg text-sm font-medium transition-opacity cursor-pointer shadow-sm hover:opacity-90" + style="background: var(--color-primary); color: var(--color-on-primary, #fff)" + @click="finish" + > + Start coding + </button> </div> - <!-- Provider selection --> - <div v-if="step === 'provider'" class="px-6 pb-5"> - <h2 class="text-base font-semibold text-zinc-800 dark:text-zinc-100 mb-1" style="font-family: var(--font-sans)">Choose a Provider</h2> - <p class="text-xs text-zinc-500 dark:text-zinc-400 mb-3">Select the AI provider you'd like to use.</p> - - <input - v-model="providerSearch" - type="text" - placeholder="Search providers..." - class="w-full px-3 py-2 text-sm bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg mb-3 outline-none focus:border-emerald-400 dark:focus:border-emerald-500 transition-colors" - /> - - <div v-if="loading" class="text-center py-8 text-sm text-zinc-400 animate-pulse">Loading providers...</div> - <div v-else-if="filteredProviders.length === 0" class="text-center py-8 text-sm text-zinc-400">No providers found</div> - <div v-else class="space-y-1.5 max-h-72 overflow-y-auto pr-1"> - <button - v-for="p in filteredProviders" - :key="p.id" - class="w-full px-4 py-3 text-left rounded-lg border transition-all cursor-pointer group" - :class="selectedProvider === p.id - ? 'border-emerald-400 dark:border-emerald-500 bg-emerald-50 dark:bg-emerald-500/10' - : 'border-zinc-200 dark:border-zinc-700 hover:border-emerald-300 dark:hover:border-emerald-600 hover:bg-zinc-50 dark:hover:bg-zinc-800'" - @click="selectProvider(p.id)" - > - <div class="flex items-center justify-between"> - <div> - <div class="text-sm font-medium text-zinc-800 dark:text-zinc-100">{{ p.name }}</div> - <div v-if="p.doc" class="text-[10px] text-zinc-400 dark:text-zinc-500 mt-0.5">{{ p.doc }}</div> - </div> - <div class="flex items-center gap-2"> - <span v-if="p.tag === 'recommended'" class="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-100 dark:bg-amber-500/15 text-amber-600 dark:text-amber-400 font-medium">Recommended</span> - <span v-if="p.tag === 'local'" class="text-[10px] px-1.5 py-0.5 rounded-full bg-blue-100 dark:bg-blue-500/15 text-blue-600 dark:text-blue-400 font-medium">Local</span> - <span v-if="p.configured" class="text-[10px] px-1.5 py-0.5 rounded-full bg-emerald-100 dark:bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 font-medium">configured</span> - <svg class="w-4 h-4 text-zinc-300 dark:text-zinc-600 group-hover:text-emerald-400 transition-colors" viewBox="0 0 20 20" fill="currentColor"> - <path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" /> - </svg> - </div> - </div> - </button> + <!-- Setup steps --> + <div v-else class="rounded-xl overflow-hidden" style="background: var(--color-surface); border: 1px solid var(--color-border); box-shadow: var(--shadow-sm)"> + <!-- Step indicator --> + <div class="flex items-center gap-2 px-6 pt-5 pb-3"> + <div class="flex items-center gap-1.5"> + <div + v-for="s in (['provider', 'model', 'apikey'] as const)" + :key="s" + class="w-2 h-2 rounded-full transition-colors" + :style="{ backgroundColor: step === s + ? 'var(--color-primary)' + : (['provider', 'model', 'apikey'].indexOf(step) > ['provider', 'model', 'apikey'].indexOf(s) ? 'var(--accent-fill)' : 'var(--color-border)') }" + /> + </div> + <span class="text-[10px] uppercase tracking-wider ml-auto" style="color: var(--color-muted-foreground)"> + {{ step === 'provider' ? 'Step 1: Choose Provider' : step === 'model' ? 'Step 2: Choose Model' : 'Step 3: API Key' }} + </span> </div> - </div> - <!-- Model selection --> - <div v-if="step === 'model'" class="px-6 pb-5"> - <div class="flex items-center gap-2 mb-1"> - <button class="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 transition-colors cursor-pointer" @click="goBack"> - <svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor"> - <path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clip-rule="evenodd" /> - </svg> - </button> - <h2 class="text-base font-semibold text-zinc-800 dark:text-zinc-100" style="font-family: var(--font-sans)">Choose a Model</h2> - </div> - <p class="text-xs text-zinc-500 dark:text-zinc-400 mb-3 ml-6">For <span class="font-mono">{{ selectedProviderInfo?.name }}</span></p> - - <input - v-model="modelSearch" - type="text" - placeholder="Search models..." - class="w-full px-3 py-2 text-sm bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg mb-3 outline-none focus:border-emerald-400 dark:focus:border-emerald-500 transition-colors" - /> - - <div v-if="loading" class="text-center py-8 text-sm text-zinc-400 animate-pulse">Loading models...</div> - <div v-else-if="filteredModels.length === 0" class="text-center py-8 text-sm text-zinc-400">No models found</div> - <div v-else class="space-y-1 max-h-72 overflow-y-auto pr-1"> - <button - v-for="m in filteredModels" - :key="m.id" - class="w-full px-4 py-2.5 text-left rounded-lg border transition-all cursor-pointer" - :class="selectedModel === m.id - ? 'border-emerald-400 dark:border-emerald-500 bg-emerald-50 dark:bg-emerald-500/10' - : 'border-zinc-200 dark:border-zinc-700 hover:border-emerald-300 dark:hover:border-emerald-600 hover:bg-zinc-50 dark:hover:bg-zinc-800'" - @click="selectModel(m.id)" - > - <div class="flex items-center justify-between"> - <div> - <div class="text-sm font-medium text-zinc-800 dark:text-zinc-100 font-mono">{{ m.id }}</div> - <div v-if="m.name && m.name !== m.id" class="text-[10px] text-zinc-400 dark:text-zinc-500 mt-0.5">{{ m.name }}</div> - </div> - <div class="flex items-center gap-2"> - <span v-if="m.context_limit" class="text-[10px] text-zinc-400 dark:text-zinc-500">{{ (m.context_limit / 1000).toFixed(0) }}k ctx</span> - <span v-if="m.reasoning" class="text-[10px] px-1.5 py-0.5 rounded-full bg-blue-100 dark:bg-blue-500/15 text-blue-600 dark:text-blue-400">reasoning</span> - <svg class="w-4 h-4 text-zinc-300 dark:text-zinc-600" viewBox="0 0 20 20" fill="currentColor"> - <path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" /> - </svg> + <!-- Provider selection --> + <div v-if="step === 'provider'" class="px-6 pb-5"> + <h2 class="text-base font-semibold mb-1" style="font-family: var(--font-sans); color: var(--color-foreground)">Choose a Provider</h2> + <p class="text-xs mb-3" style="color: var(--color-muted-foreground)">Select the AI provider you'd like to use.</p> + + <input + v-model="providerSearch" + type="text" + placeholder="Search providers..." + class="setup-input w-full px-3 py-2 text-sm rounded-lg mb-3 outline-none" + /> + + <div v-if="loading" class="text-center py-8 text-sm animate-pulse" style="color: var(--color-muted-foreground)">Loading providers...</div> + <div v-else-if="filteredProviders.length === 0" class="text-center py-8 text-sm" style="color: var(--color-muted-foreground)">No providers found</div> + <div v-else class="space-y-1.5 max-h-72 overflow-y-auto pr-1"> + <button + v-for="p in filteredProviders" + :key="p.id" + class="setup-option w-full px-4 py-3 text-left rounded-lg cursor-pointer group" + :class="{ selected: selectedProvider === p.id }" + @click="selectProvider(p.id)" + > + <div class="flex items-center justify-between"> + <div> + <div class="text-sm font-medium" style="color: var(--color-foreground)">{{ p.name }}</div> + <div v-if="p.doc" class="text-[10px] mt-0.5" style="color: var(--color-muted-foreground)">{{ p.doc }}</div> + </div> + <div class="flex items-center gap-2"> + <span v-if="p.tag === 'recommended'" class="text-[10px] px-1.5 py-0.5 rounded-full font-medium" style="background: var(--accent-wash); color: var(--color-primary)">Recommended</span> + <span v-if="p.tag === 'local'" class="text-[10px] px-1.5 py-0.5 rounded-full font-medium" style="background: var(--color-info-bg); color: var(--color-info-fg)">Local</span> + <span v-if="p.configured" class="text-[10px] px-1.5 py-0.5 rounded-full font-medium" style="background: var(--color-success-bg); color: var(--color-success-fg)">configured</span> + <svg class="setup-chevron w-4 h-4 transition-colors" viewBox="0 0 20 20" fill="currentColor"> + <path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" /> + </svg> + </div> </div> - </div> - </button> + </button> + </div> </div> - </div> - <!-- API Key input --> - <div v-if="step === 'apikey'" class="px-6 pb-5"> - <div class="flex items-center gap-2 mb-1"> - <button class="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 transition-colors cursor-pointer" @click="goBack"> - <svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor"> - <path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clip-rule="evenodd" /> - </svg> - </button> - <h2 class="text-base font-semibold text-zinc-800 dark:text-zinc-100" style="font-family: var(--font-sans)">Enter API Key</h2> + <!-- Model selection --> + <div v-if="step === 'model'" class="px-6 pb-5"> + <div class="flex items-center gap-2 mb-1"> + <button class="setup-back transition-colors cursor-pointer" @click="goBack"> + <svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor"> + <path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clip-rule="evenodd" /> + </svg> + </button> + <h2 class="text-base font-semibold" style="font-family: var(--font-sans); color: var(--color-foreground)">Choose a Model</h2> + </div> + <p class="text-xs mb-3 ml-6" style="color: var(--color-muted-foreground)">For <span class="font-mono">{{ selectedProviderInfo?.name }}</span></p> + + <input + v-model="modelSearch" + type="text" + placeholder="Search models..." + class="setup-input w-full px-3 py-2 text-sm rounded-lg mb-3 outline-none" + /> + + <div v-if="loading" class="text-center py-8 text-sm animate-pulse" style="color: var(--color-muted-foreground)">Loading models...</div> + <div v-else-if="filteredModels.length === 0" class="text-center py-8 text-sm" style="color: var(--color-muted-foreground)">No models found</div> + <div v-else class="space-y-1 max-h-72 overflow-y-auto pr-1"> + <button + v-for="m in filteredModels" + :key="m.id" + class="setup-option w-full px-4 py-2.5 text-left rounded-lg cursor-pointer" + :class="{ selected: selectedModel === m.id }" + @click="selectModel(m.id)" + > + <div class="flex items-center justify-between"> + <div> + <div class="text-sm font-medium font-mono" style="color: var(--color-foreground)">{{ m.id }}</div> + <div v-if="m.name && m.name !== m.id" class="text-[10px] mt-0.5" style="color: var(--color-muted-foreground)">{{ m.name }}</div> + </div> + <div class="flex items-center gap-2"> + <span v-if="m.context_limit" class="text-[10px]" style="color: var(--color-muted-foreground)">{{ (m.context_limit / 1000).toFixed(0) }}k ctx</span> + <span v-if="m.reasoning" class="text-[10px] px-1.5 py-0.5 rounded-full" style="background: var(--color-info-bg); color: var(--color-info-fg)">reasoning</span> + <svg class="setup-chevron w-4 h-4" viewBox="0 0 20 20" fill="currentColor"> + <path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" /> + </svg> + </div> + </div> + </button> + </div> </div> - <p class="text-xs text-zinc-500 dark:text-zinc-400 mb-4 ml-6"> - For <span class="font-mono">{{ selectedProviderInfo?.name }}</span> · <span class="font-mono">{{ selectedModel }}</span> - </p> - <div class="space-y-3 ml-6"> - <div v-if="selectedProviderInfo?.env?.length" class="px-3 py-2 rounded-md bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700"> - <div class="text-[10px] text-zinc-400 dark:text-zinc-500 mb-1">Environment variable</div> - <div class="text-xs font-mono text-zinc-600 dark:text-zinc-300">{{ selectedProviderInfo.env[0] }}</div> + <!-- API Key input --> + <div v-if="step === 'apikey'" class="px-6 pb-5"> + <div class="flex items-center gap-2 mb-1"> + <button class="setup-back transition-colors cursor-pointer" @click="goBack"> + <svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor"> + <path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clip-rule="evenodd" /> + </svg> + </button> + <h2 class="text-base font-semibold" style="font-family: var(--font-sans); color: var(--color-foreground)">Enter API Key</h2> </div> + <p class="text-xs mb-4 ml-6" style="color: var(--color-muted-foreground)"> + For <span class="font-mono">{{ selectedProviderInfo?.name }}</span> · <span class="font-mono">{{ selectedModel }}</span> + </p> + + <div class="space-y-3 ml-6"> + <div v-if="selectedProviderInfo?.env?.length" class="px-3 py-2 rounded-md" style="background: var(--color-muted); border: 1px solid var(--color-border)"> + <div class="text-[10px] mb-1" style="color: var(--color-muted-foreground)">Environment variable</div> + <div class="text-xs font-mono" style="color: var(--color-foreground)">{{ selectedProviderInfo.env[0] }}</div> + </div> + + <div> + <label class="block text-[10px] uppercase tracking-wider mb-1 font-medium" style="color: var(--color-muted-foreground)">API Key</label> + <div class="relative"> + <input + v-model="apiKey" + :type="showApiKey ? 'text' : 'password'" + placeholder="sk-..." + class="setup-input w-full px-3 py-2 text-sm font-mono rounded-lg outline-none pr-10" + :class="{ valid: validationResult?.valid, invalid: validationResult?.valid === false }" + @keydown.enter="submitSetup" + /> + <button + class="setup-back absolute right-2 top-1/2 -translate-y-1/2 cursor-pointer" + @click="showApiKey = !showApiKey" + > + <svg v-if="!showApiKey" class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor"> + <path d="M10 12.5a2.5 2.5 0 100-5 2.5 2.5 0 000 5z" /> + <path fill-rule="evenodd" d="M.664 10.59a1.651 1.651 0 010-1.186A10.004 10.004 0 0110 3c4.257 0 7.893 2.66 9.336 6.41.147.381.146.804 0 1.186A10.004 10.004 0 0110 17c-4.257 0-7.893-2.66-9.336-6.41zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd" /> + </svg> + <svg v-else class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor"> + <path fill-rule="evenodd" d="M3.28 2.22a.75.75 0 00-1.06 1.06l14.5 14.5a.75.75 0 101.06-1.06l-14.5-14.5z" clip-rule="evenodd" /> + <path d="M4.262 6.49A8.97 8.97 0 002.175 10.3a1.655 1.655 0 000 .4 10.004 10.004 0 007.548 5.953 8.97 8.97 0 004.988-.628l-1.446-1.446a4.003 4.003 0 01-5.54-5.54L4.262 6.49z" /> + </svg> + </button> + </div> + </div> - <div> - <label class="block text-[10px] text-zinc-400 dark:text-zinc-500 uppercase tracking-wider mb-1 font-medium">API Key</label> - <div class="relative"> + <div> + <label class="block text-[10px] uppercase tracking-wider mb-1 font-medium" style="color: var(--color-muted-foreground)">Base URL <span class="normal-case">(optional)</span></label> <input - v-model="apiKey" - :type="showApiKey ? 'text' : 'password'" - placeholder="sk-..." - class="w-full px-3 py-2 text-sm font-mono bg-zinc-50 dark:bg-zinc-800 border rounded-lg outline-none transition-colors pr-10" - :class="validationResult?.valid ? 'border-emerald-400 dark:border-emerald-500' : validationResult?.valid === false ? 'border-red-300 dark:border-red-500' : 'border-zinc-200 dark:border-zinc-700 focus:border-emerald-400 dark:focus:border-emerald-500'" + v-model="baseURL" + type="text" + :placeholder="selectedProviderInfo?.api || 'https://api.example.com/v1'" + class="setup-input w-full px-3 py-2 text-sm font-mono rounded-lg outline-none" @keydown.enter="submitSetup" /> + </div> + + <!-- Validate connection --> + <div class="flex items-center gap-2"> <button - class="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 cursor-pointer" - @click="showApiKey = !showApiKey" + :disabled="validating || !apiKey.trim()" + class="setup-secondary px-3 py-1.5 text-xs rounded-lg disabled:opacity-50 cursor-pointer transition-colors" + @click="validateConnection" > - <svg v-if="!showApiKey" class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor"> - <path d="M10 12.5a2.5 2.5 0 100-5 2.5 2.5 0 000 5z" /> - <path fill-rule="evenodd" d="M.664 10.59a1.651 1.651 0 010-1.186A10.004 10.004 0 0110 3c4.257 0 7.893 2.66 9.336 6.41.147.381.146.804 0 1.186A10.004 10.004 0 0110 17c-4.257 0-7.893-2.66-9.336-6.41zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd" /> - </svg> - <svg v-else class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor"> - <path fill-rule="evenodd" d="M3.28 2.22a.75.75 0 00-1.06 1.06l14.5 14.5a.75.75 0 101.06-1.06l-14.5-14.5z" clip-rule="evenodd" /> - <path d="M4.262 6.49A8.97 8.97 0 002.175 10.3a1.655 1.655 0 000 .4 10.004 10.004 0 007.548 5.953 8.97 8.97 0 004.988-.628l-1.446-1.446a4.003 4.003 0 01-5.54-5.54L4.262 6.49z" /> - </svg> + {{ validating ? 'Checking...' : 'Test Connection' }} </button> + <span v-if="validationResult?.valid" class="text-xs flex items-center gap-1" style="color: var(--color-success-fg)"> + <svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" /></svg> + Connected + </span> + <span v-if="validationResult?.valid === false" class="text-xs" style="color: var(--color-error-fg)">{{ validationResult.error }}</span> </div> - </div> - <div> - <label class="block text-[10px] text-zinc-400 dark:text-zinc-500 uppercase tracking-wider mb-1 font-medium">Base URL <span class="normal-case">(optional)</span></label> - <input - v-model="baseURL" - type="text" - :placeholder="selectedProviderInfo?.api || 'https://api.example.com/v1'" - class="w-full px-3 py-2 text-sm font-mono bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg outline-none focus:border-emerald-400 dark:focus:border-emerald-500 transition-colors" - @keydown.enter="submitSetup" - /> - </div> + <!-- Error --> + <div v-if="error" class="px-3 py-2 rounded-md" style="background: var(--color-error-bg); border: 1px solid var(--color-error-fg)"> + <span class="text-xs" style="color: var(--color-error-fg)">{{ error }}</span> + </div> - <!-- Validate connection --> - <div class="flex items-center gap-2"> <button - :disabled="validating || !apiKey.trim()" - class="px-3 py-1.5 text-xs border border-zinc-200 dark:border-zinc-700 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 disabled:opacity-50 cursor-pointer transition-colors text-zinc-600 dark:text-zinc-300" - @click="validateConnection" + :disabled="loading || !apiKey.trim()" + class="w-full px-4 py-2.5 rounded-lg text-sm font-medium transition-opacity cursor-pointer shadow-sm hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed" + style="background: var(--color-primary); color: var(--color-on-primary, #fff)" + @click="submitSetup" > - {{ validating ? 'Checking...' : 'Test Connection' }} + {{ loading ? 'Setting up...' : 'Complete Setup' }} </button> - <span v-if="validationResult?.valid" class="text-xs text-emerald-600 dark:text-emerald-400 flex items-center gap-1"> - <svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" /></svg> - Connected - </span> - <span v-if="validationResult?.valid === false" class="text-xs text-red-500">{{ validationResult.error }}</span> - </div> - - <!-- Error --> - <div v-if="error" class="px-3 py-2 rounded-md bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/30"> - <span class="text-xs text-red-600 dark:text-red-400">{{ error }}</span> </div> - - <button - :disabled="loading || !apiKey.trim()" - class="w-full px-4 py-2.5 bg-emerald-500 hover:bg-emerald-600 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg text-sm font-medium transition-colors cursor-pointer shadow-sm" - @click="submitSetup" - > - {{ loading ? 'Setting up...' : 'Complete Setup' }} - </button> </div> </div> - </div> - <!-- Footer hint --> - <p v-if="step !== 'done'" class="text-center text-[10px] text-zinc-400 dark:text-zinc-600 mt-4"> - Configuration saved to <span class="font-mono">~/.jcode/config.json</span> - </p> + <!-- Footer hint --> + <p v-if="step !== 'done'" class="text-center text-[10px] mt-4" style="color: var(--color-muted-foreground)"> + Configuration saved to <span class="font-mono">~/.jcode/config.json</span> + </p> + </div> </div> </div> </template> + +<style scoped> +.setup-viewport { + position: fixed; + inset: 0; + background: var(--color-background); + display: flex; +} + +.setup-frame { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + overflow: auto; +} + +.setup-input { + background: var(--color-background); + border: 1px solid var(--color-border); + color: var(--color-foreground); + transition: border-color 0.15s; +} +.setup-input::placeholder { + color: var(--color-muted-foreground); +} +.setup-input:focus { + border-color: var(--color-primary); +} +.setup-input.valid { + border-color: var(--color-success-fg); +} +.setup-input.invalid { + border-color: var(--color-error-fg); +} + +.setup-option { + border: 1px solid var(--color-border); + background: transparent; + transition: border-color 0.15s, background 0.15s; +} +.setup-option:hover { + border-color: color-mix(in srgb, var(--color-primary) 55%, var(--color-border)); + background: var(--color-muted); +} +.setup-option.selected { + border-color: var(--color-primary); + background: var(--accent-wash-soft); +} + +.setup-chevron { + color: var(--color-border); +} +.setup-option:hover .setup-chevron { + color: var(--color-primary); +} + +.setup-back { + color: var(--color-muted-foreground); +} +.setup-back:hover { + color: var(--color-foreground); +} + +.setup-secondary { + border: 1px solid var(--color-border); + color: var(--color-muted-foreground); +} +.setup-secondary:hover { + background: var(--color-muted); + color: var(--color-foreground); +} +</style> diff --git a/web/src/components/Sidebar.vue b/web/src/components/Sidebar.vue index 5561ae3..45539bd 100644 --- a/web/src/components/Sidebar.vue +++ b/web/src/components/Sidebar.vue @@ -1,8 +1,33 @@ <!-- eslint-disable vue/multi-word-component-names --> <script setup lang="ts"> +import { ref, computed, onMounted, watch, inject } from 'vue' +import { + Menu as HMenu, + MenuButton, + MenuItems, + MenuItem, +} from '@headlessui/vue' +import { + ChevronRight, + Folder, + FolderOpen, + Server, + Plus, + MoreHorizontal, + Pin, + Archive, + ArchiveRestore, + Pencil, + Trash2, + MailOpen, +} from 'lucide-vue-next' import { useChatStore } from '@/stores/chat' +import { useProjectStore, isRemotePath, parseRemoteLabel } from '@/stores/project' +import type { TaskItem, RemoteMeta } from '@/types/api' const store = useChatStore() +const projectStore = useProjectStore() +const openRemoteConnect = inject<(prefill?: RemoteMeta & { loadTaskUuid?: string }) => void>('openRemoteConnect') defineProps<{ resolvedTheme: 'light' | 'dark' @@ -15,91 +40,223 @@ const emit = defineEmits<{ toggleTheme: [] }>() -async function handleDelete(uuid: string) { - await store.deleteSession(uuid) +// Expanded project paths. The active project is auto-expanded. +const expanded = ref<Set<string>>(new Set()) +const showArchived = ref(false) + +// The task "⋯" menu opens downward by default; for rows near the bottom of the +// sidebar that clips it, so flip it upward when there isn't room below. +const flipUpMenus = ref<Set<string>>(new Set()) +function onTaskMenuClick(e: MouseEvent, uuid: string) { + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() + const next = new Set(flipUpMenus.value) + if (rect.bottom + 200 > window.innerHeight - 12) next.add(uuid) + else next.delete(uuid) + flipUpMenus.value = next } -function formatDate(ts: string): string { - const d = new Date(ts) - const now = new Date() - if (d.toDateString() === now.toDateString()) { - return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) +const activePath = computed(() => projectStore.activeProject?.path || store.pwd) + +function isExpanded(path: string): boolean { + return expanded.value.has(path) +} +function toggle(path: string) { + const next = new Set(expanded.value) + if (next.has(path)) next.delete(path) + else next.add(path) + expanded.value = next +} + +// Project nodes sorted with the active project first, then alphabetically. +const projectNodes = computed(() => { + const nodes = [...projectStore.projectsForTree] + return nodes.sort((a, b) => { + if (a.path === activePath.value) return -1 + if (b.path === activePath.value) return 1 + return a.name.localeCompare(b.name) + }) +}) + +function tasksFor(path: string): TaskItem[] { + const list = projectStore.tasksByProject[path] || [] + return showArchived.value ? list : list.filter((t) => !t.archived) +} + +function visibleCount(path: string): number { + return tasksFor(path).length +} + +async function refresh() { + await projectStore.fetchAllTasks() +} + +onMounted(() => { + refresh() + if (activePath.value) expanded.value = new Set([activePath.value]) +}) + +// Keep the active project expanded and the tree fresh as the active session / +// session list changes (new task, send, delete). +watch(activePath, (p) => { + if (p && !expanded.value.has(p)) toggle(p) +}) +watch(() => store.sessions.length, refresh) +watch(() => store.currentSessionId, refresh) + +async function openTask(task: TaskItem) { + if (task.unread) projectStore.updateTaskMeta(task.uuid, { unread: false }) + + // Remote tasks: if their workspace is already the active connection just load + // the transcript; otherwise reconnect via the SSH wizard (it loads the task + // after binding). We never persist the SSH secret, so a fresh connect is + // required to continue the conversation. + if (isRemotePath(task.project)) { + if (activePath.value === task.project) { + await store.loadSession(task.uuid) + } else { + const meta = parseRemoteLabel(task.project) + if (meta) openRemoteConnect?.({ ...meta, loadTaskUuid: task.uuid }) + } + return } - return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + + if (activePath.value !== task.project) { + const ok = await projectStore.openProject(task.project) + if (!ok) return + await store.fetchHealth() + } + await store.loadSession(task.uuid) +} + +function projIcon(path: string) { + if (isRemotePath(path)) return Server + return path === activePath.value ? FolderOpen : Folder +} + +function isActiveTask(task: TaskItem): boolean { + return task.uuid === store.currentSessionId && activePath.value === task.project +} + +async function handleDelete(task: TaskItem) { + await store.deleteSession(task.uuid) + await refresh() +} + +async function renameTask(task: TaskItem) { + const title = window.prompt('Rename task', task.title || '') + if (title != null && title.trim()) { + await projectStore.updateTaskMeta(task.uuid, { title: title.trim() }) + } +} + +function taskTitle(t: TaskItem): string { + return t.title || t.uuid.slice(0, 8) + '…' +} + +function relativeTime(ts: string): string { + if (!ts) return '' + const then = new Date(ts).getTime() + if (Number.isNaN(then)) return '' + const mins = Math.floor((Date.now() - then) / 60000) + if (mins < 1) return 'now' + if (mins < 60) return `${mins}m` + const hrs = Math.floor(mins / 60) + if (hrs < 24) return `${hrs}h` + const days = Math.floor(hrs / 24) + if (days < 30) return `${days}d` + return new Date(ts).toLocaleDateString([], { month: 'short', day: 'numeric' }) } </script> <template> <aside class="sidebar"> - <!-- Project header --> + <!-- New task --> <div class="sidebar-header"> - <button - class="project-btn" - @click="emit('openProjects')" - > - <div class="project-logo"> - J - </div> - <div class="project-info"> - <div class="project-name">{{ store.projectName || 'jcode' }}</div> - <div class="project-path">{{ store.pwd }}</div> - </div> - <svg class="chevron-icon" viewBox="0 0 20 20" fill="currentColor"> - <path fill-rule="evenodd" d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z" clip-rule="evenodd" /> - </svg> - </button> - - <button - class="new-chat-btn" - @click="store.newSession()" - > - <svg class="w-4 h-4" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"> - <path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" /> - </svg> - <span>New chat</span> + <button class="new-task-btn" @click="store.newSession()"> + <Plus :size="16" /> + <span>New task</span> </button> </div> - <!-- Sessions list --> - <div class="sessions-list"> - <div v-if="store.sessions.length === 0" class="empty-state"> - No conversations yet + <!-- Workspace tree --> + <div class="tree"> + <div class="tree-head"> + <span class="tree-label">Workspace</span> </div> - <div - v-for="s in store.sessions" - :key="s.uuid" - class="session-item" - :class="{ active: s.uuid === store.currentSessionId }" - @click="store.loadSession(s.uuid)" - > - <div class="session-content"> - <div class="session-title">{{ s.title || s.uuid.slice(0, 8) + '…' }}</div> - <div class="session-subtitle">{{ s.model }} · {{ formatDate(s.created_at) }}</div> - </div> - <button - class="session-delete" - @click.stop="handleDelete(s.uuid)" - title="Delete" - > - <svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"> - <path fill-rule="evenodd" d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.519.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z" clip-rule="evenodd" /> - </svg> + + <div v-if="projectNodes.length === 0" class="empty-state">No projects yet</div> + + <div v-for="proj in projectNodes" :key="proj.path" class="project-group"> + <button class="project-row" :class="{ active: proj.path === activePath }" @click="toggle(proj.path)"> + <ChevronRight :size="14" class="proj-chevron" :class="{ open: isExpanded(proj.path) }" /> + <component :is="projIcon(proj.path)" :size="15" class="proj-icon" /> + <span class="proj-name">{{ proj.name }}</span> + <span v-if="visibleCount(proj.path) > 0" class="proj-count">{{ visibleCount(proj.path) }}</span> </button> + + <div v-show="isExpanded(proj.path)" class="task-list"> + <div v-if="visibleCount(proj.path) === 0" class="task-empty">No tasks</div> + <div + v-for="task in tasksFor(proj.path)" + :key="task.uuid" + class="task-row" + :class="{ active: isActiveTask(task), archived: task.archived }" + @click="openTask(task)" + > + <span class="task-dot" :class="{ unread: task.unread }" aria-hidden="true" /> + <Pin v-if="task.pinned" :size="11" class="task-pin" /> + <span class="task-title">{{ taskTitle(task) }}</span> + <span class="task-time">{{ relativeTime(task.created_at) }}</span> + + <HMenu as="div" class="task-menu" @click.stop> + <MenuButton class="task-menu-btn" title="Task actions" @click.stop="onTaskMenuClick($event, task.uuid)"> + <MoreHorizontal :size="14" /> + </MenuButton> + <transition + enter-active-class="pop-enter-active" + enter-from-class="pop-enter-from" + leave-active-class="pop-leave-active" + leave-to-class="pop-leave-to" + > + <MenuItems class="task-menu-items" :class="{ 'flip-up': flipUpMenus.has(task.uuid) }"> + <MenuItem v-slot="{ active }"> + <button class="tmi" :class="{ hl: active }" @click.stop="projectStore.updateTaskMeta(task.uuid, { pinned: !task.pinned })"> + <Pin :size="14" /> {{ task.pinned ? 'Unpin' : 'Pin' }} + </button> + </MenuItem> + <MenuItem v-slot="{ active }"> + <button class="tmi" :class="{ hl: active }" @click.stop="renameTask(task)"> + <Pencil :size="14" /> Rename + </button> + </MenuItem> + <MenuItem v-slot="{ active }"> + <button class="tmi" :class="{ hl: active }" @click.stop="projectStore.updateTaskMeta(task.uuid, { archived: !task.archived })"> + <component :is="task.archived ? ArchiveRestore : Archive" :size="14" /> {{ task.archived ? 'Unarchive' : 'Archive' }} + </button> + </MenuItem> + <MenuItem v-slot="{ active }"> + <button class="tmi" :class="{ hl: active }" @click.stop="projectStore.updateTaskMeta(task.uuid, { unread: !task.unread })"> + <MailOpen :size="14" /> {{ task.unread ? 'Mark read' : 'Mark unread' }} + </button> + </MenuItem> + <div class="tmi-sep" /> + <MenuItem v-slot="{ active }"> + <button class="tmi danger" :class="{ hl: active }" @click.stop="handleDelete(task)"> + <Trash2 :size="14" /> Delete + </button> + </MenuItem> + </MenuItems> + </transition> + </HMenu> + </div> + </div> </div> </div> <!-- Footer --> <div class="sidebar-footer"> - <div class="footer-model"> - {{ store.modelName || 'no model' }} - </div> <div class="footer-actions"> - <!-- Theme toggle --> - <button - class="footer-btn" - @click="emit('toggleTheme')" - :title="resolvedTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'" - > + <button class="footer-btn" @click="emit('toggleTheme')" :title="resolvedTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"> <svg v-if="resolvedTheme === 'dark'" class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"> <path d="M10 2a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0v-1.5A.75.75 0 0110 2zM10 15a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0v-1.5A.75.75 0 0110 15zM10 7a3 3 0 100 6 3 3 0 000-6zM15.657 5.404a.75.75 0 10-1.06-1.06l-1.061 1.06a.75.75 0 001.06 1.06l1.06-1.06zM6.464 14.596a.75.75 0 10-1.06-1.06l-1.06 1.06a.75.75 0 001.06 1.06l1.06-1.06zM18 10a.75.75 0 01-.75.75h-1.5a.75.75 0 010-1.5h1.5A.75.75 0 0118 10zM5 10a.75.75 0 01-.75.75h-1.5a.75.75 0 010-1.5h1.5A.75.75 0 015 10zM14.596 15.657a.75.75 0 001.06-1.06l-1.06-1.061a.75.75 0 10-1.06 1.06l1.06 1.06zM5.404 6.464a.75.75 0 001.06-1.06l-1.06-1.06a.75.75 0 10-1.06 1.06l1.06 1.06z" /> </svg> @@ -107,12 +264,7 @@ function formatDate(ts: string): string { <path fill-rule="evenodd" d="M7.455 2.004a.75.75 0 01.26.77 7 7 0 009.958 7.967.75.75 0 011.067.853A8.5 8.5 0 116.647 1.921a.75.75 0 01.808.083z" clip-rule="evenodd" /> </svg> </button> - <!-- Settings --> - <button - class="footer-btn" - @click="emit('openSettings')" - title="Settings" - > + <button class="footer-btn" @click="emit('openSettings')" title="Settings"> <svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"> <path fill-rule="evenodd" d="M7.84 1.804A1 1 0 018.82 1h2.36a1 1 0 01.98.804l.331 1.652a6.993 6.993 0 011.929 1.115l1.598-.54a1 1 0 011.186.447l1.18 2.044a1 1 0 01-.205 1.251l-1.267 1.113a7.047 7.047 0 010 2.228l1.267 1.113a1 1 0 01.206 1.25l-1.18 2.045a1 1 0 01-1.187.447l-1.598-.54a6.993 6.993 0 01-1.929 1.115l-.33 1.652a1 1 0 01-.98.804H8.82a1 1 0 01-.98-.804l-.331-1.652a6.993 6.993 0 01-1.929-1.115l-1.598.54a1 1 0 01-1.186-.447l-1.18-2.044a1 1 0 01.205-1.251l1.267-1.114a7.05 7.05 0 010-2.227L1.821 7.773a1 1 0 01-.206-1.25l1.18-2.045a1 1 0 011.187-.447l1.598.54A6.993 6.993 0 017.51 3.456l.33-1.652zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" /> </svg> @@ -129,218 +281,312 @@ function formatDate(ts: string): string { flex-direction: column; flex-shrink: 0; position: relative; - background: var(--color-sidebar-bg); - border-right: 1px solid var(--color-border); + background: var(--color-background); } .sidebar-header { - padding: 12px 12px 0; + padding: 8px 12px 6px; } -.project-btn { +.new-task-btn { display: flex; align-items: center; - gap: 10px; + justify-content: center; + gap: 6px; width: 100%; - padding: 8px; - margin-bottom: 12px; - border: none; - background: transparent; - border-radius: 8px; + padding: 9px 0; + border: 1px solid var(--color-border); + background: var(--color-surface); + border-radius: var(--radius-lg); + font-size: 13px; + font-weight: 500; + color: var(--color-foreground); cursor: pointer; - text-align: left; - transition: background 0.15s; + transition: border-color 0.15s, box-shadow 0.15s, transform 0.08s var(--ease-out); } - -.project-btn:hover { - background: var(--color-muted); +.new-task-btn:hover { + border-color: color-mix(in srgb, var(--color-foreground) 32%, var(--color-border)); + box-shadow: var(--shadow-sm); } - -.project-logo { - width: 28px; - height: 28px; - border-radius: 6px; - display: flex; - align-items: center; - justify-content: center; - font-size: 14px; - font-weight: 700; - font-family: var(--font-mono); - background: var(--color-foreground); - color: var(--color-background); - flex-shrink: 0; +.new-task-btn:active { + transform: translateY(0.5px); } -.project-info { - min-width: 0; +/* ─── Tree ─── */ +.tree { flex: 1; + overflow-y: auto; + padding: 4px 8px 8px; } -.project-name { - font-size: 13px; - font-weight: 600; - color: var(--color-foreground); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - font-family: var(--font-sans); +.tree-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 6px 4px; } - -.project-path { +.tree-label { font-size: 10px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; color: var(--color-muted-foreground); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - font-family: var(--font-mono); -} - -.chevron-icon { - width: 16px; - height: 16px; - color: var(--color-muted-foreground); - flex-shrink: 0; } - -.new-chat-btn { +.tree-head-actions { display: flex; - align-items: center; - justify-content: center; - gap: 6px; - width: 100%; - padding: 9px 0; - border: 1px solid var(--color-border); + gap: 2px; +} +.tree-icon-btn { + display: grid; + place-items: center; + width: 22px; + height: 22px; + border: none; background: transparent; - border-radius: 8px; - font-size: 13px; - font-weight: 500; + border-radius: var(--radius-sm); color: var(--color-muted-foreground); cursor: pointer; - transition: border-color 0.15s, color 0.15s; - margin-bottom: 12px; + transition: background 0.15s, color 0.15s; } - -.new-chat-btn:hover { - border-color: var(--color-foreground); +.tree-icon-btn:hover { + background: var(--color-muted); color: var(--color-foreground); } - -.sessions-list { - flex: 1; - overflow-y: auto; - padding: 0 8px; +.tree-icon-btn.on { + color: var(--color-primary); } .empty-state { text-align: center; font-size: 11px; - padding: 40px 0; + padding: 24px 0; color: var(--color-muted-foreground); } -.session-item { +.project-group { + margin-bottom: 2px; +} + +.project-row { display: flex; align-items: center; - gap: 8px; - padding: 10px 10px; - border-radius: 8px; + gap: 6px; + width: 100%; + padding: 8px 6px; + border: none; + background: transparent; + border-radius: var(--radius-md); cursor: pointer; + text-align: left; transition: background 0.15s; - min-height: 50px; } - -.session-item:hover { +.project-row:hover { background: var(--color-muted); } - -.session-item.active { - background: var(--color-muted); +.proj-chevron { + color: var(--color-muted-foreground); + flex-shrink: 0; + transition: transform 0.15s; } - -.session-content { - min-width: 0; - flex: 1; - display: flex; - flex-direction: column; - gap: 2px; +.proj-chevron.open { + transform: rotate(90deg); } - -.session-title { +.proj-icon { + color: var(--color-muted-foreground); + flex-shrink: 0; +} +.project-row.active .proj-icon { + color: var(--color-primary); +} +.proj-name { + flex: 1; + min-width: 0; font-size: 13px; font-weight: 500; color: var(--color-foreground); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - line-height: 1.3; +} +.proj-count { + font-size: 10px; + font-family: var(--font-mono); + color: var(--color-muted-foreground); + flex-shrink: 0; } -.session-subtitle { +.task-list { + padding-left: 14px; +} +.task-empty { font-size: 11px; color: var(--color-muted-foreground); + padding: 5px 8px; +} + +.task-row { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 8px; + margin-left: 6px; + border-left: 1px solid var(--color-border); + cursor: pointer; + transition: background 0.15s; + position: relative; +} +.task-row:hover { + background: var(--color-muted); +} +.task-row.active { + background: var(--accent-wash-soft); + border-left-color: var(--color-primary); +} +.task-row.archived { + opacity: 0.55; +} + +.task-dot { + width: 6px; + height: 6px; + border-radius: var(--radius-pill); + flex-shrink: 0; + background: transparent; +} +.task-dot.unread { + background: var(--color-primary); +} +.task-pin { + color: var(--color-primary); + flex-shrink: 0; +} +.task-title { + flex: 1; + min-width: 0; + font-size: 13px; + color: var(--color-foreground); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - line-height: 1.3; +} +.task-time { + font-size: 10px; + font-family: var(--font-mono); + color: var(--color-muted-foreground); + flex-shrink: 0; } -.session-delete { - opacity: 0; +/* ─── Task action menu ─── */ +.task-menu { + position: relative; + display: inline-flex; flex-shrink: 0; - width: 24px; - height: 24px; +} +.task-menu-btn { + display: grid; + place-items: center; + width: 20px; + height: 20px; + border: none; + background: transparent; + border-radius: var(--radius-sm); + color: var(--color-muted-foreground); + cursor: pointer; + opacity: 0; + transition: opacity 0.15s, background 0.15s, color 0.15s; +} +.task-row:hover .task-menu-btn { + opacity: 1; +} +.task-menu-btn:hover { + background: var(--color-secondary); + color: var(--color-foreground); +} +.task-menu-items.flip-up { + top: auto; + bottom: calc(100% + 4px); +} +.task-menu-items { + position: absolute; + top: calc(100% + 4px); + right: 0; + min-width: 160px; + padding: 4px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + z-index: var(--z-dropdown); + outline: none; +} +.tmi { display: flex; align-items: center; - justify-content: center; - border-radius: 4px; + gap: 8px; + width: 100%; + padding: 7px 8px; border: none; background: transparent; - color: var(--color-muted-foreground); + border-radius: var(--radius-md); + color: var(--color-foreground); + font-size: 12.5px; + text-align: left; cursor: pointer; - transition: opacity 0.15s; +} +.tmi.hl { + background: var(--color-muted); +} +.tmi.danger { + color: var(--color-destructive); +} +.tmi-sep { + height: 1px; + margin: 4px 0; + background: var(--color-border); } -.session-item:hover .session-delete { - opacity: 1; +.pop-enter-active, +.pop-leave-active { + transition: opacity 0.12s ease, transform 0.12s ease; +} +.pop-enter-from, +.pop-leave-to { + opacity: 0; + transform: translateY(-4px); } +/* ─── Footer ─── */ .sidebar-footer { padding: 10px 12px; display: flex; align-items: center; - justify-content: space-between; + justify-content: flex-end; } - -.footer-model { - font-size: 11px; - font-family: var(--font-mono); - color: var(--color-muted-foreground); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 160px; -} - .footer-actions { display: flex; align-items: center; gap: 2px; } - .footer-btn { - width: 28px; - height: 28px; + width: 34px; + height: 34px; display: flex; align-items: center; justify-content: center; - border-radius: 6px; + border-radius: var(--radius-md); border: none; background: transparent; color: var(--color-muted-foreground); cursor: pointer; - transition: color 0.15s; + transition: background 0.15s, color 0.15s; +} +.footer-btn:hover { + background: var(--color-muted); +} +.footer-btn svg { + width: 18px; + height: 18px; } - .footer-btn:hover { color: var(--color-foreground); } diff --git a/web/src/components/TaskList.vue b/web/src/components/TaskList.vue index 25a03be..e20bef0 100644 --- a/web/src/components/TaskList.vue +++ b/web/src/components/TaskList.vue @@ -40,7 +40,7 @@ onBeforeUnmount(() => { <span v-if="todo.status === 'in_progress'" class="absolute left-0 top-0.5 bottom-0.5 w-0.5" - style="background-color: var(--color-primary); border-radius: 9999px" + style="background-color: var(--color-primary); border-radius: var(--radius-pill)" aria-hidden="true" /> diff --git a/web/src/components/TerminalInstance.vue b/web/src/components/TerminalInstance.vue index ffcfdbc..5f61db0 100644 --- a/web/src/components/TerminalInstance.vue +++ b/web/src/components/TerminalInstance.vue @@ -210,5 +210,5 @@ onUnmounted(cleanup) } :deep(.xterm-viewport::-webkit-scrollbar-thumb) { background: var(--color-border); - border-radius: 3px; + border-radius: var(--radius-xs); }</style> diff --git a/web/src/components/TerminalPanel.vue b/web/src/components/TerminalPanel.vue index 261503d..6904b4c 100644 --- a/web/src/components/TerminalPanel.vue +++ b/web/src/components/TerminalPanel.vue @@ -113,7 +113,7 @@ function closeTab(id: string) { font-family: var(--font-mono); font-weight: 500; border: none; - border-radius: 4px; + border-radius: var(--radius-sm); background: transparent; cursor: pointer; color: var(--color-muted-foreground); @@ -140,7 +140,7 @@ function closeTab(id: string) { height: 14px; padding: 0; border: none; - border-radius: 2px; + border-radius: var(--radius-xs); background: transparent; color: inherit; cursor: pointer; @@ -173,7 +173,7 @@ function closeTab(id: string) { height: 20px; border: none; background: transparent; - border-radius: 4px; + border-radius: var(--radius-sm); cursor: pointer; color: var(--color-muted-foreground); transition: background 0.1s, color 0.1s; diff --git a/web/src/components/ToolCallCard.vue b/web/src/components/ToolCallCard.vue index 0189822..c407d06 100644 --- a/web/src/components/ToolCallCard.vue +++ b/web/src/components/ToolCallCard.vue @@ -349,9 +349,9 @@ function formatArgs(args: string): string { <path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd" /> </svg> <span v-if="renderType === 'diff' && diffData.added + diffData.deleted > 0" class="ml-auto text-[10px] font-mono tabular-nums shrink-0"> - <span v-if="diffData.added" style="color: var(--color-primary)">+{{ diffData.added }}</span> + <span v-if="diffData.added" style="color: var(--color-success-fg)">+{{ diffData.added }}</span> <span v-if="diffData.added && diffData.deleted" class="mx-0.5" style="color: var(--color-muted-foreground)">/</span> - <span v-if="diffData.deleted" class="text-red-500 dark:text-red-400">-{{ diffData.deleted }}</span> + <span v-if="diffData.deleted" style="color: var(--color-error-fg)">-{{ diffData.deleted }}</span> </span> </button> @@ -373,33 +373,32 @@ function formatArgs(args: string): string { <path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd" /> </svg> <span v-if="renderType === 'diff' && diffData.added + diffData.deleted > 0" class="ml-auto text-[10px] font-mono tabular-nums shrink-0"> - <span v-if="diffData.added" style="color: var(--color-primary)">+{{ diffData.added }}</span> + <span v-if="diffData.added" style="color: var(--color-success-fg)">+{{ diffData.added }}</span> <span v-if="diffData.added && diffData.deleted" class="mx-0.5" style="color: var(--color-muted-foreground)">/</span> - <span v-if="diffData.deleted" class="text-red-500 dark:text-red-400">-{{ diffData.deleted }}</span> + <span v-if="diffData.deleted" style="color: var(--color-error-fg)">-{{ diffData.deleted }}</span> </span> </div> <!-- Content box: only content gets the border --> <div - class="overflow-hidden ml-0 mr-2 mt-1 mb-1" - :class="tool.status === 'error' ? 'border border-red-300/60 dark:border-red-500/30' : ''" - :style="{ borderRadius: 'var(--radius-xl)', border: tool.status !== 'error' ? '1px solid var(--color-border)' : undefined }" + class="overflow-hidden ml-0 mr-2 mt-1.5 mb-1.5" + :style="{ borderRadius: 'var(--radius-xl)', border: tool.status === 'error' ? '1px solid var(--color-destructive)' : '1px solid var(--color-border)' }" > <!-- ═══════ Terminal (execute) ═══════ --> - <div v-if="renderType === 'terminal'" class="bg-[#fafafa] dark:bg-[#0d1117] px-3 py-2 font-mono text-xs max-h-72 overflow-y-auto"> + <div v-if="renderType === 'terminal'" class="px-3 py-2 font-mono text-xs max-h-72 overflow-y-auto" style="background: var(--color-muted)"> <div> - <span class="text-zinc-400 dark:text-zinc-500 select-none">$ </span> - <span class="text-zinc-800 dark:text-zinc-200">{{ terminalCommand }}</span> + <span class="select-none" style="color: var(--color-muted-foreground)">$ </span> + <span style="color: var(--color-foreground)">{{ terminalCommand }}</span> </div> - <div v-if="tool.displayOutput || tool.output" class="text-zinc-600 dark:text-zinc-400 mt-1 whitespace-pre-wrap break-all">{{ truncate(tool.displayOutput || tool.output || '', 2000) }}</div> - <div v-if="tool.error" class="text-red-600 dark:text-red-400 mt-1 whitespace-pre-wrap">{{ tool.error }}</div> - <div v-if="tool.status === 'running'" class="text-zinc-400 dark:text-zinc-500 mt-1 animate-pulse">running…</div> + <div v-if="tool.displayOutput || tool.output" class="mt-1 whitespace-pre-wrap break-all" style="color: var(--color-muted-foreground)">{{ truncate(tool.displayOutput || tool.output || '', 2000) }}</div> + <div v-if="tool.error" class="mt-1 whitespace-pre-wrap" style="color: var(--color-error-fg)">{{ tool.error }}</div> + <div v-if="tool.status === 'running'" class="mt-1 animate-pulse" style="color: var(--color-muted-foreground)">running…</div> </div> <!-- ═══════ File Viewer (read/write) ═══════ --> - <div v-else-if="renderType === 'file-viewer'" class="bg-white dark:bg-[#0d1117] max-h-72 overflow-y-auto"> + <div v-else-if="renderType === 'file-viewer'" class="max-h-72 overflow-y-auto" style="background: var(--color-surface)"> <table v-if="fileLines.length" class="w-full text-xs font-mono border-collapse"> - <tr v-for="line in fileLines" :key="line.num" class="hover:bg-zinc-100/50 dark:hover:bg-zinc-800/30"> + <tr v-for="line in fileLines" :key="line.num" class="hover:bg-[var(--accent-wash-soft)]"> <td class="text-right select-none pr-3 pl-2 py-0 w-1 align-top whitespace-nowrap" style="color: color-mix(in srgb, var(--color-muted-foreground), transparent 40%)">{{ line.num }}</td> <td class="pr-3 py-0 whitespace-pre-wrap break-all" style="color: var(--color-foreground)">{{ line.text }}</td> </tr> @@ -410,27 +409,22 @@ function formatArgs(args: string): string { </div> <!-- ═══════ Diff Viewer (edit/multi_edit) ═══════ --> - <div v-else-if="renderType === 'diff'" class="bg-white dark:bg-[#0d1117] max-h-72 overflow-y-auto"> + <div v-else-if="renderType === 'diff'" class="max-h-72 overflow-y-auto" style="background: var(--color-surface)"> <table v-if="diffData.sections.length" class="w-full text-xs font-mono border-collapse"> <template v-for="(section, si) in diffData.sections" :key="si"> <tr v-for="(line, li) in section.lines" :key="`${si}-${li}`" - :class="{ - 'bg-red-50/80 dark:bg-red-500/10': section.type === 'del', - 'bg-emerald-50/80 dark:bg-emerald-500/10': section.type === 'add', - }" + :style="section.type === 'del' ? 'background: var(--color-error-bg)' : section.type === 'add' ? 'background: var(--color-success-bg)' : ''" > <td class="text-right select-none pr-2 pl-2 py-0 w-1 align-top whitespace-nowrap" - :class="{ 'text-red-300 dark:text-red-500/60': section.type === 'del', 'text-emerald-300 dark:text-emerald-500/60': section.type === 'add' }" - :style="section.type === 'context' ? 'color: color-mix(in srgb, var(--color-muted-foreground), transparent 40%)' : ''" + :style="section.type === 'del' ? 'color: var(--color-error-fg)' : section.type === 'add' ? 'color: var(--color-success-fg)' : 'color: color-mix(in srgb, var(--color-muted-foreground), transparent 40%)'" >{{ li + 1 }}</td> <td class="px-1 py-0 w-4 select-none font-bold" - :class="{ 'text-red-400 dark:text-red-400': section.type === 'del', 'text-emerald-500 dark:text-emerald-400': section.type === 'add' }" + :style="section.type === 'del' ? 'color: var(--color-error-fg)' : section.type === 'add' ? 'color: var(--color-success-fg)' : ''" >{{ section.type === 'del' ? '-' : section.type === 'add' ? '+' : ' ' }}</td> <td class="pr-3 py-0 whitespace-pre-wrap break-all" - :class="{ 'text-red-700 dark:text-red-300': section.type === 'del', 'text-emerald-700 dark:text-emerald-300': section.type === 'add' }" - :style="section.type === 'context' ? 'color: var(--color-foreground)' : ''" + :style="section.type === 'del' ? 'color: var(--color-error-fg)' : section.type === 'add' ? 'color: var(--color-success-fg)' : 'color: var(--color-foreground)'" >{{ line }}</td> </tr> </template> diff --git a/web/src/components/TopBar.vue b/web/src/components/TopBar.vue index 2ae0449..5d8b3ac 100644 --- a/web/src/components/TopBar.vue +++ b/web/src/components/TopBar.vue @@ -1,42 +1,22 @@ <script setup lang="ts"> import { computed, ref, onMounted, watch } from 'vue' -import { - Menu, - SquareTerminal, - FileDiff, - FolderOpen, - ListChecks, - GitBranch, - GitCommitVertical, - ChevronRight, - ChevronDown, - PanelRight, -} from 'lucide-vue-next' -import { - Popover, - PopoverButton, - PopoverPanel, - Menu as HMenu, - MenuButton, - MenuItems, - MenuItem, -} from '@headlessui/vue' -import { useChatStore } from '@/stores/chat' +import { SquareTerminal, FileDiff, FolderOpen, ListChecks, ChevronDown, PanelRight } from 'lucide-vue-next' +import { Menu as HMenu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue' import { api } from '@/composables/api' type PanelType = 'terminal' | 'files' | 'changes' | 'plan' -const store = useChatStore() - const props = defineProps<{ - projectName: string - pwd: string isRunning: boolean wsConnected: boolean activePanel: 'none' | PanelType terminalOpen: boolean }>() +const emit = defineEmits<{ + 'toggle-panel': [panel: PanelType] +}>() + // Terminal is a bottom panel and can be open alongside a right-panel tab, so it // is tracked separately from activePanel (which reflects the right panel only). function isCurrent(panel: PanelType): boolean { @@ -44,15 +24,10 @@ function isCurrent(panel: PanelType): boolean { return props.activePanel === panel } -const emit = defineEmits<{ - 'toggle-sidebar': [] - 'toggle-panel': [panel: PanelType] -}>() - const statusColor = computed(() => { - if (props.isRunning) return '#f59e0b' - if (props.wsConnected) return '#22c55e' - return '#9ca3af' + if (props.isRunning) return 'var(--color-primary)' + if (props.wsConnected) return 'var(--color-success)' + return 'var(--color-muted-foreground)' }) const statusLabel = computed(() => { @@ -61,20 +36,6 @@ const statusLabel = computed(() => { return 'Disconnected' }) -const sessionTitle = computed(() => { - const session = store.sessions.find(s => s.uuid === store.currentSessionId) - return session?.title || 'New Chat' -}) - -const sessionSubtitle = computed(() => { - const session = store.sessions.find(s => s.uuid === store.currentSessionId) - if (!session) return store.modelName || '' - const model = session.model || store.modelName || '' - const d = new Date(session.created_at) - const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) - return `${model} · ${time}` -}) - const panelButtons = [ { panel: 'plan' as PanelType, icon: ListChecks, label: 'Plan', shortcut: '⇧⌘P' }, { panel: 'files' as PanelType, icon: FolderOpen, label: 'Files', shortcut: '⇧⌘E' }, @@ -82,17 +43,9 @@ const panelButtons = [ { panel: 'terminal' as PanelType, icon: SquareTerminal, label: 'Terminal', shortcut: '⌘`' }, ] -// Branch name is not exposed to the web frontend. The backend computes it -// (internal/util/envinfo.go GitBranch via `git rev-parse --abbrev-ref HEAD`) -// but no /api endpoint returns it, so we render the chip without a branch -// label rather than fabricating one. See followups. -const branchName = computed<string | null>(() => null) - -// Diff stats are fetched on demand from the real /api/diff endpoint (working -// tree). We never fabricate numbers: if the fetch fails or returns nothing, -// diffStat stays null and the stat is omitted. +// Working-tree diff stat, shown inline on the Changes item. Fetched from the +// real /api/diff endpoint; never fabricated (null on failure / clean tree). const diffStat = ref<{ additions: number; deletions: number } | null>(null) -const diffLoaded = ref(false) async function loadDiffStat() { try { @@ -103,23 +56,9 @@ async function loadDiffStat() { } catch (err) { console.error('Failed to fetch diff stat:', err) diffStat.value = null - } finally { - diffLoaded.value = true } } -// Refresh stats whenever the popover is opened so the chip reflects current state. -function onChipClick() { - loadDiffStat() -} - -function openChanges(close: () => void) { - emit('toggle-panel', 'changes') - close() -} - -// Show the diff stat on first paint and refresh it whenever a run finishes (the -// working tree likely changed), not only when the chip is clicked. onMounted(loadDiffStat) watch( () => props.isRunning, @@ -130,213 +69,81 @@ watch( </script> <template> - <header class="topbar"> - <div class="topbar-left"> - <button - class="icon-btn" - aria-label="Toggle sidebar" - @click="emit('toggle-sidebar')" + <!-- The single top-right control, floated into the title-bar zone. The panel + menu carries Plan/Files/Changes/Terminal; the dot on the button reflects + live connection status. Inline position:relative — headlessui's root + doesn't get the SFC scoped attribute, so the absolute menu would otherwise + anchor to the wrong ancestor. --> + <div class="topbar-control"> + <HMenu as="div" class="panel-menu" style="position: relative" v-slot="{ open }"> + <MenuButton + class="panel-menu-btn" + :class="{ open }" + aria-label="Open panel" + :title="`Panels · ${statusLabel}`" + @click="loadDiffStat" > - <Menu :size="16" /> - </button> - <div class="session-info"> - <span class="session-title">{{ sessionTitle }}</span> - <span class="session-subtitle">{{ sessionSubtitle }}</span> - </div> - </div> - - <div class="topbar-right"> - <!-- Panel menu --> - <HMenu as="div" class="panel-menu" v-slot="{ open }"> - <MenuButton class="panel-menu-btn" :class="{ open }" aria-label="Open panel" title="Panels"> - <PanelRight :size="16" /> - <ChevronDown :size="14" class="panel-menu-caret" /> - </MenuButton> - <transition - enter-active-class="pop-enter-active" - enter-from-class="pop-enter-from" - leave-active-class="pop-leave-active" - leave-to-class="pop-leave-to" - > - <MenuItems class="panel-menu-items"> - <MenuItem v-for="btn in panelButtons" :key="btn.panel" v-slot="{ active }"> - <button - class="panel-menu-item" - :class="{ highlight: active, current: isCurrent(btn.panel) }" - :aria-current="isCurrent(btn.panel) ? 'true' : undefined" - @click="emit('toggle-panel', btn.panel)" - > - <component :is="btn.icon" :size="16" class="pmi-icon" /> - <span class="pmi-label">{{ btn.label }}</span> - <span class="pmi-key">{{ btn.shortcut }}</span> - </button> - </MenuItem> - </MenuItems> - </transition> - </HMenu> - - <!-- Workspace chip + popover --> - <Popover class="workspace-popover" v-slot="{ close }"> - <PopoverButton class="workspace-chip" :aria-label="`Workspace status: ${statusLabel}`" :title="statusLabel" @click="onChipClick"> - <span class="chip-branch"> - <GitBranch :size="16" /> - <span v-if="branchName" class="chip-branch-name">{{ branchName }}</span> - </span> - <template v-if="diffStat"> - <span class="chip-divider" /> - <span class="chip-stat"> - <span class="stat-add text-emerald-600 dark:text-emerald-400">+{{ diffStat.additions }}</span> - <span class="stat-del text-red-500 dark:text-red-400">-{{ diffStat.deletions }}</span> - </span> - </template> - <span class="chip-divider" /> - <span class="status-dot" :style="{ backgroundColor: statusColor }" /> - </PopoverButton> - - <transition - enter-active-class="pop-enter-active" - enter-from-class="pop-enter-from" - leave-active-class="pop-leave-active" - leave-to-class="pop-leave-to" - > - <PopoverPanel class="workspace-panel"> - <!-- Changes row --> - <div class="ws-row"> - <FileDiff :size="16" class="ws-icon" /> - <span class="ws-label">Changes</span> - <span class="ws-right"> - <span v-if="diffStat" class="chip-stat"> - <span class="stat-add text-emerald-600 dark:text-emerald-400">+{{ diffStat.additions }}</span> - <span class="stat-del text-red-500 dark:text-red-400">-{{ diffStat.deletions }}</span> - </span> - <button class="ws-action" @click="openChanges(close)">Review</button> - </span> - </div> - - <!-- Branch row --> - <div class="ws-row"> - <GitBranch :size="16" class="ws-icon" /> - <span class="ws-label"> - {{ branchName || 'Branch' }} - </span> - <span class="ws-right"> - <ChevronRight :size="16" class="ws-chevron" /> - </span> - </div> - - <!-- Commit or push row --> - <div class="ws-row"> - <GitCommitVertical :size="16" class="ws-icon" /> - <span class="ws-label">Commit or push</span> - <span class="ws-right"> - <ChevronRight :size="16" class="ws-chevron" /> + <PanelRight :size="15" /> + <ChevronDown :size="13" class="panel-menu-caret" /> + <span class="status-dot panel-status-dot" :style="{ backgroundColor: statusColor }" /> + </MenuButton> + <transition + enter-active-class="pop-enter-active" + enter-from-class="pop-enter-from" + leave-active-class="pop-leave-active" + leave-to-class="pop-leave-to" + > + <MenuItems class="panel-menu-items"> + <MenuItem v-for="btn in panelButtons" :key="btn.panel" v-slot="{ active }"> + <button + class="panel-menu-item" + :class="{ highlight: active, current: isCurrent(btn.panel) }" + :aria-current="isCurrent(btn.panel) ? 'true' : undefined" + @click="emit('toggle-panel', btn.panel)" + > + <component :is="btn.icon" :size="16" class="pmi-icon" /> + <span class="pmi-label">{{ btn.label }}</span> + <span v-if="btn.panel === 'changes' && diffStat" class="pmi-stat"> + <span style="color: var(--color-success-fg)">+{{ diffStat.additions }}</span> + <span style="color: var(--color-error-fg)">-{{ diffStat.deletions }}</span> </span> - </div> - - <div class="ws-sep" /> - - <!-- Status row --> - <div class="ws-row ws-row-static"> - <span class="status-dot ws-icon-dot" :style="{ backgroundColor: statusColor }" /> - <span class="ws-label">{{ statusLabel }}</span> - <span class="ws-right ws-model" v-if="store.modelName">{{ store.modelName }}</span> - </div> - </PopoverPanel> - </transition> - </Popover> - </div> - </header> + <span class="pmi-key">{{ btn.shortcut }}</span> + </button> + </MenuItem> + </MenuItems> + </transition> + </HMenu> + </div> </template> <style scoped> -.topbar { - height: 52px; - background: var(--color-sidebar-bg); - border-bottom: 1px solid var(--color-border); - padding: 0 14px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; +/* Floated into the top-right of the window's title-bar zone (anchored to the + position:relative .app-shell). z above the drag strip so it stays clickable. */ +.topbar-control { + position: absolute; + top: 7px; + right: 14px; + /* Above the title-bar drag strip (45) so it stays clickable, but below + --z-modal (50) so the Settings overlay covers it. */ + z-index: 46; font-family: var(--font-sans); } -.topbar-left { - display: flex; - align-items: center; - gap: 10px; - min-width: 0; - flex: 1; -} - -.topbar-right { - display: flex; - align-items: center; - gap: 8px; - flex: 1; - justify-content: flex-end; -} - -.icon-btn { - display: flex; - align-items: center; - justify-content: center; - padding: 6px; - border: none; - background: transparent; - border-radius: 6px; - color: var(--color-muted-foreground); - cursor: pointer; - transition: background 0.15s, color 0.15s; -} - -.icon-btn:hover { - color: var(--color-foreground); -} - -.session-info { - display: flex; - flex-direction: column; - min-width: 0; - gap: 1px; -} - -.session-title { - font-size: 13px; - font-weight: 600; - color: var(--color-foreground); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 220px; - line-height: 1.3; -} - -.session-subtitle { - font-size: 11px; - color: var(--color-muted-foreground); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 220px; - line-height: 1.3; -} - -/* Panel menu */ .panel-menu { position: relative; display: inline-flex; } .panel-menu-btn { + position: relative; display: inline-flex; align-items: center; - gap: 3px; - height: 30px; - padding: 0 8px; + gap: 2px; + height: 28px; + padding: 0 7px; border: 1px solid var(--color-border); border-radius: var(--radius-lg); - background: transparent; + background: var(--color-background); color: var(--color-muted-foreground); cursor: pointer; transition: background 0.15s, color 0.15s, border-color 0.15s; @@ -356,6 +163,23 @@ watch( opacity: 0.6; } +/* Small live-status dot on the corner of the button. The border matches the + shell tone so the dot reads as a separate indicator. */ +.status-dot { + width: 8px; + height: 8px; + border-radius: var(--radius-pill); + flex-shrink: 0; +} +.panel-status-dot { + position: absolute; + top: 4px; + right: 4px; + width: 7px; + height: 7px; + border: 1.5px solid var(--color-background); +} + .panel-menu-items { position: absolute; top: calc(100% + 6px); @@ -410,159 +234,17 @@ watch( flex-shrink: 0; } -/* Workspace chip */ -.workspace-popover { - position: relative; - display: inline-flex; -} - -.workspace-chip { - display: inline-flex; - align-items: center; - gap: 8px; - height: 30px; - padding: 0 10px; - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - background: transparent; - color: var(--color-muted-foreground); - cursor: pointer; - transition: border-color 0.15s, color 0.15s; -} - -.workspace-chip:hover { - border-color: var(--color-foreground); - color: var(--color-foreground); -} - -.chip-branch { - display: inline-flex; - align-items: center; - gap: 5px; -} - -.chip-branch-name { - font-size: 12px; - font-weight: 500; - max-width: 140px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.chip-divider { - width: 1px; - height: 16px; - background: var(--color-border); -} - -.chip-stat { +/* Working-tree diff stat shown inline on the Changes menu item. */ +.pmi-stat { display: inline-flex; align-items: center; gap: 6px; - font-family: var(--font-mono); - font-size: 12px; -} - -.status-dot { - width: 8px; - height: 8px; - border-radius: 9999px; - flex-shrink: 0; -} - -/* Workspace popover panel */ -.workspace-panel { - position: absolute; - top: calc(100% + 6px); - right: 0; - width: 260px; - padding: 4px; - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-md); - z-index: var(--z-dropdown); -} - -.ws-row { - display: flex; - align-items: center; - gap: 8px; - padding: 8px; - border-radius: var(--radius-md); - font-size: 13px; - color: var(--color-foreground); -} - -.ws-row:not(.ws-row-static):hover { - background: var(--color-muted); -} - -.ws-icon { - color: var(--color-muted-foreground); - flex-shrink: 0; -} - -.ws-icon-dot { - width: 8px; - height: 8px; - margin: 0 4px; -} - -.ws-label { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.ws-right { - display: inline-flex; - align-items: center; - gap: 8px; - flex-shrink: 0; -} - -.ws-chevron { - color: var(--color-muted-foreground); -} - -.ws-action { - border: 1px solid var(--color-border); - background: transparent; - border-radius: var(--radius-md); - padding: 2px 10px; - font-size: 12px; - font-weight: 500; - color: var(--color-foreground); - cursor: pointer; - transition: background 0.15s, border-color 0.15s; -} - -.ws-action:hover { - background: var(--color-muted); - border-color: var(--color-foreground); -} - -.ws-sep { - height: 1px; - margin: 4px 0; - background: var(--color-border); -} - -.ws-model { font-family: var(--font-mono); font-size: 11px; - color: var(--color-muted-foreground); - max-width: 140px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + flex-shrink: 0; } -/* Popover transition */ +/* Menu transition */ .pop-enter-active, .pop-leave-active { transition: opacity 0.12s ease, transform 0.12s ease; diff --git a/web/src/components/WorkspacePicker.vue b/web/src/components/WorkspacePicker.vue new file mode 100644 index 0000000..5ab4bbb --- /dev/null +++ b/web/src/components/WorkspacePicker.vue @@ -0,0 +1,501 @@ +<script setup lang="ts"> +import { ref, computed, inject } from 'vue' +import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue' +import { + Folder, + FolderOpen, + Check, + Plus, + Server, + ChevronDown, + Search, + ArrowLeft, +} from 'lucide-vue-next' +import { useChatStore } from '@/stores/chat' +import { useProjectStore, isRemotePath, parseRemoteLabel } from '@/stores/project' +import { useFolderBrowser } from '@/composables/useFolderBrowser' +import { isTauri, pickFolder } from '@/composables/useDesktop' +import type { RemoteMeta } from '@/types/api' + +withDefaults(defineProps<{ + // Which way the panel opens relative to the trigger. The composer sits near + // the bottom of the viewport, so it opens upward by default. + placement?: 'top' | 'bottom' +}>(), { placement: 'top' }) + +const store = useChatStore() +const projectStore = useProjectStore() + +// Provided by App: opens the SSH wizard, optionally prefilled for a reconnect. +const openRemoteConnect = inject<(prefill?: RemoteMeta & { loadTaskUuid?: string }) => void>('openRemoteConnect') + +const query = ref('') + +const activePath = computed(() => projectStore.activeProject?.path || store.pwd) +const activeIsRemote = computed(() => isRemotePath(activePath.value)) +const activeName = computed(() => { + const p = activePath.value + if (!p) return 'No workspace' + return projectStore.nameForPath(p) +}) + +const workspaces = computed(() => { + const q = query.value.trim().toLowerCase() + const nodes = projectStore.projectsForTree + if (!q) return nodes + return nodes.filter((n) => n.name.toLowerCase().includes(q) || n.path.toLowerCase().includes(q)) +}) + +function isActive(path: string): boolean { + return path === activePath.value +} + +// ─── Folder browser sub-view (shared logic, see useFolderBrowser) ─── +const { + showBrowser, + browsePath, + browseFolders, + browseLoading, + pathInput, + loadFolders, + openBrowser, + goUp, + handlePathSubmit, + resetBrowser, +} = useFolderBrowser() + +async function applyLocalSwitch(path: string, close: () => void) { + const ok = await projectStore.openProject(path) + if (!ok) return + await store.resetToWelcomeAfterSwitch() + reset() + close() +} + +async function pickWorkspace(node: { id: string; path: string }, close: () => void) { + // Remote workspaces must be reconnected through the SSH wizard. + if (isRemotePath(node.path)) { + const meta = parseRemoteLabel(node.path) + close() + reset() + if (meta) openRemoteConnect?.(meta) + return + } + if (node.path === activePath.value) { + // Same workspace → start a fresh task in it. + await store.newSession() + reset() + close() + return + } + await applyLocalSwitch(node.path, close) +} + +// "Open folder": on the desktop, use the native OS folder picker; in the +// browser, fall back to the in-app folder browser sub-view. +async function openFolderAction(close: () => void) { + if (isTauri) { + const path = await pickFolder(activePath.value || undefined) + if (path) await applyLocalSwitch(path, close) + return + } + openBrowser() +} + +function openRemote(close: () => void) { + close() + reset() + openRemoteConnect?.() +} + +function reset() { + resetBrowser() + query.value = '' +} + +</script> + +<template> + <div class="ws-bar"> + <!-- Inline position:relative — headlessui's Popover root doesn't receive the + SFC scoped attribute, so the scoped `.ws-popover { position: relative }` + never applies and the absolutely-positioned panel would otherwise anchor + to the composer card (landing far below the pill). Inline style is the + reliable way to make the panel sit right next to its trigger. --> + <Popover class="ws-popover" style="position: relative"> + <PopoverButton as="template" :disabled="store.isRunning"> + <button class="ws-pill ws-pill-action" :disabled="store.isRunning" :title="activePath"> + <component :is="activeIsRemote ? Server : FolderOpen" :size="14" class="ws-pill-icon" /> + <span class="ws-name">{{ activeName }}</span> + <ChevronDown :size="13" class="ws-caret" /> + </button> + </PopoverButton> + + <transition + enter-active-class="pop-enter-active" + enter-from-class="pop-enter-from" + leave-active-class="pop-leave-active" + leave-to-class="pop-leave-to" + > + <PopoverPanel + v-slot="{ close }" + class="ws-panel" + :class="placement === 'top' ? 'place-top' : 'place-bottom'" + > + <!-- Folder browser --> + <div v-if="showBrowser" class="ws-browser"> + <div class="ws-browser-head"> + <button class="ws-back" @click="showBrowser = false"><ArrowLeft :size="14" /></button> + <input + v-model="pathInput" + class="ws-path-input" + placeholder="/path/to/folder" + @keydown.enter="handlePathSubmit" + /> + </div> + <div class="ws-list"> + <button + v-if="browsePath && browsePath !== '/'" + class="ws-row ws-folder" + @click="goUp" + > + <span class="ws-folder-icon">..</span> + </button> + <div v-if="browseLoading" class="ws-hint">Loading…</div> + <div v-else-if="browseFolders.length === 0" class="ws-hint">No folders</div> + <button + v-for="folder in browseFolders" + :key="folder.path" + class="ws-row ws-folder" + @click="loadFolders(folder.path)" + > + <Folder :size="14" class="ws-folder-icon" /> + <span class="ws-row-name">{{ folder.name }}</span> + </button> + </div> + <div class="ws-browser-foot"> + <span class="ws-cur-path">{{ browsePath || '~' }}</span> + <button class="ws-open-btn" @click="applyLocalSwitch(browsePath, close)">Open</button> + </div> + </div> + + <!-- Workspace list --> + <div v-else class="ws-listview"> + <div class="ws-search"> + <Search :size="13" class="ws-search-icon" /> + <input v-model="query" class="ws-search-input" placeholder="Search workspaces" /> + </div> + + <div class="ws-list"> + <div v-if="workspaces.length === 0" class="ws-hint">No workspaces</div> + <button + v-for="node in workspaces" + :key="node.path" + class="ws-row" + :class="{ active: isActive(node.path) }" + @click="pickWorkspace(node, close)" + > + <component :is="isRemotePath(node.path) ? Server : Folder" :size="14" class="ws-row-icon" /> + <span class="ws-row-name">{{ node.name }}</span> + <Check v-if="isActive(node.path)" :size="14" class="ws-check" /> + </button> + </div> + + <div class="ws-actions"> + <button class="ws-action" @click="openFolderAction(close)"> + <Plus :size="14" /> <span>Open folder</span> + </button> + <button class="ws-action" @click="openRemote(close)"> + <Server :size="14" /> <span>Remote connect</span> + </button> + </div> + </div> + </PopoverPanel> + </transition> + </Popover> + </div> +</template> + +<style scoped> +.ws-bar { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; +} +.ws-popover { + position: relative; + display: inline-flex; + min-width: 0; +} + +/* Two distinct pills: an interactive workspace selector + a read-only branch. */ +.ws-pill { + display: inline-flex; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 9px; + border: 1px solid transparent; + border-radius: var(--radius-lg); + font-size: 12.5px; + color: var(--color-foreground); + min-width: 0; +} +.ws-pill-icon { + flex-shrink: 0; +} + +.ws-pill-action { + max-width: 230px; + background: transparent; + cursor: pointer; + transition: background 0.15s, transform 0.06s ease; +} +.ws-pill-action .ws-pill-icon { + color: var(--color-primary); +} +.ws-pill-action:hover:not(:disabled) { + background: var(--color-muted); +} +.ws-pill-action:active:not(:disabled) { + transform: translateY(0.5px); +} +.ws-pill-action:disabled { + opacity: 0.55; + cursor: not-allowed; +} +.ws-name { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.ws-caret { + color: var(--color-muted-foreground); + flex-shrink: 0; + margin-left: 1px; +} + +.ws-panel { + position: absolute; + left: 0; + z-index: 40; + width: 320px; + max-width: 84vw; + /* Cap the height so the popover never overflows a short window (it would + otherwise get clipped); the list region scrolls instead. */ + max-height: min(54vh, 360px); + display: flex; + flex-direction: column; + padding: 6px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-lg); +} +/* Both panel views (workspace list + folder browser) are flex columns so their + middle list region is the part that scrolls within the capped height. */ +.ws-browser, +.ws-listview { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1 1 auto; +} +.ws-panel.place-top { + bottom: calc(100% + 6px); +} +.ws-panel.place-bottom { + top: calc(100% + 6px); +} + +/* Search */ +.ws-search, +.ws-browser-head { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + padding: 5px 8px; + margin-bottom: 4px; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-background); +} +.ws-search-icon { + color: var(--color-muted-foreground); + flex-shrink: 0; +} +.ws-search-input, +.ws-path-input { + flex: 1; + min-width: 0; + border: none; + outline: none; + background: transparent; + font-size: 12.5px; + color: var(--color-foreground); +} +.ws-path-input { + font-family: var(--font-mono); + font-size: 11.5px; +} +.ws-search-input::placeholder, +.ws-path-input::placeholder { + color: var(--color-muted-foreground); +} +.ws-back { + display: grid; + place-items: center; + border: none; + background: transparent; + color: var(--color-muted-foreground); + cursor: pointer; + flex-shrink: 0; +} +.ws-back:hover { + color: var(--color-foreground); +} + +/* List */ +.ws-list { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 2px; +} +.ws-row { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 7px 8px; + border: none; + background: transparent; + border-radius: var(--radius-md); + cursor: pointer; + text-align: left; + color: var(--color-foreground); + font-size: 12.5px; + transition: background 0.12s; +} +.ws-row:hover { + background: var(--color-muted); +} +.ws-row.active { + background: var(--accent-wash-soft); +} +.ws-row-icon, +.ws-folder-icon { + color: var(--color-muted-foreground); + flex-shrink: 0; +} +.ws-row.active .ws-row-icon { + color: var(--color-primary); +} +.ws-row-name { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.ws-folder .ws-folder-icon { + font-family: var(--font-mono); + font-size: 12px; +} +.ws-check { + color: var(--color-primary); + flex-shrink: 0; +} +.ws-hint { + padding: 14px 8px; + text-align: center; + font-size: 11.5px; + color: var(--color-muted-foreground); +} + +/* Action rows */ +.ws-actions { + margin-top: 4px; + padding-top: 4px; + border-top: 1px solid var(--color-border); + display: flex; + flex-direction: column; + flex-shrink: 0; + gap: 2px; +} +.ws-action { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px; + border: none; + background: transparent; + border-radius: var(--radius-md); + cursor: pointer; + font-size: 12.5px; + color: var(--color-foreground); + transition: background 0.12s; +} +.ws-action:hover { + background: var(--color-muted); +} +.ws-action svg { + color: var(--color-muted-foreground); + flex-shrink: 0; +} + +/* Browser footer */ +.ws-browser-foot { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + margin-top: 4px; + padding-top: 6px; + border-top: 1px solid var(--color-border); +} +.ws-cur-path { + flex: 1; + min-width: 0; + font-family: var(--font-mono); + font-size: 11px; + color: var(--color-muted-foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.ws-open-btn { + flex-shrink: 0; + padding: 5px 14px; + border: none; + border-radius: var(--radius-md); + background: var(--color-primary); + color: #fff; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: opacity 0.15s; +} +.ws-open-btn:hover { + opacity: 0.9; +} + +/* Panel transition */ +.pop-enter-active, +.pop-leave-active { + transition: opacity 0.12s ease, transform 0.12s ease; +} +.pop-enter-from, +.pop-leave-to { + opacity: 0; + transform: translateY(4px); +} +.ws-panel.place-bottom.pop-enter-from, +.ws-panel.place-bottom.pop-leave-to { + transform: translateY(-4px); +} +</style> diff --git a/web/src/composables/api.ts b/web/src/composables/api.ts index 4536f98..f29284c 100644 --- a/web/src/composables/api.ts +++ b/web/src/composables/api.ts @@ -1,5 +1,5 @@ // API client for jcode backend -import type { ModelsResponse, AgentMode, ExecResponse, DiffResponse, MCPListResponse, MCPServerRequest, MCPLoginStatus, BrowseResponse, SSHListResponse, SkillInfo, SlashCommandInfo, TodoItem, Goal, SessionItem, SessionEntry, FileItem, SetupProvider, SetupModel, ProviderDetail, ModelStateResponse, ChatImage, AskUserAnswer, AskUserRequestData } from '@/types/api' +import type { ModelsResponse, AgentMode, ExecResponse, DiffResponse, WorkspaceInfo, GitBranchesResponse, TaskItem, TaskMetaPatch, MCPListResponse, MCPServerRequest, MCPLoginStatus, BrowseResponse, SSHListResponse, SkillInfo, SlashCommandInfo, TodoItem, Goal, SessionItem, SessionEntry, FileItem, SetupProvider, SetupModel, ProviderDetail, ModelStateResponse, ChatImage, AskUserAnswer, AskUserRequestData, RemoteConnectRequest, RemoteConnectResponse, RemoteListDirResponse, RemoteBindResponse } from '@/types/api' const BASE = '' @@ -102,6 +102,19 @@ export const api = { const q = mode ? `?mode=${encodeURIComponent(mode)}` : '' return request<DiffResponse>(`/api/diff${q}`) }, + workspace: () => request<WorkspaceInfo>('/api/workspace'), + gitBranches: () => request<GitBranchesResponse>('/api/git/branches'), + gitCheckout: (branch: string, create = false) => + request<{ branch: string }>('/api/git/checkout', { + method: 'POST', + body: JSON.stringify({ branch, create }), + }), + tasks: () => request<TaskItem[]>('/api/tasks'), + updateTask: (id: string, patch: TaskMetaPatch) => + request<TaskItem>(`/api/tasks/${encodeURIComponent(id)}`, { + method: 'PATCH', + body: JSON.stringify(patch), + }), mcpList: () => request<MCPListResponse>('/api/mcp'), mcpToggle: (name: string, enabled: boolean) => request<{ status: string }>(`/api/mcp/${encodeURIComponent(name)}/toggle`, { @@ -152,6 +165,33 @@ export const api = { request<{ status: string }>('/api/stop', { method: 'POST' }), sshList: () => request<SSHListResponse>('/api/ssh'), + + // Remote connection wizard (SSH) + remoteConnect: (data: RemoteConnectRequest) => + request<RemoteConnectResponse>('/api/remote/connect', { + method: 'POST', + body: JSON.stringify(data), + }), + remoteListDir: (connectionId: string, path: string) => + request<RemoteListDirResponse>('/api/remote/list-dir', { + method: 'POST', + body: JSON.stringify({ connection_id: connectionId, path }), + }), + remoteBind: (connectionId: string, path: string) => + request<RemoteBindResponse>('/api/remote/bind', { + method: 'POST', + body: JSON.stringify({ connection_id: connectionId, path }), + }), + remoteCancel: (connectionId: string) => + request<{ status: string }>('/api/remote/cancel', { + method: 'POST', + body: JSON.stringify({ connection_id: connectionId }), + }), + remoteSaveAlias: (name: string, addr: string, path: string) => + request<{ status: string }>('/api/remote/save-alias', { + method: 'POST', + body: JSON.stringify({ name, addr, path }), + }), skillsList: () => request<SkillInfo[]>('/api/skills'), skillToggle: (name: string, enabled: boolean) => diff --git a/web/src/composables/notifications.ts b/web/src/composables/notifications.ts new file mode 100644 index 0000000..da3cde3 --- /dev/null +++ b/web/src/composables/notifications.ts @@ -0,0 +1,54 @@ +// Notifications for long-running agent events (task finished, approval needed). +// Fires only when the user isn't already looking at the app, so a run is never +// interrupted while it's being watched. +// +// In the Tauri desktop shell this routes through the native OS notification +// plugin; in a plain browser it falls back to the web Notification API. Both +// paths are feature-detected, so the same bundle works in either host. +import { ref } from 'vue' + +import { isTauri, isAppFocused, ensureNativePermission, nativeNotify } from './useDesktop' + +const permission = ref<NotificationPermission>( + typeof Notification !== 'undefined' ? Notification.permission : 'denied', +) + +export function useNotifications() { + const supported = isTauri || typeof Notification !== 'undefined' + + async function ensurePermission() { + if (isTauri) { + await ensureNativePermission() + return + } + if (typeof Notification === 'undefined' || permission.value !== 'default') return + try { + permission.value = await Notification.requestPermission() + } catch { + /* ignore */ + } + } + + function notify(title: string, body?: string) { + // Desktop: native OS notification, only when the window isn't focused. + if (isTauri) { + if (isAppFocused()) return + void nativeNotify(title, body) + return + } + // Web: only notify when the tab is in the background. + if (typeof Notification === 'undefined' || permission.value !== 'granted') return + if (typeof document !== 'undefined' && !document.hidden) return + try { + const n = new Notification(title, { body, icon: '/icon.svg', tag: 'jcode' }) + n.onclick = () => { + window.focus() + n.close() + } + } catch { + /* ignore */ + } + } + + return { supported, permission, ensurePermission, notify } +} diff --git a/web/src/composables/useBranch.ts b/web/src/composables/useBranch.ts new file mode 100644 index 0000000..e6c7041 --- /dev/null +++ b/web/src/composables/useBranch.ts @@ -0,0 +1,66 @@ +// Shared git-branch state for the composer's BranchPicker and the TopBar chip. +// Module-level singletons so every consumer reflects the same current branch, +// and so a switch — whether done in the UI or with `git checkout` in a terminal +// — shows up everywhere. +import { ref } from 'vue' +import { api } from '@/composables/api' + +const current = ref('') +const branches = ref<string[]>([]) +const loading = ref(false) +const switching = ref(false) +const error = ref('') + +// refresh re-reads the current branch + local branch list. Any error (not a git +// repo, git missing) collapses to an empty state rather than throwing. +async function refresh() { + loading.value = true + try { + const res = await api.gitBranches() + current.value = res.current || '' + branches.value = res.branches || [] + } catch { + current.value = '' + branches.value = [] + } finally { + loading.value = false + } +} + +// checkout switches branch (create=true → `git checkout -b`). Returns true on +// success; on failure (e.g. a dirty tree git refuses to overwrite) `error` holds +// git's own message for the caller to surface. Always re-reads afterwards so the +// UI matches reality even on partial failures. +async function checkout(branch: string, create = false): Promise<boolean> { + switching.value = true + error.value = '' + try { + const res = await api.gitCheckout(branch, create) + current.value = res.branch || branch + return true + } catch (e) { + error.value = (e as Error).message || 'Failed to switch branch' + return false + } finally { + switching.value = false + await refresh() + } +} + +// Keep the UI in sync when the branch changes outside the app (e.g. the user or +// the agent runs `git checkout` in a terminal): re-read whenever the window +// regains focus or the tab becomes visible. Wired once. +let wired = false +function wireExternalSync() { + if (wired || typeof window === 'undefined') return + wired = true + window.addEventListener('focus', () => { void refresh() }) + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') void refresh() + }) +} + +export function useBranch() { + wireExternalSync() + return { current, branches, loading, switching, error, refresh, checkout } +} diff --git a/web/src/composables/useDesktop.ts b/web/src/composables/useDesktop.ts new file mode 100644 index 0000000..3bd4912 --- /dev/null +++ b/web/src/composables/useDesktop.ts @@ -0,0 +1,117 @@ +// Central bridge to native desktop (Tauri) capabilities. +// +// Every export here is feature-detected and degrades to a web fallback or a +// no-op, so the exact same web bundle runs unchanged in a plain browser +// (`jcode web`) and inside the Tauri desktop shell. Tauri plugin packages are +// imported dynamically and only ever reached when `isTauri` is true, so the +// browser build never executes their native-only code paths. + +// Tauri 2 injects `__TAURI_INTERNALS__` into every webview it controls, +// including the remote loopback origin the desktop app navigates to. +export const isTauri = + typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window + +// --- window focus tracking ------------------------------------------------- +// On the desktop we only want to fire a notification when the app isn't the +// focused window (the web build keeps using document.hidden instead). +let _focused = typeof document === 'undefined' ? true : document.hasFocus() +if (typeof window !== 'undefined') { + window.addEventListener('focus', () => (_focused = true)) + window.addEventListener('blur', () => (_focused = false)) +} + +/** True when the app window is currently focused / in the foreground. */ +export function isAppFocused(): boolean { + if (typeof document !== 'undefined' && document.hidden) return false + return _focused +} + +// --- native notifications -------------------------------------------------- +type NotifModule = typeof import('@tauri-apps/plugin-notification') +let _notif: NotifModule | null = null +async function notifModule(): Promise<NotifModule | null> { + if (!isTauri) return null + if (!_notif) _notif = await import('@tauri-apps/plugin-notification') + return _notif +} + +/** Request OS notification permission (idempotent). Returns whether granted. */ +export async function ensureNativePermission(): Promise<boolean> { + const m = await notifModule() + if (!m) return false + try { + let granted = await m.isPermissionGranted() + if (!granted) granted = (await m.requestPermission()) === 'granted' + return granted + } catch { + return false + } +} + +/** Fire a native OS notification. No-op (returns false) outside Tauri. */ +export async function nativeNotify(title: string, body?: string): Promise<boolean> { + const m = await notifModule() + if (!m) return false + try { + // Request permission on the spot if it was never granted — otherwise the + // first notification (e.g. a task that finishes before the user answers the + // initial async OS prompt) would be silently dropped. + if (!(await m.isPermissionGranted())) { + if ((await m.requestPermission()) !== 'granted') return false + } + m.sendNotification({ title, body }) + return true + } catch { + return false + } +} + +// --- open external links in the system browser ----------------------------- +/** Open a URL in the user's default browser (system browser on desktop). */ +export async function openExternal(url: string): Promise<void> { + if (isTauri) { + try { + const { openUrl } = await import('@tauri-apps/plugin-opener') + await openUrl(url) + return + } catch { + /* fall through to web */ + } + } + if (typeof window !== 'undefined') { + window.open(url, '_blank', 'noopener,noreferrer') + } +} + +// --- native folder picker -------------------------------------------------- +/** + * Open the OS folder picker and return the chosen absolute path, or null if + * cancelled / unavailable. Callers fall back to the in-app folder browser when + * this returns null. + */ +export async function pickFolder(defaultPath?: string): Promise<string | null> { + if (!isTauri) return null + try { + const { open } = await import('@tauri-apps/plugin-dialog') + const res = await open({ directory: true, multiple: false, defaultPath }) + return typeof res === 'string' ? res : null + } catch { + return null + } +} + +// --- one-time desktop init ------------------------------------------------- +/** + * Tag the document so CSS can adapt to the native shell (e.g. inset the top bar + * for the macOS traffic-light buttons under an overlay title bar). Safe to call + * on every boot; a no-op in the browser. + */ +export function initDesktop(): void { + if (!isTauri || typeof document === 'undefined') return + const root = document.documentElement + root.classList.add('is-tauri') + const platform = navigator.platform || '' + if (/Mac/i.test(platform)) root.classList.add('is-tauri-macos') + else if (/Win/i.test(platform)) root.classList.add('is-tauri-windows') + else root.classList.add('is-tauri-linux') +} diff --git a/web/src/composables/useFolderBrowser.ts b/web/src/composables/useFolderBrowser.ts new file mode 100644 index 0000000..418a82c --- /dev/null +++ b/web/src/composables/useFolderBrowser.ts @@ -0,0 +1,68 @@ +// useFolderBrowser — shared local folder-browsing state for the two workspace +// pickers (the ProjectSwitcher modal and the inline WorkspacePicker popover), +// which previously each re-implemented identical browse/navigate logic against +// /api/browse. The composable owns the directory-list navigation; each caller +// wires its own "open this path" action (switch project vs. open folder). +import { ref } from 'vue' +import { api } from '@/composables/api' +import type { BrowseFolder } from '@/types/api' + +export function useFolderBrowser() { + const showBrowser = ref(false) + const browsePath = ref('') + const browseFolders = ref<BrowseFolder[]>([]) + const browseLoading = ref(false) + const pathInput = ref('') + + async function loadFolders(path?: string) { + browseLoading.value = true + try { + const result = await api.browse(path) + browsePath.value = result.current + pathInput.value = result.current + browseFolders.value = result.folders + } catch (err: unknown) { + console.error('Browse failed:', err) + browseFolders.value = [] + } finally { + browseLoading.value = false + } + } + + function openBrowser() { + showBrowser.value = true + loadFolders() + } + + function goUp() { + if (!browsePath.value) return + const parts = browsePath.value.split('/') + parts.pop() + loadFolders(parts.join('/') || '/') + } + + function handlePathSubmit() { + const path = pathInput.value.trim() + if (path) loadFolders(path) + } + + function resetBrowser() { + showBrowser.value = false + browsePath.value = '' + pathInput.value = '' + browseFolders.value = [] + } + + return { + showBrowser, + browsePath, + browseFolders, + browseLoading, + pathInput, + loadFolders, + openBrowser, + goUp, + handlePathSubmit, + resetBrowser, + } +} diff --git a/web/src/main.ts b/web/src/main.ts index 2026d86..2e74a3c 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -3,6 +3,10 @@ import { createPinia } from 'pinia' import App from './App.vue' import './style.css' +import { initDesktop } from './composables/useDesktop' + +// Tag <html> for the native desktop shell (no-op in a plain browser). +initDesktop() const app = createApp(App) app.use(createPinia()) diff --git a/web/src/stores/chat.ts b/web/src/stores/chat.ts index 10786fb..d999edf 100644 --- a/web/src/stores/chat.ts +++ b/web/src/stores/chat.ts @@ -491,6 +491,18 @@ export const useChatStore = defineStore('chat', () => { } } + // resetToWelcomeAfterSwitch refreshes session-scoped state after the active + // workspace changed (local switch or remote bind) and lands on a fresh welcome + // screen so the next message starts a new task in the chosen workspace. + async function resetToWelcomeAfterSwitch() { + await fetchHealth() + currentSessionId.value = '' + clearChat() + fetchTodos() + fetchGoal() + await fetchSessions() + } + async function fetchTodos() { try { todos.value = await api.todos() @@ -962,6 +974,7 @@ export const useChatStore = defineStore('chat', () => { fetchSessions, deleteSession, newSession, + resetToWelcomeAfterSwitch, fetchTodos, fetchGoal, setGoalObjective, diff --git a/web/src/stores/project.ts b/web/src/stores/project.ts index abfd458..205d8d0 100644 --- a/web/src/stores/project.ts +++ b/web/src/stores/project.ts @@ -1,9 +1,32 @@ // Project management store using localStorage import { defineStore } from 'pinia' import { ref, computed } from 'vue' -import type { Project } from '@/types/api' +import type { Project, RemoteMeta, TaskItem, TaskMetaPatch } from '@/types/api' import { api } from '@/composables/api' +// A remote workspace is identified by a host-qualified label: +// ssh://user@host:port/remote/path +export function isRemotePath(path: string): boolean { + return path.startsWith('ssh://') +} + +// parseRemoteLabel decomposes a remote project label into the pieces the SSH +// wizard needs to reconnect. Returns null for non-remote paths. +export function parseRemoteLabel(label: string): RemoteMeta | null { + if (!isRemotePath(label)) return null + const rest = label.slice('ssh://'.length) + const at = rest.indexOf('@') + if (at < 0) return null + const user = rest.slice(0, at) + const afterUser = rest.slice(at + 1) + const slash = afterUser.indexOf('/') + const hostPort = slash < 0 ? afterUser : afterUser.slice(0, slash) + const remotePath = slash < 0 ? '/' : afterUser.slice(slash) + const colon = hostPort.lastIndexOf(':') + const port = colon >= 0 ? parseInt(hostPort.slice(colon + 1), 10) || 22 : 22 + return { host: hostPort, user, port, remotePath } +} + const STORAGE_KEY = 'jcode_projects' const ACTIVE_KEY = 'jcode_active_project' @@ -42,6 +65,27 @@ export const useProjectStore = defineStore('project', () => { return project } + // upsertRemoteProject records a bound remote workspace (keyed by its + // host-qualified label) and returns it. Unlike addProject it carries remote + // metadata so the tree can render it distinctly and offer reconnect. + function upsertRemoteProject(label: string, remote: RemoteMeta): Project { + const existing = projects.value.find((p) => p.path === label) + if (existing) { + existing.remote = remote + saveProjects(projects.value) + return existing + } + const project: Project = { + id: `proj_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + path: label, + createdAt: Date.now(), + remote, + } + projects.value.push(project) + saveProjects(projects.value) + return project + } + function removeProject(id: string) { projects.value = projects.value.filter((p) => p.id !== id) saveProjects(projects.value) @@ -65,6 +109,15 @@ export const useProjectStore = defineStore('project', () => { // If already active, no-op. if (activeId.value === id) return true + // Remote workspaces cannot be re-activated by a local path switch (the + // backend would `stat` a path that only exists on the remote host, and we + // never persist the SSH secret). Callers must route these through the SSH + // wizard instead. + if (project.remote) { + switchError.value = 'Remote workspace — reconnect via the SSH wizard' + return false + } + switching.value = true switchError.value = '' try { @@ -99,6 +152,61 @@ export const useProjectStore = defineStore('project', () => { return p.path.split('/').filter(Boolean).pop() || p.path } + function nameForPath(path: string): string { + return path.split('/').filter(Boolean).pop() || path + } + + // ─── Cross-project tasks (for the sidebar tree) ─── + const allTasks = ref<TaskItem[]>([]) + + async function fetchAllTasks() { + try { + allTasks.value = await api.tasks() + } catch { + allTasks.value = [] + } + } + + // Tasks grouped by project path, sorted newest-first, pinned on top. + const tasksByProject = computed(() => { + const map: Record<string, TaskItem[]> = {} + for (const t of allTasks.value) { + ;(map[t.project] ??= []).push(t) + } + for (const path in map) { + const list = map[path] + if (!list) continue + list.sort((a, b) => { + if (a.pinned !== b.pinned) return a.pinned ? -1 : 1 + return (b.created_at || '').localeCompare(a.created_at || '') + }) + } + return map + }) + + // The project nodes to render: known (localStorage) projects unioned with any + // project path that has tasks, so nothing is hidden just because it wasn't + // explicitly opened. + const projectsForTree = computed(() => { + const paths = new Set<string>(projects.value.map((p) => p.path)) + for (const t of allTasks.value) paths.add(t.project) + return [...paths].map((path) => { + const known = projects.value.find((p) => p.path === path) + return { id: known?.id ?? '', path, name: nameForPath(path) } + }) + }) + + async function updateTaskMeta(uuid: string, patch: TaskMetaPatch) { + // Optimistic local update, then persist. + const t = allTasks.value.find((x) => x.uuid === uuid) + if (t) Object.assign(t, patch) + try { + await api.updateTask(uuid, patch) + } catch { + await fetchAllTasks() // resync on failure + } + } + return { projects, activeId, @@ -106,11 +214,18 @@ export const useProjectStore = defineStore('project', () => { switching, switchError, addProject, + upsertRemoteProject, removeProject, setActive, switchToProject, openProject, ensureCurrentProject, projectName, + nameForPath, + allTasks, + fetchAllTasks, + tasksByProject, + projectsForTree, + updateTaskMeta, } }) diff --git a/web/src/style.css b/web/src/style.css index 0e8e605..d571ae1 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -14,43 +14,86 @@ html { } ::selection { - background: rgba(255, 132, 0, 0.2); -} - -/* ─── Scrollbar (dark) ─── */ -.dark ::-webkit-scrollbar { - width: 6px; - height: 6px; -} -.dark ::-webkit-scrollbar-track { - background: transparent; -} -.dark ::-webkit-scrollbar-thumb { - background: #3f3f46; - border-radius: 3px; -} -.dark ::-webkit-scrollbar-thumb:hover { - background: #52525b; -} - -/* ─── Scrollbar (light) ─── */ -:root:not(.dark) ::-webkit-scrollbar { - width: 6px; - height: 6px; -} -:root:not(.dark) ::-webkit-scrollbar-track { - background: transparent; -} -:root:not(.dark) ::-webkit-scrollbar-thumb { - background: #d4d4d8; - border-radius: 3px; -} -:root:not(.dark) ::-webkit-scrollbar-thumb:hover { - background: #a1a1aa; -} - -/* ─── Focus ring ─── */ + background: var(--accent-selection); +} + +/* ─── Native desktop (Tauri macOS overlay title bar) ─── + Reserve a thin draggable strip at the top for the OS traffic-light buttons. + These MUST be global (not App.vue's <style scoped>): Vue's scoped compiler + mangles `:global(html.is-tauri-macos) .child` into `html.is-tauri-macos`, + dropping the descendant — which put `padding-top` on <html> and made the whole + window scroll 28px. Plain global selectors avoid that. padding-top on the + 100dvh .app-shell is border-box, so it never overflows the viewport. */ +.titlebar-drag { + display: none; +} +html.is-tauri-macos .app-shell, +html.is-tauri-macos .settings-shell { + padding-top: 28px; +} +/* On desktop the 28px title-bar strip already lifts the surface below the + floating control, so it needs far less of its own top margin than the + browser (40px). 28 + 12 = 40 → consistent clearance either way. */ +html.is-tauri-macos .chat-panel { + margin-top: 12px; +} +html.is-tauri-macos .titlebar-drag { + display: block; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 28px; + /* Above the sidebar (--z-sidebar: 30) so it catches drags, but below + --z-modal (50) so the Settings overlay covers it. */ + z-index: 45; +} + +/* ─── Non-selectable chrome ─── + App chrome (sidebar, top bar, composer controls, menus, settings nav) is not + text-selectable, the way a native app behaves. Real content — chat messages, + code, terminal output, and form fields — opts selection back in. */ +.app-shell, +.settings-shell { + -webkit-user-select: none; + user-select: none; +} +.prose-chat, +.prose-chat *, +input, +textarea, +[contenteditable='true'], +pre, +code, +.fv-pre, +.preview-code, +.xterm, +.xterm *, +[data-selectable], +[data-selectable] * { + -webkit-user-select: text; + user-select: text; +} + +/* ─── Scrollbars: hidden, scrolling preserved (native-app feel) ─── */ +* { + scrollbar-width: none; /* Firefox */ +} +*::-webkit-scrollbar { + width: 0; + height: 0; +} + +/* ─── Focus ring ─── keyboard-only via :focus-visible. Restores an a11y + affordance that was previously removed outright. Text fields signal focus + through their own border, so they opt out to avoid a double ring. */ *:focus-visible { + outline: 2px solid var(--color-border-focus, var(--color-primary)); + outline-offset: 2px; +} +input:focus-visible, +textarea:focus-visible, +select:focus-visible { outline: none; } @@ -119,7 +162,7 @@ html { .prose-chat a { text-decoration: underline; text-underline-offset: 3px; - text-decoration-color: rgba(255, 132, 0, 0.3); + text-decoration-color: var(--accent-border); transition: text-decoration-color 150ms; } .prose-chat a:hover { diff --git a/web/src/styles/animations.css b/web/src/styles/animations.css index 07ce864..03dbdb5 100644 --- a/web/src/styles/animations.css +++ b/web/src/styles/animations.css @@ -71,10 +71,10 @@ @keyframes pulse-glow { 0%, 100% { - box-shadow: 0 0 8px rgba(255, 132, 0, 0.2); + box-shadow: 0 0 8px color-mix(in srgb, var(--color-primary) 20%, transparent); } 50% { - box-shadow: 0 0 20px rgba(255, 132, 0, 0.4); + box-shadow: 0 0 20px color-mix(in srgb, var(--color-primary) 40%, transparent); } } diff --git a/web/src/styles/tokens.css b/web/src/styles/tokens.css index 996e335..2823c28 100644 --- a/web/src/styles/tokens.css +++ b/web/src/styles/tokens.css @@ -8,7 +8,12 @@ --font-sans: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; - /* ─── Radii ─── */ + /* ─── Radii ─── one scale; map by role, never hardcode px. + * xs status dots, tiny chips, kbd sm badges, small controls + * md rows, menu items, inputs lg buttons, cards, dropdowns, panels + * xl dialogs / popovers 2xl hero composer + */ + --radius-xs: 3px; --radius-sm: 4px; --radius-md: 6px; --radius-lg: 8px; @@ -16,10 +21,40 @@ --radius-2xl: 16px; --radius-pill: 9999px; - /* ─── Shadows ─── */ - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.08); - --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.12); - --shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.15); + /* ─── Shadows & elevation ─── one warm-tinted scale (near-invisible on dark + * surfaces, which is correct). sm = resting card · md = dropdown/menu · + * lg = dialog/modal · xl = floating hero (the centered composer). */ + --shadow-sm: 0 1px 2px rgba(24, 20, 16, 0.06); + --shadow-md: 0 4px 14px -4px rgba(24, 20, 16, 0.12); + --shadow-lg: 0 14px 36px -14px rgba(24, 20, 16, 0.20); + --shadow-xl: 0 1px 2px rgba(24, 20, 16, 0.04), 0 24px 56px -28px rgba(24, 20, 16, 0.26); + + /* Modal scrim — one backdrop for every dialog/popover overlay. */ + --backdrop: rgba(20, 18, 16, 0.5); + + /* ─── Accent washes ─── the single source for orange tints. Derived from + * --color-primary via color-mix so every theme (and dark mode) adapts, and so + * the 8 ad-hoc rgba(255,132,0,…) alphas collapse to three semantic steps. + * soft = resting active row / subtle hover + * base = active control / highlighted button / selected item + * strong = hover-on-active / emphasis + * border = accent hairline · fill = vivid progress · selection = ::selection */ + --accent-wash-soft: color-mix(in srgb, var(--color-primary) 8%, transparent); + --accent-wash: color-mix(in srgb, var(--color-primary) 11%, transparent); + --accent-wash-strong: color-mix(in srgb, var(--color-primary) 16%, transparent); + --accent-border: color-mix(in srgb, var(--color-primary) 30%, transparent); + --accent-fill: color-mix(in srgb, var(--color-primary) 45%, transparent); + --accent-selection: color-mix(in srgb, var(--color-primary) 22%, transparent); + + /* Interactive conventions */ + --color-border-active: var(--color-primary); + --icon-stroke: 2; + + /* ─── Status indicators ─── */ + /* Saturated "online" green for small status dots. Distinct from + * --color-success-fg (a dark text token tuned for the success chip), which + * reads near-black on a tiny dot in light mode. */ + --color-success: #16A34A; /* ─── Z-index scale ─── */ --z-sidebar: 30; @@ -100,6 +135,7 @@ --color-info-fg: #B2B2FF; --color-destructive: #FF5C33; --color-sidebar-bg: #1A1A1A; + --color-success: #22C55E; /* ─── Syntax highlighting (dark) ─── */ --hljs-fg: #e4e4e7; diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 60382b2..08e99b0 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -138,6 +138,37 @@ export interface DiffResponse { entries: DiffEntry[] } +export interface WorkspaceInfo { + branch: string // empty if not a git repo + dirty: boolean +} + +export interface GitBranchesResponse { + current: string // empty if not a git repo + branches: string[] // local branches, most-recently-committed first +} + +// A task = a conversation, listed across all projects for the sidebar tree. +export interface TaskItem { + uuid: string + project: string // project path + created_at: string + provider: string + model: string + title?: string + pinned: boolean + archived: boolean + unread: boolean + status?: string +} + +export interface TaskMetaPatch { + pinned?: boolean + archived?: boolean + unread?: boolean + title?: string +} + // MCP types export interface MCPServerInfo { name: string @@ -195,6 +226,44 @@ export interface SSHListResponse { aliases: SSHAlias[] } +// Remote connection wizard +export type RemoteAuthMethod = 'password' | 'key' + +export interface RemoteConnectRequest { + type?: 'ssh' + host: string + port?: number + user?: string + auth_method?: RemoteAuthMethod + password?: string + key_path?: string + passphrase?: string +} + +export interface RemoteConnectResponse { + connection_id: string + remote_pwd: string + platform: string + user: string + host: string +} + +export interface RemoteListDirResponse { + path: string + dirs: string[] +} + +export interface RemoteBindResponse { + status: string + pwd: string + label: string + name: string + host: string + user: string + port: number + remote_path: string +} + // Skill types (for slash commands) export interface SkillInfo { name: string @@ -365,10 +434,21 @@ export interface PendingApproval { } // Project management (localStorage) +export interface RemoteMeta { + host: string // host:port as dialed + user: string + port: number + remotePath: string +} + export interface Project { id: string path: string createdAt: number + // Present for remote (SSH) workspaces. `path` is the host-qualified label + // (ssh://user@host:port/remote/path); remote workspaces cannot be re-activated + // by a local path switch and must be reconnected through the SSH wizard. + remote?: RemoteMeta } // Browse (folder picker)