diff --git a/docs/docs/config.mdx b/docs/docs/config.mdx index 05389e99ef..859316b684 100644 --- a/docs/docs/config.mdx +++ b/docs/docs/config.mdx @@ -84,6 +84,7 @@ wsh editconfig | editor:inlinediff | bool | set to true to show diffs inline instead of side-by-side, false for side-by-side (defaults to undefined which uses Monaco's responsive behavior) | | preview:showhiddenfiles | bool | set to false to disable showing hidden files in the directory preview (defaults to true) | | preview:defaultsort | string | sets the default sort column for directory preview. `"name"` (default) sorts alphabetically by name ascending; `"modtime"` sorts by last modified time descending (newest first) | +| preview:dirsfirst | bool | set to false to sort folders together with files in the directory preview instead of listing them first (defaults to true) | | markdown:fontsize | float64 | font size for the normal text when rendering markdown in preview. headers are scaled up from this size, (default 14px) | | markdown:fixedfontsize | float64 | font size for the code blocks when rendering markdown in preview (default is 12px) | | web:openlinksinternally | bool | set to false to open web links in external browser | diff --git a/docs/docs/widgets.mdx b/docs/docs/widgets.mdx index 52257619d1..487519cb14 100644 --- a/docs/docs/widgets.mdx +++ b/docs/docs/widgets.mdx @@ -83,6 +83,10 @@ While the widget is focused, you can filter by filename by typing a substring of To sort a file by a specific column, click on the header for that column. If you click the header again, it will reverse the sort order. +##### Show Folders First + +At the right of the widget header, there is a **** button. Clicking this button toggles whether folders are listed before files in this widget. Folders stay on top regardless of the sort column and direction. To change the default for new widgets, right click the header of the widget and use **Default Settings** > **Show Folders First**. + ##### Hide and Show Hidden Files At the right of the widget header, there is an **** button. Clicking this button hides and shows hidden files. diff --git a/frontend/app/view/preview/preview-directory-utils.test.ts b/frontend/app/view/preview/preview-directory-utils.test.ts new file mode 100644 index 0000000000..8abfac2950 --- /dev/null +++ b/frontend/app/view/preview/preview-directory-utils.test.ts @@ -0,0 +1,144 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { globalStore } from "@/app/store/jotaiStore"; +import { makeMockWaveEnv } from "@/preview/mock/mockwaveenv"; +import { ColumnDef, SortingState, Table, createTable, getCoreRowModel, getSortedRowModel } from "@tanstack/react-table"; +import { atom } from "jotai"; +import { describe, expect, it, vi } from "vitest"; +import { + DirsFirstColumnId, + getDirsFirstSortValue, + getNextColumnSorting, + makeDirectoryDefaultMenuItems, + makeDirectorySorting, +} from "./preview-directory-utils"; +import { type PreviewModel } from "./preview-model"; + +const Entries: FileInfo[] = [ + { name: "b.txt", path: "/d/b.txt", isdir: false, size: 30 }, + { name: "zdir", path: "/d/zdir", isdir: true, size: 0 }, + { name: "a.txt", path: "/d/a.txt", isdir: false, size: 10 }, + { name: "..", path: "/", isdir: true, size: 0 }, + { name: "adir", path: "/d/adir", isdir: true, size: 0 }, +]; + +const Columns: ColumnDef[] = [ + { accessorKey: "name", sortingFn: "alphanumeric" }, + { accessorKey: "size", sortingFn: "auto" }, + { id: DirsFirstColumnId, accessorFn: getDirsFirstSortValue, sortingFn: "basic" }, +]; + +// mirrors how DirectoryTable wires the controlled sorting state into the table +function makeTable(dirsFirst: boolean, initialSorting: SortingState): Table { + let columnSorting = initialSorting; + const table = createTable({ + data: Entries, + columns: Columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + enableMultiSort: false, + enableSortingRemoval: false, + state: {}, + onStateChange: () => {}, + renderFallbackValue: null, + }); + const applySorting = () => { + table.setOptions((prev) => ({ + ...prev, + state: { ...table.initialState, sorting: makeDirectorySorting(columnSorting, dirsFirst) }, + onSortingChange: (updater) => { + columnSorting = getNextColumnSorting(updater, table.getState().sorting); + applySorting(); + }, + })); + }; + applySorting(); + return table; +} + +function getNames(table: Table): string[] { + return table.getRowModel().rows.map((row) => row.original.name); +} + +describe("directory sorting", () => { + it("mixes folders and files when dirsfirst is off", () => { + const table = makeTable(false, [{ id: "name", desc: false }]); + expect(getNames(table)).toEqual(["..", "a.txt", "adir", "b.txt", "zdir"]); + }); + + it("lists '..' then folders then files when dirsfirst is on", () => { + const table = makeTable(true, [{ id: "name", desc: false }]); + expect(getNames(table)).toEqual(["..", "adir", "zdir", "a.txt", "b.txt"]); + }); + + it("keeps folders on top when the column sort is descending", () => { + const table = makeTable(true, [{ id: "name", desc: true }]); + expect(getNames(table)).toEqual(["..", "zdir", "adir", "b.txt", "a.txt"]); + }); + + it("keeps folders on top when toggling the sorted column", () => { + const table = makeTable(true, [{ id: "name", desc: false }]); + table.getColumn("name").toggleSorting(); + expect(table.getState().sorting).toEqual([ + { id: DirsFirstColumnId, desc: true }, + { id: "name", desc: true }, + ]); + expect(getNames(table)).toEqual(["..", "zdir", "adir", "b.txt", "a.txt"]); + }); + + it("keeps folders on top when switching to another column", () => { + const table = makeTable(true, [{ id: "name", desc: false }]); + table.getColumn("size").toggleSorting(true); + expect(table.getState().sorting).toEqual([ + { id: DirsFirstColumnId, desc: true }, + { id: "size", desc: true }, + ]); + expect(getNames(table)).toEqual(["..", "zdir", "adir", "b.txt", "a.txt"]); + }); +}); + +function makeMenuModel(dirsFirst: boolean) { + const setConfig = vi.fn(async (_client: any, _data: any) => null); + const model = { + env: makeMockWaveEnv({ rpc: { SetConfigCommand: setConfig } }), + dirsFirst: atom(dirsFirst), + showHiddenFiles: atom(true), + }; + return { model, setConfig, menuItems: makeDirectoryDefaultMenuItems(model as unknown as PreviewModel) }; +} + +function getFolderSubmenu(menuItems: ContextMenuItem[]): ContextMenuItem[] { + return menuItems.find((item) => item.label == "Show Folders First")?.submenu; +} + +describe("directory context menu", () => { + it("checks On when dirsfirst is enabled", () => { + const { menuItems } = makeMenuModel(true); + const submenu = getFolderSubmenu(menuItems); + expect(submenu.map((item) => [item.label, item.checked])).toEqual([ + ["On", true], + ["Off", false], + ]); + }); + + it("checks Off when dirsfirst is disabled", () => { + const { menuItems } = makeMenuModel(false); + const submenu = getFolderSubmenu(menuItems); + expect(submenu.map((item) => [item.label, item.checked])).toEqual([ + ["On", false], + ["Off", true], + ]); + }); + + it("persists the choice when clicking Off and On", () => { + const { model, setConfig, menuItems } = makeMenuModel(true); + const submenu = getFolderSubmenu(menuItems); + submenu[1].click(); + expect(globalStore.get(model.dirsFirst)).toBe(false); + expect(setConfig.mock.lastCall[1]).toEqual({ "preview:dirsfirst": false }); + submenu[0].click(); + expect(globalStore.get(model.dirsFirst)).toBe(true); + expect(setConfig.mock.lastCall[1]).toEqual({ "preview:dirsfirst": true }); + }); +}); diff --git a/frontend/app/view/preview/preview-directory-utils.tsx b/frontend/app/view/preview/preview-directory-utils.tsx index e278475cac..535a334405 100644 --- a/frontend/app/view/preview/preview-directory-utils.tsx +++ b/frontend/app/view/preview/preview-directory-utils.tsx @@ -4,6 +4,7 @@ import { globalStore } from "@/app/store/jotaiStore"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { fireAndForget, isBlank } from "@/util/util"; +import { SortingState, Updater, functionalUpdate } from "@tanstack/react-table"; import dayjs from "dayjs"; import React from "react"; import { type PreviewModel } from "./preview-model"; @@ -12,6 +13,28 @@ export const recursiveError = "recursive flag must be set for directory operatio export const overwriteError = "set overwrite flag to delete the existing file"; export const mergeError = "set overwrite flag to delete the existing contents or set merge flag to merge the contents"; +export const DirsFirstColumnId = "dirsfirst"; + +export function getDirsFirstSortValue(fileInfo: FileInfo): number { + if (fileInfo.name == "..") { + return 2; + } + return fileInfo.isdir ? 1 : 0; +} + +// the hidden dirsfirst column is kept as the primary sort key (with its own direction) so folders stay +// on top regardless of the user-selected column and direction +export function makeDirectorySorting(columnSorting: SortingState, dirsFirst: boolean): SortingState { + if (!dirsFirst) { + return columnSorting; + } + return [{ id: DirsFirstColumnId, desc: true }, ...columnSorting]; +} + +export function getNextColumnSorting(updater: Updater, sorting: SortingState): SortingState { + return functionalUpdate(updater, sorting).filter((sort) => sort.id !== DirsFirstColumnId); +} + export const displaySuffixes = { B: "b", kB: "k", @@ -155,6 +178,7 @@ export function handleFileDelete( export function makeDirectoryDefaultMenuItems(model: PreviewModel): ContextMenuItem[] { const defaultSort = globalStore.get(model.env.getSettingsKeyAtom("preview:defaultsort")) ?? "name"; const showHiddenFiles = globalStore.get(model.showHiddenFiles) ?? true; + const dirsFirst = globalStore.get(model.dirsFirst) ?? true; return [ { label: "Directory Sort Order", @@ -206,5 +230,32 @@ export function makeDirectoryDefaultMenuItems(model: PreviewModel): ContextMenuI }, ], }, + { + label: "Show Folders First", + submenu: [ + { + label: "On", + type: "checkbox", + checked: dirsFirst, + click: () => { + globalStore.set(model.dirsFirst, true); + fireAndForget(() => + model.env.rpc.SetConfigCommand(TabRpcClient, { "preview:dirsfirst": true }) + ); + }, + }, + { + label: "Off", + type: "checkbox", + checked: !dirsFirst, + click: () => { + globalStore.set(model.dirsFirst, false); + fireAndForget(() => + model.env.rpc.SetConfigCommand(TabRpcClient, { "preview:dirsfirst": false }) + ); + }, + }, + ], + }, ]; } diff --git a/frontend/app/view/preview/preview-directory.tsx b/frontend/app/view/preview/preview-directory.tsx index 0940ba43b3..26b0e0c7e7 100644 --- a/frontend/app/view/preview/preview-directory.tsx +++ b/frontend/app/view/preview/preview-directory.tsx @@ -15,6 +15,7 @@ import { Header, Row, RowData, + SortingState, Table, createColumnHelper, flexRender, @@ -32,14 +33,18 @@ import { debounce } from "throttle-debounce"; import "./directorypreview.scss"; import { EntryManagerOverlay, EntryManagerOverlayProps, EntryManagerType } from "./entry-manager"; import { + DirsFirstColumnId, cleanMimetype, getBestUnit, + getDirsFirstSortValue, getLastModifiedTime, + getNextColumnSorting, getSortIcon, handleFileDelete, handleRename, isIconValid, makeDirectoryDefaultMenuItems, + makeDirectorySorting, mergeError, overwriteError, } from "./preview-directory-utils"; @@ -114,6 +119,7 @@ function DirectoryTable({ const env = useWaveEnv(); const fullConfig = useAtomValue(env.atoms.fullConfigAtom); const defaultSort = useAtomValue(env.getSettingsKeyAtom("preview:defaultsort")) ?? "name"; + const dirsFirst = useAtomValue(model.dirsFirst); const setErrorMsg = useSetAtom(model.errorMsgAtom); const getIconFromMimeType = useCallback( (mimeType: string): string => { @@ -182,6 +188,10 @@ function DirectoryTable({ sortingFn: "alphanumeric", }), columnHelper.accessor("path", {}), + columnHelper.accessor(getDirsFirstSortValue, { + id: DirsFirstColumnId, + sortingFn: "basic", + }), ], [fullConfig] ); @@ -209,7 +219,10 @@ function DirectoryTable({ [model, setErrorMsg] ); - const initialSorting = defaultSort === "modtime" ? [{ id: "modtime", desc: true }] : [{ id: "name", desc: false }]; + const [columnSorting, setColumnSorting] = useState(() => + defaultSort === "modtime" ? [{ id: "modtime", desc: true }] : [{ id: "name", desc: false }] + ); + const sorting = useMemo(() => makeDirectorySorting(columnSorting, dirsFirst), [columnSorting, dirsFirst]); const table = useReactTable({ data, @@ -218,10 +231,16 @@ function DirectoryTable({ getSortedRowModel: getSortedRowModel(), getCoreRowModel: getCoreRowModel(), + state: { + sorting, + }, + onSortingChange: (updater) => { + setColumnSorting(getNextColumnSorting(updater, sorting)); + }, initialState: { - sorting: initialSorting, columnVisibility: { path: false, + [DirsFirstColumnId]: false, }, }, enableMultiSort: false, diff --git a/frontend/app/view/preview/preview-model.tsx b/frontend/app/view/preview/preview-model.tsx index 8315e48b2a..89490cfb26 100644 --- a/frontend/app/view/preview/preview-model.tsx +++ b/frontend/app/view/preview/preview-model.tsx @@ -163,6 +163,7 @@ export class PreviewModel implements ViewModel { monacoRef: React.RefObject; showHiddenFiles: PrimitiveAtom; + dirsFirst: PrimitiveAtom; refreshVersion: PrimitiveAtom; directorySearchActive: PrimitiveAtom; refreshCallback: () => void; @@ -178,6 +179,8 @@ export class PreviewModel implements ViewModel { this.env = waveEnv; let showHiddenFiles = globalStore.get(this.env.getSettingsKeyAtom("preview:showhiddenfiles")) ?? true; this.showHiddenFiles = atom(showHiddenFiles); + let dirsFirst = globalStore.get(this.env.getSettingsKeyAtom("preview:dirsfirst")) ?? true; + this.dirsFirst = atom(dirsFirst); this.refreshVersion = atom(0); this.directorySearchActive = atom(false); this.previewTextRef = createRef(); @@ -334,7 +337,16 @@ export class PreviewModel implements ViewModel { const isCeView = loadableSV.state == "hasData" && loadableSV.data.specializedView == "codeedit"; if (mimeType == "directory") { const showHiddenFiles = get(this.showHiddenFiles); + const dirsFirst = get(this.dirsFirst); return [ + { + elemtype: "iconbutton", + icon: dirsFirst ? "folder" : "file", + title: dirsFirst ? "Show Folders Mixed with Files" : "Show Folders First", + click: () => { + globalStore.set(this.dirsFirst, (prev) => !prev); + }, + }, { elemtype: "iconbutton", icon: showHiddenFiles ? "eye" : "eye-slash", diff --git a/frontend/app/view/preview/previewenv.ts b/frontend/app/view/preview/previewenv.ts index 30607b8459..e55727564e 100644 --- a/frontend/app/view/preview/previewenv.ts +++ b/frontend/app/view/preview/previewenv.ts @@ -30,6 +30,8 @@ export type PreviewEnv = WaveEnvSubset<{ object: WaveEnv["services"]["object"]; }; wos: WaveEnv["wos"]; - getSettingsKeyAtom: SettingsKeyAtomFnType<"preview:showhiddenfiles" | "editor:fontsize" | "preview:defaultsort">; + getSettingsKeyAtom: SettingsKeyAtomFnType< + "preview:showhiddenfiles" | "editor:fontsize" | "preview:defaultsort" | "preview:dirsfirst" + >; getConnStatusAtom: WaveEnv["getConnStatusAtom"]; }>; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index c5b870d7ed..732f231947 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -1444,6 +1444,7 @@ declare global { "markdown:fixedfontsize"?: number; "preview:showhiddenfiles"?: boolean; "preview:defaultsort"?: string; + "preview:dirsfirst"?: boolean; "tab:preset"?: string; "tab:confirmclose"?: boolean; "tab:background"?: string; diff --git a/pkg/wconfig/defaultconfig/settings.json b/pkg/wconfig/defaultconfig/settings.json index d8847cabf2..207f749c59 100644 --- a/pkg/wconfig/defaultconfig/settings.json +++ b/pkg/wconfig/defaultconfig/settings.json @@ -40,5 +40,6 @@ "term:durable": false, "waveai:showcloudmodes": true, "waveai:defaultmode": "waveai@balanced", - "preview:defaultsort": "name" + "preview:defaultsort": "name", + "preview:dirsfirst": true } diff --git a/pkg/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go index 7d5bba5d9d..c3c55fe455 100644 --- a/pkg/wconfig/metaconsts.go +++ b/pkg/wconfig/metaconsts.go @@ -84,6 +84,7 @@ const ( ConfigKey_PreviewShowHiddenFiles = "preview:showhiddenfiles" ConfigKey_PreviewDefaultSort = "preview:defaultsort" + ConfigKey_PreviewDirsFirst = "preview:dirsfirst" ConfigKey_TabPreset = "tab:preset" ConfigKey_TabConfirmClose = "tab:confirmclose" diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index 67118b1670..12e1706a98 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -135,6 +135,7 @@ type SettingsType struct { PreviewShowHiddenFiles *bool `json:"preview:showhiddenfiles,omitempty"` PreviewDefaultSort string `json:"preview:defaultsort,omitempty" jsonschema:"enum=name,enum=modtime"` + PreviewDirsFirst *bool `json:"preview:dirsfirst,omitempty"` TabPreset string `json:"tab:preset,omitempty"` TabConfirmClose bool `json:"tab:confirmclose,omitempty"` diff --git a/schema/settings.json b/schema/settings.json index f341a0f365..36d50ade6c 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -232,6 +232,9 @@ "modtime" ] }, + "preview:dirsfirst": { + "type": "boolean" + }, "tab:preset": { "type": "string" },