diff --git a/frontend/src/charts/query-charts.ts b/frontend/src/charts/query-charts.ts index 2561ca47a..a608761f7 100644 --- a/frontend/src/charts/query-charts.ts +++ b/frontend/src/charts/query-charts.ts @@ -1,4 +1,4 @@ -import { stratify } from "../lib/tree.ts"; +import { stratifyAccounts } from "../lib/tree.ts"; import type { Inventory, QueryResultTable, @@ -23,7 +23,7 @@ export function getQueryChart( const grouped = (table.rows as [string, Inventory][]).map( ([group, inv]) => ({ group, balance: inv.value }), ); - const root = stratify( + const root = stratifyAccounts( grouped, (d) => d.group, (account, d) => ({ account, balance: d?.balance ?? {} }), diff --git a/frontend/src/lib/paths.ts b/frontend/src/lib/paths.ts index 0dea07709..ff4f4e9af 100644 --- a/frontend/src/lib/paths.ts +++ b/frontend/src/lib/paths.ts @@ -22,3 +22,26 @@ export function documentHasAccount(filename: string, account: string): boolean { const folders = filename.split(/\/|\\/).reverse().slice(1); return accountParts.every((part, index) => part === folders[index]); } + +/** + * Splits the path to dirname (including last separator) and basename. Keeps + * Windows and UNIX style path separators as they are, but handles both. + */ +export function dirnameBasename(path: string): [string, string] { + // Special case for when we only have the last remaining separator i.e. root + if (path.length < 2) { + return ["", path]; + } + // Handle both Windows and unix style path separators and a mixture of them + const lastIndexOfSlash = path.lastIndexOf("/", path.length - 2); + const lastIndexOfBackslash = path.lastIndexOf("\\", path.length - 2); + const lastIndex = + lastIndexOfSlash > lastIndexOfBackslash + ? lastIndexOfSlash + : lastIndexOfBackslash; + // This could maybe happen on Windows if the path name is something like C:\ + if (lastIndex < 0) { + return ["", path]; + } + return [path.substring(0, lastIndex + 1), path.substring(lastIndex + 1)]; +} diff --git a/frontend/src/lib/sources.ts b/frontend/src/lib/sources.ts new file mode 100644 index 000000000..f563e08f4 --- /dev/null +++ b/frontend/src/lib/sources.ts @@ -0,0 +1,56 @@ +import { dirnameBasename } from "./paths.ts"; +import { stratify, type TreeNode } from "./tree.ts"; + +export type SourceNode = TreeNode<{ name: string; path: string }>; + +export function isDirectoryNode(node: SourceNode): boolean { + return node.children.length > 0; +} + +export function buildSourcesTree(sources: Set): SourceNode { + const root = stratify( + sources, + (path) => path, + (path) => ({ name: basename(path), path }), + (path) => parent(path), + ); + // Simplify the tree by removing the nodes with only one children + return compressTree(root); +} + +function basename(path: string): string { + const [_, basename] = dirnameBasename(path); + return basename; +} + +function parent(path: string): string { + const [dirname, _] = dirnameBasename(path); + return dirname; +} + +function compressTree(parent: SourceNode): SourceNode { + if (parent.children.length === 0) { + return parent; + } + + if (parent.children.length === 1 && parent.children[0] !== undefined) { + const onlyChild: SourceNode = parent.children[0]; + // Do not compress leaf nodes (=files) + if (onlyChild.children.length === 0) { + return parent; + } + + const newName = parent.name + onlyChild.name; + return compressTree({ + name: newName, + path: onlyChild.path, + children: onlyChild.children, + }); + } else { + const newChildren: SourceNode[] = []; + for (const child of parent.children) { + newChildren.push(compressTree(child)); + } + return { name: parent.name, path: parent.path, children: newChildren }; + } +} diff --git a/frontend/src/lib/tree.ts b/frontend/src/lib/tree.ts index 480b84d40..bf3468fca 100644 --- a/frontend/src/lib/tree.ts +++ b/frontend/src/lib/tree.ts @@ -1,4 +1,4 @@ -import { parent } from "./account.ts"; +import { parent as parentAccount } from "./account.ts"; /** * A tree node. @@ -9,7 +9,30 @@ import { parent } from "./account.ts"; export type TreeNode = S & { readonly children: TreeNode[] }; /** - * Generate an account tree from an array. + * Generate an account tree from an array. The data will be sorted. + * + * This is a bit like d3-hierarchys stratify, but inserts implicit nodes that + * are missing in the hierarchy. + * + * @param data - the data (accounts) to generate the tree for. + * @param id - A getter to obtain the node name for an input datum. + * @param init - A getter for any extra properties to set on the node. + */ +export function stratifyAccounts( + data: Iterable, + id: (datum: T) => string, + init: (name: string, datum?: T) => S, +): TreeNode { + return stratify( + [...data].sort((a, b) => id(a).localeCompare(id(b))), + id, + init, + parentAccount, + ); +} + +/** + * Generate a tree from an array. The data will not be sorted. * * This is a bit like d3-hierarchys stratify, but inserts implicit nodes that * are missing in the hierarchy. @@ -17,17 +40,19 @@ export type TreeNode = S & { readonly children: TreeNode[] }; * @param data - the data to generate the tree for. * @param id - A getter to obtain the node name for an input datum. * @param init - A getter for any extra properties to set on the node. + * @param parent - A getter to obtain the parent node name. */ export function stratify( data: Iterable, id: (datum: T) => string, init: (name: string, datum?: T) => S, + parent: (name: string) => string, ): TreeNode { const root: TreeNode = { children: [], ...init("") }; const map = new Map>(); map.set("", root); - function addAccount(name: string, datum?: T): TreeNode { + function addNode(name: string, datum?: T): TreeNode { const existing = map.get(name); if (existing) { Object.assign(existing, init(name, datum)); @@ -36,15 +61,13 @@ export function stratify( const node: TreeNode = { children: [], ...init(name, datum) }; map.set(name, node); const parentName = parent(name); - const parentNode = map.get(parentName) ?? addAccount(parentName); + const parentNode = map.get(parentName) ?? addNode(parentName); parentNode.children.push(node); return node; } - [...data] - .sort((a, b) => id(a).localeCompare(id(b))) - .forEach((datum) => { - addAccount(id(datum), datum); - }); + [...data].forEach((datum) => { + addNode(id(datum), datum); + }); return root; } diff --git a/frontend/src/reports/documents/Documents.svelte b/frontend/src/reports/documents/Documents.svelte index a874f774a..95dc7b325 100644 --- a/frontend/src/reports/documents/Documents.svelte +++ b/frontend/src/reports/documents/Documents.svelte @@ -6,7 +6,7 @@ import AccountInput from "../../entry-forms/AccountInput.svelte"; import { _ } from "../../i18n.ts"; import { basename } from "../../lib/paths.ts"; - import { stratify } from "../../lib/tree.ts"; + import { stratifyAccounts } from "../../lib/tree.ts"; import ModalBase from "../../modals/ModalBase.svelte"; import { router } from "../../router.ts"; import Accounts from "./Accounts.svelte"; @@ -24,7 +24,7 @@ let grouped = $derived(group(documents, (d) => d.account)); let node = $derived( - stratify( + stratifyAccounts( grouped.entries(), ([s]) => s, (name, d) => ({ name, count: d?.[1].length ?? 0 }), diff --git a/frontend/src/reports/editor/EditorMenu.svelte b/frontend/src/reports/editor/EditorMenu.svelte index eb574bfa5..f3f72e213 100644 --- a/frontend/src/reports/editor/EditorMenu.svelte +++ b/frontend/src/reports/editor/EditorMenu.svelte @@ -8,11 +8,11 @@ import { modKey } from "../../keyboard-shortcuts.ts"; import { router } from "../../router.ts"; import { insert_entry } from "../../stores/fava_options.ts"; - import { sources } from "../../stores/options.ts"; import AppMenu from "./AppMenu.svelte"; import AppMenuItem from "./AppMenuItem.svelte"; import AppMenuSubItem from "./AppMenuSubItem.svelte"; import Key from "./Key.svelte"; + import Sources from "./Sources.svelte"; interface Props { file_path: string; @@ -39,16 +39,13 @@
- {#each $sources as source (source)} - { - goToFileAndLine(source); - }} - selected={source === file_path} - > - {source} - - {/each} + { + goToFileAndLine(source); + }} + selectedSourcePath={file_path} + > + import type { SourceNode } from "../../lib/sources.ts"; + import Sources from "./Sources.svelte"; + import { + expandedDirectories, + sourcesTree, + toggleDirectory, + } from "./stores.ts"; + + interface Props { + isRoot?: boolean; + node?: SourceNode; + sourceSelectionAction: (source: string) => void; + selectedSourcePath: string; + } + + let { + isRoot = false, + node, + sourceSelectionAction, + selectedSourcePath, + }: Props = $props(); + + // If $sourcesTree was the default argument for node, + // we would not get updates to the tree if files change. + // node is undefined only when we are adding the root from EditorMenu. + let derivedNode: SourceNode = $derived( + isRoot + ? $sourcesTree + : (node ?? { name: "error", path: "error", children: [] }), + ); + + let nodeName: string = $derived(derivedNode.name); + let nodePath: string = $derived(derivedNode.path); + let isExpanded: boolean = $derived.by(() => { + const result = $expandedDirectories.get(nodePath); + // Even though root is always expanded, treat is as being collapsed by default. + // This allows for expanding everything with one Ctrl/Meta-Click. The subsequent click would then collapse everything. + return result ?? (!isRoot && selectedSourcePath.startsWith(nodePath)); + }); + + let isDirectory: boolean = $derived(derivedNode.children.length > 0); + let selected: boolean = $derived.by(() => { + // Show where the selected file would be, if directories are collapsed + if (isDirectory && !isExpanded && !isRoot) { + return selectedSourcePath.startsWith(nodePath); + } + return selectedSourcePath === nodePath; + }); + + let action = (event: MouseEvent) => { + if (isDirectory) { + toggleDirectory(nodePath, !isExpanded, event); + } else { + sourceSelectionAction(nodePath); + } + event.stopPropagation(); + }; + + +
  • + {#if isRoot} + + {:else} +

    + {#if isDirectory} + + {/if} + +

    + {/if} + {#if isDirectory && (isExpanded || isRoot)} +
      + {#each derivedNode.children as child (child.path)} + + {/each} +
    + {/if} +
  • + + diff --git a/frontend/src/reports/editor/stores.ts b/frontend/src/reports/editor/stores.ts new file mode 100644 index 000000000..c0935abbb --- /dev/null +++ b/frontend/src/reports/editor/stores.ts @@ -0,0 +1,78 @@ +import { derived, get, writable } from "svelte/store"; + +import { dirnameBasename } from "../../lib/paths.ts"; +import { + buildSourcesTree, + isDirectoryNode, + type SourceNode, +} from "../../lib/sources.ts"; +import { sources } from "../../stores/options.ts"; + +export const sourcesTree = derived(sources, ($sources) => { + return buildSourcesTree($sources); +}); + +// The directories which have been explicitly expanded (true) or collapsed (false). +export const expandedDirectories = writable>( + new Map(), +); + +/** + * Toggle a directory. + * + * If Shift-Click, deeply expand/collapse all descendants. + * If Ctrl- or Meta-Click, expand/collapse direct children. + */ +export function toggleDirectory( + directory: string, + expand: boolean, + event: MouseEvent, +): void { + const $sourcesTree: SourceNode = get(sourcesTree); + + expandedDirectories.update(($expandedDirectories) => { + const newExpandedDirectories = new Map($expandedDirectories); + newExpandedDirectories.set(directory, expand); + if (event.shiftKey) { + const descendants = allMatching( + $sourcesTree, + (node: SourceNode) => + isDirectoryNode(node) && node.path.startsWith(directory), + ); + + for (const node of descendants) { + newExpandedDirectories.set(node.path, expand); + } + } else if (event.ctrlKey || event.metaKey) { + const directChildren = allMatching( + $sourcesTree, + (node: SourceNode) => + isDirectoryNode(node) && parent(node.path) === directory, + ); + + for (const node of directChildren) { + newExpandedDirectories.set(node.path, expand); + } + } + return newExpandedDirectories; + }); +} + +function parent(path: string): string { + const [dirname, _] = dirnameBasename(path); + return dirname; +} + +function* allMatching( + root: SourceNode, + predicate: (node: SourceNode) => boolean, +): Generator { + if (predicate(root)) { + yield root; + } + for (const child of root.children) { + for (const match of allMatching(child, predicate)) { + yield match; + } + } +} diff --git a/frontend/test/paths.test.ts b/frontend/test/paths.test.ts index a150e9274..8f4b68157 100644 --- a/frontend/test/paths.test.ts +++ b/frontend/test/paths.test.ts @@ -1,7 +1,12 @@ -import { equal } from "node:assert/strict"; +import { deepEqual, equal } from "node:assert/strict"; import { test } from "node:test"; -import { basename, documentHasAccount, ext } from "../src/lib/paths.ts"; +import { + basename, + dirnameBasename, + documentHasAccount, + ext, +} from "../src/lib/paths.ts"; test("get basename of file", () => { equal(basename("/home/Assets/Cash/document.pdf"), "document.pdf"); @@ -38,3 +43,32 @@ test("detect account of document", () => { documentHasAccount("C:\\Assets\\Test\\Cash\\document.pdf", "Assets:Cash"), ); }); + +test("get dirname and basename", () => { + deepEqual(dirnameBasename(""), ["", ""]); + deepEqual(dirnameBasename("/"), ["", "/"]); + deepEqual(dirnameBasename("/data"), ["/", "data"]); + deepEqual(dirnameBasename("/data/financials"), ["/data/", "financials"]); + deepEqual(dirnameBasename("/data/financials/main.bean"), [ + "/data/financials/", + "main.bean", + ]); + deepEqual(dirnameBasename("C:\\"), ["", "C:\\"]); + deepEqual(dirnameBasename("C:\\data"), ["C:\\", "data"]); + deepEqual(dirnameBasename("C:\\data\\financials"), [ + "C:\\data\\", + "financials", + ]); + deepEqual(dirnameBasename("C:\\data\\financials\\main.bean"), [ + "C:\\data\\financials\\", + "main.bean", + ]); + deepEqual(dirnameBasename("C:\\data/financials"), [ + "C:\\data/", + "financials", + ]); + deepEqual(dirnameBasename("C:/data\\financials"), [ + "C:/data\\", + "financials", + ]); +}); diff --git a/frontend/test/sources.test.ts b/frontend/test/sources.test.ts new file mode 100644 index 000000000..ec682f9f0 --- /dev/null +++ b/frontend/test/sources.test.ts @@ -0,0 +1,253 @@ +import { deepEqual } from "node:assert/strict"; +import { test } from "node:test"; + +import { buildSourcesTree } from "../src/lib/sources.ts"; + +test("sources: buildSourcesTree (unix path separator)", () => { + const empty = buildSourcesTree(new Set()); + deepEqual(empty, { name: "", path: "", children: [] }); + + const root = buildSourcesTree(new Set(["/main.bean"])); + deepEqual(root, { + name: "/", + path: "/", + children: [{ name: "main.bean", path: "/main.bean", children: [] }], + }); + + const one = buildSourcesTree(new Set(["/data/main.bean"])); + deepEqual(one, { + name: "/data/", + path: "/data/", + children: [{ name: "main.bean", path: "/data/main.bean", children: [] }], + }); + + const two = buildSourcesTree( + new Set(["/data/main.bean", "/data/other.bean"]), + ); + deepEqual(two, { + name: "/data/", + path: "/data/", + children: [ + { name: "main.bean", path: "/data/main.bean", children: [] }, + { name: "other.bean", path: "/data/other.bean", children: [] }, + ], + }); + + const complex = buildSourcesTree( + new Set([ + "/home/data/main.bean", + "/home/data/deep_include.bean", + "/home/data/deep/include/data.bean", + "/home/data/include.bean", + "/home/data/include/data1.bean", + "/home/data/include/data2.bean", + "/home/data/other.bean", + ]), + ); + deepEqual(complex, { + name: "/home/data/", + path: "/home/data/", + children: [ + { name: "main.bean", path: "/home/data/main.bean", children: [] }, + { + name: "deep_include.bean", + path: "/home/data/deep_include.bean", + children: [], + }, + { + name: "deep/include/", + path: "/home/data/deep/include/", + children: [ + { + name: "data.bean", + path: "/home/data/deep/include/data.bean", + children: [], + }, + ], + }, + { name: "include.bean", path: "/home/data/include.bean", children: [] }, + { + name: "include/", + path: "/home/data/include/", + children: [ + { + name: "data1.bean", + path: "/home/data/include/data1.bean", + children: [], + }, + { + name: "data2.bean", + path: "/home/data/include/data2.bean", + children: [], + }, + ], + }, + { name: "other.bean", path: "/home/data/other.bean", children: [] }, + ], + }); +}); + +test("sources: buildSourcesTree (windows path separator)", () => { + const root = buildSourcesTree(new Set(["C:\\main.bean"])); + deepEqual(root, { + name: "C:\\", + path: "C:\\", + children: [{ name: "main.bean", path: "C:\\main.bean", children: [] }], + }); + + const one = buildSourcesTree(new Set(["C:\\data\\main.bean"])); + deepEqual(one, { + name: "C:\\data\\", + path: "C:\\data\\", + children: [ + { name: "main.bean", path: "C:\\data\\main.bean", children: [] }, + ], + }); + + const two = buildSourcesTree( + new Set(["C:\\data\\main.bean", "C:\\data\\other.bean"]), + ); + deepEqual(two, { + name: "C:\\data\\", + path: "C:\\data\\", + children: [ + { name: "main.bean", path: "C:\\data\\main.bean", children: [] }, + { name: "other.bean", path: "C:\\data\\other.bean", children: [] }, + ], + }); + + const complex = buildSourcesTree( + new Set([ + "C:\\home\\data\\main.bean", + "C:\\home\\data\\deep_include.bean", + "C:\\home\\data\\deep\\include\\data.bean", + "C:\\home\\data\\include.bean", + "C:\\home\\data\\include\\data1.bean", + "C:\\home\\data\\include\\data2.bean", + "C:\\home\\data\\other.bean", + ]), + ); + deepEqual(complex, { + name: "C:\\home\\data\\", + path: "C:\\home\\data\\", + children: [ + { name: "main.bean", path: "C:\\home\\data\\main.bean", children: [] }, + { + name: "deep_include.bean", + path: "C:\\home\\data\\deep_include.bean", + children: [], + }, + { + name: "deep\\include\\", + path: "C:\\home\\data\\deep\\include\\", + children: [ + { + name: "data.bean", + path: "C:\\home\\data\\deep\\include\\data.bean", + children: [], + }, + ], + }, + { + name: "include.bean", + path: "C:\\home\\data\\include.bean", + children: [], + }, + { + name: "include\\", + path: "C:\\home\\data\\include\\", + children: [ + { + name: "data1.bean", + path: "C:\\home\\data\\include\\data1.bean", + children: [], + }, + { + name: "data2.bean", + path: "C:\\home\\data\\include\\data2.bean", + children: [], + }, + ], + }, + { name: "other.bean", path: "C:\\home\\data\\other.bean", children: [] }, + ], + }); +}); + +test("sources: buildSourcesTree (mixed path separator)", () => { + const one = buildSourcesTree(new Set(["C:\\data/main.bean"])); + deepEqual(one, { + name: "C:\\data/", + path: "C:\\data/", + children: [{ name: "main.bean", path: "C:\\data/main.bean", children: [] }], + }); + + const two = buildSourcesTree( + new Set(["C:\\data/main.bean", "C:\\data/other.bean"]), + ); + deepEqual(two, { + name: "C:\\data/", + path: "C:\\data/", + children: [ + { name: "main.bean", path: "C:\\data/main.bean", children: [] }, + { name: "other.bean", path: "C:\\data/other.bean", children: [] }, + ], + }); + + const complex = buildSourcesTree( + new Set([ + "C:\\home\\data/main.bean", + "C:\\home\\data/deep_include.bean", + "C:\\home\\data/deep\\include\\data.bean", + "C:\\home\\data/include.bean", + "C:\\home\\data/include\\data1.bean", + "C:\\home\\data/include\\data2.bean", + "C:\\home\\data/other.bean", + ]), + ); + deepEqual(complex, { + name: "C:\\home\\data/", + path: "C:\\home\\data/", + children: [ + { name: "main.bean", path: "C:\\home\\data/main.bean", children: [] }, + { + name: "deep_include.bean", + path: "C:\\home\\data/deep_include.bean", + children: [], + }, + { + name: "deep\\include\\", + path: "C:\\home\\data/deep\\include\\", + children: [ + { + name: "data.bean", + path: "C:\\home\\data/deep\\include\\data.bean", + children: [], + }, + ], + }, + { + name: "include.bean", + path: "C:\\home\\data/include.bean", + children: [], + }, + { + name: "include\\", + path: "C:\\home\\data/include\\", + children: [ + { + name: "data1.bean", + path: "C:\\home\\data/include\\data1.bean", + children: [], + }, + { + name: "data2.bean", + path: "C:\\home\\data/include\\data2.bean", + children: [], + }, + ], + }, + { name: "other.bean", path: "C:\\home\\data/other.bean", children: [] }, + ], + }); +}); diff --git a/frontend/test/tree.test.ts b/frontend/test/tree.test.ts index 60ab3cd65..eef97d01c 100644 --- a/frontend/test/tree.test.ts +++ b/frontend/test/tree.test.ts @@ -1,22 +1,22 @@ import { deepEqual } from "node:assert/strict"; import { test } from "node:test"; -import { stratify } from "../src/lib/tree.ts"; +import { stratify, stratifyAccounts } from "../src/lib/tree.ts"; -test("tree: stratify", () => { - const empty = stratify( +test("tree: stratifyAccounts", () => { + const empty = stratifyAccounts( [], () => "", () => null, ); deepEqual(empty, { children: [] }); - const emptyWithData = stratify( + const emptyWithData = stratifyAccounts( [], () => "", () => ({ test: "test" }), ); deepEqual(emptyWithData, { children: [], test: "test" }); - const tree = stratify( + const tree = stratifyAccounts( ["aName:cName", "aName", "aName:bName"], (s) => s, (name) => ({ name }), @@ -35,7 +35,7 @@ test("tree: stratify", () => { name: "", }); deepEqual( - stratify( + stratifyAccounts( ["Assets:Cash"], (s) => s, (name) => ({ name }), @@ -51,3 +51,61 @@ test("tree: stratify", () => { }, ); }); + +test("tree: stratify", () => { + const empty = stratify( + [], + () => "", + () => null, + (s) => slash_parent(s), + ); + deepEqual(empty, { children: [] }); + const emptyWithData = stratify( + [], + () => "", + () => ({ test: "test" }), + (s) => slash_parent(s), + ); + deepEqual(emptyWithData, { children: [], test: "test" }); + const tree = stratify( + ["aName/cName", "aName", "aName/bName"], + (s) => s, + (name) => ({ name }), + (s) => slash_parent(s), + ); + + deepEqual(tree, { + children: [ + { + children: [ + { children: [], name: "aName/cName" }, + { children: [], name: "aName/bName" }, + ], + name: "aName", + }, + ], + name: "", + }); + deepEqual( + stratify( + ["Assets/Cash"], + (s) => s, + (name) => ({ name }), + (s) => slash_parent(s), + ), + { + children: [ + { + children: [{ children: [], name: "Assets/Cash" }], + name: "Assets", + }, + ], + name: "", + }, + ); +}); + +function slash_parent(name: string): string { + const parent_end = name.lastIndexOf("/"); + return parent_end > 0 ? name.slice(0, parent_end) : ""; +}