diff --git a/package.json b/package.json index 2e60d74..50e9b4b 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": [ @@ -170,6 +179,21 @@ "type": "boolean", "default": false, "description": "Enable alternate confirmation dialog for file operations. When enabled, uses a QuickPick interface instead of the default modal confirmation dialog. 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/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 0894f58..694938e 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -879,4 +879,80 @@ 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}` + ); + } + }); }); 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 17edb07..46709cc 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"); @@ -25,6 +26,11 @@ export function getEnableAlternateConfirmationSetting(): boolean { return config.get("enableAlternateConfirmation") || 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(