diff --git a/.beads/.gitignore b/.beads/.gitignore new file mode 100644 index 0000000..d27a1db --- /dev/null +++ b/.beads/.gitignore @@ -0,0 +1,44 @@ +# SQLite databases +*.db +*.db?* +*.db-journal +*.db-wal +*.db-shm + +# Daemon runtime files +daemon.lock +daemon.log +daemon.pid +bd.sock +sync-state.json +last-touched + +# Local version tracking (prevents upgrade notification spam after git ops) +.local_version + +# Legacy database files +db.sqlite +bd.db + +# Worktree redirect file (contains relative path to main repo's .beads/) +# Must not be committed as paths would be wrong in other clones +redirect + +# Merge artifacts (temporary files from 3-way merge) +beads.base.jsonl +beads.base.meta.json +beads.left.jsonl +beads.left.meta.json +beads.right.jsonl +beads.right.meta.json + +# Sync state (local-only, per-machine) +# These files are machine-specific and should not be shared across clones +.sync.lock +sync_base.jsonl + +# NOTE: Do NOT add negation patterns (e.g., !issues.jsonl) here. +# They would override fork protection in .git/info/exclude, allowing +# contributors to accidentally commit upstream issue databases. +# The JSONL files (issues.jsonl, interactions.jsonl) and config files +# are tracked by git by default since no pattern above ignores them. diff --git a/.beads/README.md b/.beads/README.md new file mode 100644 index 0000000..50f281f --- /dev/null +++ b/.beads/README.md @@ -0,0 +1,81 @@ +# Beads - AI-Native Issue Tracking + +Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code. + +## What is Beads? + +Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git. + +**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads) + +## Quick Start + +### Essential Commands + +```bash +# Create new issues +bd create "Add user authentication" + +# View all issues +bd list + +# View issue details +bd show + +# Update issue status +bd update --status in_progress +bd update --status done + +# Sync with git remote +bd sync +``` + +### Working with Issues + +Issues in Beads are: +- **Git-native**: Stored in `.beads/issues.jsonl` and synced like code +- **AI-friendly**: CLI-first design works perfectly with AI coding agents +- **Branch-aware**: Issues can follow your branch workflow +- **Always in sync**: Auto-syncs with your commits + +## Why Beads? + +✨ **AI-Native Design** +- Built specifically for AI-assisted development workflows +- CLI-first interface works seamlessly with AI coding agents +- No context switching to web UIs + +🚀 **Developer Focused** +- Issues live in your repo, right next to your code +- Works offline, syncs when you push +- Fast, lightweight, and stays out of your way + +🔧 **Git Integration** +- Automatic sync with git commits +- Branch-aware issue tracking +- Intelligent JSONL merge resolution + +## Get Started with Beads + +Try Beads in your own projects: + +```bash +# Install Beads +curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash + +# Initialize in your repo +bd init + +# Create your first issue +bd create "Try out Beads" +``` + +## Learn More + +- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs) +- **Quick Start Guide**: Run `bd quickstart` +- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples) + +--- + +*Beads: Issue tracking that moves at the speed of thought* ⚡ diff --git a/.beads/config.yaml b/.beads/config.yaml new file mode 100644 index 0000000..f242785 --- /dev/null +++ b/.beads/config.yaml @@ -0,0 +1,62 @@ +# Beads Configuration File +# This file configures default behavior for all bd commands in this repository +# All settings can also be set via environment variables (BD_* prefix) +# or overridden with command-line flags + +# Issue prefix for this repository (used by bd init) +# If not set, bd init will auto-detect from directory name +# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. +# issue-prefix: "" + +# Use no-db mode: load from JSONL, no SQLite, write back after each command +# When true, bd will use .beads/issues.jsonl as the source of truth +# instead of SQLite database +# no-db: false + +# Disable daemon for RPC communication (forces direct database access) +# no-daemon: false + +# Disable auto-flush of database to JSONL after mutations +# no-auto-flush: false + +# Disable auto-import from JSONL when it's newer than database +# no-auto-import: false + +# Enable JSON output by default +# json: false + +# Default actor for audit trails (overridden by BD_ACTOR or --actor) +# actor: "" + +# Path to database (overridden by BEADS_DB or --db) +# db: "" + +# Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON) +# auto-start-daemon: true + +# Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE) +# flush-debounce: "5s" + +# Git branch for beads commits (bd sync will commit to this branch) +# IMPORTANT: Set this for team projects so all clones use the same sync branch. +# This setting persists across clones (unlike database config which is gitignored). +# Can also use BEADS_SYNC_BRANCH env var for local override. +# If not set, bd sync will require you to run 'bd config set sync.branch '. +# sync-branch: "beads-sync" + +# Multi-repo configuration (experimental - bd-307) +# Allows hydrating from multiple repositories and routing writes to the correct JSONL +# repos: +# primary: "." # Primary repo (where this database lives) +# additional: # Additional repos to hydrate from (read-only) +# - ~/beads-planning # Personal planning repo +# - ~/work-planning # Work planning repo + +# Integration settings (access with 'bd config get/set') +# These are stored in the database, not in this file: +# - jira.url +# - jira.project +# - linear.url +# - linear.api-key +# - github.org +# - github.repo diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl new file mode 100644 index 0000000..e69de29 diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl new file mode 100644 index 0000000..8b97f95 --- /dev/null +++ b/.beads/issues.jsonl @@ -0,0 +1,8 @@ +{"id":"vimput-21l","title":"Fix: Navegação com J em linhas vazias não funciona","description":"Não é possível navegar para linhas vazias usando J (ou k). Quando há linhas em branco, o cursor não consegue descer/subir para elas.","status":"closed","priority":2,"issue_type":"bug","owner":"mvacoimbra.dev@gmail.com","created_at":"2026-01-26T17:40:51.630718-03:00","created_by":"mvacoimbra","updated_at":"2026-01-26T18:03:54.757076-03:00","closed_at":"2026-01-26T18:03:54.757076-03:00","close_reason":"Fixed cursor rendering on empty lines"} +{"id":"vimput-2jf","title":"Fix: Enter no insert mode cria linha no lugar errado","description":"No insert mode, quando o cursor está no meio de uma linha e o usuário pressiona Enter, a quebra de linha deveria ser inserida exatamente onde o cursor está, mas está criando a linha embaixo da última linha.","status":"closed","priority":2,"issue_type":"bug","owner":"mvacoimbra.dev@gmail.com","created_at":"2026-01-26T17:40:51.376161-03:00","created_by":"mvacoimbra","updated_at":"2026-01-26T18:03:54.431016-03:00","closed_at":"2026-01-26T18:03:54.431016-03:00","close_reason":"Fixed cursor rendering on empty lines"} +{"id":"vimput-bzx","title":"Setting para controlar indentação (tab/espaços)","description":"Adicionar configuração para controlar indentação: escolher entre tab ou espaços, e quantos caracteres usar.","status":"open","priority":2,"issue_type":"feature","owner":"mvacoimbra.dev@gmail.com","created_at":"2026-01-27T09:07:25.005439-03:00","created_by":"mvacoimbra","updated_at":"2026-01-27T09:07:25.005439-03:00"} +{"id":"vimput-d8x","title":"Cursor só pisca em idle","description":"O cursor deve parar de piscar quando o usuário está movimentando ou digitando. Ele só deve piscar quando está em idle (sem atividade).","status":"closed","priority":2,"issue_type":"feature","owner":"mvacoimbra.dev@gmail.com","created_at":"2026-01-26T17:40:52.309519-03:00","created_by":"mvacoimbra","updated_at":"2026-01-26T18:08:53.543475-03:00","closed_at":"2026-01-26T18:08:53.543475-03:00","close_reason":"Cursor now only blinks when idle and uses element-based rendering"} +{"id":"vimput-l50","title":"Add code formatter (Prettier + sql-formatter)","description":"Add code formatting support to the editor.\n\n## Libraries\n- **Prettier** (standalone): JS, TS, JSX, TSX, CSS, HTML, JSON, Markdown, YAML, GraphQL\n- **sql-formatter**: SQL\n\n## Features\n- Command `:fmt` or `:format` to format current buffer\n- Shortcut `\u003cSpace\u003ecf` in normal mode (leader + code + format)\n- Respect user's indentation settings (indentType: tabs/spaces, indentSize: 2/4/8)\n- Opt-in setting with privacy disclaimer\n\n## UX for unsupported languages\n- Show alert/message in status bar when user tries to format unsupported language\n- Add icon or tooltip in language selector indicating formatter support\n\n## Implementation Progress\n\n### Done\n- [x] Added `formatterEnabled` setting (opt-in, disabled by default)\n- [x] Added UI in Settings \u003e Editor with privacy disclaimer\n- [x] Added `:fmt` and `:format` commands in vimEngine\n- [x] Added `\u003cSpace\u003ecf` shortcut in normal mode\n- [x] Added `pendingAction` to VimState for component communication\n- [x] Added status bar messages for formatting feedback\n- [x] Added sparkle icon in language selector (shows when formatter enabled)\n- [x] Created formatter.ts with Prettier + sql-formatter integration\n\n### Blocked\n- **Chrome UTF-8 encoding error**: When bundling Prettier (~2MB), Chrome fails to load content script with \"Could not load file 'content-scripts/content.js' for content script. It isn't UTF-8 encoded.\"\n- CDN approach (esm.sh) blocked by page CSP policies\n- File validates as ASCII/UTF-8 with `file` command and Node.js syntax check passes\n\n## Next Steps to Try\n1. **Web Worker approach**: Move Prettier to a separate web accessible resource loaded via Web Worker\n2. **Investigate Prettier plugins**: Some plugins (especially TypeScript at 874KB) may contain problematic characters\n3. **Try alternative bundler settings**: Different minification or encoding options\n4. **Consider lighter alternatives**: Use only sql-formatter (works) and simpler JS formatter\n\n## Technical Notes\n- sql-formatter works fine when bundled (~300KB)\n- Prettier standalone + all plugins = ~2MB\n- TypeScript plugin alone is 874KB\n- Dynamic imports from CDN blocked by strict CSP on many sites","status":"closed","priority":2,"issue_type":"feature","owner":"mvacoimbra.dev@gmail.com","created_at":"2026-01-28T00:59:32.155309-03:00","created_by":"mvacoimbra","updated_at":"2026-01-28T02:01:21.120647-03:00","closed_at":"2026-01-28T02:01:21.120647-03:00","close_reason":"Implemented local Python formatter worker approach to bypass Chrome UTF-8 bundling issues"} +{"id":"vimput-rej","title":"Add indentation settings","description":"Add user-configurable indentation settings to the extension.\n\n## Requirements\n- Allow users to configure:\n - Indentation type (tabs vs spaces)\n - Indentation size (2, 4, 8 spaces)\n- Settings should be persisted in browser.storage.sync\n- Settings should be accessible from the popup settings panel\n\n## Implementation\n- Add new fields to configStore.ts\n- Update SettingsPanel.tsx with new UI controls\n- Apply indentation settings in VimputEditor.tsx","status":"closed","priority":2,"issue_type":"feature","owner":"mvacoimbra.dev@gmail.com","created_at":"2026-01-27T22:27:07.179345-03:00","created_by":"mvacoimbra","updated_at":"2026-01-28T00:14:59.834224-03:00","closed_at":"2026-01-28T00:14:59.834224-03:00","close_reason":"Implemented indentation settings: added indentType (tabs/spaces) and indentSize (2/4/8) to configStore, SettingsPanel UI, and VimputEditor Tab handling"} +{"id":"vimput-sp2","title":"Debug: Compatibilidade com TypeScript Playground","description":"O editor não funciona corretamente com o TypeScript Playground (typescriptlang.org/play). Investigar a integração com editores baseados em Monaco/VS Code.","status":"closed","priority":2,"issue_type":"bug","owner":"mvacoimbra.dev@gmail.com","created_at":"2026-01-26T17:40:51.859149-03:00","created_by":"mvacoimbra","updated_at":"2026-01-27T11:50:52.440637-03:00","closed_at":"2026-01-27T11:50:52.440637-03:00","close_reason":"Closed"} +{"id":"vimput-ztw","title":"Debug: Compatibilidade com editor da Udemy","description":"O editor não funciona corretamente com o editor de código da Udemy. Investigar a integração com esse editor específico.","status":"closed","priority":2,"issue_type":"bug","owner":"mvacoimbra.dev@gmail.com","created_at":"2026-01-26T17:40:52.081289-03:00","created_by":"mvacoimbra","updated_at":"2026-01-27T09:07:23.975834-03:00","closed_at":"2026-01-27T09:07:23.975834-03:00","close_reason":"Added Ace Editor support"} diff --git a/.beads/metadata.json b/.beads/metadata.json new file mode 100644 index 0000000..01c4bb3 --- /dev/null +++ b/.beads/metadata.json @@ -0,0 +1,4 @@ +{ + "database": "beads.db", + "jsonl_export": "issues.jsonl" +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..43cda20 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# GitHub Actions Secrets for Store Deployment +# Copy these to your repository secrets in GitHub Settings > Secrets and variables > Actions + +# ============================================ +# Chrome Web Store (Service Account) +# ============================================ +# 1. Create a service account in Google Cloud Console +# 2. Enable Chrome Web Store API +# 3. Add the service account email to your Chrome Web Store publisher account +# (Settings > Group publishers > Add member) +# 4. Copy the entire JSON key file content as the secret value + +CHROME_SERVICE_ACCOUNT_KEY={"type":"service_account","project_id":"...","private_key":"..."} + +# Set as repository variable (Settings > Secrets and variables > Variables): +# CHROME_EXTENSION_ID=your-extension-id-from-chrome-web-store + +# ============================================ +# Firefox Add-ons (AMO) +# ============================================ +# Get API credentials: https://addons.mozilla.org/developers/addon/api/key/ + +AMO_JWT_ISSUER=user:12345678:123 +AMO_JWT_SECRET=your-api-secret + +# Set as repository variable (not secret): +# AMO_EXTENSION_ID=vimput@extension diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..807d598 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ + +# Use bd merge for beads JSONL files +.beads/issues.jsonl merge=beads diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3eb7c88 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,180 @@ +name: Release and Deploy + +on: + push: + branches: + - main + workflow_dispatch: + inputs: + version_bump: + description: 'Version bump type' + required: true + default: 'patch' + type: choice + options: + - patch + - minor + - major + +jobs: + release: + runs-on: ubuntu-latest + # Skip if the commit was made by the bot (version bump commit) + if: "!contains(github.event.head_commit.message, '[skip ci]')" + environment: prod + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Bump version + id: version + run: | + # Determine bump type (default to patch for push events) + BUMP_TYPE="${{ github.event.inputs.version_bump || 'patch' }}" + + # Get current version from package.json + CURRENT_VERSION=$(node -p "require('./package.json').version") + echo "Current version: $CURRENT_VERSION" + + # Calculate new version + IFS='.' read -ra VERSION_PARTS <<< "$CURRENT_VERSION" + MAJOR=${VERSION_PARTS[0]} + MINOR=${VERSION_PARTS[1]} + PATCH=${VERSION_PARTS[2]} + + case $BUMP_TYPE in + major) + MAJOR=$((MAJOR + 1)) + MINOR=0 + PATCH=0 + ;; + minor) + MINOR=$((MINOR + 1)) + PATCH=0 + ;; + patch) + PATCH=$((PATCH + 1)) + ;; + esac + + NEW_VERSION="$MAJOR.$MINOR.$PATCH" + echo "New version: $NEW_VERSION" + echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT + + # Update version in all files + sed -i "s/\"version\": \"$CURRENT_VERSION\"/\"version\": \"$NEW_VERSION\"/" package.json + sed -i "s/version: \"$CURRENT_VERSION\"/version: \"$NEW_VERSION\"/" wxt.config.ts + sed -i "s/v$CURRENT_VERSION/v$NEW_VERSION/" entrypoints/popup/App.tsx + + - name: Lint and format + run: | + pnpm run lint:fix + pnpm run format:fix + + - name: Type check + run: pnpm run compile + + - name: Build Chrome extension + run: pnpm run build + + - name: Build Firefox extension + run: pnpm run build:firefox + + - name: Create Chrome zip + run: pnpm run zip + + - name: Create Firefox zip + run: pnpm run zip:firefox + + - name: Commit version bump + run: | + git add package.json wxt.config.ts entrypoints/popup/App.tsx + git commit -m "chore: bump version to ${{ steps.version.outputs.version }} [skip ci]" || echo "No changes to commit" + git push + + - name: Create Git tag + run: | + git tag -a "v${{ steps.version.outputs.version }}" -m "Release v${{ steps.version.outputs.version }}" + git push origin "v${{ steps.version.outputs.version }}" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.version.outputs.version }} + name: Release v${{ steps.version.outputs.version }} + generate_release_notes: true + files: | + .output/*.zip + + - name: Upload to Chrome Web Store + if: ${{ secrets.CHROME_SERVICE_ACCOUNT_KEY != '' && vars.CHROME_EXTENSION_ID != '' }} + env: + CHROME_SERVICE_ACCOUNT_KEY: ${{ secrets.CHROME_SERVICE_ACCOUNT_KEY }} + EXTENSION_ID: ${{ vars.CHROME_EXTENSION_ID }} + run: | + # Install Google Auth library + pnpm add -D google-auth-library + + # Write service account key to file + echo "$CHROME_SERVICE_ACCOUNT_KEY" > /tmp/service-account.json + + # Get access token using service account + ACCESS_TOKEN=$(node -e " + const { GoogleAuth } = require('google-auth-library'); + (async () => { + const auth = new GoogleAuth({ + keyFile: '/tmp/service-account.json', + scopes: ['https://www.googleapis.com/auth/chromewebstore'] + }); + const token = await auth.getAccessToken(); + console.log(token); + })(); + ") + + # Upload to Chrome Web Store API + RESPONSE=$(curl -s -X PUT \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "x-goog-api-version: 2" \ + -T ".output/vimput-${{ steps.version.outputs.version }}-chrome.zip" \ + "https://www.googleapis.com/upload/chromewebstore/v1.1/items/$EXTENSION_ID") + + echo "Upload response: $RESPONSE" + + # Clean up + rm /tmp/service-account.json + + - name: Upload to Firefox Add-ons + if: ${{ vars.AMO_EXTENSION_ID != '' }} + run: | + pnpm add -D web-ext + pnpm web-ext sign \ + --channel=listed \ + --source-dir=.output/firefox-mv2 \ + --api-key=${{ secrets.AMO_JWT_ISSUER }} \ + --api-secret=${{ secrets.AMO_JWT_SECRET }} + continue-on-error: true # AMO review can take time diff --git a/.gitignore b/.gitignore index a256953..aa77925 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,15 @@ web-ext.config.ts *.njsproj *.sln *.sw? + +# bv (beads viewer) local config and caches +.bv/ + +# Environment files (keep .env.example) +.env +.env.local +.env.*.local + +# Google Cloud service account keys +vimput-*.json +*-service-account*.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..00f4ec6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,103 @@ +# Agent Instructions + +This project uses **bd** (beads) for issue tracking. Run `bd onboard` to get started. + +## Quick Reference + +```bash +bd ready # Find available work +bd show # View issue details +bd update --status in_progress # Claim work +bd close # Complete work +bd sync # Sync with git +``` + +## Landing the Plane (Session Completion) + +**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. + +**MANDATORY WORKFLOW:** + +1. **File issues for remaining work** - Create issues for anything that needs follow-up +2. **Run quality gates** (if code changed) - Tests, linters, builds +3. **Update issue status** - Close finished work, update in-progress items +4. **PUSH TO REMOTE** - This is MANDATORY: + ```bash + git pull --rebase + bd sync + git push + git status # MUST show "up to date with origin" + ``` +5. **Clean up** - Clear stashes, prune remote branches +6. **Verify** - All changes committed AND pushed +7. **Hand off** - Provide context for next session + +**CRITICAL RULES:** +- Work is NOT complete until `git push` succeeds +- NEVER stop before pushing - that leaves work stranded locally +- NEVER say "ready to push when you are" - YOU must push +- If push fails, resolve and retry until it succeeds + + + + +--- + +## Beads Workflow Integration + +This project uses [beads_viewer](https://github.com/Dicklesworthstone/beads_viewer) for issue tracking. Issues are stored in `.beads/` and tracked in git. + +### Essential Commands + +```bash +# View issues (launches TUI - avoid in automated sessions) +bv + +# CLI commands for agents (use these instead) +bd ready # Show issues ready to work (no blockers) +bd list --status=open # All open issues +bd show # Full issue details with dependencies +bd create --title="..." --type=task --priority=2 +bd update --status=in_progress +bd close --reason="Completed" +bd close # Close multiple issues at once +bd sync # Commit and push changes +``` + +### Workflow Pattern + +1. **Start**: Run `bd ready` to find actionable work +2. **Claim**: Use `bd update --status=in_progress` +3. **Work**: Implement the task +4. **Complete**: Use `bd close ` +5. **Sync**: Always run `bd sync` at session end + +### Key Concepts + +- **Dependencies**: Issues can block other issues. `bd ready` shows only unblocked work. +- **Priority**: P0=critical, P1=high, P2=medium, P3=low, P4=backlog (use numbers, not words) +- **Types**: task, bug, feature, epic, question, docs +- **Blocking**: `bd dep add ` to add dependencies + +### Session Protocol + +**Before ending any session, run this checklist:** + +```bash +git status # Check what changed +git add # Stage code changes +bd sync # Commit beads changes +git commit -m "..." # Commit code +bd sync # Commit any new beads changes +git push # Push to remote +``` + +### Best Practices + +- Check `bd ready` at session start to find available work +- Update status as you work (in_progress → closed) +- Create new issues with `bd create` when you discover tasks +- Use descriptive titles and set appropriate priority/type +- Always `bd sync` before ending session + + diff --git a/CHANGELOG-1.3.0.md b/CHANGELOG-1.3.0.md new file mode 100644 index 0000000..4400466 --- /dev/null +++ b/CHANGELOG-1.3.0.md @@ -0,0 +1,64 @@ +# Vimput v1.3.0 Changelog + +## New Features + +### Code Formatter +- **Local Formatter Worker**: New Python-based formatter service that runs locally + - Download the script directly from extension settings + - Runs as background daemon on `localhost:7483` + - Auto-installs dependencies (Flask) + - Prompts to install formatters (prettier, black) on first run +- **Supported Languages**: JavaScript, TypeScript, JSX, TSX, Python, CSS, HTML, JSON, Markdown, YAML, GraphQL, Bash, SQL, Go, Rust, Java, C, C++ +- **Commands**: `:fmt` or `:format` to format current buffer +- **Shortcut**: `cf` in normal mode +- **Privacy**: 100% local - your code never leaves your machine + +### Indentation Settings +- Choose between **tabs** or **spaces** +- Configurable indent size: **2, 4, or 8** spaces +- Settings apply to both editing and formatting + +### Ace Editor Support +- Full compatibility with **Ace Editor** (used by Udemy and other platforms) +- Proper cursor positioning and text synchronization + +### TypeScript Playground Compatibility +- Fixed compatibility via external page script injection +- Works with Monaco-based editors + +## Improvements + +### Cursor Behavior +- Improved cursor rendering and positioning +- Fixed cursor display on empty lines +- Better visual feedback across different editor types + +### Default Settings +- `enterToSaveAndExit`: Now **enabled** by default (press Enter in Normal mode to save and close) +- More intuitive out-of-the-box experience + +### Internal +- Normalized line endings in vim engine (CRLF → LF) +- Better cross-platform compatibility + +## Technical Details + +### Formatter Worker Architecture +``` +[Vimput Extension] --HTTP--> [localhost:7483] --subprocess--> [prettier/black/gofmt/...] +``` + +The formatter worker bridges the gap between the browser extension (which can't execute local commands) and system-installed formatters. + +### Supported Formatters +| Language | Formatters | +|----------|------------| +| JS/TS/JSX/TSX | prettier, biome | +| Python | ruff, black | +| CSS/HTML/JSON/MD/YAML/GraphQL | prettier | +| Bash | shfmt | +| SQL | sqlfluff, sql-formatter (built-in) | +| Go | gofmt | +| Rust | rustfmt | +| Java | google-java-format | +| C/C++ | clang-format | diff --git a/CLAUDE.md b/CLAUDE.md index 553a08c..9270cc9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,6 +60,25 @@ This is a WXT-based browser extension using React and TypeScript. - **components/SettingsPanel.tsx** - Settings UI in popup - **components/BuyMeCoffee.tsx** - Support link component +## Issue Tracking + +This project uses **bd** (beads) for local issue tracking. Issues are stored in `.beads/` and tracked in git. + +```bash +bd ready # Find available work +bd show # View issue details +bd create --title="..." --type=task --priority=2 +bd update --status=in_progress +bd close # Complete work +bd sync # Sync with git +``` + +See `AGENTS.md` for full workflow details. + +## Workflow + +- Always run `pnpm run build` after making code changes so the user can test + ## Code Conventions - Use camelCase for non-component files diff --git a/components/SettingsPanel.tsx b/components/SettingsPanel.tsx index 55d0996..680749c 100644 --- a/components/SettingsPanel.tsx +++ b/components/SettingsPanel.tsx @@ -1,18 +1,29 @@ import { + CheckCircle, CornerDownLeft, + Download, HelpCircle, + IndentIncrease, MessageCircleWarning, MousePointerClick, + RefreshCw, RotateCcw, + Sparkles, Type, + XCircle, } from "lucide-react"; -import { useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Kbd } from "@/components/ui/kbd"; import { Label } from "@/components/ui/label"; import { Slider } from "@/components/ui/slider"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + checkWorkerStatus, + getWorkerDownloadUrl, + type WorkerStatus, +} from "@/lib/formatter"; import { builtInThemes, getThemeById, type ThemeColors } from "@/lib/themes"; import { useConfigStore } from "@/stores/configStore"; @@ -136,6 +147,9 @@ export function SettingsPanel() { openOnClick, enterToSaveAndExit, confirmOnBackdropClick, + indentType, + indentSize, + formatterEnabled, setThemeId, setCustomColors, resetCustomColors, @@ -143,13 +157,43 @@ export function SettingsPanel() { setOpenOnClick, setEnterToSaveAndExit, setConfirmOnBackdropClick, + setIndentType, + setIndentSize, + setFormatterEnabled, loadFromStorage, } = useConfigStore(); + const [workerStatus, setWorkerStatus] = useState(null); + const [checkingWorker, setCheckingWorker] = useState(false); + useEffect(() => { loadFromStorage(); }, [loadFromStorage]); + const refreshWorkerStatus = useCallback(async () => { + setCheckingWorker(true); + try { + const status = await checkWorkerStatus(); + setWorkerStatus(status); + } finally { + setCheckingWorker(false); + } + }, []); + + useEffect(() => { + if (formatterEnabled) { + refreshWorkerStatus(); + } + }, [formatterEnabled, refreshWorkerStatus]); + + const handleDownloadScript = useCallback(() => { + const url = getWorkerDownloadUrl(); + const a = document.createElement("a"); + a.href = url; + a.download = "vimput-formatter.py"; + a.click(); + }, []); + const currentTheme = getThemeById(themeId); const hasCustomColors = Object.keys(customColors).length > 0; @@ -346,6 +390,235 @@ export function SettingsPanel() { 24px + +
+
+ + +
+
+ + {indentType === "spaces" && ( + + )} +
+
+ +
+
+
+ +
+ +

