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
110 changes: 110 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Oil.code Development Guide

## Architecture Overview

Oil.code is a VSCode extension that provides oil.nvim-like file system editing capabilities. The core architecture revolves around:

- **Custom FileSystemProviders** (`src/providers/`) - Virtual file systems using `oil://` and `oil-preview://` schemes
- **State Management** (`src/state/`) - Centralized state for navigation history, edits, and preview
- **Command System** (`src/commands/`) - User actions like select, preview, navigation
- **Vim Integration** (`src/vim/`) - Dynamic keymap registration for VSCodeVim and vscode-neovim

## Key Patterns

### URI Schemes and Path Translation

The extension uses custom URI schemes that must be translated to/from disk paths:

```typescript
// oil://current-path β†’ disk path
const diskPath = oilUriToDiskPath(uri);
// disk path β†’ oil://path
const oilUri = updateOilUri(oilState, targetPath);
```

### State-Driven File Operations

File operations are handled through state changes rather than direct filesystem calls:

```typescript
// Pattern: Update state β†’ Save document β†’ onDidSaveTextDocument handler processes changes
oilState.editedPaths.set(currentPath, modifiedLines);
await document.save(); // Triggers actual filesystem operations
```

### Line Format Convention

Files/directories are displayed with hidden identifiers: `/000 ../`, `/001 filename.txt`, `/002 folder/`

- Extract actual names: `lineText.replace(/^\/\d{3} /, "")`
- Directory detection: `fileName.endsWith("/")`

### Dynamic Extension Detection

Vim keymap registration uses retry logic since extensions load asynchronously:

```typescript
attemptRegisteringVimKeymaps(
MAX_EXTENSION_DETECTION_RETRIES,
EXTENSION_DETECTION_DELAY
);
```

## Testing Patterns

Tests in `src/test/extension.test.ts` follow this structure:

- **Setup**: `cleanupTestDir()`, stub `showWarningMessage` for modal dialogs
- **Actions**: Use `vscode.commands.executeCommand()` + `editor.edit()`
- **Assertions**: `waitForDocumentText()`, `assertProjectFileStructure()`, `assertSelectionOnLine()`
- **Timing**: Liberal use of `sleep()` for async operations

### Critical Test Utilities

- `waitForDocumentText(expectedLines)` - Wait for oil view to update
- `assertProjectFileStructure(structure)` - Verify actual filesystem changes
- `moveCursorToLine(editor, lineNumber)` - Position cursor for selections

## Build & Development

```bash
# Watch builds (run both for full development)
npm run watch:tsc # TypeScript compilation
npm run watch:esbuild # Bundle for extension

# Testing
npm test # Run extension tests
```

### ESBuild Integration

The extension uses a custom esbuild plugin to inline `.lua` files as text (see `esbuild.js`). Lua files in `src/vim/` are embedded for neovim integration.

## Navigation & Editor Management

### Document Transitions

When navigating directories, the extension opens new documents and closes old ones rather than in-place URI changes (VSCode limitation). The `closeOldDocument()` function handles tab cleanup with timing delays.

### Preview System

- Two-column layout: main oil view (left) + preview (right)
- Preview uses separate `oil-preview://` scheme with readonly provider
- Cursor movement triggers preview updates when enabled

## Common Gotchas

1. **Async timing**: Many operations need `sleep()` delays for VSCode state consistency
2. **Modal dialogs**: Stub `vscode.window.showWarningMessage` in tests to avoid blocking
3. **Extension loading**: Vim extensions may not be available immediately at activation
4. **State persistence**: `editedPaths` tracks unsaved changes across navigation
5. **Path normalization**: Always use `removeTrailingSlash()` for consistent path handling

## File Organization

- `src/commands/` - User-facing command implementations
- `src/handlers/` - Event handlers (save, editor change)
- `src/providers/` - FileSystemProvider implementations
- `src/state/` - Global state management
- `src/utils/` - Path utilities, file operations, oil-specific helpers
- `src/vim/` - Vim extension integration and keymaps
139 changes: 87 additions & 52 deletions src/handlers/onDidSaveTextDocument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,15 @@ export async function onDidSaveTextDocument(document: vscode.TextDocument) {
allDestinations.add(path);
});

const replacedDeletedLines = new Set<string>();
// Check existing files that would be deleted and remove from existingFileConflicts
deletedLines.forEach((path) => {
if (existingFileConflicts.has(path)) {
existingFileConflicts.delete(path);
replacedDeletedLines.add(path);
}
});

