diff --git a/package.json b/package.json
index 0c45fdb..3186f5f 100644
--- a/package.json
+++ b/package.json
@@ -28,7 +28,7 @@
"@types/jest": "^30.0.0",
"@types/node": "^22.15.30",
"@types/react": "^19.1.13",
- "@types/react-dom": "^19.1.6",
+ "@types/react-dom": "^19.1.13",
"@types/webpack": "^5.28.5",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^7.0.0",
@@ -43,8 +43,8 @@
"lint-staged": "^15.5.1",
"npm-run-all": "^4.1.5",
"prettier": "^3.6.2",
- "react": "^19.1.1",
- "react-dom": "^19.1.0",
+ "react": "^19.1.13",
+ "react-dom": "^19.1.13",
"react-router": "^7.8.2",
"ts-jest": "^29.4.4",
"ts-loader": "^9.5.4",
diff --git a/src/client/components/SidebarArticles.tsx b/src/client/components/SidebarArticles.tsx
index eacabb4..69585e0 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 337d574..e96a3b5 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 215d15d..e049c85 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 d35699b..99836bb 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 def04db..ac41f76 100644
--- a/src/server/app.ts
+++ b/src/server/app.ts
@@ -65,7 +65,10 @@ export function startLocalChangeWatcher({
watchPath: string;
}) {
const wsServer = new WebSocketServer({ server });
- const watcher = chokidar.watch(watchPath);
+ const watcher = chokidar.watch(watchPath, {
+ ignored: /node_modules|\.git/,
+ persistent: true,
+ });
watcher.on("change", () => {
wsServer.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
diff --git a/yarn.lock b/yarn.lock
index 5dde63f..e740514 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1315,10 +1315,10 @@
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
-"@types/react-dom@^19.1.6":
- version "19.1.6"
- resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.1.6.tgz#4af629da0e9f9c0f506fc4d1caa610399c595d64"
- integrity sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==
+"@types/react-dom@^19.1.13":
+ version "19.2.3"
+ resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.2.3.tgz#c1e305d15a52a3e508d54dca770d202cb63abf2c"
+ integrity sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==
"@types/react@^19.1.13":
version "19.1.13"
@@ -5919,12 +5919,12 @@ raw-body@^3.0.0:
iconv-lite "0.6.3"
unpipe "1.0.0"
-react-dom@^19.1.0:
- version "19.1.0"
- resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.1.0.tgz#133558deca37fa1d682708df8904b25186793623"
- integrity sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==
+react-dom@^19.1.13:
+ version "19.2.0"
+ resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.0.tgz#00ed1e959c365e9a9d48f8918377465466ec3af8"
+ integrity sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==
dependencies:
- scheduler "^0.26.0"
+ scheduler "^0.27.0"
react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1"
@@ -5944,10 +5944,10 @@ react-router@^7.8.2:
cookie "^1.0.1"
set-cookie-parser "^2.6.0"
-react@^19.1.1:
- version "19.1.1"
- resolved "https://registry.yarnpkg.com/react/-/react-19.1.1.tgz#06d9149ec5e083a67f9a1e39ce97b06a03b644af"
- integrity sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==
+react@^19.1.13:
+ version "19.2.0"
+ resolved "https://registry.yarnpkg.com/react/-/react-19.2.0.tgz#d33dd1721698f4376ae57a54098cb47fc75d93a5"
+ integrity sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==
read-pkg@^3.0.0:
version "3.0.0"
@@ -6214,10 +6214,10 @@ safe-regex-test@^1.1.0:
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
-scheduler@^0.26.0:
- version "0.26.0"
- resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.26.0.tgz#4ce8a8c2a2095f13ea11bf9a445be50c555d6337"
- integrity sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==
+scheduler@^0.27.0:
+ version "0.27.0"
+ resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.27.0.tgz#0c4ef82d67d1e5c1e359e8fc76d3a87f045fe5bd"
+ integrity sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==
schema-utils@^4.3.0, schema-utils@^4.3.2:
version "4.3.2"