Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/extension-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import * as vscode from "vscode";

/**
* Returns the fsPath of the workspace folder that owns the active editor's
* file. Falls back to workspaceFolders[0] when no editor is active or the
* active file isn't inside any workspace folder. Returns undefined when no
* workspace folders are open at all.
*/
export function resolveActiveWorkspaceFolder(): string | undefined {
const folders = vscode.workspace.workspaceFolders;
if (!folders || folders.length === 0) return undefined;

const uri = vscode.window.activeTextEditor?.document.uri;
if (uri) {
const folder = vscode.workspace.getWorkspaceFolder(uri);
if (folder) return folder.uri.fsPath;
}

return folders[0].uri.fsPath;
}
106 changes: 64 additions & 42 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { fileSearchTool } from "./tools/file-search";
import { fetchUrlTool } from "./tools/fetch-url";
import { runTestsTool } from "./tools/run-tests";
import { browserTool, closeBrowser } from "./tools/browser";
import { createWebSearchTool } from "./tools/web-search";
import { AgentController, type AgentMode } from "./agent/agent-controller";
import { ChatViewProvider } from "./ui/chat-view-provider";
import { ChampInlineCompletionProvider } from "./completion/inline-provider";
Expand Down Expand Up @@ -99,6 +100,7 @@ import { DiffOverlayController } from "./ui/diff-overlay-controller";
import { CircuitBreaker } from "./providers/circuit-breaker";
import { FallbackProvider } from "./providers/fallback-provider";
import { RateLimitedProvider } from "./providers/rate-limited-provider";
import { resolveActiveWorkspaceFolder } from "./extension-utils";
import { AuditLog } from "./observability/audit-log";
import { ChampServer } from "./server/champ-server";

