From 596f7c610161c43ccd98dd5f5dc59f2ae19c23e6 Mon Sep 17 00:00:00 2001 From: mvacoimbra Date: Mon, 26 Jan 2026 17:34:37 -0300 Subject: [PATCH 01/19] chore: bump version to 1.3.0 Co-Authored-By: Claude Opus 4.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ad2e5b3..fef9468 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.0", "type": "module", "scripts": { "dev": "wxt", From c2673228fe8ef128eda49b32dd36bda8b974eda1 Mon Sep 17 00:00:00 2001 From: mvacoimbra Date: Mon, 26 Jan 2026 17:34:49 -0300 Subject: [PATCH 02/19] feat: change default settings for better UX - enterToSaveAndExit now defaults to true - confirmOnBackdropClick now defaults to false - openOnClick no longer forces insert mode (opens in normal mode) Co-Authored-By: Claude Opus 4.5 --- entrypoints/content.tsx | 10 +++++----- stores/configStore.ts | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/entrypoints/content.tsx b/entrypoints/content.tsx index ea122b0..f76852c 100644 --- a/entrypoints/content.tsx +++ b/entrypoints/content.tsx @@ -271,9 +271,9 @@ 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", }; } catch { @@ -281,8 +281,8 @@ async function getConfig(): Promise { theme: defaultDarkTheme, fontSize: 14, openOnClick: false, - enterToSaveAndExit: false, - confirmOnBackdropClick: true, + enterToSaveAndExit: true, + confirmOnBackdropClick: false, syntaxLanguage: "plaintext", }; } @@ -306,7 +306,7 @@ async function openEditor(startInInsertMode = false) { const config = await getConfig(); const initialText = getElementText(activeElement); - const shouldStartInInsertMode = startInInsertMode || config.openOnClick; + const shouldStartInInsertMode = startInInsertMode; // Create shadow DOM host for style isolation shadowHost = document.createElement("div"); diff --git a/stores/configStore.ts b/stores/configStore.ts index 87b34a5..1274843 100644 --- a/stores/configStore.ts +++ b/stores/configStore.ts @@ -32,8 +32,8 @@ export const useConfigStore = create((set, get) => ({ customColors: {}, fontSize: 14, openOnClick: false, - enterToSaveAndExit: false, - confirmOnBackdropClick: true, + enterToSaveAndExit: true, + confirmOnBackdropClick: false, syntaxLanguage: "plaintext", setThemeId: (themeId: string) => { @@ -111,9 +111,9 @@ export const useConfigStore = create((set, get) => ({ 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", }); } catch (error) { From f72cfec62941991f139a7c7f4a9ac67ca7512f57 Mon Sep 17 00:00:00 2001 From: mvacoimbra Date: Mon, 26 Jan 2026 17:41:48 -0300 Subject: [PATCH 03/19] chore: add beads issue tracking for v1.3.0 Added bd (beads) for dependency-aware issue tracking. Issues created for v1.3.0: - vimput-2jf: Fix Enter in insert mode - vimput-21l: Fix navigation in empty lines - vimput-sp2: TypeScript Playground compatibility - vimput-ztw: Udemy editor compatibility - vimput-d8x: Cursor blink only on idle Co-Authored-By: Claude Opus 4.5 --- .beads/.gitignore | 44 +++++++++++++++++++++ .beads/README.md | 81 +++++++++++++++++++++++++++++++++++++++ .beads/config.yaml | 62 ++++++++++++++++++++++++++++++ .beads/interactions.jsonl | 0 .beads/issues.jsonl | 5 +++ .beads/metadata.json | 4 ++ .gitattributes | 3 ++ AGENTS.md | 40 +++++++++++++++++++ 8 files changed, 239 insertions(+) create mode 100644 .beads/.gitignore create mode 100644 .beads/README.md create mode 100644 .beads/config.yaml create mode 100644 .beads/interactions.jsonl create mode 100644 .beads/issues.jsonl create mode 100644 .beads/metadata.json create mode 100644 .gitattributes create mode 100644 AGENTS.md 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..f56beaf --- /dev/null +++ b/.beads/issues.jsonl @@ -0,0 +1,5 @@ +{"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":"open","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-26T17:40:51.630718-03:00"} +{"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":"open","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-26T17:40:51.376161-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":"open","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-26T17:40:52.309519-03:00"} +{"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":"open","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-26T17:40:51.859149-03:00"} +{"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":"open","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-26T17:40:52.081289-03:00"} diff --git a/.beads/metadata.json b/.beads/metadata.json new file mode 100644 index 0000000..c787975 --- /dev/null +++ b/.beads/metadata.json @@ -0,0 +1,4 @@ +{ + "database": "beads.db", + "jsonl_export": "issues.jsonl" +} \ No newline at end of file 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/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..df7a4af --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,40 @@ +# 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 + From b677657d9ac161fc14c71a7fdad265bdf2619cee Mon Sep 17 00:00:00 2001 From: mvacoimbra Date: Mon, 26 Jan 2026 17:44:14 -0300 Subject: [PATCH 04/19] fix: normalize line endings in vim engine Handle Windows (\r\n) and old Mac (\r) line endings by normalizing to Unix (\n) format. This fixes potential issues with Enter and navigation when text comes from editors with different line endings. Fixes: vimput-2jf, vimput-21l (partial) Co-Authored-By: Claude Opus 4.5 --- .beads/issues.jsonl | 2 +- lib/vimEngine.ts | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index f56beaf..4a97071 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,5 +1,5 @@ {"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":"open","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-26T17:40:51.630718-03:00"} -{"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":"open","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-26T17:40:51.376161-03:00"} +{"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":"in_progress","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-26T17:42:07.173459-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":"open","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-26T17:40:52.309519-03:00"} {"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":"open","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-26T17:40:51.859149-03:00"} {"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":"open","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-26T17:40:52.081289-03:00"} diff --git a/lib/vimEngine.ts b/lib/vimEngine.ts index bea539b..bbb2c58 100644 --- a/lib/vimEngine.ts +++ b/lib/vimEngine.ts @@ -29,9 +29,11 @@ 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: "", @@ -42,7 +44,8 @@ export function createInitialState(text: string = ""): VimState { } 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 { From f02b307096d464ef89864be2f254039a72496237 Mon Sep 17 00:00:00 2001 From: mvacoimbra Date: Mon, 26 Jan 2026 18:03:45 -0300 Subject: [PATCH 05/19] fix: cursor rendering on empty lines - Add explicit height to cursor span for consistent rendering - Add minHeight to line divs to ensure empty lines have height - Cursor now properly visible when navigating to/through empty lines Fixes: vimput-2jf, vimput-21l Co-Authored-By: Claude Opus 4.5 --- components/VimputEditor.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/components/VimputEditor.tsx b/components/VimputEditor.tsx index 6ab699e..a6b6918 100644 --- a/components/VimputEditor.tsx +++ b/components/VimputEditor.tsx @@ -511,7 +511,7 @@ export const VimputEditor = forwardRef(
{renderLineWithCursor( line, @@ -546,7 +546,7 @@ export const VimputEditor = forwardRef(
{renderHighlightedLine( styledTokens, @@ -824,6 +824,8 @@ function renderEndOfLineCursor( style={{ backgroundColor: colors.visualSelection, width: "0.5rem", + height: "1.5em", + verticalAlign: "text-bottom", }} > {"\u00A0"} @@ -837,12 +839,16 @@ function renderEndOfLineCursor( ? { color: colors.editorText, opacity: cursorVisible ? 1 : 0, + height: "1.5em", + verticalAlign: "text-bottom", } : { backgroundColor: cursorVisible ? colors.cursorBackground : "transparent", width: "0.5rem", + height: "1.5em", + verticalAlign: "text-bottom", } } > From 28ea75dcc5fb8ea50de6c88e0a4a2911f76b4cc6 Mon Sep 17 00:00:00 2001 From: mvacoimbra Date: Mon, 26 Jan 2026 18:08:49 -0300 Subject: [PATCH 06/19] feat: improve cursor behavior and rendering - Cursor only blinks when idle (after ~500ms of inactivity) - Cursor stays visible during typing and navigation - Insert mode cursor now uses a 2px wide element instead of "|" character - Better positioning control with adjustable offset Closes: vimput-d8x Co-Authored-By: Claude Opus 4.5 --- .beads/issues.jsonl | 6 ++-- components/VimputEditor.tsx | 56 +++++++++++++++++++++++++------------ 2 files changed, 41 insertions(+), 21 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 4a97071..c1b0ba1 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,5 +1,5 @@ -{"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":"open","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-26T17:40:51.630718-03:00"} -{"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":"in_progress","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-26T17:42:07.173459-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":"open","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-26T17:40:52.309519-03:00"} +{"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-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":"in_progress","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:05:43.695602-03:00"} {"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":"open","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-26T17:40:51.859149-03:00"} {"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":"open","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-26T17:40:52.081289-03:00"} diff --git a/components/VimputEditor.tsx b/components/VimputEditor.tsx index a6b6918..c0d3298 100644 --- a/components/VimputEditor.tsx +++ b/components/VimputEditor.tsx @@ -161,19 +161,35 @@ export const VimputEditor = forwardRef( 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); + } + }; + }, [vimState.cursor, vimState.text, vimState.mode]); const colors = theme.colors; @@ -762,14 +778,16 @@ function renderChar( - | - + /> {char} ); @@ -837,10 +855,12 @@ function renderEndOfLineCursor( style={ vimState.mode === "insert" ? { - color: colors.editorText, + width: "2px", + height: "1.2em", + backgroundColor: colors.editorText, opacity: cursorVisible ? 1 : 0, - height: "1.5em", verticalAlign: "text-bottom", + marginTop: "0.1em", } : { backgroundColor: cursorVisible @@ -852,7 +872,7 @@ function renderEndOfLineCursor( } } > - {vimState.mode === "insert" ? "|" : "\u00A0"} + {vimState.mode === "insert" ? "" : "\u00A0"} )} From d1daf0f8079add8260864ddff3881725bdbecb6e Mon Sep 17 00:00:00 2001 From: mvacoimbra Date: Tue, 27 Jan 2026 09:06:59 -0300 Subject: [PATCH 07/19] feat: add Ace Editor support (Udemy compatibility) - Detect Ace Editor (.ace_editor) alongside Monaco - Use Ace API (ace.edit) to get/set text when available - Fallback to reading from .ace_text-layer (ignoring line numbers) - Fixes text extraction including line numbers from gutter Closes: vimput-ztw Co-Authored-By: Claude Opus 4.5 --- .beads/issues.jsonl | 4 +- entrypoints/content.tsx | 142 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 140 insertions(+), 6 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index c1b0ba1..e91f1dd 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,5 +1,5 @@ {"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-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":"in_progress","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:05:43.695602-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-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":"open","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-26T17:40:51.859149-03:00"} -{"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":"open","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-26T17:40:52.081289-03:00"} +{"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":"in_progress","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-27T08:15:27.306866-03:00"} diff --git a/entrypoints/content.tsx b/entrypoints/content.tsx index f76852c..ce8fb83 100644 --- a/entrypoints/content.tsx +++ b/entrypoints/content.tsx @@ -48,6 +48,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" ) { @@ -95,6 +97,68 @@ function getElementText(element: EditableElement): string { return element.value; } + // Check if this is an Ace editor + const aceEditor = element.closest(".ace_editor"); + if (aceEditor) { + // Try to use Ace API if available + const win = window as Window & { + ace?: { + edit: ( + el: Element, + ) => { getValue: () => string; setValue: (v: string) => void } | null; + }; + }; + if (win.ace?.edit) { + try { + const editor = win.ace.edit(aceEditor); + if (editor?.getValue) { + return editor.getValue(); + } + } catch { + // Ace may throw if element is not initialized + } + } + + // Fallback: get text from ace_line elements only (not gutter) + 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"); + } + } + + // Check if this is a Monaco editor + const monacoEditor = element.closest(".monaco-editor"); + if (monacoEditor) { + // Try to access Monaco API if available + const win = window as Window & { + monaco?: { + editor: { + getModels: () => Array<{ getValue: () => string }>; + }; + }; + }; + if (win.monaco?.editor?.getModels) { + const models = win.monaco.editor.getModels(); + if (models.length > 0) { + return models[0].getValue(); + } + } + + // Fallback: try to get text from the view lines only (not line numbers) + 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 || ""; } @@ -107,11 +171,81 @@ 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 an Ace editor + const aceEditor = element.closest(".ace_editor"); + if (aceEditor) { + // Try to use Ace API if available + const win = window as Window & { + ace?: { + edit: ( + el: Element, + ) => { getValue: () => string; setValue: (v: string) => void } | null; + }; + }; + if (win.ace?.edit) { + try { + const editor = win.ace.edit(aceEditor); + if (editor?.setValue) { + editor.setValue(text); + return; + } + } catch { + // Ace may throw if element is not initialized + } + } + + // Fallback: try to use the textarea + const aceTextarea = aceEditor.querySelector( + "textarea.ace_text-input", + ) as HTMLTextAreaElement | null; + if (aceTextarea) { + aceTextarea.focus(); + document.execCommand("selectAll", false); + document.execCommand("insertText", false, text); + return; + } + } + + // Check if this is a Monaco editor + const monacoEditor = element.closest(".monaco-editor"); + if (monacoEditor) { + // Try to use Monaco API if available + const win = window as Window & { + monaco?: { + editor: { + getModels: () => Array<{ + getValue: () => string; + setValue: (value: string) => void; + }>; + }; + }; + }; + if (win.monaco?.editor?.getModels) { + const models = win.monaco.editor.getModels(); + if (models.length > 0) { + models[0].setValue(text); + return; + } + } + + // Fallback: try to use the textarea and trigger input + const monacoTextarea = monacoEditor.querySelector( + "textarea.inputarea", + ) as HTMLTextAreaElement | null; + if (monacoTextarea) { + monacoTextarea.focus(); + document.execCommand("selectAll", false); + document.execCommand("insertText", false, text); + return; + } + } + + // For contenteditable elements + element.innerText = text; + element.dispatchEvent(new Event("input", { bubbles: true })); } function getElementLabel(element: EditableElement): string | undefined { From c869478c79b3e7ac7af42af122e89bf05f0a8eb1 Mon Sep 17 00:00:00 2001 From: mvacoimbra Date: Tue, 27 Jan 2026 11:51:21 -0300 Subject: [PATCH 08/19] fix: TypeScript Playground compatibility via external page script - Create external pageScript.js to bypass CSP restrictions - Use Monaco's pushEditOperations for reliable text replacement - Add web_accessible_resources to manifest - Increase API timeout from 100ms to 500ms - Remove problematic execCommand fallbacks that caused text duplication - Bump version to 1.3.0 --- .beads/issues.jsonl | 5 +- .gitignore | 3 + AGENTS.md | 63 +++++++++++ entrypoints/content.tsx | 231 ++++++++++++++++++-------------------- entrypoints/popup/App.tsx | 2 +- public/pageScript.js | 130 +++++++++++++++++++++ wxt.config.ts | 6 + 7 files changed, 318 insertions(+), 122 deletions(-) create mode 100644 public/pageScript.js diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index e91f1dd..4ced94f 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,5 +1,6 @@ {"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-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":"open","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-26T17:40:51.859149-03:00"} -{"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":"in_progress","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-27T08:15:27.306866-03:00"} +{"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/.gitignore b/.gitignore index a256953..b23de59 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ web-ext.config.ts *.njsproj *.sln *.sw? + +# bv (beads viewer) local config and caches +.bv/ diff --git a/AGENTS.md b/AGENTS.md index df7a4af..00f4ec6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,3 +38,66 @@ bd sync # Sync with git - 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/entrypoints/content.tsx b/entrypoints/content.tsx index ce8fb83..163f98e 100644 --- a/entrypoints/content.tsx +++ b/entrypoints/content.tsx @@ -13,6 +13,69 @@ 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); + }); +} + +// 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: { 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; @@ -89,7 +152,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 @@ -97,65 +160,39 @@ function getElementText(element: EditableElement): string { return element.value; } - // Check if this is an Ace editor + // 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 (aceEditor) { - // Try to use Ace API if available - const win = window as Window & { - ace?: { - edit: ( - el: Element, - ) => { getValue: () => string; setValue: (v: string) => void } | null; - }; - }; - if (win.ace?.edit) { - try { - const editor = win.ace.edit(aceEditor); - if (editor?.getValue) { - return editor.getValue(); - } - } catch { - // Ace may throw if element is not initialized - } - } - // Fallback: get text from ace_line elements only (not gutter) - 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"); + 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; } - } - // Check if this is a Monaco editor - const monacoEditor = element.closest(".monaco-editor"); - if (monacoEditor) { - // Try to access Monaco API if available - const win = window as Window & { - monaco?: { - editor: { - getModels: () => Array<{ getValue: () => string }>; - }; - }; - }; - if (win.monaco?.editor?.getModels) { - const models = win.monaco.editor.getModels(); - if (models.length > 0) { - return models[0].getValue(); + // 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: try to get text from the view lines only (not line numbers) - 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"); + // 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"); + } } } @@ -163,7 +200,10 @@ function getElementText(element: EditableElement): string { 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 @@ -174,73 +214,23 @@ function setElementText(element: EditableElement, text: string): void { return; } - // Check if this is an Ace editor + // 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 (aceEditor) { - // Try to use Ace API if available - const win = window as Window & { - ace?: { - edit: ( - el: Element, - ) => { getValue: () => string; setValue: (v: string) => void } | null; - }; - }; - if (win.ace?.edit) { - try { - const editor = win.ace.edit(aceEditor); - if (editor?.setValue) { - editor.setValue(text); - return; - } - } catch { - // Ace may throw if element is not initialized - } - } - // Fallback: try to use the textarea - const aceTextarea = aceEditor.querySelector( - "textarea.ace_text-input", - ) as HTMLTextAreaElement | null; - if (aceTextarea) { - aceTextarea.focus(); - document.execCommand("selectAll", false); - document.execCommand("insertText", false, text); + if (monacoEditor || aceEditor) { + // Try to set text via injected page script (can access Monaco/Ace APIs) + const success = await setEditorTextViaPageScript(text); + if (success) { return; } - } - - // Check if this is a Monaco editor - const monacoEditor = element.closest(".monaco-editor"); - if (monacoEditor) { - // Try to use Monaco API if available - const win = window as Window & { - monaco?: { - editor: { - getModels: () => Array<{ - getValue: () => string; - setValue: (value: string) => void; - }>; - }; - }; - }; - if (win.monaco?.editor?.getModels) { - const models = win.monaco.editor.getModels(); - if (models.length > 0) { - models[0].setValue(text); - return; - } - } - // Fallback: try to use the textarea and trigger input - const monacoTextarea = monacoEditor.querySelector( - "textarea.inputarea", - ) as HTMLTextAreaElement | null; - if (monacoTextarea) { - monacoTextarea.focus(); - document.execCommand("selectAll", false); - document.execCommand("insertText", false, text); - 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 @@ -310,6 +300,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", @@ -439,7 +432,7 @@ async function openEditor(startInInsertMode = false) { } const config = await getConfig(); - const initialText = getElementText(activeElement); + const initialText = await getElementText(activeElement); const shouldStartInInsertMode = startInInsertMode; // Create shadow DOM host for style isolation @@ -536,9 +529,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..513ef19 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.0
diff --git a/public/pageScript.js b/public/pageScript.js new file mode 100644 index 0000000..a9916c3 --- /dev/null +++ b/public/pageScript.js @@ -0,0 +1,130 @@ +// Vimput Page Script - Injected to access Monaco/Ace APIs +(function () { + // Listen for requests from content script to get editor text + window.addEventListener("vimput-get-editor-text", function () { + 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", function (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 }, + }), + ); + }); + + console.log("[Vimput] Page script loaded successfully"); +})(); diff --git a/wxt.config.ts b/wxt.config.ts index 54102d7..d10fba8 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -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"], + matches: [""], + }, + ], browser_specific_settings: { gecko: { id: "vimput@extension", From d29185d7deae353b765aa1c36111e88fca9db55b Mon Sep 17 00:00:00 2001 From: mvacoimbra Date: Wed, 28 Jan 2026 00:53:16 -0300 Subject: [PATCH 09/19] feat: add indentation settings (tabs/spaces) Add user-configurable indentation settings to the extension: - New indentType setting (tabs or spaces) - New indentSize setting (2, 4, or 8 spaces) - Settings UI in Editor tab with conditional size selector - Fixed vimEngine to handle multi-character insertions Co-Authored-By: Claude Opus 4.5 --- .beads/issues.jsonl | 1 + CLAUDE.md | 19 +++++++++ components/SettingsPanel.tsx | 82 ++++++++++++++++++++++++++++++++++++ components/VimputEditor.tsx | 8 +++- entrypoints/content.tsx | 10 +++++ lib/vimEngine.ts | 5 ++- stores/configStore.ts | 27 ++++++++++++ 7 files changed, 149 insertions(+), 3 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 4ced94f..833ff15 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -2,5 +2,6 @@ {"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-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/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..8dd66a3 100644 --- a/components/SettingsPanel.tsx +++ b/components/SettingsPanel.tsx @@ -1,6 +1,7 @@ import { CornerDownLeft, HelpCircle, + IndentIncrease, MessageCircleWarning, MousePointerClick, RotateCcw, @@ -136,6 +137,8 @@ export function SettingsPanel() { openOnClick, enterToSaveAndExit, confirmOnBackdropClick, + indentType, + indentSize, setThemeId, setCustomColors, resetCustomColors, @@ -143,6 +146,8 @@ export function SettingsPanel() { setOpenOnClick, setEnterToSaveAndExit, setConfirmOnBackdropClick, + setIndentType, + setIndentSize, loadFromStorage, } = useConfigStore(); @@ -346,6 +351,83 @@ export function SettingsPanel() { 24px + +
+
+ + +
+
+ + {indentType === "spaces" && ( + + )} +
+
{/* Misc Tab */} diff --git a/components/VimputEditor.tsx b/components/VimputEditor.tsx index c0d3298..9e1ff57 100644 --- a/components/VimputEditor.tsx +++ b/components/VimputEditor.tsx @@ -100,6 +100,8 @@ interface VimputEditorProps { inputLabel?: string; initialLanguage?: string; onLanguageChange?: (language: string) => void; + indentType?: "tabs" | "spaces"; + indentSize?: 2 | 4 | 8; } interface Position { @@ -126,6 +128,8 @@ export const VimputEditor = forwardRef( inputLabel, initialLanguage = "plaintext", onLanguageChange, + indentType = "spaces", + indentSize = 2, }, ref, ) { @@ -341,7 +345,7 @@ export const VimputEditor = forwardRef( // Handle special keys if (key === "Tab") { - key = "\t"; + key = indentType === "tabs" ? "\t" : " ".repeat(indentSize); } const prevCommand = vimState.commandBuffer; @@ -376,6 +380,8 @@ export const VimputEditor = forwardRef( isResizing, handleSaveAndClose, enterToSaveAndExit, + indentType, + indentSize, ], ); diff --git a/entrypoints/content.tsx b/entrypoints/content.tsx index 163f98e..bc3a7b2 100644 --- a/entrypoints/content.tsx +++ b/entrypoints/content.tsx @@ -366,6 +366,8 @@ interface EditorConfig { enterToSaveAndExit: boolean; confirmOnBackdropClick: boolean; syntaxLanguage: string; + indentType: "tabs" | "spaces"; + indentSize: 2 | 4 | 8; } async function getConfig(): Promise { @@ -378,6 +380,8 @@ async function getConfig(): Promise { "enterToSaveAndExit", "confirmOnBackdropClick", "syntaxLanguage", + "indentType", + "indentSize", ]); const themeId = (result.themeId as string) || "default-dark"; @@ -402,6 +406,8 @@ async function getConfig(): Promise { confirmOnBackdropClick: (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, }; } catch { return { @@ -411,6 +417,8 @@ async function getConfig(): Promise { enterToSaveAndExit: true, confirmOnBackdropClick: false, syntaxLanguage: "plaintext", + indentType: "spaces", + indentSize: 2, }; } } @@ -522,6 +530,8 @@ async function openEditor(startInInsertMode = false) { confirmOnBackdropClick={config.confirmOnBackdropClick} inputLabel={inputLabel} initialLanguage={config.syntaxLanguage} + indentType={config.indentType} + indentSize={config.indentSize} onLanguageChange={async (language) => { try { await browser.storage.sync.set({ syntaxLanguage: language }); diff --git a/lib/vimEngine.ts b/lib/vimEngine.ts index bbb2c58..91f6fe7 100644 --- a/lib/vimEngine.ts +++ b/lib/vimEngine.ts @@ -216,7 +216,8 @@ 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 + @@ -225,7 +226,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 }, }; } diff --git a/stores/configStore.ts b/stores/configStore.ts index 1274843..285887c 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,8 @@ export interface ConfigState { enterToSaveAndExit: boolean; confirmOnBackdropClick: boolean; syntaxLanguage: string; + indentType: IndentType; + indentSize: IndentSize; setThemeId: (themeId: string) => void; setCustomColors: (colors: Partial) => void; resetCustomColors: () => void; @@ -22,6 +27,8 @@ export interface ConfigState { setEnterToSaveAndExit: (enabled: boolean) => void; setConfirmOnBackdropClick: (enabled: boolean) => void; setSyntaxLanguage: (language: string) => void; + setIndentType: (type: IndentType) => void; + setIndentSize: (size: IndentSize) => void; getActiveTheme: () => Theme; loadFromStorage: () => Promise; saveToStorage: () => Promise; @@ -35,6 +42,8 @@ export const useConfigStore = create((set, get) => ({ enterToSaveAndExit: true, confirmOnBackdropClick: false, syntaxLanguage: "plaintext", + indentType: "spaces", + indentSize: 2, setThemeId: (themeId: string) => { set({ themeId, customColors: {} }); @@ -76,6 +85,16 @@ export const useConfigStore = create((set, get) => ({ get().saveToStorage(); }, + setIndentType: (type: IndentType) => { + set({ indentType: type }); + get().saveToStorage(); + }, + + setIndentSize: (size: IndentSize) => { + set({ indentSize: size }); + get().saveToStorage(); + }, + getActiveTheme: () => { const { themeId, customColors } = get(); const baseTheme = getThemeById(themeId) || defaultDarkTheme; @@ -105,6 +124,8 @@ export const useConfigStore = create((set, get) => ({ "enterToSaveAndExit", "confirmOnBackdropClick", "syntaxLanguage", + "indentType", + "indentSize", ]); set({ themeId: (result.themeId as string) || "default-dark", @@ -115,6 +136,8 @@ export const useConfigStore = create((set, get) => ({ confirmOnBackdropClick: (result.confirmOnBackdropClick as boolean) ?? false, syntaxLanguage: (result.syntaxLanguage as string) || "plaintext", + indentType: (result.indentType as IndentType) || "spaces", + indentSize: (result.indentSize as IndentSize) || 2, }); } catch (error) { console.error("Failed to load config from storage:", error); @@ -131,6 +154,8 @@ export const useConfigStore = create((set, get) => ({ enterToSaveAndExit, confirmOnBackdropClick, syntaxLanguage, + indentType, + indentSize, } = get(); await browser.storage.sync.set({ themeId, @@ -140,6 +165,8 @@ export const useConfigStore = create((set, get) => ({ enterToSaveAndExit, confirmOnBackdropClick, syntaxLanguage, + indentType, + indentSize, }); } catch (error) { console.error("Failed to save config to storage:", error); From 15624fe0747da2979b026d31a86e4223c318e77a Mon Sep 17 00:00:00 2001 From: mvacoimbra Date: Wed, 28 Jan 2026 01:23:21 -0300 Subject: [PATCH 10/19] bd sync: 2026-01-28 01:23:21 --- .beads/issues.jsonl | 1 + 1 file changed, 1 insertion(+) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 833ff15..fa620d1 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -2,6 +2,7 @@ {"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":"open","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-28T01:22:23.639117-03:00"} {"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"} From 488cebc3d20eed129515de6815572d84af2630e8 Mon Sep 17 00:00:00 2001 From: mvacoimbra Date: Wed, 28 Jan 2026 02:01:10 -0300 Subject: [PATCH 11/19] feat: add local formatter worker for code formatting - Add Python-based formatter worker (public/vimput-formatter.py) - Runs as background daemon on localhost:7483 - Auto-installs Flask dependency - Prompts to install formatters (prettier, black) on first run - Supports: JS/TS/JSX/TSX, Python, CSS, HTML, JSON, MD, YAML, GraphQL, Bash, SQL, Go, Rust, Java, C/C++ - Update formatter.ts to communicate with local worker - Falls back to built-in sql-formatter when worker unavailable - Language-specific error messages with install suggestions - Add formatter worker UI in SettingsPanel - Shows worker connection status - Download script button - Lists available formatters when connected - OS-specific run instructions (Linux/macOS/Windows) Co-Authored-By: Claude Opus 4.5 --- components/SettingsPanel.tsx | 195 ++++++++++++++- lib/formatter.ts | 222 +++++++++++++++++ public/vimput-formatter.py | 471 +++++++++++++++++++++++++++++++++++ wxt.config.ts | 2 +- 4 files changed, 888 insertions(+), 2 deletions(-) create mode 100644 lib/formatter.ts create mode 100644 public/vimput-formatter.py diff --git a/components/SettingsPanel.tsx b/components/SettingsPanel.tsx index 8dd66a3..8f568bc 100644 --- a/components/SettingsPanel.tsx +++ b/components/SettingsPanel.tsx @@ -1,19 +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"; @@ -139,6 +149,7 @@ export function SettingsPanel() { confirmOnBackdropClick, indentType, indentSize, + formatterEnabled, setThemeId, setCustomColors, resetCustomColors, @@ -148,13 +159,41 @@ export function SettingsPanel() { 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; @@ -428,6 +467,160 @@ export function SettingsPanel() { )} + +
+
+
+ +
+ +

+ 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/lib/formatter.ts b/lib/formatter.ts new file mode 100644 index 0000000..62f09fe --- /dev/null +++ b/lib/formatter.ts @@ -0,0 +1,222 @@ +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/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/wxt.config.ts b/wxt.config.ts index d10fba8..bdce08d 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -17,7 +17,7 @@ export default defineConfig({ }, web_accessible_resources: [ { - resources: ["pageScript.js"], + resources: ["pageScript.js", "vimput-formatter.py"], matches: [""], }, ], From 95dcb1143dbc137a887fb96ce8bea4b8aafdf108 Mon Sep 17 00:00:00 2001 From: mvacoimbra Date: Wed, 28 Jan 2026 02:03:35 -0300 Subject: [PATCH 12/19] feat: integrate formatter with editor UI - Add formatter integration in VimputEditor - Status messages for format success/failure - Sparkle icon indicator for formatter-enabled languages - Handle pendingAction from vim engine for :fmt command - Add :fmt and :format commands in vimEngine - Add cf shortcut for formatting in normal mode - Add formatterEnabled setting in configStore - Add sql-formatter dependency - Update beads issue tracking - Add CHANGELOG-1.3.0.md Co-Authored-By: Claude Opus 4.5 --- .beads/issues.jsonl | 2 +- CHANGELOG-1.3.0.md | 64 +++++++++++++++++++++++++++++++++ components/VimputEditor.tsx | 70 +++++++++++++++++++++++++++++++++++-- entrypoints/content.tsx | 5 +++ lib/vimEngine.ts | 24 +++++++++++++ package.json | 1 + pnpm-lock.yaml | 58 ++++++++++++++++++++++++++++++ stores/configStore.ts | 12 +++++++ 8 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 CHANGELOG-1.3.0.md diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index fa620d1..8b97f95 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -2,7 +2,7 @@ {"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":"open","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-28T01:22:23.639117-03:00"} +{"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/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/components/VimputEditor.tsx b/components/VimputEditor.tsx index 9e1ff57..5fecfb9 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, @@ -8,6 +8,7 @@ import { useRef, useState, } from "react"; +import { formatCode, isFormatterSupported } from "@/lib/formatter"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -102,6 +103,7 @@ interface VimputEditorProps { onLanguageChange?: (language: string) => void; indentType?: "tabs" | "spaces"; indentSize?: 2 | 4 | 8; + formatterEnabled?: boolean; } interface Position { @@ -130,6 +132,7 @@ export const VimputEditor = forwardRef( onLanguageChange, indentType = "spaces", indentSize = 2, + formatterEnabled = false, }, ref, ) { @@ -151,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) => { @@ -160,6 +165,52 @@ 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); @@ -600,7 +651,11 @@ export const VimputEditor = forwardRef( }} >
- {vimState.mode === "command" ? ( + {statusMessage ? ( + + {statusMessage} + + ) : vimState.mode === "command" ? ( {vimState.commandBuffer} @@ -640,7 +695,16 @@ export const VimputEditor = forwardRef( className="text-xs cursor-pointer" style={{ color: colors.headerText }} > - {lang.label} + + {lang.label} + {formatterEnabled && isFormatterSupported(lang.value) && ( + + )} + ))} diff --git a/entrypoints/content.tsx b/entrypoints/content.tsx index bc3a7b2..1bd2b3f 100644 --- a/entrypoints/content.tsx +++ b/entrypoints/content.tsx @@ -368,6 +368,7 @@ interface EditorConfig { syntaxLanguage: string; indentType: "tabs" | "spaces"; indentSize: 2 | 4 | 8; + formatterEnabled: boolean; } async function getConfig(): Promise { @@ -382,6 +383,7 @@ async function getConfig(): Promise { "syntaxLanguage", "indentType", "indentSize", + "formatterEnabled", ]); const themeId = (result.themeId as string) || "default-dark"; @@ -408,6 +410,7 @@ async function getConfig(): Promise { 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 { @@ -419,6 +422,7 @@ async function getConfig(): Promise { syntaxLanguage: "plaintext", indentType: "spaces", indentSize: 2, + formatterEnabled: false, }; } } @@ -532,6 +536,7 @@ async function openEditor(startInInsertMode = false) { initialLanguage={config.syntaxLanguage} indentType={config.indentType} indentSize={config.indentSize} + formatterEnabled={config.formatterEnabled} onLanguageChange={async (language) => { try { await browser.storage.sync.set({ syntaxLanguage: language }); diff --git a/lib/vimEngine.ts b/lib/vimEngine.ts index 91f6fe7..eb13ccb 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 { @@ -40,6 +43,7 @@ export function createInitialState(text: string = ""): VimState { isLineYank: false, visualStart: null, pendingOperator: null, + pendingAction: null, }; } @@ -498,6 +502,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: "" }; @@ -1205,6 +1225,10 @@ 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 fef9468..10d3327 100644 --- a/package.json +++ b/package.json @@ -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/stores/configStore.ts b/stores/configStore.ts index 285887c..2f4cf15 100644 --- a/stores/configStore.ts +++ b/stores/configStore.ts @@ -19,6 +19,7 @@ export interface ConfigState { syntaxLanguage: string; indentType: IndentType; indentSize: IndentSize; + formatterEnabled: boolean; setThemeId: (themeId: string) => void; setCustomColors: (colors: Partial) => void; resetCustomColors: () => void; @@ -29,6 +30,7 @@ export interface ConfigState { setSyntaxLanguage: (language: string) => void; setIndentType: (type: IndentType) => void; setIndentSize: (size: IndentSize) => void; + setFormatterEnabled: (enabled: boolean) => void; getActiveTheme: () => Theme; loadFromStorage: () => Promise; saveToStorage: () => Promise; @@ -44,6 +46,7 @@ export const useConfigStore = create((set, get) => ({ syntaxLanguage: "plaintext", indentType: "spaces", indentSize: 2, + formatterEnabled: false, setThemeId: (themeId: string) => { set({ themeId, customColors: {} }); @@ -95,6 +98,11 @@ export const useConfigStore = create((set, get) => ({ get().saveToStorage(); }, + setFormatterEnabled: (enabled: boolean) => { + set({ formatterEnabled: enabled }); + get().saveToStorage(); + }, + getActiveTheme: () => { const { themeId, customColors } = get(); const baseTheme = getThemeById(themeId) || defaultDarkTheme; @@ -126,6 +134,7 @@ export const useConfigStore = create((set, get) => ({ "syntaxLanguage", "indentType", "indentSize", + "formatterEnabled", ]); set({ themeId: (result.themeId as string) || "default-dark", @@ -138,6 +147,7 @@ export const useConfigStore = create((set, get) => ({ 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); @@ -156,6 +166,7 @@ export const useConfigStore = create((set, get) => ({ syntaxLanguage, indentType, indentSize, + formatterEnabled, } = get(); await browser.storage.sync.set({ themeId, @@ -167,6 +178,7 @@ export const useConfigStore = create((set, get) => ({ syntaxLanguage, indentType, indentSize, + formatterEnabled, }); } catch (error) { console.error("Failed to save config to storage:", error); From b9559818fbcaa43351bb93aa332957e798b5e391 Mon Sep 17 00:00:00 2001 From: mvacoimbra Date: Wed, 28 Jan 2026 02:10:41 -0300 Subject: [PATCH 13/19] chore: bump version to 1.3.0 and fix TypeScript errors - Update manifest version from 1.2.0 to 1.3.0 - Fix public path references (add leading /) - Fix Lucide icon title prop (wrap in span) - Apply Biome formatting --- components/SettingsPanel.tsx | 4 +--- components/VimputEditor.tsx | 40 ++++++++++++++++++++++-------------- entrypoints/content.tsx | 2 +- lib/formatter.ts | 7 ++++--- lib/vimEngine.ts | 16 +++++++++++++-- public/pageScript.js | 6 +++--- wxt.config.ts | 2 +- 7 files changed, 49 insertions(+), 28 deletions(-) diff --git a/components/SettingsPanel.tsx b/components/SettingsPanel.tsx index 8f568bc..680749c 100644 --- a/components/SettingsPanel.tsx +++ b/components/SettingsPanel.tsx @@ -563,9 +563,7 @@ export function SettingsPanel() { {/* Download and instructions */} {!workerStatus?.available && (
-

- Download the script and run it to enable formatting. -

+

Download the script and run it to enable formatting.

diff --git a/package.json b/package.json index 10d3327..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.3.0", + "version": "1.3.1", "type": "module", "scripts": { "dev": "wxt", diff --git a/public/pageScript.js b/public/pageScript.js index 32338e1..21ca808 100644 --- a/public/pageScript.js +++ b/public/pageScript.js @@ -125,6 +125,4 @@ }), ); }); - - console.log("[Vimput] Page script loaded successfully"); })(); diff --git a/wxt.config.ts b/wxt.config.ts index 64d378e..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.3.0", + version: "1.3.1", permissions: ["contextMenus", "storage", "activeTab"], icons: { 16: "icons/png/icon-16.png", From 7e4e8d44794a283894efe37d0a26a30329edc10d Mon Sep 17 00:00:00 2001 From: mvacoimbra Date: Wed, 28 Jan 2026 08:41:27 -0300 Subject: [PATCH 15/19] ci: add GitHub Actions workflow for automated releases - Automatically bumps version (patch by default) on push to main - Supports manual trigger with major/minor/patch selection - Builds and zips extensions for Chrome and Firefox - Creates GitHub Release with artifacts - Optional: uploads to Chrome Web Store and Firefox Add-ons (requires secrets to be configured) Co-Authored-By: Claude Opus 4.5 --- .github/workflows/release.yml | 153 ++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..80cea17 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,153 @@ +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]')" + 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: ${{ vars.CHROME_EXTENSION_ID != '' }} + uses: mnao305/chrome-extension-upload@v5.0.0 + with: + file-path: .output/vimput-${{ steps.version.outputs.version }}-chrome.zip + extension-id: ${{ vars.CHROME_EXTENSION_ID }} + client-id: ${{ secrets.CHROME_CLIENT_ID }} + client-secret: ${{ secrets.CHROME_CLIENT_SECRET }} + refresh-token: ${{ secrets.CHROME_REFRESH_TOKEN }} + publish: false # Set to true to auto-publish + + - 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 From 827be0a2c52b48e0c314220d4b82e09c5488bf9d Mon Sep 17 00:00:00 2001 From: mvacoimbra Date: Wed, 28 Jan 2026 09:14:17 -0300 Subject: [PATCH 16/19] chore: add .env.example for store deployment secrets Co-Authored-By: Claude Opus 4.5 --- .env.example | 27 +++++++++++++++++++++++++++ .gitignore | 5 +++++ 2 files changed, 32 insertions(+) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b2d1b28 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# GitHub Actions Secrets for Store Deployment +# Copy this to your repository secrets in GitHub Settings > Secrets and variables > Actions + +# ============================================ +# Chrome Web Store +# ============================================ +# Follow: https://developer.chrome.com/docs/webstore/using_webstore_api/ +# 1. Create OAuth credentials in Google Cloud Console +# 2. Get refresh token using the OAuth flow + +CHROME_CLIENT_ID=your-client-id.apps.googleusercontent.com +CHROME_CLIENT_SECRET=your-client-secret +CHROME_REFRESH_TOKEN=your-refresh-token + +# Set as repository variable (not secret): +# 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/.gitignore b/.gitignore index b23de59..3ee8ff4 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,8 @@ web-ext.config.ts # bv (beads viewer) local config and caches .bv/ + +# Environment files (keep .env.example) +.env +.env.local +.env.*.local From 360fbd8ae9540fd8427d20ec0aea4d24cdd247bf Mon Sep 17 00:00:00 2001 From: mvacoimbra Date: Wed, 28 Jan 2026 09:22:42 -0300 Subject: [PATCH 17/19] chore: update CI to use Google Cloud service account - Add service account JSON patterns to .gitignore - Update workflow to authenticate with service account - Update .env.example with service account instructions Co-Authored-By: Claude Opus 4.5 --- .env.example | 18 +++++++------- .github/workflows/release.yml | 44 ++++++++++++++++++++++++++++------- .gitignore | 4 ++++ 3 files changed, 48 insertions(+), 18 deletions(-) diff --git a/.env.example b/.env.example index b2d1b28..43cda20 100644 --- a/.env.example +++ b/.env.example @@ -1,18 +1,18 @@ # GitHub Actions Secrets for Store Deployment -# Copy this to your repository secrets in GitHub Settings > Secrets and variables > Actions +# Copy these to your repository secrets in GitHub Settings > Secrets and variables > Actions # ============================================ -# Chrome Web Store +# Chrome Web Store (Service Account) # ============================================ -# Follow: https://developer.chrome.com/docs/webstore/using_webstore_api/ -# 1. Create OAuth credentials in Google Cloud Console -# 2. Get refresh token using the OAuth flow +# 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_CLIENT_ID=your-client-id.apps.googleusercontent.com -CHROME_CLIENT_SECRET=your-client-secret -CHROME_REFRESH_TOKEN=your-refresh-token +CHROME_SERVICE_ACCOUNT_KEY={"type":"service_account","project_id":"...","private_key":"..."} -# Set as repository variable (not secret): +# Set as repository variable (Settings > Secrets and variables > Variables): # CHROME_EXTENSION_ID=your-extension-id-from-chrome-web-store # ============================================ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 80cea17..45e633d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -131,15 +131,41 @@ jobs: .output/*.zip - name: Upload to Chrome Web Store - if: ${{ vars.CHROME_EXTENSION_ID != '' }} - uses: mnao305/chrome-extension-upload@v5.0.0 - with: - file-path: .output/vimput-${{ steps.version.outputs.version }}-chrome.zip - extension-id: ${{ vars.CHROME_EXTENSION_ID }} - client-id: ${{ secrets.CHROME_CLIENT_ID }} - client-secret: ${{ secrets.CHROME_CLIENT_SECRET }} - refresh-token: ${{ secrets.CHROME_REFRESH_TOKEN }} - publish: false # Set to true to auto-publish + 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 != '' }} diff --git a/.gitignore b/.gitignore index 3ee8ff4..aa77925 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,7 @@ web-ext.config.ts .env .env.local .env.*.local + +# Google Cloud service account keys +vimput-*.json +*-service-account*.json From 28c5ad8d550ad26fb0db3ad27228c43e249f1f61 Mon Sep 17 00:00:00 2001 From: mvacoimbra Date: Wed, 28 Jan 2026 09:40:07 -0300 Subject: [PATCH 18/19] chore: sync beads --- .beads/metadata.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.beads/metadata.json b/.beads/metadata.json index c787975..01c4bb3 100644 --- a/.beads/metadata.json +++ b/.beads/metadata.json @@ -1,4 +1,4 @@ { - "database": "beads.db", - "jsonl_export": "issues.jsonl" -} \ No newline at end of file + "database": "beads.db", + "jsonl_export": "issues.jsonl" +} From 25a73dea93b88cbb85332bf90f2273acea317f5c Mon Sep 17 00:00:00 2001 From: mvacoimbra Date: Wed, 28 Jan 2026 09:43:37 -0300 Subject: [PATCH 19/19] ci: use prod environment for secrets Co-Authored-By: Claude Opus 4.5 --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 45e633d..3eb7c88 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,7 @@ jobs: 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