+ Format code with :fmt or Space+c+f +

+
+
+ +
+ {formatterEnabled && ( +
+ {/* Worker Status */} +
+
+ {workerStatus?.available ? ( + + ) : ( + + )} + + {workerStatus?.available + ? "Worker connected" + : "Worker not running"} + +
+ +
+ + {/* Available formatters */} + {workerStatus?.available && + Object.keys(workerStatus.formatters).length > 0 && ( +
+ + Available:{" "} + + + {Object.entries(workerStatus.formatters) + .map(([lang, cmd]) => `${lang} (${cmd})`) + .join(", ")} + +
+ )} + + {/* Download and instructions */} + {!workerStatus?.available && ( +
+

Download the script and run it to enable formatting.

+
+ +
+
+
+ + Linux/macOS:{" "} + + + python3 vimput-formatter.py + +
+
+ + Windows:{" "} + + + python vimput-formatter.py + +
+
+

+ Requires Python 3.8+. Dependencies install automatically. +

+
+ )} + + {/* Privacy notice */} +

+ Privacy:{" "} + The worker runs 100% locally. Your code never leaves your + machine. +

+
+ )} +
{/* Misc Tab */} diff --git a/components/VimputEditor.tsx b/components/VimputEditor.tsx index 6ab699e..fb0d328 100644 --- a/components/VimputEditor.tsx +++ b/components/VimputEditor.tsx @@ -1,4 +1,4 @@ -import { GripHorizontal, LogOut, Menu, Save, X } from "lucide-react"; +import { GripHorizontal, LogOut, Menu, Save, Sparkles, X } from "lucide-react"; import { Highlight, type Language, themes } from "prism-react-renderer"; import { forwardRef, @@ -23,6 +23,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { formatCode, isFormatterSupported } from "@/lib/formatter"; import { defaultDarkTheme, type PrismThemeName, @@ -100,6 +101,9 @@ interface VimputEditorProps { inputLabel?: string; initialLanguage?: string; onLanguageChange?: (language: string) => void; + indentType?: "tabs" | "spaces"; + indentSize?: 2 | 4 | 8; + formatterEnabled?: boolean; } interface Position { @@ -126,6 +130,9 @@ export const VimputEditor = forwardRef( inputLabel, initialLanguage = "plaintext", onLanguageChange, + indentType = "spaces", + indentSize = 2, + formatterEnabled = false, }, ref, ) { @@ -147,6 +154,8 @@ export const VimputEditor = forwardRef( const [selectedLanguage, setSelectedLanguage] = useState< Language | "plaintext" >(initialLanguage as Language | "plaintext"); + const [statusMessage, setStatusMessage] = useState(null); + const [isFormatting, setIsFormatting] = useState(false); const handleLanguageChange = useCallback( (language: string) => { @@ -156,23 +165,96 @@ export const VimputEditor = forwardRef( [onLanguageChange], ); + const showTemporaryMessage = useCallback( + (message: string, duration = 3000) => { + setStatusMessage(message); + setTimeout(() => setStatusMessage(null), duration); + }, + [], + ); + + const handleFormat = useCallback(async () => { + if (isFormatting) return; + + if (!formatterEnabled) { + showTemporaryMessage("Formatter disabled. Enable in settings."); + return; + } + + if (!isFormatterSupported(selectedLanguage)) { + showTemporaryMessage(`No formatter available for ${selectedLanguage}`); + return; + } + + setIsFormatting(true); + showTemporaryMessage("Formatting..."); + const result = await formatCode(vimState.text, selectedLanguage, { + indentType, + indentSize, + }); + setIsFormatting(false); + + if (result.success) { + setVimState((prev) => ({ + ...prev, + text: result.result, + cursor: { line: 0, column: 0 }, + })); + showTemporaryMessage("Formatted"); + } else { + showTemporaryMessage(result.error || "Format failed"); + } + }, [ + isFormatting, + formatterEnabled, + selectedLanguage, + vimState.text, + indentType, + indentSize, + showTemporaryMessage, + ]); + + // Handle pending actions from vim commands + useEffect(() => { + if (vimState.pendingAction === "format") { + handleFormat(); + setVimState((prev) => ({ ...prev, pendingAction: null })); + } + }, [vimState.pendingAction, handleFormat]); + const editorRef = useRef(null); const containerRef = useRef(null); const hasUnsavedChanges = vimState.text !== lastSavedText; - // Cursor blinking effect - useEffect(() => { - const blinkInterval = setInterval(() => { - setCursorVisible((prev) => !prev); - }, 530); - - return () => clearInterval(blinkInterval); - }, []); + // Cursor blinking effect - only blink when idle + const blinkIntervalRef = useRef | null>( + null, + ); - // Reset cursor visibility when state changes (e.g., typing, moving) useEffect(() => { + // Reset cursor to visible on any state change setCursorVisible(true); + + // Clear any existing blink interval + if (blinkIntervalRef.current) { + clearInterval(blinkIntervalRef.current); + blinkIntervalRef.current = null; + } + + // Start blinking after a short delay (idle detection) + const blinkDelay = setTimeout(() => { + blinkIntervalRef.current = setInterval(() => { + setCursorVisible((prev) => !prev); + }, 530); + }, 530); + + return () => { + clearTimeout(blinkDelay); + if (blinkIntervalRef.current) { + clearInterval(blinkIntervalRef.current); + } + }; }, []); const colors = theme.colors; @@ -325,7 +407,7 @@ export const VimputEditor = forwardRef( // Handle special keys if (key === "Tab") { - key = "\t"; + key = indentType === "tabs" ? "\t" : " ".repeat(indentSize); } const prevCommand = vimState.commandBuffer; @@ -360,6 +442,8 @@ export const VimputEditor = forwardRef( isResizing, handleSaveAndClose, enterToSaveAndExit, + indentType, + indentSize, ], ); @@ -511,7 +595,7 @@ export const VimputEditor = forwardRef(
{renderLineWithCursor( line, @@ -546,7 +630,7 @@ export const VimputEditor = forwardRef(
{renderHighlightedLine( styledTokens, @@ -578,7 +662,9 @@ export const VimputEditor = forwardRef( }} >
- {vimState.mode === "command" ? ( + {statusMessage ? ( + {statusMessage} + ) : vimState.mode === "command" ? ( {vimState.commandBuffer} @@ -618,7 +704,17 @@ export const VimputEditor = forwardRef( className="text-xs cursor-pointer" style={{ color: colors.headerText }} > - {lang.label} + + {lang.label} + {formatterEnabled && isFormatterSupported(lang.value) && ( + + + + )} + ))} @@ -762,14 +858,16 @@ function renderChar( - | - + /> {char} ); @@ -824,6 +922,8 @@ function renderEndOfLineCursor( style={{ backgroundColor: colors.visualSelection, width: "0.5rem", + height: "1.5em", + verticalAlign: "text-bottom", }} > {"\u00A0"} @@ -835,18 +935,24 @@ function renderEndOfLineCursor( style={ vimState.mode === "insert" ? { - color: colors.editorText, + width: "2px", + height: "1.2em", + backgroundColor: colors.editorText, opacity: cursorVisible ? 1 : 0, + verticalAlign: "text-bottom", + marginTop: "0.1em", } : { backgroundColor: cursorVisible ? colors.cursorBackground : "transparent", width: "0.5rem", + height: "1.5em", + verticalAlign: "text-bottom", } } > - {vimState.mode === "insert" ? "|" : "\u00A0"} + {vimState.mode === "insert" ? "" : "\u00A0"} )} diff --git a/entrypoints/content.tsx b/entrypoints/content.tsx index ea122b0..262db6a 100644 --- a/entrypoints/content.tsx +++ b/entrypoints/content.tsx @@ -13,6 +13,88 @@ type EditableElement = HTMLInputElement | HTMLTextAreaElement | HTMLElement; // Store reference to the currently focused editable element let activeElement: EditableElement | null = null; + +// Inject a script into the page context to access Monaco/Ace APIs +// Uses external file to comply with CSP restrictions +function injectPageScript() { + const script = document.createElement("script"); + script.src = browser.runtime.getURL("/pageScript.js"); + script.onload = () => { + script.remove(); + }; + script.onerror = (e) => { + console.error("[Vimput] Failed to load page script:", e); + script.remove(); + }; + (document.head || document.documentElement).appendChild(script); +} + +// Get text from Monaco/Ace via injected script +function getEditorTextViaPageScript(): Promise { + return new Promise((resolve) => { + let resolved = false; + const handler = (e: Event) => { + if (resolved) return; + resolved = true; + window.removeEventListener("vimput-editor-text-response", handler); + const detail = (e as CustomEvent).detail; + resolve(detail.text); + }; + window.addEventListener("vimput-editor-text-response", handler); + window.dispatchEvent(new CustomEvent("vimput-get-editor-text")); + // Timeout fallback - increased to 500ms for slower pages + setTimeout(() => { + if (resolved) return; + resolved = true; + window.removeEventListener("vimput-editor-text-response", handler); + resolve(null); + }, 500); + }); +} + +// Firefox-specific: cloneInto is needed to pass data from content script to page script +declare function cloneInto( + obj: T, + targetScope: Window | object, + options?: { cloneFunctions?: boolean; wrapReflectors?: boolean }, +): T; + +// Helper to create detail object that works across both Chrome and Firefox +function createEventDetail(detail: T): T { + // In Firefox, we need to use cloneInto to pass data to page script context + // In Chrome, cloneInto doesn't exist, so we just return the detail as-is + if (typeof cloneInto === "function") { + return cloneInto(detail, window); + } + return detail; +} + +// Set text in Monaco/Ace via injected script +function setEditorTextViaPageScript(text: string): Promise { + return new Promise((resolve) => { + let resolved = false; + const handler = (e: Event) => { + if (resolved) return; + resolved = true; + window.removeEventListener("vimput-set-text-response", handler); + const detail = (e as CustomEvent).detail; + resolve(detail.success); + }; + window.addEventListener("vimput-set-text-response", handler); + window.dispatchEvent( + new CustomEvent("vimput-set-editor-text", { + detail: createEventDetail({ text }), + }), + ); + // Timeout fallback - increased to 500ms for slower pages + setTimeout(() => { + if (resolved) return; + resolved = true; + window.removeEventListener("vimput-set-text-response", handler); + resolve(false); + }, 500); + }); +} let editorRoot: ReactDOM.Root | null = null; let shadowHost: HTMLDivElement | null = null; let editorRef: React.RefObject | null = null; @@ -48,6 +130,8 @@ function isEditableElement(element: HTMLElement): boolean { element.classList.contains("monaco-editor") || element.classList.contains("CodeMirror") || element.classList.contains("ace_editor") || + element.closest(".ace_editor") || + element.closest(".monaco-editor") || element.getAttribute("role") === "textbox" || element.getAttribute("role") === "code" ) { @@ -87,7 +171,7 @@ function findEditableElement(element: HTMLElement): EditableElement | null { return null; } -function getElementText(element: EditableElement): string { +async function getElementText(element: EditableElement): Promise { if ( element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement @@ -95,11 +179,50 @@ function getElementText(element: EditableElement): string { return element.value; } + // Check if this is a Monaco or Ace editor - use page script for API access + const monacoEditor = element.closest(".monaco-editor"); + const aceEditor = element.closest(".ace_editor"); + + if (monacoEditor || aceEditor) { + // Try to get text via injected page script (can access Monaco/Ace APIs) + const text = await getEditorTextViaPageScript(); + if (text !== null) { + return text; + } + + // Fallback: DOM scraping for Ace + if (aceEditor) { + const textLayer = aceEditor.querySelector(".ace_text-layer"); + if (textLayer) { + const lines: string[] = []; + textLayer.querySelectorAll(".ace_line").forEach((line) => { + lines.push(line.textContent || ""); + }); + return lines.join("\n"); + } + } + + // Fallback: DOM scraping for Monaco (less reliable due to virtualization) + if (monacoEditor) { + const viewLines = monacoEditor.querySelector(".view-lines"); + if (viewLines) { + const lines: string[] = []; + viewLines.querySelectorAll(".view-line").forEach((line) => { + lines.push(line.textContent || ""); + }); + return lines.join("\n"); + } + } + } + // For contenteditable elements, get the text content return element.innerText || element.textContent || ""; } -function setElementText(element: EditableElement, text: string): void { +async function setElementText( + element: EditableElement, + text: string, +): Promise { if ( element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement @@ -107,11 +230,31 @@ function setElementText(element: EditableElement, text: string): void { element.value = text; element.dispatchEvent(new Event("input", { bubbles: true })); element.dispatchEvent(new Event("change", { bubbles: true })); - } else { - // For contenteditable elements - element.innerText = text; - element.dispatchEvent(new Event("input", { bubbles: true })); + return; + } + + // Check if this is a Monaco or Ace editor - use page script for API access + const monacoEditor = element.closest(".monaco-editor"); + const aceEditor = element.closest(".ace_editor"); + + if (monacoEditor || aceEditor) { + // Try to set text via injected page script (can access Monaco/Ace APIs) + const success = await setEditorTextViaPageScript(text); + if (success) { + return; + } + + // If the API approach failed, log a warning + // We avoid using execCommand fallback as it causes text duplication issues + console.warn( + "[Vimput] Could not set text via editor API. The editor might not be fully supported.", + ); + return; } + + // For contenteditable elements + element.innerText = text; + element.dispatchEvent(new Event("input", { bubbles: true })); } function getElementLabel(element: EditableElement): string | undefined { @@ -176,6 +319,9 @@ export default defineContentScript({ cssInjectionMode: "ui", main() { + // Inject script into page context for Monaco/Ace API access + injectPageScript(); + // Track focused editable elements document.addEventListener( "focusin", @@ -227,8 +373,6 @@ export default defineContentScript({ openEditor(); } }); - - console.log("Vimput content script loaded"); }, }); @@ -239,6 +383,9 @@ interface EditorConfig { enterToSaveAndExit: boolean; confirmOnBackdropClick: boolean; syntaxLanguage: string; + indentType: "tabs" | "spaces"; + indentSize: 2 | 4 | 8; + formatterEnabled: boolean; } async function getConfig(): Promise { @@ -251,6 +398,9 @@ async function getConfig(): Promise { "enterToSaveAndExit", "confirmOnBackdropClick", "syntaxLanguage", + "indentType", + "indentSize", + "formatterEnabled", ]); const themeId = (result.themeId as string) || "default-dark"; @@ -271,19 +421,25 @@ async function getConfig(): Promise { theme, fontSize: (result.fontSize as number) || 14, openOnClick: (result.openOnClick as boolean) ?? false, - enterToSaveAndExit: (result.enterToSaveAndExit as boolean) ?? false, + enterToSaveAndExit: (result.enterToSaveAndExit as boolean) ?? true, confirmOnBackdropClick: - (result.confirmOnBackdropClick as boolean) ?? true, + (result.confirmOnBackdropClick as boolean) ?? false, syntaxLanguage: (result.syntaxLanguage as string) || "plaintext", + indentType: (result.indentType as "tabs" | "spaces") || "spaces", + indentSize: (result.indentSize as 2 | 4 | 8) || 2, + formatterEnabled: (result.formatterEnabled as boolean) ?? false, }; } catch { return { theme: defaultDarkTheme, fontSize: 14, openOnClick: false, - enterToSaveAndExit: false, - confirmOnBackdropClick: true, + enterToSaveAndExit: true, + confirmOnBackdropClick: false, syntaxLanguage: "plaintext", + indentType: "spaces", + indentSize: 2, + formatterEnabled: false, }; } } @@ -305,8 +461,8 @@ async function openEditor(startInInsertMode = false) { } const config = await getConfig(); - const initialText = getElementText(activeElement); - const shouldStartInInsertMode = startInInsertMode || config.openOnClick; + const initialText = await getElementText(activeElement); + const shouldStartInInsertMode = startInInsertMode; // Create shadow DOM host for style isolation shadowHost = document.createElement("div"); @@ -395,6 +551,9 @@ async function openEditor(startInInsertMode = false) { confirmOnBackdropClick={config.confirmOnBackdropClick} inputLabel={inputLabel} initialLanguage={config.syntaxLanguage} + indentType={config.indentType} + indentSize={config.indentSize} + formatterEnabled={config.formatterEnabled} onLanguageChange={async (language) => { try { await browser.storage.sync.set({ syntaxLanguage: language }); @@ -402,9 +561,9 @@ async function openEditor(startInInsertMode = false) { console.error("Failed to save syntax language:", error); } }} - onSave={(text) => { + onSave={async (text) => { if (targetElement) { - setElementText(targetElement, text); + await setElementText(targetElement, text); } }} onClose={closeEditor} diff --git a/entrypoints/popup/App.tsx b/entrypoints/popup/App.tsx index 216abd9..cc1feb1 100644 --- a/entrypoints/popup/App.tsx +++ b/entrypoints/popup/App.tsx @@ -89,7 +89,7 @@ function App() { color: colors?.statusText, }} > - v1.2.0 + v1.3.1
diff --git a/lib/formatter.ts b/lib/formatter.ts new file mode 100644 index 0000000..cc8bdfa --- /dev/null +++ b/lib/formatter.ts @@ -0,0 +1,223 @@ +import { format as formatSQL } from "sql-formatter"; + +const WORKER_URL = "http://localhost:7483"; + +// Languages supported by the local formatter worker +const WORKER_LANGUAGES = new Set([ + "javascript", + "typescript", + "jsx", + "tsx", + "python", + "css", + "html", + "json", + "markdown", + "yaml", + "graphql", + "bash", + "sql", + "go", + "rust", + "java", + "c", + "cpp", +]); + +// Fallback: SQL is always supported via bundled sql-formatter +const BUILTIN_LANGUAGES = new Set(["sql"]); + +// Formatter suggestions per language +const FORMATTER_SUGGESTIONS: Record = { + javascript: "prettier (npm install -g prettier)", + typescript: "prettier (npm install -g prettier)", + jsx: "prettier (npm install -g prettier)", + tsx: "prettier (npm install -g prettier)", + css: "prettier (npm install -g prettier)", + html: "prettier (npm install -g prettier)", + json: "prettier (npm install -g prettier)", + markdown: "prettier (npm install -g prettier)", + yaml: "prettier (npm install -g prettier)", + graphql: "prettier (npm install -g prettier)", + python: "black or ruff (pip install black)", + bash: "shfmt (brew install shfmt)", + go: "gofmt (included with Go)", + rust: "rustfmt (rustup component add rustfmt)", + java: "google-java-format", + c: "clang-format (brew install clang-format)", + cpp: "clang-format (brew install clang-format)", +}; + +function getFormatterSuggestion(language: string): string { + return FORMATTER_SUGGESTIONS[language] || "a formatter"; +} + +export interface FormatOptions { + indentType: "tabs" | "spaces"; + indentSize: 2 | 4 | 8; +} + +export interface WorkerStatus { + available: boolean; + formatters: Record; +} + +let workerStatus: WorkerStatus | null = null; +let lastWorkerCheck = 0; +const WORKER_CHECK_INTERVAL = 5000; // 5 seconds + +/** + * Check if the formatter worker is running and get available formatters. + */ +export async function checkWorkerStatus(): Promise { + const now = Date.now(); + + // Use cached status if recent + if (workerStatus && now - lastWorkerCheck < WORKER_CHECK_INTERVAL) { + return workerStatus; + } + + try { + const [healthRes, formattersRes] = await Promise.all([ + fetch(`${WORKER_URL}/health`, { method: "GET" }), + fetch(`${WORKER_URL}/formatters`, { method: "GET" }), + ]); + + if (healthRes.ok && formattersRes.ok) { + const formatters = await formattersRes.json(); + workerStatus = { available: true, formatters }; + lastWorkerCheck = now; + return workerStatus; + } + } catch { + // Worker not running + } + + workerStatus = { available: false, formatters: {} }; + lastWorkerCheck = now; + return workerStatus; +} + +/** + * Check if a language has formatter support (either via worker or builtin). + */ +export function isFormatterSupported(language: string): boolean { + return WORKER_LANGUAGES.has(language) || BUILTIN_LANGUAGES.has(language); +} + +/** + * Check if a language is supported by the worker (when available). + */ +export function isWorkerLanguage(language: string): boolean { + return WORKER_LANGUAGES.has(language); +} + +/** + * Get all languages that could be formatted. + */ +export function getFormatterSupportedLanguages(): string[] { + return Array.from(WORKER_LANGUAGES); +} + +/** + * Format code using the worker if available, otherwise use builtin formatter. + */ +export async function formatCode( + code: string, + language: string, + options: FormatOptions, +): Promise<{ success: boolean; result: string; error?: string }> { + // Check if language is supported at all + if (!isFormatterSupported(language)) { + return { + success: false, + result: code, + error: `No formatter for ${language}`, + }; + } + + // Try worker first for all supported languages + if (WORKER_LANGUAGES.has(language)) { + const status = await checkWorkerStatus(); + + if (status.available) { + // Check if worker has a formatter for this language + if (status.formatters[language]) { + try { + const response = await fetch(`${WORKER_URL}/format`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code, language, options }), + }); + + if (response.ok) { + const data = await response.json(); + if (data.success) { + return { success: true, result: data.result }; + } + return { + success: false, + result: code, + error: data.error || "Formatter error", + }; + } + } catch (_err) { + // Worker request failed, try builtin fallback + } + } else { + // Worker running but no formatter for this language + if (!BUILTIN_LANGUAGES.has(language)) { + const suggestions = getFormatterSuggestion(language); + return { + success: false, + result: code, + error: `No ${language} formatter installed. Install ${suggestions} and restart the worker.`, + }; + } + } + } else { + // Worker not running + if (!BUILTIN_LANGUAGES.has(language)) { + return { + success: false, + result: code, + error: + "Formatter worker not running. Start it to format this language.", + }; + } + } + } + + // Builtin fallback for SQL + if (language === "sql") { + try { + const formatted = formatSQL(code, { + tabWidth: options.indentSize, + useTabs: options.indentType === "tabs", + keywordCase: "upper", + }); + return { success: true, result: formatted }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + return { + success: false, + result: code, + error: errorMessage, + }; + } + } + + return { + success: false, + result: code, + error: `Unknown language: ${language}`, + }; +} + +/** + * Get the URL to download the formatter worker script. + */ +export function getWorkerDownloadUrl(): string { + return browser.runtime.getURL("/vimput-formatter.py"); +} diff --git a/lib/vimEngine.ts b/lib/vimEngine.ts index bea539b..4de42a8 100644 --- a/lib/vimEngine.ts +++ b/lib/vimEngine.ts @@ -12,6 +12,8 @@ export interface CursorPosition { column: number; } +export type PendingAction = "format" | null; + export interface VimState { mode: VimMode; text: string; @@ -21,6 +23,7 @@ export interface VimState { isLineYank: boolean; visualStart: CursorPosition | null; pendingOperator: string | null; // For operators like 'c', 'd' waiting for motion + pendingAction: PendingAction; // Action to be executed by the component } export interface VimAction { @@ -29,20 +32,24 @@ export interface VimAction { } export function createInitialState(text: string = ""): VimState { + // Normalize line endings on initialization + const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); return { mode: "normal", - text, + text: normalizedText, cursor: { line: 0, column: 0 }, commandBuffer: "", yankBuffer: "", isLineYank: false, visualStart: null, pendingOperator: null, + pendingAction: null, }; } function getLines(text: string): string[] { - return text.split("\n"); + // Normalize line endings (handle Windows \r\n and old Mac \r) + return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); } function joinLines(lines: string[]): string { @@ -213,7 +220,15 @@ export function processKey(state: VimState, key: string): VimState { }; } - if (key.length === 1) { + // Insert printable characters (single char or multi-char like spaces for Tab) + if ( + key.length >= 1 && + !key.startsWith("Arrow") && + key !== "Shift" && + key !== "Control" && + key !== "Alt" && + key !== "Meta" + ) { const newLine = currentLine.slice(0, state.cursor.column) + key + @@ -222,7 +237,7 @@ export function processKey(state: VimState, key: string): VimState { return { ...state, text: joinLines(lines), - cursor: { ...state.cursor, column: state.cursor.column + 1 }, + cursor: { ...state.cursor, column: state.cursor.column + key.length }, }; } @@ -494,6 +509,22 @@ export function processKey(state: VimState, key: string): VimState { return { ...state, commandBuffer: "g" }; } + // Handle leader key sequences ( as leader) + // cf - format code + if (key === "f" && state.commandBuffer === " c") { + return { + ...state, + commandBuffer: "", + pendingAction: "format", + }; + } + if (key === "c" && state.commandBuffer === " ") { + return { ...state, commandBuffer: " c" }; + } + if (key === " " && !state.commandBuffer) { + return { ...state, commandBuffer: " " }; + } + // Undo command buffer on other keys if (state.commandBuffer) { return { ...state, commandBuffer: "" }; @@ -1201,6 +1232,15 @@ function executeCommand(state: VimState): VimState { case "q!": // Force quit - will be handled by the component return { ...state, mode: "normal", commandBuffer: "" }; + case "fmt": + case "format": + // Format - will be handled by the component + return { + ...state, + mode: "normal", + commandBuffer: "", + pendingAction: "format", + }; default: return { ...state, mode: "normal", commandBuffer: "" }; } diff --git a/package.json b/package.json index ad2e5b3..719d2fe 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "vimput", "description": "Edit any text input with a Vim-powered editor", "private": true, - "version": "1.2.0", + "version": "1.3.1", "type": "module", "scripts": { "dev": "wxt", @@ -67,6 +67,7 @@ "react-resizable-panels": "^4.4.1", "recharts": "2.15.4", "sonner": "^2.0.7", + "sql-formatter": "^15.7.0", "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7", "vaul": "^1.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 858354f..7cdad77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,6 +137,9 @@ importers: sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + sql-formatter: + specifier: ^15.7.0 + version: 15.7.0 tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -1867,6 +1870,9 @@ packages: resolution: {integrity: sha512-lomjuFZKfM6MSAnV9aCZC9sc0qGbmZdfygNv+nCpqVkSKdCxCklLtd16O0EILGkImHw9ZpHkAnHaB+8Zxq5W6Q==} engines: {node: '>=8.0.0'} + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + commander@2.9.0: resolution: {integrity: sha512-bmkUukX8wAOjHdN26xj5c4ctEV22TQ7dQYhSmuckKhToXrkUn0iIaolHdIxYYqD55nhpSPA9zPQ1yP57GdXP2A==} engines: {node: '>= 0.6.x'} @@ -2047,6 +2053,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + discontinuous-range@1.0.0: + resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==} + dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} @@ -2787,6 +2796,9 @@ packages: mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + moo@0.5.2: + resolution: {integrity: sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2806,6 +2818,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + nearley@2.20.1: + resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==} + hasBin: true + next-themes@0.4.6: resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: @@ -3018,6 +3034,13 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + railroad-diagrams@1.0.0: + resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==} + + randexp@0.4.6: + resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==} + engines: {node: '>=0.12'} + rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} @@ -3153,6 +3176,10 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} + ret@0.1.15: + resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==} + engines: {node: '>=0.12'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -3271,6 +3298,10 @@ packages: split@1.0.1: resolution: {integrity: sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==} + sql-formatter@15.7.0: + resolution: {integrity: sha512-o2yiy7fYXK1HvzA8P6wwj8QSuwG3e/XcpWht/jIxkQX99c0SVPw0OXdLSV9fHASPiYB09HLA0uq8hokGydi/QA==} + hasBin: true + stdin-discarder@0.2.2: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} @@ -5285,6 +5316,8 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 + commander@2.20.3: {} + commander@2.9.0: dependencies: graceful-readlink: 1.0.1 @@ -5429,6 +5462,8 @@ snapshots: detect-node-es@1.1.0: {} + discontinuous-range@1.0.0: {} + dom-helpers@5.2.1: dependencies: '@babel/runtime': 7.28.2 @@ -6108,6 +6143,8 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.3 + moo@0.5.2: {} + ms@2.1.3: {} multimatch@6.0.0: @@ -6123,6 +6160,13 @@ snapshots: natural-compare@1.4.0: {} + nearley@2.20.1: + dependencies: + commander: 2.20.3 + moo: 0.5.2 + railroad-diagrams: 1.0.0 + randexp: 0.4.6 + next-themes@0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: react: 19.2.3 @@ -6379,6 +6423,13 @@ snapshots: quick-format-unescaped@4.0.4: {} + railroad-diagrams@1.0.0: {} + + randexp@0.4.6: + dependencies: + discontinuous-range: 1.0.0 + ret: 0.1.15 + rc9@2.1.2: dependencies: defu: 6.1.4 @@ -6516,6 +6567,8 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 + ret@0.1.15: {} + reusify@1.1.0: {} rfdc@1.4.1: {} @@ -6635,6 +6688,11 @@ snapshots: dependencies: through: 2.3.8 + sql-formatter@15.7.0: + dependencies: + argparse: 2.0.1 + nearley: 2.20.1 + stdin-discarder@0.2.2: {} string-width@4.2.3: diff --git a/public/pageScript.js b/public/pageScript.js new file mode 100644 index 0000000..21ca808 --- /dev/null +++ b/public/pageScript.js @@ -0,0 +1,128 @@ +// Vimput Page Script - Injected to access Monaco/Ace APIs +(() => { + // Listen for requests from content script to get editor text + window.addEventListener("vimput-get-editor-text", () => { + let text = null; + let editorType = null; + + // Try TypeScript Playground sandbox.editor + if (window.sandbox?.editor?.getValue) { + try { + text = window.sandbox.editor.getValue(); + editorType = "sandbox"; + } catch (err) { + console.error("[Vimput] sandbox.editor.getValue error:", err); + } + } + + // Try Monaco editor + if (!text && window.monaco?.editor?.getModels) { + try { + const models = window.monaco.editor.getModels(); + if (models.length > 0) { + text = models[0].getValue(); + editorType = "monaco"; + } + } catch (err) { + console.error("[Vimput] monaco.editor.getValue error:", err); + } + } + + // Try Ace editor + if (!text && window.ace?.edit) { + try { + const aceEl = document.querySelector(".ace_editor"); + if (aceEl) { + const editor = window.ace.edit(aceEl); + if (editor?.getValue) { + text = editor.getValue(); + editorType = "ace"; + } + } + } catch (err) { + console.error("[Vimput] ace.editor.getValue error:", err); + } + } + + window.dispatchEvent( + new CustomEvent("vimput-editor-text-response", { + detail: { text, editorType }, + }), + ); + }); + + // Listen for requests from content script to set editor text + window.addEventListener("vimput-set-editor-text", (e) => { + const text = e.detail.text; + let success = false; + + // Try TypeScript Playground sandbox.editor (uses Monaco under the hood) + if (window.sandbox?.editor) { + try { + const editor = window.sandbox.editor; + const model = editor.getModel?.(); + if (model) { + // Use pushEditOperations for clean full replacement + const fullRange = model.getFullModelRange(); + model.pushEditOperations( + [], + [{ range: fullRange, text: text }], + () => null, + ); + editor.setPosition?.({ lineNumber: 1, column: 1 }); + editor.focus?.(); + success = true; + } + } catch (err) { + console.error("[Vimput] sandbox.editor set error:", err); + } + } + + // Try generic Monaco editor + if (!success && window.monaco?.editor) { + try { + const editors = window.monaco.editor.getEditors?.() || []; + if (editors.length > 0) { + const editor = editors[0]; + const model = editor.getModel?.(); + if (model) { + const fullRange = model.getFullModelRange(); + model.pushEditOperations( + [], + [{ range: fullRange, text: text }], + () => null, + ); + editor.setPosition?.({ lineNumber: 1, column: 1 }); + editor.focus?.(); + success = true; + } + } + } catch (err) { + console.error("[Vimput] monaco.editor set error:", err); + } + } + + // Try Ace editor + if (!success && window.ace?.edit) { + try { + const aceEl = document.querySelector(".ace_editor"); + if (aceEl) { + const editor = window.ace.edit(aceEl); + if (editor?.setValue) { + // Ace setValue: second param -1 moves cursor to start + editor.setValue(text, -1); + success = true; + } + } + } catch (err) { + console.error("[Vimput] ace.editor set error:", err); + } + } + + window.dispatchEvent( + new CustomEvent("vimput-set-text-response", { + detail: { success }, + }), + ); + }); +})(); diff --git a/public/vimput-formatter.py b/public/vimput-formatter.py new file mode 100644 index 0000000..14867e7 --- /dev/null +++ b/public/vimput-formatter.py @@ -0,0 +1,471 @@ +#!/usr/bin/env python3 +""" +Vimput Formatter Worker +A local HTTP server that formats code using system-installed formatters. + +Usage: + python vimput-formatter.py + +The server runs on http://localhost:7483 +Dependencies are installed automatically on first run. +""" + +import subprocess +import shutil +import sys +import os +import platform + +PORT = 7483 +IS_WINDOWS = platform.system() == "Windows" + + +def pip_install(package: str) -> bool: + """Try to install a package with pip using various strategies.""" + strategies = [ + [sys.executable, "-m", "pip", "install", "--quiet", package], + [sys.executable, "-m", "pip", "install", "--quiet", "--user", package], + [sys.executable, "-m", "pip", "install", "--quiet", "--break-system-packages", package], + ] + + for cmd in strategies: + try: + subprocess.check_call(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return True + except subprocess.CalledProcessError: + continue + return False + + +def ensure_flask(): + """Install Flask if not present.""" + try: + import flask + except ImportError: + print("Installing Flask...") + if pip_install("flask"): + print("Flask installed.") + else: + print("Failed to install Flask. Please install manually: pip install flask") + sys.exit(1) + + +# Install commands for each formatter +INSTALL_COMMANDS = { + "prettier": { + "npm": "npm install -g prettier", + "desc": "JS/TS/CSS/HTML/JSON/MD/YAML/GraphQL", + }, + "black": { + "pip": "black", + "desc": "Python", + }, + "ruff": { + "pip": "ruff", + "desc": "Python (faster)", + }, + "shfmt": { + "brew": "shfmt", + "go": "go install mvdan.cc/sh/v3/cmd/shfmt@latest", + "desc": "Bash/Shell", + }, + "gofmt": { + "note": "Included with Go installation", + "desc": "Go", + }, + "rustfmt": { + "note": "Included with Rust (rustup component add rustfmt)", + "desc": "Rust", + }, + "clang-format": { + "brew": "clang-format", + "apt": "clang-format", + "desc": "C/C++", + }, +} + +# Formatter configurations +FORMATTERS = { + "javascript": [ + ("prettier", ["--parser", "babel", "--stdin-filepath", "file.js"], None), + ("biome", ["format", "--stdin-file-path", "file.js"], None), + ], + "typescript": [ + ("prettier", ["--parser", "typescript", "--stdin-filepath", "file.ts"], None), + ("biome", ["format", "--stdin-file-path", "file.ts"], None), + ], + "jsx": [ + ("prettier", ["--parser", "babel", "--stdin-filepath", "file.jsx"], None), + ("biome", ["format", "--stdin-file-path", "file.jsx"], None), + ], + "tsx": [ + ("prettier", ["--parser", "typescript", "--stdin-filepath", "file.tsx"], None), + ("biome", ["format", "--stdin-file-path", "file.tsx"], None), + ], + "python": [ + ("ruff", ["format", "-"], None), + ("black", ["-"], None), + ], + "css": [ + ("prettier", ["--parser", "css", "--stdin-filepath", "file.css"], None), + ], + "html": [ + ("prettier", ["--parser", "html", "--stdin-filepath", "file.html"], None), + ], + "json": [ + ("prettier", ["--parser", "json", "--stdin-filepath", "file.json"], None), + ("biome", ["format", "--stdin-file-path", "file.json"], None), + ], + "markdown": [ + ("prettier", ["--parser", "markdown", "--stdin-filepath", "file.md"], None), + ], + "yaml": [ + ("prettier", ["--parser", "yaml", "--stdin-filepath", "file.yaml"], None), + ], + "graphql": [ + ("prettier", ["--parser", "graphql", "--stdin-filepath", "file.graphql"], None), + ], + "bash": [ + ("shfmt", ["-"], None), + ], + "sql": [ + ("sqlfluff", ["fix", "-f", "ansi", "-"], None), + ("sql-formatter", [], None), + ], + "go": [ + ("gofmt", [], None), + ], + "rust": [ + ("rustfmt", [], None), + ], + "java": [ + ("google-java-format", ["-"], None), + ], + "c": [ + ("clang-format", [], None), + ], + "cpp": [ + ("clang-format", [], None), + ], +} + + +def get_available_formatters() -> dict[str, str]: + """Get all available formatters for each language.""" + available = {} + for lang, formatters in FORMATTERS.items(): + for cmd, _, _ in formatters: + if shutil.which(cmd): + available[lang] = cmd + break + return available + + +def get_missing_formatters() -> list[str]: + """Get list of recommended formatters that are not installed.""" + recommended = ["prettier", "black", "shfmt", "gofmt", "rustfmt", "clang-format"] + return [f for f in recommended if not shutil.which(f)] + + +def prompt_install_formatters(): + """Ask user if they want to install missing formatters.""" + missing = get_missing_formatters() + if not missing: + return + + print("\n Missing formatters:") + for fmt in missing: + info = INSTALL_COMMANDS.get(fmt, {}) + desc = info.get("desc", "") + print(f" - {fmt} ({desc})") + + print("\n Install options:") + + # Show pip installable + pip_fmts = [f for f in missing if "pip" in INSTALL_COMMANDS.get(f, {})] + if pip_fmts: + pkgs = " ".join(INSTALL_COMMANDS[f]["pip"] for f in pip_fmts) + print(f" pip install {pkgs}") + + # Show npm installable + npm_fmts = [f for f in missing if "npm" in INSTALL_COMMANDS.get(f, {})] + if npm_fmts: + for f in npm_fmts: + print(f" {INSTALL_COMMANDS[f]['npm']}") + + # Show brew installable (macOS) + if platform.system() == "Darwin": + brew_fmts = [f for f in missing if "brew" in INSTALL_COMMANDS.get(f, {})] + if brew_fmts: + pkgs = " ".join(INSTALL_COMMANDS[f]["brew"] for f in brew_fmts) + print(f" brew install {pkgs}") + + # Show notes + for fmt in missing: + info = INSTALL_COMMANDS.get(fmt, {}) + if "note" in info: + print(f" # {fmt}: {info['note']}") + + print("") + + # Ask about npm packages (prettier) + if npm_fmts and shutil.which("npm"): + try: + answer = input(" Install prettier via npm? [Y/n] ").strip().lower() + except (EOFError, KeyboardInterrupt): + print("") + return + + if answer != "n": + print(" Installing prettier globally...") + try: + subprocess.check_call( + ["npm", "install", "-g", "prettier"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + print(" prettier installed.") + except subprocess.CalledProcessError: + print(" Failed to install prettier. Try: npm install -g prettier") + + # Ask about pip packages + if pip_fmts: + try: + answer = input(" Install Python formatters (black)? [Y/n] ").strip().lower() + except (EOFError, KeyboardInterrupt): + print("") + return + + if answer != "n": + for fmt in pip_fmts: + pkg = INSTALL_COMMANDS[fmt]["pip"] + print(f" Installing {pkg}...") + if pip_install(pkg): + print(f" {pkg} installed.") + else: + print(f" Failed to install {pkg}.") + + +def find_formatter(language: str) -> tuple[str, list[str]] | None: + """Find the first available formatter for a language.""" + if language not in FORMATTERS: + return None + + for cmd, args, _ in FORMATTERS[language]: + if shutil.which(cmd): + return (cmd, args) + + return None + + +def format_code(code: str, language: str, options: dict) -> tuple[bool, str, str | None]: + """Format code using the appropriate formatter.""" + formatter = find_formatter(language) + if not formatter: + return (False, code, f"No formatter found for {language}") + + cmd, args = formatter + indent_type = options.get("indentType", "spaces") + indent_size = options.get("indentSize", 2) + + full_cmd = [cmd] + args + + # Add indent options for formatters that support them + if cmd == "prettier": + full_cmd.extend(["--tab-width", str(indent_size)]) + if indent_type == "tabs": + full_cmd.append("--use-tabs") + elif cmd == "biome": + full_cmd.extend(["--indent-width", str(indent_size)]) + if indent_type == "tabs": + full_cmd.extend(["--indent-style", "tab"]) + elif cmd == "shfmt": + full_cmd.extend(["-i", str(indent_size) if indent_type == "spaces" else "0"]) + elif cmd == "clang-format": + style = f"{{IndentWidth: {indent_size}, UseTab: {'Always' if indent_type == 'tabs' else 'Never'}}}" + full_cmd.extend([f"--style={style}"]) + elif cmd == "rustfmt": + full_cmd.extend(["--config", f"tab_spaces={indent_size}"]) + if indent_type == "tabs": + full_cmd.extend(["--config", "hard_tabs=true"]) + + try: + result = subprocess.run( + full_cmd, + input=code, + capture_output=True, + text=True, + timeout=10, + ) + + if result.returncode == 0: + return (True, result.stdout, None) + else: + if result.stdout and result.stdout.strip(): + return (True, result.stdout, None) + error = result.stderr or f"{cmd} exited with code {result.returncode}" + return (False, code, error) + + except subprocess.TimeoutExpired: + return (False, code, "Formatter timed out") + except Exception as e: + return (False, code, str(e)) + + +def run_server(): + """Run the Flask server.""" + from flask import Flask, request, jsonify + + app = Flask(__name__) + + @app.route("/health", methods=["GET"]) + def health(): + return jsonify({"status": "ok", "version": "1.0.0"}) + + @app.route("/formatters", methods=["GET"]) + def formatters(): + return jsonify(get_available_formatters()) + + @app.route("/format", methods=["POST"]) + def format_endpoint(): + data = request.get_json() + if not data: + return jsonify({"success": False, "error": "No JSON body"}), 400 + + code = data.get("code", "") + language = data.get("language", "") + options = data.get("options", {}) + + if not language: + return jsonify({"success": False, "error": "Language is required"}), 400 + + success, result, error = format_code(code, language, options) + response = {"success": success, "result": result} + if error: + response["error"] = error + return jsonify(response) + + @app.after_request + def add_cors_headers(response): + response.headers["Access-Control-Allow-Origin"] = "*" + response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS" + response.headers["Access-Control-Allow-Headers"] = "Content-Type" + return response + + @app.route("/format", methods=["OPTIONS"]) + @app.route("/formatters", methods=["OPTIONS"]) + @app.route("/health", methods=["OPTIONS"]) + def handle_options(): + return "", 204 + + import logging + log = logging.getLogger("werkzeug") + log.setLevel(logging.ERROR) + + app.run(host="127.0.0.1", port=PORT, debug=False) + + +def start_background(): + """Start the server in background and return the PID.""" + if IS_WINDOWS: + # Windows: use subprocess with CREATE_NEW_PROCESS_GROUP + CREATE_NEW_PROCESS_GROUP = 0x00000200 + DETACHED_PROCESS = 0x00000008 + + proc = subprocess.Popen( + [sys.executable, __file__, "--daemon"], + creationflags=DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + ) + return proc.pid + else: + # Unix: double fork to daemonize, use pipe to get real PID + read_fd, write_fd = os.pipe() + + pid = os.fork() + if pid > 0: + # Parent: wait for real PID from pipe + os.close(write_fd) + real_pid = os.read(read_fd, 32).decode().strip() + os.close(read_fd) + os.waitpid(pid, 0) # Reap first child + return int(real_pid) if real_pid else pid + + # First child: create new session + os.close(read_fd) + os.setsid() + + # Second fork + pid = os.fork() + if pid > 0: + # Send grandchild PID to parent + os.write(write_fd, str(pid).encode()) + os.close(write_fd) + os._exit(0) + + # Grandchild: close pipe and redirect std streams + os.close(write_fd) + sys.stdout.flush() + sys.stderr.flush() + + with open(os.devnull, 'r') as devnull: + os.dup2(devnull.fileno(), sys.stdin.fileno()) + with open(os.devnull, 'w') as devnull: + os.dup2(devnull.fileno(), sys.stdout.fileno()) + os.dup2(devnull.fileno(), sys.stderr.fileno()) + + run_server() + os._exit(0) + + +def main(): + ensure_flask() + + # If called with --daemon, just run server (for Windows background process) + if "--daemon" in sys.argv: + run_server() + return + + print("") + print(" Vimput Formatter Worker v1.0.0") + print(" ==============================") + + available = get_available_formatters() + if available: + print("\n Available formatters:") + for lang, cmd in sorted(available.items()): + print(f" {lang:<12} -> {cmd}") + else: + print("\n No formatters found.") + + # Prompt to install missing formatters + prompt_install_formatters() + + # Refresh available formatters after potential install + available = get_available_formatters() + + # Start in background + print("\n Starting server in background...") + pid = start_background() + + # Give server a moment to start + import time + time.sleep(0.5) + + print(f""" + Server running on http://localhost:{PORT} + PID: {pid} +""") + + if IS_WINDOWS: + print(f" To stop: taskkill /PID {pid} /F") + else: + print(f" To stop: kill {pid}") + + print("") + + +if __name__ == "__main__": + main() diff --git a/stores/configStore.ts b/stores/configStore.ts index 87b34a5..2f4cf15 100644 --- a/stores/configStore.ts +++ b/stores/configStore.ts @@ -6,6 +6,9 @@ import { type ThemeColors, } from "@/lib/themes"; +export type IndentType = "tabs" | "spaces"; +export type IndentSize = 2 | 4 | 8; + export interface ConfigState { themeId: string; customColors: Partial; @@ -14,6 +17,9 @@ export interface ConfigState { enterToSaveAndExit: boolean; confirmOnBackdropClick: boolean; syntaxLanguage: string; + indentType: IndentType; + indentSize: IndentSize; + formatterEnabled: boolean; setThemeId: (themeId: string) => void; setCustomColors: (colors: Partial) => void; resetCustomColors: () => void; @@ -22,6 +28,9 @@ export interface ConfigState { setEnterToSaveAndExit: (enabled: boolean) => void; setConfirmOnBackdropClick: (enabled: boolean) => void; setSyntaxLanguage: (language: string) => void; + setIndentType: (type: IndentType) => void; + setIndentSize: (size: IndentSize) => void; + setFormatterEnabled: (enabled: boolean) => void; getActiveTheme: () => Theme; loadFromStorage: () => Promise; saveToStorage: () => Promise; @@ -32,9 +41,12 @@ export const useConfigStore = create((set, get) => ({ customColors: {}, fontSize: 14, openOnClick: false, - enterToSaveAndExit: false, - confirmOnBackdropClick: true, + enterToSaveAndExit: true, + confirmOnBackdropClick: false, syntaxLanguage: "plaintext", + indentType: "spaces", + indentSize: 2, + formatterEnabled: false, setThemeId: (themeId: string) => { set({ themeId, customColors: {} }); @@ -76,6 +88,21 @@ export const useConfigStore = create((set, get) => ({ get().saveToStorage(); }, + setIndentType: (type: IndentType) => { + set({ indentType: type }); + get().saveToStorage(); + }, + + setIndentSize: (size: IndentSize) => { + set({ indentSize: size }); + get().saveToStorage(); + }, + + setFormatterEnabled: (enabled: boolean) => { + set({ formatterEnabled: enabled }); + get().saveToStorage(); + }, + getActiveTheme: () => { const { themeId, customColors } = get(); const baseTheme = getThemeById(themeId) || defaultDarkTheme; @@ -105,16 +132,22 @@ export const useConfigStore = create((set, get) => ({ "enterToSaveAndExit", "confirmOnBackdropClick", "syntaxLanguage", + "indentType", + "indentSize", + "formatterEnabled", ]); set({ themeId: (result.themeId as string) || "default-dark", customColors: (result.customColors as Partial) || {}, fontSize: (result.fontSize as number) || 14, openOnClick: (result.openOnClick as boolean) ?? false, - enterToSaveAndExit: (result.enterToSaveAndExit as boolean) ?? false, + enterToSaveAndExit: (result.enterToSaveAndExit as boolean) ?? true, confirmOnBackdropClick: - (result.confirmOnBackdropClick as boolean) ?? true, + (result.confirmOnBackdropClick as boolean) ?? false, syntaxLanguage: (result.syntaxLanguage as string) || "plaintext", + indentType: (result.indentType as IndentType) || "spaces", + indentSize: (result.indentSize as IndentSize) || 2, + formatterEnabled: (result.formatterEnabled as boolean) ?? false, }); } catch (error) { console.error("Failed to load config from storage:", error); @@ -131,6 +164,9 @@ export const useConfigStore = create((set, get) => ({ enterToSaveAndExit, confirmOnBackdropClick, syntaxLanguage, + indentType, + indentSize, + formatterEnabled, } = get(); await browser.storage.sync.set({ themeId, @@ -140,6 +176,9 @@ export const useConfigStore = create((set, get) => ({ enterToSaveAndExit, confirmOnBackdropClick, syntaxLanguage, + indentType, + indentSize, + formatterEnabled, }); } catch (error) { console.error("Failed to save config to storage:", error); diff --git a/wxt.config.ts b/wxt.config.ts index 54102d7..25bedfb 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ name: "Vimput", description: "Edit any text input with a Vim-powered editor. Right-click on any input field to open the Vimput editor.", - version: "1.2.0", + version: "1.3.1", permissions: ["contextMenus", "storage", "activeTab"], icons: { 16: "icons/png/icon-16.png", @@ -15,6 +15,12 @@ export default defineConfig({ 48: "icons/png/icon-48.png", 128: "icons/png/icon-128.png", }, + web_accessible_resources: [ + { + resources: ["pageScript.js", "vimput-formatter.py"], + matches: [""], + }, + ], browser_specific_settings: { gecko: { id: "vimput@extension",