// Check for duplicate destinations or existing file conflicts
if (duplicateDestinations.size > 0 || existingFileConflicts.size > 0) {
logger.debug(
Expand Down Expand Up @@ -166,6 +175,79 @@ export async function onDidSaveTextDocument(document: vscode.TextDocument) {
return;
}
logger.debug("Processing changes...");

// Delete files/directories
for (const line of replacedDeletedLines) {
const filePath = line;
try {
if (line.endsWith(path.sep)) {
// This is a directory - remove recursively
await removeDirectoryRecursively(filePath);
} else {
// This is a file
fs.unlinkSync(filePath);
}
} catch (error) {
vscode.window.showErrorMessage(
`Failed to delete: ${line} - ${error}`
);
}
}

// Create new files/directories
for (const line of addedLines) {
const newFilePath = line;
if (line.endsWith(path.sep)) {
// Create directory
try {
fs.mkdirSync(newFilePath, { recursive: true });
} catch (error) {
vscode.window.showErrorMessage(
`Failed to create directory: ${line} - ${error}`
);
}
} else {
// Create empty file
try {
// If it's a file in subfolders, ensure the folders exist
if (line.includes(path.sep)) {
const dirPath = path.dirname(newFilePath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
fs.writeFileSync(newFilePath, "");
} catch (error) {
vscode.window.showErrorMessage(
`Failed to create file: ${line} - ${error}`
);
}
}
}

// Copy files
for (const [oldPath, newPath] of copiedLines) {
try {
// Create directory structure if needed
const isDir = newPath.endsWith(path.sep);
const dirPath = isDir ? newPath : path.dirname(newPath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
if (!isDir) {
// Copy the file to the new location
fs.copyFileSync(oldPath, newPath);
}
} catch (error) {
vscode.window.showErrorMessage(
`Failed to copy file: ${formatPath(oldPath)} to ${newPath.replace(
currentPath + path.sep,
""
)} - ${error}`
);
}
}

// Get the workspace edit setting
const useWorkspaceEdit = getEnableWorkspaceEditSetting();

Expand Down Expand Up @@ -210,61 +292,14 @@ export async function onDidSaveTextDocument(document: vscode.TextDocument) {
`Failed to apply workspace edit: ${error}`
);
}
// Copy files
for (const [oldPath, newPath] of copiedLines) {
try {
// Create directory structure if needed
const isDir = newPath.endsWith(path.sep);
const dirPath = isDir ? newPath : path.dirname(newPath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
if (!isDir) {
// Copy the file to the new location
fs.copyFileSync(oldPath, newPath);
}
} catch (error) {
vscode.window.showErrorMessage(
`Failed to copy file: ${formatPath(oldPath)} to ${newPath.replace(
currentPath + path.sep,
""
)} - ${error}`
);
}
}

// Create new files/directories
for (const line of addedLines) {
const newFilePath = line;
if (line.endsWith(path.sep)) {
// Create directory
try {
fs.mkdirSync(newFilePath, { recursive: true });
} catch (error) {
vscode.window.showErrorMessage(
`Failed to create directory: ${line} - ${error}`
);
}
} else {
// Create empty file
try {
// If it's a file in subfolders, ensure the folders exist
if (line.includes(path.sep)) {
const dirPath = path.dirname(newFilePath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
fs.writeFileSync(newFilePath, "");
} catch (error) {
vscode.window.showErrorMessage(
`Failed to create file: ${line} - ${error}`
);
}
}
}
// Delete files/directories
for (const line of deletedLines) {
if (replacedDeletedLines.has(line)) {
// Skip lines that were already processed
continue;
}
// If the line is not in replacedDeletedLines, proceed with deletion
const filePath = line;
try {
if (line.endsWith(path.sep)) {
Expand Down
112 changes: 112 additions & 0 deletions src/test/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -767,4 +767,116 @@ suite("oil.code", () => {
);
});
});

test("Copy and move file in one action", async () => {
await vscode.commands.executeCommand("oil-code.open");
await waitForDocumentText("/000 ../");

const editor = vscode.window.activeTextEditor;
assert.ok(editor, "No active editor");

// Create a target directory and source file
await editor.edit((editBuilder) => {
editBuilder.insert(
new vscode.Position(1, 0),
`${newline}target-dir/${newline}source-file.md`
);
});

await saveFile();

// Wait for file content to update
await waitForDocumentText([
"/000 ../",
"/001 target-dir/",
"/002 source-file.md",
]);

// Add content to the source file
const filePosition = new vscode.Position(2, 0);
editor.selection = new vscode.Selection(filePosition, filePosition);
await vscode.commands.executeCommand("oil-code.select");
await sleep(200);

const testContent = `# Test File${newline}This is test content for copy/move operation.`;
await vscode.window.activeTextEditor?.edit((editBuilder) => {
editBuilder.insert(new vscode.Position(0, 0), testContent);
});

await saveFile();

// Return to oil view
await vscode.commands.executeCommand("oil-code.open");
await sleep(100);

const editor2 = vscode.window.activeTextEditor;
assert.ok(editor2, "No active editor2");

// Edit the oil view to copy and move the file in one action
await editor2.edit((editBuilder) => {
// Remove the original file line
editBuilder.delete(
new vscode.Range(new vscode.Position(2, 0), new vscode.Position(3, 0))
);
});

// Select the target directory
const targetPosition = new vscode.Position(1, 0);
editor2.selection = new vscode.Selection(targetPosition, targetPosition);
await vscode.commands.executeCommand("oil-code.select");
await sleep(300);

const editor3 = vscode.window.activeTextEditor;
assert.ok(editor3, "No active editor3");
// Insert the copied file line
await editor3.edit((editBuilder) => {
editBuilder.insert(
new vscode.Position(1, 0),
`${newline}/002 source-file-rename.md`
);
editBuilder.insert(
new vscode.Position(2, 0),
`${newline}/002 source-file.md`
);
});

await saveFile();

await waitForDocumentText([
"/000 ../",
"/003 source-file-rename.md",
"/004 source-file.md",
]);

await assertProjectFileStructure([
"target-dir/",
" source-file-rename.md",
" source-file.md",
]);

// Check content of copied file
const copiedFileUri = vscode.Uri.joinPath(
vscode.workspace.workspaceFolders![0].uri,
"target-dir",
"source-file-rename.md"
);
const copiedFileContent = await vscode.workspace.fs.readFile(copiedFileUri);
assert.strictEqual(
copiedFileContent.toString(),
testContent,
"Content of copied file does not match expected content"
);
// Check content of moved file
const movedFileUri = vscode.Uri.joinPath(
vscode.workspace.workspaceFolders![0].uri,
"target-dir",
"source-file.md"
);
const movedFileContent = await vscode.workspace.fs.readFile(movedFileUri);
assert.strictEqual(
movedFileContent.toString(),
testContent,
"Content of moved file does not match expected content"
);
});
});
Loading