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
155 changes: 139 additions & 16 deletions src/client/components/SidebarArticles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,26 +59,111 @@ 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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SlidebarArticle の定義が長くなっているため、このファイルとは別のファイルで TreeNode は定義してほしいです。

また、React の Component 定義は pure function として書くという原則があるため、 addToTree, countSubtree などは不具合が起きないように Component 内から直接使えないようにしたいです

Ref: コンポーネントを純粋に保つ – React

なので、↓ のように、別ファイル (client/lib/fileTree.ts などで OK です) に切り出してください。

export type FileTreeNodeMap = { [name: string]: TreeNode }
export type type FileTreeNode = {
    name: string;
    items: ItemViewModel[];
    children: FileTreeNodeMap;
};

// SidebarArticles からはこの function を呼び出す
export const fileTreeFromItemViewModels(items: ItemViewModel[]): FileTreeNode {
  const topLevelItems: ItemViewModel[] = []
  const roots: FileTreeNodeMap = {};

  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);
    }
  });


  return {
     name: "root"
     items: topLevelItems,
     children: roots
  }
}


const renderNode = (node: TreeNode, path: string) => {
const cmp = compare[sortType];
return (
<li key={path}>
<details css={articleDetailsStyle} open>
<summary css={articleSummaryStyle}>
{node.name}
<span css={articleSectionTitleCountStyle}>
{countSubtreeItems(node)}
</span>
</summary>
<ul>
{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) => (
<li key={item.items_show_path}>
<Link css={articlesListItemStyle} to={item.items_show_path}>
<MaterialSymbol
fill={item.modified && articleState !== "Draft"}
>
note
</MaterialSymbol>
<span css={articleListItemInnerStyle}>
{item.modified && articleState !== "Draft" && "(差分あり) "}
{item.title}
</span>
</Link>
</li>
))}
</ul>
</details>
</li>
);
};

return (
<details css={articleDetailsStyle} open={isDetailsOpen}>
<summary css={articleSummaryStyle} onClick={toggleAccordion}>
{ArticleState[articleState]}
<span css={articleSectionTitleCountStyle}>{items.length}</span>
</summary>
<ul>
{items.sort(compare[sortType]).map((item) => (
<li key={item.items_show_path}>
<Link css={articlesListItemStyle} to={item.items_show_path}>
<MaterialSymbol fill={item.modified && articleState !== "Draft"}>
note
</MaterialSymbol>
<span css={articleListItemInnerStyle}>
{item.modified && articleState !== "Draft" && "(差分あり) "}
{item.title}
</span>
</Link>
</li>
))}
{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) => (
<li key={item.items_show_path}>
<Link css={articlesListItemStyle} to={item.items_show_path}>
<MaterialSymbol
fill={item.modified && articleState !== "Draft"}
>
note
</MaterialSymbol>
<span css={articleListItemInnerStyle}>
{item.modified && articleState !== "Draft" && "(差分あり) "}
{item.title}
</span>
</Link>
</li>
))}
</ul>
</details>
);
Expand All @@ -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({
Expand Down Expand Up @@ -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",

Expand Down
25 changes: 15 additions & 10 deletions src/lib/file-system-repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,17 +206,22 @@ export class FileSystemRepo {
}

private parseFilename(filename: string) {
return path.basename(filename, ".md");
return filename.replace(/\.md$/, "");
}

private getFilePath(uuid: string, remote: boolean = false) {
return path.join(this.getRootOrRemotePath(remote), this.getFilename(uuid));
}

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/"),
);
}

Expand Down Expand Up @@ -246,6 +251,8 @@ export class FileSystemRepo {
private static fileSystemOptions() {
return {
encoding: "utf8",
withFileTypes: false,
recursive: true,
} as const;
}

Expand Down Expand Up @@ -325,12 +332,10 @@ export class FileSystemRepo {
async loadItems(): Promise<QiitaItem[]> {
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;
Expand Down
1 change: 1 addition & 0 deletions src/lib/view-models/items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type ItemViewModel = {
title: string;
updated_at: string;
modified: boolean;
parent: string[];
};

export type ItemsIndexViewModel = {
Expand Down
1 change: 1 addition & 0 deletions src/server/api/items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lib/entities/qiita-item.ts 側に

import { dirname, sep } from "path"

export class QiitaItem {
   getParentDirNames() {
     return dirname(this.itemPath).split(sep)
   }
}

のようなメソッド定義を追加した上で、

Suggested change
parent: item.name.split("/").slice(0, -1),
parentDirNames: item.parentDirNames(),

とするのが良いと思います。

  • parent だと何を指しているか不明瞭なので parentDirNames など具体的な名前にしたい
  • 親ディレクトリ名の算出処理は router 側に書くのではなく、entities 側のメソッドとして持たせたい
  • Windows 等を考慮して path.sep を使ったほうが良い

という理由です。

};

if (item.id) {
Expand Down
3 changes: 2 additions & 1 deletion src/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝

persistent (default: true). Indicates whether the process should continue to run as long as files are being watched.
https://github.com/paulmillr/chokidar?tab=readme-ov-file#persistence

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
persistent: true,

persistent は元々 default で true なので、この行を足しても挙動は変わりません。

この Pull Request の本題は再帰的なディレクトリの読み込みを行えるようにすることで、それと関係ない変更は最小限にしたいです。なので、この行は削除してもらっていいでしょうか?

});
watcher.on("change", () => {
wsServer.clients.forEach((client) => {
Expand Down