diff --git a/src/client/components/SidebarArticles.tsx b/src/client/components/SidebarArticles.tsx
index eacabb44..69585e0d 100644
--- a/src/client/components/SidebarArticles.tsx
+++ b/src/client/components/SidebarArticles.tsx
@@ -59,6 +59,84 @@ export const SidebarArticles = ({ items, sortType, articleState }: Props) => {
localStorage.setItem(StorageName[articleState], isDetailsOpen.toString());
}, [isDetailsOpen]);
+ // build recursive tree from item.parent (segments array)
+ const topLevelItems: ItemViewModel[] = [];
+
+ type TreeNode = {
+ name: string;
+ items: ItemViewModel[];
+ children: { [name: string]: TreeNode };
+ };
+
+ const roots: { [name: string]: TreeNode } = {};
+
+ const addToTree = (segments: string[], item: ItemViewModel) => {
+ const rootName = segments[0];
+ if (!roots[rootName])
+ roots[rootName] = { name: rootName, items: [], children: {} };
+ let node = roots[rootName];
+ const rest = segments.slice(1);
+ if (rest.length === 0) {
+ node.items.push(item);
+ return;
+ }
+ for (const seg of rest) {
+ if (!node.children[seg])
+ node.children[seg] = { name: seg, items: [], children: {} };
+ node = node.children[seg];
+ }
+ node.items.push(item);
+ };
+
+ items.forEach((item) => {
+ if (!item.parent || item.parent.length === 0) {
+ topLevelItems.push(item);
+ } else {
+ addToTree(item.parent, item);
+ }
+ });
+
+ const countSubtreeItems = (node: TreeNode): number =>
+ node.items.length +
+ Object.values(node.children).reduce((s, c) => s + countSubtreeItems(c), 0);
+
+ const renderNode = (node: TreeNode, path: string) => {
+ const cmp = compare[sortType];
+ return (
+
+
+
+ {node.name}
+
+ {countSubtreeItems(node)}
+
+
+
+ {Object.values(node.children)
+ .sort((a, b) => a.name.localeCompare(b.name))
+ .map((child) => renderNode(child, `${path}/${child.name}`))}
+
+ {[...node.items].sort(cmp).map((item) => (
+ -
+
+
+ note
+
+
+ {item.modified && articleState !== "Draft" && "(差分あり) "}
+ {item.title}
+
+
+
+ ))}
+
+
+
+ );
+ };
+
return (
@@ -66,19 +144,26 @@ export const SidebarArticles = ({ items, sortType, articleState }: Props) => {
{items.length}
- {items.sort(compare[sortType]).map((item) => (
- -
-
-
- note
-
-
- {item.modified && articleState !== "Draft" && "(差分あり) "}
- {item.title}
-
-
-
- ))}
+ {Object.values(roots)
+ .sort((a, b) => a.name.localeCompare(b.name))
+ .map((r) => renderNode(r, r.name))}
+
+ {topLevelItems.length > 0 &&
+ [...topLevelItems].sort(compare[sortType]).map((item) => (
+ -
+
+
+ note
+
+
+ {item.modified && articleState !== "Draft" && "(差分あり) "}
+ {item.title}
+
+
+
+ ))}
);
@@ -93,6 +178,44 @@ const articleDetailsStyle = css({
"&[open] > summary::before": {
content: "'expand_more'",
},
+ // nested lists: draw vertical guide lines inside the padded area
+ "& ul": {
+ listStyle: "none",
+ margin: 0,
+ paddingLeft: getSpace(1),
+ },
+ "& ul ul": {
+ position: "relative",
+ paddingLeft: getSpace(3),
+ },
+ "& ul ul::before": {
+ content: "''",
+ position: "absolute",
+ left: getSpace(3),
+ top: 0,
+ bottom: 0,
+ width: 1,
+ backgroundColor: Colors.gray20,
+ },
+ "& ul ul > li": {
+ paddingLeft: getSpace(1.5),
+ },
+ "& ul ul ul": {
+ position: "relative",
+ paddingLeft: getSpace(4),
+ },
+ "& ul ul ul::before": {
+ content: "''",
+ position: "absolute",
+ left: getSpace(3),
+ top: 0,
+ bottom: 0,
+ width: 1,
+ backgroundColor: Colors.gray20,
+ },
+ "& ul ul ul > li": {
+ paddingLeft: getSpace(1.5),
+ },
});
const articleSummaryStyle = css({
@@ -137,9 +260,9 @@ const articlesListItemStyle = css({
fontSize: Typography.body2,
gap: getSpace(1),
lineHeight: LineHeight.bodyDense,
- padding: `${getSpace(3 / 4)}px ${getSpace(5 / 2)}px ${getSpace(
- 3 / 4,
- )}px ${getSpace(3 / 2)}px`,
+ padding: `${getSpace(3 / 4)}px ${getSpace(5 / 2)}px ${getSpace(3 / 4)}px ${getSpace(
+ 3,
+ )}px`,
whiteSpace: "nowrap",
textOverflow: "ellipsis",
diff --git a/src/lib/file-system-repo.ts b/src/lib/file-system-repo.ts
index 1fb27427..194368df 100644
--- a/src/lib/file-system-repo.ts
+++ b/src/lib/file-system-repo.ts
@@ -206,7 +206,7 @@ export class FileSystemRepo {
}
private parseFilename(filename: string) {
- return path.basename(filename, ".md");
+ return filename.replace(/\.md$/, "");
}
private getFilePath(uuid: string, remote: boolean = false) {
@@ -214,9 +214,14 @@ export class FileSystemRepo {
}
private async getItemFilenames(remote: boolean = false) {
- return await fs.readdir(
- this.getRootOrRemotePath(remote),
- FileSystemRepo.fileSystemOptions(),
+ return (
+ await fs.readdir(
+ this.getRootOrRemotePath(remote),
+ FileSystemRepo.fileSystemOptions(),
+ )
+ ).filter(
+ (itemFilename) =>
+ /\.md$/.test(itemFilename) && !itemFilename.startsWith(".remote/"),
);
}
@@ -246,6 +251,8 @@ export class FileSystemRepo {
private static fileSystemOptions() {
return {
encoding: "utf8",
+ withFileTypes: false,
+ recursive: true,
} as const;
}
@@ -325,12 +332,10 @@ export class FileSystemRepo {
async loadItems(): Promise {
const itemFilenames = await this.getItemFilenames();
- const promises = itemFilenames
- .filter((itemFilename) => /\.md$/.test(itemFilename))
- .map(async (itemFilename) => {
- const basename = this.parseFilename(itemFilename);
- return await this.loadItemByBasename(basename);
- });
+ const promises = itemFilenames.map(async (itemFilename) => {
+ const basename = this.parseFilename(itemFilename);
+ return await this.loadItemByBasename(basename);
+ });
const items = excludeNull(await Promise.all(promises));
return items;
diff --git a/src/lib/view-models/items.ts b/src/lib/view-models/items.ts
index 215d15d9..e049c854 100644
--- a/src/lib/view-models/items.ts
+++ b/src/lib/view-models/items.ts
@@ -5,6 +5,7 @@ export type ItemViewModel = {
title: string;
updated_at: string;
modified: boolean;
+ parent: string[];
};
export type ItemsIndexViewModel = {
diff --git a/src/server/api/items.ts b/src/server/api/items.ts
index d35699b6..99836bb2 100644
--- a/src/server/api/items.ts
+++ b/src/server/api/items.ts
@@ -27,6 +27,7 @@ const itemsIndex = async (req: Express.Request, res: Express.Response) => {
title: item.title,
updated_at: item.updatedAt,
modified: item.modified,
+ parent: item.name.split("/").slice(0, -1),
};
if (item.id) {
diff --git a/src/server/app.ts b/src/server/app.ts
index c1ea01e9..81f4d98e 100644
--- a/src/server/app.ts
+++ b/src/server/app.ts
@@ -66,7 +66,8 @@ export function startLocalChangeWatcher({
}) {
const wsServer = new WebSocketServer({ server });
const watcher = chokidar.watch(watchPath, {
- ignored: ["**/.remote/**"],
+ ignored: [/node_modules|\.git/, "**/.remote/**"],
+ persistent: true,
});
watcher.on("change", () => {
wsServer.clients.forEach((client) => {