mirror of
https://github.com/ollama/ollama.git
synced 2026-03-27 02:58:43 +07:00
cmd: ollama launch vscode (#15060)
Co-authored-by: Parth Sareen <parth.sareen@ollama.com>
This commit is contained in:
@@ -2065,6 +2065,10 @@ func runLauncherAction(cmd *cobra.Command, action tui.TUIAction, deps launcherDe
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return true, fmt.Errorf("launching %s: %w", action.Integration, err)
|
return true, fmt.Errorf("launching %s: %w", action.Integration, err)
|
||||||
}
|
}
|
||||||
|
// VS Code is a GUI app — exit the TUI loop after launching
|
||||||
|
if action.Integration == "vscode" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
default:
|
default:
|
||||||
return false, fmt.Errorf("unknown launcher action: %d", action.Kind)
|
return false, fmt.Errorf("unknown launcher action: %d", action.Kind)
|
||||||
|
|||||||
@@ -209,6 +209,43 @@ func TestRunLauncherAction_RunModelContinuesAfterCancellation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunLauncherAction_VSCodeExitsTUILoop(t *testing.T) {
|
||||||
|
setCmdTestHome(t, t.TempDir())
|
||||||
|
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.SetContext(context.Background())
|
||||||
|
|
||||||
|
// VS Code should exit the TUI loop (return false) after a successful launch.
|
||||||
|
continueLoop, err := runLauncherAction(cmd, tui.TUIAction{Kind: tui.TUIActionLaunchIntegration, Integration: "vscode"}, launcherDeps{
|
||||||
|
resolveRunModel: unexpectedRunModelResolution(t),
|
||||||
|
launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
runModel: unexpectedModelLaunch(t),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected nil error, got %v", err)
|
||||||
|
}
|
||||||
|
if continueLoop {
|
||||||
|
t.Fatal("expected vscode launch to exit the TUI loop (return false)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other integrations should continue the TUI loop (return true).
|
||||||
|
continueLoop, err = runLauncherAction(cmd, tui.TUIAction{Kind: tui.TUIActionLaunchIntegration, Integration: "claude"}, launcherDeps{
|
||||||
|
resolveRunModel: unexpectedRunModelResolution(t),
|
||||||
|
launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
runModel: unexpectedModelLaunch(t),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected nil error, got %v", err)
|
||||||
|
}
|
||||||
|
if !continueLoop {
|
||||||
|
t.Fatal("expected non-vscode integration to continue the TUI loop (return true)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunLauncherAction_IntegrationContinuesAfterCancellation(t *testing.T) {
|
func TestRunLauncherAction_IntegrationContinuesAfterCancellation(t *testing.T) {
|
||||||
setCmdTestHome(t, t.TempDir())
|
setCmdTestHome(t, t.TempDir())
|
||||||
|
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ Supported integrations:
|
|||||||
opencode OpenCode
|
opencode OpenCode
|
||||||
openclaw OpenClaw (aliases: clawdbot, moltbot)
|
openclaw OpenClaw (aliases: clawdbot, moltbot)
|
||||||
pi Pi
|
pi Pi
|
||||||
|
vscode VS Code (aliases: code)
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
ollama launch
|
ollama launch
|
||||||
@@ -801,13 +802,6 @@ func cloneAliases(aliases map[string]string) map[string]string {
|
|||||||
return cloned
|
return cloned
|
||||||
}
|
}
|
||||||
|
|
||||||
func singleModelPrechecked(current string) []string {
|
|
||||||
if current == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []string{current}
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstModel(models []string) string {
|
func firstModel(models []string) string {
|
||||||
if len(models) == 0 {
|
if len(models) == 0 {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ type IntegrationInfo struct {
|
|||||||
Description string
|
Description string
|
||||||
}
|
}
|
||||||
|
|
||||||
var launcherIntegrationOrder = []string{"opencode", "droid", "pi", "cline"}
|
var launcherIntegrationOrder = []string{"vscode", "opencode", "droid", "pi", "cline"}
|
||||||
|
|
||||||
var integrationSpecs = []*IntegrationSpec{
|
var integrationSpecs = []*IntegrationSpec{
|
||||||
{
|
{
|
||||||
@@ -131,6 +131,18 @@ var integrationSpecs = []*IntegrationSpec{
|
|||||||
Command: []string{"npm", "install", "-g", "@mariozechner/pi-coding-agent"},
|
Command: []string{"npm", "install", "-g", "@mariozechner/pi-coding-agent"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "vscode",
|
||||||
|
Runner: &VSCode{},
|
||||||
|
Aliases: []string{"code"},
|
||||||
|
Description: "Microsoft's open-source AI code editor",
|
||||||
|
Install: IntegrationInstallSpec{
|
||||||
|
CheckInstalled: func() bool {
|
||||||
|
return (&VSCode{}).findBinary() != ""
|
||||||
|
},
|
||||||
|
URL: "https://code.visualstudio.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var integrationSpecsByName map[string]*IntegrationSpec
|
var integrationSpecsByName map[string]*IntegrationSpec
|
||||||
|
|||||||
594
cmd/launch/vscode.go
Normal file
594
cmd/launch/vscode.go
Normal file
@@ -0,0 +1,594 @@
|
|||||||
|
package launch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
"github.com/ollama/ollama/api"
|
||||||
|
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||||
|
"github.com/ollama/ollama/envconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VSCode implements Runner and Editor for Visual Studio Code integration.
|
||||||
|
type VSCode struct{}
|
||||||
|
|
||||||
|
func (v *VSCode) String() string { return "Visual Studio Code" }
|
||||||
|
|
||||||
|
// findBinary returns the path/command to launch VS Code, or "" if not found.
|
||||||
|
// It checks for the "code" CLI on PATH first, then falls back to platform-specific locations.
|
||||||
|
func (v *VSCode) findBinary() string {
|
||||||
|
if _, err := exec.LookPath("code"); err == nil {
|
||||||
|
return "code"
|
||||||
|
}
|
||||||
|
var candidates []string
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
candidates = []string{
|
||||||
|
"/Applications/Visual Studio Code.app",
|
||||||
|
}
|
||||||
|
case "windows":
|
||||||
|
if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" {
|
||||||
|
candidates = append(candidates, filepath.Join(localAppData, "Programs", "Microsoft VS Code", "bin", "code.cmd"))
|
||||||
|
}
|
||||||
|
default: // linux
|
||||||
|
candidates = []string{
|
||||||
|
"/usr/bin/code",
|
||||||
|
"/snap/bin/code",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range candidates {
|
||||||
|
if _, err := os.Stat(c); err == nil {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRunning reports whether VS Code is currently running.
|
||||||
|
// Each platform uses a pattern specific enough to avoid matching Cursor or
|
||||||
|
// other VS Code forks.
|
||||||
|
func (v *VSCode) IsRunning() bool {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
out, err := exec.Command("pgrep", "-f", "Visual Studio Code.app/Contents/MacOS/Code").Output()
|
||||||
|
return err == nil && len(out) > 0
|
||||||
|
case "windows":
|
||||||
|
// Match VS Code by executable path to avoid matching Cursor or other forks.
|
||||||
|
out, err := exec.Command("powershell", "-NoProfile", "-Command",
|
||||||
|
`Get-Process Code -ErrorAction SilentlyContinue | Where-Object { $_.Path -like '*Microsoft VS Code*' } | Select-Object -First 1`).Output()
|
||||||
|
return err == nil && len(strings.TrimSpace(string(out))) > 0
|
||||||
|
default:
|
||||||
|
// Match VS Code specifically by its install path to avoid matching
|
||||||
|
// Cursor (/cursor/) or other forks.
|
||||||
|
for _, pattern := range []string{"/usr/share/code/", "/snap/code/"} {
|
||||||
|
out, err := exec.Command("pgrep", "-f", pattern).Output()
|
||||||
|
if err == nil && len(out) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quit gracefully quits VS Code and waits for it to exit so that it flushes
|
||||||
|
// its in-memory state back to the database.
|
||||||
|
func (v *VSCode) Quit() {
|
||||||
|
if !v.IsRunning() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
_ = exec.Command("osascript", "-e", `quit app "Visual Studio Code"`).Run()
|
||||||
|
case "windows":
|
||||||
|
// Kill VS Code by executable path to avoid killing Cursor or other forks.
|
||||||
|
_ = exec.Command("powershell", "-NoProfile", "-Command",
|
||||||
|
`Get-Process Code -ErrorAction SilentlyContinue | Where-Object { $_.Path -like '*Microsoft VS Code*' } | Stop-Process -Force`).Run()
|
||||||
|
default:
|
||||||
|
for _, pattern := range []string{"/usr/share/code/", "/snap/code/"} {
|
||||||
|
_ = exec.Command("pkill", "-f", pattern).Run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Wait for the process to fully exit and flush its state to disk
|
||||||
|
// TODO(hoyyeva): update spinner to use bubble tea
|
||||||
|
spinnerFrames := []string{"|", "/", "-", "\\"}
|
||||||
|
frame := 0
|
||||||
|
fmt.Fprintf(os.Stderr, "\033[90mRestarting VS Code... %s\033[0m", spinnerFrames[0])
|
||||||
|
|
||||||
|
ticker := time.NewTicker(200 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range 150 { // 150 ticks × 200ms = 30s timeout
|
||||||
|
<-ticker.C
|
||||||
|
frame++
|
||||||
|
fmt.Fprintf(os.Stderr, "\r\033[90mRestarting VS Code... %s\033[0m", spinnerFrames[frame%len(spinnerFrames)])
|
||||||
|
|
||||||
|
if frame%5 == 0 { // check every ~1s
|
||||||
|
if !v.IsRunning() {
|
||||||
|
fmt.Fprintf(os.Stderr, "\r\033[K")
|
||||||
|
// Give VS Code a moment to finish writing its state DB
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "\r\033[K")
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
minCopilotChatVersion = "0.41.0"
|
||||||
|
minVSCodeVersion = "1.113"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (v *VSCode) Run(model string, args []string) error {
|
||||||
|
v.checkVSCodeVersion()
|
||||||
|
v.checkCopilotChatVersion()
|
||||||
|
|
||||||
|
// Get all configured models (saved by the launcher framework before Run is called)
|
||||||
|
models := []string{model}
|
||||||
|
if cfg, err := loadStoredIntegrationConfig("vscode"); err == nil && len(cfg.Models) > 0 {
|
||||||
|
models = cfg.Models
|
||||||
|
}
|
||||||
|
|
||||||
|
// VS Code discovers models from ollama ls. Cloud models that pass Show
|
||||||
|
// (the server knows about them) but aren't in ls need to be pulled to
|
||||||
|
// register them so VS Code can find them.
|
||||||
|
if client, err := api.ClientFromEnvironment(); err == nil {
|
||||||
|
v.ensureModelsRegistered(context.Background(), client, models)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn if the default model doesn't support tool calling
|
||||||
|
if client, err := api.ClientFromEnvironment(); err == nil {
|
||||||
|
if resp, err := client.Show(context.Background(), &api.ShowRequest{Model: models[0]}); err == nil {
|
||||||
|
hasTools := false
|
||||||
|
for _, c := range resp.Capabilities {
|
||||||
|
if c == "tools" {
|
||||||
|
hasTools = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasTools {
|
||||||
|
fmt.Fprintf(os.Stderr, "Note: %s does not support tool calling and may not appear in the Copilot Chat model picker.\n", models[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
v.printModelAccessTip()
|
||||||
|
|
||||||
|
if v.IsRunning() {
|
||||||
|
restart, err := ConfirmPrompt("Restart VS Code?")
|
||||||
|
if err != nil {
|
||||||
|
restart = false
|
||||||
|
}
|
||||||
|
if restart {
|
||||||
|
v.Quit()
|
||||||
|
if err := v.ShowInModelPicker(models); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s Warning: could not update VS Code model picker: %v%s\n", ansiYellow, err, ansiReset)
|
||||||
|
}
|
||||||
|
v.FocusVSCode()
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "\nTo get the latest model configuration, restart VS Code when you're ready.\n")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := v.ShowInModelPicker(models); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s Warning: could not update VS Code model picker: %v%s\n", ansiYellow, err, ansiReset)
|
||||||
|
}
|
||||||
|
v.FocusVSCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureModelsRegistered pulls models that the server knows about (Show succeeds)
|
||||||
|
// but aren't in ollama ls yet. This is needed for cloud models so that VS Code
|
||||||
|
// can discover them from the Ollama API.
|
||||||
|
func (v *VSCode) ensureModelsRegistered(ctx context.Context, client *api.Client, models []string) {
|
||||||
|
listed, err := client.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
registered := make(map[string]bool, len(listed.Models))
|
||||||
|
for _, m := range listed.Models {
|
||||||
|
registered[m.Name] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, model := range models {
|
||||||
|
if registered[model] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Also check without :latest suffix
|
||||||
|
if !strings.Contains(model, ":") && registered[model+":latest"] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := pullModel(ctx, client, model, false); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s Warning: could not register model %s: %v%s\n", ansiYellow, model, err, ansiReset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FocusVSCode brings VS Code to the foreground.
|
||||||
|
func (v *VSCode) FocusVSCode() {
|
||||||
|
binary := v.findBinary()
|
||||||
|
if binary == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "darwin" && strings.HasSuffix(binary, ".app") {
|
||||||
|
_ = exec.Command("open", "-a", binary).Run()
|
||||||
|
} else {
|
||||||
|
_ = exec.Command(binary).Start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// printModelAccessTip shows instructions for finding Ollama models in VS Code.
|
||||||
|
func (v *VSCode) printModelAccessTip() {
|
||||||
|
fmt.Fprintf(os.Stderr, "\nTip: To use Ollama models, open Copilot Chat and click the model picker.\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " If you don't see your models, click \"Other models\" to find them.\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VSCode) Paths() []string {
|
||||||
|
if p := v.chatLanguageModelsPath(); fileExists(p) {
|
||||||
|
return []string{p}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VSCode) Edit(models []string) error {
|
||||||
|
if len(models) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write chatLanguageModels.json with Ollama vendor entry
|
||||||
|
clmPath := v.chatLanguageModelsPath()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(clmPath), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries []map[string]any
|
||||||
|
if data, err := os.ReadFile(clmPath); err == nil {
|
||||||
|
_ = json.Unmarshal(data, &entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any existing Ollama entries, preserve others
|
||||||
|
filtered := make([]map[string]any, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if vendor, _ := entry["vendor"].(string); vendor != "ollama" {
|
||||||
|
filtered = append(filtered, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new Ollama entry
|
||||||
|
filtered = append(filtered, map[string]any{
|
||||||
|
"vendor": "ollama",
|
||||||
|
"name": "Ollama",
|
||||||
|
"url": envconfig.Host().String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(filtered, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := fileutil.WriteWithBackup(clmPath, data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up legacy settings from older Ollama integrations
|
||||||
|
v.updateSettings()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VSCode) Models() []string {
|
||||||
|
if !v.hasOllamaVendor() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if cfg, err := loadStoredIntegrationConfig("vscode"); err == nil {
|
||||||
|
return cfg.Models
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasOllamaVendor checks if chatLanguageModels.json contains an Ollama vendor entry.
|
||||||
|
func (v *VSCode) hasOllamaVendor() bool {
|
||||||
|
data, err := os.ReadFile(v.chatLanguageModelsPath())
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries []map[string]any
|
||||||
|
if err := json.Unmarshal(data, &entries); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if vendor, _ := entry["vendor"].(string); vendor == "ollama" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VSCode) chatLanguageModelsPath() string {
|
||||||
|
return v.vscodePath("chatLanguageModels.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VSCode) settingsPath() string {
|
||||||
|
return v.vscodePath("settings.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateSettings cleans up legacy settings from older Ollama integrations.
|
||||||
|
func (v *VSCode) updateSettings() {
|
||||||
|
settingsPath := v.settingsPath()
|
||||||
|
data, err := os.ReadFile(settingsPath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings map[string]any
|
||||||
|
if err := json.Unmarshal(data, &settings); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := false
|
||||||
|
for _, key := range []string{"github.copilot.chat.byok.ollamaEndpoint", "ollama.launch.configured"} {
|
||||||
|
if _, ok := settings[key]; ok {
|
||||||
|
delete(settings, key)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !changed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := json.MarshalIndent(settings, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = fileutil.WriteWithBackup(settingsPath, updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VSCode) statePath() string {
|
||||||
|
return v.vscodePath("globalStorage", "state.vscdb")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowInModelPicker ensures the given models are visible in VS Code's Copilot
|
||||||
|
// Chat model picker. It sets the configured models to true in the picker
|
||||||
|
// preferences so they appear in the dropdown. Models use the VS Code identifier
|
||||||
|
// format "ollama/Ollama/<name>".
|
||||||
|
func (v *VSCode) ShowInModelPicker(models []string) error {
|
||||||
|
if len(models) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dbPath := v.statePath()
|
||||||
|
needsCreate := !fileExists(dbPath)
|
||||||
|
if needsCreate {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("creating state directory: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite3", dbPath+"?_busy_timeout=5000")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("opening state database: %w", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// Create the table if this is a fresh DB. Schema must match what VS Code creates.
|
||||||
|
if needsCreate {
|
||||||
|
if _, err := db.Exec("CREATE TABLE ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)"); err != nil {
|
||||||
|
return fmt.Errorf("initializing state database: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read existing preferences
|
||||||
|
prefs := make(map[string]bool)
|
||||||
|
var prefsJSON string
|
||||||
|
if err := db.QueryRow("SELECT value FROM ItemTable WHERE key = 'chatModelPickerPreferences'").Scan(&prefsJSON); err == nil {
|
||||||
|
_ = json.Unmarshal([]byte(prefsJSON), &prefs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build name→ID map from VS Code's cached model list.
|
||||||
|
// VS Code uses numeric IDs like "ollama/Ollama/4", not "ollama/Ollama/kimi-k2.5:cloud".
|
||||||
|
nameToID := make(map[string]string)
|
||||||
|
var cacheJSON string
|
||||||
|
if err := db.QueryRow("SELECT value FROM ItemTable WHERE key = 'chat.cachedLanguageModels.v2'").Scan(&cacheJSON); err == nil {
|
||||||
|
var cached []map[string]any
|
||||||
|
if json.Unmarshal([]byte(cacheJSON), &cached) == nil {
|
||||||
|
for _, entry := range cached {
|
||||||
|
meta, _ := entry["metadata"].(map[string]any)
|
||||||
|
if meta == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if vendor, _ := meta["vendor"].(string); vendor == "ollama" {
|
||||||
|
name, _ := meta["name"].(string)
|
||||||
|
id, _ := entry["identifier"].(string)
|
||||||
|
if name != "" && id != "" {
|
||||||
|
nameToID[name] = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ollama config is authoritative: always show configured models,
|
||||||
|
// hide Ollama models that are no longer in the config.
|
||||||
|
configuredIDs := make(map[string]bool)
|
||||||
|
for _, m := range models {
|
||||||
|
for _, id := range v.modelVSCodeIDs(m, nameToID) {
|
||||||
|
prefs[id] = true
|
||||||
|
configuredIDs[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id := range prefs {
|
||||||
|
if strings.HasPrefix(id, "ollama/") && !configuredIDs[id] {
|
||||||
|
prefs[id] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(prefs)
|
||||||
|
if _, err = db.Exec("INSERT OR REPLACE INTO ItemTable (key, value) VALUES ('chatModelPickerPreferences', ?)", string(data)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelVSCodeIDs returns all possible VS Code picker IDs for a model name.
|
||||||
|
func (v *VSCode) modelVSCodeIDs(model string, nameToID map[string]string) []string {
|
||||||
|
var ids []string
|
||||||
|
if id, ok := nameToID[model]; ok {
|
||||||
|
ids = append(ids, id)
|
||||||
|
} else if !strings.Contains(model, ":") {
|
||||||
|
if id, ok := nameToID[model+":latest"]; ok {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ids = append(ids, "ollama/Ollama/"+model)
|
||||||
|
if !strings.Contains(model, ":") {
|
||||||
|
ids = append(ids, "ollama/Ollama/"+model+":latest")
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VSCode) vscodePath(parts ...string) string {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
var base string
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
base = filepath.Join(home, "Library", "Application Support", "Code", "User")
|
||||||
|
case "windows":
|
||||||
|
base = filepath.Join(os.Getenv("APPDATA"), "Code", "User")
|
||||||
|
default:
|
||||||
|
base = filepath.Join(home, ".config", "Code", "User")
|
||||||
|
}
|
||||||
|
return filepath.Join(append([]string{base}, parts...)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkVSCodeVersion warns if VS Code is older than minVSCodeVersion.
|
||||||
|
func (v *VSCode) checkVSCodeVersion() {
|
||||||
|
codeCLI := v.findCodeCLI()
|
||||||
|
if codeCLI == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := exec.Command(codeCLI, "--version").Output()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// "code --version" outputs: version\ncommit\narch
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||||
|
if len(lines) == 0 || lines[0] == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
version := strings.TrimSpace(lines[0])
|
||||||
|
|
||||||
|
if compareVersions(version, minVSCodeVersion) < 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "\n%sWarning: VS Code version (%s) is older than the recommended version (%s)%s\n", ansiYellow, version, minVSCodeVersion, ansiReset)
|
||||||
|
fmt.Fprintf(os.Stderr, "Please update VS Code to the latest version.\n\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkCopilotChatVersion warns if the GitHub Copilot Chat extension is
|
||||||
|
// missing or older than minCopilotChatVersion.
|
||||||
|
func (v *VSCode) checkCopilotChatVersion() {
|
||||||
|
codeCLI := v.findCodeCLI()
|
||||||
|
if codeCLI == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := exec.Command(codeCLI, "--list-extensions", "--show-versions").Output()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
installed, version := parseCopilotChatVersion(string(out))
|
||||||
|
if !installed {
|
||||||
|
fmt.Fprintf(os.Stderr, "\n%sWarning: GitHub Copilot Chat extension is not installed%s\n", ansiYellow, ansiReset)
|
||||||
|
fmt.Fprintf(os.Stderr, "Install it in VS Code: Extensions → search \"GitHub Copilot Chat\" → Install\n\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if compareVersions(version, minCopilotChatVersion) < 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "\n%sWarning: GitHub Copilot Chat extension version (%s) is older than the recommended version (%s)%s\n", ansiYellow, version, minCopilotChatVersion, ansiReset)
|
||||||
|
fmt.Fprintf(os.Stderr, "Please update it in VS Code: Extensions → search \"GitHub Copilot Chat\" → Update\n\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findCodeCLI returns the path to the VS Code CLI for querying extensions.
|
||||||
|
// On macOS, findBinary may return an .app bundle which can't run --list-extensions,
|
||||||
|
// so this resolves to the actual CLI binary inside the bundle.
|
||||||
|
func (v *VSCode) findCodeCLI() string {
|
||||||
|
binary := v.findBinary()
|
||||||
|
if binary == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "darwin" && strings.HasSuffix(binary, ".app") {
|
||||||
|
bundleCLI := binary + "/Contents/Resources/app/bin/code"
|
||||||
|
if _, err := os.Stat(bundleCLI); err == nil {
|
||||||
|
return bundleCLI
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return binary
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCopilotChatVersion extracts the version of the GitHub Copilot Chat
|
||||||
|
// extension from "code --list-extensions --show-versions" output.
|
||||||
|
func parseCopilotChatVersion(output string) (installed bool, version string) {
|
||||||
|
for _, line := range strings.Split(output, "\n") {
|
||||||
|
// Format: github.copilot-chat@0.40.1
|
||||||
|
if !strings.HasPrefix(strings.ToLower(line), "github.copilot-chat@") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(line, "@", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return true, strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// compareVersions compares two dot-separated version strings.
|
||||||
|
// Returns -1 if a < b, 0 if a == b, 1 if a > b.
|
||||||
|
func compareVersions(a, b string) int {
|
||||||
|
aParts := strings.Split(a, ".")
|
||||||
|
bParts := strings.Split(b, ".")
|
||||||
|
|
||||||
|
maxLen := len(aParts)
|
||||||
|
if len(bParts) > maxLen {
|
||||||
|
maxLen = len(bParts)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range maxLen {
|
||||||
|
var aNum, bNum int
|
||||||
|
if i < len(aParts) {
|
||||||
|
aNum, _ = strconv.Atoi(aParts[i])
|
||||||
|
}
|
||||||
|
if i < len(bParts) {
|
||||||
|
bNum, _ = strconv.Atoi(bParts[i])
|
||||||
|
}
|
||||||
|
if aNum < bNum {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if aNum > bNum {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(path string) bool {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
486
cmd/launch/vscode_test.go
Normal file
486
cmd/launch/vscode_test.go
Normal file
@@ -0,0 +1,486 @@
|
|||||||
|
package launch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVSCodeIntegration(t *testing.T) {
|
||||||
|
v := &VSCode{}
|
||||||
|
|
||||||
|
t.Run("String", func(t *testing.T) {
|
||||||
|
if got := v.String(); got != "Visual Studio Code" {
|
||||||
|
t.Errorf("String() = %q, want %q", got, "Visual Studio Code")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("implements Runner", func(t *testing.T) {
|
||||||
|
var _ Runner = v
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("implements Editor", func(t *testing.T) {
|
||||||
|
var _ Editor = v
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVSCodeEdit(t *testing.T) {
|
||||||
|
v := &VSCode{}
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", "")
|
||||||
|
clmPath := testVSCodePath(t, tmpDir, "chatLanguageModels.json")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setup string // initial chatLanguageModels.json content, empty means no file
|
||||||
|
models []string
|
||||||
|
validate func(t *testing.T, data []byte)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "fresh install",
|
||||||
|
models: []string{"llama3.2"},
|
||||||
|
validate: func(t *testing.T, data []byte) {
|
||||||
|
assertOllamaVendorConfigured(t, data)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "preserve other vendor entries",
|
||||||
|
setup: `[{"vendor": "azure", "name": "Azure", "url": "https://example.com"}]`,
|
||||||
|
models: []string{"llama3.2"},
|
||||||
|
validate: func(t *testing.T, data []byte) {
|
||||||
|
var entries []map[string]any
|
||||||
|
json.Unmarshal(data, &entries)
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Errorf("expected 2 entries, got %d", len(entries))
|
||||||
|
}
|
||||||
|
// Check Azure entry preserved
|
||||||
|
found := false
|
||||||
|
for _, e := range entries {
|
||||||
|
if v, _ := e["vendor"].(string); v == "azure" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("azure vendor entry was not preserved")
|
||||||
|
}
|
||||||
|
assertOllamaVendorConfigured(t, data)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update existing ollama entry",
|
||||||
|
setup: `[{"vendor": "ollama", "name": "Ollama", "url": "http://old:11434"}]`,
|
||||||
|
models: []string{"llama3.2"},
|
||||||
|
validate: func(t *testing.T, data []byte) {
|
||||||
|
assertOllamaVendorConfigured(t, data)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty models is no-op",
|
||||||
|
setup: `[{"vendor": "azure", "name": "Azure"}]`,
|
||||||
|
models: []string{},
|
||||||
|
validate: func(t *testing.T, data []byte) {
|
||||||
|
if string(data) != `[{"vendor": "azure", "name": "Azure"}]` {
|
||||||
|
t.Error("empty models should not modify file")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "corrupted JSON treated as empty",
|
||||||
|
setup: `{corrupted json`,
|
||||||
|
models: []string{"llama3.2"},
|
||||||
|
validate: func(t *testing.T, data []byte) {
|
||||||
|
var entries []map[string]any
|
||||||
|
if err := json.Unmarshal(data, &entries); err != nil {
|
||||||
|
t.Errorf("result is not valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
os.RemoveAll(filepath.Dir(clmPath))
|
||||||
|
|
||||||
|
if tt.setup != "" {
|
||||||
|
os.MkdirAll(filepath.Dir(clmPath), 0o755)
|
||||||
|
os.WriteFile(clmPath, []byte(tt.setup), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := v.Edit(tt.models); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := os.ReadFile(clmPath)
|
||||||
|
tt.validate(t, data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVSCodeEditCleansUpOldSettings(t *testing.T) {
|
||||||
|
v := &VSCode{}
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", "")
|
||||||
|
settingsPath := testVSCodePath(t, tmpDir, "settings.json")
|
||||||
|
|
||||||
|
// Create settings.json with old byok setting
|
||||||
|
os.MkdirAll(filepath.Dir(settingsPath), 0o755)
|
||||||
|
os.WriteFile(settingsPath, []byte(`{"github.copilot.chat.byok.ollamaEndpoint": "http://old:11434", "ollama.launch.configured": true, "editor.fontSize": 14}`), 0o644)
|
||||||
|
|
||||||
|
if err := v.Edit([]string{"llama3.2"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify old settings were removed
|
||||||
|
data, err := os.ReadFile(settingsPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings map[string]any
|
||||||
|
json.Unmarshal(data, &settings)
|
||||||
|
if _, ok := settings["github.copilot.chat.byok.ollamaEndpoint"]; ok {
|
||||||
|
t.Error("github.copilot.chat.byok.ollamaEndpoint should have been removed")
|
||||||
|
}
|
||||||
|
if _, ok := settings["ollama.launch.configured"]; ok {
|
||||||
|
t.Error("ollama.launch.configured should have been removed")
|
||||||
|
}
|
||||||
|
if settings["editor.fontSize"] != float64(14) {
|
||||||
|
t.Error("editor.fontSize should have been preserved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVSCodePaths(t *testing.T) {
|
||||||
|
v := &VSCode{}
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", "")
|
||||||
|
clmPath := testVSCodePath(t, tmpDir, "chatLanguageModels.json")
|
||||||
|
|
||||||
|
t.Run("no file returns nil", func(t *testing.T) {
|
||||||
|
os.Remove(clmPath)
|
||||||
|
if paths := v.Paths(); paths != nil {
|
||||||
|
t.Errorf("expected nil, got %v", paths)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("existing file returns path", func(t *testing.T) {
|
||||||
|
os.MkdirAll(filepath.Dir(clmPath), 0o755)
|
||||||
|
os.WriteFile(clmPath, []byte(`[]`), 0o644)
|
||||||
|
|
||||||
|
if paths := v.Paths(); len(paths) != 1 {
|
||||||
|
t.Errorf("expected 1 path, got %d", len(paths))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// testVSCodePath returns the expected VS Code config path for the given file in tests.
|
||||||
|
func testVSCodePath(t *testing.T, tmpDir, filename string) string {
|
||||||
|
t.Helper()
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
return filepath.Join(tmpDir, "Library", "Application Support", "Code", "User", filename)
|
||||||
|
case "windows":
|
||||||
|
t.Setenv("APPDATA", tmpDir)
|
||||||
|
return filepath.Join(tmpDir, "Code", "User", filename)
|
||||||
|
default:
|
||||||
|
return filepath.Join(tmpDir, ".config", "Code", "User", filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertOllamaVendorConfigured(t *testing.T, data []byte) {
|
||||||
|
t.Helper()
|
||||||
|
var entries []map[string]any
|
||||||
|
if err := json.Unmarshal(data, &entries); err != nil {
|
||||||
|
t.Fatalf("invalid JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if vendor, _ := entry["vendor"].(string); vendor == "ollama" {
|
||||||
|
if name, _ := entry["name"].(string); name != "Ollama" {
|
||||||
|
t.Errorf("expected name \"Ollama\", got %q", name)
|
||||||
|
}
|
||||||
|
if url, _ := entry["url"].(string); url == "" {
|
||||||
|
t.Error("url not set")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Error("no ollama vendor entry found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShowInModelPicker(t *testing.T) {
|
||||||
|
v := &VSCode{}
|
||||||
|
|
||||||
|
// helper to create a state DB with optional seed data
|
||||||
|
setupDB := func(t *testing.T, tmpDir string, seedPrefs map[string]bool, seedCache []map[string]any) string {
|
||||||
|
t.Helper()
|
||||||
|
dbDir := filepath.Join(tmpDir, "globalStorage")
|
||||||
|
os.MkdirAll(dbDir, 0o755)
|
||||||
|
dbPath := filepath.Join(dbDir, "state.vscdb")
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite3", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
if _, err := db.Exec("CREATE TABLE ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if seedPrefs != nil {
|
||||||
|
data, _ := json.Marshal(seedPrefs)
|
||||||
|
db.Exec("INSERT INTO ItemTable (key, value) VALUES ('chatModelPickerPreferences', ?)", string(data))
|
||||||
|
}
|
||||||
|
if seedCache != nil {
|
||||||
|
data, _ := json.Marshal(seedCache)
|
||||||
|
db.Exec("INSERT INTO ItemTable (key, value) VALUES ('chat.cachedLanguageModels.v2', ?)", string(data))
|
||||||
|
}
|
||||||
|
return dbPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// helper to read prefs back from DB
|
||||||
|
readPrefs := func(t *testing.T, dbPath string) map[string]bool {
|
||||||
|
t.Helper()
|
||||||
|
db, err := sql.Open("sqlite3", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
var raw string
|
||||||
|
if err := db.QueryRow("SELECT value FROM ItemTable WHERE key = 'chatModelPickerPreferences'").Scan(&raw); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
prefs := make(map[string]bool)
|
||||||
|
json.Unmarshal([]byte(raw), &prefs)
|
||||||
|
return prefs
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("fresh DB creates table and shows models", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", "")
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Setenv("APPDATA", tmpDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := v.ShowInModelPicker([]string{"llama3.2"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbPath := testVSCodePath(t, tmpDir, filepath.Join("globalStorage", "state.vscdb"))
|
||||||
|
prefs := readPrefs(t, dbPath)
|
||||||
|
if !prefs["ollama/Ollama/llama3.2"] {
|
||||||
|
t.Error("expected llama3.2 to be shown")
|
||||||
|
}
|
||||||
|
if !prefs["ollama/Ollama/llama3.2:latest"] {
|
||||||
|
t.Error("expected llama3.2:latest to be shown")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("configured models are shown", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", "")
|
||||||
|
dbPath := setupDB(t, testVSCodePath(t, tmpDir, ""), nil, nil)
|
||||||
|
|
||||||
|
err := v.ShowInModelPicker([]string{"llama3.2", "qwen3:8b"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prefs := readPrefs(t, dbPath)
|
||||||
|
if !prefs["ollama/Ollama/llama3.2"] {
|
||||||
|
t.Error("expected llama3.2 to be shown")
|
||||||
|
}
|
||||||
|
if !prefs["ollama/Ollama/qwen3:8b"] {
|
||||||
|
t.Error("expected qwen3:8b to be shown")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("removed models are hidden", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", "")
|
||||||
|
dbPath := setupDB(t, testVSCodePath(t, tmpDir, ""), map[string]bool{
|
||||||
|
"ollama/Ollama/llama3.2": true,
|
||||||
|
"ollama/Ollama/llama3.2:latest": true,
|
||||||
|
"ollama/Ollama/mistral": true,
|
||||||
|
"ollama/Ollama/mistral:latest": true,
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
// Only configure llama3.2 — mistral should get hidden
|
||||||
|
err := v.ShowInModelPicker([]string{"llama3.2"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prefs := readPrefs(t, dbPath)
|
||||||
|
if !prefs["ollama/Ollama/llama3.2"] {
|
||||||
|
t.Error("expected llama3.2 to stay shown")
|
||||||
|
}
|
||||||
|
if prefs["ollama/Ollama/mistral"] {
|
||||||
|
t.Error("expected mistral to be hidden")
|
||||||
|
}
|
||||||
|
if prefs["ollama/Ollama/mistral:latest"] {
|
||||||
|
t.Error("expected mistral:latest to be hidden")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-ollama prefs are preserved", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", "")
|
||||||
|
dbPath := setupDB(t, testVSCodePath(t, tmpDir, ""), map[string]bool{
|
||||||
|
"copilot/gpt-4o": true,
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
err := v.ShowInModelPicker([]string{"llama3.2"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prefs := readPrefs(t, dbPath)
|
||||||
|
if !prefs["copilot/gpt-4o"] {
|
||||||
|
t.Error("expected copilot/gpt-4o to stay shown")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("uses cached numeric IDs when available", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", "")
|
||||||
|
cache := []map[string]any{
|
||||||
|
{
|
||||||
|
"identifier": "ollama/Ollama/4",
|
||||||
|
"metadata": map[string]any{"vendor": "ollama", "name": "llama3.2"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dbPath := setupDB(t, testVSCodePath(t, tmpDir, ""), nil, cache)
|
||||||
|
|
||||||
|
err := v.ShowInModelPicker([]string{"llama3.2"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prefs := readPrefs(t, dbPath)
|
||||||
|
if !prefs["ollama/Ollama/4"] {
|
||||||
|
t.Error("expected numeric ID ollama/Ollama/4 to be shown")
|
||||||
|
}
|
||||||
|
// Name-based fallback should also be set
|
||||||
|
if !prefs["ollama/Ollama/llama3.2"] {
|
||||||
|
t.Error("expected name-based ID to also be shown")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty models is no-op", func(t *testing.T) {
|
||||||
|
err := v.ShowInModelPicker([]string{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("previously hidden model is re-shown when configured", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", "")
|
||||||
|
dbPath := setupDB(t, testVSCodePath(t, tmpDir, ""), map[string]bool{
|
||||||
|
"ollama/Ollama/llama3.2": false,
|
||||||
|
"ollama/Ollama/llama3.2:latest": false,
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
// Ollama config is authoritative — should override the hidden state
|
||||||
|
err := v.ShowInModelPicker([]string{"llama3.2"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prefs := readPrefs(t, dbPath)
|
||||||
|
if !prefs["ollama/Ollama/llama3.2"] {
|
||||||
|
t.Error("expected llama3.2 to be re-shown")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCopilotChatVersion(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
output string
|
||||||
|
wantInstalled bool
|
||||||
|
wantVersion string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "found among other extensions",
|
||||||
|
output: "ms-python.python@2024.1.1\ngithub.copilot-chat@0.40.1\ngithub.copilot@1.200.0\n",
|
||||||
|
wantInstalled: true,
|
||||||
|
wantVersion: "0.40.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only extension",
|
||||||
|
output: "GitHub.copilot-chat@0.41.0\n",
|
||||||
|
wantInstalled: true,
|
||||||
|
wantVersion: "0.41.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not installed",
|
||||||
|
output: "ms-python.python@2024.1.1\ngithub.copilot@1.200.0\n",
|
||||||
|
wantInstalled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty output",
|
||||||
|
output: "",
|
||||||
|
wantInstalled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "case insensitive match",
|
||||||
|
output: "GitHub.Copilot-Chat@0.39.0\n",
|
||||||
|
wantInstalled: true,
|
||||||
|
wantVersion: "0.39.0",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
installed, version := parseCopilotChatVersion(tt.output)
|
||||||
|
if installed != tt.wantInstalled {
|
||||||
|
t.Errorf("installed = %v, want %v", installed, tt.wantInstalled)
|
||||||
|
}
|
||||||
|
if installed && version != tt.wantVersion {
|
||||||
|
t.Errorf("version = %q, want %q", version, tt.wantVersion)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareVersions(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
a, b string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"0.40.1", "0.40.1", 0},
|
||||||
|
{"0.40.2", "0.40.1", 1},
|
||||||
|
{"0.40.0", "0.40.1", -1},
|
||||||
|
{"0.41.0", "0.40.1", 1},
|
||||||
|
{"0.39.9", "0.40.1", -1},
|
||||||
|
{"1.0.0", "0.40.1", 1},
|
||||||
|
{"0.40", "0.40.1", -1},
|
||||||
|
{"0.40.1.1", "0.40.1", 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.a+"_vs_"+tt.b, func(t *testing.T) {
|
||||||
|
got := compareVersions(tt.a, tt.b)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("compareVersions(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,9 @@ var mainMenuItems = []menuItem{
|
|||||||
{
|
{
|
||||||
integration: "openclaw",
|
integration: "openclaw",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
integration: "vscode",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var othersMenuItem = menuItem{
|
var othersMenuItem = menuItem{
|
||||||
@@ -139,6 +142,7 @@ func otherIntegrationItems(state *launch.LauncherState) []menuItem {
|
|||||||
"claude": true,
|
"claude": true,
|
||||||
"codex": true,
|
"codex": true,
|
||||||
"openclaw": true,
|
"openclaw": true,
|
||||||
|
"vscode": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
var items []menuItem
|
var items []menuItem
|
||||||
|
|||||||
Reference in New Issue
Block a user