Expand Down Expand Up @@ -195,6 +197,7 @@ export async function activate(
toolRegistry.register(runTestsTool);
toolRegistry.register(generateDocTool);
toolRegistry.register(browserTool);
toolRegistry.register(createWebSearchTool(context.secrets));
toolRegistry.register(
createCodebaseSearchTool(() => indexingService ?? null),
);
Expand Down Expand Up @@ -1395,14 +1398,15 @@ export async function activate(
);
}),
vscode.commands.registerCommand("champ.generateConfig", async () => {
if (!workspaceRoot) {
const activeFolder = resolveActiveWorkspaceFolder() ?? workspaceRoot;
if (!activeFolder) {
void vscode.window.showErrorMessage(
"Champ: open a workspace folder before generating a config file.",
);
return;
}
const targetUri = vscode.Uri.file(
path.join(workspaceRoot, ".champ", "config.yaml"),
path.join(activeFolder, ".champ", "config.yaml"),
);
// If file exists, just open it (don't prompt to overwrite).
try {
Expand All @@ -1416,7 +1420,7 @@ export async function activate(
const template = generateDefaultConfigYaml();
try {
await vscode.workspace.fs.createDirectory(
vscode.Uri.file(path.join(workspaceRoot, ".champ")),
vscode.Uri.file(path.join(activeFolder, ".champ")),
);
} catch {
// Directory may already exist.
Expand Down Expand Up @@ -1515,13 +1519,14 @@ export async function activate(
// `provider:` line. Comments and the rest of the file are
// preserved. The file watcher fires loadProvider() which
// broadcasts a fresh providerStatus to the chat view.
if (!workspaceRoot) {
const activeFolder = resolveActiveWorkspaceFolder() ?? workspaceRoot;
if (!activeFolder) {
void vscode.window.showErrorMessage(
"Champ: cannot switch model without an open workspace.",
);
return;
}
const yamlPath = path.join(workspaceRoot, ".champ", "config.yaml");
const yamlPath = path.join(activeFolder, ".champ", "config.yaml");
const yamlUri = vscode.Uri.file(yamlPath);
let text: string;
let configExisted = true;
Expand All @@ -1535,7 +1540,7 @@ export async function activate(
const template = generateDefaultConfigYaml();
try {
await vscode.workspace.fs.createDirectory(
vscode.Uri.file(path.join(workspaceRoot, ".champ")),
vscode.Uri.file(path.join(activeFolder, ".champ")),
);
} catch {
/* dir already exists */
Expand Down Expand Up @@ -1582,15 +1587,16 @@ export async function activate(
);
return;
}
if (!workspaceRoot) {
const activeFolder = resolveActiveWorkspaceFolder() ?? workspaceRoot;
if (!activeFolder) {
void vscode.window.showErrorMessage(
"Champ: open a workspace folder before creating a config file.",
);
return;
}
const targetDir = vscode.Uri.file(path.join(workspaceRoot, ".champ"));
const targetDir = vscode.Uri.file(path.join(activeFolder, ".champ"));
const targetUri = vscode.Uri.file(
path.join(workspaceRoot, ".champ", "config.yaml"),
path.join(activeFolder, ".champ", "config.yaml"),
);
try {
await vscode.workspace.fs.createDirectory(targetDir);
Expand Down Expand Up @@ -1791,13 +1797,14 @@ export async function activate(
},
action: "add" | "delete",
) => {
if (!workspaceRoot) {
const activeMcpFolder = resolveActiveWorkspaceFolder() ?? workspaceRoot;
if (!activeMcpFolder) {
void vscode.window.showErrorMessage(
"Champ: open a workspace to configure MCP servers.",
);
return;
}
const configPath = path.join(workspaceRoot, ".champ", "config.yaml");
const configPath = path.join(activeMcpFolder, ".champ", "config.yaml");
let rawConfig = "";
try {
rawConfig = new TextDecoder().decode(
Expand Down Expand Up @@ -1895,15 +1902,17 @@ export async function activate(

const newServer = buildMcpServerConfig(entry, resolvedEnv);

if (!workspaceRoot) {
const activeMcpInstallFolder =
resolveActiveWorkspaceFolder() ?? workspaceRoot;
if (!activeMcpInstallFolder) {
void vscode.window.showErrorMessage(
"Champ: open a workspace to install MCP servers.",
);
return;
}

const configPath = path.join(
workspaceRoot,
activeMcpInstallFolder,
".champ",
"config.yaml",
);
Expand Down Expand Up @@ -2953,8 +2962,9 @@ export async function activate(
* crash activation.
*/
const resolveConfig = async (): Promise<ChampConfig | null> => {
const workspacePath = workspaceRoot
? path.join(workspaceRoot, ".champ", "config.yaml")
const activeFolder = resolveActiveWorkspaceFolder();
const workspacePath = activeFolder
? path.join(activeFolder, ".champ", "config.yaml")
: null;
const userPath = path.join(os.homedir(), ".champ", "config.yaml");

Expand Down Expand Up @@ -3037,8 +3047,9 @@ export async function activate(
yamlConfig = await resolveConfig();
cachedYamlConfig = yamlConfig;
// Load project rules from .champ/rules/*.md
if (workspaceRoot) {
const rulesDir = path.join(workspaceRoot, ".champ", "rules");
const activeRulesFolder = resolveActiveWorkspaceFolder();
if (activeRulesFolder) {
const rulesDir = path.join(activeRulesFolder, ".champ", "rules");
rulesEngine.clearProjectRules();
await rulesEngine.loadRulesFromDirectory(rulesDir).catch((err) => {
console.warn(`Champ: failed to load rules from ${rulesDir}:`, err);
Expand Down Expand Up @@ -3673,36 +3684,47 @@ export async function activate(
}),
);

// Watch .champ/config.yaml in the workspace for live reload. Created,
// changed, or deleted — any of those should trigger a provider reload
// since the file is the source of truth when it exists.
if (workspaceRoot) {
const yamlWatcher = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(workspaceRoot, ".champ/config.yaml"),
);
// Reload provider when the active editor moves to a different workspace
// folder so that the correct .champ/config.yaml is used per project.
let _lastActiveFolder: string | undefined = workspaceRoot ?? undefined;
context.subscriptions.push(
vscode.window.onDidChangeActiveTextEditor(async (editor) => {
if (!editor) return;
const newFolder = resolveActiveWorkspaceFolder();
if (newFolder !== _lastActiveFolder) {
_lastActiveFolder = newFolder;
await loadProvider();
}
}),
);

// Watch .champ/config.yaml in every open workspace folder and in ~/.champ/
// for live reload. Created, changed, or deleted — any triggers a provider reload.
{
const yamlWatchers: vscode.FileSystemWatcher[] = [];
const watchedRoots = [
...(vscode.workspace.workspaceFolders?.map((f) => f.uri.fsPath) ??
(workspaceRoot ? [workspaceRoot] : [])),
os.homedir(),
];
let configReloadTimer: ReturnType<typeof setTimeout> | undefined;
(yamlWatcher.onDidChange(() => {
const debouncedReload = () => {
if (configReloadTimer) clearTimeout(configReloadTimer);
configReloadTimer = setTimeout(() => {
configReloadTimer = undefined;
void loadProvider();
}, 300); // 300ms debounce
}),
yamlWatcher.onDidCreate(() => {
if (configReloadTimer) clearTimeout(configReloadTimer);
configReloadTimer = setTimeout(() => {
configReloadTimer = undefined;
void loadProvider();
}, 300);
}),
yamlWatcher.onDidDelete(() => {
if (configReloadTimer) clearTimeout(configReloadTimer);
configReloadTimer = setTimeout(() => {
configReloadTimer = undefined;
void loadProvider();
}, 300);
}));
context.subscriptions.push(yamlWatcher);
}, 300);
};
for (const root of watchedRoots) {
const w = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(root, ".champ/config.yaml"),
);
w.onDidChange(debouncedReload);
w.onDidCreate(debouncedReload);
w.onDidDelete(debouncedReload);
yamlWatchers.push(w);
}
context.subscriptions.push(...yamlWatchers);
}

// ---- Team Builder and Rules Editor commands -------------------------
Expand Down
Loading
Loading