Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(vscode): support attach files in inline chat #3231

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
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
6 changes: 6 additions & 0 deletions clients/tabby-agent/src/config/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ export const defaultConfigData: ConfigData = {
kind: "replace",
promptTemplate: generateDocsPrompt,
},
"/file": {
label: "Select a file to attach",
filters: {},
kind: "replace",
promptTemplate: "",
},
"/fix": {
label: "Fix spelling and grammar errors",
filters: { languageIdIn: "plaintext,markdown" },
Expand Down
3 changes: 3 additions & 0 deletions clients/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,7 @@
"@quilted/threads": "^2.2.0",
"@types/deep-equal": "^1.0.4",
"@types/diff": "^5.2.1",
"@types/lodash": "^4.17.12",
"@types/mocha": "^10.0.1",
"@types/node": "18.x",
"@types/object-hash": "^3.0.0",
Expand All @@ -480,7 +481,9 @@
"esbuild-plugin-polyfill-node": "^0.3.0",
"eslint": "^8.55.0",
"eslint-config-prettier": "^9.0.0",
"fuzzysort": "^3.0.2",
"get-installed-path": "^4.0.8",
"lodash": "^4.17.21",
"object-hash": "^3.0.0",
"ovsx": "^0.9.5",
"prettier": "^3.0.0",
Expand Down
4 changes: 3 additions & 1 deletion clients/vscode/src/Commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ export class Commands {
"chat.addRelevantContext": async () => {
this.addRelevantContext();
},
"chat.addFileContext": () => {
"chat.addFileContext": async () => {
const editor = window.activeTextEditor;
if (editor) {
commands.executeCommand("tabby.chatView.focus").then(() => {
Expand Down Expand Up @@ -290,6 +290,7 @@ export class Commands {
"chat.edit.start": async (userCommand?: string) => {
const editor = window.activeTextEditor;
if (!editor) {
window.showInformationMessage("No active editor");
return;
}

Expand All @@ -308,6 +309,7 @@ export class Commands {
this.client,
this.config,
this.contextVariables,
this.gitProvider,
editor,
editLocation,
userCommand,
Expand Down
268 changes: 268 additions & 0 deletions clients/vscode/src/inline-edit/QuickFileAttachController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
import fuzzysort from "fuzzysort";
import fs from "fs/promises";
import throttle from "lodash/throttle";
import path from "path";
import {
CancellationToken,
QuickInputButtons,
QuickPickItemKind,
RelativePattern,
TabInputText,
Uri,
ThemeIcon,
window,
workspace,
} from "vscode";
import type { QuickPick, QuickPickItem } from "vscode";
import type { Context } from "tabby-chat-panel";
import { WebviewHelper } from "../chat/WebviewHelper";
import type { GitProvider } from "../git/GitProvider";

const activeSeparator = {
label: "Active",
kind: QuickPickItemKind.Separator,
value: "",
};

const folderSeparator = {
label: "Folders",
kind: QuickPickItemKind.Separator,
value: "",
};

const fileSeparator = {
label: "Files",
kind: QuickPickItemKind.Separator,
value: "",
};

export class QuickFileAttachController {
private quickPick: QuickPick<FilePickItem> = window.createQuickPick<FilePickItem>();
private current: string;
private fileContext: Context | undefined = undefined;

constructor(
private readonly gitProvider: GitProvider,
private selectCallback: (file: string) => void,
private onBackTrigger: () => void,
) {
this.current = this.root;

this.quickPick.placeholder = "Select a file to attach";
this.quickPick.canSelectMany = false;
this.quickPick.matchOnDescription = false;
this.quickPick.buttons = [QuickInputButtons.Back];

this.quickPick.onDidAccept(this.onDidAccept, this);
this.quickPick.onDidChangeValue(this.onDidChangeValue, this);
this.quickPick.onDidTriggerButton(this.onDidTriggerButton, this);
}

get root() {
const rootPath = workspace.workspaceFolders?.[0]?.uri.path || "";
return rootPath;
}

set currentBase(p: string) {
this.current = p;
}

get currentBase() {
return this.current;
}

set selectedFileContext(ctx: Context) {
this.fileContext = ctx;
}

get selectedFileContext(): Context | undefined {
return this.fileContext;
}

public async start() {
this.quickPick.items = await this.listFiles();
this.quickPick.show();
}

public clear() {
this.fileContext = undefined;
}

/**
* This action is too expensive, so it will be only trigged in every 20 seconds;
*/
public findWorkspaceFiles = throttle(() => this.getWorkspaceFiles(), 20 * 1000);

private async onDidAccept() {
const selected = this.quickPick.selectedItems[0]?.value;
if (selected) {
const stat = await fs.stat(selected);
if (stat.isFile()) {
const fileContext = await this.getSelectedFileContext(selected);
this.fileContext = fileContext;
this.quickPick.value = "";
this.quickPick.hide();

if (typeof this.selectCallback === "function") {
this.selectCallback(path.basename(selected));
}
} else {
this.currentBase = selected;
this.quickPick.items = await this.listFiles(selected);
}
}
}

private async onDidChangeValue(value: string) {
const results = await this.search(value);

if (results.length) {
this.quickPick.items = results.map((result) => ({
label: result.target,
value: result.obj.file.path,
iconPath: new ThemeIcon("file"),
}));
} else {
this.quickPick.items = await this.listFiles();
}
}

private async onDidTriggerButton() {
if (this.currentBase === this.root) {
this.onBackTrigger();
return;
}

const pos = this.currentBase.lastIndexOf("/");
const root = pos > 0 ? this.currentBase.slice(0, pos) : this.root;
const files = await this.listFiles(root === this.root ? undefined : root);
this.currentBase = root;
this.quickPick.items = files;
}

private async listFiles(p?: string) {
const root = p || this.root;
const ignorePatterns = this.getExcludedConfig().split(",");
const currentDir = await fs.readdir(root);
const files: FilePickItem[] = [];
const dirs: FilePickItem[] = [];
const activeFiles = this.getFilesFromOpenTabs();
const result = [];

if (activeFiles.length && typeof p === "undefined") {
result.push(activeSeparator);
result.push(
...(activeFiles.map((file) => ({
value: file,
label: this.handleQuickPickItemLabel(file),
iconPath: new ThemeIcon("file"),
})) as FilePickItem[]),
);
}

for (const dir of currentDir) {
const p = path.join(root, dir);

if (ignorePatterns.includes(dir)) {
continue;
}

const s = await fs.stat(p);
if (s.isFile()) {
files.push({
value: p,
label: dir,
iconPath: new ThemeIcon("file"),
});
} else if (s.isDirectory()) {
dirs.push({
value: p,
label: dir,
iconPath: new ThemeIcon("file-directory"),
});
}
}

if (files.length) {
result.push(fileSeparator);
result.push(...files);
}

if (dirs.length) {
result.push(folderSeparator);
result.push(...dirs);
}

return result;
}

private getExcludedConfig() {
// FIXME(@eryue0220): support custom exclude patterns
const excluded = ".git,.svn,.hg,CVS,.DS_Store,Thumbs.db,node_modules,bower_components,*.code-search";
return excluded;
}

private async getSelectedFileContext(path: string): Promise<Context> {
const uri = Uri.file(path);
const content = await fs.readFile(path, "utf8");
const lines = content.split("\n").length;
const { filepath, git_url } = WebviewHelper.resolveFilePathAndGitUrl(uri, this.gitProvider);
const fileContext: Context = {
kind: "file",
content,
range: {
start: 1,
end: lines,
},
filepath,
git_url,
};
return fileContext;
}

private async getWorkspaceFiles(cancellationToken?: CancellationToken) {
return (
await Promise.all(
(workspace.workspaceFolders ?? [null]).map(async (workspaceFolder) =>
workspace.findFiles(
workspaceFolder ? new RelativePattern(workspaceFolder, "**") : "",
this.getExcludedConfig(),
undefined,
cancellationToken,
),
),
)
).flat();
}

private getFilesFromOpenTabs(): string[] {
const tabGroups = window.tabGroups.all;
const openTabs = tabGroups.flatMap((group) => group.tabs.map((tab) => tab.input)) as TabInputText[];

return openTabs
.map((tab) => {
if (!tab.uri || tab.uri.scheme !== "file" || !workspace.getWorkspaceFolder(tab.uri)) {
return undefined;
}

return tab.uri.path;
})
.filter((path): path is string => path !== undefined);
}

private handleQuickPickItemLabel(path: string) {
return path.replace(`${this.root}/`, "");
}

private async search(query: string) {
const files = await this.findWorkspaceFiles();
const ranges = files.map((file) => ({ file, key: this.handleQuickPickItemLabel(file.path) }));
const results = fuzzysort.go(query, ranges, { key: "key", limit: 20 });

return results.map((item) => ({ ...item, score: item.score })).sort((a, b) => b.score - a.score);
}
}

interface FilePickItem extends QuickPickItem {
value: string;
}
Loading
Loading