Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions frontend/src/charts/query-charts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { stratify } from "../lib/tree.ts";
import { stratifyAccounts } from "../lib/tree.ts";
import type {
Inventory,
QueryResultTable,
Expand All @@ -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 ?? {} }),
Expand Down
23 changes: 23 additions & 0 deletions frontend/src/lib/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)];
}
56 changes: 56 additions & 0 deletions frontend/src/lib/sources.ts
Original file line number Diff line number Diff line change
@@ -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<string>): 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 };
}
}
41 changes: 32 additions & 9 deletions frontend/src/lib/tree.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { parent } from "./account.ts";
import { parent as parentAccount } from "./account.ts";

/**
* A tree node.
Expand All @@ -9,25 +9,50 @@ import { parent } from "./account.ts";
export type TreeNode<S> = S & { readonly children: TreeNode<S>[] };

/**
* 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<T, S = null>(
data: Iterable<T>,
id: (datum: T) => string,
init: (name: string, datum?: T) => S,
): TreeNode<S> {
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.
*
* @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<T, S = null>(
data: Iterable<T>,
id: (datum: T) => string,
init: (name: string, datum?: T) => S,
parent: (name: string) => string,
): TreeNode<S> {
const root: TreeNode<S> = { children: [], ...init("") };
const map = new Map<string, TreeNode<S>>();
map.set("", root);

function addAccount(name: string, datum?: T): TreeNode<S> {
function addNode(name: string, datum?: T): TreeNode<S> {
const existing = map.get(name);
if (existing) {
Object.assign(existing, init(name, datum));
Expand All @@ -36,15 +61,13 @@ export function stratify<T, S = null>(
const node: TreeNode<S> = { 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;
}
4 changes: 2 additions & 2 deletions frontend/src/reports/documents/Documents.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 }),
Expand Down
19 changes: 8 additions & 11 deletions frontend/src/reports/editor/EditorMenu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -39,16 +39,13 @@
<div>
<AppMenu>
<AppMenuItem name={_("File")}>
{#each $sources as source (source)}
<AppMenuSubItem
action={() => {
goToFileAndLine(source);
}}
selected={source === file_path}
>
{source}
</AppMenuSubItem>
{/each}
<Sources
isRoot={true}
sourceSelectionAction={(source: string) => {
goToFileAndLine(source);
}}
selectedSourcePath={file_path}
></Sources>
</AppMenuItem>
<AppMenuItem name={_("Edit")}>
<AppMenuSubItem
Expand Down
131 changes: 131 additions & 0 deletions frontend/src/reports/editor/Sources.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<script lang="ts">
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();
};
</script>

<li class:selected role="menuitem">
{#if isRoot}
<button
type="button"
title="Beancount data root directory
Shift-Click to expand/collapse immediate directories
Ctrl-/Cmd-/Meta-Click to expand/collapse all directories."
class="unset root"
onclick={action}>{nodeName}</button
>
{:else}
<p>
{#if isDirectory}
<button type="button" class="unset toggle" onclick={action}
>{isExpanded ? "▾" : "▸"}</button
>
{/if}
<button type="button" class="unset leaf" onclick={action}
>{nodeName}</button
>
</p>
{/if}
{#if isDirectory && (isExpanded || isRoot)}
<ul>
{#each derivedNode.children as child (child.path)}
<Sources node={child} {sourceSelectionAction} {selectedSourcePath} />
{/each}
</ul>
{/if}
</li>

<style>
ul {
padding: 0 0 0 0.5em;
margin: 0;
}

p {
position: relative;
display: flex;
padding-right: 0.5em;
margin: 0;
overflow: hidden;
border-bottom: 1px solid var(--table-border);
border-left: 1px solid var(--table-border);
}

p > * {
padding: 1px;
}

.selected {
background-color: var(--table-header-background);
}

.leaf {
flex-grow: 1;
margin-left: 1em;
}

.toggle {
position: absolute;
margin: 0 0.25rem;
color: var(--treetable-expander);
}

.root {
margin: 0 0.25rem;
font-size: 90%;
}
</style>
Loading