mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-19 07:16:19 +00:00
Compare commits
17 Commits
mingholy/f
...
feat/suppo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0e561ca73 | ||
|
|
563d68ad5b | ||
|
|
bde31d1261 | ||
|
|
cba9c424eb | ||
|
|
6714f9ce3c | ||
|
|
155d1f9518 | ||
|
|
f776075aa8 | ||
|
|
0a0ab64da0 | ||
|
|
8a15017593 | ||
|
|
3d059b71de | ||
|
|
87dc618a21 | ||
|
|
94a5d828bd | ||
|
|
fd41309ed2 | ||
|
|
48bc0f35d7 | ||
|
|
e30c2dbe23 | ||
|
|
e9204ecba9 | ||
|
|
f24bda3d7b |
@@ -11,9 +11,14 @@ import type { SlashCommand, type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
getErrorMessage,
|
||||
loadServerHierarchicalMemory,
|
||||
QWEN_DIR,
|
||||
setGeminiMdFilename,
|
||||
type FileDiscoveryService,
|
||||
type LoadServerHierarchicalMemoryResponse,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
@@ -31,7 +36,18 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('node:fs/promises', () => {
|
||||
const readFile = vi.fn();
|
||||
return {
|
||||
readFile,
|
||||
default: {
|
||||
readFile,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const mockLoadServerHierarchicalMemory = loadServerHierarchicalMemory as Mock;
|
||||
const mockReadFile = readFile as unknown as Mock;
|
||||
|
||||
describe('memoryCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
@@ -52,6 +68,10 @@ describe('memoryCommand', () => {
|
||||
let mockGetGeminiMdFileCount: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
setGeminiMdFilename('QWEN.md');
|
||||
mockReadFile.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
|
||||
showCommand = getSubCommand('show');
|
||||
|
||||
mockGetUserMemory = vi.fn();
|
||||
@@ -102,6 +122,52 @@ describe('memoryCommand', () => {
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show project memory from the configured context file', async () => {
|
||||
const projectCommand = showCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === '--project',
|
||||
);
|
||||
if (!projectCommand?.action) throw new Error('Command has no action');
|
||||
|
||||
setGeminiMdFilename('AGENTS.md');
|
||||
vi.spyOn(process, 'cwd').mockReturnValue('/test/project');
|
||||
mockReadFile.mockResolvedValue('project memory');
|
||||
|
||||
await projectCommand.action(mockContext, '');
|
||||
|
||||
const expectedProjectPath = path.join('/test/project', 'AGENTS.md');
|
||||
expect(mockReadFile).toHaveBeenCalledWith(expectedProjectPath, 'utf-8');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: expect.stringContaining(expectedProjectPath),
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show global memory from the configured context file', async () => {
|
||||
const globalCommand = showCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === '--global',
|
||||
);
|
||||
if (!globalCommand?.action) throw new Error('Command has no action');
|
||||
|
||||
setGeminiMdFilename('AGENTS.md');
|
||||
vi.spyOn(os, 'homedir').mockReturnValue('/home/user');
|
||||
mockReadFile.mockResolvedValue('global memory');
|
||||
|
||||
await globalCommand.action(mockContext, '');
|
||||
|
||||
const expectedGlobalPath = path.join('/home/user', QWEN_DIR, 'AGENTS.md');
|
||||
expect(mockReadFile).toHaveBeenCalledWith(expectedGlobalPath, 'utf-8');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: expect.stringContaining('Global memory content'),
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/memory add', () => {
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
|
||||
import {
|
||||
getErrorMessage,
|
||||
getCurrentGeminiMdFilename,
|
||||
loadServerHierarchicalMemory,
|
||||
QWEN_DIR,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import path from 'node:path';
|
||||
import os from 'os';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'node:os';
|
||||
import fs from 'node:fs/promises';
|
||||
import { MessageType } from '../types.js';
|
||||
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
@@ -56,7 +57,12 @@ export const memoryCommand: SlashCommand = {
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context) => {
|
||||
try {
|
||||
const projectMemoryPath = path.join(process.cwd(), 'QWEN.md');
|
||||
const workingDir =
|
||||
context.services.config?.getWorkingDir?.() ?? process.cwd();
|
||||
const projectMemoryPath = path.join(
|
||||
workingDir,
|
||||
getCurrentGeminiMdFilename(),
|
||||
);
|
||||
const memoryContent = await fs.readFile(
|
||||
projectMemoryPath,
|
||||
'utf-8',
|
||||
@@ -104,7 +110,7 @@ export const memoryCommand: SlashCommand = {
|
||||
const globalMemoryPath = path.join(
|
||||
os.homedir(),
|
||||
QWEN_DIR,
|
||||
'QWEN.md',
|
||||
getCurrentGeminiMdFilename(),
|
||||
);
|
||||
const globalMemoryContent = await fs.readFile(
|
||||
globalMemoryPath,
|
||||
|
||||
@@ -1,21 +1,58 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useStdin } from 'ink';
|
||||
import type { EditorType } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
editorCommands,
|
||||
commandExists as coreCommandExists,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
|
||||
/**
|
||||
* Cache for command existence checks to avoid repeated execSync calls.
|
||||
*/
|
||||
const commandExistsCache = new Map<string, boolean>();
|
||||
|
||||
/**
|
||||
* Check if a command exists in the system with caching.
|
||||
* Results are cached to improve performance in test environments.
|
||||
*/
|
||||
function commandExists(cmd: string): boolean {
|
||||
if (commandExistsCache.has(cmd)) {
|
||||
return commandExistsCache.get(cmd)!;
|
||||
}
|
||||
|
||||
const exists = coreCommandExists(cmd);
|
||||
commandExistsCache.set(cmd, exists);
|
||||
return exists;
|
||||
}
|
||||
/**
|
||||
* Get the actual executable command for an editor type.
|
||||
*/
|
||||
function getExecutableCommand(editorType: EditorType): string {
|
||||
const commandConfig = editorCommands[editorType];
|
||||
const commands =
|
||||
process.platform === 'win32' ? commandConfig.win32 : commandConfig.default;
|
||||
|
||||
const availableCommand = commands.find((cmd) => commandExists(cmd));
|
||||
|
||||
if (!availableCommand) {
|
||||
throw new Error(
|
||||
`No available editor command found for ${editorType}. ` +
|
||||
`Tried: ${commands.join(', ')}. ` +
|
||||
`Please install one of these editors or set a different preferredEditor in settings.`,
|
||||
);
|
||||
}
|
||||
|
||||
return availableCommand;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the editor command to use based on user preferences and platform.
|
||||
*/
|
||||
function getEditorCommand(preferredEditor?: EditorType): string {
|
||||
if (preferredEditor) {
|
||||
return preferredEditor;
|
||||
return getExecutableCommand(preferredEditor);
|
||||
}
|
||||
|
||||
// Platform-specific defaults with UI preference for macOS
|
||||
@@ -63,8 +100,14 @@ export function useLaunchEditor() {
|
||||
try {
|
||||
setRawMode?.(false);
|
||||
|
||||
// On Windows, .cmd and .bat files need shell: true
|
||||
const needsShell =
|
||||
process.platform === 'win32' &&
|
||||
(editorCommand.endsWith('.cmd') || editorCommand.endsWith('.bat'));
|
||||
|
||||
const { status, error } = spawnSync(editorCommand, editorArgs, {
|
||||
stdio: 'inherit',
|
||||
shell: needsShell,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
@@ -207,6 +207,27 @@ describe('OpenAIContentConverter', () => {
|
||||
expect.objectContaining({ text: 'visible text' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw when streaming chunk has no delta', () => {
|
||||
const chunk = converter.convertOpenAIChunkToGemini({
|
||||
object: 'chat.completion.chunk',
|
||||
id: 'chunk-2',
|
||||
created: 456,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
// Some OpenAI-compatible providers may omit delta entirely.
|
||||
delta: undefined,
|
||||
finish_reason: null,
|
||||
logprobs: null,
|
||||
},
|
||||
],
|
||||
model: 'gpt-test',
|
||||
} as unknown as OpenAI.Chat.ChatCompletionChunk);
|
||||
|
||||
const parts = chunk.candidates?.[0]?.content?.parts;
|
||||
expect(parts).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertGeminiToolsToOpenAI', () => {
|
||||
|
||||
@@ -799,7 +799,7 @@ export class OpenAIContentConverter {
|
||||
const parts: Part[] = [];
|
||||
|
||||
const reasoningText = (choice.delta as ExtendedCompletionChunkDelta)
|
||||
.reasoning_content;
|
||||
?.reasoning_content;
|
||||
if (reasoningText) {
|
||||
parts.push({ text: reasoningText, thought: true });
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ interface DiffCommand {
|
||||
args: string[];
|
||||
}
|
||||
|
||||
function commandExists(cmd: string): boolean {
|
||||
export function commandExists(cmd: string): boolean {
|
||||
try {
|
||||
execSync(
|
||||
process.platform === 'win32' ? `where.exe ${cmd}` : `command -v ${cmd}`,
|
||||
@@ -52,7 +52,7 @@ function commandExists(cmd: string): boolean {
|
||||
* Editor command configurations for different platforms.
|
||||
* Each editor can have multiple possible command names, listed in order of preference.
|
||||
*/
|
||||
const editorCommands: Record<
|
||||
export const editorCommands: Record<
|
||||
EditorType,
|
||||
{ win32: string[]; default: string[] }
|
||||
> = {
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as vscode from 'vscode';
|
||||
import { OpenFilesManager, MAX_FILES } from './open-files-manager.js';
|
||||
import { OpenFilesManager } from './open-files-manager.js';
|
||||
import { MAX_FILES } from './services/open-files-manager/constants.js';
|
||||
|
||||
vi.mock('vscode', () => ({
|
||||
EventEmitter: vi.fn(() => {
|
||||
|
||||
@@ -9,9 +9,23 @@ import type {
|
||||
File,
|
||||
IdeContext,
|
||||
} from '@qwen-code/qwen-code-core/src/ide/types.js';
|
||||
|
||||
export const MAX_FILES = 10;
|
||||
const MAX_SELECTED_TEXT_LENGTH = 16384; // 16 KiB limit
|
||||
import {
|
||||
isFileUri,
|
||||
isNotebookFileUri,
|
||||
isNotebookCellUri,
|
||||
removeFile,
|
||||
renameFile,
|
||||
getNotebookUriFromCellUri,
|
||||
} from './services/open-files-manager/utils.js';
|
||||
import {
|
||||
addOrMoveToFront,
|
||||
updateActiveContext,
|
||||
} from './services/open-files-manager/text-handler.js';
|
||||
import {
|
||||
addOrMoveToFrontNotebook,
|
||||
updateNotebookActiveContext,
|
||||
updateNotebookCellSelection,
|
||||
} from './services/open-files-manager/notebook-handler.js';
|
||||
|
||||
/**
|
||||
* Keeps track of the workspace state, including open files, cursor position, and selected text.
|
||||
@@ -25,33 +39,102 @@ export class OpenFilesManager {
|
||||
constructor(private readonly context: vscode.ExtensionContext) {
|
||||
const editorWatcher = vscode.window.onDidChangeActiveTextEditor(
|
||||
(editor) => {
|
||||
if (editor && this.isFileUri(editor.document.uri)) {
|
||||
this.addOrMoveToFront(editor);
|
||||
if (editor && isFileUri(editor.document.uri)) {
|
||||
addOrMoveToFront(this.openFiles, editor);
|
||||
this.fireWithDebounce();
|
||||
} else if (editor && isNotebookCellUri(editor.document.uri)) {
|
||||
// Handle when a notebook cell becomes active (which indicates the notebook is active)
|
||||
const notebookUri = getNotebookUriFromCellUri(editor.document.uri);
|
||||
if (notebookUri && isNotebookFileUri(notebookUri)) {
|
||||
// Find the notebook editor for this cell
|
||||
const notebookEditor = vscode.window.visibleNotebookEditors.find(
|
||||
(nbEditor) =>
|
||||
nbEditor.notebook.uri.toString() === notebookUri.toString(),
|
||||
);
|
||||
if (notebookEditor) {
|
||||
addOrMoveToFrontNotebook(this.openFiles, notebookEditor);
|
||||
this.fireWithDebounce();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Watch for when notebook editors gain focus by monitoring focus changes
|
||||
// Since VS Code doesn't have a direct onDidChangeActiveNotebookEditor event,
|
||||
// we monitor when visible notebook editors change and assume the last one shown is active
|
||||
let notebookFocusWatcher: vscode.Disposable | undefined;
|
||||
if (vscode.window.onDidChangeVisibleNotebookEditors) {
|
||||
notebookFocusWatcher = vscode.window.onDidChangeVisibleNotebookEditors(
|
||||
() => {
|
||||
// When visible notebook editors change, the currently focused one is likely the active one
|
||||
const activeNotebookEditor = vscode.window.activeNotebookEditor;
|
||||
if (
|
||||
activeNotebookEditor &&
|
||||
isNotebookFileUri(activeNotebookEditor.notebook.uri)
|
||||
) {
|
||||
addOrMoveToFrontNotebook(this.openFiles, activeNotebookEditor);
|
||||
this.fireWithDebounce();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const selectionWatcher = vscode.window.onDidChangeTextEditorSelection(
|
||||
(event) => {
|
||||
if (this.isFileUri(event.textEditor.document.uri)) {
|
||||
this.updateActiveContext(event.textEditor);
|
||||
if (isFileUri(event.textEditor.document.uri)) {
|
||||
updateActiveContext(this.openFiles, event.textEditor);
|
||||
this.fireWithDebounce();
|
||||
} else if (isNotebookCellUri(event.textEditor.document.uri)) {
|
||||
// Handle text selections within notebook cells
|
||||
updateNotebookCellSelection(
|
||||
this.openFiles,
|
||||
event.textEditor,
|
||||
event.selections,
|
||||
);
|
||||
this.fireWithDebounce();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Add notebook cell selection watcher for .ipynb files if the API is available
|
||||
let notebookCellSelectionWatcher: vscode.Disposable | undefined;
|
||||
if (vscode.window.onDidChangeNotebookEditorSelection) {
|
||||
notebookCellSelectionWatcher =
|
||||
vscode.window.onDidChangeNotebookEditorSelection((event) => {
|
||||
if (isNotebookFileUri(event.notebookEditor.notebook.uri)) {
|
||||
// Ensure the notebook is added to the active list if selected
|
||||
addOrMoveToFrontNotebook(this.openFiles, event.notebookEditor);
|
||||
updateNotebookActiveContext(this.openFiles, event.notebookEditor);
|
||||
this.fireWithDebounce();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const closeWatcher = vscode.workspace.onDidCloseTextDocument((document) => {
|
||||
if (this.isFileUri(document.uri)) {
|
||||
this.remove(document.uri);
|
||||
if (isFileUri(document.uri)) {
|
||||
removeFile(this.openFiles, document.uri);
|
||||
this.fireWithDebounce();
|
||||
}
|
||||
});
|
||||
|
||||
// Add notebook close watcher if the API is available
|
||||
let notebookCloseWatcher: vscode.Disposable | undefined;
|
||||
if (vscode.workspace.onDidCloseNotebookDocument) {
|
||||
notebookCloseWatcher = vscode.workspace.onDidCloseNotebookDocument(
|
||||
(document) => {
|
||||
if (isNotebookFileUri(document.uri)) {
|
||||
removeFile(this.openFiles, document.uri);
|
||||
this.fireWithDebounce();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const deleteWatcher = vscode.workspace.onDidDeleteFiles((event) => {
|
||||
for (const uri of event.files) {
|
||||
if (this.isFileUri(uri)) {
|
||||
this.remove(uri);
|
||||
if (isFileUri(uri) || isNotebookFileUri(uri)) {
|
||||
removeFile(this.openFiles, uri);
|
||||
}
|
||||
}
|
||||
this.fireWithDebounce();
|
||||
@@ -59,12 +142,12 @@ export class OpenFilesManager {
|
||||
|
||||
const renameWatcher = vscode.workspace.onDidRenameFiles((event) => {
|
||||
for (const { oldUri, newUri } of event.files) {
|
||||
if (this.isFileUri(oldUri)) {
|
||||
if (this.isFileUri(newUri)) {
|
||||
this.rename(oldUri, newUri);
|
||||
if (isFileUri(oldUri) || isNotebookFileUri(oldUri)) {
|
||||
if (isFileUri(newUri) || isNotebookFileUri(newUri)) {
|
||||
renameFile(this.openFiles, oldUri, newUri);
|
||||
} else {
|
||||
// The file was renamed to a non-file URI, so we should remove it.
|
||||
this.remove(oldUri);
|
||||
removeFile(this.openFiles, oldUri);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,87 +162,37 @@ export class OpenFilesManager {
|
||||
renameWatcher,
|
||||
);
|
||||
|
||||
// Conditionally add notebook-specific watchers if they were created
|
||||
if (notebookCellSelectionWatcher) {
|
||||
context.subscriptions.push(notebookCellSelectionWatcher);
|
||||
}
|
||||
|
||||
if (notebookCloseWatcher) {
|
||||
context.subscriptions.push(notebookCloseWatcher);
|
||||
}
|
||||
|
||||
if (notebookFocusWatcher) {
|
||||
context.subscriptions.push(notebookFocusWatcher);
|
||||
}
|
||||
|
||||
// Just add current active file on start-up.
|
||||
if (
|
||||
vscode.window.activeTextEditor &&
|
||||
this.isFileUri(vscode.window.activeTextEditor.document.uri)
|
||||
isFileUri(vscode.window.activeTextEditor.document.uri)
|
||||
) {
|
||||
this.addOrMoveToFront(vscode.window.activeTextEditor);
|
||||
}
|
||||
}
|
||||
|
||||
private isFileUri(uri: vscode.Uri): boolean {
|
||||
return uri.scheme === 'file';
|
||||
}
|
||||
|
||||
private addOrMoveToFront(editor: vscode.TextEditor) {
|
||||
// Deactivate previous active file
|
||||
const currentActive = this.openFiles.find((f) => f.isActive);
|
||||
if (currentActive) {
|
||||
currentActive.isActive = false;
|
||||
currentActive.cursor = undefined;
|
||||
currentActive.selectedText = undefined;
|
||||
addOrMoveToFront(this.openFiles, vscode.window.activeTextEditor);
|
||||
}
|
||||
|
||||
// Remove if it exists
|
||||
const index = this.openFiles.findIndex(
|
||||
(f) => f.path === editor.document.uri.fsPath,
|
||||
);
|
||||
if (index !== -1) {
|
||||
this.openFiles.splice(index, 1);
|
||||
// Also add current active notebook if applicable and the API is available
|
||||
if (
|
||||
vscode.window.activeNotebookEditor &&
|
||||
isNotebookFileUri(vscode.window.activeNotebookEditor.notebook.uri)
|
||||
) {
|
||||
addOrMoveToFrontNotebook(
|
||||
this.openFiles,
|
||||
vscode.window.activeNotebookEditor,
|
||||
);
|
||||
}
|
||||
|
||||
// Add to the front as active
|
||||
this.openFiles.unshift({
|
||||
path: editor.document.uri.fsPath,
|
||||
timestamp: Date.now(),
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
// Enforce max length
|
||||
if (this.openFiles.length > MAX_FILES) {
|
||||
this.openFiles.pop();
|
||||
}
|
||||
|
||||
this.updateActiveContext(editor);
|
||||
}
|
||||
|
||||
private remove(uri: vscode.Uri) {
|
||||
const index = this.openFiles.findIndex((f) => f.path === uri.fsPath);
|
||||
if (index !== -1) {
|
||||
this.openFiles.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private rename(oldUri: vscode.Uri, newUri: vscode.Uri) {
|
||||
const index = this.openFiles.findIndex((f) => f.path === oldUri.fsPath);
|
||||
if (index !== -1) {
|
||||
this.openFiles[index].path = newUri.fsPath;
|
||||
}
|
||||
}
|
||||
|
||||
private updateActiveContext(editor: vscode.TextEditor) {
|
||||
const file = this.openFiles.find(
|
||||
(f) => f.path === editor.document.uri.fsPath,
|
||||
);
|
||||
if (!file || !file.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
file.cursor = editor.selection.active
|
||||
? {
|
||||
line: editor.selection.active.line + 1,
|
||||
character: editor.selection.active.character,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
let selectedText: string | undefined =
|
||||
editor.document.getText(editor.selection) || undefined;
|
||||
if (selectedText && selectedText.length > MAX_SELECTED_TEXT_LENGTH) {
|
||||
selectedText =
|
||||
selectedText.substring(0, MAX_SELECTED_TEXT_LENGTH) + '... [TRUNCATED]';
|
||||
}
|
||||
file.selectedText = selectedText;
|
||||
}
|
||||
|
||||
private fireWithDebounce() {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export const MAX_FILES = 10;
|
||||
export const MAX_SELECTED_TEXT_LENGTH = 16384; // 16 KiB limit
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import type { File } from '@qwen-code/qwen-code-core/src/ide/types.js';
|
||||
import { MAX_FILES, MAX_SELECTED_TEXT_LENGTH } from './constants.js';
|
||||
import {
|
||||
deactivateCurrentActiveFile,
|
||||
enforceMaxFiles,
|
||||
truncateSelectedText,
|
||||
getNotebookUriFromCellUri,
|
||||
} from './utils.js';
|
||||
|
||||
export function addOrMoveToFrontNotebook(
|
||||
openFiles: File[],
|
||||
notebookEditor: vscode.NotebookEditor,
|
||||
) {
|
||||
// Deactivate previous active file
|
||||
deactivateCurrentActiveFile(openFiles);
|
||||
|
||||
// Remove if it exists
|
||||
const index = openFiles.findIndex(
|
||||
(f) => f.path === notebookEditor.notebook.uri.fsPath,
|
||||
);
|
||||
if (index !== -1) {
|
||||
openFiles.splice(index, 1);
|
||||
}
|
||||
|
||||
// Add to the front as active
|
||||
openFiles.unshift({
|
||||
path: notebookEditor.notebook.uri.fsPath,
|
||||
timestamp: Date.now(),
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
// Enforce max length
|
||||
enforceMaxFiles(openFiles, MAX_FILES);
|
||||
|
||||
updateNotebookActiveContext(openFiles, notebookEditor);
|
||||
}
|
||||
|
||||
export function updateNotebookActiveContext(
|
||||
openFiles: File[],
|
||||
notebookEditor: vscode.NotebookEditor,
|
||||
) {
|
||||
const file = openFiles.find(
|
||||
(f) => f.path === notebookEditor.notebook.uri.fsPath,
|
||||
);
|
||||
if (!file || !file.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For notebook editors, selections may span multiple cells
|
||||
// We'll gather selected text from all selected cells
|
||||
const selections = notebookEditor.selections;
|
||||
let combinedSelectedText = '';
|
||||
|
||||
for (const selection of selections) {
|
||||
// Process each selected cell range
|
||||
for (let i = selection.start; i < selection.end; i++) {
|
||||
const cell = notebookEditor.notebook.cellAt(i);
|
||||
if (cell && cell.kind === vscode.NotebookCellKind.Code) {
|
||||
// For now, we'll get the full cell content if it's in a selection
|
||||
// TODO: Implement per-cell cursor position and finer-grained selection if needed
|
||||
combinedSelectedText += cell.document.getText() + '\n';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (combinedSelectedText) {
|
||||
combinedSelectedText = combinedSelectedText.trim();
|
||||
file.selectedText = truncateSelectedText(
|
||||
combinedSelectedText,
|
||||
MAX_SELECTED_TEXT_LENGTH,
|
||||
);
|
||||
} else {
|
||||
file.selectedText = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function updateNotebookCellSelection(
|
||||
openFiles: File[],
|
||||
cellEditor: vscode.TextEditor,
|
||||
selections: readonly vscode.Selection[],
|
||||
) {
|
||||
// Find the parent notebook by traversing the URI
|
||||
const notebookUri = getNotebookUriFromCellUri(cellEditor.document.uri);
|
||||
if (!notebookUri) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the corresponding file entry for this notebook
|
||||
const file = openFiles.find((f) => f.path === notebookUri.fsPath);
|
||||
if (!file || !file.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract the selected text from the cell editor
|
||||
let selectedText = '';
|
||||
for (const selection of selections) {
|
||||
const text = cellEditor.document.getText(selection);
|
||||
if (text) {
|
||||
selectedText += text + '\n';
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedText) {
|
||||
selectedText = selectedText.trim();
|
||||
file.selectedText = truncateSelectedText(
|
||||
selectedText,
|
||||
MAX_SELECTED_TEXT_LENGTH,
|
||||
);
|
||||
} else {
|
||||
file.selectedText = undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type * as vscode from 'vscode';
|
||||
import type { File } from '@qwen-code/qwen-code-core/src/ide/types.js';
|
||||
import { MAX_FILES, MAX_SELECTED_TEXT_LENGTH } from './constants.js';
|
||||
import {
|
||||
deactivateCurrentActiveFile,
|
||||
enforceMaxFiles,
|
||||
truncateSelectedText,
|
||||
} from './utils.js';
|
||||
|
||||
export function addOrMoveToFront(openFiles: File[], editor: vscode.TextEditor) {
|
||||
// Deactivate previous active file
|
||||
deactivateCurrentActiveFile(openFiles);
|
||||
|
||||
// Remove if it exists
|
||||
const index = openFiles.findIndex(
|
||||
(f) => f.path === editor.document.uri.fsPath,
|
||||
);
|
||||
if (index !== -1) {
|
||||
openFiles.splice(index, 1);
|
||||
}
|
||||
|
||||
// Add to the front as active
|
||||
openFiles.unshift({
|
||||
path: editor.document.uri.fsPath,
|
||||
timestamp: Date.now(),
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
// Enforce max length
|
||||
enforceMaxFiles(openFiles, MAX_FILES);
|
||||
|
||||
updateActiveContext(openFiles, editor);
|
||||
}
|
||||
|
||||
export function updateActiveContext(
|
||||
openFiles: File[],
|
||||
editor: vscode.TextEditor,
|
||||
) {
|
||||
const file = openFiles.find((f) => f.path === editor.document.uri.fsPath);
|
||||
if (!file || !file.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
file.cursor = editor.selection.active
|
||||
? {
|
||||
line: editor.selection.active.line + 1,
|
||||
character: editor.selection.active.character,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
let selectedText: string | undefined =
|
||||
editor.document.getText(editor.selection) || undefined;
|
||||
selectedText = truncateSelectedText(selectedText, MAX_SELECTED_TEXT_LENGTH);
|
||||
file.selectedText = selectedText;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import type { File } from '@qwen-code/qwen-code-core/src/ide/types.js';
|
||||
|
||||
export function isFileUri(uri: vscode.Uri): boolean {
|
||||
return uri.scheme === 'file';
|
||||
}
|
||||
|
||||
export function isNotebookFileUri(uri: vscode.Uri): boolean {
|
||||
return uri.scheme === 'file' && uri.path.toLowerCase().endsWith('.ipynb');
|
||||
}
|
||||
|
||||
export function isNotebookCellUri(uri: vscode.Uri): boolean {
|
||||
// Notebook cell URIs have the scheme 'vscode-notebook-cell'
|
||||
return uri.scheme === 'vscode-notebook-cell';
|
||||
}
|
||||
|
||||
export function removeFile(openFiles: File[], uri: vscode.Uri): void {
|
||||
const index = openFiles.findIndex((f) => f.path === uri.fsPath);
|
||||
if (index !== -1) {
|
||||
openFiles.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
export function renameFile(
|
||||
openFiles: File[],
|
||||
oldUri: vscode.Uri,
|
||||
newUri: vscode.Uri,
|
||||
): void {
|
||||
const index = openFiles.findIndex((f) => f.path === oldUri.fsPath);
|
||||
if (index !== -1) {
|
||||
openFiles[index].path = newUri.fsPath;
|
||||
}
|
||||
}
|
||||
|
||||
export function deactivateCurrentActiveFile(openFiles: File[]): void {
|
||||
const currentActive = openFiles.find((f) => f.isActive);
|
||||
if (currentActive) {
|
||||
currentActive.isActive = false;
|
||||
currentActive.cursor = undefined;
|
||||
currentActive.selectedText = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function enforceMaxFiles(openFiles: File[], maxFiles: number): void {
|
||||
if (openFiles.length > maxFiles) {
|
||||
openFiles.pop();
|
||||
}
|
||||
}
|
||||
|
||||
export function truncateSelectedText(
|
||||
selectedText: string | undefined,
|
||||
maxLength: number,
|
||||
): string | undefined {
|
||||
if (!selectedText) {
|
||||
return undefined;
|
||||
}
|
||||
if (selectedText.length > maxLength) {
|
||||
return selectedText.substring(0, maxLength) + '... [TRUNCATED]';
|
||||
}
|
||||
return selectedText;
|
||||
}
|
||||
|
||||
export function getNotebookUriFromCellUri(
|
||||
cellUri: vscode.Uri,
|
||||
): vscode.Uri | null {
|
||||
// Most efficient approach: Check if the currently active notebook editor contains this cell
|
||||
const activeNotebookEditor = vscode.window.activeNotebookEditor;
|
||||
if (
|
||||
activeNotebookEditor &&
|
||||
isNotebookFileUri(activeNotebookEditor.notebook.uri)
|
||||
) {
|
||||
for (let i = 0; i < activeNotebookEditor.notebook.cellCount; i++) {
|
||||
const cell = activeNotebookEditor.notebook.cellAt(i);
|
||||
if (cell.document.uri.toString() === cellUri.toString()) {
|
||||
return activeNotebookEditor.notebook.uri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If not in the active editor, check all visible notebook editors
|
||||
for (const editor of vscode.window.visibleNotebookEditors) {
|
||||
if (
|
||||
editor !== activeNotebookEditor &&
|
||||
isNotebookFileUri(editor.notebook.uri)
|
||||
) {
|
||||
for (let i = 0; i < editor.notebook.cellCount; i++) {
|
||||
const cell = editor.notebook.cellAt(i);
|
||||
if (cell.document.uri.toString() === cellUri.toString()) {
|
||||
return editor.notebook.uri;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user