Repository navigation
preview: list folders before files in the Files widget #3575
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<FileInfo, any>[] = [ | ||
| { 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<FileInfo> { | ||
| let columnSorting = initialSorting; | ||
| const table = createTable<FileInfo>({ | ||
| 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<FileInfo>): 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 }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<SortingState>, 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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Read the persisted default for the Default Settings checkmark. If a user changes the toolbar toggle,
|
||
| 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 }) | ||
| ); | ||
| }, | ||
| }, | ||
| ], | ||
| }, | ||
| ]; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Correct the Default Settings menu path.
When users right-click the widget header, Default Settings is a disabled heading. Show Folders First appears directly below it, not inside a submenu. Change the instruction to select Show Folders First directly, or direct users to a file row for the submenu path. (raw.githubusercontent.com)
LanguageTool
[grammar] ~88-~88: Use a hyphen to join words.
Context: ...hange the default for new widgets, right click the header of the widget and use *...
(QB_NEW_EN_HYPHEN)