From 9c21114fdca49e35022b86e851a7302981495194 Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Mon, 22 Jun 2026 10:14:33 -0700 Subject: [PATCH 1/2] feat: add configurable metadata columns - add optional detail columns for permissions, size, and mtime - render metadata as decorations so oil buffer edits stay unchanged - add toggleDetails command and gd/alt+shift+d keybindings - document the columns setting and extend regression coverage Closes #53 Supersedes #54 Co-authored-by: Goutham-AR <52819899+Goutham-AR@users.noreply.github.com> --- README.md | 27 ++++++++ package.json | 24 +++++++ src/commands/help.ts | 1 + src/commands/refresh.ts | 1 + src/commands/toggleDetails.ts | 14 ++++ src/constants.ts | 9 +++ src/decorations.ts | 65 +++++++++++++++++++ src/extension.ts | 4 +- src/state/columnState.ts | 13 ++++ src/state/initState.ts | 2 + src/state/oilState.ts | 4 ++ src/test/extension.test.ts | 113 ++++++++++++++++++++++++++++++++ src/utils/fileUtils.ts | 7 ++ src/utils/metadataUtils.ts | 118 ++++++++++++++++++++++++++++++++++ src/utils/settings.ts | 6 ++ src/vim/oil.code.lua | 1 + src/vim/vscodeVim.ts | 14 ++++ 17 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 src/commands/toggleDetails.ts create mode 100644 src/state/columnState.ts create mode 100644 src/utils/metadataUtils.ts diff --git a/README.md b/README.md index c0b1f59..1ff9d14 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ To open oil.code: | `ctrl+p` | `alt+p` | `oil-code.preview` | Toggle preview window of entry under the cursor | | `ctrl+l` | `alt+l` | `oil-code.refresh` | Refresh directory listing from disk | | \` | alt+\` | `oil-code.cd` | Change Directory to current | +| `gd` | `alt+shift+d` | `oil-code.toggleDetails` | Toggle detail columns visibility | | -- No Default -- | `alt+shift+h` | `oil-code.help` | Display oil.code default keymaps | ### [vscode-neovim](https://github.com/vscode-neovim/vscode-neovim) Keymaps @@ -67,6 +68,7 @@ if vim.g.vscode then map("n", "", function() vscode.action('oil-code.selectTab') end) map("n", "", function() vscode.action('oil-code.refresh') end) map("n", "`", function() vscode.action('oil-code.cd') end) + map("n", "gd", function() vscode.action('oil-code.toggleDetails') end) end, }) end @@ -96,6 +98,14 @@ If you're using VSCodeVim and want to customize the keymaps for oil.code: "command": "oil-code.select" } ] + }, + { + "before": ["g", "d"], + "commands": [ + { + "command": "oil-code.toggleDetails" + } + ] } ] ``` @@ -170,6 +180,23 @@ You can set the VSCode text editor to use an installed Nerd Font by setting `"ed Once you have a Nerd Font set for your editor font, to use these icons in your oil view, set `"oil-code.hasNerdFont": true`. +## Detail Columns + +By default, oil.code shows file icons only. You can add metadata columns to the directory listing with `"oil-code.columns"`: + +```json +"oil-code.columns": ["icon", "permissions", "size", "mtime"] +``` + +Supported columns: + +- `icon` — file or directory icon +- `permissions` — Unix-style permissions such as `-rw-r--r--` +- `size` — human-readable file size +- `mtime` — last modified time + +The metadata is rendered as non-editable decoration text, so the oil buffer keeps the same `/NNN filename` format for file operations. Use `gd` in Vim mode or `alt+shift+d` to toggle detail column visibility for the current session. + ## Confirmation Dialog By default, oil.code uses a modal confirmation dialog when you save file operations. You can enable an alternate confirmation interface by setting `"oil-code.enableAlternateConfirmation": true`. diff --git a/package.json b/package.json index 0983372..c6d4c96 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,10 @@ "command": "oil-code.cd", "title": "Oil: cd - Change directory to the currently opened directory", "when": "editorTextFocus && editorLangId == oil" + }, + { + "command": "oil-code.toggleDetails", + "title": "Oil: Toggle Detail Columns" } ], "languages": [ @@ -129,6 +133,11 @@ "command": "oil-code.cd", "key": "alt+`", "when": "editorTextFocus && editorLangId == oil" + }, + { + "command": "oil-code.toggleDetails", + "key": "alt+shift+d", + "when": "editorTextFocus && editorLangId == oil" } ], "grammars": [ @@ -175,6 +184,21 @@ "type": "boolean", "default": false, "description": "Enable preview automatically when opening oil. Default is false." + }, + "oil-code.columns": { + "type": "array", + "items": { + "type": "string", + "enum": ["icon", "permissions", "size", "mtime"], + "enumDescriptions": [ + "File type icon", + "Unix-style file permissions (e.g. -rw-r--r--)", + "File size in human-readable form (e.g. 12K)", + "Last modified time (e.g. Mar 14 14:23)" + ] + }, + "default": ["icon"], + "description": "Columns to display in oil directory listings. 'icon' controls the file type icon. Reload the oil buffer after changing this setting." } } } diff --git a/src/commands/help.ts b/src/commands/help.ts index 1f282cc..bb97646 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -28,6 +28,7 @@ export async function help() { "alt+`", "Change VSCode working directory to current oil directory", ], + ["toggleDetails", "gd", "alt+shift+d", "Toggle detail columns visibility"], ]; // Display the message in a Markdown preview panel diff --git a/src/commands/refresh.ts b/src/commands/refresh.ts index b66ebd4..bf4efa7 100644 --- a/src/commands/refresh.ts +++ b/src/commands/refresh.ts @@ -52,6 +52,7 @@ export async function refresh() { try { // Clear the visited path cache for the current directory to force refresh from disk oilState.visitedPaths.delete(currentPath); + oilState.metadataCache.delete(currentPath); // Get updated directory content from disk const directoryContent = await getDirectoryListing( diff --git a/src/commands/toggleDetails.ts b/src/commands/toggleDetails.ts new file mode 100644 index 0000000..c8cfb91 --- /dev/null +++ b/src/commands/toggleDetails.ts @@ -0,0 +1,14 @@ +import * as vscode from "vscode"; +import { toggleDetailsVisible } from "../state/columnState"; +import { updateDecorations } from "../decorations"; + +export function toggleDetails(): void { + toggleDetailsVisible(); + + // Redraw decorations on all visible oil editors + for (const editor of vscode.window.visibleTextEditors) { + if (editor.document.languageId === "oil") { + updateDecorations(editor); + } + } +} diff --git a/src/constants.ts b/src/constants.ts index 3001984..dc2f7fb 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,5 +1,13 @@ import * as vscode from "vscode"; +export type MetadataColumn = "icon" | "permissions" | "size" | "mtime"; + +export interface FileMetadata { + permissions: string; + size: string; + mtime: string; +} + export interface OilEntry { identifier: string; value: string; @@ -13,6 +21,7 @@ export interface OilState { identifierCounter: number; visitedPaths: Map; editedPaths: Map; + metadataCache: Map>; openAfterSave?: string; } diff --git a/src/decorations.ts b/src/decorations.ts index d2b229e..6a6b13a 100644 --- a/src/decorations.ts +++ b/src/decorations.ts @@ -1,6 +1,11 @@ import * as vscode from "vscode"; import * as path from "path"; import { getNerdFontFileIcon } from "./nerd-fonts"; +import { peekOilState } from "./state/oilState"; +import { getDetailsVisible } from "./state/columnState"; +import { getColumnsSettings } from "./utils/settings"; +import { formatMetadataColumns } from "./utils/metadataUtils"; +import { oilUriToDiskPath, normalizePathToUri, removeTrailingSlash } from "./utils/pathUtils"; // Create decoration type for hidden prefix const hiddenPrefixDecoration = vscode.window.createTextEditorDecorationType({ @@ -111,6 +116,20 @@ function getFileIcon(fileName: string, isDirectory: boolean): string { // Decoration types for different file types (created on demand) const fileIconDecorations = new Map(); +// Single decoration type for all metadata before-text (style only; contentText is per-instance). +// Applied AFTER icon decorations so VSCode stacks it between the icon and the filename text. +let metadataDecorationType: vscode.TextEditorDecorationType | null = null; +function getMetadataDecorationType(): vscode.TextEditorDecorationType { + if (!metadataDecorationType) { + metadataDecorationType = vscode.window.createTextEditorDecorationType({ + before: { + color: new vscode.ThemeColor("editorInlayHint.foreground"), + }, + }); + } + return metadataDecorationType; +} + // Apply decorations to hide prefixes export function updateDecorations(editor: vscode.TextEditor | undefined) { if (!editor || editor.document.languageId !== "oil") { @@ -125,9 +144,35 @@ export function updateDecorations(editor: vscode.TextEditor | undefined) { editor.setDecorations(decoration, []); }); + // Clear previous metadata decorations (avoid creating the decoration type too early) + if (metadataDecorationType) { + editor.setDecorations(metadataDecorationType, []); + } + // Track icon decorations for this update const iconDecorations = new Map(); + // Determine if metadata columns should be rendered + const oilState = peekOilState(); + const columns = getColumnsSettings(); + const detailsVisible = getDetailsVisible(); + const metaColumns = columns.filter((c) => c !== "icon"); + const showMetadata = oilState !== undefined && detailsVisible && metaColumns.length > 0; + + // Resolve the metadata map for the current directory (once, before the line loop) + let metadataMap: Map | undefined; + if (showMetadata) { + try { + const diskPath = oilUriToDiskPath(document.uri); + const folderPathUri = removeTrailingSlash(normalizePathToUri(diskPath)); + metadataMap = oilState!.metadataCache.get(folderPathUri); + } catch { + // Not a valid oil URI — skip metadata rendering + } + } + + const metaDecorations: vscode.DecorationOptions[] = []; + // Add icon after the prefix and space // Get appropriate icon based on configuration const config = vscode.workspace.getConfiguration("oil-code"); @@ -196,6 +241,23 @@ export function updateDecorations(editor: vscode.TextEditor | undefined) { ) ); + // Add metadata before-decoration for this line (renders between icon and filename) + if (showMetadata && metadataMap) { + const meta = metadataMap.get(fileName); + if (meta) { + const metaText = formatMetadataColumns(meta, metaColumns); + if (metaText) { + const filenameStartPos = new vscode.Position(i, prefixLength); + metaDecorations.push({ + range: new vscode.Range(filenameStartPos, filenameStartPos), + renderOptions: { + before: { contentText: metaText }, + }, + }); + } + } + } + // If cursor is within the prefix area, move it to the first visible character for (let selection of editor.selections) { if ( @@ -226,6 +288,9 @@ export function updateDecorations(editor: vscode.TextEditor | undefined) { editor.setDecorations(decoration, ranges); } } + + // Apply metadata after-decorations + editor.setDecorations(getMetadataDecorationType(), metaDecorations); } // Disposable for cleanup diff --git a/src/extension.ts b/src/extension.ts index 8869552..5d6a8dd 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -24,6 +24,7 @@ import { openCwd } from "./commands/openCwd"; import { preview } from "./commands/preview"; import { refresh } from "./commands/refresh"; import { cd } from "./commands/cd"; +import { toggleDetails } from "./commands/toggleDetails"; import { logger } from "./logger"; // In your extension's activate function @@ -79,7 +80,8 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand("oil-code.openCwd", openCwd), vscode.commands.registerCommand("oil-code.preview", preview), vscode.commands.registerCommand("oil-code.refresh", refresh), - vscode.commands.registerCommand("oil-code.cd", cd) + vscode.commands.registerCommand("oil-code.cd", cd), + vscode.commands.registerCommand("oil-code.toggleDetails", toggleDetails) ); // Make initial attempt to register Vim keymaps with retries diff --git a/src/state/columnState.ts b/src/state/columnState.ts new file mode 100644 index 0000000..53a7f3a --- /dev/null +++ b/src/state/columnState.ts @@ -0,0 +1,13 @@ +let _detailsVisible = true; + +export function getDetailsVisible(): boolean { + return _detailsVisible; +} + +export function setDetailsVisible(v: boolean): void { + _detailsVisible = v; +} + +export function toggleDetailsVisible(): void { + _detailsVisible = !_detailsVisible; +} diff --git a/src/state/initState.ts b/src/state/initState.ts index 5fdc8bf..2411dd1 100644 --- a/src/state/initState.ts +++ b/src/state/initState.ts @@ -24,6 +24,7 @@ export function initOilState(): OilState { identifierCounter: 1, visitedPaths: new Map(), editedPaths: new Map(), + metadataCache: new Map(), }; return newState; @@ -39,6 +40,7 @@ export function initOilStateWithPath(path: string): OilState { identifierCounter: 1, visitedPaths: new Map(), editedPaths: new Map(), + metadataCache: new Map(), }; return newState; diff --git a/src/state/oilState.ts b/src/state/oilState.ts index e9c4ac7..1fe0efc 100644 --- a/src/state/oilState.ts +++ b/src/state/oilState.ts @@ -18,6 +18,10 @@ export function setOilState(state: OilState | undefined) { oilState = state; } +export function peekOilState(): OilState | undefined { + return oilState; +} + export function getCurrentPath(): string | undefined { const activeEditor = vscode.window.activeTextEditor; if (activeEditor && oilState) { diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index 5052c84..a153fcc 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -9,6 +9,7 @@ import { sleep } from "./utils/sleep"; import { assertProjectFileStructure } from "./utils/assertProjectFileStructure"; import { moveCursorToLine } from "./utils/moveCursorToLine"; import { assertSelectionOnLine } from "./utils/assertSelectionOnLine"; +import { formatMetadataColumns, formatSize } from "../utils/metadataUtils"; async function cleanupTestDir() { const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; @@ -1124,4 +1125,116 @@ suite("oil.code", () => { "Content of moved file does not match expected content" ); }); + + test("toggleDetails command executes without error", async () => { + // Run the command twice (toggle on, toggle off) to verify it + // completes without throwing in both directions. + // Direct state inspection is not possible here because the extension + // is bundled by esbuild, giving it a separate module instance from + // the test's compiled output. + await vscode.commands.executeCommand("oil-code.toggleDetails"); + await vscode.commands.executeCommand("oil-code.toggleDetails"); + }); + + test("Rename with metadata columns enabled does not corrupt file operations", async () => { + // Open oil in icon+size+mtime mode + await vscode.workspace + .getConfiguration("oil-code") + .update("columns", ["icon", "size", "mtime"], vscode.ConfigurationTarget.Global); + + await vscode.commands.executeCommand("oil-code.open"); + await waitForDocumentText("/000 ../"); + + const editor = vscode.window.activeTextEditor; + assert.ok(editor, "No active editor"); + + // Create a file + await editor.edit((editBuilder) => { + editBuilder.insert(new vscode.Position(1, 0), `${newline}metadata-rename-test.md`); + }); + await saveFile(); + await waitForDocumentText(["/000 ../", "/001 metadata-rename-test.md"]); + + // Rename the file by editing only the filename portion (after the identifier) + const line = editor.document.lineAt(1).text; // "/001 metadata-rename-test.md" + const prefixLength = 5; // "/001 " + await editor.edit((editBuilder) => { + editBuilder.replace( + new vscode.Range( + new vscode.Position(1, prefixLength), + new vscode.Position(1, line.length) + ), + "metadata-renamed.md" + ); + }); + await saveFile(); + + await assertProjectFileStructure(["metadata-renamed.md"]); + + // Restore columns setting + await vscode.workspace + .getConfiguration("oil-code") + .update("columns", ["icon"], vscode.ConfigurationTarget.Global); + }); + + test("When columns is icon-only, buffer text stays plain /NNN filename format", async () => { + // Verifies that metadata is never embedded in buffer text (always decoration-only). + // Cross-bundle internal state (metadataCache) cannot be read from test code because + // the extension is bundled by esbuild into a separate module instance. + await vscode.workspace + .getConfiguration("oil-code") + .update("columns", ["icon"], vscode.ConfigurationTarget.Global); + + await vscode.commands.executeCommand("oil-code.open"); + await waitForDocumentText("/000 ../"); + + const editor = vscode.window.activeTextEditor; + assert.ok(editor, "Oil editor should be active"); + + const text = editor.document.getText(); + const lines = text.split(newline).filter((l) => l.trim().length > 0); + for (const line of lines) { + assert.match( + line, + /^\/\d{3} /, + `Each line must start with /NNN prefix only — no metadata in buffer text. Got: ${line}` + ); + } + }); + + test("metadata size column stays fixed-width around unit boundaries", () => { + const sizes = [ + 0, + 999, + 1023, + 1024, + 1024 * 999, + 1024 * 1024 - 1, + 1024 * 1024, + 1024 * 1024 * 999, + ]; + + for (const size of sizes) { + assert.ok( + formatSize(size).length <= 4, + `Expected ${size} bytes to fit the 4-character size column, got ${formatSize(size)}` + ); + } + }); + + test("metadata formatter pads columns consistently with non-breaking spaces", () => { + const formatted = formatMetadataColumns( + { + permissions: "-rw-r--r--", + size: "1K", + mtime: "Jan\u00A005\u00A014:23", + }, + ["permissions", "size", "mtime"] + ); + + assert.strictEqual( + formatted, + "\u00A0\u00A0-rw-r--r--\u00A0\u00A0\u00A0\u00A01K\u00A0\u00A0Jan\u00A005\u00A014:23\u00A0\u00A0" + ); + }); }); diff --git a/src/utils/fileUtils.ts b/src/utils/fileUtils.ts index 59a3576..3f8cbe5 100644 --- a/src/utils/fileUtils.ts +++ b/src/utils/fileUtils.ts @@ -4,6 +4,8 @@ import * as path from "path"; import { GO_UP_IDENTIFIER, OilState } from "../constants"; import { removeTrailingSlash, normalizePathToUri } from "./pathUtils"; import { newline } from "../newline"; +import { populateMetadataCache } from "./metadataUtils"; +import { getColumnsSettings } from "./settings"; export async function getDirectoryListing( folderPath: string, @@ -79,6 +81,11 @@ export async function getDirectoryListing( oilState.visitedPaths.set(folderPathUri, listingsWithIds); + const activeColumns = getColumnsSettings(); + if (activeColumns.some((c) => c !== "icon")) { + populateMetadataCache(folderPath, listings, oilState); + } + return listingsWithIds.join(newline); } diff --git a/src/utils/metadataUtils.ts b/src/utils/metadataUtils.ts new file mode 100644 index 0000000..0cce26a --- /dev/null +++ b/src/utils/metadataUtils.ts @@ -0,0 +1,118 @@ +import * as fs from "fs"; +import * as path from "path"; +import { FileMetadata, MetadataColumn, OilState } from "../constants"; +import { normalizePathToUri, removeTrailingSlash } from "./pathUtils"; + +const MONTHS = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + +// Returns at most 5 chars (e.g. "999B", " 1.0K", "99.9M") so padStart(5) always gives +// a fixed-width right-aligned string. +export function formatSize(bytes: number): string { + if (bytes < 1000) { + return `${bytes}B`; // max "999B" = 4 chars + } + if (bytes < 1000 * 1024) { + const k = bytes / 1024; + return k >= 10 ? `${Math.round(k)}K` : `${k.toFixed(1)}K`; // max "999K" = 4 chars + } + if (bytes < 1000 * 1024 * 1024) { + const m = bytes / (1024 * 1024); + return m >= 10 ? `${Math.round(m)}M` : `${m.toFixed(1)}M`; // max "999M" = 4 chars + } + const g = bytes / (1024 * 1024 * 1024); + return g >= 10 ? `${Math.round(g)}G` : `${g.toFixed(1)}G`; // max "999G" = 4 chars +} + +export function formatMtime(mtime: Date): string { + const month = MONTHS[mtime.getMonth()]; + const day = String(mtime.getDate()).padStart(2, "0"); + const now = new Date(); + if (mtime.getFullYear() === now.getFullYear()) { + const hours = String(mtime.getHours()).padStart(2, "0"); + const minutes = String(mtime.getMinutes()).padStart(2, "0"); + return `${month}\u00A0${day}\u00A0${hours}:${minutes}`; + } + return `${month}\u00A0${day}\u00A0\u00A0${mtime.getFullYear()}`; +} + +export function formatPermissions(stat: fs.Stats): string { + const typeChar = stat.isDirectory() ? "d" : stat.isSymbolicLink() ? "l" : "-"; + + if (process.platform === "win32") { + const readonly = !(stat.mode & 0o200); + return readonly ? `${typeChar}r--r--r--` : `${typeChar}rw-rw-rw-`; + } + + const mode = stat.mode & 0o777; + const rwx = ["---", "--x", "-w-", "-wx", "r--", "r-x", "rw-", "rwx"]; + return `${typeChar}${rwx[(mode >> 6) & 7]}${rwx[(mode >> 3) & 7]}${rwx[mode & 7]}`; +} + +export function getFileMetadata(filePath: string, stat: fs.Stats): FileMetadata { + return { + permissions: formatPermissions(stat), + size: formatSize(stat.size), + mtime: formatMtime(stat.mtime), + }; +} + +// Column widths (monospace chars), all padding uses non-breaking spaces (\u00A0): +// permissions : exactly 10 (e.g. "-rw-r--r--") +// size : right-aligned in 4, padded left with NBSP (e.g. "\u00A012K", "999B") +// mtime : exactly 12 (e.g. "Mar\u00A014\u00A014:23", "Mar\u00A014\u00A0\u00A02024") +// Separator between columns: 2 NBSP. Leading 2 NBSP (gap from icon), trailing 2 NBSP before filename. +// All rows in the same directory with the same column set are identical width. +export function formatMetadataColumns( + meta: FileMetadata, + columns: MetadataColumn[] +): string { + const parts: string[] = []; + for (const col of columns) { + switch (col) { + case "permissions": + parts.push(meta.permissions); // always 10 chars + break; + case "size": + parts.push(meta.size.padStart(4, "\u00A0")); // right-aligned, always 4 chars, NBSP padding + break; + case "mtime": + parts.push(meta.mtime); // always 12 chars + break; + // "icon" is not a metadata column — silently ignored + } + } + // Leading 2 NBSP (gap from icon), 2 NBSP between columns, 2 NBSP before filename + return parts.length > 0 ? "\u00A0\u00A0" + parts.join("\u00A0\u00A0") + "\u00A0\u00A0" : ""; +} + +export function populateMetadataCache( + folderPath: string, + listings: string[], + oilState: OilState +): void { + const folderPathUri = removeTrailingSlash(normalizePathToUri(folderPath)); + const fileMap = new Map(); + + for (const name of listings) { + if (name === "../") { + fileMap.set(name, { + permissions: "-".padStart(10, "\u00A0"), + size: "-", + mtime: "-".padStart(12, "\u00A0"), + }); + continue; + } + try { + const fullPath = path.join(folderPath, name.replace(/\/$/, "")); + const stat = fs.statSync(fullPath); + fileMap.set(name, getFileMetadata(fullPath, stat)); + } catch { + // Skip entries we can't stat (broken symlinks, permission errors, etc.) + } + } + + oilState.metadataCache.set(folderPathUri, fileMap); +} diff --git a/src/utils/settings.ts b/src/utils/settings.ts index c915dae..1ef4951 100644 --- a/src/utils/settings.ts +++ b/src/utils/settings.ts @@ -1,4 +1,5 @@ import * as vscode from "vscode"; +import { MetadataColumn } from "../constants"; export function getDisableVimKeymapsSetting(): boolean { const config = vscode.workspace.getConfiguration("oil-code"); @@ -30,6 +31,11 @@ export function getPreviewByDefaultSetting(): boolean { return config.get("previewByDefault") || false; } +export function getColumnsSettings(): MetadataColumn[] { + const config = vscode.workspace.getConfiguration("oil-code"); + return config.get("columns") ?? ["icon"]; +} + let restoreAutoSave = false; export async function checkAndDisableAutoSave() { diff --git a/src/vim/oil.code.lua b/src/vim/oil.code.lua index 18b2ea8..e6728a6 100644 --- a/src/vim/oil.code.lua +++ b/src/vim/oil.code.lua @@ -22,5 +22,6 @@ vim.api.nvim_create_autocmd({'FileType'}, { map("n", "", function() vscode.action('oil-code.selectTab') end) map("n", "", function() vscode.action('oil-code.refresh') end) map("n", "`", function() vscode.action('oil-code.cd') end) + map("n", "gd", function() vscode.action('oil-code.toggleDetails') end) end, }) diff --git a/src/vim/vscodeVim.ts b/src/vim/vscodeVim.ts index 67406b9..4851dec 100644 --- a/src/vim/vscodeVim.ts +++ b/src/vim/vscodeVim.ts @@ -104,6 +104,20 @@ export async function registerVSCodeVimKeymap(): Promise { keymapChanged = true; } + // Check for and add the Oil toggleDetails binding if not present + const hasOilToggleDetailsBinding = normalModeKeymap.some((binding) => + binding.commands?.some( + (cmd: { command: string }) => cmd.command === "oil-code.toggleDetails" + ) + ); + if (!hasOilToggleDetailsBinding) { + updatedKeymap.push({ + before: ["g", "d"], + commands: [{ command: "oil-code.toggleDetails" }], + }); + keymapChanged = true; + } + // Update the configuration if changes were made if (keymapChanged) { await vimConfig.update( From 4c4288dd053d96c7d7aa1c5d73ec450c7f215160 Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Tue, 23 Jun 2026 14:56:13 -0700 Subject: [PATCH 2/2] fix: stabilize metadata column decorations --- src/decorations.ts | 143 ++++++++++++++----------------------- src/test/extension.test.ts | 10 +++ 2 files changed, 65 insertions(+), 88 deletions(-) diff --git a/src/decorations.ts b/src/decorations.ts index 6a6b13a..e8a915e 100644 --- a/src/decorations.ts +++ b/src/decorations.ts @@ -113,21 +113,29 @@ function getFileIcon(fileName: string, isDirectory: boolean): string { } } -// Decoration types for different file types (created on demand) -const fileIconDecorations = new Map(); - -// Single decoration type for all metadata before-text (style only; contentText is per-instance). -// Applied AFTER icon decorations so VSCode stacks it between the icon and the filename text. -let metadataDecorationType: vscode.TextEditorDecorationType | null = null; -function getMetadataDecorationType(): vscode.TextEditorDecorationType { - if (!metadataDecorationType) { - metadataDecorationType = vscode.window.createTextEditorDecorationType({ - before: { - color: new vscode.ThemeColor("editorInlayHint.foreground"), - }, - }); +export function buildEntryDecorationContent({ + icon, + metadataText, +}: { + icon: string; + metadataText?: string; +}): string { + if (metadataText) { + return `${icon.trimEnd()}${metadataText}`; + } + + return /\s$/.test(icon) ? icon : `${icon} `; +} + +// Single decoration type for all entry before-text. The icon and metadata columns +// are emitted together so VS Code cannot reorder multiple same-position +// decorations while navigating between directories. +let entryDecorationType: vscode.TextEditorDecorationType | null = null; +function getEntryDecorationType(): vscode.TextEditorDecorationType { + if (!entryDecorationType) { + entryDecorationType = vscode.window.createTextEditorDecorationType({}); } - return metadataDecorationType; + return entryDecorationType; } // Apply decorations to hide prefixes @@ -139,19 +147,11 @@ export function updateDecorations(editor: vscode.TextEditor | undefined) { const document = editor.document; const hiddenRanges: vscode.Range[] = []; - // Clear previous icon decorations - fileIconDecorations.forEach((decoration) => { - editor.setDecorations(decoration, []); - }); - - // Clear previous metadata decorations (avoid creating the decoration type too early) - if (metadataDecorationType) { - editor.setDecorations(metadataDecorationType, []); + // Clear previous entry decorations + if (entryDecorationType) { + editor.setDecorations(entryDecorationType, []); } - // Track icon decorations for this update - const iconDecorations = new Map(); - // Determine if metadata columns should be rendered const oilState = peekOilState(); const columns = getColumnsSettings(); @@ -171,9 +171,9 @@ export function updateDecorations(editor: vscode.TextEditor | undefined) { } } - const metaDecorations: vscode.DecorationOptions[] = []; + const entryDecorations: vscode.DecorationOptions[] = []; - // Add icon after the prefix and space + // Add icon and optional metadata after the hidden prefix // Get appropriate icon based on configuration const config = vscode.workspace.getConfiguration("oil-code"); const hasNerdFont = config.get("hasNerdFont") === true; @@ -195,69 +195,41 @@ export function updateDecorations(editor: vscode.TextEditor | undefined) { const endPos = new vscode.Position(i, prefixLength); hiddenRanges.push(new vscode.Range(startPos, endPos)); - let icon; - let fontColor = "inherit"; - let iconKey = ""; + let icon: string; + let fontColor: string | vscode.ThemeColor | undefined; if (hasNerdFont) { - const { - icon: nerdIcon, - color, - extension, - } = getNerdFontFileIcon(fileName, isDirectory); + const { icon: nerdIcon, color } = getNerdFontFileIcon(fileName, isDirectory); icon = nerdIcon; fontColor = color; - iconKey = extension; } else { icon = getFileIcon(fileName, isDirectory); - iconKey = isDirectory ? "directory" : path.extname(fileName) || "file"; } - // Create decoration type if it doesn't exist - if (!fileIconDecorations.has(iconKey)) { - fileIconDecorations.set( - iconKey, - vscode.window.createTextEditorDecorationType({ - before: { - contentText: icon, - width: "1.5em", - color: fontColor, - }, - }) - ); - } - - // Get or create the ranges array for this icon type - if (!iconDecorations.has(iconKey)) { - iconDecorations.set(iconKey, []); - } - - // Add decoration range at the beginning of visible part - iconDecorations - .get(iconKey)! - .push( - new vscode.Range( - new vscode.Position(i, prefixLength), - new vscode.Position(i, prefixLength) - ) - ); - - // Add metadata before-decoration for this line (renders between icon and filename) + let metadataText = ""; if (showMetadata && metadataMap) { const meta = metadataMap.get(fileName); if (meta) { - const metaText = formatMetadataColumns(meta, metaColumns); - if (metaText) { - const filenameStartPos = new vscode.Position(i, prefixLength); - metaDecorations.push({ - range: new vscode.Range(filenameStartPos, filenameStartPos), - renderOptions: { - before: { contentText: metaText }, - }, - }); + metadataText = formatMetadataColumns(meta, metaColumns); + if (metadataText) { + // A combined icon+metadata decoration must use one color. Prefer the + // metadata/inlay hint color when details are visible so the data + // columns read as subdued metadata instead of file-type glyphs. + fontColor = new vscode.ThemeColor("editorInlayHint.foreground"); } } } + const filenameStartPos = new vscode.Position(i, prefixLength); + entryDecorations.push({ + range: new vscode.Range(filenameStartPos, filenameStartPos), + renderOptions: { + before: { + contentText: buildEntryDecorationContent({ icon, metadataText }), + color: fontColor, + }, + }, + }); + // If cursor is within the prefix area, move it to the first visible character for (let selection of editor.selections) { if ( @@ -281,16 +253,9 @@ export function updateDecorations(editor: vscode.TextEditor | undefined) { // Apply the hidden prefix decoration editor.setDecorations(hiddenPrefixDecoration, hiddenRanges); - // Apply file type icon decorations - for (const [iconKey, ranges] of iconDecorations.entries()) { - const decoration = fileIconDecorations.get(iconKey); - if (decoration) { - editor.setDecorations(decoration, ranges); - } - } - - // Apply metadata after-decorations - editor.setDecorations(getMetadataDecorationType(), metaDecorations); + // Apply entry before-text decorations after hiding prefixes. Icon and metadata + // share one per-line decoration to keep VS Code from reordering columns. + editor.setDecorations(getEntryDecorationType(), entryDecorations); } // Disposable for cleanup @@ -408,8 +373,10 @@ export function activateDecorations(context: vscode.ExtensionContext) { context.subscriptions.push(decorationUpdateListener, { dispose: () => { hiddenPrefixDecoration.dispose(); - fileIconDecorations.forEach((decoration) => decoration.dispose()); - fileIconDecorations.clear(); + if (entryDecorationType) { + entryDecorationType.dispose(); + entryDecorationType = null; + } if (decorationUpdateListener) { decorationUpdateListener.dispose(); diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index a153fcc..7cb05ef 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -9,6 +9,7 @@ import { sleep } from "./utils/sleep"; import { assertProjectFileStructure } from "./utils/assertProjectFileStructure"; import { moveCursorToLine } from "./utils/moveCursorToLine"; import { assertSelectionOnLine } from "./utils/assertSelectionOnLine"; +import { buildEntryDecorationContent } from "../decorations"; import { formatMetadataColumns, formatSize } from "../utils/metadataUtils"; async function cleanupTestDir() { @@ -1237,4 +1238,13 @@ suite("oil.code", () => { "\u00A0\u00A0-rw-r--r--\u00A0\u00A0\u00A0\u00A01K\u00A0\u00A0Jan\u00A005\u00A014:23\u00A0\u00A0" ); }); + + test("decorated listing columns are emitted as one stable before-text string", () => { + const content = buildEntryDecorationContent({ + icon: "📄", + metadataText: "\u00A0\u00A0999B\u00A0\u00A0", + }); + + assert.strictEqual(content, "📄\u00A0\u00A0999B\u00A0\u00A0"); + }); });