Ver Fonte

Merge branch 'dev/7.4.x' into imprv/173835-new-help-button

satof3 há 3 meses atrás
pai
commit
74b7126205
100 ficheiros alterados com 4512 adições e 2301 exclusões
  1. 67 6
      .serena/memories/apps-app-jotai-directory-structure.md
  2. 683 0
      .serena/memories/apps-app-page-tree-specification.md
  3. 0 186
      .serena/memories/apps-app-pagetree-performance-refactor-plan.md
  4. 2 1
      .serena/memories/page-state-hooks-useLatestRevision-degradation.md
  5. 1 1
      .serena/memories/page-transition-and-rendering-flow.md
  6. 22 1
      CHANGELOG.md
  7. 31 13
      apps/app/.eslintrc.js
  8. 3 1
      apps/app/next.config.js
  9. 4 2
      apps/app/package.json
  10. 8 22
      apps/app/public/static/locales/en_US/admin.json
  11. 8 22
      apps/app/public/static/locales/fr_FR/admin.json
  12. 8 22
      apps/app/public/static/locales/ja_JP/admin.json
  13. 8 22
      apps/app/public/static/locales/ko_KR/admin.json
  14. 8 22
      apps/app/public/static/locales/zh_CN/admin.json
  15. 7 0
      apps/app/resource/Contributor.js
  16. 5 8
      apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx
  17. 2 264
      apps/app/src/client/components/Admin/ImportData/ImportDataPageContents.jsx
  18. 0 1
      apps/app/src/client/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  19. 6 1
      apps/app/src/client/components/Admin/UserManagement.tsx
  20. 39 0
      apps/app/src/client/components/Admin/Users/UserStatisticsTable.tsx
  21. 46 21
      apps/app/src/client/components/Bookmarks/BookmarkItem.tsx
  22. 54 2
      apps/app/src/client/components/Common/Dropdown/PageItemControl.spec.tsx
  23. 35 18
      apps/app/src/client/components/Common/Dropdown/PageItemControl.tsx
  24. 0 40
      apps/app/src/client/components/Hotkeys/Subscribers/EditPage.jsx
  25. 74 0
      apps/app/src/client/components/Hotkeys/Subscribers/EditPage.tsx
  26. 0 18
      apps/app/src/client/components/ItemsTree/ItemNode.ts
  27. 0 4
      apps/app/src/client/components/ItemsTree/ItemsTree.module.scss
  28. 0 164
      apps/app/src/client/components/ItemsTree/ItemsTree.tsx
  29. 0 2
      apps/app/src/client/components/ItemsTree/index.ts
  30. 17 20
      apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx
  31. 5 23
      apps/app/src/client/components/Navbar/PageEditorModeManager.tsx
  32. 4 4
      apps/app/src/client/components/Page/DisplaySwitcher.tsx
  33. 36 40
      apps/app/src/client/components/PageControls/PageControls.tsx
  34. 40 35
      apps/app/src/client/components/PageSelectModal/PageSelectModal.tsx
  35. 25 13
      apps/app/src/client/components/PageSelectModal/TreeItemForModal.tsx
  36. 4 3
      apps/app/src/client/components/PageSideContents/PageSideContents.tsx
  37. 6 4
      apps/app/src/client/components/PageTags/TagEditModal/TagEditModal.tsx
  38. 14 66
      apps/app/src/client/components/Sidebar/PageTree/PageTreeSubstance.tsx
  39. 4 3
      apps/app/src/client/components/Sidebar/PageTreeItem/CountBadgeForPageTreeItem.tsx
  40. 108 162
      apps/app/src/client/components/Sidebar/PageTreeItem/PageTreeItem.tsx
  41. 10 116
      apps/app/src/client/components/Sidebar/PageTreeItem/use-page-item-control.tsx
  42. 1 3
      apps/app/src/client/components/Sidebar/SidebarHeaderReloadButton.tsx
  43. 0 1
      apps/app/src/client/components/TemplateModal/TemplateModal.tsx
  44. 0 18
      apps/app/src/client/components/TreeItem/ItemNode.ts
  45. 0 37
      apps/app/src/client/components/TreeItem/NewPageInput/NewPageCreateButton.tsx
  46. 0 6
      apps/app/src/client/components/TreeItem/NewPageInput/NewPageInput.module.scss
  47. 0 1
      apps/app/src/client/components/TreeItem/NewPageInput/index.ts
  48. 0 180
      apps/app/src/client/components/TreeItem/NewPageInput/use-new-page-input.tsx
  49. 0 233
      apps/app/src/client/components/TreeItem/TreeItemLayout.tsx
  50. 0 5
      apps/app/src/client/components/TreeItem/index.ts
  51. 0 38
      apps/app/src/client/components/TreeItem/interfaces/index.ts
  52. 1 1
      apps/app/src/client/interfaces/clearable.ts
  53. 1 1
      apps/app/src/client/interfaces/focusable.ts
  54. 8 9
      apps/app/src/client/interfaces/global-notification.ts
  55. 3 3
      apps/app/src/client/interfaces/handsontable-modal.ts
  56. 1 1
      apps/app/src/client/interfaces/in-app-notification-openable.ts
  57. 4 4
      apps/app/src/client/interfaces/notification.ts
  58. 11 11
      apps/app/src/client/interfaces/react-bootstrap-typeahead.ts
  59. 5 5
      apps/app/src/client/interfaces/selectable-all.ts
  60. 11 8
      apps/app/src/client/models/BootstrapGrid.js
  61. 8 4
      apps/app/src/client/models/HotkeyStroke.js
  62. 0 6
      apps/app/src/client/services/AdminAppContainer.js
  63. 0 117
      apps/app/src/client/services/AdminImportContainer.js
  64. 10 0
      apps/app/src/client/services/AdminUsersContainer.js
  65. 1 1
      apps/app/src/client/services/side-effects/page-updated.ts
  66. 38 0
      apps/app/src/client/services/use-start-editing.tsx
  67. 1 10
      apps/app/src/components/Common/PagePathNav/PagePathNav.tsx
  68. 0 0
      apps/app/src/components/Common/PagePathNav/PagePathNavLayout.module.scss
  69. 1 1
      apps/app/src/components/Common/PagePathNav/PagePathNavLayout.tsx
  70. 8 5
      apps/app/src/components/PageView/PageAlerts/OldRevisionAlert.tsx
  71. 11 50
      apps/app/src/components/PageView/PageView.tsx
  72. 20 13
      apps/app/src/components/ReactMarkdownComponents/NextLink.tsx
  73. 1 1
      apps/app/src/features/collaborative-editor/side-effects/index.ts
  74. 5 4
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementPageTreeSelection.module.scss
  75. 21 133
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementPageTreeSelection.tsx
  76. 51 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/PageTreeSelectionTree.tsx
  77. 40 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectedPagesPanel.tsx
  78. 10 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/TreeItemWithCheckbox.module.scss
  79. 62 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/TreeItemWithCheckbox.tsx
  80. 88 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/hooks/use-page-tree-selection.ts
  81. 18 18
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-listing-api-handler.spec.ts
  82. 2 2
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-listing-api-handler.ts
  83. 3 4
      apps/app/src/features/page-bulk-export/server/service/check-page-bulk-export-job-in-progress-cron.ts
  84. 659 0
      apps/app/src/features/page-tree/components/ItemsTree.spec.tsx
  85. 258 0
      apps/app/src/features/page-tree/components/ItemsTree.tsx
  86. 0 0
      apps/app/src/features/page-tree/components/SimpleItemContent.module.scss
  87. 28 14
      apps/app/src/features/page-tree/components/SimpleItemContent.tsx
  88. 7 0
      apps/app/src/features/page-tree/components/TreeItemLayout.module.scss
  89. 163 0
      apps/app/src/features/page-tree/components/TreeItemLayout.tsx
  90. 105 0
      apps/app/src/features/page-tree/components/TreeNameInput.tsx
  91. 1 0
      apps/app/src/features/page-tree/components/_tree-item-variables.scss
  92. 4 0
      apps/app/src/features/page-tree/components/index.ts
  93. 2 0
      apps/app/src/features/page-tree/constants/_inner.ts
  94. 8 0
      apps/app/src/features/page-tree/hooks/_inner/index.ts
  95. 294 0
      apps/app/src/features/page-tree/hooks/_inner/use-auto-expand-ancestors.spec.tsx
  96. 107 0
      apps/app/src/features/page-tree/hooks/_inner/use-auto-expand-ancestors.ts
  97. 81 0
      apps/app/src/features/page-tree/hooks/_inner/use-checkbox.ts
  98. 346 0
      apps/app/src/features/page-tree/hooks/_inner/use-data-loader.integration.spec.tsx
  99. 490 0
      apps/app/src/features/page-tree/hooks/_inner/use-data-loader.spec.tsx
  100. 120 0
      apps/app/src/features/page-tree/hooks/_inner/use-data-loader.ts

+ 67 - 6
.serena/memories/apps-app-jotai-directory-structure.md

@@ -13,7 +13,6 @@ states/
 │   ├── untitled-page.ts            # 無題ページ状態 ✅
 │   ├── page-abilities.ts           # ページ権限判定状態 ✅ DERIVED ATOM!
 │   ├── unsaved-warning.ts          # 未保存警告状態 ✅ JOTAI PATTERN!
-│   ├── page-tree-desc-count-map.ts # ページツリー子孫カウント ✅ JOTAI PATTERN!
 │   └── modal/                      # 個別モーダルファイル ✅
 │       ├── page-create.ts          # ページ作成モーダル ✅
 │       ├── page-delete.ts          # ページ削除モーダル ✅
@@ -45,6 +44,25 @@ states/
             └── states/             # OpenAI専用状態 ✅
                 ├── index.ts        # exports ✅
                 └── unified-merge-view.ts # UnifiedMergeView状態 ✅
+
+features/                           # Feature Directory Pattern ✅
+└── page-tree/                      # ページツリー機能 ✅ (NEW!)
+    ├── index.ts                    # メインエクスポート
+    ├── client/
+    │   ├── components/             # 汎用UIコンポーネント
+    │   │   ├── SimplifiedItemsTree.tsx
+    │   │   ├── TreeItemLayout.tsx
+    │   │   └── SimpleItemContent.tsx
+    │   ├── hooks/                  # 汎用フック
+    │   │   ├── use-data-loader.ts
+    │   │   └── use-scroll-to-selected-item.ts
+    │   ├── interfaces/             # インターフェース定義
+    │   │   └── index.ts            # TreeItemProps, TreeItemToolProps
+    │   └── states/                 # Jotai状態 ✅
+    │       ├── page-tree-update.ts # ツリー更新状態
+    │       └── page-tree-desc-count-map.ts # 子孫カウント状態
+    └── constants/
+        └── index.ts                # ROOT_PAGE_VIRTUAL_ID
 ```
 
 ## 📋 ファイル配置ルール
@@ -60,9 +78,36 @@ states/
 - **グローバル状態**: `global/` ディレクトリ
 - **通信系**: `socket-io/` ディレクトリ
 
-### 機能別専用states (`states/features/`)
-- **OpenAI機能**: `features/openai/client/states/`
-- **将来の機能**: `features/{feature-name}/client/states/`
+### 機能別専用states (`states/features/` および `features/`)
+
+**OpenAI機能**: `states/features/openai/client/states/`
+**ページツリー機能**: `features/page-tree/client/states/` ✅ (Feature Directory Pattern)
+
+### Feature Directory Pattern (新パターン) ✅
+
+`features/{feature-name}/` パターンは、特定機能に関連するコンポーネント、フック、状態、定数をすべて一箇所に集約する構造。
+
+**適用例**: `features/page-tree/`
+```
+features/page-tree/
+├── index.ts           # 全エクスポートの集約
+├── client/
+│   ├── components/    # UIコンポーネント
+│   ├── hooks/         # カスタムフック
+│   ├── interfaces/    # 型定義
+│   └── states/        # Jotai状態
+└── constants/         # 定数
+```
+
+**インポート方法**:
+```typescript
+import { 
+  SimplifiedItemsTree,
+  TreeItemLayout,
+  usePageTreeInformationUpdate,
+  ROOT_PAGE_VIRTUAL_ID 
+} from '~/features/page-tree';
+```
 
 ## 🏷️ ファイル命名規則
 
@@ -119,13 +164,29 @@ states/context.ts → _atomsForDerivedAbilities
 ## 🎯 今後の拡張指針
 
 ### 新規機能追加時
-1. **機能専用度評価**: 汎用 → `states/ui/`、専用 → `states/features/`
+1. **機能専用度評価**: 汎用 → `states/ui/`、専用 → `features/{feature-name}/client/states/`
 2. **複雑度評価**: シンプル → 単一ファイル、複雑 → ディレクトリ
 3. **依存関係確認**: 既存atomの活用可能性
 4. **命名規則遵守**: 確立された命名パターンに従う
+5. **Feature Directory Pattern検討**: 複数のコンポーネント・フック・状態が関連する場合は `features/` 配下に集約
 
 ### ディレクトリ構造維持
 - **責務単一原則**: 1ファイル = 1機能・責務
 - **依存関係最小化**: 循環参照の回避
 - **拡張性**: 将来の機能追加を考慮した構造
-- **検索性**: ファイル名から機能が推測できる命名
+- **検索性**: ファイル名から機能が推測できる命名
+
+### Feature Directory Pattern 採用基準
+以下の条件を満たす場合は `features/` 配下に配置:
+- 複数のUIコンポーネントが関連している
+- 専用のカスタムフックがある
+- 専用のJotai状態がある
+- 機能として独立性が高い
+
+**例**: `features/page-tree/` は SimplifiedItemsTree, TreeItemLayout, useDataLoader, page-tree-update.ts などが密接に関連
+
+---
+
+## 📝 最終更新日
+
+2025-11-28 (Feature Directory Pattern 追加)

+ 683 - 0
.serena/memories/apps-app-page-tree-specification.md

@@ -0,0 +1,683 @@
+# PageTree 仕様書
+
+## 概要
+
+GROWIのPageTreeは、`@headless-tree/react` と `@tanstack/react-virtual` を使用したVirtualized Tree実装です。
+5000件以上の兄弟ページでも快適に動作するよう設計されています。
+
+---
+
+## 1. アーキテクチャ
+
+### 1.1 ディレクトリ構成
+
+```
+src/features/page-tree/
+├── index.ts                                # メインエクスポート
+├── components/
+│   ├── ItemsTree.tsx                       # コアvirtualizedツリーコンポーネント
+│   ├── ItemsTree.spec.tsx                  # テスト
+│   ├── TreeItemLayout.tsx                  # 汎用ツリーアイテムレイアウト
+│   ├── TreeItemLayout.module.scss
+│   ├── SimpleItemContent.tsx               # シンプルなアイテムコンテンツ表示
+│   ├── SimpleItemContent.module.scss
+│   ├── TreeNameInput.tsx                   # リネーム/新規作成用入力コンポーネント
+│   ├── _tree-item-variables.scss           # SCSS変数
+│   └── index.ts
+├── hooks/
+│   ├── use-page-rename.tsx                 # Renameビジネスロジック
+│   ├── use-page-create.tsx                 # Createビジネスロジック
+│   ├── use-page-create.spec.tsx
+│   ├── use-page-dnd.tsx                    # Drag & Dropビジネスロジック
+│   ├── use-page-dnd.spec.ts
+│   ├── use-page-dnd.module.scss            # D&D用スタイル
+│   ├── use-placeholder-rename-effect.ts    # プレースホルダーリネームエフェクト
+│   ├── use-socket-update-desc-count.ts     # Socket.ioリアルタイム更新フック
+│   ├── index.ts
+│   └── _inner/
+│       ├── use-data-loader.ts              # データローダーフック
+│       ├── use-data-loader.spec.tsx
+│       ├── use-data-loader.integration.spec.tsx
+│       ├── use-scroll-to-selected-item.ts  # スクロール制御フック
+│       ├── use-tree-features.ts            # Feature統合フック(checkbox・DnD含む)
+│       ├── use-tree-revalidation.ts        # ツリー再検証フック
+│       ├── use-tree-item-handlers.tsx      # アイテムハンドラーフック
+│       ├── use-auto-expand-ancestors.ts    # 祖先自動展開フック
+│       ├── use-auto-expand-ancestors.spec.tsx
+│       ├── use-expand-parent-on-create.ts  # 作成時親展開フック
+│       ├── use-checkbox.ts                 # チェックボックス状態フック
+│       └── index.ts
+├── interfaces/
+│   └── index.ts                            # TreeItemProps, TreeItemToolProps
+├── states/
+│   ├── page-tree-update.ts                 # ツリー更新状態(Jotai)
+│   ├── page-tree-desc-count-map.ts         # 子孫カウント状態(Jotai)
+│   ├── index.ts
+│   └── _inner/
+│       ├── page-tree-create.ts             # 作成中状態(Jotai)
+│       ├── page-tree-create.spec.tsx
+│       └── tree-rebuild.ts                 # ツリー再構築状態
+├── services/
+│   └── page-tree-children.ts               # 子ページ取得サービス
+└── constants/
+    └── _inner.ts                           # ROOT_PAGE_VIRTUAL_ID
+```
+
+### 1.2 Sidebar専用コンポーネント(移動しなかったファイル)
+
+以下は `components/Sidebar/PageTreeItem/` に残留:
+
+- `PageTreeItem.tsx` - Sidebar専用の実装
+- `CountBadgeForPageTreeItem.tsx` - PageTree専用バッジ
+- `use-page-item-control.tsx` - コンテキストメニュー制御
+
+---
+
+## 2. 主要コンポーネント
+
+### 2.1 ItemsTree
+
+**ファイル**: `features/page-tree/components/ItemsTree.tsx`
+
+Virtualizedツリーのコアコンポーネント。`@headless-tree/react` と `@tanstack/react-virtual` を統合。
+
+#### Props
+
+```typescript
+interface ItemsTreeProps {
+  // 表示対象のターゲットパスまたはID
+  targetPathOrId: string | null;
+  // WIPページを表示するか
+  isWipPageShown?: boolean;
+  // 仮想スクロール用の親要素
+  scrollerElem: HTMLElement | null;
+  // カスタムTreeItemコンポーネント
+  CustomTreeItem?: React.ComponentType<TreeItemProps<IPageForTreeItem>>;
+  // チェックボックス機能
+  enableCheckboxes?: boolean;
+  initialCheckedItems?: string[];
+  onCheckedItemsChange?: (checkedItems: IPageForTreeItem[]) => void;
+}
+```
+
+#### 使用している @headless-tree/core Features
+
+- `asyncDataLoaderFeature` - 非同期データローディング
+- `selectionFeature` - 選択機能
+- `renamingFeature` - リネーム機能
+- `hotkeysCoreFeature` - キーボードショートカット
+- `checkboxesFeature` - チェックボックス(オプション)
+- `dragAndDropFeature` - ドラッグ&ドロップ(オプション)
+
+#### 重要な実装詳細
+
+1. **データローダー**: `use-data-loader.ts` で既存API(`/page-listing/root`, `/page-listing/children`)を活用
+2. **Virtualization**: `@tanstack/react-virtual` の `useVirtualizer` を使用、`overscan: 5` で最適化
+3. **初期スクロール**: `scrollToIndex` で選択アイテムまでスクロール
+
+### 2.2 TreeItemLayout
+
+**ファイル**: `features/page-tree/components/TreeItemLayout.tsx`
+
+汎用的なツリーアイテムレイアウト。展開/折りたたみ、アイコン、カスタムコンポーネントを配置。
+
+#### Props
+
+```typescript
+interface TreeItemLayoutProps {
+  page: IPageForTreeItem;
+  level: number;
+  isOpen: boolean;
+  isSelected: boolean;
+  onToggle?: () => void;
+  onClick?: () => void;
+  // カスタムコンポーネント
+  customEndComponents?: React.ReactNode[];
+  customHoveredEndComponents?: React.ReactNode[];
+  customAlternativeComponents?: React.ReactNode[];
+  showAlternativeContent?: boolean;
+}
+```
+
+#### 自動展開ロジック
+
+```typescript
+useEffect(() => {
+  if (isExpanded) return;
+  const isPathToTarget = page.path != null
+    && targetPath.startsWith(addTrailingSlash(page.path))
+    && targetPath !== page.path;
+  if (isPathToTarget) onToggle?.();
+}, [targetPath, page.path, isExpanded, onToggle]);
+```
+
+### 2.3 PageTreeItem
+
+**ファイル**: `components/Sidebar/PageTreeItem/PageTreeItem.tsx`
+
+Sidebar用のツリーアイテム実装。TreeItemLayoutを使用し、Rename/Create/Control機能を統合。
+
+#### 機能
+
+- WIPページフィルター
+- descendantCountバッジ
+- hover時の操作ボタン(duplicate/delete/rename/create)
+- リネームモード表示
+- 新規作成入力表示(子として)
+
+---
+
+## 3. 機能実装
+
+### 3.1 Rename(ページ名変更)
+
+**実装ファイル**:
+- `features/page-tree/hooks/use-page-rename.tsx`
+- `features/page-tree/components/TreeNameInput.tsx`
+
+#### 使用方法
+
+```typescript
+const { rename, isRenaming, RenameAlternativeComponent } = usePageRename(item);
+
+// TreeItemLayoutに渡す
+<TreeItemLayout
+  showAlternativeContent={isRenaming(item)}
+  customAlternativeComponents={[RenameAlternativeComponent]}
+/>
+```
+
+#### 操作方法
+
+- **開始**: F2キー or コンテキストメニュー
+- **確定**: Enter
+- **キャンセル**: Escape
+
+### 3.2 Create(ページ新規作成)
+
+**実装ファイル**:
+- `features/page-tree/hooks/use-page-create.tsx`
+- `features/page-tree/components/TreeNameInput.tsx`
+- `features/page-tree/states/_inner/page-tree-create.ts`
+
+#### 状態管理(Jotai)
+
+```typescript
+// page-tree-create.ts
+creatingParentIdAtom: 作成中の親ノードID
+useCreatingParentId(): 現在の作成中親ID取得
+useIsCreatingChild(parentId): 特定アイテムが作成中か判定
+usePageTreeCreateActions(): startCreating, cancelCreating
+```
+
+#### 使用方法
+
+```typescript
+const { isCreatingChild, CreateInputComponent, startCreating } = usePageCreate(item);
+
+// PageTreeItemで使用
+{isCreatingChild() && <CreateInputComponent />}
+```
+
+#### 操作方法
+
+- **開始**: コンテキストメニューから「作成」を選択
+- **確定**: Enter → POST /page API → 新規ページに遷移
+- **キャンセル**: Escape or ブラー
+
+### 3.3 Drag and Drop(ページ移動)
+
+**実装ファイル**:
+- `features/page-tree/hooks/use-page-dnd.tsx`
+- `features/page-tree/hooks/use-page-dnd.module.scss`
+- `features/page-tree/hooks/_inner/use-tree-features.ts`
+
+#### 機能概要
+
+ページをドラッグ&ドロップして別のページの子として移動する機能。複数選択D&Dにも対応。
+
+#### 使用方法
+
+```typescript
+<ItemsTree
+  enableDragAndDrop={true}
+  // ...他のprops
+/>
+```
+
+#### 主要コンポーネント
+
+- `usePageDnd(isEnabled)`: D&Dロジックを提供するフック(`UsePageDndProperties`を返す)
+  - `canDrag`: ドラッグ可否判定
+  - `canDrop`: ドロップ可否判定
+  - `onDrop`: ドロップ時の処理(APIコール、ツリー更新)
+  - `renderDragLine`: ドラッグライン描画(treeインスタンスを引数に取る)
+
+**統合方法**:
+- `useTreeFeatures`が内部で`usePageDnd`を呼び出し、`dndProperties`として返す
+- ItemsTree側で`dndProperties.renderDragLine(tree)`を呼び出してドラッグライン表示
+
+#### バリデーションロジック
+
+**canDrag チェック項目**:
+1. 祖先-子孫関係チェック: 選択されたアイテム間に祖先-子孫関係がある場合は禁止
+2. 保護ページチェック: `pagePathUtils.isUsersProtectedPages(path)`が`true`の場合は禁止
+
+**canDrop チェック項目**:
+1. ユーザートップページチェック: `pagePathUtils.isUsersTopPage(targetPath)`が`true`の場合は禁止
+2. 移動可否チェック: `pagePathUtils.canMoveByPath(fromPath, newPath)`で検証
+
+#### エラーハンドリング
+
+- `operation__blocked`エラー: 「このページは現在移動できません」トースト表示
+- その他のエラー: 「ページの移動に失敗しました」トースト表示
+
+#### ドロップ処理の流れ
+
+1. 移動APIコール: `/pages/rename`エンドポイントで各ページを新しいパスに移動
+2. SWRキャッシュ更新: `mutatePageTree()`でページツリーデータを再取得
+3. headless-tree更新: `notifyUpdateItems()`で親ノードの子リストを無効化
+4. ターゲット更新: `targetItem.invalidateItemData()`でdescendantCountを再取得
+5. 自動展開: `targetItem.expand()`でドロップ先を展開
+
+#### 制限事項
+
+- 並び替え(Reorder)は無効(子として追加のみ)
+- キーボードD&Dは非対応
+
+### 3.4 リアルタイム更新(Socket.io統合)
+
+**実装ファイル**:
+- `features/page-tree/hooks/use-socket-update-desc-count.ts`
+- `features/page-tree/states/page-tree-desc-count-map.ts`
+- `features/page-tree/states/page-tree-update.ts`
+
+#### 設計方針
+
+**descendantCountバッジの更新** と **ツリー構造の更新** は別々の関心事として分離:
+
+| 更新タイプ | トリガー | 動作 | 対象 |
+|-----------|---------|------|------|
+| バッジ更新 | Socket.io `UpdateDescCount` | 数字のみ更新(軽量) | 全祖先 |
+| ツリー構造更新 | リロードボタン / 自分の操作後 | 子リスト再取得(重い) | 操作した本人のみ |
+
+**この分離の理由:**
+- 大規模環境で多くのユーザーが同時に操作する場合、全員のツリーが頻繁に再構築されるとパフォーマンス問題が発生
+- バッジ(数字)の更新は軽量なので全員にリアルタイム反映してもOK
+- ツリー構造の変更は操作した本人のウィンドウのみで即時反映し、他ユーザーはリロードボタンで対応
+
+#### 使用方法
+
+`ItemsTree`コンポーネント内で自動的に有効化されます。
+
+```typescript
+// ItemsTree.tsx内で呼び出し
+useSocketUpdateDescCount();
+```
+
+#### 受信イベント
+
+- `UpdateDescCount`: ページの子孫カウント(descendantCount)の更新
+  - サーバーからページ作成/削除/移動時に発行される
+  - 受信データ(Record形式)をMap形式に変換してJotai stateに保存
+  - **バッジ表示のみ更新、ツリー構造は更新しない**
+
+#### 実装詳細
+
+```typescript
+export const useSocketUpdateDescCount = (): void => {
+  const socket = useGlobalSocket();
+  const { update: updatePtDescCountMap } = usePageTreeDescCountMapAction();
+
+  useEffect(() => {
+    if (socket == null) return;
+
+    const handler = (data: UpdateDescCountRawData) => {
+      // バッジの数字のみ更新(ツリー構造は更新しない)
+      const newData: UpdateDescCountData = new Map(Object.entries(data));
+      updatePtDescCountMap(newData);
+    };
+
+    socket.on(SocketEventName.UpdateDescCount, handler);
+    return () => socket.off(SocketEventName.UpdateDescCount, handler);
+  }, [socket, updatePtDescCountMap]);
+};
+```
+
+#### ツリー構造の更新
+
+ツリー構造(子リスト)の更新は以下のタイミングで行われる:
+
+1. **リロードボタン**: `notifyUpdateAllTrees()` を呼び出し、全ツリーを再取得
+2. **自分の操作後**: 
+   - Create/Delete/Move操作の完了コールバックで `notifyUpdateItems([parentId])` を呼び出し
+   - 操作した親ノードの子リストのみ再取得
+
+```typescript
+// リロードボタンの例
+const { notifyUpdateAllTrees } = usePageTreeInformationUpdate();
+const handleReload = () => notifyUpdateAllTrees();
+
+// 操作完了後の例(Create, Delete, Move)
+const { notifyUpdateItems } = usePageTreeInformationUpdate();
+const handleOperationComplete = (parentId: string) => notifyUpdateItems([parentId]);
+```
+
+#### 関連状態
+
+- `page-tree-desc-count-map.ts`: 子孫カウントを管理するJotai atom
+  - `usePageTreeDescCountMap()`: カウント取得(バッジ表示用)
+  - `usePageTreeDescCountMapAction()`: カウント更新(Socket.ioから)
+
+- `page-tree-update.ts`: ツリー更新を管理するJotai atom
+  - `generationAtom`: 更新世代番号
+  - `lastUpdatedItemIdsAtom`: 更新対象アイテムID(nullは全体更新)
+  - `usePageTreeInformationUpdate()`: 更新通知(notifyUpdateItems, notifyUpdateAllTrees)
+  - `usePageTreeRevalidationEffect()`: 更新検知と再取得実行
+
+### 3.5 Checkboxes(AI Assistant用)
+
+**使用箇所**: `AiAssistantManagementPageTreeSelection.tsx`
+
+ItemsTreeのcheckboxesオプションを使用。
+
+#### Props
+
+```typescript
+<ItemsTree
+  enableCheckboxes={true}
+  initialCheckedItems={['page-id-1', 'page-id-2']}
+  onCheckedItemsChange={(checkedItems) => {
+    // チェック変更時の処理
+    // ページパスに `/*` を付加して保存
+  }}
+/>
+```
+
+#### 実装詳細
+
+**フック構成**:
+- `useTreeFeatures`: feature設定とチェックボックス・D&D機能を統合管理
+- `useCheckbox`: チェックボックス状態管理(`checkedItemIds`, `setCheckedItems`, `createNotifyEffect`)
+- `createNotifyEffect`: 親コンポーネントへの変更通知用ヘルパー関数を提供
+
+**循環依存の回避**:
+- `useTreeFeatures`はtreeインスタンスに依存しない
+- `createNotifyEffect`がtreeインスタンスとコールバックを受け取り、useEffectのコールバック関数を返す
+- ItemsTree側で`useEffect(createNotifyEffect(tree, onCheckedItemsChange), [createNotifyEffect, tree])`を呼び出す
+
+**設定**:
+- `checkboxesFeature` を条件付きで追加
+- `propagateCheckedState: false` で子への伝播を無効化
+- `canCheckFolders: true` でフォルダもチェック可能
+
+---
+
+## 4. バックエンドAPI
+
+### 4.1 使用エンドポイント
+
+```
+GET /page-listing/root
+→ ルートページ "/" のデータ
+
+GET /page-listing/children?id={pageId}
+→ 指定ページの直下の子のみ
+
+GET /page-listing/item?id={pageId}
+→ 単一ページデータ(新規追加)
+```
+
+### 4.2 IPageForTreeItem インターフェース
+
+```typescript
+interface IPageForTreeItem {
+  _id: string;
+  path: string;
+  parent?: string;
+  descendantCount: number;
+  revision?: string;
+  grant: PageGrant;
+  isEmpty: boolean;
+  wip: boolean;
+  processData?: IPageOperationProcessData;
+}
+```
+
+---
+
+## 5. @headless-tree/react 基礎知識
+
+### 5.1 データ構造
+
+- **IDベースの参照**: ツリーアイテムは文字列IDで識別
+- **フラット構造を推奨**: dataLoaderで親子関係を定義
+- **ジェネリック型対応**: `useTree<IPageForTreeItem>` でカスタム型を指定
+
+### 5.2 非同期データローダー
+
+```typescript
+const tree = useTree<IPageForTreeItem>({
+  rootItemId: "root",
+  dataLoader: {
+    getItem: async (itemId) => await api.fetchItem(itemId),
+    getChildren: async (itemId) => await api.fetchChildren(itemId),
+  },
+  createLoadingItemData: () => ({ /* loading state */ }),
+  features: [asyncDataLoaderFeature],
+});
+```
+
+#### キャッシュの無効化
+
+```typescript
+const item = tree.getItemInstance("item1");
+item.invalidateItemData();      // アイテムデータの再取得
+item.invalidateChildrenIds();   // 子IDリストの再取得
+```
+
+### 5.3 Virtualization統合
+
+```typescript
+const items = tree.getItems(); // フラット化されたアイテムリスト
+
+const virtualizer = useVirtualizer({
+  count: items.length,
+  getScrollElement: () => scrollElementRef.current,
+  estimateSize: () => 32,
+  overscan: 5,
+});
+```
+
+### 5.4 主要API
+
+#### Tree インスタンス
+- `tree.getItems()`: フラット化されたツリーアイテムのリスト
+- `tree.getItemInstance(id)`: IDからアイテムインスタンスを取得
+- `tree.getContainerProps()`: ツリーコンテナのprops(ホットキー有効化に必須)
+- `tree.rebuildTree()`: ツリー構造を再構築
+
+#### Item インスタンス
+- `item.getProps()`: アイテム要素のprops
+- `item.getId()`: アイテムID
+- `item.getItemData()`: カスタムペイロード(IPageForTreeItem)
+- `item.getItemMeta()`: メタデータ(level, indexなど)
+- `item.isFolder()`: フォルダかどうか
+- `item.isExpanded()`: 展開されているか
+- `item.expand()` / `item.collapse()`: 展開/折りたたみ
+- `item.startRenaming()`: リネームモード開始
+- `item.isRenaming()`: リネーム中か判定
+
+---
+
+## 6. パフォーマンス最適化
+
+### 6.1 headless-tree のキャッシュ無効化と再取得
+
+#### 重要な知見
+
+`@headless-tree/core` の `asyncDataLoaderFeature` は内部キャッシュを持ち、`invalidateChildrenIds()` メソッドでキャッシュを無効化できます。
+
+**invalidateChildrenIds(optimistic?: boolean) の動作:**
+
+```typescript
+// 内部実装(feature.ts より)
+invalidateChildrenIds: async ({ tree, itemId }, optimistic) => {
+  if (!optimistic) {
+    delete getDataRef(tree).current.childrenIds?.[itemId];  // キャッシュ削除
+  }
+  await loadChildrenIds(tree, itemId);  // データ再取得
+  // loadChildrenIds 内で自動的に tree.rebuildTree() が呼ばれる
+};
+```
+
+**optimistic パラメータの影響:**
+
+| パラメータ | 動作 | 用途 |
+|-----------|------|------|
+| `false` (デフォルト) | ローディング状態を更新、再レンダリングをトリガー | 最後の呼び出しに使用 |
+| `true` | ローディング状態を更新しない、古いデータを表示し続ける | バッチ処理の途中に使用 |
+
+**パフォーマンス最適化パターン:**
+
+```typescript
+// ❌ 非効率: 全アイテムに optimistic=false
+items.forEach(item => item.invalidateChildrenIds(false));
+// → 各呼び出しで rebuildTree() が実行され、N回の再構築が発生
+
+// ✅ 効率的: 展開済みアイテムのみ対象、最後だけ optimistic=false
+const expandedItems = tree.getItems().filter(item => item.isExpanded());
+expandedItems.forEach(item => item.invalidateChildrenIds(true));  // 楽観的
+rootItem.invalidateChildrenIds(false);  // 最後に1回だけ再構築
+```
+
+**実際の実装 (page-tree-update.ts):**
+
+```typescript
+useEffect(() => {
+  if (globalGeneration <= generation) return;
+
+  const shouldUpdateAll = globalLastUpdatedItemIds == null;
+
+  if (shouldUpdateAll) {
+    // pendingリクエストキャッシュをクリア
+    invalidatePageTreeChildren();
+
+    // 展開済みアイテムのみ楽観的に無効化(rebuildTree回避)
+    const expandedItems = tree.getItems().filter(item => item.isExpanded());
+    expandedItems.forEach(item => item.invalidateChildrenIds(true));
+
+    // ルートのみ optimistic=false で再構築トリガー
+    getItemInstance(ROOT_PAGE_VIRTUAL_ID)?.invalidateChildrenIds(false);
+  } else {
+    // 部分更新: 指定アイテムのみ
+    invalidatePageTreeChildren(globalLastUpdatedItemIds);
+    globalLastUpdatedItemIds.forEach(itemId => {
+      getItemInstance(itemId)?.invalidateChildrenIds(false);
+    });
+  }
+
+  onRevalidatedRef.current?.();
+}, [globalGeneration, generation, getItemInstance, globalLastUpdatedItemIds, tree]);
+```
+
+#### 注意事項
+
+1. **invalidateChildrenIds は async 関数** - Promise を返すが、await しなくても動作する
+2. **loadChildrenIds 完了後に自動で rebuildTree()** - 明示的な呼び出し不要
+3. **optimistic=true でもデータは再取得される** - ただしローディングUIは表示されない
+4. **tree.getItems() は表示中のアイテムのみ** - 折りたたまれた子は含まれない
+
+### 6.2 Virtualization
+
+- **100k+アイテムでテスト済み**
+- `overscan: 5` で表示範囲外の先読み
+- `estimateSize: 32` でアイテム高さを推定
+
+### 6.3 非同期データローダーのキャッシング
+
+- asyncDataLoaderFeatureが自動キャッシング
+- 展開済みアイテムは再取得なし
+- `invalidateChildrenIds()` で明示的に無効化可能
+
+### 6.4 ツリー更新
+
+```typescript
+// Jotai atomでツリー更新を通知
+const { notifyUpdateItems } = usePageTreeInformationUpdate();
+notifyUpdateItems(updatedPages);
+
+// SWRでページデータを再取得
+const { mutate: mutatePageTree } = useSWRxPageTree();
+await mutatePageTree();
+```
+
+---
+
+## 7. 実装済み機能
+
+- ✅ Virtualizedツリー表示
+- ✅ 展開/折りたたみ
+- ✅ ページ遷移(クリック)
+- ✅ 選択状態表示
+- ✅ WIPページフィルター
+- ✅ descendantCountバッジ
+- ✅ hover時の操作ボタン
+- ✅ 選択ページまでの自動展開
+- ✅ 選択ページへの初期スクロール
+- ✅ Rename(F2、コンテキストメニュー)
+- ✅ Create(コンテキストメニュー)
+- ✅ Duplicate(hover時ボタン)
+- ✅ Delete(hover時ボタン)
+- ✅ Checkboxes(AI Assistant用)
+- ✅ Drag and Drop(ページ移動)
+- ✅ リアルタイム更新(Socket.io統合)
+
+---
+
+## 8. 未実装機能
+
+なし(全機能実装済み)
+
+---
+
+## 9. 参考リンク
+
+- @headless-tree/react 公式ドキュメント: https://headless-tree.lukasbach.com/
+- GitHub: https://github.com/lukasbach/headless-tree
+- @tanstack/react-virtual: https://tanstack.com/virtual/latest
+
+---
+
+## 10. 改修時の注意点
+
+### 10.1 ホットキーサポート
+
+`hotkeysCoreFeature` と `getContainerProps()` の組み合わせが必須。
+`getContainerProps()` がないとホットキーが動作しない。
+
+### 10.2 ツリー更新の通知
+
+操作完了後は以下を呼び出す:
+1. `mutatePageTree()` - SWRでデータ再取得
+2. `notifyUpdateItems()` - Jotai atomで更新通知
+
+### 10.3 旧実装について
+
+以下のファイルはTypeScriptエラーあり(許容):
+- `ItemsTree.tsx` - 旧実装
+- `PageTreeItem.tsx` - 旧Sidebar用
+- `TreeItemForModal.tsx` - 旧Modal用
+
+---
+
+## 更新履歴
+
+- 2025-11-10: 初版作成(Virtualization計画)
+- 2025-11-28: Rename/Create実装完了、ディレクトリ再編成
+- 2025-12-05: 仕様書として統合
+- 2025-12-08: Drag and Drop実装完了、ディレクトリ構成更新
+- 2025-12-08: リアルタイム更新(Socket.io統合)実装完了
+- 2025-12-08: headless-tree キャッシュ無効化の知見を追加(invalidateChildrenIds の optimistic パラメータ)
+- 2025-12-08: Socket.io更新の設計方針を明確化(バッジ更新とツリー構造更新の分離)
+- 2025-12-09: useTreeFeaturesリファクタリング完了(checkboxとDnD機能を統合、循環依存を回避)

+ 0 - 186
.serena/memories/apps-app-pagetree-performance-refactor-plan.md

@@ -1,186 +0,0 @@
-# PageTree パフォーマンス改善リファクタ計画 - 現実的戦略
-
-## 🎯 目標
-現在のパフォーマンス問題を解決:
-- **問題**: 5000件の兄弟ページで初期レンダリングが重い
-- **目標**: 表示速度を10-20倍改善、UX維持
-
-## ✅ 戦略2: API軽量化 - **完了済み**
-
-### 実装済み内容
-- **ファイル**: `apps/app/src/server/service/page-listing/page-listing.ts:77`
-- **変更内容**: `.select('_id path parent descendantCount grant isEmpty createdAt updatedAt wip')` を追加
-- **型定義**: `apps/app/src/interfaces/page.ts` の `IPageForTreeItem` 型も対応済み
-- **追加改善**: 計画にはなかった `wip` フィールドも最適化対象に含める
-
-### 実現できた効果
-- **データサイズ**: 推定 500バイト → 約100バイト(5倍軽量化)
-- **ネットワーク転送**: 5000ページ時 2.5MB → 500KB程度に削減
-- **状況**: **実装完了・効果発現中**
-
----
-
-## 🚀 戦略1: 既存アーキテクチャ活用 + headless-tree部分導入 - **現実的戦略**
-
-### 前回のreact-window失敗原因
-1. **動的itemCount**: ツリー展開時にアイテム数が変化→react-windowの前提と衝突
-2. **非同期ローディング**: APIレスポンス待ちでフラット化不可
-3. **複雑な状態管理**: SWRとreact-windowの状態同期が困難
-
-### 現実的制約の認識
-**ItemsTree/TreeItemLayoutは廃止困難**:
-- **CustomTreeItemの出し分け**: `PageTreeItem` vs `TreeItemForModal`  
-- **共通副作用処理**: rename/duplicate/delete時のmutation処理
-- **多箇所からの利用**: PageTree, PageSelectModal, AiAssistant等
-
-## 📋 修正された実装戦略: **ハイブリッドアプローチ**
-
-### **核心アプローチ**: ItemsTreeを**dataProvider**として活用
-
-**既存の責務は保持しつつ、内部実装のみheadless-tree化**:
-
-1. **ItemsTree**: UIロジック・副作用処理はそのまま
-2. **TreeItemLayout**: 個別アイテムレンダリングはそのまま  
-3. **データ管理**: 内部でheadless-treeを使用(SWR → headless-tree)
-4. **Virtualization**: ItemsTree内部にreact-virtualを導入
-
-### **実装計画: 段階的移行**
-
-#### **Phase 1: データ層のheadless-tree化**
-
-**ファイル**: `ItemsTree.tsx`
-```typescript
-// Before: 複雑なSWR + 子コンポーネント管理
-const tree = useTree<IPageForTreeItem>({
-  rootItemId: initialItemNode.page._id,
-  dataLoader: {
-    getItem: async (itemId) => {
-      const response = await apiv3Get('/page-listing/item', { id: itemId });
-      return response.data;
-    },
-    getChildren: async (itemId) => {
-      const response = await apiv3Get('/page-listing/children', { id: itemId });
-      return response.data.children.map(child => child._id);
-    },
-  },
-  features: [asyncDataLoaderFeature],
-});
-
-// 既存のCustomTreeItemに渡すためのアダプター
-const adaptedNodes = tree.getItems().map(item => 
-  new ItemNode(item.getItemData())
-);
-
-return (
-  <ul className={`${moduleClass} list-group`}>
-    {adaptedNodes.map(node => (
-      <CustomTreeItem
-        key={node.page._id}
-        itemNode={node}
-        // ... 既存のpropsをそのまま渡す
-        onRenamed={onRenamed}
-        onClickDuplicateMenuItem={onClickDuplicateMenuItem}
-        onClickDeleteMenuItem={onClickDeleteMenuItem}
-      />
-    ))}
-  </ul>
-);
-```
-
-#### **Phase 2: Virtualization導入**
-
-**ファイル**: `ItemsTree.tsx` (Phase1をベースに拡張)
-```typescript
-const virtualizer = useVirtualizer({
-  count: adaptedNodes.length,
-  getScrollElement: () => containerRef.current,
-  estimateSize: () => 40,
-});
-
-return (
-  <div ref={containerRef} className="tree-container">
-    <div style={{ height: virtualizer.getTotalSize() }}>
-      {virtualizer.getVirtualItems().map(virtualItem => {
-        const node = adaptedNodes[virtualItem.index];
-        return (
-          <div
-            key={node.page._id}
-            style={{
-              position: 'absolute',
-              top: virtualItem.start,
-              height: virtualItem.size,
-              width: '100%',
-            }}
-          >
-            <CustomTreeItem
-              itemNode={node}
-              // ... 既存props
-            />
-          </div>
-        );
-      })}
-    </div>
-  </div>
-);
-```
-
-#### **Phase 3 (将来): 完全なheadless-tree移行**
-
-最終的にはdrag&drop、selection等のUI機能もheadless-treeに移行可能ですが、**今回のスコープ外**。
-
-## 📁 現実的なファイル変更まとめ
-
-| アクション | ファイル | 内容 | スコープ |
-|---------|---------|------|------|
-| ✅ **完了** | **apps/app/src/server/service/page-listing/page-listing.ts** | selectクエリ追加 | API軽量化 |
-| ✅ **完了** | **apps/app/src/interfaces/page.ts** | IPageForTreeItem型定義 | API軽量化 |
-| 🔄 **修正** | **src/client/components/ItemsTree/ItemsTree.tsx** | headless-tree + virtualization導入 | **今回の核心** |
-| 🆕 **新規** | **src/client/components/ItemsTree/usePageTreeDataLoader.ts** | データローダー分離 | 保守性向上 |
-| ⚠️ **保持** | **src/client/components/TreeItem/TreeItemLayout.tsx** | 既存のまま(後方互換) | 既存責務保持 |
-| ⚠️ **部分削除** | **src/stores/page-listing.tsx** | useSWRxPageChildren削除 | 重複排除 |
-
-**新規ファイル**: 1個(データローダー分離のみ)  
-**変更ファイル**: 2個(ItemsTree改修 + store整理)  
-**削除ファイル**: 0個(既存アーキテクチャ尊重)
-
----
-
-## 🎯 実装優先順位
-
-**✅ Phase 1**: API軽量化(低リスク・即効性) - **完了**
-
-**📋 Phase 2-A**: ItemsTree内部のheadless-tree化
-- **工数**: 2-3日
-- **リスク**: 低(外部IF変更なし)
-- **効果**: 非同期ローディング最適化、キャッシュ改善
-
-**📋 Phase 2-B**: Virtualization導入  
-- **工数**: 2-3日
-- **リスク**: 低(内部実装のみ)
-- **効果**: レンダリング性能10-20倍改善
-
-**現在の効果**: API軽量化により 5倍のデータ転送量削減済み  
-**Phase 2完了時の予想効果**: 初期表示速度 20-50倍改善
-
----
-
-## 🏗️ 実装方針: **既存アーキテクチャ尊重**
-
-**基本方針**:
-- **既存のCustomTreeItem責務**は保持(rename/duplicate/delete等)
-- **データ管理層のみ**をheadless-tree化  
-- **外部インターフェース**は変更せず、内部最適化に集中
-- **段階的移行**で低リスク実装
-
-**今回のスコープ**:
-- ✅ 非同期データローディング最適化
-- ✅ Virtualizationによる大量要素対応  
-- ❌ drag&drop/selection(将来フェーズ)
-- ❌ 既存アーキテクチャの破壊的変更
-
----
-
-## 技術的参考資料
-- **headless-tree**: https://headless-tree.lukasbach.com/ (データ管理層のみ利用)
-- **react-virtual**: @tanstack/react-virtualを使用  
-- **アプローチ**: 既存ItemsTree内部でheadless-tree + virtualizationをハイブリッド活用

+ 2 - 1
.serena/memories/page-state-hooks-useLatestRevision-degradation.md

@@ -305,7 +305,8 @@ export const useFetchCurrentPage = () => {
       const { page: newData } = data;
 
       set(currentPageDataAtom, newData);
-      set(currentPageIdAtom, newData._id);
+      set(currentPageEntityIdAtom, newData._id);
+      set(currentPageEmptyIdAtom, undefined);
 
       // ✅ 追加: PageInfo を再フェッチ
       mutatePageInfo();  // 引数なし = revalidate (再フェッチ)

+ 1 - 1
.serena/memories/page-transition-and-rendering-flow.md

@@ -53,7 +53,7 @@
     - **3d. API通信**: `apiv3Get('/page', ...)` を実行してサーバーから新しいページデータを取得します。パラメータには、パス、ページID、リビジョンIDなどが含まれます。
 4.  **アトミックな状態更新**:
     - **API成功時**:
-        - 関連する **すべてのatomを一度に更新** します (`currentPageDataAtom`, `currentPageIdAtom`, `pageNotFoundAtom`, `pageLoadingAtom` など)。
+        - 関連する **すべてのatomを一度に更新** します (`currentPageDataAtom`, `currentPageEntityIdAtom`, `currentPageEmptyIdAtom`, `pageNotFoundAtom`, `pageLoadingAtom` など)。
         - これにより、中間的な状態(`pageId`が`undefined`になるなど)が発生することなく、データが完全に揃った状態で一度だけ状態が更新されます。
     - **APIエラー時 (例: 404 Not Found)**:
         - `pageErrorAtom` にエラーオブジェクトを設定します。

+ 22 - 1
CHANGELOG.md

@@ -1,9 +1,30 @@
 # Changelog
 
-## [Unreleased](https://github.com/growilabs/compare/v7.3.7...HEAD)
+## [Unreleased](https://github.com/growilabs/compare/v7.3.9...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v7.3.9](https://github.com/growilabs/compare/v7.3.8...v7.3.9) - 2025-12-09
+
+### 🐛 Bug Fixes
+
+* fix: Change the name of maintenance mode. (#10559) @hikaru-n-cpu
+
+### 🧰 Maintenance
+
+* support: Add new intern names to staff credits (#10556) @riona-k
+
+## [v7.3.8](https://github.com/growilabs/compare/v7.3.7...v7.3.8) - 2025-12-04
+
+### 💎 Features
+
+* feat: Enable page bulk export for GROWI.cloud (#10292) @arafubeatbox
+* feat: Users statistics table for admin (#10539) @riona-k
+
+### 🧰 Maintenance
+
+* ci(deps): bump validator from 13.15.20 to 13.15.22 (#10560) @[dependabot[bot]](https://github.com/apps/dependabot)
+
 ## [v7.3.7](https://github.com/growilabs/compare/v7.3.6...v7.3.7) - 2025-11-25
 
 ### 💎 Features

+ 31 - 13
apps/app/.eslintrc.js

@@ -32,19 +32,7 @@ module.exports = {
     'src/linter-checker/**',
     'src/migrations/**',
     'src/models/**',
-    'src/features/callout/**',
-    'src/features/collaborative-editor/**',
-    'src/features/comment/**',
-    'src/features/templates/**',
-    'src/features/mermaid/**',
-    'src/features/search/**',
-    'src/features/plantuml/**',
-    'src/features/external-user-group/**',
-    'src/features/page-bulk-export/**',
-    'src/features/growi-plugin/**',
-    'src/features/opentelemetry/**',
-    'src/features/openai/**',
-    'src/features/rate-limiter/**',
+    'src/features/**',
     'src/stores-universal/**',
     'src/interfaces/**',
     'src/utils/**',
@@ -69,7 +57,37 @@ module.exports = {
     'src/server/routes/apiv3/user/**',
     'src/server/routes/apiv3/personal-setting/**',
     'src/server/routes/apiv3/security-settings/**',
+    'src/server/routes/apiv3/app-settings/**',
+    'src/server/routes/apiv3/page/**',
+    'src/server/routes/apiv3/*.js',
     'src/server/routes/apiv3/*.ts',
+    'src/server/service/*.ts',
+    'src/server/service/*.js',
+    'src/server/service/access-token/**',
+    'src/server/service/config-manager/**',
+    'src/server/service/page/**',
+    'src/server/service/page-listing/**',
+    'src/server/service/revision/**',
+    'src/server/service/s2s-messaging/**',
+    'src/server/service/search-delegator/**',
+    'src/server/service/search-reconnect-context/**',
+    'src/server/service/slack-command-handler/**',
+    'src/server/service/slack-event-handler/**',
+    'src/server/service/socket-io/**',
+    'src/server/service/system-events/**',
+    'src/server/service/user-notification/**',
+    'src/server/service/yjs/**',
+    'src/server/service/file-uploader/**',
+    'src/server/service/global-notification/**',
+    'src/server/service/growi-bridge/**',
+    'src/server/service/growi-info/**',
+    'src/server/service/import/**',
+    'src/server/service/in-app-notification/**',
+    'src/server/service/interfaces/**',
+    'src/server/service/normalize-data/**',
+    'src/server/service/page/**',
+    'src/client/interfaces/**',
+    'src/client/models/**',
   ],
   settings: {
     // resolve path aliases by eslint-import-resolver-typescript

+ 3 - 1
apps/app/next.config.js

@@ -160,8 +160,10 @@ module.exports = async (phase) => {
   };
 
   // production server
+  // Skip withSuperjson() in production server phase because the pages directory
+  // doesn't exist in the production build and withSuperjson() tries to find it
   if (phase === PHASE_PRODUCTION_SERVER) {
-    return withSuperjson()(nextConfig);
+    return nextConfig;
   }
 
   const withBundleAnalyzer = require('@next/bundle-analyzer')({

+ 4 - 2
apps/app/package.json

@@ -127,7 +127,6 @@
     "diff_match_patch": "^0.1.1",
     "dotenv-flow": "^3.2.0",
     "ejs": "^3.1.10",
-    "esa-node": "^0.2.2",
     "escape-string-regexp": "^4.0.0",
     "expose-gc": "^1.0.0",
     "express": "^4.20.0",
@@ -248,7 +247,7 @@
     "url-join": "^4.0.0",
     "usehooks-ts": "^2.6.0",
     "uuid": "^11.0.3",
-    "validator": "^13.15.20",
+    "validator": "^13.15.22",
     "ws": "^8.17.1",
     "xss": "^1.0.15",
     "y-mongodb-provider": "^0.2.0",
@@ -271,10 +270,13 @@
     "@growi/editor": "workspace:^",
     "@growi/ui": "workspace:^",
     "@handsontable/react": "=2.1.0",
+    "@headless-tree/core": "^1.5.1",
+    "@headless-tree/react": "^1.5.1",
     "@next/bundle-analyzer": "^14.1.3",
     "@popperjs/core": "^2.11.8",
     "@swc-node/jest": "^1.8.1",
     "@swc/jest": "^0.2.36",
+    "@tanstack/react-virtual": "^3.13.12",
     "@testing-library/jest-dom": "^6.5.0",
     "@testing-library/user-event": "^14.5.2",
     "@types/archiver": "^6.0.2",

+ 8 - 22
apps/app/public/static/locales/en_US/admin.json

@@ -339,7 +339,7 @@
     "supplymentary_message_to_start": "As for the API, only the administrator API will be functional.",
     "start_maintenance_mode": "Start maintenance mode",
     "end_maintenance_mode": "End maintenance mode",
-    "description": "Maintenance mode restricts all user operations. Always start the maintenance mode before \"importing data\" and \"upgrading to V5\". To exit, go to \"Security Settings\" > \"Maintenance Mode\"."
+    "description": "Maintenance mode restricts all user operations. Always start the maintenance mode before \"importing data\" and \"upgrading to V5\". To exit, go to \"App Settings\" > \"Maintenance Mode\"."
   },
   "app_setting": {
     "site_name": "Site name",
@@ -531,7 +531,7 @@
     "page_path": "Page Path",
     "beta_warning": "This function is Beta.",
     "import_from": "Import from {{from}}",
-    "import_growi_archive": "Import GROWI archive",
+    "import_growi_archive": "Import Archive Data",
     "error": {
       "only_upsert_available": "Only 'Upsert' option is available for pages collection."
     },
@@ -577,23 +577,11 @@
         }
       }
     },
-    "esa_settings": {
-      "team_name": "Team name",
-      "access_token": "Access token",
-      "test_connection": "Test connection to esa"
-    },
-    "qiita_settings": {
-      "team_name": "Team name",
-      "access_token": "Access token",
-      "test_connection": "Test connection to qiita:team"
-    },
     "import": "Import",
     "skip_username_and_email_when_overlapped": "Skip username and email using same username and email in new environment",
     "prepare_new_account_for_migration": "Prepare new account for migration",
     "archive_data_import_detail": "More Details? Ckick here.",
-    "admin_archive_data_import_guide_url": "https://docs.growi.org/en/admin-guide/management-cookbook/import.html",
-    "page_skip": "Pages with a name that already exists on GROWI are not imported",
-    "Directory_hierarchy_tag": "Directory hierarchy tag"
+    "admin_archive_data_import_guide_url": "https://docs.growi.org/en/admin-guide/management-cookbook/import.html"
   },
   "export_management": {
     "export_archive_data": "Export Archive Data",
@@ -796,7 +784,11 @@
     "unset": "No",
     "related_username": "Related user's ",
     "cannot_invite_maximum_users": "Can not invite more than the maximum number of users.",
-    "current_users": "Current users:"
+    "user_statistics": {
+      "total": "Total Users",
+      "active": "Active",
+      "inactive": "Inactive"
+    }
   },
   "user_group_management": {
     "user_group_management": "User Group Management",
@@ -1011,12 +1003,6 @@
     "ADMIN_ARCHIVE_DATA_UPLOAD": "Upload Archived Data",
     "ADMIN_GROWI_DATA_IMPORTED": "Import Archived Data",
     "ADMIN_UPLOADED_GROWI_DATA_DISCARDED": "Discard Archived Data",
-    "ADMIN_ESA_DATA_IMPORTED": "Import from esa.io",
-    "ADMIN_ESA_DATA_UPDATED": "Update esa.io import settings",
-    "ADMIN_CONNECTION_TEST_OF_ESA_DATA": "Test connection to esa",
-    "ADMIN_QIITA_DATA_IMPORTED": "Import from Qiita:Team",
-    "ADMIN_QIITA_DATA_UPDATED": "Update Qiita:Team import settings",
-    "ADMIN_CONNECTION_TEST_OF_QIITA_DATA": "Test connection to Qiita:Team",
     "ADMIN_ARCHIVE_DATA_CREATE": "Create Archived Data",
     "ADMIN_ARCHIVE_DATA_DOWNLOAD": "Download Archive Data",
     "ADMIN_ARCHIVE_DATA_DELETE": "Delete Archive Data",

+ 8 - 22
apps/app/public/static/locales/fr_FR/admin.json

@@ -339,7 +339,7 @@
     "supplymentary_message_to_start": "Seul l'API d'administration sera actif.",
     "start_maintenance_mode": "Activer le mode maitenance",
     "end_maintenance_mode": "Désactiver le mode maitenance",
-    "description": "Le mode maintenance restreint l'utilisation de GROWI. Toujours démarrer le mode maintenance avant l'\"import de données\" et la \"conversion vers la V5\"."
+    "description": "Le mode maintenance restreint l'utilisation de GROWI. Toujours démarrer le mode maintenance avant l'\"import de données\" et la \"conversion vers la V5\".Pour quitter ce mode, veuillez vous rendre dans « Paramètres de l'application » > « Mode maintenance »."
   },
   "app_setting": {
     "site_name": "Nom",
@@ -531,7 +531,7 @@
     "page_path": "Chemin de page",
     "beta_warning": "Cette fonctionnalité est en beta.",
     "import_from": "Importer depuis {{from}}",
-    "import_growi_archive": "Importer une archive GROWI",
+    "import_growi_archive": "Importer les données d'archive",
     "error": {
       "only_upsert_available": "Seul l'option 'Upsert' est disponible pour les collections de pages"
     },
@@ -577,23 +577,11 @@
         }
       }
     },
-    "esa_settings": {
-      "team_name": "Nom de l'équipe",
-      "access_token": "Jeton d'accès",
-      "test_connection": "Essai de la connection esa"
-    },
-    "qiita_settings": {
-      "team_name": "Nom de l'équipe",
-      "access_token": "Jeton d'accès",
-      "test_connection": "Essai de la connection qiita:team"
-    },
     "import": "Importer",
     "skip_username_and_email_when_overlapped": "Passe le nom et adresse courriel exactes dans le nouvel environnement",
     "prepare_new_account_for_migration": "Préparer le compte pour la migration",
     "archive_data_import_detail": "En savoir plus",
-    "admin_archive_data_import_guide_url": "https://docs.growi.org/en/admin-guide/management-cookbook/import.html",
-    "page_skip": "Les pages ayant le nom d'une page déjà existante ne seront pas importées.",
-    "Directory_hierarchy_tag": "Tag de hiérarchie"
+    "admin_archive_data_import_guide_url": "https://docs.growi.org/en/admin-guide/management-cookbook/import.html"
   },
   "export_management": {
     "export_archive_data": "Archive de données d'export",
@@ -796,7 +784,11 @@
     "unset": "Non",
     "related_username": "Utilisateur ",
     "cannot_invite_maximum_users": "La limite maximale d'utilisateurs invitables est atteinte.",
-    "current_users": "Utilisateurs:"
+    "user_statistics": {
+      "total": "Utilisateurs totaux",
+      "active": "Actifs",
+      "inactive": "Inactifs"
+    }
   },
   "user_group_management": {
     "user_group_management": "Gestion des groupes",
@@ -1010,12 +1002,6 @@
     "ADMIN_ARCHIVE_DATA_UPLOAD": "Téléverser les données d'archive",
     "ADMIN_GROWI_DATA_IMPORTED": "Importer les données d'archive",
     "ADMIN_UPLOADED_GROWI_DATA_DISCARDED": "Supprimer les données d'archive",
-    "ADMIN_ESA_DATA_IMPORTED": "Importer depuis esa.io",
-    "ADMIN_ESA_DATA_UPDATED": "Mettre à jour les paramètres d'import esa.io",
-    "ADMIN_CONNECTION_TEST_OF_ESA_DATA": "Tester la connexion esa",
-    "ADMIN_QIITA_DATA_IMPORTED": "Importer depuis Qiita:Team",
-    "ADMIN_QIITA_DATA_UPDATED": "Mettre à jour les paramètres d'import Qiita:Team",
-    "ADMIN_CONNECTION_TEST_OF_QIITA_DATA": "Tester la connexion Qiita:Team",
     "ADMIN_ARCHIVE_DATA_CREATE": "Créer données d'archive",
     "ADMIN_ARCHIVE_DATA_DOWNLOAD": "Télécharger les données d'archive",
     "ADMIN_ARCHIVE_DATA_DELETE": "Supprimer les données d'archive",

+ 8 - 22
apps/app/public/static/locales/ja_JP/admin.json

@@ -348,7 +348,7 @@
     "supplymentary_message_to_start": "API についても管理者用 API しか機能しなくなります。",
     "start_maintenance_mode": "メンテナンスモードを開始する",
     "end_maintenance_mode": "メンテナンスモードを終了する",
-    "description": "メンテナンスモードでは、ユーザーのあらゆる操作を制限します。「データのインポート」および「V5 へのアップグレード」の際には必ずメンテナンスモードを開始してから行ってください。終了するには「セキュリティ設定」>「メンテナンスモード」から操作してください。"
+    "description": "メンテナンスモードでは、ユーザーのあらゆる操作を制限します。「データのインポート」および「V5 へのアップグレード」の際には必ずメンテナンスモードを開始してから行ってください。終了するには「アプリ設定」>「メンテナンスモード」から操作してください。"
   },
   "app_setting": {
     "site_name": "サイト名",
@@ -540,7 +540,7 @@
     "page_path": "ページパス",
     "beta_warning": "この機能はベータ版です",
     "import_from": "{{from}} からインポート",
-    "import_growi_archive": "GROWI アーカイブをインポート",
+    "import_growi_archive": "データインポート",
     "error": {
       "only_upsert_available": "pages コレクションには 'Upsert' オプションのみ対応しています"
     },
@@ -586,23 +586,11 @@
         }
       }
     },
-    "esa_settings": {
-      "team_name": "チーム名",
-      "access_token": "アクセストークン",
-      "test_connection": "接続テスト"
-    },
-    "qiita_settings": {
-      "team_name": "チーム名",
-      "access_token": "アクセストークン",
-      "test_connection": "接続テスト"
-    },
     "import": "インポート",
     "skip_username_and_email_when_overlapped": "ユーザー名またはメールアドレスが同じ場合、その部分がスキップされます。",
     "prepare_new_account_for_migration": "移行用のアカウントを新環境で用意してください。",
     "archive_data_import_detail": "参考: GROWI Docs - データのインポート",
-    "admin_archive_data_import_guide_url": "https://docs.growi.org/ja/admin-guide/management-cookbook/import.html#growi-%E3%82%A2%E3%83%BC%E3%82%AB%E3%82%A4%E3%83%96%E3%83%87%E3%83%BC%E3%82%BF%E3%82%A4%E3%83%B3%E3%83%9D%E3%83%BC%E3%83%88",
-    "page_skip": "既に GROWI 側に同名のページが存在する場合、そのページはスキップされます",
-    "Directory_hierarchy_tag": "ディレクトリ階層タグ"
+    "admin_archive_data_import_guide_url": "https://docs.growi.org/ja/admin-guide/management-cookbook/import.html#growi-%E3%82%A2%E3%83%BC%E3%82%AB%E3%82%A4%E3%83%96%E3%83%87%E3%83%BC%E3%82%BF%E3%82%A4%E3%83%B3%E3%83%9D%E3%83%BC%E3%83%88"
   },
   "export_management": {
     "export_archive_data": "データアーカイブ",
@@ -805,7 +793,11 @@
     "unset": "未設定",
     "related_username": "関連付けられているユーザーの ",
     "cannot_invite_maximum_users": "ユーザーが上限に達したため招待できません。",
-    "current_users": "現在のユーザー数:"
+    "user_statistics": {
+      "total": "総ユーザー数",
+      "active": "アクティブ",
+      "inactive": "非アクティブ"
+    }
   },
   "user_group_management": {
     "user_group_management": "グループ管理",
@@ -1020,12 +1012,6 @@
     "ADMIN_ARCHIVE_DATA_UPLOAD": "アーカイブデータのアップロード",
     "ADMIN_GROWI_DATA_IMPORTED": "アーカイブデータのインポート",
     "ADMIN_UPLOADED_GROWI_DATA_DISCARDED": "アーカイブデータの破棄",
-    "ADMIN_ESA_DATA_IMPORTED": "esa.io からインポート",
-    "ADMIN_ESA_DATA_UPDATED": "esa.io のインポート設定の更新",
-    "ADMIN_CONNECTION_TEST_OF_ESA_DATA": "esa.io の接続テスト",
-    "ADMIN_QIITA_DATA_IMPORTED": "Qiita:Team からのインポート",
-    "ADMIN_QIITA_DATA_UPDATED": "Qiita:Team のインポート設定の更新",
-    "ADMIN_CONNECTION_TEST_OF_QIITA_DATA": "Qiita:Team の接続テスト",
     "ADMIN_ARCHIVE_DATA_CREATE": "アーカイブデータの作成",
     "ADMIN_ARCHIVE_DATA_DOWNLOAD": "アーカイブデータのダウンロード",
     "ADMIN_ARCHIVE_DATA_DELETE": "アーカイブデータの削除",

+ 8 - 22
apps/app/public/static/locales/ko_KR/admin.json

@@ -339,7 +339,7 @@
     "supplymentary_message_to_start": "API의 경우 관리자 API만 작동합니다.",
     "start_maintenance_mode": "유지 보수 모드 시작",
     "end_maintenance_mode": "유지 보수 모드 종료",
-    "description": "유지 보수 모드는 모든 사용자 작업을 제한합니다. 데이터 가져오기 및 V5로 업그레이드 전에 항상 유지 보수 모드를 시작하십시오. 종료하려면 보안 설정 > 유지 보수 모드로 이동하십시오."
+    "description": "유지 보수 모드는 모든 사용자 작업을 제한합니다. 데이터 가져오기 및 V5로 업그레이드 전에 항상 유지 보수 모드를 시작하십시오. 종료하려면 보안 설정 > 유지 보수 모드로 이동하십시오.종료하려면 ‘앱 설정’ > '유지보수 모드'에서 조작하십시오."
   },
   "app_setting": {
     "site_name": "사이트 이름",
@@ -531,7 +531,7 @@
     "page_path": "페이지 경로",
     "beta_warning": "이 기능은 베타입니다.",
     "import_from": "{{from}}에서 가져오기",
-    "import_growi_archive": "GROWI 아카이브 가져오기",
+    "import_growi_archive": "아카이브 데이터 가져오기",
     "error": {
       "only_upsert_available": "페이지 컬렉션에는 'Upsert' 옵션만 사용할 수 있습니다."
     },
@@ -577,23 +577,11 @@
         }
       }
     },
-    "esa_settings": {
-      "team_name": "팀 이름",
-      "access_token": "액세스 토큰",
-      "test_connection": "esa 연결 테스트"
-    },
-    "qiita_settings": {
-      "team_name": "팀 이름",
-      "access_token": "액세스 토큰",
-      "test_connection": "qiita:team 연결 테스트"
-    },
     "import": "가져오기",
     "skip_username_and_email_when_overlapped": "새 환경에서 동일한 사용자 이름과 이메일을 사용하는 경우 사용자 이름과 이메일 건너뛰기",
     "prepare_new_account_for_migration": "마이그레이션을 위한 새 계정 준비",
     "archive_data_import_detail": "자세한 내용은 여기를 클릭하십시오.",
-    "admin_archive_data_import_guide_url": "https://docs.growi.org/en/admin-guide/management-cookbook/import.html",
-    "page_skip": "GROWI에 이미 존재하는 이름의 페이지는 가져오지 않습니다.",
-    "Directory_hierarchy_tag": "디렉토리 계층 태그"
+    "admin_archive_data_import_guide_url": "https://docs.growi.org/en/admin-guide/management-cookbook/import.html"
   },
   "export_management": {
     "export_archive_data": "아카이브 데이터 내보내기",
@@ -796,7 +784,11 @@
     "unset": "아니요",
     "related_username": "관련 사용자 ",
     "cannot_invite_maximum_users": "최대 사용자 수 이상을 초대할 수 없습니다.",
-    "current_users": "현재 사용자:"
+    "user_statistics": {
+      "total": "총 사용자",
+      "active": "활성",
+      "inactive": "비활성"
+    }
   },
   "user_group_management": {
     "user_group_management": "사용자 그룹 관리",
@@ -1011,12 +1003,6 @@
     "ADMIN_ARCHIVE_DATA_UPLOAD": "아카이브 데이터 업로드",
     "ADMIN_GROWI_DATA_IMPORTED": "아카이브 데이터 가져오기",
     "ADMIN_UPLOADED_GROWI_DATA_DISCARDED": "업로드된 GROWI 데이터 버리기",
-    "ADMIN_ESA_DATA_IMPORTED": "esa.io에서 가져오기",
-    "ADMIN_ESA_DATA_UPDATED": "esa.io 가져오기 설정 업데이트",
-    "ADMIN_CONNECTION_TEST_OF_ESA_DATA": "esa 연결 테스트",
-    "ADMIN_QIITA_DATA_IMPORTED": "Qiita:Team에서 가져오기",
-    "ADMIN_QIITA_DATA_UPDATED": "Qiita:Team 가져오기 설정 업데이트",
-    "ADMIN_CONNECTION_TEST_OF_QIITA_DATA": "Qiita:Team 연결 테스트",
     "ADMIN_ARCHIVE_DATA_CREATE": "아카이브 데이터 생성",
     "ADMIN_ARCHIVE_DATA_DOWNLOAD": "아카이브 데이터 다운로드",
     "ADMIN_ARCHIVE_DATA_DELETE": "아카이브 데이터 삭제",

+ 8 - 22
apps/app/public/static/locales/zh_CN/admin.json

@@ -348,7 +348,7 @@
     "supplymentary_message_to_start": "至于API,只有管理员的API将是有效的。",
     "start_maintenance_mode": "启动维护模式",
     "end_maintenance_mode": "结束维护模式",
-    "description": "维护模式限制了所有的用户操作。 在执行 \"数据导入 \"和 \"升级到V5 \"之前,务必启动维护模式。 要退出,进入 \"安全设置\">\"维护模式\"。"
+    "description": "维护模式限制了所有的用户操作。 在执行 \"数据导入 \"和 \"升级到V5 \"之前,务必启动维护模式。 要退出,进入 \"系统设置\">\"维护模式\"。"
   },
   "app_setting": {
     "site_name": "网站名称 ",
@@ -540,7 +540,7 @@
     "page_path": "相对路径",
     "beta_warning": "这个函数是Beta。",
     "import_from": "Import from {{from}}",
-    "import_growi_archive": "Import GROWI archive",
+    "import_archive_data": "导入存档数据",
     "error": {
       "only_upsert_available": "Only 'Upsert' option is available for pages collection."
     },
@@ -586,23 +586,11 @@
         }
       }
     },
-    "esa_settings": {
-      "team_name": "Team name",
-      "access_token": "Access token",
-      "test_connection": "Test connection to esa"
-    },
-    "qiita_settings": {
-      "team_name": "Team name",
-      "access_token": "Access token",
-      "test_connection": "Test connection to qiita:team"
-    },
     "import": "Import",
     "skip_username_and_email_when_overlapped": "Skip username and email using same username and email in new environment",
     "prepare_new_account_for_migration": "Prepare new account for migration",
     "archive_data_import_detail": "More details? Click here.",
-    "admin_archive_data_import_guide_url": "https://docs.growi.org/en/admin-guide/management-cookbook/import.html",
-    "page_skip": "Pages with a name that already exists on GROWI are not imported",
-    "Directory_hierarchy_tag": "Directory hierarchy tag"
+    "admin_archive_data_import_guide_url": "https://docs.growi.org/en/admin-guide/management-cookbook/import.html"
   },
   "export_management": {
     "export_archive_data": "导出主题数据",
@@ -805,7 +793,11 @@
     "unset": "否",
     "related_username": "相关用户的",
     "cannot_invite_maximum_users": "邀请的用户数不能超过最大值。",
-    "current_users": "当前用户:"
+    "user_statistics": {
+      "total": "用户总数",
+      "active": "活跃",
+      "inactive": "非活跃"
+    }
   },
   "user_group_management": {
     "user_group_management": "用户组管理",
@@ -1020,12 +1012,6 @@
     "ADMIN_ARCHIVE_DATA_UPLOAD": "上传存档数据",
     "ADMIN_GROWI_DATA_IMPORTED": "导入存档数据",
     "ADMIN_UPLOADED_GROWI_DATA_DISCARDED": "丢弃存档数据",
-    "ADMIN_ESA_DATA_IMPORTED": "从 esa.io 导入",
-    "ADMIN_ESA_DATA_UPDATED": "更新 esa.io 导入设置",
-    "ADMIN_CONNECTION_TEST_OF_ESA_DATA": "测试与 esa 的连接",
-    "ADMIN_QIITA_DATA_IMPORTED": "从 Qiita:Team 导入",
-    "ADMIN_QIITA_DATA_UPDATED": "更新 Qiita:团队导入设置",
-    "ADMIN_CONNECTION_TEST_OF_QIITA_DATA": "测试与 Qiita:Team 的连接",
     "ADMIN_ARCHIVE_DATA_CREATE": "创建归档数据",
     "ADMIN_ARCHIVE_DATA_DOWNLOAD": "下载存档数据",
     "ADMIN_ARCHIVE_DATA_DELETE": "删除存档数据",

+ 7 - 0
apps/app/resource/Contributor.js

@@ -78,6 +78,13 @@ const contributors = [
           { name: 'shironegi39' },
           { name: 'ryo-h15' },
           { name: 'jam411' },
+          { name: 'Naoki427' },
+          { name: 'yusa-bot' },
+          { name: 'arvid-e' },
+          { name: 'riona-k' },
+          { name: 'hiroki-hgs' },
+          { name: 'taikou-m' },
+          { name: 'hikaru-n-cpu' },
         ],
       },
     ],

+ 5 - 8
apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx

@@ -108,15 +108,12 @@ const AppSettingsPageContents = (props: Props) => {
         </div>
       </div>
 
-      {/* TODO: Enable configuring bulk export for GROWI.cloud when it can be relased for cloud (https://redmine.weseek.co.jp/issues/163220) */}
-      {!adminAppContainer.state.isBulkExportDisabledForCloud && (
-        <div className="row mt-5">
-          <div className="col-lg-12">
-            <h2 className="admin-setting-header">{t('admin:app_setting.page_bulk_export_settings')}</h2>
-            <PageBulkExportSettings />
-          </div>
+      <div className="row mt-5">
+        <div className="col-lg-12">
+          <h2 className="admin-setting-header">{t('admin:app_setting.page_bulk_export_settings')}</h2>
+          <PageBulkExportSettings />
         </div>
-      )}
+      </div>
 
       <div className="row">
         <div className="col-lg-12">

+ 2 - 264
apps/app/src/client/components/Admin/ImportData/ImportDataPageContents.jsx

@@ -1,280 +1,18 @@
-import React, { useEffect } from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-import { useForm } from 'react-hook-form';
-
-import AdminImportContainer from '~/client/services/AdminImportContainer';
-import { toastError } from '~/client/util/toastr';
-import { toArrayIfNot } from '~/utils/array-utils';
-import loggerFactory from '~/utils/logger';
-
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 import GrowiArchiveSection from './GrowiArchiveSection';
 
-const logger = loggerFactory('growi:importer');
-
-const ImportDataPageContents = ({ t, adminImportContainer }) => {
-  const { register: registerEsa, reset: resetEsa, handleSubmit: handleSubmitEsa } = useForm();
-  const { register: registerQiita, reset: resetQiita, handleSubmit: handleSubmitQiita } = useForm();
-
-  useEffect(() => {
-    resetEsa({
-      esaTeamName: adminImportContainer.state.esaTeamName || '',
-      esaAccessToken: adminImportContainer.state.esaAccessToken || '',
-    });
-  }, [resetEsa, adminImportContainer.state.esaTeamName, adminImportContainer.state.esaAccessToken]);
-
-  useEffect(() => {
-    resetQiita({
-      qiitaTeamName: adminImportContainer.state.qiitaTeamName || '',
-      qiitaAccessToken: adminImportContainer.state.qiitaAccessToken || '',
-    });
-  }, [resetQiita, adminImportContainer.state.qiitaTeamName, adminImportContainer.state.qiitaAccessToken]);
-
+const ImportDataPageContents = () => {
   return (
     <div data-testid="admin-import-data">
       <GrowiArchiveSection />
-
-      <form
-        className="mt-5"
-        id="importerSettingFormEsa"
-        role="form"
-        onSubmit={handleSubmitEsa(adminImportContainer.esaHandleSubmitUpdate)}
-      >
-        <fieldset>
-          <h2 className="admin-setting-header">{t('importer_management.import_from', { from: 'esa.io' })}</h2>
-          <table className="table table-bordered table-mapping">
-            <thead>
-              <tr>
-                <th width="45%">esa.io</th>
-                <th width="10%"></th>
-                <th>GROWI</th>
-              </tr>
-            </thead>
-            <tbody>
-              <tr>
-                <th>{t('importer_management.article')}</th>
-                <th><span className="material-symbols-outlined text-success">arrow_circle_right</span></th>
-                <th>{t('importer_management.page')}</th>
-              </tr>
-              <tr>
-                <th>{t('importer_management.category')}</th>
-                <th><span className="material-symbols-outlined text-success">arrow_circle_right</span></th>
-                <th>{t('importer_management.page_path')}</th>
-              </tr>
-              <tr>
-                <th>{t('User')}</th>
-                <th></th>
-                <th>(TBD)</th>
-              </tr>
-            </tbody>
-          </table>
-
-          <div className="card custom-card bg-body-tertiary mb-0 small">
-            <ul>
-              <li>{t('importer_management.page_skip')}</li>
-            </ul>
-          </div>
-
-          <div className="row mt-4">
-            <input type="password" name="dummypass" style={{ display: 'none', top: '-100px', left: '-100px' }} />
-          </div>
-
-          <div className="row">
-            <label htmlFor="settingForm[importer:esa:team_name]" className="text-start text-md-end col-md-3 col-form-label">
-              {t('importer_management.esa_settings.team_name')}
-            </label>
-            <div className="col-md-6">
-              <input
-                className="form-control"
-                type="text"
-                {...registerEsa('esaTeamName')}
-              />
-            </div>
-
-          </div>
-
-          <div className="row mt-3">
-            <label htmlFor="settingForm[importer:esa:access_token]" className="text-start text-md-end col-md-3 col-form-label">
-              {t('importer_management.esa_settings.access_token')}
-            </label>
-            <div className="col-md-6">
-              <input
-                className="form-control"
-                type="password"
-                {...registerEsa('esaAccessToken')}
-              />
-            </div>
-          </div>
-
-          <div className="row mt-3">
-            <div className="offset-md-3 col-md-6">
-              <input
-                id="testConnectionToEsa"
-                type="button"
-                className="btn btn-primary btn-esa me-3"
-                name="Esa"
-                onClick={adminImportContainer.esaHandleSubmit}
-                value={t('importer_management.import')}
-              />
-              <input type="submit" className="btn btn-secondary" value={t('Update')} />
-              <span className="offset-0 offset-sm-1">
-                <input
-                  id="importFromEsa"
-                  type="button"
-                  name="Esa"
-                  className="btn btn-secondary btn-esa"
-                  onClick={adminImportContainer.esaHandleSubmitTest}
-                  value={t('importer_management.esa_settings.test_connection')}
-                />
-              </span>
-            </div>
-          </div>
-        </fieldset>
-      </form>
-
-      <form
-        className="mt-5"
-        id="importerSettingFormQiita"
-        role="form"
-        onSubmit={handleSubmitQiita(adminImportContainer.qiitaHandleSubmitUpdate)}
-      >
-        <fieldset>
-          <h2 className="admin-setting-header">{t('importer_management.import_from', { from: 'Qiita:Team' })}</h2>
-          <table className="table table-bordered table-mapping">
-            <thead>
-              <tr>
-                <th width="45%">Qiita:Team</th>
-                <th width="10%"></th>
-                <th>GROWI</th>
-              </tr>
-            </thead>
-            <tbody>
-              <tr>
-                <th>{t('importer_management.article')}</th>
-                <th><span className="material-symbols-outlined">arrow_circle_right</span></th>
-                <th>{t('importer_management.page')}</th>
-              </tr>
-              <tr>
-                <th>{t('importer_management.tag')}</th>
-                <th></th>
-                <th>-</th>
-              </tr>
-              <tr>
-                <th>{t('importer_management.Directory_hierarchy_tag')}</th>
-                <th></th>
-                <th>(TBD)</th>
-              </tr>
-              <tr>
-                <th>{t('User')}</th>
-                <th></th>
-                <th>(TBD)</th>
-              </tr>
-            </tbody>
-          </table>
-          <div className="card custom-card bg-body-tertiary mb-3 small">
-            <ul>
-              <li>{t('importer_management.page_skip')}</li>
-            </ul>
-          </div>
-
-          <div className="row mt-3">
-            <input type="password" name="dummypass" style={{ display: 'none', top: '-100px', left: '-100px' }} />
-          </div>
-          <div className="row mt-3">
-            <label htmlFor="settingForm[importer:qiita:team_name]" className="text-start text-md-end col-md-3 col-form-label">
-              {t('importer_management.qiita_settings.team_name')}
-            </label>
-            <div className="col-md-6">
-              <input
-                className="form-control"
-                type="text"
-                {...registerQiita('qiitaTeamName')}
-              />
-            </div>
-          </div>
-
-          <div className="row mt-3">
-            <label htmlFor="settingForm[importer:qiita:access_token]" className="text-start text-md-end col-md-3 col-form-label">
-              {t('importer_management.qiita_settings.access_token')}
-            </label>
-            <div className="col-md-6">
-              <input
-                className="form-control"
-                type="password"
-                {...registerQiita('qiitaAccessToken')}
-              />
-            </div>
-          </div>
-
-
-          <div className="row mt-3">
-            <div className="offset-md-3 col-md-6">
-              <input
-                id="testConnectionToQiita"
-                type="button"
-                className="btn btn-primary btn-qiita me-3"
-                name="Qiita"
-                onClick={adminImportContainer.qiitaHandleSubmit}
-                value={t('importer_management.import')}
-              />
-              <input type="submit" className="btn btn-secondary" value={t('Update')} />
-              <span className="offset-0 offset-sm-1">
-                <input
-                  name="Qiita"
-                  type="button"
-                  id="importFromQiita"
-                  className="btn btn-secondary btn-qiita"
-                  onClick={adminImportContainer.qiitaHandleSubmitTest}
-                  value={t('importer_management.qiita_settings.test_connection')}
-                />
-              </span>
-
-            </div>
-          </div>
-
-
-        </fieldset>
-
-
-      </form>
     </div>
   );
 };
 
-ImportDataPageContents.propTypes = {
-  t: PropTypes.func.isRequired,
-  adminImportContainer: PropTypes.instanceOf(AdminImportContainer).isRequired,
-};
-
-const ImportDataPageContentsWrapperFc = (props) => {
-  const { t } = useTranslation('admin');
-
-  const { adminImportContainer } = props;
-
-  useEffect(() => {
-    const fetchImportSettingsData = async() => {
-      await adminImportContainer.retrieveImportSettingsData();
-    };
-
-    try {
-      fetchImportSettingsData();
-    }
-    catch (err) {
-      const errs = toArrayIfNot(err);
-      toastError(errs);
-      logger.error(errs);
-    }
-  }, [adminImportContainer]);
-
-  return <ImportDataPageContents t={t} {...props} />;
-};
-
 /**
  * Wrapper component for using unstated
  */
-const ImportDataPageContentsWrapper = withUnstatedContainers(ImportDataPageContentsWrapperFc, [AdminImportContainer]);
+const ImportDataPageContentsWrapper = withUnstatedContainers(ImportDataPageContents, []);
 
 export default ImportDataPageContentsWrapper;

+ 0 - 1
apps/app/src/client/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -366,7 +366,6 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
               <li
                 key={ancestorUserGroup._id}
                 className={`breadcrumb-item ${ancestorUserGroup._id === currentUserGroupId ? 'active' : ''}`}
-                aria-current="page"
               >
                 { ancestorUserGroup._id === currentUserGroupId ? (
                   <span>{ancestorUserGroup.name}</span>

+ 6 - 1
apps/app/src/client/components/Admin/UserManagement.tsx

@@ -13,6 +13,7 @@ import { withUnstatedContainers } from '../UnstatedUtils';
 
 import InviteUserControl from './Users/InviteUserControl';
 import PasswordResetModal from './Users/PasswordResetModal';
+import UserStatisticsTable from './Users/UserStatisticsTable';
 import UserTable from './Users/UserTable';
 
 import styles from './UserManagement.module.scss';
@@ -40,7 +41,8 @@ const UserManagement = (props: UserManagementProps) => {
   // for Next routing
   useEffect(() => {
     pagingHandler(1);
-  }, [pagingHandler]);
+    adminUsersContainer.retrieveUserStatistics();
+  }, [pagingHandler, adminUsersContainer]);
 
   const validateToggleStatus = (statusType: string) => {
     return (adminUsersContainer.isSelected(statusType)) ? (
@@ -134,6 +136,9 @@ const UserManagement = (props: UserManagementProps) => {
       </p>
 
       <h2>{t('user_management.user_management')}</h2>
+      <UserStatisticsTable
+        userStatistics={adminUsersContainer.state.userStatistics}
+      />
       <div className="border-top border-bottom">
 
         <div className="row d-flex justify-content-start align-items-center my-2">

+ 39 - 0
apps/app/src/client/components/Admin/Users/UserStatisticsTable.tsx

@@ -0,0 +1,39 @@
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+type UserStatistics = {
+  total: number;
+  active: { total: number };
+  inactive: { total: number };
+};
+
+type Props = {
+  userStatistics?: UserStatistics | null;
+};
+
+const UserStatisticsTable: React.FC<Props> = ({ userStatistics }) => {
+  const { t } = useTranslation('admin');
+  if (userStatistics == null) return null;
+
+  return (
+    <table className="table table-bordered w-100">
+      <tbody>
+        <tr>
+          <th className="col-sm-4 align-top">{t('user_management.user_statistics.total')}</th>
+          <td className="align-top">{ userStatistics.total }</td>
+        </tr>
+        <tr>
+          <th className="col-sm-4 align-top">{t('user_management.user_statistics.active')}</th>
+          <td className="align-top">{ userStatistics.active.total }</td>
+        </tr>
+        <tr>
+          <th className="col-sm-4 align-top">{t('user_management.user_statistics.inactive')}</th>
+          <td className="align-top">{ userStatistics.inactive.total }</td>
+        </tr>
+      </tbody>
+    </table>
+  );
+};
+
+export default UserStatisticsTable;

+ 46 - 21
apps/app/src/client/components/Bookmarks/BookmarkItem.tsx

@@ -1,8 +1,11 @@
-import React, { useCallback, useState, type JSX } from 'react';
+import React, {
+  useCallback, useMemo, useState, type JSX,
+} from 'react';
 
 import nodePath from 'path';
 
 import type { IPageHasId, IPageInfoExt, IPageToDeleteWithMeta } from '@growi/core';
+import { getIdStringForRef } from '@growi/core';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { pathUtils } from '@growi/core/dist/utils';
 import { useRouter } from 'next/router';
@@ -59,17 +62,21 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     ...bookmarkedPage, parentFolder,
   };
 
+  const bookmarkedPageId = bookmarkedPage?._id;
+  const bookmarkedPagePath = bookmarkedPage?.path;
+  const bookmarkedPageRevision = bookmarkedPage?.revision;
+
   const onClickMoveToRootHandler = useCallback(async() => {
-    if (bookmarkedPage == null) return;
+    if (bookmarkedPageId == null) return;
 
     try {
-      await addBookmarkToFolder(bookmarkedPage._id, null);
+      await addBookmarkToFolder(bookmarkedPageId, null);
       bookmarkFolderTreeMutation();
     }
     catch (err) {
       toastError(err);
     }
-  }, [bookmarkFolderTreeMutation, bookmarkedPage]);
+  }, [bookmarkFolderTreeMutation, bookmarkedPageId]);
 
   const bookmarkMenuItemClickHandler = useCallback(async(pageId: string, shouldBookmark: boolean) => {
     if (shouldBookmark) {
@@ -91,23 +98,23 @@ export const BookmarkItem = (props: Props): JSX.Element => {
   }, []);
 
   const rename = useCallback(async(inputText: string) => {
-    if (bookmarkedPage == null) return;
+    if (bookmarkedPageId == null) return;
 
 
     if (inputText.trim() === '') {
       return cancel();
     }
 
-    const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(bookmarkedPage.path ?? ''));
+    const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(bookmarkedPagePath ?? ''));
     const newPagePath = nodePath.resolve(parentPath, inputText.trim());
-    if (newPagePath === bookmarkedPage.path) {
+    if (newPagePath === bookmarkedPagePath) {
       setRenameInputShown(false);
       return;
     }
 
     try {
       setRenameInputShown(false);
-      await renamePage(bookmarkedPage._id, bookmarkedPage.revision, newPagePath);
+      await renamePage(bookmarkedPageId, bookmarkedPageRevision, newPagePath);
       bookmarkFolderTreeMutation();
       mutatePageInfo();
     }
@@ -115,26 +122,26 @@ export const BookmarkItem = (props: Props): JSX.Element => {
       setRenameInputShown(true);
       toastError(err);
     }
-  }, [bookmarkedPage, cancel, bookmarkFolderTreeMutation, mutatePageInfo]);
+  }, [bookmarkedPageId, bookmarkedPagePath, bookmarkedPageRevision, cancel, bookmarkFolderTreeMutation, mutatePageInfo]);
 
   const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoExt | undefined): Promise<void> => {
-    if (bookmarkedPage == null) return;
+    if (bookmarkedPageId == null) return;
 
-    if (bookmarkedPage._id == null || bookmarkedPage.path == null) {
+    if (bookmarkedPageId == null || bookmarkedPagePath == null) {
       throw Error('_id and path must not be null.');
     }
 
     const pageToDelete: IPageToDeleteWithMeta = {
       data: {
-        _id: bookmarkedPage._id,
-        revision: bookmarkedPage.revision as string,
-        path: bookmarkedPage.path,
+        _id: bookmarkedPageId,
+        revision: bookmarkedPageRevision == null ? null : getIdStringForRef(bookmarkedPageRevision),
+        path: bookmarkedPagePath,
       },
       meta: pageInfo,
     };
 
     onClickDeleteMenuItemHandler(pageToDelete);
-  }, [bookmarkedPage, onClickDeleteMenuItemHandler]);
+  }, [bookmarkedPageId, bookmarkedPagePath, bookmarkedPageRevision, onClickDeleteMenuItemHandler]);
 
   const putBackClickHandler = useCallback(() => {
     if (bookmarkedPage == null) return;
@@ -156,15 +163,33 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     openPutBackPageModal({ pageId, path }, { onPutBacked: putBackedHandler });
   }, [bookmarkedPage, openPutBackPageModal, bookmarkFolderTreeMutation, router, fetchCurrentPage, t]);
 
+  const {
+    pageTitle, formerPagePath, isFormerRoot, bookmarkItemId,
+  } = useMemo(() => {
+    const bookmarkItemId = `bookmark-item-${bookmarkedPageId}`;
+
+    if (bookmarkedPagePath == null) {
+      return {
+        pageTitle: '',
+        formerPagePath: '',
+        isFormerRoot: false,
+        bookmarkItemId,
+      };
+    }
+
+    const dPagePath = new DevidedPagePath(bookmarkedPagePath, false, true);
+    return {
+      pageTitle: dPagePath.latter,
+      formerPagePath: dPagePath.former,
+      isFormerRoot: dPagePath.isFormerRoot,
+      bookmarkItemId,
+    };
+  }, [bookmarkedPagePath, bookmarkedPageId]);
+
   if (bookmarkedPage == null) {
     return <></>;
   }
 
-  const dPagePath = new DevidedPagePath(bookmarkedPage.path, false, true);
-  const { latter: pageTitle, former: formerPagePath } = dPagePath;
-
-  const bookmarkItemId = `bookmark-item-${bookmarkedPage._id}`;
-
   return (
     <DragAndDropWrapper
       item={dragItem}
@@ -215,7 +240,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
           target={bookmarkItemId}
           fade={false}
         >
-          {dPagePath.isFormerRoot ? '/' : `${formerPagePath}/`}
+          {isFormerRoot ? '/' : `${formerPagePath}/`}
         </UncontrolledTooltip>
       </li>
     </DragAndDropWrapper>

+ 54 - 2
apps/app/src/client/components/Common/Dropdown/PageItemControl.spec.tsx

@@ -1,4 +1,4 @@
-import { type IPageInfoForOperation } from '@growi/core/dist/interfaces';
+import { type IPageInfoForOperation, type IPageInfoForEmpty } from '@growi/core/dist/interfaces';
 import {
   fireEvent, screen, within,
 } from '@testing-library/dom';
@@ -8,14 +8,16 @@ import { mock } from 'vitest-mock-extended';
 import { PageItemControl } from './PageItemControl';
 
 
-// mock for isIPageInfoForOperation
+// mock for isIPageInfoForOperation and isIPageInfoForEmpty
 
 const mocks = vi.hoisted(() => ({
   isIPageInfoForOperationMock: vi.fn(),
+  isIPageInfoForEmptyMock: vi.fn(),
 }));
 
 vi.mock('@growi/core/dist/interfaces', () => ({
   isIPageInfoForOperation: mocks.isIPageInfoForOperationMock,
+  isIPageInfoForEmpty: mocks.isIPageInfoForEmptyMock,
 }));
 
 
@@ -32,6 +34,8 @@ describe('PageItemControl.tsx', () => {
           return true;
         }
       });
+      // return false for isIPageInfoForEmpty since we're using IPageInfoForOperation
+      mocks.isIPageInfoForEmptyMock.mockReturnValue(false);
 
       const props = {
         pageId: 'dummy-page-id',
@@ -51,5 +55,53 @@ describe('PageItemControl.tsx', () => {
       // then
       expect(onClickRenameMenuItemMock).toHaveBeenCalled();
     });
+
+    it('with empty page (IPageInfoForEmpty)', async() => {
+      // setup - Create an empty page mock with required properties
+      const pageInfo: IPageInfoForEmpty = {
+        emptyPageId: 'empty-page-id',
+        isNotFound: false,
+        isEmpty: true,
+        isV5Compatible: true,
+        isMovable: true, // Allow rename operation
+        isDeletable: true,
+        isAbleToDeleteCompletely: false,
+        isRevertible: false,
+        bookmarkCount: 0,
+        isBookmarked: false,
+      };
+
+      const onClickRenameMenuItemMock = vi.fn();
+
+      // return false for isIPageInfoForOperation since this is an empty page
+      mocks.isIPageInfoForOperationMock.mockReturnValue(false);
+
+      // return true when the argument is pageInfo (empty page)
+      mocks.isIPageInfoForEmptyMock.mockImplementation((arg) => {
+        if (arg === pageInfo) {
+          return true;
+        }
+        return false;
+      });
+
+      const props = {
+        pageId: 'dummy-page-id',
+        isEnableActions: true,
+        pageInfo,
+        onClickRenameMenuItem: onClickRenameMenuItemMock,
+      };
+
+      render(<PageItemControl {...props} />);
+
+      // when
+      const button = within(screen.getByTestId('open-page-item-control-btn')).getByText(/more_vert/);
+      fireEvent.click(button);
+      const renameMenuItem = await screen.findByTestId('rename-page-btn');
+      fireEvent.click(renameMenuItem);
+
+      // then
+      expect(onClickRenameMenuItemMock).toHaveBeenCalled();
+      expect(onClickRenameMenuItemMock).toHaveBeenCalledWith('dummy-page-id', pageInfo);
+    });
   });
 });

+ 35 - 18
apps/app/src/client/components/Common/Dropdown/PageItemControl.tsx

@@ -3,7 +3,7 @@ import React, {
 } from 'react';
 
 import {
-  type IPageInfoExt, isIPageInfoForOperation,
+  type IPageInfoExt, isIPageInfoForOperation, isIPageInfoForEmpty,
 } from '@growi/core/dist/interfaces';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
@@ -58,6 +58,7 @@ type CommonProps = {
 type DropdownMenuProps = CommonProps & {
   pageId: string,
   isLoading?: boolean,
+  isDataUnavailable?: boolean,
   operationProcessData?: IPageOperationProcessData,
 }
 
@@ -65,7 +66,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
   const { t } = useTranslation('');
 
   const {
-    pageId, isLoading, pageInfo, isEnableActions, isReadOnlyUser, forceHideMenuItems, operationProcessData,
+    pageId, isLoading, isDataUnavailable, pageInfo, isEnableActions, isReadOnlyUser, forceHideMenuItems, operationProcessData,
     onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem,
     onClickRevertMenuItem, onClickPathRecoveryMenuItem,
     additionalMenuItemOnTopRenderer: AdditionalMenuItemsOnTop,
@@ -75,21 +76,24 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const bookmarkItemClickedHandler = useCallback(async() => {
-    if (!isIPageInfoForOperation(pageInfo) || onClickBookmarkMenuItem == null) {
+    if (onClickBookmarkMenuItem == null) return;
+
+    if (!isIPageInfoForEmpty(pageInfo) && !isIPageInfoForOperation(pageInfo)) {
       return;
     }
+
     await onClickBookmarkMenuItem(pageId, !pageInfo.isBookmarked);
   }, [onClickBookmarkMenuItem, pageId, pageInfo]);
 
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const renameItemClickedHandler = useCallback(async() => {
-    if (onClickRenameMenuItem == null) {
-      return;
-    }
-    if (!isIPageInfoForOperation(pageInfo) || !pageInfo?.isMovable) {
+    if (onClickRenameMenuItem == null) return;
+
+    if (!(isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo)) || !pageInfo?.isMovable) {
       logger.warn('This page could not be renamed.');
       return;
     }
+
     await onClickRenameMenuItem(pageId, pageInfo);
   }, [onClickRenameMenuItem, pageId, pageInfo]);
 
@@ -110,10 +114,9 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const deleteItemClickedHandler = useCallback(async() => {
-    if (pageInfo == null || onClickDeleteMenuItem == null) {
-      return;
-    }
-    if (!isIPageInfoForOperation(pageInfo) || !pageInfo?.isDeletable) {
+    if (onClickDeleteMenuItem == null) return;
+
+    if (!(isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo)) || !pageInfo?.isDeletable) {
       logger.warn('This page could not be deleted.');
       return;
     }
@@ -130,7 +133,15 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
   let contents = <></>;
 
-  if (isLoading) {
+  if (isDataUnavailable) {
+    // Show message when data is not available (e.g., fetch error)
+    contents = (
+      <div className="text-warning text-center px-3">
+        <span className="material-symbols-outlined">error_outline</span> No data available
+      </div>
+    );
+  }
+  else if (isLoading) {
     contents = (
       <div className="text-muted text-center my-2">
         <LoadingSpinner />
@@ -164,7 +175,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
 
         {/* Bookmark */}
-        { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && isIPageInfoForOperation(pageInfo) && (
+        { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && (isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo)) && (
           <DropdownItem
             onClick={bookmarkItemClickedHandler}
             className="grw-page-control-dropdown-item"
@@ -177,7 +188,8 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
         {/* Move/Rename */}
         { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && !isReadOnlyUser
-          && isIPageInfoForOperation(pageInfo) && pageInfo.isMovable && (
+          && (isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo))
+          && pageInfo.isMovable && (
           <DropdownItem
             onClick={renameItemClickedHandler}
             data-testid="rename-page-btn"
@@ -202,7 +214,8 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
         {/* Revert */}
         { !forceHideMenuItems?.includes(MenuItemType.REVERT) && isEnableActions && !isReadOnlyUser
-          && isIPageInfoForOperation(pageInfo) && pageInfo.isRevertible && (
+          && (isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo))
+          && pageInfo.isRevertible && (
           <DropdownItem
             onClick={revertItemClickedHandler}
             className="grw-page-control-dropdown-item"
@@ -233,7 +246,8 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         {/* divider */}
         {/* Delete */}
         { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && !isReadOnlyUser
-          && isIPageInfoForOperation(pageInfo) && pageInfo.isDeletable && (
+          && (isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo))
+          && pageInfo.isDeletable && (
           <>
             { showDeviderBeforeDelete && <DropdownItem divider /> }
             <DropdownItem
@@ -284,7 +298,7 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
   const [isOpen, setIsOpen] = useState(false);
   const [shouldFetch, setShouldFetch] = useState(false);
 
-  const { data: fetchedPageInfo, mutate: mutatePageInfo } = useSWRxPageInfo(shouldFetch ? pageId : null);
+  const { data: fetchedPageInfo, error: fetchError, mutate: mutatePageInfo } = useSWRxPageInfo(shouldFetch ? pageId : null);
 
   // update shouldFetch (and will never be false)
   useEffect(() => {
@@ -307,7 +321,9 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
     }
   }, [mutatePageInfo, onClickBookmarkMenuItem, shouldFetch]);
 
-  const isLoading = shouldFetch && fetchedPageInfo == null;
+  // isLoading should be true only when fetching is in progress (data and error are both undefined)
+  const isLoading = shouldFetch && fetchedPageInfo == null && fetchError == null;
+  const isDataUnavailable = !isLoading && fetchedPageInfo == null && presetPageInfo == null;
 
   const renameMenuItemClickHandler = useCallback(async() => {
     if (onClickRenameMenuItem == null) {
@@ -350,6 +366,7 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
           <PageItemControlDropdownMenu
             {...props}
             isLoading={isLoading}
+            isDataUnavailable={isDataUnavailable}
             pageInfo={fetchedPageInfo ?? presetPageInfo}
             onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
             onClickRenameMenuItem={renameMenuItemClickHandler}

+ 0 - 40
apps/app/src/client/components/Hotkeys/Subscribers/EditPage.jsx

@@ -1,40 +0,0 @@
-import { useEffect } from 'react';
-
-import PropTypes from 'prop-types';
-
-import { useIsEditable } from '~/states/page';
-import { EditorMode, useEditorMode } from '~/states/ui/editor';
-
-const EditPage = (props) => {
-  const isEditable = useIsEditable();
-  const { setEditorMode } = useEditorMode();
-
-  // setup effect
-  useEffect(() => {
-    if (!isEditable) {
-      return;
-    }
-
-    // ignore when dom that has 'modal in' classes exists
-    if (document.getElementsByClassName('modal in').length > 0) {
-      return;
-    }
-
-    setEditorMode(EditorMode.Editor);
-
-    // remove this
-    props.onDeleteRender(this);
-  }, [isEditable, props, setEditorMode]);
-
-  return null;
-};
-
-EditPage.propTypes = {
-  onDeleteRender: PropTypes.func.isRequired,
-};
-
-EditPage.getHotkeyStrokes = () => {
-  return [['e']];
-};
-
-export default EditPage;

+ 74 - 0
apps/app/src/client/components/Hotkeys/Subscribers/EditPage.tsx

@@ -0,0 +1,74 @@
+import { useCallback, useEffect, useRef } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { useStartEditing } from '~/client/services/use-start-editing';
+import { toastError } from '~/client/util/toastr';
+import { useCurrentPathname } from '~/states/global';
+import { useIsEditable, useCurrentPagePath } from '~/states/page';
+
+type Props = {
+  onDeleteRender: () => void,
+}
+
+/**
+ * Custom hook for edit page logic
+ */
+const useEditPage = (
+    onCompleted: () => void,
+    onError?: (path: string) => void,
+): void => {
+  const isEditable = useIsEditable();
+  const startEditing = useStartEditing();
+  const currentPagePath = useCurrentPagePath();
+  const currentPathname = useCurrentPathname();
+  const path = currentPagePath ?? currentPathname;
+  const isExecutedRef = useRef(false);
+
+  useEffect(() => {
+    (async() => {
+      // Prevent multiple executions
+      if (isExecutedRef.current) return;
+      isExecutedRef.current = true;
+
+      if (!isEditable) {
+        return;
+      }
+
+      // ignore when dom that has 'modal in' classes exists
+      if (document.getElementsByClassName('modal in').length > 0) {
+        return;
+      }
+
+      try {
+        await startEditing(path);
+      }
+      catch (err) {
+        onError?.(path);
+      }
+
+      onCompleted();
+    })();
+  }, [startEditing, isEditable, path, onCompleted, onError]);
+};
+
+/**
+ * EditPage component - handles hotkey 'e' for editing
+ */
+const EditPage = (props: Props): null => {
+  const { t } = useTranslation('commons');
+
+  const handleError = useCallback((path: string) => {
+    toastError(t('toaster.create_failed', { target: path }));
+  }, [t]);
+
+  useEditPage(props.onDeleteRender, handleError);
+
+  return null;
+};
+
+EditPage.getHotkeyStrokes = () => {
+  return [['e']];
+};
+
+export default EditPage;

+ 0 - 18
apps/app/src/client/components/ItemsTree/ItemNode.ts

@@ -1,18 +0,0 @@
-import type { IPageForItem } from '../../../interfaces/page';
-
-export class ItemNode {
-
-  page: IPageForItem;
-
-  children: ItemNode[];
-
-  constructor(page: IPageForItem, children: ItemNode[] = []) {
-    this.page = page;
-    this.children = children;
-  }
-
-  static generateNodesFromPages(pages: IPageForItem[]): ItemNode[] {
-    return pages.map(page => new ItemNode(page));
-  }
-
-}

+ 0 - 4
apps/app/src/client/components/ItemsTree/ItemsTree.module.scss

@@ -1,4 +0,0 @@
-/* stylelint-disable-next-line block-no-empty */
-.items-tree :global {
-
-}

+ 0 - 164
apps/app/src/client/components/ItemsTree/ItemsTree.tsx

@@ -1,164 +0,0 @@
-import React, { useEffect, useCallback, type JSX } from 'react';
-
-import path from 'path';
-
-import type { IPageToDeleteWithMeta } from '@growi/core';
-import { useTranslation } from 'next-i18next';
-import { useRouter } from 'next/router';
-
-import { toastError, toastSuccess } from '~/client/util/toastr';
-import type { IPageForItem } from '~/interfaces/page';
-import type { OnDuplicatedFunction, OnDeletedFunction } from '~/interfaces/ui';
-import type { UpdateDescCountData, UpdateDescCountRawData } from '~/interfaces/websocket';
-import { SocketEventName } from '~/interfaces/websocket';
-import { useCurrentPagePath, useFetchCurrentPage } from '~/states/page';
-import { useGlobalSocket } from '~/states/socket-io';
-import { usePageDeleteModalActions } from '~/states/ui/modal/page-delete';
-import type { IPageForPageDuplicateModal } from '~/states/ui/modal/page-duplicate';
-import { usePageDuplicateModalActions } from '~/states/ui/modal/page-duplicate';
-import { usePageTreeDescCountMapAction } from '~/states/ui/page-tree-desc-count-map';
-import { mutateAllPageInfo } from '~/stores/page';
-import {
-  useSWRxRootPage, mutatePageTree, mutatePageList,
-} from '~/stores/page-listing';
-import { mutateSearching } from '~/stores/search';
-import loggerFactory from '~/utils/logger';
-
-import { ItemNode, type TreeItemProps } from '../TreeItem';
-
-import ItemsTreeContentSkeleton from './ItemsTreeContentSkeleton';
-
-import styles from './ItemsTree.module.scss';
-
-const moduleClass = styles['items-tree'] ?? '';
-
-const logger = loggerFactory('growi:cli:ItemsTree');
-
-type ItemsTreeProps = {
-  isEnableActions: boolean
-  isReadOnlyUser: boolean
-  isWipPageShown?: boolean
-  targetPath: string
-  targetPathOrId?: string,
-  CustomTreeItem: React.FunctionComponent<TreeItemProps>
-  onClickTreeItem?: (page: IPageForItem) => void;
-}
-
-/*
- * ItemsTree
- */
-export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
-  const {
-    targetPath, targetPathOrId, isEnableActions, isReadOnlyUser, isWipPageShown, CustomTreeItem, onClickTreeItem,
-  } = props;
-
-  const { t } = useTranslation();
-  const router = useRouter();
-
-  const { data: rootPageResult, error } = useSWRxRootPage({ suspense: true });
-  const currentPagePath = useCurrentPagePath();
-  const { open: openDuplicateModal } = usePageDuplicateModalActions();
-  const { open: openDeleteModal } = usePageDeleteModalActions();
-
-  const socket = useGlobalSocket();
-  const { update: updatePtDescCountMap } = usePageTreeDescCountMapAction();
-
-  // for mutation
-  const { fetchCurrentPage } = useFetchCurrentPage();
-
-  useEffect(() => {
-    if (socket == null) {
-      return;
-    }
-
-    socket.on(SocketEventName.UpdateDescCount, (data: UpdateDescCountRawData) => {
-      // save to global state
-      const newData: UpdateDescCountData = new Map(Object.entries(data));
-
-      updatePtDescCountMap(newData);
-    });
-
-    return () => { socket.off(SocketEventName.UpdateDescCount) };
-
-  }, [socket, updatePtDescCountMap]);
-
-  const onRenamed = useCallback((fromPath: string | undefined, toPath: string) => {
-    mutatePageTree();
-    mutateSearching();
-    mutatePageList();
-
-    if (currentPagePath === fromPath || currentPagePath === toPath) {
-      fetchCurrentPage({ force: true });
-    }
-  }, [currentPagePath, fetchCurrentPage]);
-
-  const onClickDuplicateMenuItem = useCallback((pageToDuplicate: IPageForPageDuplicateModal) => {
-    // eslint-disable-next-line @typescript-eslint/no-unused-vars
-    const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
-      toastSuccess(t('duplicated_pages', { fromPath }));
-
-      mutatePageTree();
-      mutateSearching();
-      mutatePageList();
-    };
-
-    openDuplicateModal(pageToDuplicate, { onDuplicated: duplicatedHandler });
-  }, [openDuplicateModal, t]);
-
-  const onClickDeleteMenuItem = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
-    const onDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, isRecursively, isCompletely) => {
-      if (typeof pathOrPathsToDelete !== 'string') {
-        return;
-      }
-
-      if (isCompletely) {
-        toastSuccess(t('deleted_pages_completely', { path: pathOrPathsToDelete }));
-      }
-      else {
-        toastSuccess(t('deleted_pages', { path: pathOrPathsToDelete }));
-      }
-
-      mutatePageTree();
-      mutateSearching();
-      mutatePageList();
-      mutateAllPageInfo();
-
-      if (currentPagePath === pathOrPathsToDelete) {
-        fetchCurrentPage({ force: true });
-        router.push(isCompletely ? path.dirname(pathOrPathsToDelete) : `/trash${pathOrPathsToDelete}`);
-      }
-    };
-
-    openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
-  }, [currentPagePath, fetchCurrentPage, openDeleteModal, router, t]);
-
-
-  if (error != null) {
-    toastError(t('pagetree.error_retrieving_the_pagetree'));
-    return <></>;
-  }
-
-  const initialItemNode = rootPageResult ? new ItemNode(rootPageResult.rootPage) : null;
-  if (initialItemNode != null) {
-    return (
-      <ul className={`${moduleClass} list-group`}>
-        <CustomTreeItem
-          key={initialItemNode.page.path}
-          targetPath={targetPath}
-          targetPathOrId={targetPathOrId}
-          itemNode={initialItemNode}
-          isOpen
-          isEnableActions={isEnableActions}
-          isWipPageShown={isWipPageShown}
-          isReadOnlyUser={isReadOnlyUser}
-          onRenamed={onRenamed}
-          onClickDuplicateMenuItem={onClickDuplicateMenuItem}
-          onClickDeleteMenuItem={onClickDeleteMenuItem}
-          onClick={onClickTreeItem}
-        />
-      </ul>
-    );
-  }
-
-  return <ItemsTreeContentSkeleton />;
-};

+ 0 - 2
apps/app/src/client/components/ItemsTree/index.ts

@@ -1,2 +0,0 @@
-export { ItemNode } from './ItemNode';
-export * from './ItemsTree';

+ 17 - 20
apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -270,7 +270,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const revisionId = (revision != null && isPopulated(revision)) ? revision._id : undefined;
 
   const { editorMode } = useEditorMode();
-  const pageId = useCurrentPageId();
+  const pageId = useCurrentPageId(true);
   const currentUser = useCurrentUser();
   const isGuestUser = useIsGuestUser();
   const isReadOnlyUser = useIsReadOnlyUser();
@@ -291,7 +291,6 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
   const [isStickyActive, setStickyActive] = useState(false);
 
-
   const path = currentPage?.path ?? currentPathname;
   // const grant = currentPage?.grant ?? grantData?.grant;
   // const grantUserGroupId = currentPage?.grantedGroup?._id ?? grantData?.grantedGroup?.id;
@@ -342,7 +341,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const switchContentWidthHandler = useCallback(async (pageId: string, value: boolean) => {
     if (!isSharedPage) {
       await updateContentWidth(pageId, value);
-      fetchCurrentPage();
+      fetchCurrentPage({ force: true });
     }
   }, [isSharedPage, fetchCurrentPage]);
 
@@ -405,23 +404,21 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
             id="grw-contextual-sub-nav"
           >
 
-            {pageId != null && (
-              <PageControls
-                pageId={pageId}
-                revisionId={revisionId}
-                shareLinkId={shareLinkId}
-                path={path ?? currentPathname} // If the page is empty, "path" is undefined
-                expandContentWidth={shouldExpandContent}
-                disableSeenUserInfoPopover={isSharedUser}
-                hideSubControls={hideSubControls}
-                showPageControlDropdown={isAbleToShowPageManagement}
-                additionalMenuItemRenderer={additionalMenuItemsRenderer}
-                onClickDuplicateMenuItem={duplicateItemClickedHandler}
-                onClickRenameMenuItem={renameItemClickedHandler}
-                onClickDeleteMenuItem={deleteItemClickedHandler}
-                onClickSwitchContentWidth={switchContentWidthHandler}
-              />
-            )}
+            <PageControls
+              pageId={pageId}
+              revisionId={revisionId}
+              shareLinkId={shareLinkId}
+              path={path ?? currentPathname} // If the page is empty, "path" is undefined
+              expandContentWidth={shouldExpandContent}
+              disableSeenUserInfoPopover={isSharedUser}
+              hideSubControls={hideSubControls}
+              showPageControlDropdown={isAbleToShowPageManagement}
+              additionalMenuItemRenderer={additionalMenuItemsRenderer}
+              onClickDuplicateMenuItem={duplicateItemClickedHandler}
+              onClickRenameMenuItem={renameItemClickedHandler}
+              onClickDeleteMenuItem={deleteItemClickedHandler}
+              onClickSwitchContentWidth={switchContentWidthHandler}
+            />
 
             {isAbleToChangeEditorMode && (
               <PageEditorModeManager

+ 5 - 23
apps/app/src/client/components/Navbar/PageEditorModeManager.tsx

@@ -2,20 +2,15 @@ import React, {
   type ReactNode, useCallback, useMemo, type JSX,
 } from 'react';
 
-import { Origin } from '@growi/core';
-import { getParentPath } from '@growi/core/dist/utils/path-utils';
 import { useTranslation } from 'next-i18next';
 
 import { useCreatePage } from '~/client/services/create-page';
+import { useStartEditing } from '~/client/services/use-start-editing';
 import { toastError } from '~/client/util/toastr';
 import { useCurrentPageYjsData } from '~/features/collaborative-editor/states';
-import { usePageNotFound } from '~/states/page';
 import { useDeviceLargerThanMd } from '~/states/ui/device';
 import { useEditorMode, EditorMode } from '~/states/ui/editor';
 
-import { shouldCreateWipPage } from '../../../utils/should-create-wip-page';
-
-
 import styles from './PageEditorModeManager.module.scss';
 
 
@@ -66,34 +61,21 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
 
   const { t } = useTranslation('commons');
 
-  const isNotFound = usePageNotFound();
   const { setEditorMode } = useEditorMode();
   const [isDeviceLargerThanMd] = useDeviceLargerThanMd();
   const currentPageYjsData = useCurrentPageYjsData();
+  const startEditing = useStartEditing();
 
-  const { isCreating, create } = useCreatePage();
+  const { isCreating } = useCreatePage();
 
   const editButtonClickedHandler = useCallback(async () => {
-    if (!isNotFound) {
-      setEditorMode(EditorMode.Editor);
-      return;
-    }
-
-    // Create a new page if it does not exist and transit to the editor mode
     try {
-      const parentPath = path != null ? getParentPath(path) : undefined; // does not have to exist
-      await create(
-        {
-          path, parentPath, wip: shouldCreateWipPage(path), origin: Origin.View,
-        },
-      );
-
-      setEditorMode(EditorMode.Editor);
+      await startEditing(path);
     }
     catch (err) {
       toastError(t('toaster.create_failed', { target: path }));
     }
-  }, [create, isNotFound, setEditorMode, path, t]);
+  }, [startEditing, path, t]);
 
   const _isBtnDisabled = isCreating || isBtnDisabled;
 

+ 4 - 4
apps/app/src/client/components/Page/DisplaySwitcher.tsx

@@ -3,9 +3,8 @@ import type { JSX } from 'react';
 import dynamic from 'next/dynamic';
 
 import { useHashChangedEffect } from '~/client/services/side-effects/hash-changed';
-import { useIsEditable } from '~/states/page';
+import { useIsEditable, useRevisionIdFromUrl } from '~/states/page';
 import { EditorMode, useEditorMode, useReservedNextCaretLine } from '~/states/ui/editor';
-import { useSWRxIsLatestRevision } from '~/stores/page';
 
 import { LazyRenderer } from '../Common/LazyRenderer';
 
@@ -18,14 +17,15 @@ export const DisplaySwitcher = (): JSX.Element => {
 
   const { editorMode } = useEditorMode();
   const isEditable = useIsEditable();
-  const { data: isLatestRevision } = useSWRxIsLatestRevision();
+  const revisionIdFromUrl = useRevisionIdFromUrl();
 
   useHashChangedEffect();
   useReservedNextCaretLine();
 
   return (
     <LazyRenderer shouldRender={isEditable === true && editorMode === EditorMode.Editor}>
-      { isLatestRevision !== false
+      {/* Display <PageEditorReadOnly /> when the user is intentionally viewing a specific (past) revision. */}
+      { revisionIdFromUrl == null
         ? <PageEditor />
         : <PageEditorReadOnly />
       }

+ 36 - 40
apps/app/src/client/components/PageControls/PageControls.tsx

@@ -3,9 +3,11 @@ import React, {
 } from 'react';
 
 import type {
-  IPageInfoForOperation, IPageToDeleteWithMeta, IPageToRenameWithMeta,
+  IPageInfo, IPageToDeleteWithMeta, IPageToRenameWithMeta,
 } from '@growi/core';
 import {
+  isIPageInfoForEmpty,
+
   isIPageInfoForEntity, isIPageInfoForOperation,
 } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
@@ -104,7 +106,7 @@ const WideViewMenuItem = (props: WideViewMenuItemProps): JSX.Element => {
 
 
 type CommonProps = {
-  pageId: string,
+  pageId?: string,
   shareLinkId?: string | null,
   revisionId?: string | null,
   path?: string | null,
@@ -121,7 +123,7 @@ type CommonProps = {
 }
 
 type PageControlsSubstanceProps = CommonProps & {
-  pageInfo: IPageInfoForOperation,
+  pageInfo: IPageInfo | undefined,
   onClickEditTagsButton: () => void,
 }
 
@@ -167,10 +169,12 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   const seenUsers = usersList != null ? usersList.filter(({ _id }) => seenUserIds.includes(_id)).slice(0, 15) : [];
 
   const subscribeClickhandler = useCallback(async () => {
-    if (isGuestUser ?? true) {
+    if (isGuestUser) {
+      logger.warn('Guest users cannot subscribe to pages');
       return;
     }
-    if (!isIPageInfoForOperation(pageInfo)) {
+    if (!isIPageInfoForOperation(pageInfo) || pageId == null) {
+      logger.warn('PageInfo is not for operation or pageId is null');
       return;
     }
 
@@ -179,10 +183,12 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
 
   const likeClickhandler = useCallback(async () => {
-    if (isGuestUser ?? true) {
+    if (isGuestUser) {
+      logger.warn('Guest users cannot like pages');
       return;
     }
-    if (!isIPageInfoForOperation(pageInfo)) {
+    if (!isIPageInfoForOperation(pageInfo) || pageId == null) {
+      logger.warn('PageInfo is not for operation or pageId is null');
       return;
     }
 
@@ -191,7 +197,8 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
 
   const duplicateMenuItemClickHandler = useCallback(async (): Promise<void> => {
-    if (onClickDuplicateMenuItem == null || path == null) {
+    if (onClickDuplicateMenuItem == null || pageId == null || path == null) {
+      logger.warn('Cannot duplicate the page because onClickDuplicateMenuItem, pageId or path is null');
       return;
     }
     const page: IPageForPageDuplicateModal = { pageId, path };
@@ -200,7 +207,8 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   }, [onClickDuplicateMenuItem, pageId, path]);
 
   const renameMenuItemClickHandler = useCallback(async (): Promise<void> => {
-    if (onClickRenameMenuItem == null || path == null) {
+    if (onClickRenameMenuItem == null || pageId == null || path == null) {
+      logger.warn('Cannot rename the page because onClickRenameMenuItem, pageId or path is null');
       return;
     }
 
@@ -217,7 +225,8 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   }, [onClickRenameMenuItem, pageId, pageInfo, path, revisionId]);
 
   const deleteMenuItemClickHandler = useCallback(async (): Promise<void> => {
-    if (onClickDeleteMenuItem == null || path == null) {
+    if (onClickDeleteMenuItem == null || pageId == null || path == null) {
+      logger.warn('Cannot delete the page because onClickDeleteMenuItem, pageId or path is null');
       return;
     }
 
@@ -234,22 +243,22 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   }, [onClickDeleteMenuItem, pageId, pageInfo, path, revisionId]);
 
   const switchContentWidthClickHandler = useCallback(() => {
-    if (onClickSwitchContentWidth == null) {
+    if (isGuestUser || isReadOnlyUser) {
+      logger.warn('Guest or read-only users cannot switch content width');
       return;
     }
 
-    const newValue = !expandContentWidth;
-    if ((isGuestUser ?? true) || (isReadOnlyUser ?? true)) {
-      logger.warn('Could not switch content width', {
-        isGuestUser,
-        isReadOnlyUser,
-      });
+    if (onClickSwitchContentWidth == null || pageId == null) {
+      logger.warn('Cannot switch content width because onClickSwitchContentWidth or pageId is null');
       return;
     }
     if (!isIPageInfoForEntity(pageInfo)) {
+      logger.warn('PageInfo is not for entity');
       return;
     }
+
     try {
+      const newValue = !expandContentWidth;
       onClickSwitchContentWidth(pageId, newValue);
     }
     catch (err) {
@@ -287,21 +296,12 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     return wideviewMenuItemRenderer;
   }, [pageInfo, expandContentWidth, onClickSwitchContentWidth, switchContentWidthClickHandler]);
 
-  if (!isIPageInfoForEntity(pageInfo)) {
-    return <></>;
-  }
-
-  const {
-    sumOfLikers, sumOfSeenUsers, isLiked,
-  } = pageInfo;
-
   const forceHideMenuItemsWithAdditions = [
     ...(forceHideMenuItems ?? []),
     MenuItemType.BOOKMARK,
     MenuItemType.REVERT,
   ];
 
-  const _isIPageInfoForOperation = isIPageInfoForOperation(pageInfo);
   const isViewMode = editorMode === EditorMode.View;
 
   return (
@@ -313,7 +313,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
         </>
       )}
 
-      {revisionId != null && !isViewMode && _isIPageInfoForOperation && (
+      {revisionId != null && !isViewMode && (
         <Tags
           onClickEditTagsButton={onClickEditTagsButton}
         />
@@ -321,38 +321,38 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
 
       {!hideSubControls && (
         <div className={`hstack gap-1 ${!isViewMode && 'd-none d-lg-flex'}`}>
-          {revisionId != null && _isIPageInfoForOperation && (
+          {isIPageInfoForOperation(pageInfo) && (
             <SubscribeButton
               status={pageInfo.subscriptionStatus}
               onClick={subscribeClickhandler}
             />
           )}
-          {revisionId != null && _isIPageInfoForOperation && (
+          {isIPageInfoForOperation(pageInfo) && (
             <LikeButtons
               onLikeClicked={likeClickhandler}
-              sumOfLikers={sumOfLikers}
-              isLiked={isLiked}
+              sumOfLikers={pageInfo.sumOfLikers}
+              isLiked={pageInfo.isLiked}
               likers={likers}
             />
           )}
-          {revisionId != null && _isIPageInfoForOperation && (
+          {(isIPageInfoForOperation(pageInfo) || isIPageInfoForEmpty(pageInfo)) && pageId != null && (
             <BookmarkButtons
               pageId={pageId}
               isBookmarked={pageInfo.isBookmarked}
               bookmarkCount={pageInfo.bookmarkCount}
             />
           )}
-          {revisionId != null && !isSearchPage && (
+          {isIPageInfoForEntity(pageInfo) && !isSearchPage && (
             <SeenUserInfo
               seenUsers={seenUsers}
-              sumOfSeenUsers={sumOfSeenUsers}
+              sumOfSeenUsers={pageInfo.sumOfSeenUsers}
               disabled={disableSeenUserInfoPopover}
             />
           )}
         </div>
       )}
 
-      {showPageControlDropdown && _isIPageInfoForOperation && (
+      {showPageControlDropdown && (
         <PageItemControl
           pageId={pageId}
           pageInfo={pageInfo}
@@ -383,7 +383,7 @@ export const PageControls = memo((props: PageControlsProps): JSX.Element => {
   const { open: openTagEditModal } = useTagEditModalActions();
 
   const onClickEditTagsButton = useCallback(() => {
-    if (tagsInfoData == null || revisionId == null) {
+    if (tagsInfoData == null || pageId == null || revisionId == null) {
       return;
     }
     openTagEditModal(tagsInfoData.tags, pageId, revisionId);
@@ -393,10 +393,6 @@ export const PageControls = memo((props: PageControlsProps): JSX.Element => {
     return <></>;
   }
 
-  if (!isIPageInfoForEntity(pageInfo)) {
-    return <></>;
-  }
-
   return (
     <PageControlsSubstance
       pageInfo={pageInfo}

+ 40 - 35
apps/app/src/client/components/PageSelectModal/PageSelectModal.tsx

@@ -3,30 +3,35 @@ import {
   Suspense, useState, useCallback, useMemo,
 } from 'react';
 
-import nodePath from 'path';
-
-import { pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
+import { dirname } from 'pathe';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter, Button,
 } from 'reactstrap';
-import SimpleBar from 'simplebar-react';
 
-import type { IPageForItem } from '~/interfaces/page';
+import { ItemsTree } from '~/features/page-tree/components';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/states/context';
 import { useCurrentPageData } from '~/states/page';
-import { usePageSelectModalStatus, usePageSelectModalActions } from '~/states/ui/modal/page-select';
+import {
+  usePageSelectModalStatus,
+  usePageSelectModalActions,
+  useSelectedPageInModal,
+} from '~/states/ui/modal/page-select';
 
-import { ItemsTree } from '../ItemsTree';
 import ItemsTreeContentSkeleton from '../ItemsTree/ItemsTreeContentSkeleton';
 
-import { TreeItemForModal } from './TreeItemForModal';
+import { TreeItemForModal, treeItemForModalSize } from './TreeItemForModal';
 
 const PageSelectModalSubstance: FC = () => {
   const { close: closeModal } = usePageSelectModalActions();
 
-  const [clickedParentPage, setClickedParentPage] = useState<IPageForItem | null>(null);
   const [isIncludeSubPage, setIsIncludeSubPage] = useState(true);
+  const [scrollerElem, setScrollerElem] = useState<HTMLDivElement | null>(null);
+
+  // Callback ref to capture the scroller element and trigger re-render
+  const scrollerRefCallback = useCallback((node: HTMLDivElement | null) => {
+    setScrollerElem(node);
+  }, []);
 
   const { t } = useTranslation();
 
@@ -35,41 +40,35 @@ const PageSelectModalSubstance: FC = () => {
   const currentPage = useCurrentPageData();
   const { opts } = usePageSelectModalStatus();
 
-  const isHierarchicalSelectionMode = opts?.isHierarchicalSelectionMode ?? false;
-
-  const onClickTreeItem = useCallback((page: IPageForItem) => {
-    const parentPagePath = page.path;
+  // Get selected page from atom
+  const selectedPage = useSelectedPageInModal();
 
-    if (parentPagePath == null) {
-      return;
-    }
-
-    setClickedParentPage(page);
-  }, []);
+  const isHierarchicalSelectionMode = opts?.isHierarchicalSelectionMode ?? false;
 
   const onClickCancel = useCallback(() => {
-    setClickedParentPage(null);
     closeModal();
   }, [closeModal]);
 
   const { onSelected } = opts ?? {};
   const onClickDone = useCallback(() => {
-    if (clickedParentPage != null) {
-      onSelected?.(clickedParentPage, isIncludeSubPage);
+    if (selectedPage != null) {
+      onSelected?.(selectedPage, isIncludeSubPage);
     }
 
     closeModal();
-  }, [clickedParentPage, closeModal, isIncludeSubPage, onSelected]);
+  }, [selectedPage, closeModal, isIncludeSubPage, onSelected]);
 
-  // Memoize heavy calculation
-  const parentPagePath = useMemo(() => (
-    pathUtils.addTrailingSlash(nodePath.dirname(currentPage?.path ?? ''))
-  ), [currentPage?.path]);
+  // Memoize heavy calculation - parent page path without trailing slash for matching
+  const parentPagePath = useMemo(() => {
+    const dn = dirname(currentPage?.path ?? '');
+    // Ensure root path is '/' not ''
+    return dn === '' ? '/' : dn;
+  }, [currentPage?.path]);
 
-  // Memoize target path calculation (avoid duplication)
+  // Memoize target path calculation
   const targetPath = useMemo(() => (
-    clickedParentPage?.path || parentPagePath
-  ), [clickedParentPage?.path, parentPagePath]);
+    selectedPage?.path || parentPagePath
+  ), [selectedPage?.path, parentPagePath]);
 
   // Memoize checkbox handler
   const handleIncludeSubPageChange = useCallback(() => {
@@ -85,18 +84,24 @@ const PageSelectModalSubstance: FC = () => {
       <ModalHeader toggle={closeModal}>{t('page_select_modal.select_page_location')}</ModalHeader>
       <ModalBody className="p-0">
         <Suspense fallback={<ItemsTreeContentSkeleton />}>
-          <SimpleBar style={{ maxHeight: 'calc(85vh - 133px)' }}> {/* 133px = 63px(ModalHeader) + 70px(ModalFooter) */}
-            <div className="p-3">
+          {/* 133px = 63px(ModalHeader) + 70px(ModalFooter) */}
+          <div
+            ref={scrollerRefCallback}
+            className="p-3"
+            style={{ maxHeight: 'calc(85vh - 133px)', overflowY: 'auto' }}
+          >
+            {scrollerElem && (
               <ItemsTree
                 CustomTreeItem={TreeItemForModal}
                 isEnableActions={!isGuestUser}
                 isReadOnlyUser={!!isReadOnlyUser}
                 targetPath={targetPath}
                 targetPathOrId={targetPath}
-                onClickTreeItem={onClickTreeItem}
+                estimateTreeItemSize={() => treeItemForModalSize}
+                scrollerElem={scrollerElem}
               />
-            </div>
-          </SimpleBar>
+            )}
+          </div>
         </Suspense>
       </ModalBody>
       <ModalFooter className="border-top d-flex flex-column">

+ 25 - 13
apps/app/src/client/components/PageSelectModal/TreeItemForModal.tsx

@@ -1,27 +1,40 @@
 import type { FC } from 'react';
+import { useCallback, useMemo } from 'react';
 
-import {
-  TreeItemLayout, useNewPageInput, type TreeItemProps,
-} from '../TreeItem';
-
+import type { TreeItemProps } from '~/features/page-tree';
+import { TreeItemLayout } from '~/features/page-tree/components';
+import type { IPageForItem } from '~/interfaces/page';
+import { useSelectPageInModal } from '~/states/ui/modal/page-select';
 
 import styles from './TreeItemForModal.module.scss';
 
-const moduleClass = styles['tree-item-for-modal'];
+const moduleClass = styles['tree-item-for-modal'] ?? '';
 
+export const treeItemForModalSize = 36; // in px
 
 type TreeItemForModalProps = TreeItemProps & {
-  key?: React.Key | null,
+  key?: React.Key | null;
 };
 
 export const TreeItemForModal: FC<TreeItemForModalProps> = (props) => {
+  const {
+    item,
+    targetPathOrId,
+    onToggle,
+  } = props;
 
-  const { itemNode, targetPathOrId } = props;
-  const { page } = itemNode;
+  const page = item.getItemData();
+  const selectPage = useSelectPageInModal();
 
-  const { Input: NewPageInput, CreateButton: NewPageCreateButton } = useNewPageInput();
+  // Determine if this item is selected
+  const isSelected = useMemo(() => {
+    return page._id === targetPathOrId || page.path === targetPathOrId;
+  }, [page._id, page.path, targetPathOrId]);
 
-  const isSelected = page._id === targetPathOrId || page.path === targetPathOrId;
+  // Handle click to select this page
+  const handleClick = useCallback((selectedPage: IPageForItem) => {
+    selectPage(selectedPage);
+  }, [selectPage]);
 
   const itemClassNames = [
     isSelected ? 'active' : '',
@@ -31,10 +44,9 @@ export const TreeItemForModal: FC<TreeItemForModalProps> = (props) => {
     <TreeItemLayout
       {...props}
       className={moduleClass}
-      itemClass={TreeItemForModal}
       itemClassName={itemClassNames.join(' ')}
-      customHeadOfChildrenComponents={[NewPageInput]}
-      customHoveredEndComponents={[NewPageCreateButton]}
+      onClick={handleClick}
+      onToggle={onToggle}
     />
   );
 };

+ 4 - 3
apps/app/src/client/components/PageSideContents/PageSideContents.tsx

@@ -5,7 +5,8 @@ import React, {
   type JSX,
 } from 'react';
 
-import type { IPagePopulatedToShowRevision, IPageInfoForOperation } from '@growi/core';
+import type { IPagePopulatedToShowRevision } from '@growi/core';
+import { isIPageInfoForOperation } from '@growi/core/dist/interfaces';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
@@ -129,7 +130,7 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
               icon={<span className="material-symbols-outlined">subject</span>}
               label={t('page_list')}
               // Do not display CountBadge if '/trash/*': https://github.com/growilabs/growi/pull/7600
-              count={!isTrash && pageInfo != null ? (pageInfo as IPageInfoForOperation).descendantCount : undefined}
+              count={!isTrash && isIPageInfoForOperation(pageInfo) ? pageInfo.descendantCount : undefined}
               offset={1}
               onClick={() => openDescendantPageListModal(pagePath)}
             />
@@ -142,7 +143,7 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
             <PageAccessoriesControl
               icon={<span className="material-symbols-outlined">chat</span>}
               label={t('comments')}
-              count={pageInfo != null ? (pageInfo as IPageInfoForOperation).commentCount : undefined}
+              count={isIPageInfoForOperation(pageInfo) ? pageInfo.commentCount : undefined}
               onClick={() => scroller.scrollTo('comments-container', { smooth: false, offset: -120 })}
             />
           </div>

+ 6 - 4
apps/app/src/client/components/PageTags/TagEditModal/TagEditModal.tsx

@@ -11,6 +11,7 @@ import { useUpdateStateAfterSave } from '~/client/services/page-operation';
 import { apiPost } from '~/client/util/apiv1-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useTagEditModalStatus, useTagEditModalActions, type TagEditModalStatus } from '~/states/ui/modal/tag-edit';
+import { useSWRxTagsInfo } from '~/stores/page';
 
 import { TagsInput } from './TagsInput';
 
@@ -28,8 +29,8 @@ const TagEditModalSubstance: React.FC<TagEditModalSubstanceProps> = (props: TagE
   const pageId = tagEditModalData.pageId;
   const revisionId = tagEditModalData.revisionId;
   const updateStateAfterSave = useUpdateStateAfterSave(pageId);
-
-  const [tags, setTags] = useState<string[]>([]);
+  const { mutate: mutateTags } = useSWRxTagsInfo(pageId);
+  const [tags, setTags] = useState<string[]>(initTags ?? []);
 
   // use to take initTags when redirect to other page
   useEffect(() => {
@@ -46,6 +47,7 @@ const TagEditModalSubstance: React.FC<TagEditModalSubstanceProps> = (props: TagE
   const handleSubmit = useCallback(async() => {
     try {
       await apiPost('/tags.update', updateTagsData);
+      mutateTags();
       updateStateAfterSave?.();
 
       toastSuccess('updated tags successfully');
@@ -54,7 +56,7 @@ const TagEditModalSubstance: React.FC<TagEditModalSubstanceProps> = (props: TagE
     catch (err) {
       toastError(err);
     }
-  }, [closeTagEditModal, updateTagsData, updateStateAfterSave]);
+  }, [updateTagsData, mutateTags, updateStateAfterSave, closeTagEditModal]);
 
   // Memoized tags update handler
   const handleTagsUpdate = useCallback((newTags: string[]) => {
@@ -67,7 +69,7 @@ const TagEditModalSubstance: React.FC<TagEditModalSubstanceProps> = (props: TagE
         {t('tag_edit_modal.edit_tags')}
       </ModalHeader>
       <ModalBody>
-        <TagsInput tags={initTags} onTagsUpdated={handleTagsUpdate} autoFocus />
+        <TagsInput tags={tags} onTagsUpdated={handleTagsUpdate} autoFocus />
       </ModalBody>
       <ModalFooter>
         <button type="button" data-testid="tag-edit-done-btn" className="btn btn-primary" onClick={handleSubmit}>

+ 14 - 66
apps/app/src/client/components/Sidebar/PageTree/PageTreeSubstance.tsx

@@ -1,10 +1,11 @@
 import React, {
-  memo, useCallback, useEffect, useMemo, useRef, useState,
+  memo, useCallback,
 } from 'react';
 
 import { useTranslation } from 'next-i18next';
-import { debounce } from 'throttle-debounce';
 
+import { ItemsTree } from '~/features/page-tree/components';
+import { usePageTreeInformationUpdate } from '~/features/page-tree/states/page-tree-update';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/states/context';
 import { useCurrentPageId, useCurrentPagePath } from '~/states/page';
 import { useSidebarScrollerElem } from '~/states/ui/sidebar';
@@ -13,8 +14,7 @@ import {
 } from '~/stores/page-listing';
 import loggerFactory from '~/utils/logger';
 
-import { ItemsTree } from '../../ItemsTree/ItemsTree';
-import { PageTreeItem } from '../PageTreeItem';
+import { PageTreeItem, pageTreeItemSize } from '../PageTreeItem';
 import { SidebarHeaderReloadButton } from '../SidebarHeaderReloadButton';
 
 import { PrivateLegacyPagesLink } from './PrivateLegacyPagesLink';
@@ -31,12 +31,15 @@ export const PageTreeHeader = memo(({ isWipPageShown, onWipPageShownChange }: He
 
   const { mutate: mutateRootPage } = useSWRxRootPage({ suspense: true });
   useSWRxV5MigrationStatus({ suspense: true });
+  const { notifyUpdateAllTrees } = usePageTreeInformationUpdate();
 
   const mutate = useCallback(() => {
     mutateRootPage();
     mutatePageTree();
     mutateRecentlyUpdated();
-  }, [mutateRootPage]);
+    // Notify headless-tree to rebuild with fresh data
+    notifyUpdateAllTrees();
+  }, [mutateRootPage, notifyUpdateAllTrees]);
 
   return (
     <>
@@ -103,68 +106,9 @@ export const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) =
   const targetPathOrId = targetId || currentPath;
   const path = currentPath || '/';
 
-  const { data: rootPageResult } = useSWRxRootPage({ suspense: true });
   const sidebarScrollerElem = useSidebarScrollerElem();
-  const [isInitialScrollCompleted, setIsInitialScrollCompleted] = useState(false);
-
-  const rootElemRef = useRef<HTMLDivElement>(null);
-
-  // ***************************  Scroll on init ***************************
-  const scrollOnInit = useCallback(() => {
-    const rootElement = rootElemRef.current;
-    const scrollElement = sidebarScrollerElem;
-
-    if (rootElement == null || scrollElement == null) {
-      return;
-    }
-
-    const scrollTargetElement = rootElement.querySelector<HTMLElement>('[aria-current]');
-
-    if (scrollTargetElement == null) {
-      return;
-    }
-
-    logger.debug('scrollOnInit has invoked');
-
-
-    // NOTE: could not use scrollIntoView
-    //  https://stackoverflow.com/questions/11039885/scrollintoview-causing-the-whole-page-to-move
-
-    // calculate the center point
-    const scrollTop = scrollTargetElement.offsetTop - scrollElement.getBoundingClientRect().height / 2;
-    scrollElement.scrollTo({ top: scrollTop });
-
-    setIsInitialScrollCompleted(true);
-  }, [sidebarScrollerElem]);
-
-  const scrollOnInitDebounced = useMemo(() => debounce(500, scrollOnInit), [scrollOnInit]);
-
-  useEffect(() => {
-    if (isInitialScrollCompleted || rootPageResult == null) {
-      return;
-    }
-
-    const rootElement = rootElemRef.current as HTMLElement | null;
-    if (rootElement == null) {
-      return;
-    }
-
-    const observerCallback = (mutationRecords: MutationRecord[]) => {
-      mutationRecords.forEach(() => scrollOnInitDebounced());
-    };
-
-    const observer = new MutationObserver(observerCallback);
-    observer.observe(rootElement, { childList: true, subtree: true });
-
-    // first call for the situation that all rendering is complete at this point
-    scrollOnInitDebounced();
-
-    return () => {
-      observer.disconnect();
-    };
-  }, [isInitialScrollCompleted, scrollOnInitDebounced, rootPageResult]);
-  // *******************************  end  *******************************
 
+  const estimateTreeItemSize = useCallback(() => pageTreeItemSize, []);
 
   if (!migrationStatus?.isV5Compatible) {
     return <PageTreeUnavailable />;
@@ -178,14 +122,18 @@ export const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) =
   }
 
   return (
-    <div ref={rootElemRef} className="pt-4">
+    <div className="pt-4">
       <ItemsTree
+        enableRenaming
+        enableDragAndDrop
         isEnableActions={!isGuestUser}
         isReadOnlyUser={!!isReadOnlyUser}
         isWipPageShown={isWipPageShown}
         targetPath={path}
         targetPathOrId={targetPathOrId}
         CustomTreeItem={PageTreeItem}
+        estimateTreeItemSize={estimateTreeItemSize}
+        scrollerElem={sidebarScrollerElem}
       />
 
       {!isGuestUser && !isReadOnlyUser && migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (

+ 4 - 3
apps/app/src/client/components/Sidebar/PageTreeItem/CountBadgeForPageTreeItem.tsx

@@ -1,14 +1,15 @@
 import type { JSX } from 'react';
 
 import CountBadge from '~/client/components/Common/CountBadge';
-import type { TreeItemToolProps } from '~/client/components/TreeItem';
-import { usePageTreeDescCountMap } from '~/states/ui/page-tree-desc-count-map';
+import type { TreeItemToolProps } from '~/features/page-tree/interfaces';
+import { usePageTreeDescCountMap } from '~/features/page-tree/states';
 
 
 export const CountBadgeForPageTreeItem = (props: TreeItemToolProps): JSX.Element => {
   const { getDescCount } = usePageTreeDescCountMap();
 
-  const { page } = props.itemNode;
+  const { item } = props;
+  const page = item.getItemData();
 
   const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
 

+ 108 - 162
apps/app/src/client/components/Sidebar/PageTreeItem/PageTreeItem.tsx

@@ -1,211 +1,157 @@
-import React, {
-  useCallback, useState, type JSX,
-} from 'react';
+import type { FC } from 'react';
+import { useCallback } from 'react';
 
-import nodePath from 'path';
+import path from 'path';
 
-import type { IPageHasId } from '@growi/core';
-import { pagePathUtils, pathUtils } from '@growi/core/dist/utils';
+import type { IPageToDeleteWithMeta } from '@growi/core/dist/interfaces';
+import { getIdStringForRef } from '@growi/core/dist/interfaces';
+import { pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
-import { useDrag, useDrop } from 'react-dnd';
 
-import { apiv3Put } from '~/client/util/apiv3-client';
-import { toastWarning, toastError } from '~/client/util/toastr';
+import { toastSuccess } from '~/client/util/toastr';
+import type { TreeItemProps } from '~/features/page-tree';
+import {
+  usePageTreeInformationUpdate, usePageRename, usePageCreate,
+  usePlaceholderRenameEffect,
+} from '~/features/page-tree';
+import { TreeNameInput, TreeItemLayout } from '~/features/page-tree/components';
 import type { IPageForItem } from '~/interfaces/page';
-import { mutatePageTree, useSWRxPageChildren } from '~/stores/page-listing';
-import loggerFactory from '~/utils/logger';
+import type { OnDeletedFunction, OnDuplicatedFunction } from '~/interfaces/ui';
+import { useCurrentPagePath, useFetchCurrentPage } from '~/states/page';
+import { usePageDeleteModalActions } from '~/states/ui/modal/page-delete';
+import type { IPageForPageDuplicateModal } from '~/states/ui/modal/page-duplicate';
+import { usePageDuplicateModalActions } from '~/states/ui/modal/page-duplicate';
+import { mutateAllPageInfo } from '~/stores/page';
+import { mutatePageTree, mutatePageList } from '~/stores/page-listing';
+import { mutateSearching } from '~/stores/search';
 
-import type { ItemNode } from '../../TreeItem';
-import {
-  TreeItemLayout, useNewPageInput, type TreeItemProps,
-} from '../../TreeItem';
 
 import { CountBadgeForPageTreeItem } from './CountBadgeForPageTreeItem';
-import { CreatingNewPageSpinner } from './CreatingNewPageSpinner';
 import { usePageItemControl } from './use-page-item-control';
 
-
 import styles from './PageTreeItem.module.scss';
 
 const moduleClass = styles['page-tree-item'] ?? '';
 
 
-const logger = loggerFactory('growi:cli:Item');
+export const pageTreeItemSize = 40; // in px
 
-export const PageTreeItem = (props:TreeItemProps): JSX.Element => {
+
+export const PageTreeItem: FC<TreeItemProps> = ({
+  item,
+  targetPath,
+  targetPathOrId,
+  isWipPageShown,
+  isEnableActions = false,
+  isReadOnlyUser = false,
+  onToggle,
+}) => {
+  const { t } = useTranslation();
   const router = useRouter();
 
-  const getNewPathAfterMoved = (droppedPagePath: string, newParentPagePath: string): string => {
-    const pageTitle = nodePath.basename(droppedPagePath);
-    return nodePath.join(newParentPagePath, pageTitle);
-  };
+  const itemData = item.getItemData();
+
+  const currentPagePath = useCurrentPagePath();
+  const { fetchCurrentPage } = useFetchCurrentPage();
+
+  const { open: openDuplicateModal } = usePageDuplicateModalActions();
+  const { open: openDeleteModal } = usePageDeleteModalActions();
+  const { notifyUpdateItems } = usePageTreeInformationUpdate();
+
+  const onClickDuplicateMenuItem = useCallback((page: IPageForPageDuplicateModal) => {
+    const duplicatedHandler: OnDuplicatedFunction = (fromPath) => {
+      toastSuccess(t('duplicated_pages', { fromPath }));
+
+      mutatePageTree();
+      mutateSearching();
+      mutatePageList();
 
-  const isDroppable = (fromPage?: Partial<IPageHasId>, newParentPage?: Partial<IPageHasId>, printLog = false): boolean => {
-    if (fromPage == null || newParentPage == null || fromPage.path == null || newParentPage.path == null) {
-      if (printLog) {
-        logger.warn('Any of page, page.path or droppedPage.path is null');
+      // Notify headless-tree update
+      const parentIds = itemData.parent != null ? [getIdStringForRef(itemData.parent)] : undefined;
+      notifyUpdateItems(parentIds);
+    };
+
+    openDuplicateModal(page, { onDuplicated: duplicatedHandler });
+  }, [openDuplicateModal, t, notifyUpdateItems, itemData.parent]);
+
+  const onClickDeleteMenuItem = useCallback((page: IPageToDeleteWithMeta) => {
+    const onDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, isRecursively, isCompletely) => {
+      if (typeof pathOrPathsToDelete !== 'string') {
+        return;
+      }
+
+      if (isCompletely) {
+        toastSuccess(t('deleted_pages_completely', { path: pathOrPathsToDelete }));
+      }
+      else {
+        toastSuccess(t('deleted_pages', { path: pathOrPathsToDelete }));
       }
-      return false;
-    }
 
-    const newPathAfterMoved = getNewPathAfterMoved(fromPage.path, newParentPage.path);
-    return pagePathUtils.canMoveByPath(fromPage.path, newPathAfterMoved) && !pagePathUtils.isUsersTopPage(newParentPage.path);
-  };
+      mutatePageTree();
+      mutateSearching();
+      mutatePageList();
+      mutateAllPageInfo();
 
-  const { t } = useTranslation();
+      if (currentPagePath === pathOrPathsToDelete) {
+        fetchCurrentPage({ force: true });
+        router.push(isCompletely ? path.dirname(pathOrPathsToDelete) : `/trash${pathOrPathsToDelete}`);
+      }
 
-  const {
-    itemNode, targetPathOrId, isOpen: _isOpen = false, onRenamed,
-  } = props;
+      // Notify headless-tree update
+      const parentIds = itemData.parent != null ? [getIdStringForRef(itemData.parent)] : undefined;
+      notifyUpdateItems(parentIds);
+    };
 
-  const { page } = itemNode;
-  const [isOpen, setIsOpen] = useState(_isOpen);
+    openDeleteModal([page], { onDeleted: onDeletedHandler });
+  }, [openDeleteModal, t, currentPagePath, fetchCurrentPage, router, itemData.parent, notifyUpdateItems]);
 
-  const { mutate: mutateChildren } = useSWRxPageChildren(isOpen ? page._id : null);
+  const { Control } = usePageItemControl();
 
-  const {
-    showRenameInput, Control, RenameInput,
-  } = usePageItemControl();
-  const { isProcessingSubmission, Input: NewPageInput, CreateButton: NewPageCreateButton } = useNewPageInput();
+  // Rename feature from usePageRename hook
+  const { isRenaming } = usePageRename();
+
+  // Page create feature
+  const { cancelCreating, CreateButton, isCreatingPlaceholder } = usePageCreate();
+
+  // Manage placeholder renaming mode (auto-start, track, and cancel on Esc)
+  usePlaceholderRenameEffect({
+    item,
+    onCancelCreate: cancelCreating,
+  });
 
   const itemSelectedHandler = useCallback((page: IPageForItem) => {
-    if (page.path == null || page._id == null) {
-      return;
-    }
+    if (page.path == null || page._id == null) return;
 
     const link = pathUtils.returnPathForURL(page.path, page._id);
-
     router.push(link);
   }, [router]);
 
   const itemSelectedByWheelClickHandler = useCallback((page: IPageForItem) => {
-    if (page.path == null || page._id == null) {
-      return;
-    }
+    if (page.path == null || page._id == null) return;
 
     const url = pathUtils.returnPathForURL(page.path, page._id);
-
     window.open(url, '_blank');
   }, []);
 
-  const [, drag] = useDrag({
-    type: 'PAGE_TREE',
-    item: { page },
-    canDrag: () => {
-      if (page.path == null) {
-        return false;
-      }
-      return !pagePathUtils.isUsersProtectedPages(page.path);
-    },
-    end: (item, monitor) => {
-      // in order to set d-none to dropped Item
-      const dropResult = monitor.getDropResult();
-    },
-    collect: monitor => ({
-      isDragging: monitor.isDragging(),
-      canDrag: monitor.canDrag(),
-    }),
-  });
-
-  const pageItemDropHandler = async(item: ItemNode) => {
-    const { page: droppedPage } = item;
-    if (!isDroppable(droppedPage, page, true)) {
-      return;
-    }
-    if (droppedPage.path == null || page.path == null) {
-      return;
-    }
-    const newPagePath = getNewPathAfterMoved(droppedPage.path, page.path);
-    try {
-      await apiv3Put('/pages/rename', {
-        pageId: droppedPage._id,
-        revisionId: droppedPage.revision,
-        newPagePath,
-        isRenameRedirect: false,
-        updateMetadata: true,
-      });
-      await mutatePageTree();
-      await mutateChildren();
-      if (onRenamed != null) {
-        onRenamed(page.path, newPagePath);
-      }
-      // force open
-      setIsOpen(true);
-    }
-    catch (err) {
-      if (err.code === 'operation__blocked') {
-        toastWarning(t('pagetree.you_cannot_move_this_page_now'));
-      }
-      else {
-        toastError(t('pagetree.something_went_wrong_with_moving_page'));
-      }
-    }
-  };
-
-  const [{ isOver }, drop] = useDrop<ItemNode, Promise<void>, { isOver: boolean }>(
-    () => ({
-      accept: 'PAGE_TREE',
-      drop: pageItemDropHandler,
-      hover: (item, monitor) => {
-        // when a drag item is overlapped more than 1 sec, the drop target item will be opened.
-        if (monitor.isOver()) {
-          setTimeout(() => {
-            if (monitor.isOver()) {
-              setIsOpen(true);
-            }
-          }, 600);
-        }
-      },
-      canDrop: (item) => {
-        const { page: droppedPage } = item;
-        return isDroppable(droppedPage, page);
-      },
-      collect: monitor => ({
-        isOver: monitor.isOver(),
-      }),
-    }),
-    [page],
-  );
-
-  const itemRef = (c) => {
-    // do not apply when RenameInput is shown
-    if (showRenameInput) return;
-
-    drag(c);
-    drop(c);
-  };
-
-  const isSelected = page._id === targetPathOrId || page.path === targetPathOrId;
-  const itemClassNames = [
-    isOver ? 'drag-over' : '',
-    page.path !== '/' && isSelected ? 'active' : '', // set 'active' except the root page
-  ];
-
   return (
     <TreeItemLayout
       className={moduleClass}
-      targetPath={props.targetPath}
-      targetPathOrId={props.targetPathOrId}
-      itemLevel={props.itemLevel}
-      itemNode={props.itemNode}
-      isOpen={isOpen}
-      isEnableActions={props.isEnableActions}
-      isReadOnlyUser={props.isReadOnlyUser}
-      isWipPageShown={props.isWipPageShown}
+      item={item}
+      targetPath={targetPath}
+      targetPathOrId={targetPathOrId ?? undefined}
+      isWipPageShown={isWipPageShown}
+      isEnableActions={isEnableActions}
+      isReadOnlyUser={isReadOnlyUser}
       onClick={itemSelectedHandler}
-      onClickDuplicateMenuItem={props.onClickDuplicateMenuItem}
-      onClickDeleteMenuItem={props.onClickDeleteMenuItem}
       onWheelClick={itemSelectedByWheelClickHandler}
-      onRenamed={props.onRenamed}
-      itemRef={itemRef}
-      itemClass={PageTreeItem}
-      itemClassName={itemClassNames.join(' ')}
+      onToggle={onToggle}
+      onClickDuplicateMenuItem={onClickDuplicateMenuItem}
+      onClickDeleteMenuItem={onClickDeleteMenuItem}
       customEndComponents={[CountBadgeForPageTreeItem]}
-      customHoveredEndComponents={[Control, NewPageCreateButton]}
-      customHeadOfChildrenComponents={[NewPageInput, () => <CreatingNewPageSpinner show={isProcessingSubmission} />]}
-      showAlternativeContent={showRenameInput}
-      customAlternativeComponents={[RenameInput]}
+      customHoveredEndComponents={[Control, CreateButton]}
+      showAlternativeContent={isRenaming(item) || isCreatingPlaceholder(item)}
+      customAlternativeComponents={[TreeNameInput]}
     />
   );
 };

+ 10 - 116
apps/app/src/client/components/Sidebar/PageTreeItem/use-page-item-control.tsx

@@ -1,50 +1,37 @@
-import type { ChangeEvent, FC } from 'react';
-import React, {
-  useCallback, useRef, useState,
-} from 'react';
-
-import nodePath from 'path';
+import type { FC } from 'react';
+import React, { useCallback } from 'react';
 
 import type { IPageInfoExt, IPageToDeleteWithMeta } from '@growi/core';
-import { pathUtils } from '@growi/core/dist/utils';
-import { useRect } from '@growi/ui/dist/utils';
+import { getIdStringForRef } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { DropdownToggle } from 'reactstrap';
-import { debounce } from 'throttle-debounce';
 
-import { AutosizeSubmittableInput, getAdjustedMaxWidthForAutosizeInput } from '~/client/components/Common/SubmittableInput';
 import { NotAvailableForGuest } from '~/client/components/NotAvailableForGuest';
 import { bookmark, unbookmark, resumeRenameOperation } from '~/client/services/page-operation';
-import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
-import { ValidationTarget, useInputValidator, type InputValidationResult } from '~/client/util/use-input-validator';
+import type { TreeItemToolProps } from '~/features/page-tree/interfaces';
 import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
 import { useSWRMUTxPageInfo } from '~/stores/page';
 
 import { PageItemControl } from '../../Common/Dropdown/PageItemControl';
-import type { TreeItemToolProps } from '../../TreeItem';
 
 
 type UsePageItemControl = {
   Control: FC<TreeItemToolProps>,
-  RenameInput: FC<TreeItemToolProps>,
-  showRenameInput: boolean,
 }
 
 export const usePageItemControl = (): UsePageItemControl => {
   const { t } = useTranslation();
 
-  const [showRenameInput, setShowRenameInput] = useState(false);
-
 
   const Control: FC<TreeItemToolProps> = (props) => {
     const {
-      itemNode,
+      item,
       isEnableActions,
       isReadOnlyUser,
       onClickDuplicateMenuItem, onClickDeleteMenuItem,
     } = props;
-    const { page } = itemNode;
+    const page = item.getItemData();
 
     const { trigger: mutateCurrentUserBookmarks } = useSWRMUTxCurrentUserBookmarks();
     const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(page._id ?? null);
@@ -73,8 +60,9 @@ export const usePageItemControl = (): UsePageItemControl => {
     }, [onClickDuplicateMenuItem, page]);
 
     const renameMenuItemClickHandler = useCallback(() => {
-      setShowRenameInput(true);
-    }, []);
+      // Use headless-tree's renamingFeature
+      item.startRenaming();
+    }, [item]);
 
     const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoExt | undefined): Promise<void> => {
       if (onClickDeleteMenuItem == null) {
@@ -88,7 +76,7 @@ export const usePageItemControl = (): UsePageItemControl => {
       const pageToDelete: IPageToDeleteWithMeta = {
         data: {
           _id: page._id,
-          revision: page.revision as string,
+          revision: page.revision != null ? getIdStringForRef(page.revision) : null,
           path: page.path,
         },
         meta: pageInfo,
@@ -134,102 +122,8 @@ export const usePageItemControl = (): UsePageItemControl => {
   };
 
 
-  const RenameInput: FC<TreeItemToolProps> = (props) => {
-    const { itemNode, onRenamed } = props;
-    const { page } = itemNode;
-
-    const parentRef = useRef<HTMLDivElement>(null);
-    const [parentRect] = useRect(parentRef);
-
-    const [validationResult, setValidationResult] = useState<InputValidationResult>();
-
-
-    const inputValidator = useInputValidator(ValidationTarget.PAGE);
-
-    const changeHandler = useCallback(async(e: ChangeEvent<HTMLInputElement>) => {
-      const validationResult = inputValidator(e.target.value);
-      setValidationResult(validationResult ?? undefined);
-    }, [inputValidator]);
-    const changeHandlerDebounced = debounce(300, changeHandler);
-
-    const cancel = useCallback(() => {
-      setValidationResult(undefined);
-      setShowRenameInput(false);
-    }, []);
-
-    const rename = useCallback(async(inputText) => {
-      if (inputText.trim() === '') {
-        return cancel();
-      }
-
-      const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(page.path ?? ''));
-      const newPagePath = nodePath.resolve(parentPath, inputText);
-
-      if (newPagePath === page.path) {
-        setValidationResult(undefined);
-        setShowRenameInput(false);
-        return;
-      }
-
-      try {
-        await apiv3Put('/pages/rename', {
-          pageId: page._id,
-          revisionId: page.revision,
-          newPagePath,
-        });
-
-        onRenamed?.(page.path, newPagePath);
-        setShowRenameInput(false);
-
-        toastSuccess(t('renamed_pages', { path: page.path }));
-      }
-      catch (err) {
-        toastError(err);
-      }
-      finally {
-        setValidationResult(undefined);
-      }
-
-    }, [cancel, onRenamed, page._id, page.path, page.revision]);
-
-
-    if (!showRenameInput) {
-      return <></>;
-    }
-
-    const isInvalid = validationResult != null;
-
-    const maxWidth = parentRect != null
-      ? getAdjustedMaxWidthForAutosizeInput(parentRect.width, 'sm', validationResult != null ? false : undefined)
-      : undefined;
-
-    return (
-      <div ref={parentRef} className="flex-fill">
-        <AutosizeSubmittableInput
-          value={nodePath.basename(page.path ?? '')}
-          inputClassName={`form-control ${isInvalid ? 'is-invalid' : ''}`}
-          inputStyle={{ maxWidth }}
-          placeholder={t('Input page name')}
-          aria-describedby={isInvalid ? 'rename-feedback' : undefined}
-          onChange={changeHandlerDebounced}
-          onSubmit={rename}
-          onCancel={cancel}
-          autoFocus
-        />
-        { isInvalid && (
-          <div id="rename-feedback" className="invalid-feedback d-block my-1">
-            {validationResult.message}
-          </div>
-        ) }
-      </div>
-    );
-  };
-
-
   return {
     Control,
-    RenameInput,
-    showRenameInput,
   };
 
 };

+ 1 - 3
apps/app/src/client/components/Sidebar/SidebarHeaderReloadButton.tsx

@@ -1,10 +1,8 @@
-import React from 'react';
-
 type Props = {
   onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void
 };
 
-export const SidebarHeaderReloadButton = ({ onClick }: Props) => {
+export const SidebarHeaderReloadButton = ({ onClick }: Props): JSX.Element => {
 
   return (
     <button type="button" className="btn btn-sm ms-auto py-0 grw-btn-reload" onClick={onClick}>

+ 0 - 1
apps/app/src/client/components/TemplateModal/TemplateModal.tsx

@@ -67,7 +67,6 @@ const TemplateListGroupItem: React.FC<TemplateSummaryItemProps> = ({
     <a
       className={`list-group-item list-group-item-action ${isSelected ? 'active' : ''}`}
       onClick={onClick}
-      aria-current="true"
     >
       <h4 className="mb-1 d-flex">
         <span className="d-inline-block text-truncate">{localizedTemplate.title}</span>

+ 0 - 18
apps/app/src/client/components/TreeItem/ItemNode.ts

@@ -1,18 +0,0 @@
-import type { IPageForItem } from '../../../interfaces/page';
-
-export class ItemNode {
-
-  page: IPageForItem;
-
-  children: ItemNode[];
-
-  constructor(page: IPageForItem, children: ItemNode[] = []) {
-    this.page = page;
-    this.children = children;
-  }
-
-  static generateNodesFromPages(pages: IPageForItem[]): ItemNode[] {
-    return pages.map(page => new ItemNode(page));
-  }
-
-}

+ 0 - 37
apps/app/src/client/components/TreeItem/NewPageInput/NewPageCreateButton.tsx

@@ -1,37 +0,0 @@
-import React, { type FC } from 'react';
-
-import { pagePathUtils } from '@growi/core/dist/utils';
-
-import { NotAvailableForGuest } from '~/client/components/NotAvailableForGuest';
-import { NotAvailableForReadOnlyUser } from '~/client/components/NotAvailableForReadOnlyUser';
-import type { IPageForItem } from '~/interfaces/page';
-
-type NewPageCreateButtonProps = {
-  page: IPageForItem,
-  onClick?: () => void,
-};
-
-export const NewPageCreateButton: FC<NewPageCreateButtonProps> = (props) => {
-  const {
-    page, onClick,
-  } = props;
-
-  return (
-    <>
-      {!pagePathUtils.isUsersTopPage(page.path ?? '') && (
-        <NotAvailableForGuest>
-          <NotAvailableForReadOnlyUser>
-            <button
-              id="page-create-button-in-page-tree"
-              type="button"
-              className="border-0 rounded btn btn-page-item-control p-0"
-              onClick={onClick}
-            >
-              <span className="material-symbols-outlined p-0">add_circle</span>
-            </button>
-          </NotAvailableForReadOnlyUser>
-        </NotAvailableForGuest>
-      )}
-    </>
-  );
-};

+ 0 - 6
apps/app/src/client/components/TreeItem/NewPageInput/NewPageInput.module.scss

@@ -1,6 +0,0 @@
-@use '../tree-item-variables';
-
-.new-page-input-container {
-  width: calc(100% - tree-item-variables.$btn-triangle-min-width);
-  padding-left: 24px;
-}

+ 0 - 1
apps/app/src/client/components/TreeItem/NewPageInput/index.ts

@@ -1 +0,0 @@
-export * from './use-new-page-input';

+ 0 - 180
apps/app/src/client/components/TreeItem/NewPageInput/use-new-page-input.tsx

@@ -1,180 +0,0 @@
-import type { ChangeEvent } from 'react';
-import React, {
-  useState, type FC, useCallback, useRef,
-} from 'react';
-
-import nodePath from 'path';
-
-import { Origin } from '@growi/core';
-import { pathUtils, pagePathUtils } from '@growi/core/dist/utils';
-import { useRect } from '@growi/ui/dist/utils';
-import { useTranslation } from 'next-i18next';
-import { debounce } from 'throttle-debounce';
-
-import { AutosizeSubmittableInput, getAdjustedMaxWidthForAutosizeInput } from '~/client/components/Common/SubmittableInput';
-import { useCreatePage } from '~/client/services/create-page';
-import { toastWarning, toastError, toastSuccess } from '~/client/util/toastr';
-import type { InputValidationResult } from '~/client/util/use-input-validator';
-import { ValidationTarget, useInputValidator } from '~/client/util/use-input-validator';
-import { usePageTreeDescCountMap } from '~/states/ui/page-tree-desc-count-map';
-import { mutatePageTree, mutateRecentlyUpdated } from '~/stores/page-listing';
-
-import { shouldCreateWipPage } from '../../../../utils/should-create-wip-page';
-import type { TreeItemToolProps } from '../interfaces';
-
-import { NewPageCreateButton } from './NewPageCreateButton';
-
-
-import newPageInputStyles from './NewPageInput.module.scss';
-
-
-type UseNewPageInput = {
-  Input: FC<TreeItemToolProps>,
-  CreateButton: FC<TreeItemToolProps>,
-  isProcessingSubmission: boolean,
-}
-
-export const useNewPageInput = (): UseNewPageInput => {
-
-  const [showInput, setShowInput] = useState(false);
-  const [isProcessingSubmission, setProcessingSubmission] = useState(false);
-
-  const CreateButton: FC<TreeItemToolProps> = (props) => {
-
-    const { itemNode, stateHandlers } = props;
-    const { page } = itemNode;
-
-    const onClick = useCallback(() => {
-      setShowInput(true);
-      stateHandlers?.setIsOpen(true);
-    }, [stateHandlers]);
-
-    return (
-      <NewPageCreateButton
-        page={page}
-        onClick={onClick}
-      />
-    );
-  };
-
-  const Input: FC<TreeItemToolProps> = (props) => {
-
-    const { t } = useTranslation();
-    const { create: createPage } = useCreatePage();
-
-    const { itemNode, stateHandlers, isEnableActions } = props;
-    const { page, children } = itemNode;
-
-    const { getDescCount } = usePageTreeDescCountMap();
-    const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
-
-    const isChildrenLoaded = children?.length > 0;
-    const hasDescendants = descendantCount > 0 || isChildrenLoaded;
-
-    const parentRef = useRef<HTMLDivElement>(null);
-    const [parentRect] = useRect(parentRef);
-
-    const [validationResult, setValidationResult] = useState<InputValidationResult>();
-
-    const inputValidator = useInputValidator(ValidationTarget.PAGE);
-
-    const changeHandler = useCallback(async (e: ChangeEvent<HTMLInputElement>) => {
-      const validationResult = inputValidator(e.target.value);
-      setValidationResult(validationResult ?? undefined);
-    }, [inputValidator]);
-    const changeHandlerDebounced = debounce(300, changeHandler);
-
-    const cancel = useCallback(() => {
-      setValidationResult(undefined);
-      setShowInput(false);
-    }, []);
-
-    const create = useCallback(async (inputText) => {
-      if (inputText.trim() === '') {
-        return cancel();
-      }
-
-      const parentPath = pathUtils.addTrailingSlash(page.path as string);
-      const newPagePath = nodePath.resolve(parentPath, inputText);
-      const isCreatable = pagePathUtils.isCreatablePage(newPagePath);
-
-      if (!isCreatable) {
-        toastWarning(t('you_can_not_create_page_with_this_name_or_hierarchy'));
-        return;
-      }
-
-      setProcessingSubmission(true);
-
-      setShowInput(false);
-
-      try {
-        await createPage(
-          {
-            path: newPagePath,
-            parentPath,
-            body: undefined,
-            // keep grant info undefined to inherit from parent
-            grant: undefined,
-            grantUserGroupIds: undefined,
-            origin: Origin.View,
-            wip: shouldCreateWipPage(newPagePath),
-          },
-          {
-            skipTransition: true,
-            onCreated: () => {
-              mutatePageTree();
-              mutateRecentlyUpdated();
-
-              if (!hasDescendants) {
-                stateHandlers?.setIsOpen(true);
-              }
-
-              toastSuccess(t('successfully_saved_the_page'));
-            },
-          },
-        );
-      }
-      catch (err) {
-        toastError(err);
-      }
-      finally {
-        setProcessingSubmission(false);
-      }
-    }, [cancel, hasDescendants, page.path, stateHandlers, t, createPage]);
-
-    const inputContainerClass = newPageInputStyles['new-page-input-container'] ?? '';
-    const isInvalid = validationResult != null;
-
-    const maxWidth = parentRect != null
-      ? getAdjustedMaxWidthForAutosizeInput(parentRect.width, 'sm', validationResult != null ? false : undefined)
-      : undefined;
-
-    return isEnableActions && showInput
-      ? (
-        <div ref={parentRef} className={inputContainerClass}>
-          <AutosizeSubmittableInput
-            inputClassName={`form-control ${isInvalid ? 'is-invalid' : ''}`}
-            inputStyle={{ maxWidth }}
-            placeholder={t('Input page name')}
-            aria-describedby={isInvalid ? 'new-page-input-feedback' : undefined}
-            onChange={changeHandlerDebounced}
-            onSubmit={create}
-            onCancel={cancel}
-            autoFocus
-          />
-          {isInvalid && (
-            <div id="new-page-input" className="invalid-feedback d-block my-1">
-              {validationResult.message}
-            </div>
-          )}
-        </div>
-      )
-      : <></>;
-  };
-
-  return {
-    Input,
-    CreateButton,
-    isProcessingSubmission,
-  };
-};

+ 0 - 233
apps/app/src/client/components/TreeItem/TreeItemLayout.tsx

@@ -1,233 +0,0 @@
-import React, {
-  useCallback,
-  useState,
-  useEffect,
-  useMemo,
-  type RefObject,
-  type RefCallback,
-  type MouseEvent,
-  type JSX,
-} from 'react';
-
-import { addTrailingSlash } from '@growi/core/dist/utils/path-utils';
-
-import { usePageTreeDescCountMap } from '~/states/ui/page-tree-desc-count-map';
-import { useSWRxPageChildren } from '~/stores/page-listing';
-
-import { ItemNode } from './ItemNode';
-import { SimpleItemContent } from './SimpleItemContent';
-import type { TreeItemProps, TreeItemToolProps } from './interfaces';
-
-
-import styles from './TreeItemLayout.module.scss';
-
-const moduleClass = styles['tree-item-layout'] ?? '';
-
-
-type TreeItemLayoutProps = TreeItemProps & {
-  className?: string,
-  itemRef?: RefObject<any> | RefCallback<any>,
-  indentSize?: number,
-}
-
-export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
-  const {
-    className, itemClassName,
-    indentSize = 10,
-    itemLevel: baseItemLevel = 1,
-    itemNode, targetPath, targetPathOrId, isOpen: _isOpen = false,
-    onRenamed, onClick, onClickDuplicateMenuItem, onClickDeleteMenuItem, onWheelClick,
-    isEnableActions, isReadOnlyUser, isWipPageShown = true,
-    itemRef, itemClass,
-    showAlternativeContent,
-  } = props;
-
-  const { page } = itemNode;
-
-  const [currentChildren, setCurrentChildren] = useState<ItemNode[]>([]);
-  const [isOpen, setIsOpen] = useState(_isOpen);
-
-  const { data } = useSWRxPageChildren(isOpen ? page._id : null);
-
-
-  const itemClickHandler = useCallback((e: MouseEvent) => {
-    // DO NOT handle the event when e.currentTarget and e.target is different
-    if (e.target !== e.currentTarget) {
-      return;
-    }
-
-    onClick?.(page);
-
-  }, [onClick, page]);
-
-  const itemMouseupHandler = useCallback((e: MouseEvent) => {
-    // DO NOT handle the event when e.currentTarget and e.target is different
-    if (e.target !== e.currentTarget) {
-      return;
-    }
-
-    if (e.button === 1) {
-      e.preventDefault();
-      onWheelClick?.(page);
-    }
-
-  }, [onWheelClick, page]);
-
-
-  // descendantCount
-  const { getDescCount } = usePageTreeDescCountMap();
-  const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
-
-  // hasDescendants flag
-  const isChildrenLoaded = currentChildren?.length > 0;
-  const hasDescendants = descendantCount > 0 || isChildrenLoaded;
-
-  const hasChildren = useCallback((): boolean => {
-    return currentChildren != null && currentChildren.length > 0;
-  }, [currentChildren]);
-
-  const onClickLoadChildren = useCallback(() => {
-    setIsOpen(!isOpen);
-  }, [isOpen]);
-
-  useEffect(() => {
-    const isPathToTarget = page.path != null
-      && targetPath.startsWith(addTrailingSlash(page.path))
-      && targetPath !== page.path; // Target Page does not need to be opened
-    if (isPathToTarget) setIsOpen(true);
-  }, [targetPath, page.path]);
-
-  /*
-   * When swr fetch succeeded
-   */
-  useEffect(() => {
-    if (isOpen && data != null) {
-      const newChildren = ItemNode.generateNodesFromPages(data.children);
-      setCurrentChildren(newChildren);
-    }
-  }, [data, isOpen, targetPathOrId]);
-
-  const isSelected = useMemo(() => {
-    return page._id === targetPathOrId || page.path === targetPathOrId;
-  }, [page, targetPathOrId]);
-
-  const ItemClassFixed = itemClass ?? TreeItemLayout;
-
-  const baseProps: Omit<TreeItemProps, 'itemLevel' | 'itemNode'> = {
-    isEnableActions,
-    isReadOnlyUser,
-    isOpen: false,
-    isWipPageShown,
-    targetPath,
-    targetPathOrId,
-    onRenamed,
-    onClickDuplicateMenuItem,
-    onClickDeleteMenuItem,
-  };
-
-  const toolProps: TreeItemToolProps = {
-    ...baseProps,
-    itemLevel: baseItemLevel,
-    itemNode,
-    stateHandlers: {
-      setIsOpen,
-    },
-  };
-
-  const EndComponents = props.customEndComponents;
-  const HoveredEndComponents = props.customHoveredEndComponents;
-  const HeadObChildrenComponents = props.customHeadOfChildrenComponents;
-  const AlternativeComponents = props.customAlternativeComponents;
-
-  if (!isWipPageShown && page.wip) {
-    return <></>;
-  }
-
-  return (
-    <div
-      id={`tree-item-layout-${page._id}`}
-      data-testid="grw-pagetree-item-container"
-      className={`${moduleClass} ${className} level-${baseItemLevel}`}
-      style={{ paddingLeft: `${baseItemLevel > 1 ? indentSize : 0}px` }}
-    >
-      <li
-        ref={itemRef}
-        role="button"
-        className={`list-group-item list-group-item-action ${itemClassName}
-          border-0 py-0 ps-0 d-flex align-items-center rounded-1`}
-        id={`grw-pagetree-list-${page._id}`}
-        onClick={itemClickHandler}
-        onMouseUp={itemMouseupHandler}
-        aria-current={isSelected ? true : undefined}
-      >
-
-        <div className="btn-triangle-container d-flex justify-content-center">
-          {hasDescendants && (
-            <button
-              type="button"
-              className={`btn btn-triangle p-0 ${isOpen ? 'open' : ''}`}
-              onClick={onClickLoadChildren}
-            >
-              <div className="d-flex justify-content-center">
-                <span className="material-symbols-outlined fs-5">arrow_right</span>
-              </div>
-            </button>
-          )}
-        </div>
-
-        {showAlternativeContent && AlternativeComponents != null
-          ? (
-            AlternativeComponents.map((AlternativeContent, index) => (
-              // eslint-disable-next-line react/no-array-index-key
-              (<AlternativeContent key={index} {...toolProps} />)
-            ))
-          )
-          : (
-            <>
-              <SimpleItemContent page={page} />
-              <div className="d-hover-none">
-                {EndComponents?.map((EndComponent, index) => (
-                  // eslint-disable-next-line react/no-array-index-key
-                  (<EndComponent key={index} {...toolProps} />)
-                ))}
-              </div>
-              <div className="d-none d-hover-flex">
-                {HoveredEndComponents?.map((HoveredEndContent, index) => (
-                  // eslint-disable-next-line react/no-array-index-key
-                  (<HoveredEndContent key={index} {...toolProps} />)
-                ))}
-              </div>
-            </>
-          )
-        }
-
-      </li>
-      {isOpen && (
-        <div className={`tree-item-layout-children level-${baseItemLevel + 1}`}>
-
-          {HeadObChildrenComponents?.map((HeadObChildrenContents, index) => (
-            // eslint-disable-next-line react/no-array-index-key
-            (<HeadObChildrenContents key={index} {...toolProps} itemLevel={baseItemLevel + 1} />)
-          ))}
-
-          {hasChildren() && currentChildren.map((node) => {
-            const itemProps = {
-              ...baseProps,
-              className,
-              itemLevel: baseItemLevel + 1,
-              itemNode: node,
-              itemClass,
-              itemClassName,
-              onClick,
-            };
-
-            return (
-              <ItemClassFixed key={node.page._id} {...itemProps} />
-            );
-          })}
-
-        </div>
-      )}
-    </div>
-  );
-};

+ 0 - 5
apps/app/src/client/components/TreeItem/index.ts

@@ -1,5 +0,0 @@
-export * from './interfaces';
-
-export * from './NewPageInput';
-export * from './ItemNode';
-export * from './TreeItemLayout';

+ 0 - 38
apps/app/src/client/components/TreeItem/interfaces/index.ts

@@ -1,38 +0,0 @@
-import type { IPageToDeleteWithMeta } from '@growi/core';
-
-import type { IPageForItem } from '~/interfaces/page';
-import type { IPageForPageDuplicateModal } from '~/states/ui/modal/page-duplicate';
-
-import type { ItemNode } from '../ItemNode';
-
-type TreeItemBaseProps = {
-  itemLevel?: number,
-  itemNode: ItemNode,
-  isEnableActions: boolean,
-  isReadOnlyUser: boolean,
-  onClickDuplicateMenuItem?(pageToDuplicate: IPageForPageDuplicateModal): void,
-  onClickDeleteMenuItem?(pageToDelete: IPageToDeleteWithMeta): void,
-  onRenamed?(fromPath: string | undefined, toPath: string): void,
-}
-
-export type TreeItemToolProps = TreeItemBaseProps & {
-  stateHandlers?: {
-    setIsOpen: React.Dispatch<React.SetStateAction<boolean>>,
-  },
-};
-
-export type TreeItemProps = TreeItemBaseProps & {
-  targetPath: string,
-  targetPathOrId?:string,
-  isOpen?: boolean,
-  isWipPageShown?: boolean,
-  itemClass?: React.FunctionComponent<TreeItemProps>,
-  itemClassName?: string,
-  customEndComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
-  customHoveredEndComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
-  customHeadOfChildrenComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
-  showAlternativeContent?: boolean,
-  customAlternativeComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
-  onClick?(page: IPageForItem): void,
-  onWheelClick?(page: IPageForItem): void,
-};

+ 1 - 1
apps/app/src/client/interfaces/clearable.ts

@@ -1,3 +1,3 @@
 export interface IClearable {
-  clear: () => void,
+  clear: () => void;
 }

+ 1 - 1
apps/app/src/client/interfaces/focusable.ts

@@ -1,3 +1,3 @@
 export interface IFocusable {
-  focus: () => void,
+  focus: () => void;
 }

+ 8 - 9
apps/app/src/client/interfaces/global-notification.ts

@@ -3,8 +3,7 @@ export const NotifyType = {
   SLACK: 'slack',
 } as const;
 
-export type NotifyType = typeof NotifyType[keyof typeof NotifyType]
-
+export type NotifyType = (typeof NotifyType)[keyof typeof NotifyType];
 
 export const TriggerEventType = {
   CREATE: 'pageCreate',
@@ -15,13 +14,13 @@ export const TriggerEventType = {
   POST: 'comment',
 } as const;
 
-type TriggerEventType = typeof TriggerEventType[keyof typeof TriggerEventType]
-
+type TriggerEventType =
+  (typeof TriggerEventType)[keyof typeof TriggerEventType];
 
 export type IGlobalNotification = {
-  triggerPath: string,
-  notifyType: NotifyType,
-  emailToSend: string,
-  slackChannelToSend: string,
-  triggerEvents: TriggerEventType[],
+  triggerPath: string;
+  notifyType: NotifyType;
+  emailToSend: string;
+  slackChannelToSend: string;
+  triggerEvents: TriggerEventType[];
 };

+ 3 - 3
apps/app/src/client/interfaces/handsontable-modal.ts

@@ -1,4 +1,4 @@
 export type LaunchHandsonTableModalEventDetail = {
-  bol: number,
-  eol: number,
-}
+  bol: number;
+  eol: number;
+};

+ 1 - 1
apps/app/src/client/interfaces/in-app-notification-openable.ts

@@ -1,3 +1,3 @@
 export interface IInAppNotificationOpenable {
-  open: () => void,
+  open: () => void;
 }

+ 4 - 4
apps/app/src/client/interfaces/notification.ts

@@ -1,8 +1,8 @@
 import type { NotifyType } from './global-notification';
 
 export type INotificationType = {
-  __t?: NotifyType
-  _id: string
+  __t?: NotifyType;
+  _id: string;
   // TOOD: Define the provider type
-  provider?: any
-}
+  provider?: any;
+};

+ 11 - 11
apps/app/src/client/interfaces/react-bootstrap-typeahead.ts

@@ -1,15 +1,15 @@
 // https://github.com/ericgio/react-bootstrap-typeahead/blob/5.x/docs/API.md
 export type TypeaheadProps = {
-  dropup?: boolean,
-  emptyLabel?: string,
-  placeholder?: string,
-  autoFocus?: boolean,
-  inputProps?: unknown,
+  dropup?: boolean;
+  emptyLabel?: string;
+  placeholder?: string;
+  autoFocus?: boolean;
+  inputProps?: unknown;
 
-  onChange?: (data: unknown[]) => void,
-  onBlur?: () => void,
-  onFocus?: () => void,
-  onSearch?: (text: string) => void,
-  onInputChange?: (text: string) => void,
-  onKeyDown?: (input: string) => void,
+  onChange?: (data: unknown[]) => void;
+  onBlur?: () => void;
+  onFocus?: () => void;
+  onSearch?: (text: string) => void;
+  onInputChange?: (text: string) => void;
+  onKeyDown?: (input: string) => void;
 };

+ 5 - 5
apps/app/src/client/interfaces/selectable-all.ts

@@ -1,13 +1,13 @@
 export interface ISelectable {
-  select: () => void,
-  deselect: () => void,
+  select: () => void;
+  deselect: () => void;
 }
 
 export interface ISelectableAndIndeterminatable extends ISelectable {
-  setIndeterminate: () => void,
+  setIndeterminate: () => void;
 }
 
 export interface ISelectableAll {
-  selectAll: () => void,
-  deselectAll: () => void,
+  selectAll: () => void;
+  deselectAll: () => void;
 }

+ 11 - 8
apps/app/src/client/models/BootstrapGrid.js

@@ -1,20 +1,22 @@
 export default class BootstrapGrid {
-
   constructor(colsRatios, responsiveSize) {
     this.colsRatios = BootstrapGrid.validateColsRatios(colsRatios);
     this.responsiveSize = BootstrapGrid.validateResponsiveSize(responsiveSize);
   }
 
   static ResponsiveSize = {
-    XS_SIZE: 'xs', SM_SIZE: 'sm', MD_SIZE: 'md',
+    XS_SIZE: 'xs',
+    SM_SIZE: 'sm',
+    MD_SIZE: 'md',
   };
 
   static validateColsRatios(colsRatios) {
-
     if (colsRatios.length < 2 || colsRatios.length > 4) {
       throw new Error('Incorrect array length of cols ratios');
     }
-    const ratiosTotal = colsRatios.reduce((total, ratio) => { return total + ratio }, 0);
+    const ratiosTotal = colsRatios.reduce((total, ratio) => {
+      return total + ratio;
+    }, 0);
     if (ratiosTotal !== 12) {
       throw new Error('Incorrect cols ratios value');
     }
@@ -23,12 +25,13 @@ export default class BootstrapGrid {
   }
 
   static validateResponsiveSize(responsiveSize) {
-    if (responsiveSize === this.ResponsiveSize.XS_SIZE
-      || responsiveSize === this.ResponsiveSize.SM_SIZE
-      || responsiveSize === this.ResponsiveSize.MD_SIZE) {
+    if (
+      responsiveSize === BootstrapGrid.ResponsiveSize.XS_SIZE ||
+      responsiveSize === BootstrapGrid.ResponsiveSize.SM_SIZE ||
+      responsiveSize === BootstrapGrid.ResponsiveSize.MD_SIZE
+    ) {
       return responsiveSize;
     }
     throw new Error('Incorrect responsive size');
   }
-
 }

+ 8 - 4
apps/app/src/client/models/HotkeyStroke.js

@@ -3,7 +3,6 @@ import loggerFactory from '~/utils/logger';
 const logger = loggerFactory('growi:cli:HotkeyStroke');
 
 export default class HotkeyStroke {
-
   constructor(stroke) {
     this.stroke = stroke;
     this.activeIndices = [];
@@ -42,16 +41,21 @@ export default class HotkeyStroke {
         return nextIndex;
       })
       // exclude null
-      .filter(index => index != null);
+      .filter((index) => index != null);
 
     // reset if completed
     if (isCompleted) {
       this.activeIndices = [];
     }
 
-    logger.debug('activeIndices for [', this.stroke, '] => [', this.activeIndices, ']');
+    logger.debug(
+      'activeIndices for [',
+      this.stroke,
+      '] => [',
+      this.activeIndices,
+      ']',
+    );
 
     return isCompleted;
   }
-
 }

+ 0 - 6
apps/app/src/client/services/AdminAppContainer.js

@@ -41,9 +41,6 @@ export default class AdminAppContainer extends Container {
       sesSecretAccessKey: '',
 
       isMaintenanceMode: false,
-
-      // TODO: remove this property when bulk export can be relased for cloud (https://redmine.weseek.co.jp/issues/163220)
-      isBulkExportDisabledForCloud: false,
     };
 
   }
@@ -84,9 +81,6 @@ export default class AdminAppContainer extends Container {
       sesSecretAccessKey: appSettingsParams.sesSecretAccessKey,
 
       isMaintenanceMode: appSettingsParams.isMaintenanceMode,
-
-      // TODO: remove this property when bulk export can be relased for cloud (https://redmine.weseek.co.jp/issues/163220)
-      isBulkExportDisabledForCloud: appSettingsParams.isBulkExportDisabledForCloud,
     });
   }
 

+ 0 - 117
apps/app/src/client/services/AdminImportContainer.js

@@ -1,14 +1,6 @@
 import { isServer } from '@growi/core/dist/utils';
 import { Container } from 'unstated';
 
-import loggerFactory from '~/utils/logger';
-
-import { apiPost } from '../util/apiv1-client';
-import { apiv3Get } from '../util/apiv3-client';
-import { toastSuccess, toastError } from '../util/toastr';
-
-const logger = loggerFactory('growi:appSettings');
-
 /**
  * Service container for admin app setting page (AppSettings.jsx)
  * @extends {Container} unstated Container
@@ -26,19 +18,7 @@ export default class AdminImportContainer extends Container {
 
     this.state = {
       retrieveError: null,
-      esaTeamName: '',
-      esaAccessToken: '',
-      qiitaTeamName: '',
-      qiitaAccessToken: '',
     };
-
-    this.esaHandleSubmit = this.esaHandleSubmit.bind(this);
-    this.esaHandleSubmitTest = this.esaHandleSubmitTest.bind(this);
-    this.esaHandleSubmitUpdate = this.esaHandleSubmitUpdate.bind(this);
-    this.qiitaHandleSubmit = this.qiitaHandleSubmit.bind(this);
-    this.qiitaHandleSubmitTest = this.qiitaHandleSubmitTest.bind(this);
-    this.qiitaHandleSubmitUpdate = this.qiitaHandleSubmitUpdate.bind(this);
-    this.handleInputValue = this.handleInputValue.bind(this);
   }
 
   /**
@@ -48,101 +28,4 @@ export default class AdminImportContainer extends Container {
     return 'AdminImportContainer';
   }
 
-  /**
-   * retrieve app sttings data
-   */
-  async retrieveImportSettingsData() {
-    const response = await apiv3Get('/import/');
-    const {
-      importSettingsParams,
-    } = response.data;
-
-    this.setState({
-      esaTeamName: importSettingsParams.esaTeamName,
-      esaAccessToken: importSettingsParams.esaAccessToken,
-      qiitaTeamName: importSettingsParams.qiitaTeamName,
-      qiitaAccessToken: importSettingsParams.qiitaAccessToken,
-    });
-  }
-
-  handleInputValue(event) {
-    this.setState({
-      [event.target.name]: event.target.value,
-    });
-  }
-
-  async esaHandleSubmit() {
-    try {
-      await apiPost('/admin/import/esa');
-      toastSuccess('Import posts from esa success.');
-    }
-    catch (err) {
-      logger.error(err);
-      toastError(err);
-    }
-  }
-
-  async esaHandleSubmitTest() {
-    try {
-      await apiPost('/admin/import/testEsaAPI');
-      toastSuccess('Test connection to esa success.');
-    }
-    catch (error) {
-      toastError(error);
-    }
-  }
-
-  async esaHandleSubmitUpdate(formData) {
-    const params = {
-      'importer:esa:team_name': formData.esaTeamName,
-      'importer:esa:access_token': formData.esaAccessToken,
-    };
-    try {
-      await apiPost('/admin/settings/importerEsa', params);
-      toastSuccess('Updated');
-    }
-    catch (err) {
-      logger.error(err);
-      toastError(err);
-    }
-  }
-
-  async qiitaHandleSubmit() {
-    try {
-      await apiPost('/admin/import/qiita');
-      toastSuccess('Import posts from qiita:team success.');
-    }
-    catch (err) {
-      logger.error(err);
-      toastError(err);
-    }
-  }
-
-
-  async qiitaHandleSubmitTest() {
-    try {
-      await apiPost('/admin/import/testQiitaAPI');
-      toastSuccess('Test connection to qiita:team success.');
-    }
-    catch (err) {
-      logger.error(err);
-      toastError(err);
-    }
-  }
-
-  async qiitaHandleSubmitUpdate(formData) {
-    const params = {
-      'importer:qiita:team_name': formData.qiitaTeamName,
-      'importer:qiita:access_token': formData.qiitaAccessToken,
-    };
-    try {
-      await apiPost('/admin/settings/importerQiita', params);
-      toastSuccess('Updated');
-    }
-    catch (err) {
-      logger.error(err);
-      toastError(err);
-    }
-  }
-
 }

+ 10 - 0
apps/app/src/client/services/AdminUsersContainer.js

@@ -34,6 +34,7 @@ export default class AdminUsersContainer extends Container {
       pagingLimit: Infinity,
       selectedStatusList: new Set(['all']),
       searchText: '',
+      userStatistics: null,
     };
 
     this.showPasswordResetModal = this.showPasswordResetModal.bind(this);
@@ -158,6 +159,15 @@ export default class AdminUsersContainer extends Container {
 
   }
 
+  /**
+ * retrieve user statistics
+ */
+  async retrieveUserStatistics() {
+    const statsRes = await apiv3Get('/statistics/user');
+    const userStatistics = statsRes.data.data;
+    this.setState({ userStatistics });
+  }
+
   /**
    * create user invited
    * @memberOf AdminUsersContainer

+ 1 - 1
apps/app/src/client/services/side-effects/page-updated.ts

@@ -45,7 +45,7 @@ export const usePageUpdatedEffect = (): void => {
 
       // !!CAUTION!! Timing of calling openPageStatusAlert may clash with components/PageEditor/conflict.tsx
       if (isRevisionOutdated && editorMode === EditorMode.View) {
-        openPageStatusAlert({ hideEditorMode: EditorMode.Editor, onRefleshPage: fetchCurrentPage });
+        openPageStatusAlert({ hideEditorMode: EditorMode.Editor, onRefleshPage: () => fetchCurrentPage({ force: true }) });
       }
 
       // Clear cache

+ 38 - 0
apps/app/src/client/services/use-start-editing.tsx

@@ -0,0 +1,38 @@
+import { useCallback } from 'react';
+
+import { Origin } from '@growi/core';
+import { getParentPath } from '@growi/core/dist/utils/path-utils';
+
+import { useCreatePage } from '~/client/services/create-page';
+import { usePageNotFound } from '~/states/page';
+import { useEditorMode, EditorMode } from '~/states/ui/editor';
+
+import { shouldCreateWipPage } from '../../utils/should-create-wip-page';
+
+export const useStartEditing = (): ((path?: string) => Promise<void>) => {
+  const isNotFound = usePageNotFound();
+  const { setEditorMode } = useEditorMode();
+  const { create } = useCreatePage();
+
+  return useCallback(async (path?: string) => {
+    if (!isNotFound) {
+      setEditorMode(EditorMode.Editor);
+      return;
+    }
+    // Create a new page if it does not exist and transit to the editor mode
+    try {
+      const parentPath = path != null ? getParentPath(path) : undefined; // does not have to exist
+      await create(
+        {
+          path, parentPath, wip: shouldCreateWipPage(path), origin: Origin.View,
+        },
+      );
+
+      setEditorMode(EditorMode.Editor);
+    }
+    catch (err) {
+      throw new Error(err);
+    }
+  }, [create, isNotFound, setEditorMode]);
+
+};

+ 1 - 10
apps/app/src/components/Common/PagePathNav/PagePathNav.tsx

@@ -5,21 +5,12 @@ import { pagePathUtils } from '@growi/core/dist/utils';
 import LinkedPagePath from '~/models/linked-page-path';
 
 import { PagePathHierarchicalLink } from '../PagePathHierarchicalLink';
+import { Separator } from '.';
 import type { PagePathNavLayoutProps } from './PagePathNavLayout';
 import { PagePathNavLayout } from './PagePathNavLayout';
 
-import styles from './PagePathNav.module.scss';
-
 const { isTrashPage } = pagePathUtils;
 
-const Separator = ({ className }: { className?: string }): JSX.Element => {
-  return (
-    <span className={`separator ${className ?? ''} ${styles['grw-mx-02em']}`}>
-      /
-    </span>
-  );
-};
-
 export const PagePathNav = (props: PagePathNavLayoutProps): JSX.Element => {
   const { pagePath } = props;
 

+ 0 - 0
apps/app/src/components/Common/PagePathNav/PagePathNav.module.scss → apps/app/src/components/Common/PagePathNav/PagePathNavLayout.module.scss


+ 1 - 1
apps/app/src/components/Common/PagePathNav/PagePathNavLayout.tsx

@@ -3,7 +3,7 @@ import dynamic from 'next/dynamic';
 
 import { usePageNotFound } from '~/states/page';
 
-import styles from './PagePathNav.module.scss';
+import styles from './PagePathNavLayout.module.scss';
 
 const moduleClass = styles['grw-page-path-nav-layout'] ?? '';
 

+ 8 - 5
apps/app/src/components/PageView/PageAlerts/OldRevisionAlert.tsx

@@ -3,14 +3,17 @@ import { useRouter } from 'next/router';
 import { returnPathForURL } from '@growi/core/dist/utils/path-utils';
 import { useTranslation } from 'react-i18next';
 
-import { useCurrentPageData, useFetchCurrentPage } from '~/states/page';
-import { useSWRxIsLatestRevision } from '~/stores/page';
+import {
+  useCurrentPageData,
+  useFetchCurrentPage,
+  useRevisionIdFromUrl,
+} from '~/states/page';
 
 export const OldRevisionAlert = (): JSX.Element => {
   const router = useRouter();
   const { t } = useTranslation();
 
-  const { data: isLatestRevision } = useSWRxIsLatestRevision();
+  const revisionIdFromUrl = useRevisionIdFromUrl();
   const page = useCurrentPageData();
   const { fetchCurrentPage } = useFetchCurrentPage();
 
@@ -24,8 +27,8 @@ export const OldRevisionAlert = (): JSX.Element => {
     fetchCurrentPage({ force: true });
   }, [fetchCurrentPage, page, router]);
 
-  // Show alert only when viewing an old revision (isLatestRevision === false)
-  if (isLatestRevision !== false) {
+  // Show alert only when intentionally viewing a specific (past) revision (revisionIdFromUrl != null)
+  if (revisionIdFromUrl == null) {
     // biome-ignore lint/complexity/noUselessFragments: ignore
     return <></>;
   }

+ 11 - 50
apps/app/src/components/PageView/PageView.tsx

@@ -1,5 +1,6 @@
 import { type JSX, memo, useCallback, useEffect, useMemo, useRef } from 'react';
 import dynamic from 'next/dynamic';
+import { isDeepEquals } from '@growi/core/dist/utils/is-deep-equals';
 import { isUsersHomepage } from '@growi/core/dist/utils/page-path-utils';
 import { useSlidesByFrontmatter } from '@growi/presentation/dist/services';
 
@@ -85,9 +86,13 @@ type Props = {
   className?: string;
 };
 
-export const PageView = memo((props: Props): JSX.Element => {
-  const renderStartTime = performance.now();
+// Custom comparison function for memo to prevent unnecessary re-renders
+const arePropsEqual = (prevProps: Props, nextProps: Props): boolean =>
+  prevProps.pagePath === nextProps.pagePath &&
+  prevProps.className === nextProps.className &&
+  isDeepEquals(prevProps.rendererConfig, nextProps.rendererConfig);
 
+const PageViewComponent = (props: Props): JSX.Element => {
   const commentsContainerRef = useRef<HTMLDivElement>(null);
 
   const { pagePath, rendererConfig, className } = props;
@@ -101,15 +106,6 @@ export const PageView = memo((props: Props): JSX.Element => {
   const page = useCurrentPageData();
   const { data: viewOptions } = useViewOptions();
 
-  // DEBUG: Log PageView render start
-  console.log('[PAGEVIEW-DEBUG] PageView render started:', {
-    pagePath,
-    currentPageId,
-    pageId: page?._id,
-    timestamp: new Date().toISOString(),
-    renderStartTime,
-  });
-
   const isNotFound = isNotFoundMeta || page == null;
   const isUsersHomepagePath = isUsersHomepage(pagePath);
 
@@ -123,23 +119,13 @@ export const PageView = memo((props: Props): JSX.Element => {
 
   // ***************************  Auto Scroll  ***************************
   useEffect(() => {
-    const scrollEffectStartTime = performance.now();
-    console.log('[PAGEVIEW-DEBUG] Auto scroll effect triggered:', {
-      currentPageId,
-      hash: window.location.hash,
-      timestamp: new Date().toISOString(),
-      effectStartTime: scrollEffectStartTime,
-    });
-
     if (currentPageId == null) {
-      console.log('[PAGEVIEW-DEBUG] Auto scroll skipped - no currentPageId');
       return;
     }
 
     // do nothing if hash is empty
     const { hash } = window.location;
     if (hash.length === 0) {
-      console.log('[PAGEVIEW-DEBUG] Auto scroll skipped - no hash');
       return;
     }
 
@@ -204,18 +190,7 @@ export const PageView = memo((props: Props): JSX.Element => {
     ) : null;
 
   const Contents = useCallback(() => {
-    const contentsRenderStartTime = performance.now();
-    console.log('[PAGEVIEW-DEBUG] Contents component render started:', {
-      isNotFound,
-      hasPage: page != null,
-      hasRevision: page?.revision != null,
-      pageId: page?._id,
-      timestamp: new Date().toISOString(),
-      contentsRenderStartTime,
-    });
-
     if (isNotFound || page?.revision == null) {
-      console.log('[PAGEVIEW-DEBUG] Rendering NotFoundPage');
       return <NotFoundPage path={pagePath} />;
     }
 
@@ -223,13 +198,6 @@ export const PageView = memo((props: Props): JSX.Element => {
     const rendererOptions =
       viewOptions ?? generateSSRViewOptions(rendererConfig, pagePath);
 
-    console.log('[PAGEVIEW-DEBUG] Rendering page content:', {
-      markdownLength: markdown?.length,
-      hasViewOptions: viewOptions != null,
-      isSlide: isSlide != null,
-      renderDuration: performance.now() - contentsRenderStartTime,
-    });
-
     return (
       <>
         <PageContentsUtilities />
@@ -268,16 +236,6 @@ export const PageView = memo((props: Props): JSX.Element => {
     page,
   ]);
 
-  // DEBUG: Log final render completion time
-  const renderEndTime = performance.now();
-  console.log('[PAGEVIEW-DEBUG] PageView render completed:', {
-    pagePath,
-    currentPageId,
-    pageId: page?._id,
-    totalRenderDuration: renderEndTime - renderStartTime,
-    timestamp: new Date().toISOString(),
-  });
-
   return (
     <PageViewLayout
       className={className}
@@ -301,4 +259,7 @@ export const PageView = memo((props: Props): JSX.Element => {
       )}
     </PageViewLayout>
   );
-});
+};
+
+export const PageView = memo(PageViewComponent, arePropsEqual);
+PageView.displayName = 'PageView';

+ 20 - 13
apps/app/src/components/ReactMarkdownComponents/NextLink.tsx

@@ -1,4 +1,4 @@
-import type { JSX } from 'react';
+import type { AnchorHTMLAttributes, JSX } from 'react';
 import type { LinkProps } from 'next/link';
 import Link from 'next/link';
 import { pagePathUtils } from '@growi/core/dist/utils';
@@ -8,7 +8,7 @@ import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:components:NextLink');
 
-const isAnchorLink = (href: string): boolean => {
+const hasAnchorLink = (href: string): boolean => {
   return href.toString().length > 0 && href[0] === '#';
 };
 
@@ -34,15 +34,16 @@ const isCreatablePage = (href: string) => {
   }
 };
 
-type Props = Omit<LinkProps, 'href'> & {
-  children: React.ReactNode;
-  id?: string;
-  href?: string;
-  className?: string;
-};
+type Props = AnchorHTMLAttributes<HTMLAnchorElement> &
+  Omit<LinkProps, 'href'> & {
+    children: React.ReactNode;
+    id?: string;
+    href?: string;
+    className?: string;
+  };
 
 export const NextLink = (props: Props): JSX.Element => {
-  const { id, href, children, className, onClick, ...rest } = props;
+  const { id, href, children, className, target, onClick, ...rest } = props;
 
   const siteUrl = useSiteUrl();
 
@@ -56,7 +57,7 @@ export const NextLink = (props: Props): JSX.Element => {
     Object.entries(rest).filter(([key]) => key.startsWith('data-')),
   );
 
-  if (isExternalLink(href, siteUrl)) {
+  if (isExternalLink(href, siteUrl) || target === '_blank') {
     return (
       <a
         id={id}
@@ -67,19 +68,25 @@ export const NextLink = (props: Props): JSX.Element => {
         rel="noopener noreferrer"
         {...dataAttributes}
       >
-        {children}&nbsp;
-        <span className="growi-custom-icons">external_link</span>
+        {children}
+        {target === '_blank' && (
+          <span style={{ userSelect: 'none' }}>
+            &nbsp;
+            <span className="growi-custom-icons">external_link</span>
+          </span>
+        )}
       </a>
     );
   }
 
   // when href is an anchor link or not-creatable path
-  if (isAnchorLink(href) || !isCreatablePage(href)) {
+  if (hasAnchorLink(href) || !isCreatablePage(href) || target != null) {
     return (
       <a
         id={id}
         href={href}
         className={className}
+        target={target}
         onClick={onClick}
         {...dataAttributes}
       >

+ 1 - 1
apps/app/src/features/collaborative-editor/side-effects/index.ts

@@ -15,7 +15,7 @@ export const useCurrentPageYjsDataAutoLoadEffect = (): void => {
   const pageId = useCurrentPageId();
   const currentPage = useCurrentPageData();
   const isGuestUser = useIsGuestUser();
-  const isNotFound = usePageNotFound();
+  const isNotFound = usePageNotFound(false);
 
   // Optimized effects with minimal dependencies
   useEffect(() => {

+ 5 - 4
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementPageTreeSelection.module.scss

@@ -3,9 +3,10 @@
     width: 30%;
   }
 
-  .page-tree-item {
-    .list-group-item {
-      padding: 0.4rem 1rem !important;
-    }
+  .page-tree-container {
+    min-height: 200px;
+    max-height: 50vh;
+    overflow-y: auto;
   }
 }
+

+ 21 - 133
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementPageTreeSelection.tsx

@@ -1,103 +1,25 @@
-import React, { memo, Suspense, useCallback } from 'react';
+import { useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 import { ModalBody } from 'reactstrap';
-import SimpleBar from 'simplebar-react';
 
-import { ItemsTree } from '~/client/components/ItemsTree';
-import ItemsTreeContentSkeleton from '~/client/components/ItemsTree/ItemsTreeContentSkeleton';
-import type { TreeItemProps } from '~/client/components/TreeItem';
-import { TreeItemLayout } from '~/client/components/TreeItem';
-import type { IPageForItem } from '~/interfaces/page';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/states/context';
 
-import {
-  isSelectablePage,
-  type SelectablePage,
-} from '../../../../interfaces/selectable-page';
-import { useSelectedPages } from '../../../services/use-selected-pages';
+import type { SelectablePage } from '../../../../interfaces/selectable-page';
 import {
   AiAssistantManagementModalPageMode,
   useAiAssistantManagementModalActions,
   useAiAssistantManagementModalStatus,
 } from '../../../states/modal/ai-assistant-management';
 import { AiAssistantManagementHeader } from './AiAssistantManagementHeader';
-import { SelectablePageList } from './SelectablePageList';
+import { usePageTreeSelection } from './hooks/use-page-tree-selection';
+import { PageTreeSelectionTree } from './PageTreeSelectionTree';
+import { SelectedPagesPanel } from './SelectedPagesPanel';
 
 import styles from './AiAssistantManagementPageTreeSelection.module.scss';
 
 const moduleClass =
   styles['grw-ai-assistant-management-page-tree-selection'] ?? '';
 
-const SelectablePageTree = memo(
-  (props: { onClickAddPageButton: (page: SelectablePage) => void }) => {
-    const { onClickAddPageButton } = props;
-
-    const isGuestUser = useIsGuestUser();
-    const isReadOnlyUser = useIsReadOnlyUser();
-
-    const pageTreeItemClickHandler = useCallback(
-      (page: IPageForItem) => {
-        if (!isSelectablePage(page)) {
-          return;
-        }
-
-        onClickAddPageButton(page);
-      },
-      [onClickAddPageButton],
-    );
-
-    const SelectPageButton = useCallback(
-      ({ page }: { page: IPageForItem }) => {
-        return (
-          <button
-            type="button"
-            className="border-0 rounded btn p-0"
-            onClick={(e) => {
-              e.stopPropagation();
-              pageTreeItemClickHandler(page);
-            }}
-          >
-            <span className="material-symbols-outlined p-0 me-2 text-primary">
-              add_circle
-            </span>
-          </button>
-        );
-      },
-      [pageTreeItemClickHandler],
-    );
-
-    const PageTreeItem = useCallback(
-      (props: TreeItemProps) => {
-        const { itemNode } = props;
-        const { page } = itemNode;
-
-        return (
-          <TreeItemLayout
-            {...props}
-            itemClass={PageTreeItem}
-            className="text-muted"
-            customHoveredEndComponents={[
-              () => <SelectPageButton page={page} />,
-            ]}
-          />
-        );
-      },
-      [SelectPageButton],
-    );
-
-    return (
-      <div className="page-tree-item">
-        <ItemsTree
-          targetPath="/"
-          isEnableActions={!isGuestUser}
-          isReadOnlyUser={!!isReadOnlyUser}
-          CustomTreeItem={PageTreeItem}
-        />
-      </div>
-    );
-  },
-);
-
 type Props = {
   baseSelectedPages: SelectablePage[];
   updateBaseSelectedPages: (pages: SelectablePage[]) => void;
@@ -109,6 +31,8 @@ export const AiAssistantManagementPageTreeSelection = (
   const { baseSelectedPages, updateBaseSelectedPages } = props;
 
   const { t } = useTranslation();
+  const isGuestUser = useIsGuestUser();
+  const isReadOnlyUser = useIsReadOnlyUser();
   const aiAssistantManagementModalData = useAiAssistantManagementModalStatus();
   const { changePageMode } = useAiAssistantManagementModalActions();
   const isNewAiAssistant =
@@ -116,32 +40,11 @@ export const AiAssistantManagementPageTreeSelection = (
 
   const {
     selectedPages,
-    selectedPagesRef,
     selectedPagesArray,
-    addPage,
+    initialCheckedItems,
+    handleCheckedItemsChange,
     removePage,
-  } = useSelectedPages(baseSelectedPages);
-
-  const addPageButtonClickHandler = useCallback(
-    (page: SelectablePage) => {
-      const pagePathWithGlob = `${page.path}/*`;
-      if (
-        selectedPagesRef.current == null ||
-        selectedPagesRef.current.has(pagePathWithGlob)
-      ) {
-        return;
-      }
-
-      const clonedPage = { ...page };
-      clonedPage.path = pagePathWithGlob;
-
-      addPage(clonedPage);
-    },
-    [
-      addPage,
-      selectedPagesRef, // Prevent flickering (use ref to avoid method recreation)
-    ],
-  );
+  } = usePageTreeSelection(baseSelectedPages);
 
   const nextButtonClickHandler = useCallback(() => {
     updateBaseSelectedPages(Array.from(selectedPages.values()));
@@ -178,35 +81,20 @@ export const AiAssistantManagementPageTreeSelection = (
           {t('modal_ai_assistant.search_reference_pages_by_keyword')}
         </h4>
 
-        <Suspense fallback={<ItemsTreeContentSkeleton />}>
-          <div className="px-4">
-            <SelectablePageTree
-              onClickAddPageButton={addPageButtonClickHandler}
-            />
-          </div>
-        </Suspense>
-
-        <h4 className="text-center fw-bold mb-3 mt-4">
-          {t('modal_ai_assistant.reference_pages')}
-        </h4>
-
         <div className="px-4">
-          <SimpleBar
-            className="page-list-container"
-            style={{ maxHeight: '300px' }}
-          >
-            <SelectablePageList
-              method="remove"
-              methodButtonPosition="right"
-              pages={selectedPagesArray}
-              onClickMethodButton={removePage}
-            />
-          </SimpleBar>
-          <span className="form-text text-muted mt-2">
-            {t('modal_ai_assistant.can_add_later')}
-          </span>
+          <PageTreeSelectionTree
+            isEnableActions={!isGuestUser}
+            isReadOnlyUser={!!isReadOnlyUser}
+            initialCheckedItems={initialCheckedItems}
+            onCheckedItemsChange={handleCheckedItemsChange}
+          />
         </div>
 
+        <SelectedPagesPanel
+          pages={selectedPagesArray}
+          onRemovePage={removePage}
+        />
+
         <div className="d-flex justify-content-center mt-4">
           <button
             type="button"

+ 51 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/PageTreeSelectionTree.tsx

@@ -0,0 +1,51 @@
+import { Suspense, useCallback, useState } from 'react';
+
+import ItemsTreeContentSkeleton from '~/client/components/ItemsTree/ItemsTreeContentSkeleton';
+import { ItemsTree } from '~/features/page-tree/components';
+import type { IPageForTreeItem } from '~/interfaces/page';
+
+import {
+  TreeItemWithCheckbox,
+  treeItemWithCheckboxSize,
+} from './TreeItemWithCheckbox';
+
+type Props = {
+  isEnableActions: boolean;
+  isReadOnlyUser: boolean;
+  initialCheckedItems: string[];
+  onCheckedItemsChange: (checkedPages: IPageForTreeItem[]) => void;
+};
+
+export const PageTreeSelectionTree = (props: Props): JSX.Element => {
+  const {
+    isEnableActions,
+    isReadOnlyUser,
+    initialCheckedItems,
+    onCheckedItemsChange,
+  } = props;
+
+  // Scroll container for virtualization
+  const [scrollerElem, setScrollerElem] = useState<HTMLElement | null>(null);
+
+  const estimateTreeItemSize = useCallback(() => treeItemWithCheckboxSize, []);
+
+  return (
+    <div className="page-tree-container" ref={setScrollerElem}>
+      {scrollerElem != null && (
+        <Suspense fallback={<ItemsTreeContentSkeleton />}>
+          <ItemsTree
+            targetPath="/"
+            isEnableActions={isEnableActions}
+            isReadOnlyUser={isReadOnlyUser}
+            CustomTreeItem={TreeItemWithCheckbox}
+            estimateTreeItemSize={estimateTreeItemSize}
+            scrollerElem={scrollerElem}
+            enableCheckboxes
+            initialCheckedItems={initialCheckedItems}
+            onCheckedItemsChange={onCheckedItemsChange}
+          />
+        </Suspense>
+      )}
+    </div>
+  );
+};

+ 40 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectedPagesPanel.tsx

@@ -0,0 +1,40 @@
+import { useTranslation } from 'react-i18next';
+import SimpleBar from 'simplebar-react';
+
+import type { SelectablePage } from '../../../../interfaces/selectable-page';
+import { SelectablePageList } from './SelectablePageList';
+
+type Props = {
+  pages: SelectablePage[];
+  onRemovePage: (page: SelectablePage) => void;
+};
+
+export const SelectedPagesPanel = (props: Props): JSX.Element => {
+  const { pages, onRemovePage } = props;
+  const { t } = useTranslation();
+
+  return (
+    <>
+      <h4 className="text-center fw-bold mb-3 mt-4">
+        {t('modal_ai_assistant.reference_pages')}
+      </h4>
+
+      <div className="px-4">
+        <SimpleBar
+          className="page-list-container"
+          style={{ maxHeight: '300px' }}
+        >
+          <SelectablePageList
+            method="remove"
+            methodButtonPosition="right"
+            pages={pages}
+            onClickMethodButton={onRemovePage}
+          />
+        </SimpleBar>
+        <span className="form-text text-muted mt-2">
+          {t('modal_ai_assistant.can_add_later')}
+        </span>
+      </div>
+    </>
+  );
+};

+ 10 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/TreeItemWithCheckbox.module.scss

@@ -0,0 +1,10 @@
+.page-tree-item :global {
+  li {
+    min-height: 36px;
+  }
+
+  input[type="checkbox"] {
+    margin-left: auto;
+    cursor: pointer;
+  }
+}

+ 62 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/TreeItemWithCheckbox.tsx

@@ -0,0 +1,62 @@
+import type { FC } from 'react';
+
+import type {
+  TreeItemProps,
+  TreeItemWithCheckboxToolProps,
+} from '~/features/page-tree';
+import { TreeItemLayout } from '~/features/page-tree/components';
+
+import styles from './TreeItemWithCheckbox.module.scss';
+
+const moduleClass = styles['page-tree-item'] ?? '';
+
+export const treeItemWithCheckboxSize = 36; // in px
+
+// Checkbox component to be used as customEndComponents
+const TreeItemCheckbox: FC<TreeItemWithCheckboxToolProps> = ({ item }) => {
+  const checkboxProps = item.getCheckboxProps();
+
+  const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    e.stopPropagation();
+    item.toggleCheckedState();
+  };
+
+  // Prevent click events from bubbling up to the li element
+  const handleClick = (e: React.MouseEvent) => {
+    e.stopPropagation();
+  };
+
+  return (
+    // biome-ignore lint/a11y/useKeyWithClickEvents: click handler only prevents propagation
+    // biome-ignore lint/a11y/noStaticElementInteractions: wrapper div to stop click propagation
+    <div
+      onClick={handleClick}
+      className="form-check form-switch d-flex align-items-center"
+    >
+      <input
+        className="form-check-input"
+        type="checkbox"
+        role="switch"
+        aria-checked={checkboxProps.checked ?? false}
+        aria-label="Toggle selection"
+        checked={checkboxProps.checked ?? false}
+        onChange={handleCheckboxChange}
+      />
+    </div>
+  );
+};
+
+type TreeItemWithCheckboxProps = TreeItemProps & {
+  key?: React.Key | null;
+};
+
+export const TreeItemWithCheckbox: FC<TreeItemWithCheckboxProps> = (props) => {
+  return (
+    <TreeItemLayout
+      {...props}
+      className={moduleClass}
+      customEndComponents={[TreeItemCheckbox]}
+      customHoveredEndComponents={[TreeItemCheckbox]}
+    />
+  );
+};

+ 88 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/hooks/use-page-tree-selection.ts

@@ -0,0 +1,88 @@
+import { useCallback, useMemo } from 'react';
+
+import type { IPageForTreeItem } from '~/interfaces/page';
+
+import {
+  isSelectablePage,
+  type SelectablePage,
+} from '../../../../../interfaces/selectable-page';
+import { useSelectedPages } from '../../../../services/use-selected-pages';
+
+/**
+ * Convert a page path to a glob pattern for selecting descendants.
+ * Handles the root page case where '//*' should become '/*'.
+ */
+export const toPagePathGlob = (path: string): string => {
+  if (path === '/') {
+    return '/*';
+  }
+  return `${path}/*`;
+};
+
+type UsePageTreeSelectionReturn = {
+  selectedPages: Map<string, SelectablePage>;
+  selectedPagesArray: SelectablePage[];
+  initialCheckedItems: string[];
+  handleCheckedItemsChange: (checkedPages: IPageForTreeItem[]) => void;
+  addPage: (page: SelectablePage) => void;
+  removePage: (page: SelectablePage) => void;
+};
+
+export const usePageTreeSelection = (
+  baseSelectedPages: SelectablePage[],
+): UsePageTreeSelectionReturn => {
+  const { selectedPages, selectedPagesArray, addPage, removePage } =
+    useSelectedPages(baseSelectedPages);
+
+  // Calculate initial checked items from baseSelectedPages
+  // Remove the /* suffix to match with page IDs
+  const initialCheckedItems = useMemo(() => {
+    return baseSelectedPages
+      .filter((page) => page._id != null)
+      .map((page) => page._id as string);
+  }, [baseSelectedPages]);
+
+  // Handle checked items change from tree
+  const handleCheckedItemsChange = useCallback(
+    (checkedPages: IPageForTreeItem[]) => {
+      // Get current checked page IDs (with /* suffix paths)
+      const currentCheckedPaths = new Set(
+        checkedPages
+          .filter((page) => isSelectablePage(page) && page.path != null)
+          .map((page) => toPagePathGlob(page.path as string)),
+      );
+
+      // Get currently selected page paths
+      const currentSelectedPaths = new Set(selectedPages.keys());
+
+      // Add newly checked pages
+      checkedPages.forEach((page) => {
+        if (!isSelectablePage(page) || page.path == null) {
+          return;
+        }
+        const pagePathWithGlob = toPagePathGlob(page.path);
+        if (!currentSelectedPaths.has(pagePathWithGlob)) {
+          const clonedPage = { ...page, path: pagePathWithGlob };
+          addPage(clonedPage as SelectablePage);
+        }
+      });
+
+      // Remove unchecked pages
+      selectedPagesArray.forEach((page) => {
+        if (page.path != null && !currentCheckedPaths.has(page.path)) {
+          removePage(page);
+        }
+      });
+    },
+    [selectedPages, selectedPagesArray, addPage, removePage],
+  );
+
+  return {
+    selectedPages,
+    selectedPagesArray,
+    initialCheckedItems,
+    handleCheckedItemsChange,
+    addPage,
+    removePage,
+  };
+};

+ 18 - 18
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-listing-api-handler.spec.ts

@@ -12,20 +12,20 @@ describe('pageListingApiModule', () => {
 
   describe('canHandle', () => {
     it.each`
-      description                           | url                                                  | expected
-      ${'ancestors-children endpoint'}      | ${'/_api/v3/page-listing/ancestors-children?path=/'} | ${true}
-      ${'children endpoint'}                | ${'/_api/v3/page-listing/children?path=/docs'}       | ${true}
-      ${'info endpoint'}                    | ${'/_api/v3/page-listing/info?path=/wiki'}           | ${true}
-      ${'ancestors-children without query'} | ${'/_api/v3/page-listing/ancestors-children'}        | ${true}
-      ${'children without query'}           | ${'/_api/v3/page-listing/children'}                  | ${true}
-      ${'info without query'}               | ${'/_api/v3/page-listing/info'}                      | ${true}
-      ${'other page-listing endpoint'}      | ${'/_api/v3/page-listing/other'}                     | ${false}
-      ${'different API version'}            | ${'/_api/v2/page-listing/children'}                  | ${false}
-      ${'non-page-listing API'}             | ${'/_api/v3/pages/list'}                             | ${false}
-      ${'regular page path'}                | ${'/page/path'}                                      | ${false}
-      ${'root path'}                        | ${'/'}                                               | ${false}
-      ${'empty URL'}                        | ${''}                                                | ${false}
-      ${'partial match'}                    | ${'/_api/v3/page-listing-other/children'}            | ${false}
+      description                           | url                                                         | expected
+      ${'ancestors-children endpoint'}      | ${'/_api/v3/page-listing/ancestors-children?path=/'}        | ${true}
+      ${'children endpoint'}                | ${'/_api/v3/page-listing/children?path=/docs'}              | ${true}
+      ${'item endpoint'}                    | ${'/_api/v3/page-listing/item?id=68b686d3984fce462ecc7c05'} | ${true}
+      ${'ancestors-children without query'} | ${'/_api/v3/page-listing/ancestors-children'}               | ${true}
+      ${'children without query'}           | ${'/_api/v3/page-listing/children'}                         | ${true}
+      ${'item without query'}               | ${'/_api/v3/page-listing/item'}                             | ${true}
+      ${'other page-listing endpoint'}      | ${'/_api/v3/page-listing/other'}                            | ${false}
+      ${'different API version'}            | ${'/_api/v2/page-listing/children'}                         | ${false}
+      ${'non-page-listing API'}             | ${'/_api/v3/pages/list'}                                    | ${false}
+      ${'regular page path'}                | ${'/page/path'}                                             | ${false}
+      ${'root path'}                        | ${'/'}                                                      | ${false}
+      ${'empty URL'}                        | ${''}                                                       | ${false}
+      ${'partial match'}                    | ${'/_api/v3/page-listing-other/children'}                   | ${false}
     `('should return $expected for $description: $url', ({ url, expected }) => {
       const result = pageListingApiModule.canHandle(url);
       expect(result).toBe(expected);
@@ -120,10 +120,10 @@ describe('pageListingApiModule', () => {
       });
     });
 
-    describe('info endpoint', () => {
+    describe('item endpoint', () => {
       it('should anonymize path parameter when present', () => {
         const originalUrl =
-          '/_api/v3/page-listing/info?path=/wiki/documentation';
+          '/_api/v3/page-listing/item?path=/wiki/documentation';
 
         // Ensure canHandle returns true for this URL
         expect(pageListingApiModule.canHandle(originalUrl)).toBe(true);
@@ -131,12 +131,12 @@ describe('pageListingApiModule', () => {
         const result = pageListingApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/page-listing/info?path=%5BANONYMIZED%5D',
+          'http.target': '/_api/v3/page-listing/item?path=%5BANONYMIZED%5D',
         });
       });
 
       it('should return null when no path parameter is present', () => {
-        const originalUrl = '/_api/v3/page-listing/info';
+        const originalUrl = '/_api/v3/page-listing/item';
 
         // Ensure canHandle returns true for this URL
         expect(pageListingApiModule.canHandle(originalUrl)).toBe(true);

+ 2 - 2
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-listing-api-handler.ts

@@ -20,7 +20,7 @@ export const pageListingApiModule: AnonymizationModule = {
     return (
       url.includes('/_api/v3/page-listing/ancestors-children') ||
       url.includes('/_api/v3/page-listing/children') ||
-      url.includes('/_api/v3/page-listing/info')
+      url.includes('/_api/v3/page-listing/item')
     );
     // Add other page-listing endpoints here as needed
   },
@@ -39,7 +39,7 @@ export const pageListingApiModule: AnonymizationModule = {
     if (
       url.includes('/_api/v3/page-listing/ancestors-children') ||
       url.includes('/_api/v3/page-listing/children') ||
-      url.includes('/_api/v3/page-listing/info')
+      url.includes('/_api/v3/page-listing/item')
     ) {
       const anonymizedUrl = anonymizeQueryParams(url, ['path']);
       // Only set attributes if the URL was actually modified

+ 3 - 4
apps/app/src/features/page-bulk-export/server/service/check-page-bulk-export-job-in-progress-cron.ts

@@ -22,10 +22,9 @@ class CheckPageBulkExportJobInProgressCronService extends CronService {
   }
 
   override async executeJob(): Promise<void> {
-    // TODO: remove growiCloudUri condition when bulk export can be relased for GROWI.cloud (https://redmine.weseek.co.jp/issues/163220)
-    const isBulkExportPagesEnabled =
-      configManager.getConfig('app:isBulkExportPagesEnabled') &&
-      configManager.getConfig('app:growiCloudUri') == null;
+    const isBulkExportPagesEnabled = configManager.getConfig(
+      'app:isBulkExportPagesEnabled',
+    );
     if (!isBulkExportPagesEnabled) return;
 
     const pageBulkExportJobInProgress = await PageBulkExportJob.findOne({

+ 659 - 0
apps/app/src/features/page-tree/components/ItemsTree.spec.tsx

@@ -0,0 +1,659 @@
+import type React from 'react';
+import type { FC } from 'react';
+import { Suspense } from 'react';
+import { render, waitFor } from '@testing-library/react';
+
+import type { IPageForTreeItem } from '~/interfaces/page';
+
+import type { TreeItemProps } from '../interfaces';
+import { invalidatePageTreeChildren } from '../services';
+import { ItemsTree } from './ItemsTree';
+
+// Mock the apiv3Get function
+const mockApiv3Get = vi.fn();
+vi.mock('~/client/util/apiv3-client', () => ({
+  apiv3Get: (...args: unknown[]) => mockApiv3Get(...args),
+}));
+
+// Mock useSWRxRootPage
+const mockRootPage: IPageForTreeItem = {
+  _id: 'root-page-id',
+  path: '/',
+  parent: null,
+  descendantCount: 10,
+  grant: 1,
+  isEmpty: false,
+  wip: false,
+};
+
+vi.mock('~/stores/page-listing', () => ({
+  useSWRxRootPage: () => ({
+    data: { rootPage: mockRootPage },
+  }),
+}));
+
+// Mock page-tree-create state hooks
+// These will be overridden in specific tests
+let mockCreatingParentId: string | null = null;
+let mockCreatingParentPath: string | null = null;
+
+vi.mock('../states/page-tree-create', async () => {
+  const actual = await vi.importActual('../states/page-tree-create');
+  return {
+    ...actual,
+    useCreatingParentId: () => mockCreatingParentId,
+    useCreatingParentPath: () => mockCreatingParentPath,
+  };
+});
+
+// Mock page-tree-update hooks
+vi.mock('../states/page-tree-update', () => ({
+  usePageTreeInformationGeneration: () => 1,
+  usePageTreeRevalidationEffect: () => {},
+  usePageTreeInformationUpdate: () => ({
+    notifyUpdateItems: vi.fn(),
+    notifyUpdateAllTrees: vi.fn(),
+  }),
+}));
+
+// Mock usePageRename
+vi.mock('../hooks/use-page-rename', () => ({
+  usePageRename: () => ({
+    rename: vi.fn(),
+    getPageName: (item: { getItemData: () => IPageForTreeItem }) => {
+      const data = item.getItemData();
+      const parts = data.path?.split('/') ?? [];
+      return parts[parts.length - 1] || '/';
+    },
+  }),
+}));
+
+// Mock usePageCreate
+vi.mock('../hooks/use-page-create', () => ({
+  usePageCreate: () => ({
+    createFromPlaceholder: vi.fn(),
+    isCreatingPlaceholder: () => false,
+    cancelCreating: vi.fn(),
+  }),
+}));
+
+// Mock useScrollToSelectedItem
+vi.mock('../hooks/use-scroll-to-selected-item', () => ({
+  useScrollToSelectedItem: () => {},
+}));
+
+/**
+ * Create mock page data for testing
+ */
+const createMockPage = (
+  id: string,
+  path: string,
+  options: Partial<IPageForTreeItem> = {},
+): IPageForTreeItem => ({
+  _id: id,
+  path,
+  parent: null,
+  descendantCount: 0,
+  grant: 1,
+  isEmpty: false,
+  wip: false,
+  ...options,
+});
+
+/**
+ * Simple TreeItem component for testing
+ */
+const TestTreeItem: FC<TreeItemProps> = ({ item }) => {
+  const itemData = item.getItemData();
+  return <div data-testid={`tree-item-${itemData._id}`}>{itemData.path}</div>;
+};
+
+/**
+ * Wrapper component with Suspense for testing
+ */
+const TestWrapper: FC<{ children: React.ReactNode }> = ({ children }) => (
+  <Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
+);
+
+describe('ItemsTree', () => {
+  beforeEach(() => {
+    // Clear cache before each test
+    invalidatePageTreeChildren();
+    // Reset mock
+    mockApiv3Get.mockReset();
+    // Reset creating state
+    mockCreatingParentId = null;
+    mockCreatingParentPath = null;
+  });
+
+  describe('API call optimization', () => {
+    test('should only fetch children for expanded nodes, not for all visible nodes', async () => {
+      // Setup: Root page has 3 children, each with descendantCount > 0 (folders)
+      // but none are expanded initially except root
+      const rootChildren = [
+        createMockPage('child-1', '/Page1', { descendantCount: 5 }),
+        createMockPage('child-2', '/Page2', { descendantCount: 3 }),
+        createMockPage('child-3', '/Page3', { descendantCount: 0 }), // leaf node
+      ];
+
+      // Mock API responses
+      mockApiv3Get.mockImplementation(
+        (endpoint: string, params: { id: string }) => {
+          if (endpoint === '/page-listing/children') {
+            if (params.id === 'root-page-id') {
+              return Promise.resolve({ data: { children: rootChildren } });
+            }
+            // Return empty children for other nodes (they shouldn't be called if not expanded)
+            return Promise.resolve({ data: { children: [] } });
+          }
+          // Handle /page-listing/item endpoint
+          if (endpoint === '/page-listing/item') {
+            return Promise.resolve({
+              data: { item: createMockPage(params.id, `/${params.id}`) },
+            });
+          }
+          return Promise.reject(new Error(`Unexpected endpoint: ${endpoint}`));
+        },
+      );
+
+      const scrollerElem = document.createElement('div');
+      scrollerElem.style.height = '500px';
+      scrollerElem.style.overflow = 'auto';
+      document.body.appendChild(scrollerElem);
+
+      render(
+        <TestWrapper>
+          <ItemsTree
+            targetPath="/"
+            isEnableActions={false}
+            CustomTreeItem={TestTreeItem}
+            estimateTreeItemSize={() => 32}
+            scrollerElem={scrollerElem}
+          />
+        </TestWrapper>,
+      );
+
+      // Wait for initial render and API calls to complete
+      await waitFor(() => {
+        expect(mockApiv3Get).toHaveBeenCalled();
+      });
+
+      // Give time for any additional API calls that might happen
+      await new Promise((resolve) => setTimeout(resolve, 100));
+
+      // Key assertion: API should only be called for:
+      // 1. root-page-id (the only expanded node by default)
+      // NOT for child-1, child-2, child-3 even though they are visible
+      const childrenApiCalls = mockApiv3Get.mock.calls.filter(
+        (call) => call[0] === '/page-listing/children',
+      );
+
+      // Should only have 1 call for root-page-id
+      expect(childrenApiCalls).toHaveLength(1);
+      expect(childrenApiCalls[0][1]).toEqual({ id: 'root-page-id' });
+
+      // Cleanup
+      document.body.removeChild(scrollerElem);
+    });
+
+    test('should not call API for nodes that have descendantCount of 0 (leaf nodes)', async () => {
+      // Setup: All children are leaf nodes (descendantCount = 0)
+      const rootChildren = [
+        createMockPage('leaf-1', '/Leaf1', { descendantCount: 0 }),
+        createMockPage('leaf-2', '/Leaf2', { descendantCount: 0 }),
+        createMockPage('leaf-3', '/Leaf3', { descendantCount: 0 }),
+      ];
+
+      mockApiv3Get.mockImplementation(
+        (endpoint: string, params: { id: string }) => {
+          if (endpoint === '/page-listing/children') {
+            if (params.id === 'root-page-id') {
+              return Promise.resolve({ data: { children: rootChildren } });
+            }
+            return Promise.resolve({ data: { children: [] } });
+          }
+          // Handle /page-listing/item endpoint
+          if (endpoint === '/page-listing/item') {
+            return Promise.resolve({
+              data: { item: createMockPage(params.id, `/${params.id}`) },
+            });
+          }
+          return Promise.resolve({ data: { children: [] } });
+        },
+      );
+
+      const scrollerElem = document.createElement('div');
+      scrollerElem.style.height = '500px';
+      document.body.appendChild(scrollerElem);
+
+      render(
+        <TestWrapper>
+          <ItemsTree
+            targetPath="/"
+            CustomTreeItem={TestTreeItem}
+            estimateTreeItemSize={() => 32}
+            scrollerElem={scrollerElem}
+          />
+        </TestWrapper>,
+      );
+
+      await waitFor(() => {
+        expect(mockApiv3Get).toHaveBeenCalled();
+      });
+
+      await new Promise((resolve) => setTimeout(resolve, 100));
+
+      const childrenApiCalls = mockApiv3Get.mock.calls.filter(
+        (call) => call[0] === '/page-listing/children',
+      );
+
+      // Only root should have children fetched
+      expect(childrenApiCalls).toHaveLength(1);
+      expect(childrenApiCalls[0][1]).toEqual({ id: 'root-page-id' });
+
+      document.body.removeChild(scrollerElem);
+    });
+
+    test('isItemFolder should use descendantCount instead of calling getChildren()', async () => {
+      // This test verifies the fix for the bug where isItemFolder called getChildren()
+      // which triggered API calls for ALL visible nodes
+
+      const rootChildren = [
+        createMockPage('folder-1', '/Folder1', { descendantCount: 5 }),
+        createMockPage('folder-2', '/Folder2', { descendantCount: 10 }),
+        createMockPage('leaf-1', '/Leaf1', { descendantCount: 0 }),
+      ];
+
+      // Track which IDs have their children fetched
+      const fetchedChildrenIds: string[] = [];
+
+      mockApiv3Get.mockImplementation(
+        (endpoint: string, params: { id: string }) => {
+          if (endpoint === '/page-listing/children') {
+            fetchedChildrenIds.push(params.id);
+            if (params.id === 'root-page-id') {
+              return Promise.resolve({ data: { children: rootChildren } });
+            }
+            return Promise.resolve({ data: { children: [] } });
+          }
+          // Handle /page-listing/item endpoint
+          if (endpoint === '/page-listing/item') {
+            return Promise.resolve({
+              data: { item: createMockPage(params.id, `/${params.id}`) },
+            });
+          }
+          return Promise.reject(new Error(`Unexpected endpoint: ${endpoint}`));
+        },
+      );
+
+      const scrollerElem = document.createElement('div');
+      scrollerElem.style.height = '500px';
+      document.body.appendChild(scrollerElem);
+
+      render(
+        <TestWrapper>
+          <ItemsTree
+            targetPath="/"
+            CustomTreeItem={TestTreeItem}
+            estimateTreeItemSize={() => 32}
+            scrollerElem={scrollerElem}
+          />
+        </TestWrapper>,
+      );
+
+      await waitFor(() => {
+        expect(mockApiv3Get).toHaveBeenCalled();
+      });
+
+      // Wait for any potential additional API calls
+      await new Promise((resolve) => setTimeout(resolve, 200));
+
+      // Critical assertion: Only root-page-id should have children fetched
+      // folder-1 and folder-2 should NOT be fetched even though they are folders (descendantCount > 0)
+      // This verifies that isItemFolder doesn't call getChildren()
+      expect(fetchedChildrenIds).toEqual(['root-page-id']);
+      expect(fetchedChildrenIds).not.toContain('folder-1');
+      expect(fetchedChildrenIds).not.toContain('folder-2');
+
+      document.body.removeChild(scrollerElem);
+    });
+  });
+
+  describe('auto-expand ancestors', () => {
+    test('should fetch children only for ancestors of targetPath', async () => {
+      // Setup: Deep nested structure
+      // / (root)
+      //   /Sandbox (expanded because it's ancestor of target)
+      //     /Sandbox/Test (target)
+      //   /Other (NOT expanded)
+
+      const rootChildren = [
+        createMockPage('sandbox-id', '/Sandbox', { descendantCount: 5 }),
+        createMockPage('other-id', '/Other', { descendantCount: 3 }),
+      ];
+
+      const sandboxChildren = [
+        createMockPage('test-id', '/Sandbox/Test', { descendantCount: 0 }),
+      ];
+
+      const fetchedChildrenIds: string[] = [];
+
+      mockApiv3Get.mockImplementation(
+        (endpoint: string, params: { id: string }) => {
+          if (endpoint === '/page-listing/children') {
+            fetchedChildrenIds.push(params.id);
+
+            if (params.id === 'root-page-id') {
+              return Promise.resolve({ data: { children: rootChildren } });
+            }
+            if (params.id === 'sandbox-id') {
+              return Promise.resolve({ data: { children: sandboxChildren } });
+            }
+            return Promise.resolve({ data: { children: [] } });
+          }
+          // Handle /page-listing/item endpoint
+          if (endpoint === '/page-listing/item') {
+            return Promise.resolve({
+              data: { item: createMockPage(params.id, `/${params.id}`) },
+            });
+          }
+          return Promise.reject(new Error(`Unexpected endpoint: ${endpoint}`));
+        },
+      );
+
+      const scrollerElem = document.createElement('div');
+      scrollerElem.style.height = '500px';
+      document.body.appendChild(scrollerElem);
+
+      render(
+        <TestWrapper>
+          <ItemsTree
+            targetPath="/Sandbox/Test"
+            CustomTreeItem={TestTreeItem}
+            estimateTreeItemSize={() => 32}
+            scrollerElem={scrollerElem}
+          />
+        </TestWrapper>,
+      );
+
+      // Wait for auto-expand to complete
+      await waitFor(
+        () => {
+          expect(fetchedChildrenIds).toContain('sandbox-id');
+        },
+        { timeout: 1000 },
+      );
+
+      // Give some extra time for any unwanted calls
+      await new Promise((resolve) => setTimeout(resolve, 200));
+
+      // Should fetch: root-page-id (initial), sandbox-id (ancestor of target)
+      // Should NOT fetch: other-id (not an ancestor of target)
+      expect(fetchedChildrenIds).toContain('root-page-id');
+      expect(fetchedChildrenIds).toContain('sandbox-id');
+      expect(fetchedChildrenIds).not.toContain('other-id');
+
+      document.body.removeChild(scrollerElem);
+    });
+  });
+
+  // NOTE: Page creation placeholder tests are covered in use-data-loader.spec.tsx
+  // The dataLoader is responsible for prepending placeholder nodes when creatingParentId is set
+
+  describe('page creation (creatingParentId)', () => {
+    test('should not cause infinite API requests when creatingParentId is set', async () => {
+      // This test verifies the fix for the infinite request loop bug
+      // When creatingParentId is set, the component should:
+      // 1. Invalidate and refetch children for that parent ONCE
+      // 2. NOT continuously refetch in an infinite loop
+
+      const rootChildren = [
+        createMockPage('parent-1', '/Parent1', { descendantCount: 2 }),
+        createMockPage('parent-2', '/Parent2', { descendantCount: 0 }),
+      ];
+
+      const parent1Children = [
+        createMockPage('child-1', '/Parent1/Child1', { descendantCount: 0 }),
+      ];
+
+      // Track API call count per ID
+      const apiCallCounts: Record<string, number> = {};
+
+      mockApiv3Get.mockImplementation(
+        (endpoint: string, params: { id: string }) => {
+          if (endpoint === '/page-listing/children') {
+            apiCallCounts[params.id] = (apiCallCounts[params.id] || 0) + 1;
+
+            if (params.id === 'root-page-id') {
+              return Promise.resolve({ data: { children: rootChildren } });
+            }
+            if (params.id === 'parent-1') {
+              return Promise.resolve({ data: { children: parent1Children } });
+            }
+            return Promise.resolve({ data: { children: [] } });
+          }
+          // Handle /page-listing/item endpoint for individual item fetches
+          if (endpoint === '/page-listing/item') {
+            return Promise.resolve({
+              data: { item: createMockPage(params.id, `/${params.id}`) },
+            });
+          }
+          return Promise.reject(new Error(`Unexpected endpoint: ${endpoint}`));
+        },
+      );
+
+      const scrollerElem = document.createElement('div');
+      scrollerElem.style.height = '500px';
+      document.body.appendChild(scrollerElem);
+
+      // Set creatingParentId BEFORE rendering to simulate the user clicking create button
+      mockCreatingParentId = 'parent-1';
+      mockCreatingParentPath = '/Parent1';
+
+      const { rerender } = render(
+        <TestWrapper>
+          <ItemsTree
+            targetPath="/"
+            isEnableActions={true}
+            CustomTreeItem={TestTreeItem}
+            estimateTreeItemSize={() => 32}
+            scrollerElem={scrollerElem}
+          />
+        </TestWrapper>,
+      );
+
+      // Wait for initial data fetch
+      await waitFor(() => {
+        expect(mockApiv3Get).toHaveBeenCalled();
+      });
+
+      // Wait a reasonable amount of time to detect infinite loops
+      // If there's an infinite loop, we'd see many API calls within this time
+      await new Promise((resolve) => setTimeout(resolve, 500));
+
+      // Force re-render to simulate React re-renders that could trigger the loop
+      rerender(
+        <TestWrapper>
+          <ItemsTree
+            targetPath="/"
+            isEnableActions={true}
+            CustomTreeItem={TestTreeItem}
+            estimateTreeItemSize={() => 32}
+            scrollerElem={scrollerElem}
+          />
+        </TestWrapper>,
+      );
+
+      // Wait more time for potential infinite loop to manifest
+      await new Promise((resolve) => setTimeout(resolve, 500));
+
+      // Key assertion: API calls for parent-1 should be bounded
+      // An infinite loop would cause this count to be very high (100+)
+      // Normal behavior: 1-3 calls (initial load + invalidation)
+      const parent1CallCount = apiCallCounts['parent-1'] || 0;
+      expect(parent1CallCount).toBeLessThanOrEqual(5);
+
+      // Total API calls should also be bounded
+      const totalCalls = Object.values(apiCallCounts).reduce(
+        (sum, count) => sum + count,
+        0,
+      );
+      expect(totalCalls).toBeLessThanOrEqual(10);
+
+      document.body.removeChild(scrollerElem);
+    });
+
+    test('should handle creatingParentId change without infinite loop', async () => {
+      // Test that changing creatingParentId from one value to another
+      // doesn't cause infinite requests
+
+      const rootChildren = [
+        createMockPage('parent-1', '/Parent1', { descendantCount: 1 }),
+        createMockPage('parent-2', '/Parent2', { descendantCount: 1 }),
+      ];
+
+      let totalApiCalls = 0;
+
+      mockApiv3Get.mockImplementation(
+        (endpoint: string, params: { id: string }) => {
+          if (endpoint === '/page-listing/children') {
+            totalApiCalls++;
+
+            if (params.id === 'root-page-id') {
+              return Promise.resolve({ data: { children: rootChildren } });
+            }
+            return Promise.resolve({ data: { children: [] } });
+          }
+          // Handle /page-listing/item endpoint for individual item fetches
+          if (endpoint === '/page-listing/item') {
+            return Promise.resolve({
+              data: { item: createMockPage(params.id, `/${params.id}`) },
+            });
+          }
+          return Promise.reject(new Error(`Unexpected endpoint: ${endpoint}`));
+        },
+      );
+
+      const scrollerElem = document.createElement('div');
+      scrollerElem.style.height = '500px';
+      document.body.appendChild(scrollerElem);
+
+      // Initial render without creating state
+      render(
+        <TestWrapper>
+          <ItemsTree
+            targetPath="/"
+            isEnableActions={true}
+            CustomTreeItem={TestTreeItem}
+            estimateTreeItemSize={() => 32}
+            scrollerElem={scrollerElem}
+          />
+        </TestWrapper>,
+      );
+
+      // Wait for initial load
+      await waitFor(() => {
+        expect(mockApiv3Get).toHaveBeenCalled();
+      });
+      await new Promise((resolve) => setTimeout(resolve, 200));
+
+      const callsAfterInitialLoad = totalApiCalls;
+
+      // Simulate setting creatingParentId (user clicks create button)
+      // Note: Since we can't easily change the mock mid-test in this setup,
+      // we're mainly testing the initial render with creatingParentId set
+
+      // Wait to ensure no more calls happen
+      await new Promise((resolve) => setTimeout(resolve, 500));
+
+      // Verify API calls stabilized
+      expect(totalApiCalls).toBeLessThanOrEqual(callsAfterInitialLoad + 2);
+
+      document.body.removeChild(scrollerElem);
+    });
+
+    test('should stop fetching when creatingParentId becomes null', async () => {
+      // Verify that resetting creatingParentId to null doesn't cause issues
+
+      const rootChildren = [
+        createMockPage('parent-1', '/Parent1', { descendantCount: 1 }),
+      ];
+
+      let apiCallCount = 0;
+
+      mockApiv3Get.mockImplementation(
+        (endpoint: string, params: { id: string }) => {
+          if (endpoint === '/page-listing/children') {
+            apiCallCount++;
+
+            if (params.id === 'root-page-id') {
+              return Promise.resolve({ data: { children: rootChildren } });
+            }
+            return Promise.resolve({ data: { children: [] } });
+          }
+          // Handle /page-listing/item endpoint for individual item fetches
+          if (endpoint === '/page-listing/item') {
+            return Promise.resolve({
+              data: { item: createMockPage(params.id, `/${params.id}`) },
+            });
+          }
+          return Promise.reject(new Error(`Unexpected endpoint: ${endpoint}`));
+        },
+      );
+
+      const scrollerElem = document.createElement('div');
+      scrollerElem.style.height = '500px';
+      document.body.appendChild(scrollerElem);
+
+      // Start with creatingParentId set
+      mockCreatingParentId = 'parent-1';
+      mockCreatingParentPath = '/Parent1';
+
+      const { unmount } = render(
+        <TestWrapper>
+          <ItemsTree
+            targetPath="/"
+            isEnableActions={true}
+            CustomTreeItem={TestTreeItem}
+            estimateTreeItemSize={() => 32}
+            scrollerElem={scrollerElem}
+          />
+        </TestWrapper>,
+      );
+
+      await waitFor(() => {
+        expect(mockApiv3Get).toHaveBeenCalled();
+      });
+      await new Promise((resolve) => setTimeout(resolve, 300));
+
+      const callsBeforeReset = apiCallCount;
+
+      // Reset creating state (simulating cancel or completion)
+      mockCreatingParentId = null;
+      mockCreatingParentPath = null;
+
+      // Unmount and remount to apply the null state
+      unmount();
+
+      render(
+        <TestWrapper>
+          <ItemsTree
+            targetPath="/"
+            isEnableActions={true}
+            CustomTreeItem={TestTreeItem}
+            estimateTreeItemSize={() => 32}
+            scrollerElem={scrollerElem}
+          />
+        </TestWrapper>,
+      );
+
+      await new Promise((resolve) => setTimeout(resolve, 500));
+
+      // API calls should be bounded even after state changes
+      // The difference should be minimal (just the initial load after remount)
+      expect(apiCallCount - callsBeforeReset).toBeLessThanOrEqual(3);
+
+      document.body.removeChild(scrollerElem);
+    });
+  });
+});

+ 258 - 0
apps/app/src/features/page-tree/components/ItemsTree.tsx

@@ -0,0 +1,258 @@
+import type { FC } from 'react';
+import { useCallback, useEffect, useMemo } from 'react';
+import { useTree } from '@headless-tree/react';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import { useTranslation } from 'next-i18next';
+
+import { toastError, toastWarning } from '~/client/util/toastr';
+import type { IPageForTreeItem } from '~/interfaces/page';
+import { useSWRxRootPage } from '~/stores/page-listing';
+
+import { ROOT_PAGE_VIRTUAL_ID } from '../constants/_inner';
+import {
+  useAutoExpandAncestors,
+  useDataLoader,
+  useExpandParentOnCreate,
+  useScrollToSelectedItem,
+  useTreeFeatures,
+  useTreeItemHandlers,
+  useTreeRevalidation,
+} from '../hooks/_inner';
+import { useSocketUpdateDescCount } from '../hooks/use-socket-update-desc-count';
+import type { TreeItemProps } from '../interfaces';
+import { useTriggerTreeRebuild } from '../states/_inner';
+
+// Stable createLoadingItemData function
+const createLoadingItemData = (): IPageForTreeItem => ({
+  _id: '',
+  path: 'Loading...',
+  parent: '',
+  descendantCount: 0,
+  grant: 1,
+  isEmpty: false,
+  wip: false,
+});
+
+type Props = {
+  targetPath: string;
+  targetPathOrId?: string;
+  isWipPageShown?: boolean;
+  isEnableActions?: boolean;
+  isReadOnlyUser?: boolean;
+  CustomTreeItem: React.FunctionComponent<TreeItemProps>;
+  estimateTreeItemSize: () => number;
+  scrollerElem?: HTMLElement | null;
+  // Feature options
+  enableRenaming?: boolean;
+  enableCheckboxes?: boolean;
+  enableDragAndDrop?: boolean;
+  initialCheckedItems?: string[];
+  onCheckedItemsChange?: (checkedItems: IPageForTreeItem[]) => void;
+};
+
+export const ItemsTree: FC<Props> = (props: Props) => {
+  const {
+    targetPath,
+    targetPathOrId,
+    isWipPageShown = true,
+    isEnableActions = false,
+    isReadOnlyUser = false,
+    CustomTreeItem,
+    estimateTreeItemSize,
+    scrollerElem,
+    enableRenaming = false,
+    enableCheckboxes = false,
+    enableDragAndDrop = false,
+    initialCheckedItems = [],
+    onCheckedItemsChange,
+  } = props;
+
+  const { t } = useTranslation();
+  const triggerTreeRebuild = useTriggerTreeRebuild();
+
+  const { data: rootPageResult } = useSWRxRootPage({ suspense: true });
+  const rootPage = rootPageResult?.rootPage;
+  const rootPageId = rootPage?._id ?? ROOT_PAGE_VIRTUAL_ID;
+  const allPagesCount = rootPage?.descendantCount ?? 0;
+
+  const dataLoader = useDataLoader(rootPageId, allPagesCount);
+
+  // Tree item handlers (rename, create, etc.) with stable callbacks for headless-tree
+  const { getItemName, isItemFolder, handleRename, creatingParentId } =
+    useTreeItemHandlers(triggerTreeRebuild);
+
+  // Configure tree features and get checkbox state and D&D handlers
+  const { features, checkboxProperties, dndProperties } = useTreeFeatures({
+    enableRenaming,
+    enableCheckboxes,
+    enableDragAndDrop,
+    initialCheckedItems,
+  });
+
+  const { setCheckedItems, createNotifyEffect } = checkboxProperties;
+  const { canDrag, canDrop, onDrop, renderDragLine } = dndProperties;
+
+  // Wrap onDrop to show toast notifications
+  const handleDrop = useCallback(
+    async (...args: Parameters<typeof onDrop>) => {
+      const result = await onDrop(...args);
+      if (!result.success) {
+        if (result.errorType === 'operation_blocked') {
+          toastWarning(t('page_tree.move_blocked'));
+        } else {
+          toastError(t('page_tree.move_failed'));
+        }
+      }
+    },
+    [onDrop, t],
+  );
+
+  // Stable initial state
+  // biome-ignore lint/correctness/useExhaustiveDependencies: initialCheckedItems is intentionally not in deps to avoid reinitializing on every change
+  const initialState = useMemo(
+    () => ({
+      expandedItems: [ROOT_PAGE_VIRTUAL_ID],
+      ...(enableCheckboxes ? { checkedItems: initialCheckedItems } : {}),
+    }),
+    [enableCheckboxes],
+  );
+
+  const tree = useTree<IPageForTreeItem>({
+    rootItemId: ROOT_PAGE_VIRTUAL_ID,
+    getItemName,
+    initialState,
+    isItemFolder,
+    createLoadingItemData,
+    dataLoader,
+    onRename: handleRename,
+    features,
+    // Checkbox configuration
+    canCheckFolders: enableCheckboxes,
+    propagateCheckedState: false,
+    setCheckedItems,
+    // Drag and drop configuration (only when enabled)
+    ...(enableDragAndDrop && {
+      canDrag,
+      canDrop,
+      onDrop: handleDrop,
+      canDropInbetween: false,
+    }),
+  });
+
+  // Notify parent when checked items change
+  // biome-ignore lint/correctness/useExhaustiveDependencies: createNotifyEffect already includes checkedItemIds in its closure
+  useEffect(createNotifyEffect(tree, onCheckedItemsChange), [
+    createNotifyEffect,
+    tree,
+  ]);
+
+  // Subscribe to Socket.io UpdateDescCount events
+  useSocketUpdateDescCount();
+
+  // Handle tree revalidation and items count tracking
+  useTreeRevalidation({ tree, triggerTreeRebuild });
+
+  // Expand parent item when page creation is initiated
+  useExpandParentOnCreate({
+    tree,
+    creatingParentId,
+    onTreeUpdated: triggerTreeRebuild,
+  });
+
+  const items = tree.getItems();
+
+  // Auto-expand items that are ancestors of targetPath
+  useAutoExpandAncestors({
+    items,
+    targetPath,
+    onExpanded: triggerTreeRebuild,
+  });
+
+  const getScrollElement = useCallback(
+    () => scrollerElem ?? null,
+    [scrollerElem],
+  );
+
+  const stableEstimateSize = useCallback(() => {
+    return estimateTreeItemSize();
+  }, [estimateTreeItemSize]);
+
+  const measureElement = useCallback(
+    (element: Element | null) => {
+      // Return consistent height measurement
+      return element?.getBoundingClientRect().height ?? stableEstimateSize();
+    },
+    [stableEstimateSize],
+  );
+
+  const virtualizer = useVirtualizer({
+    count: items.length,
+    getScrollElement,
+    estimateSize: stableEstimateSize,
+    overscan: 5,
+    measureElement,
+  });
+
+  // Scroll to selected item on mount or when targetPathOrId changes
+  useScrollToSelectedItem({ targetPathOrId, items, virtualizer });
+
+  return (
+    <div
+      {...tree.getContainerProps()}
+      className="list-group position-relative"
+      style={{ height: `${virtualizer.getTotalSize()}px` }}
+    >
+      {virtualizer.getVirtualItems().map((virtualItem) => {
+        const item = items[virtualItem.index];
+        const itemData = item.getItemData();
+
+        // Skip rendering virtual root
+        if (itemData._id === ROOT_PAGE_VIRTUAL_ID) {
+          return null;
+        }
+
+        // Skip rendering WIP pages if not shown
+        if (!isWipPageShown && itemData.wip) {
+          return null;
+        }
+
+        const { ref: itemRef, ...itemProps } = item.getProps();
+        // Exclude onClick from itemProps to prevent conflicts
+        const { onClick: _onClick, ...itemPropsWithoutOnClick } = itemProps;
+
+        return (
+          <div
+            key={virtualItem.key}
+            data-index={virtualItem.index}
+            style={{
+              position: 'absolute',
+              top: 0,
+              left: 0,
+              width: '100%',
+              transform: `translateY(${virtualItem.start}px)`,
+            }}
+            ref={(node) => {
+              if (node && itemRef) {
+                (itemRef as (node: HTMLElement) => void)(node);
+              }
+            }}
+            // Apply props
+            {...itemPropsWithoutOnClick}
+          >
+            <CustomTreeItem
+              item={item}
+              targetPath={targetPath}
+              targetPathOrId={targetPathOrId}
+              isWipPageShown={isWipPageShown}
+              isEnableActions={isEnableActions}
+              isReadOnlyUser={isReadOnlyUser}
+              onToggle={triggerTreeRebuild}
+            />
+          </div>
+        );
+      })}
+      {/* Drag line indicator (rendered by dndProperties when D&D is enabled) */}
+      {renderDragLine(tree)}
+    </div>
+  );
+};

+ 0 - 0
apps/app/src/client/components/TreeItem/SimpleItemContent.module.scss → apps/app/src/features/page-tree/components/SimpleItemContent.module.scss


+ 28 - 14
apps/app/src/client/components/TreeItem/SimpleItemContent.tsx → apps/app/src/features/page-tree/components/SimpleItemContent.tsx

@@ -1,8 +1,6 @@
-import type { JSX } from 'react';
-
-import nodePath from 'path';
-
+import { useId } from 'react';
 import { useTranslation } from 'next-i18next';
+import path from 'pathe';
 import { UncontrolledTooltip } from 'reactstrap';
 
 import type { IPageForItem } from '~/interfaces/page';
@@ -10,16 +8,21 @@ import { shouldRecoverPagePaths } from '~/utils/page-operation';
 
 import styles from './SimpleItemContent.module.scss';
 
-
 const moduleClass = styles['simple-item-content'] ?? '';
 
-
-export const SimpleItemContent = ({ page }: { page: IPageForItem }): JSX.Element => {
+export const SimpleItemContent = ({
+  page,
+}: {
+  page: IPageForItem;
+}): JSX.Element => {
   const { t } = useTranslation();
 
-  const pageName = nodePath.basename(page.path ?? '') || '/';
+  const pageName = path.basename(page.path ?? '') || '/';
+
+  const shouldShowAttentionIcon =
+    page.processData != null ? shouldRecoverPagePaths(page.processData) : false;
 
-  const shouldShowAttentionIcon = page.processData != null ? shouldRecoverPagePaths(page.processData) : false;
+  const spanId = `path-recovery-${useId()}`;
 
   return (
     <div
@@ -28,8 +31,13 @@ export const SimpleItemContent = ({ page }: { page: IPageForItem }): JSX.Element
     >
       {shouldShowAttentionIcon && (
         <>
-          <span id="path-recovery" className="material-symbols-outlined mr-2 text-warning">warning</span>
-          <UncontrolledTooltip placement="top" target="path-recovery" fade={false}>
+          <span
+            id={spanId}
+            className="material-symbols-outlined mr-2 text-warning"
+          >
+            warning
+          </span>
+          <UncontrolledTooltip placement="top" target={spanId} fade={false}>
             {t('tooltip.operation.attention.rename')}
           </UncontrolledTooltip>
         </>
@@ -37,9 +45,15 @@ export const SimpleItemContent = ({ page }: { page: IPageForItem }): JSX.Element
       {page != null && page.path != null && page._id != null && (
         <div className="grw-page-title-anchor flex-grow-1">
           <div className="d-flex align-items-center">
-            <span className={`text-truncate me-1 ${page.isEmpty && 'opacity-75'}`}>{pageName}</span>
-            { page.wip && (
-              <span className="wip-page-badge badge rounded-pill me-1 text-bg-secondary">WIP</span>
+            <span
+              className={`text-truncate me-1 ${page.isEmpty && 'opacity-75'}`}
+            >
+              {pageName}
+            </span>
+            {page.wip && (
+              <span className="wip-page-badge badge rounded-pill me-1 text-bg-secondary">
+                WIP
+              </span>
             )}
           </div>
         </div>

+ 7 - 0
apps/app/src/client/components/TreeItem/TreeItemLayout.module.scss → apps/app/src/features/page-tree/components/TreeItemLayout.module.scss

@@ -12,6 +12,13 @@
           display: flex !important;
         }
       }
+
+      // Drag target state styling
+      &.drag-target {
+        background-color: var(--bs-list-group-active-bg) !important;
+        outline: 3px dashed var(--bs-list-group-active-border-color);
+        outline-offset: -4px;
+      }
     }
   }
 }

+ 163 - 0
apps/app/src/features/page-tree/components/TreeItemLayout.tsx

@@ -0,0 +1,163 @@
+import type { JSX, MouseEvent } from 'react';
+import { useCallback, useMemo } from 'react';
+
+import type { TreeItemProps, TreeItemToolProps } from '../interfaces';
+import { SimpleItemContent } from './SimpleItemContent';
+
+import styles from './TreeItemLayout.module.scss';
+
+const moduleClass = styles['tree-item-layout'] ?? '';
+
+const indentSize = 10; // in px
+
+type TreeItemLayoutProps = TreeItemProps & {
+  className?: string;
+};
+
+export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
+  const {
+    className,
+    itemClassName,
+    item,
+    targetPathOrId,
+    isEnableActions,
+    isReadOnlyUser,
+    isWipPageShown = true,
+    showAlternativeContent,
+    onRenamed,
+    onClick,
+    onClickDuplicateMenuItem,
+    onClickDeleteMenuItem,
+    onWheelClick,
+    onToggle,
+  } = props;
+
+  const page = item.getItemData();
+  const itemLevel = item.getItemMeta().level;
+
+  const toggleHandler = useCallback(() => {
+    if (item.isExpanded()) {
+      item.collapse();
+    } else {
+      item.expand();
+    }
+
+    onToggle?.();
+  }, [item, onToggle]);
+
+  const itemClickHandler = useCallback(
+    (e: MouseEvent) => {
+      // DO NOT handle the event when e.currentTarget and e.target is different
+      if (e.target !== e.currentTarget) {
+        return;
+      }
+
+      onClick?.(page);
+    },
+    [onClick, page],
+  );
+
+  const itemMouseupHandler = useCallback(
+    (e: MouseEvent) => {
+      // DO NOT handle the event when e.currentTarget and e.target is different
+      if (e.target !== e.currentTarget) {
+        return;
+      }
+
+      if (e.button === 1) {
+        e.preventDefault();
+        onWheelClick?.(page);
+      }
+    },
+    [onWheelClick, page],
+  );
+
+  // Use item.isFolder() which is evaluated by headless-tree's isItemFolder config
+  // This will be re-evaluated after rebuildTree()
+  const hasDescendants = item.isFolder();
+
+  const isSelected = useMemo(() => {
+    return page._id === targetPathOrId || page.path === targetPathOrId;
+  }, [page, targetPathOrId]);
+
+  // Check if this item is a drag target (being dragged over)
+  const isDragTarget = item.isDragTarget?.() ?? false;
+
+  const toolProps: TreeItemToolProps = {
+    item,
+    isEnableActions,
+    isReadOnlyUser,
+    onRenamed,
+    onClickDuplicateMenuItem,
+    onClickDeleteMenuItem,
+  };
+
+  const EndComponents = props.customEndComponents;
+  const HoveredEndComponents = props.customHoveredEndComponents;
+  const AlternativeComponents = props.customAlternativeComponents;
+
+  if (!isWipPageShown && page.wip) {
+    // biome-ignore lint/complexity/noUselessFragments: ignore
+    return <></>;
+  }
+
+  return (
+    <div
+      id={`tree-item-layout-${page._id}`}
+      data-testid="grw-pagetree-item-container"
+      className={`${moduleClass} ${className}`}
+      style={{ paddingLeft: `${itemLevel > 0 ? indentSize * itemLevel : 0}px` }}
+    >
+      {/* biome-ignore lint/a11y/useKeyWithClickEvents: tree item interaction */}
+      <li
+        className={`list-group-item list-group-item-action
+          ${isSelected ? 'active' : ''}
+          ${isDragTarget ? 'drag-target' : ''}
+          ${itemClassName ?? ''}
+          border-0 py-0 ps-0 d-flex align-items-center rounded-1`}
+        id={`grw-pagetree-list-${page._id}`}
+        onClick={itemClickHandler}
+        onMouseUp={itemMouseupHandler}
+      >
+        <div className="btn-triangle-container d-flex justify-content-center">
+          {hasDescendants && (
+            <button
+              type="button"
+              className={`btn btn-triangle p-0 ${item.isExpanded() ? 'open' : ''}`}
+              onClick={toggleHandler}
+            >
+              <div className="d-flex justify-content-center">
+                <span className="material-symbols-outlined fs-5">
+                  arrow_right
+                </span>
+              </div>
+            </button>
+          )}
+        </div>
+
+        {showAlternativeContent && AlternativeComponents != null ? (
+          AlternativeComponents.map((AlternativeContent, index) => (
+            // biome-ignore lint/suspicious/noArrayIndexKey: static component list
+            <AlternativeContent key={index} {...toolProps} />
+          ))
+        ) : (
+          <>
+            <SimpleItemContent page={page} />
+            <div className="d-hover-none">
+              {EndComponents?.map((EndComponent, index) => (
+                // biome-ignore lint/suspicious/noArrayIndexKey: static component list
+                <EndComponent key={index} {...toolProps} />
+              ))}
+            </div>
+            <div className="d-none d-hover-flex">
+              {HoveredEndComponents?.map((HoveredEndContent, index) => (
+                // biome-ignore lint/suspicious/noArrayIndexKey: static component list
+                <HoveredEndContent key={index} {...toolProps} />
+              ))}
+            </div>
+          </>
+        )}
+      </li>
+    </div>
+  );
+};

+ 105 - 0
apps/app/src/features/page-tree/components/TreeNameInput.tsx

@@ -0,0 +1,105 @@
+import type { FC, InputHTMLAttributes } from 'react';
+import { useState } from 'react';
+import { useTranslation } from 'next-i18next';
+import { debounce } from 'throttle-debounce';
+
+import type { InputValidationResult } from '~/client/util/use-input-validator';
+import {
+  useInputValidator,
+  ValidationTarget,
+} from '~/client/util/use-input-validator';
+
+import { CREATING_PAGE_VIRTUAL_ID } from '../constants/_inner';
+import type { TreeItemToolProps } from '../interfaces';
+
+type TreeNameInputProps = {
+  /**
+   * Props from headless-tree's getRenameInputProps()
+   * Includes value, onChange, onBlur, onKeyDown, ref
+   */
+  inputProps: InputHTMLAttributes<HTMLInputElement> & {
+    ref?: (r: HTMLInputElement | null) => void;
+  };
+  /**
+   * Validation function for the input value
+   */
+  validateName?: (name: string) => InputValidationResult | null;
+  /**
+   * Placeholder text
+   */
+  placeholder?: string;
+  /**
+   * Additional CSS class
+   */
+  className?: string;
+};
+
+/**
+ * Unified input component for tree item name editing (rename/create)
+ * Uses headless-tree's renamingFeature for keyboard handling (Enter/Escape)
+ */
+const TreeNameInputSubstance: FC<TreeNameInputProps> = ({
+  inputProps,
+  validateName,
+  placeholder,
+  className,
+}) => {
+  const [validationResult, setValidationResult] =
+    useState<InputValidationResult | null>(null);
+
+  const validate = debounce(300, (value: string) => {
+    setValidationResult(validateName?.(value) ?? null);
+  });
+
+  const isInvalid = validationResult != null;
+
+  return (
+    <div className={`${className ?? ''} flex-fill`}>
+      <input
+        {...inputProps}
+        onChange={(e) => {
+          inputProps.onChange?.(e);
+          validate(e.target.value);
+        }}
+        onBlur={(e) => {
+          setValidationResult(null);
+          inputProps.onBlur?.(e);
+        }}
+        type="text"
+        placeholder={placeholder}
+        className={`form-control form-control-sm ${isInvalid ? 'is-invalid' : ''}`}
+      />
+      {isInvalid && (
+        <div className="invalid-feedback d-block my-1">
+          {validationResult.message}
+        </div>
+      )}
+    </div>
+  );
+};
+
+/**
+ * Tree item name input component for rename/create mode
+ * Wraps TreeNameInputSubstance with headless-tree's item props
+ */
+export const TreeNameInput: FC<TreeItemToolProps> = ({ item }) => {
+  const { t } = useTranslation();
+  const inputValidator = useInputValidator(ValidationTarget.PAGE);
+
+  const validateName = (name: string): InputValidationResult | null => {
+    return inputValidator(name) ?? null;
+  };
+
+  // Show placeholder only for create mode
+  const isCreating = item.getId() === CREATING_PAGE_VIRTUAL_ID;
+  const placeholder = isCreating ? t('Input page name') : undefined;
+
+  return (
+    <TreeNameInputSubstance
+      inputProps={item.getRenameInputProps()}
+      validateName={validateName}
+      placeholder={placeholder}
+      className="flex-grow-1"
+    />
+  );
+};

+ 1 - 0
apps/app/src/client/components/TreeItem/_tree-item-variables.scss → apps/app/src/features/page-tree/components/_tree-item-variables.scss

@@ -1 +1,2 @@
 $btn-triangle-min-width: 35px;
+$indent-size: 10px;

+ 4 - 0
apps/app/src/features/page-tree/components/index.ts

@@ -0,0 +1,4 @@
+export * from './ItemsTree';
+export * from './SimpleItemContent';
+export * from './TreeItemLayout';
+export * from './TreeNameInput';

+ 2 - 0
apps/app/src/features/page-tree/constants/_inner.ts

@@ -0,0 +1,2 @@
+export const ROOT_PAGE_VIRTUAL_ID = '__virtual_root__';
+export const CREATING_PAGE_VIRTUAL_ID = '__creating_page_placeholder__';

+ 8 - 0
apps/app/src/features/page-tree/hooks/_inner/index.ts

@@ -0,0 +1,8 @@
+export * from './use-auto-expand-ancestors';
+export * from './use-checkbox';
+export * from './use-data-loader';
+export * from './use-expand-parent-on-create';
+export * from './use-scroll-to-selected-item';
+export * from './use-tree-features';
+export * from './use-tree-item-handlers';
+export * from './use-tree-revalidation';

+ 294 - 0
apps/app/src/features/page-tree/hooks/_inner/use-auto-expand-ancestors.spec.tsx

@@ -0,0 +1,294 @@
+import type { ItemInstance } from '@headless-tree/core';
+import { renderHook } from '@testing-library/react';
+
+import type { IPageForTreeItem } from '~/interfaces/page';
+
+import {
+  getAncestorPaths,
+  isAncestorOf,
+  useAutoExpandAncestors,
+} from './use-auto-expand-ancestors';
+
+/**
+ * Create a mock item instance for testing
+ */
+const createMockItem = (
+  path: string,
+  options: {
+    isFolder?: boolean;
+    isExpanded?: boolean;
+  } = {},
+): ItemInstance<IPageForTreeItem> => {
+  const { isFolder = true, isExpanded = false } = options;
+  let expanded = isExpanded;
+
+  return {
+    getItemData: () => ({ path }) as IPageForTreeItem,
+    isFolder: () => isFolder,
+    isExpanded: () => expanded,
+    expand: vi.fn(() => {
+      expanded = true;
+    }),
+  } as unknown as ItemInstance<IPageForTreeItem>;
+};
+
+describe('use-auto-expand-ancestors', () => {
+  describe('getAncestorPaths', () => {
+    describe.each`
+      targetPath                      | expected
+      ${'/'}                          | ${[]}
+      ${'/Sandbox'}                   | ${[]}
+      ${'/Sandbox/Diagrams'}          | ${['/Sandbox']}
+      ${'/Sandbox/Diagrams/figure-1'} | ${['/Sandbox', '/Sandbox/Diagrams']}
+      ${'/a/b/c/d'}                   | ${['/a', '/a/b', '/a/b/c']}
+    `('should return $expected', ({ targetPath, expected }) => {
+      test(`when targetPath is '${targetPath}'`, () => {
+        const result = getAncestorPaths(targetPath);
+        expect(result).toEqual(expected);
+      });
+    });
+  });
+
+  describe('isAncestorOf', () => {
+    describe('when itemPath is root "/"', () => {
+      describe.each`
+        targetPath                      | expected
+        ${'/'}                          | ${false}
+        ${'/Sandbox'}                   | ${true}
+        ${'/Sandbox/Diagrams'}          | ${true}
+        ${'/Sandbox/Diagrams/figure-1'} | ${true}
+      `('should return $expected', ({ targetPath, expected }) => {
+        test(`when targetPath is '${targetPath}'`, () => {
+          const result = isAncestorOf('/', targetPath);
+          expect(result).toBe(expected);
+        });
+      });
+    });
+
+    describe('when itemPath is "/Sandbox"', () => {
+      describe.each`
+        targetPath                      | expected
+        ${'/'}                          | ${false}
+        ${'/Sandbox'}                   | ${false}
+        ${'/SandboxOther'}              | ${false}
+        ${'/Sandbox/Diagrams'}          | ${true}
+        ${'/Sandbox/Diagrams/figure-1'} | ${true}
+      `('should return $expected', ({ targetPath, expected }) => {
+        test(`when targetPath is '${targetPath}'`, () => {
+          const result = isAncestorOf('/Sandbox', targetPath);
+          expect(result).toBe(expected);
+        });
+      });
+    });
+
+    describe('when itemPath is "/Sandbox/Diagrams"', () => {
+      describe.each`
+        targetPath                      | expected
+        ${'/'}                          | ${false}
+        ${'/Sandbox'}                   | ${false}
+        ${'/Sandbox/Diagrams'}          | ${false}
+        ${'/Sandbox/DiagramsOther'}     | ${false}
+        ${'/Sandbox/Diagrams/figure-1'} | ${true}
+        ${'/Sandbox/Diagrams/a/b/c'}    | ${true}
+      `('should return $expected', ({ targetPath, expected }) => {
+        test(`when targetPath is '${targetPath}'`, () => {
+          const result = isAncestorOf('/Sandbox/Diagrams', targetPath);
+          expect(result).toBe(expected);
+        });
+      });
+    });
+  });
+
+  describe('useAutoExpandAncestors', () => {
+    describe('when items is empty', () => {
+      test('should not call onExpanded', () => {
+        const onExpanded = vi.fn();
+
+        renderHook(() =>
+          useAutoExpandAncestors({
+            items: [],
+            targetPath: '/Sandbox/Diagrams/figure-1',
+            onExpanded,
+          }),
+        );
+
+        expect(onExpanded).not.toHaveBeenCalled();
+      });
+
+      test('should call onExpanded when items become available on rerender', () => {
+        const onExpanded = vi.fn();
+        const rootItem = createMockItem('/');
+
+        // First render with empty items
+        const { rerender } = renderHook(
+          ({ items }) =>
+            useAutoExpandAncestors({
+              items,
+              targetPath: '/Sandbox/Diagrams/figure-1',
+              onExpanded,
+            }),
+          { initialProps: { items: [] as ItemInstance<IPageForTreeItem>[] } },
+        );
+
+        expect(onExpanded).not.toHaveBeenCalled();
+
+        // Rerender with items
+        rerender({ items: [rootItem] });
+
+        expect(rootItem.expand).toHaveBeenCalled();
+        expect(onExpanded).toHaveBeenCalledTimes(1);
+      });
+    });
+
+    describe('when items contains ancestors that need to be expanded', () => {
+      test('should expand ancestor items and call onExpanded', () => {
+        const onExpanded = vi.fn();
+        const rootItem = createMockItem('/', { isExpanded: false });
+        const sandboxItem = createMockItem('/Sandbox', { isExpanded: false });
+
+        renderHook(() =>
+          useAutoExpandAncestors({
+            items: [rootItem, sandboxItem],
+            targetPath: '/Sandbox/Diagrams/figure-1',
+            onExpanded,
+          }),
+        );
+
+        expect(rootItem.expand).toHaveBeenCalled();
+        expect(sandboxItem.expand).toHaveBeenCalled();
+        expect(onExpanded).toHaveBeenCalledTimes(1);
+      });
+
+      test('should not expand already expanded items', () => {
+        const onExpanded = vi.fn();
+        const rootItem = createMockItem('/', { isExpanded: true });
+        const sandboxItem = createMockItem('/Sandbox', { isExpanded: false });
+
+        renderHook(() =>
+          useAutoExpandAncestors({
+            items: [rootItem, sandboxItem],
+            targetPath: '/Sandbox/Diagrams/figure-1',
+            onExpanded,
+          }),
+        );
+
+        expect(rootItem.expand).not.toHaveBeenCalled();
+        expect(sandboxItem.expand).toHaveBeenCalled();
+        expect(onExpanded).toHaveBeenCalledTimes(1);
+      });
+    });
+
+    describe('when not all ancestors are loaded yet', () => {
+      test('should continue expanding as ancestors become available', () => {
+        const onExpanded = vi.fn();
+        const rootItem = createMockItem('/', { isExpanded: false });
+
+        // First render - only root is available
+        const { rerender } = renderHook(
+          ({ items }) =>
+            useAutoExpandAncestors({
+              items,
+              targetPath: '/Sandbox/Diagrams/figure-1',
+              onExpanded,
+            }),
+          { initialProps: { items: [rootItem] } },
+        );
+
+        expect(rootItem.expand).toHaveBeenCalled();
+        expect(onExpanded).toHaveBeenCalledTimes(1);
+
+        // Simulate async load - /Sandbox becomes available
+        const sandboxItem = createMockItem('/Sandbox', { isExpanded: false });
+        onExpanded.mockClear();
+
+        rerender({ items: [rootItem, sandboxItem] });
+
+        expect(sandboxItem.expand).toHaveBeenCalled();
+        expect(onExpanded).toHaveBeenCalledTimes(1);
+
+        // Simulate async load - /Sandbox/Diagrams becomes available
+        const diagramsItem = createMockItem('/Sandbox/Diagrams', {
+          isExpanded: false,
+        });
+        onExpanded.mockClear();
+
+        rerender({ items: [rootItem, sandboxItem, diagramsItem] });
+
+        expect(diagramsItem.expand).toHaveBeenCalled();
+        expect(onExpanded).toHaveBeenCalledTimes(1);
+      });
+    });
+
+    describe('when all ancestors are already expanded', () => {
+      test('should not call onExpanded', () => {
+        const onExpanded = vi.fn();
+        const rootItem = createMockItem('/', { isExpanded: true });
+        const sandboxItem = createMockItem('/Sandbox', { isExpanded: true });
+        const diagramsItem = createMockItem('/Sandbox/Diagrams', {
+          isExpanded: true,
+        });
+
+        renderHook(() =>
+          useAutoExpandAncestors({
+            items: [rootItem, sandboxItem, diagramsItem],
+            targetPath: '/Sandbox/Diagrams/figure-1',
+            onExpanded,
+          }),
+        );
+
+        expect(rootItem.expand).not.toHaveBeenCalled();
+        expect(sandboxItem.expand).not.toHaveBeenCalled();
+        expect(diagramsItem.expand).not.toHaveBeenCalled();
+        expect(onExpanded).not.toHaveBeenCalled();
+      });
+
+      test('should not process again on rerender with same targetPath', () => {
+        const onExpanded = vi.fn();
+        const rootItem = createMockItem('/', { isExpanded: true });
+        const sandboxItem = createMockItem('/Sandbox', { isExpanded: true });
+        const diagramsItem = createMockItem('/Sandbox/Diagrams', {
+          isExpanded: true,
+        });
+
+        const { rerender } = renderHook(
+          ({ items }) =>
+            useAutoExpandAncestors({
+              items,
+              targetPath: '/Sandbox/Diagrams/figure-1',
+              onExpanded,
+            }),
+          { initialProps: { items: [rootItem, sandboxItem, diagramsItem] } },
+        );
+
+        expect(onExpanded).not.toHaveBeenCalled();
+
+        // Rerender with same props - should not process again
+        rerender({ items: [rootItem, sandboxItem, diagramsItem] });
+
+        expect(onExpanded).not.toHaveBeenCalled();
+      });
+    });
+
+    describe('when item is not a folder', () => {
+      test('should not expand non-folder items', () => {
+        const onExpanded = vi.fn();
+        const rootItem = createMockItem('/', { isExpanded: false });
+        const sandboxItem = createMockItem('/Sandbox', {
+          isFolder: false,
+          isExpanded: false,
+        });
+
+        renderHook(() =>
+          useAutoExpandAncestors({
+            items: [rootItem, sandboxItem],
+            targetPath: '/Sandbox/Diagrams/figure-1',
+            onExpanded,
+          }),
+        );
+
+        expect(rootItem.expand).toHaveBeenCalled();
+        expect(sandboxItem.expand).not.toHaveBeenCalled();
+      });
+    });
+  });
+});

+ 107 - 0
apps/app/src/features/page-tree/hooks/_inner/use-auto-expand-ancestors.ts

@@ -0,0 +1,107 @@
+import { useEffect, useRef } from 'react';
+import { addTrailingSlash } from '@growi/core/dist/utils/path-utils';
+import type { ItemInstance } from '@headless-tree/core';
+
+import type { IPageForTreeItem } from '~/interfaces/page';
+
+type UseAutoExpandAncestorsProps = {
+  items: ItemInstance<IPageForTreeItem>[];
+  targetPath: string;
+  onExpanded?: () => void;
+};
+
+/**
+ * Get all ancestor paths for a given target path
+ * e.g., "/Sandbox/Diagrams/figure-1" => ["/Sandbox", "/Sandbox/Diagrams"]
+ */
+export const getAncestorPaths = (targetPath: string): string[] => {
+  const segments = targetPath.split('/').filter(Boolean);
+  const ancestors: string[] = [];
+
+  // Build ancestor paths (excluding the target itself)
+  for (let i = 0; i < segments.length - 1; i++) {
+    ancestors.push(`/${segments.slice(0, i + 1).join('/')}`);
+  }
+
+  return ancestors;
+};
+
+/**
+ * Check if itemPath is an ancestor of targetPath
+ */
+export const isAncestorOf = (itemPath: string, targetPath: string): boolean => {
+  if (itemPath === '/') {
+    return targetPath !== '/';
+  }
+  return (
+    targetPath.startsWith(addTrailingSlash(itemPath)) && targetPath !== itemPath
+  );
+};
+
+/**
+ * Hook to auto-expand tree items that are ancestors of the target path.
+ * This is useful for revealing a deeply nested page in a tree view.
+ *
+ * The hook handles async data loading by:
+ * 1. Expanding ancestors as they become available in items
+ * 2. Waiting for all ancestor paths to be loaded before marking as complete
+ * 3. Re-running when items change (e.g., after children are loaded)
+ */
+export const useAutoExpandAncestors = ({
+  items,
+  targetPath,
+  onExpanded,
+}: UseAutoExpandAncestorsProps): void => {
+  const processedTargetPathRef = useRef<string | null>(null);
+
+  useEffect(() => {
+    // Skip if no items loaded yet
+    if (items.length === 0) {
+      return;
+    }
+
+    // Skip if already fully processed for this targetPath
+    if (processedTargetPathRef.current === targetPath) {
+      return;
+    }
+
+    let didExpand = false;
+
+    for (const item of items) {
+      const itemData = item.getItemData();
+      const itemPath = itemData.path;
+
+      if (itemPath == null) continue;
+
+      // Check if this item is an ancestor of targetPath (including root "/")
+      const isAncestorOfTarget =
+        itemPath === '/' || isAncestorOf(itemPath, targetPath);
+
+      if (!isAncestorOfTarget) continue;
+
+      const isFolder = item.isFolder();
+      const isExpanded = item.isExpanded();
+
+      if (isFolder && !isExpanded) {
+        item.expand();
+        didExpand = true;
+      }
+    }
+
+    // If we expanded any items, trigger callback to re-render and load children
+    if (didExpand) {
+      onExpanded?.();
+    } else {
+      // Only mark as fully processed when all ancestors are expanded
+      // Check if we have all the ancestors we need
+      const ancestorPaths = getAncestorPaths(targetPath);
+      const hasAllAncestors = ancestorPaths.every((ancestorPath) =>
+        items.some((item) => item.getItemData().path === ancestorPath),
+      );
+
+      if (hasAllAncestors) {
+        processedTargetPathRef.current = targetPath;
+      }
+    }
+  }, [items, targetPath, onExpanded]);
+};

+ 81 - 0
apps/app/src/features/page-tree/hooks/_inner/use-checkbox.ts

@@ -0,0 +1,81 @@
+import { useCallback, useMemo, useState } from 'react';
+
+import type { IPageForTreeItem } from '~/interfaces/page';
+
+type TreeInstance = {
+  getItemInstance: (
+    id: string,
+  ) => { getItemData: () => IPageForTreeItem } | undefined;
+};
+
+export type UseCheckboxOptions = {
+  enabled: boolean;
+  initialCheckedItems: string[];
+};
+
+export type UseCheckboxProperties = {
+  checkedItemIds: string[];
+  setCheckedItems: ((itemIds: string[]) => void) | undefined;
+  /**
+   * Helper to create a useEffect callback for notifying parent of checked items changes.
+   * Usage in component:
+   * ```
+   * useEffect(
+   *   checkboxProperties.createNotifyEffect(tree, onCheckedItemsChange),
+   *   [checkboxProperties.checkedItemIds, tree, onCheckedItemsChange]
+   * );
+   * ```
+   */
+  createNotifyEffect: (
+    tree: TreeInstance,
+    onCheckedItemsChange?: (checkedItems: IPageForTreeItem[]) => void,
+  ) => () => void;
+};
+
+/**
+ * Hook to manage checkbox state for headless-tree.
+ * Provides state tracking and setter callback for checked items.
+ */
+export const useCheckbox = (
+  options: UseCheckboxOptions,
+): UseCheckboxProperties => {
+  const { enabled, initialCheckedItems } = options;
+
+  // State to track checked items for re-rendering
+  const [checkedItemIds, setCheckedItemIds] =
+    useState<string[]>(initialCheckedItems);
+
+  // Callback to update checked items state (triggers re-render)
+  const handleSetCheckedItems = useCallback((itemIds: string[]) => {
+    setCheckedItemIds(itemIds);
+  }, []);
+
+  // Helper to create useEffect callback for notifying parent
+  const createNotifyEffect = useCallback(
+    (
+      tree: TreeInstance,
+      onCheckedItemsChange?: (checkedItems: IPageForTreeItem[]) => void,
+    ) => {
+      return () => {
+        if (!enabled || onCheckedItemsChange == null) {
+          return;
+        }
+
+        const checkedPages = checkedItemIds
+          .map((id) => tree.getItemInstance(id)?.getItemData())
+          .filter((page): page is IPageForTreeItem => page != null);
+        onCheckedItemsChange(checkedPages);
+      };
+    },
+    [enabled, checkedItemIds],
+  );
+
+  return useMemo(
+    () => ({
+      checkedItemIds,
+      setCheckedItems: enabled ? handleSetCheckedItems : undefined,
+      createNotifyEffect,
+    }),
+    [checkedItemIds, enabled, handleSetCheckedItems, createNotifyEffect],
+  );
+};

+ 346 - 0
apps/app/src/features/page-tree/hooks/_inner/use-data-loader.integration.spec.tsx

@@ -0,0 +1,346 @@
+/**
+ * Integration tests for use-data-loader with real Jotai atoms
+ *
+ * These tests verify that the dataLoader correctly reads creating state
+ * from Jotai atoms. This is critical because:
+ *
+ * 1. The dataLoader callbacks (getChildrenWithData) need to read the latest
+ *    creating state when they are invoked
+ * 2. The dataLoader reference must remain stable to prevent headless-tree
+ *    from refetching all data
+ * 3. Changes to the creating state must be reflected in subsequent
+ *    getChildrenWithData calls WITHOUT recreating the dataLoader
+ *
+ * These tests use real Jotai atoms instead of mocks to ensure the integration
+ * works correctly. This catches bugs like using getDefaultStore() incorrectly.
+ */
+import type { FC, PropsWithChildren } from 'react';
+import { act, renderHook } from '@testing-library/react';
+import { createStore, Provider } from 'jotai';
+
+import type { IPageForTreeItem } from '~/interfaces/page';
+
+import { CREATING_PAGE_VIRTUAL_ID } from '../../constants/_inner';
+import { invalidatePageTreeChildren } from '../../services';
+// Re-import the actions hook to use real implementation
+import {
+  resetCreatingFlagForTesting,
+  usePageTreeCreateActions,
+} from '../../states/_inner';
+import { useDataLoader } from './use-data-loader';
+
+/**
+ * Type helper to extract getChildrenWithData from TreeDataLoader
+ */
+type DataLoaderWithChildrenData = ReturnType<typeof useDataLoader> & {
+  getChildrenWithData: (
+    itemId: string,
+  ) => Promise<{ id: string; data: IPageForTreeItem }[]>;
+};
+
+/**
+ * Combined hook result type for testing both hooks together
+ */
+type CombinedHookResult = {
+  dataLoader: ReturnType<typeof useDataLoader>;
+  actions: ReturnType<typeof usePageTreeCreateActions>;
+};
+
+// Mock the apiv3Get function
+const mockApiv3Get = vi.fn();
+vi.mock('~/client/util/apiv3-client', () => ({
+  apiv3Get: (...args: unknown[]) => mockApiv3Get(...args),
+}));
+
+/**
+ * Create mock page data for testing
+ */
+const createMockPage = (
+  id: string,
+  path: string,
+  options: Partial<IPageForTreeItem> = {},
+): IPageForTreeItem => ({
+  _id: id,
+  path,
+  parent: null,
+  descendantCount: 0,
+  grant: 1,
+  isEmpty: false,
+  wip: false,
+  ...options,
+});
+
+/**
+ * Helper to get typed dataLoader with getChildrenWithData
+ */
+const getDataLoader = (result: {
+  current: CombinedHookResult;
+}): DataLoaderWithChildrenData => {
+  return result.current.dataLoader as DataLoaderWithChildrenData;
+};
+
+describe('use-data-loader integration with Jotai atoms', () => {
+  const ROOT_PAGE_ID = 'root-page-id';
+  const ALL_PAGES_COUNT = 100;
+
+  // Create a fresh Jotai store for each test
+  let store: ReturnType<typeof createStore>;
+
+  // Wrapper component that provides the Jotai store
+  const createWrapper = (): FC<PropsWithChildren> => {
+    const Wrapper: FC<PropsWithChildren> = ({ children }) => (
+      <Provider store={store}>{children}</Provider>
+    );
+    return Wrapper;
+  };
+
+  beforeEach(() => {
+    // Create fresh store for each test
+    store = createStore();
+    // Clear pending requests before each test
+    invalidatePageTreeChildren();
+    // Reset the creating flag for testing
+    resetCreatingFlagForTesting();
+    // Reset mock
+    mockApiv3Get.mockReset();
+  });
+
+  describe('placeholder node with real Jotai atoms', () => {
+    test('should prepend placeholder when creating state is set via actions hook', async () => {
+      const mockChildren = [
+        createMockPage('existing-child', '/parent/existing'),
+      ];
+      mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } });
+
+      const wrapper = createWrapper();
+
+      // Render both hooks together in the same component to share the store
+      // and ensure refs are updated when atom state changes
+      const { result, rerender } = renderHook(
+        (): CombinedHookResult => ({
+          dataLoader: useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+          actions: usePageTreeCreateActions(),
+        }),
+        { wrapper },
+      );
+
+      // First call - no placeholder (creating state is null)
+      const childrenBefore =
+        await getDataLoader(result).getChildrenWithData('parent-id');
+      expect(childrenBefore).toHaveLength(1);
+      expect(childrenBefore[0].id).toBe('existing-child');
+
+      // Set creating state using the actions hook
+      act(() => {
+        result.current.actions.startCreating('parent-id', '/parent');
+      });
+
+      // Rerender to update refs with the new atom state
+      rerender();
+
+      // Clear pending requests to force re-fetch
+      invalidatePageTreeChildren(['parent-id']);
+
+      // Second call - should have placeholder because atom state changed
+      const childrenAfter =
+        await getDataLoader(result).getChildrenWithData('parent-id');
+      expect(childrenAfter).toHaveLength(2);
+      expect(childrenAfter[0].id).toBe(CREATING_PAGE_VIRTUAL_ID);
+      expect(childrenAfter[0].data.parent).toBe('parent-id');
+      expect(childrenAfter[0].data.path).toBe('/parent/');
+      expect(childrenAfter[1].id).toBe('existing-child');
+    });
+
+    test('should remove placeholder when creating state is cancelled', async () => {
+      const mockChildren = [
+        createMockPage('existing-child', '/parent/existing'),
+      ];
+      mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } });
+
+      const wrapper = createWrapper();
+
+      // Render both hooks together in the same component
+      const { result, rerender } = renderHook(
+        (): CombinedHookResult => ({
+          dataLoader: useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+          actions: usePageTreeCreateActions(),
+        }),
+        { wrapper },
+      );
+
+      // Set creating state
+      act(() => {
+        result.current.actions.startCreating('parent-id', '/parent');
+      });
+
+      // Rerender to update refs
+      rerender();
+
+      // Clear pending requests and fetch - should have placeholder
+      invalidatePageTreeChildren(['parent-id']);
+      const childrenWithPlaceholder =
+        await getDataLoader(result).getChildrenWithData('parent-id');
+      expect(childrenWithPlaceholder).toHaveLength(2);
+      expect(childrenWithPlaceholder[0].id).toBe(CREATING_PAGE_VIRTUAL_ID);
+
+      // Cancel creating
+      act(() => {
+        result.current.actions.cancelCreating();
+      });
+
+      // Rerender to update refs
+      rerender();
+
+      // Clear pending requests and fetch - should NOT have placeholder
+      invalidatePageTreeChildren(['parent-id']);
+      const childrenAfterCancel =
+        await getDataLoader(result).getChildrenWithData('parent-id');
+      expect(childrenAfterCancel).toHaveLength(1);
+      expect(childrenAfterCancel[0].id).toBe('existing-child');
+    });
+
+    test('dataLoader reference should remain stable when creating state changes via atom', async () => {
+      const wrapper = createWrapper();
+
+      // Render both hooks together in the same component
+      const { result, rerender } = renderHook(
+        (): CombinedHookResult => ({
+          dataLoader: useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+          actions: usePageTreeCreateActions(),
+        }),
+        { wrapper },
+      );
+
+      const firstDataLoader = result.current.dataLoader;
+
+      // Change creating state via atom
+      act(() => {
+        result.current.actions.startCreating('some-parent', '/some-parent');
+      });
+
+      // Rerender to update refs
+      rerender();
+
+      const secondDataLoader = result.current.dataLoader;
+
+      // DataLoader reference should be STABLE (same reference)
+      // This is critical to prevent headless-tree from refetching all data
+      expect(firstDataLoader).toBe(secondDataLoader);
+    });
+
+    test('should correctly read state changes after rerender', async () => {
+      // This test verifies that the dataLoader callbacks can read the updated
+      // atom state after a React rerender. The refs in useDataLoader are updated
+      // during the render cycle, so a rerender is needed to see state changes.
+
+      const mockChildren = [
+        createMockPage('existing-child', '/parent/existing'),
+      ];
+      mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } });
+
+      const wrapper = createWrapper();
+
+      // Render both hooks together in the same component
+      const { result, rerender } = renderHook(
+        (): CombinedHookResult => ({
+          dataLoader: useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+          actions: usePageTreeCreateActions(),
+        }),
+        { wrapper },
+      );
+
+      // Get the dataLoader reference BEFORE state change
+      const dataLoader = getDataLoader(result);
+
+      // Set creating state
+      act(() => {
+        result.current.actions.startCreating('parent-id', '/parent');
+      });
+
+      // Rerender to update refs
+      rerender();
+
+      // Clear pending requests
+      invalidatePageTreeChildren(['parent-id']);
+
+      // Call getChildrenWithData using the SAME dataLoader reference
+      // This should still see the updated atom state
+      const children = await dataLoader.getChildrenWithData('parent-id');
+
+      expect(children).toHaveLength(2);
+      expect(children[0].id).toBe(CREATING_PAGE_VIRTUAL_ID);
+      expect(children[1].id).toBe('existing-child');
+    });
+
+    test('should work with multiple sequential state changes', async () => {
+      const mockChildren = [
+        createMockPage('existing-child', '/parent/existing'),
+      ];
+      mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } });
+
+      const wrapper = createWrapper();
+
+      // Render both hooks together in the same component
+      const { result, rerender } = renderHook(
+        (): CombinedHookResult => ({
+          dataLoader: useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+          actions: usePageTreeCreateActions(),
+        }),
+        { wrapper },
+      );
+
+      const dataLoader = getDataLoader(result);
+
+      // Sequence: start -> cancel -> start again -> cancel
+      // Each time, the dataLoader should correctly reflect the state
+
+      // 1. Start creating
+      act(() => {
+        result.current.actions.startCreating('parent-id', '/parent');
+      });
+      rerender();
+      invalidatePageTreeChildren(['parent-id']);
+      let children = await dataLoader.getChildrenWithData('parent-id');
+      expect(children).toHaveLength(2);
+      expect(children[0].id).toBe(CREATING_PAGE_VIRTUAL_ID);
+
+      // 2. Cancel
+      act(() => {
+        result.current.actions.cancelCreating();
+      });
+      rerender();
+      invalidatePageTreeChildren(['parent-id']);
+      children = await dataLoader.getChildrenWithData('parent-id');
+      expect(children).toHaveLength(1);
+      expect(children[0].id).toBe('existing-child');
+
+      // 3. Start again with different parent
+      act(() => {
+        result.current.actions.startCreating('other-parent', '/other');
+      });
+      rerender();
+      invalidatePageTreeChildren(['parent-id', 'other-parent']);
+
+      // Original parent should NOT have placeholder
+      children = await dataLoader.getChildrenWithData('parent-id');
+      expect(children).toHaveLength(1);
+
+      // New parent should have placeholder
+      mockApiv3Get.mockResolvedValueOnce({ data: { children: [] } });
+      children = await dataLoader.getChildrenWithData('other-parent');
+      expect(children).toHaveLength(1);
+      expect(children[0].id).toBe(CREATING_PAGE_VIRTUAL_ID);
+      expect(children[0].data.path).toBe('/other/');
+
+      // 4. Cancel again
+      act(() => {
+        result.current.actions.cancelCreating();
+      });
+      rerender();
+      invalidatePageTreeChildren(['other-parent']);
+      mockApiv3Get.mockResolvedValueOnce({ data: { children: [] } });
+      children = await dataLoader.getChildrenWithData('other-parent');
+      expect(children).toHaveLength(0);
+    });
+  });
+});

+ 490 - 0
apps/app/src/features/page-tree/hooks/_inner/use-data-loader.spec.tsx

@@ -0,0 +1,490 @@
+import { renderHook } from '@testing-library/react';
+
+import type { IPageForTreeItem } from '~/interfaces/page';
+
+import {
+  CREATING_PAGE_VIRTUAL_ID,
+  ROOT_PAGE_VIRTUAL_ID,
+} from '../../constants/_inner';
+import { invalidatePageTreeChildren } from '../../services';
+import { useDataLoader } from './use-data-loader';
+
+/**
+ * Type helper to extract getChildrenWithData from TreeDataLoader
+ * TreeDataLoader is a union type, and we're using the variant with getChildrenWithData
+ */
+type DataLoaderWithChildrenData = ReturnType<typeof useDataLoader> & {
+  getChildrenWithData: (
+    itemId: string,
+  ) => Promise<{ id: string; data: IPageForTreeItem }[]>;
+};
+
+// Mock the apiv3Get function
+const mockApiv3Get = vi.fn();
+vi.mock('~/client/util/apiv3-client', () => ({
+  apiv3Get: (...args: unknown[]) => mockApiv3Get(...args),
+}));
+
+// Mutable state for creating parent info
+let mockCreatingParentId: string | null = null;
+let mockCreatingParentPath: string | null = null;
+
+// Mock the page-tree-create state hooks
+// Note: use-data-loader.ts imports from '../../states/_inner' which re-exports from page-tree-create.ts
+vi.mock('../../states/_inner/page-tree-create', async () => {
+  const actual = await vi.importActual('../../states/_inner/page-tree-create');
+  return {
+    ...actual,
+    useCreatingParentId: () => mockCreatingParentId,
+    useCreatingParentPath: () => mockCreatingParentPath,
+  };
+});
+
+/**
+ * Create mock page data for testing
+ */
+const createMockPage = (
+  id: string,
+  path: string,
+  options: Partial<IPageForTreeItem> = {},
+): IPageForTreeItem => ({
+  _id: id,
+  path,
+  parent: null,
+  descendantCount: 0,
+  grant: 1,
+  isEmpty: false,
+  wip: false,
+  ...options,
+});
+
+/**
+ * Helper to get typed dataLoader with getChildrenWithData
+ */
+const getDataLoader = (result: {
+  current: ReturnType<typeof useDataLoader>;
+}): DataLoaderWithChildrenData => {
+  return result.current as DataLoaderWithChildrenData;
+};
+
+describe('use-data-loader', () => {
+  const ROOT_PAGE_ID = 'root-page-id';
+  const ALL_PAGES_COUNT = 100;
+
+  beforeEach(() => {
+    // Clear pending requests before each test
+    invalidatePageTreeChildren();
+    // Reset mock
+    mockApiv3Get.mockReset();
+    // Reset creating state
+    mockCreatingParentId = null;
+    mockCreatingParentPath = null;
+  });
+
+  describe('useDataLoader', () => {
+    describe('getItem', () => {
+      test('should return virtual root data without API call', async () => {
+        const { result } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        const item = await result.current.getItem(ROOT_PAGE_VIRTUAL_ID);
+
+        expect(item._id).toBe(ROOT_PAGE_ID);
+        expect(item.path).toBe('/');
+        expect(item.descendantCount).toBe(ALL_PAGES_COUNT);
+        expect(mockApiv3Get).not.toHaveBeenCalled();
+      });
+
+      test('should return placeholder data without API call for creating placeholder', async () => {
+        const { result } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        const item = await result.current.getItem(CREATING_PAGE_VIRTUAL_ID);
+
+        expect(item._id).toBe(CREATING_PAGE_VIRTUAL_ID);
+        expect(mockApiv3Get).not.toHaveBeenCalled();
+      });
+
+      test('should call API for regular item', async () => {
+        const mockPage = createMockPage('page-1', '/test');
+        mockApiv3Get.mockResolvedValue({ data: { item: mockPage } });
+
+        const { result } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        const item = await result.current.getItem('page-1');
+
+        expect(item).toEqual(mockPage);
+        expect(mockApiv3Get).toHaveBeenCalledTimes(1);
+        expect(mockApiv3Get).toHaveBeenCalledWith('/page-listing/item', {
+          id: 'page-1',
+        });
+      });
+    });
+
+    describe('getChildrenWithData', () => {
+      test('should return root page without API call for virtual root', async () => {
+        const { result } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        const children =
+          await getDataLoader(result).getChildrenWithData(ROOT_PAGE_VIRTUAL_ID);
+
+        expect(children).toHaveLength(1);
+        expect(children[0].id).toBe(ROOT_PAGE_ID);
+        expect(children[0].data.path).toBe('/');
+        expect(mockApiv3Get).not.toHaveBeenCalled();
+      });
+
+      test('should return empty array without API call for placeholder node', async () => {
+        const { result } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        const children = await getDataLoader(result).getChildrenWithData(
+          CREATING_PAGE_VIRTUAL_ID,
+        );
+
+        expect(children).toHaveLength(0);
+        expect(mockApiv3Get).not.toHaveBeenCalled();
+      });
+
+      test('should call API for regular item', async () => {
+        const mockChildren = [
+          createMockPage('child-1', '/parent/child-1'),
+          createMockPage('child-2', '/parent/child-2'),
+        ];
+        mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } });
+
+        const { result } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        const children =
+          await getDataLoader(result).getChildrenWithData('parent-id');
+
+        expect(children).toHaveLength(2);
+        expect(children[0].id).toBe('child-1');
+        expect(children[1].id).toBe('child-2');
+        expect(mockApiv3Get).toHaveBeenCalledTimes(1);
+        expect(mockApiv3Get).toHaveBeenCalledWith('/page-listing/children', {
+          id: 'parent-id',
+        });
+      });
+    });
+
+    describe('concurrent request deduplication', () => {
+      test('should deduplicate concurrent requests for the same itemId', async () => {
+        const mockChildren = [createMockPage('child-1', '/parent/child-1')];
+
+        // Create a promise that we can resolve manually
+        let resolvePromise: ((value: unknown) => void) | undefined;
+        const delayedPromise = new Promise((resolve) => {
+          resolvePromise = resolve;
+        });
+
+        mockApiv3Get.mockReturnValue(delayedPromise);
+
+        const { result } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        // Start multiple concurrent requests
+        const promise1 = getDataLoader(result).getChildrenWithData('parent-id');
+        const promise2 = getDataLoader(result).getChildrenWithData('parent-id');
+        const promise3 = getDataLoader(result).getChildrenWithData('parent-id');
+
+        // Resolve the API call
+        resolvePromise?.({ data: { children: mockChildren } });
+
+        // Wait for all promises
+        const [result1, result2, result3] = await Promise.all([
+          promise1,
+          promise2,
+          promise3,
+        ]);
+
+        // All should return the same data
+        expect(result1).toEqual(result2);
+        expect(result2).toEqual(result3);
+
+        // API should only be called once
+        expect(mockApiv3Get).toHaveBeenCalledTimes(1);
+      });
+
+      test('should call API once per unique itemId for concurrent requests', async () => {
+        const mockChildren1 = [createMockPage('child-1', '/parent1/child-1')];
+        const mockChildren2 = [createMockPage('child-2', '/parent2/child-2')];
+
+        let resolvePromise1: ((value: unknown) => void) | undefined;
+        let resolvePromise2: ((value: unknown) => void) | undefined;
+
+        mockApiv3Get
+          .mockReturnValueOnce(
+            new Promise((resolve) => {
+              resolvePromise1 = resolve;
+            }),
+          )
+          .mockReturnValueOnce(
+            new Promise((resolve) => {
+              resolvePromise2 = resolve;
+            }),
+          );
+
+        const { result } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        // Start concurrent requests for different IDs
+        const promise1 = getDataLoader(result).getChildrenWithData('parent-1');
+        const promise2 = getDataLoader(result).getChildrenWithData('parent-2');
+
+        // Resolve both
+        resolvePromise1?.({ data: { children: mockChildren1 } });
+        resolvePromise2?.({ data: { children: mockChildren2 } });
+
+        await Promise.all([promise1, promise2]);
+
+        // API should be called once per unique ID
+        expect(mockApiv3Get).toHaveBeenCalledTimes(2);
+      });
+    });
+
+    describe('dataLoader reference stability', () => {
+      test('should return stable dataLoader reference when props do not change', () => {
+        const { result, rerender } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        const firstDataLoader = result.current;
+
+        // Rerender with same props
+        rerender();
+
+        const secondDataLoader = result.current;
+
+        // Should be the same reference
+        expect(firstDataLoader).toBe(secondDataLoader);
+      });
+
+      test('should return new dataLoader reference when rootPageId changes', () => {
+        const { result, rerender } = renderHook(
+          ({ rootPageId }) => useDataLoader(rootPageId, ALL_PAGES_COUNT),
+          { initialProps: { rootPageId: ROOT_PAGE_ID } },
+        );
+
+        const firstDataLoader = result.current;
+
+        // Rerender with different rootPageId
+        rerender({ rootPageId: 'new-root-page-id' });
+
+        const secondDataLoader = result.current;
+
+        // Should be a new reference
+        expect(firstDataLoader).not.toBe(secondDataLoader);
+      });
+
+      test('should return new dataLoader reference when allPagesCount changes', () => {
+        const { result, rerender } = renderHook(
+          ({ allPagesCount }) => useDataLoader(ROOT_PAGE_ID, allPagesCount),
+          { initialProps: { allPagesCount: ALL_PAGES_COUNT } },
+        );
+
+        const firstDataLoader = result.current;
+
+        // Rerender with different allPagesCount
+        rerender({ allPagesCount: 200 });
+
+        const secondDataLoader = result.current;
+
+        // Should be a new reference
+        expect(firstDataLoader).not.toBe(secondDataLoader);
+      });
+    });
+
+    describe('error handling', () => {
+      test('should allow retry after API error', async () => {
+        const error = new Error('API Error');
+        mockApiv3Get.mockRejectedValueOnce(error).mockResolvedValueOnce({
+          data: { children: [createMockPage('child-1', '/parent/child-1')] },
+        });
+
+        const { result } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        // First call - should fail and remove pending entry
+        try {
+          await getDataLoader(result).getChildrenWithData('parent-id');
+        } catch {
+          // Expected to fail
+        }
+
+        // Second call - should retry since pending entry was removed
+        const children =
+          await getDataLoader(result).getChildrenWithData('parent-id');
+
+        expect(children).toHaveLength(1);
+        expect(mockApiv3Get).toHaveBeenCalledTimes(2);
+      });
+    });
+  });
+
+  describe('placeholder node for page creation', () => {
+    test('should prepend placeholder node when parent is in creating mode', async () => {
+      const mockChildren = [
+        createMockPage('existing-child', '/parent/existing'),
+      ];
+      mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } });
+
+      // Set creating state BEFORE rendering the hook
+      mockCreatingParentId = 'parent-id';
+      mockCreatingParentPath = '/parent';
+
+      const { result } = renderHook(() =>
+        useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+      );
+
+      const children =
+        await getDataLoader(result).getChildrenWithData('parent-id');
+
+      // Should have placeholder + existing children
+      expect(children).toHaveLength(2);
+      // Placeholder should be first
+      expect(children[0].id).toBe(CREATING_PAGE_VIRTUAL_ID);
+      expect(children[0].data._id).toBe(CREATING_PAGE_VIRTUAL_ID);
+      expect(children[0].data.parent).toBe('parent-id');
+      expect(children[0].data.path).toBe('/parent/');
+      // Existing child should be second
+      expect(children[1].id).toBe('existing-child');
+    });
+
+    test('should not add placeholder when parent is not in creating mode', async () => {
+      const mockChildren = [
+        createMockPage('existing-child', '/parent/existing'),
+      ];
+      mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } });
+
+      // Creating state is null (not creating)
+      mockCreatingParentId = null;
+      mockCreatingParentPath = null;
+
+      const { result } = renderHook(() =>
+        useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+      );
+
+      const children =
+        await getDataLoader(result).getChildrenWithData('parent-id');
+
+      // Should only have existing children, no placeholder
+      expect(children).toHaveLength(1);
+      expect(children[0].id).toBe('existing-child');
+    });
+
+    test('should not add placeholder to different parent', async () => {
+      const mockChildren = [
+        createMockPage('existing-child', '/other/existing'),
+      ];
+      mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } });
+
+      // Creating under 'parent-id', but fetching children of 'other-id'
+      mockCreatingParentId = 'parent-id';
+      mockCreatingParentPath = '/parent';
+
+      const { result } = renderHook(() =>
+        useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+      );
+
+      const children =
+        await getDataLoader(result).getChildrenWithData('other-id');
+
+      // Should only have existing children, no placeholder
+      expect(children).toHaveLength(1);
+      expect(children[0].id).toBe('existing-child');
+    });
+
+    test('should add placeholder to empty parent (no existing children)', async () => {
+      // Parent has no existing children
+      mockApiv3Get.mockResolvedValue({ data: { children: [] } });
+
+      // Set creating state
+      mockCreatingParentId = 'empty-parent-id';
+      mockCreatingParentPath = '/empty-parent';
+
+      const { result } = renderHook(() =>
+        useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+      );
+
+      const children =
+        await getDataLoader(result).getChildrenWithData('empty-parent-id');
+
+      // Should have only the placeholder
+      expect(children).toHaveLength(1);
+      expect(children[0].id).toBe(CREATING_PAGE_VIRTUAL_ID);
+      expect(children[0].data.parent).toBe('empty-parent-id');
+      expect(children[0].data.path).toBe('/empty-parent/');
+    });
+
+    test('should read creating state via refs when called after state change', async () => {
+      const mockChildren = [
+        createMockPage('existing-child', '/parent/existing'),
+      ];
+      mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } });
+
+      // Render hook WITHOUT creating state
+      const { result, rerender } = renderHook(() =>
+        useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+      );
+
+      // First call - no placeholder
+      const childrenBefore =
+        await getDataLoader(result).getChildrenWithData('parent-id');
+      expect(childrenBefore).toHaveLength(1);
+      expect(childrenBefore[0].id).toBe('existing-child');
+
+      // Now set creating state
+      mockCreatingParentId = 'parent-id';
+      mockCreatingParentPath = '/parent';
+
+      // Rerender to update refs
+      rerender();
+
+      // Second call - should have placeholder because refs are updated
+      // Note: Since sequential caching is now handled by headless-tree,
+      // we need to clear pending requests to get fresh data
+      invalidatePageTreeChildren();
+
+      const childrenAfter =
+        await getDataLoader(result).getChildrenWithData('parent-id');
+      expect(childrenAfter).toHaveLength(2);
+      expect(childrenAfter[0].id).toBe(CREATING_PAGE_VIRTUAL_ID);
+      expect(childrenAfter[1].id).toBe('existing-child');
+    });
+
+    test('dataLoader reference should remain stable when creating state changes', () => {
+      // Render hook WITHOUT creating state
+      const { result, rerender } = renderHook(() =>
+        useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+      );
+
+      const firstDataLoader = result.current;
+
+      // Change creating state
+      mockCreatingParentId = 'some-parent';
+      mockCreatingParentPath = '/some-parent';
+
+      // Rerender
+      rerender();
+
+      const secondDataLoader = result.current;
+
+      // DataLoader reference should be STABLE (same reference)
+      // This is critical to prevent headless-tree from refetching all data
+      expect(firstDataLoader).toBe(secondDataLoader);
+    });
+  });
+});

+ 120 - 0
apps/app/src/features/page-tree/hooks/_inner/use-data-loader.ts

@@ -0,0 +1,120 @@
+import { useMemo, useRef } from 'react';
+import type { TreeDataLoader } from '@headless-tree/core';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+import type { IPageForTreeItem } from '~/interfaces/page';
+
+import {
+  CREATING_PAGE_VIRTUAL_ID,
+  ROOT_PAGE_VIRTUAL_ID,
+} from '../../constants/_inner';
+import { type ChildrenData, fetchAndCacheChildren } from '../../services';
+import {
+  createPlaceholderPageData,
+  useCreatingParentId,
+  useCreatingParentPath,
+} from '../../states/_inner';
+
+function constructRootPageForVirtualRoot(
+  rootPageId: string,
+  allPagesCount: number,
+): IPageForTreeItem {
+  return {
+    _id: rootPageId,
+    path: '/',
+    parent: null,
+    descendantCount: allPagesCount,
+    grant: 1,
+    isEmpty: false,
+    wip: false,
+  };
+}
+
+export const useDataLoader = (
+  rootPageId: string,
+  allPagesCount: number,
+): TreeDataLoader<IPageForTreeItem> => {
+  const creatingParentId = useCreatingParentId();
+  const creatingParentPath = useCreatingParentPath();
+
+  // Use refs to avoid recreating dataLoader callbacks when creating state changes
+  // The creating state is accessed via refs so that:
+  // 1. The dataLoader reference stays stable (prevents headless-tree from refetching all data)
+  // 2. The actual creating state is still read at execution time (when invalidateChildrenIds is called)
+  const creatingParentIdRef = useRef(creatingParentId);
+  const creatingParentPathRef = useRef(creatingParentPath);
+  creatingParentIdRef.current = creatingParentId;
+  creatingParentPathRef.current = creatingParentPath;
+
+  // Memoize the entire dataLoader object to ensure reference stability
+  // Only recreate when rootPageId or allPagesCount changes (which are truly needed for the API calls)
+  // Note: Creating state is read from refs inside callbacks to avoid triggering dataLoader recreation
+  const dataLoader = useMemo<TreeDataLoader<IPageForTreeItem>>(() => {
+    const getItem = async (itemId: string): Promise<IPageForTreeItem> => {
+      // Virtual root (should rarely be called since it's provided by getChildrenWithData)
+      if (itemId === ROOT_PAGE_VIRTUAL_ID) {
+        return constructRootPageForVirtualRoot(rootPageId, allPagesCount);
+      }
+
+      // Creating placeholder node - return placeholder data
+      if (itemId === CREATING_PAGE_VIRTUAL_ID) {
+        // This shouldn't normally be called, but return empty placeholder if it is
+        return createPlaceholderPageData('', '/');
+      }
+
+      // For all pages (including root), use /page-listing/item endpoint
+      // Note: This should rarely be called thanks to getChildrenWithData caching
+      const response = await apiv3Get<{ item: IPageForTreeItem }>(
+        '/page-listing/item',
+        { id: itemId },
+      );
+      return response.data.item;
+    };
+
+    const getChildrenWithData = async (
+      itemId: string,
+    ): Promise<ChildrenData> => {
+      // Virtual root returns root page as its only child
+      // Use actual MongoDB _id as tree item ID to avoid duplicate API calls
+      if (itemId === ROOT_PAGE_VIRTUAL_ID) {
+        return [
+          {
+            id: rootPageId,
+            data: constructRootPageForVirtualRoot(rootPageId, allPagesCount),
+          },
+        ];
+      }
+
+      // Placeholder node has no children
+      if (itemId === CREATING_PAGE_VIRTUAL_ID) {
+        return [];
+      }
+
+      const children = await fetchAndCacheChildren(itemId);
+
+      // If this parent is in "creating" mode, prepend placeholder node
+      // Read from refs to get current value without triggering dataLoader recreation
+      const currentCreatingParentId = creatingParentIdRef.current;
+      const currentCreatingParentPath = creatingParentPathRef.current;
+      if (
+        currentCreatingParentId === itemId &&
+        currentCreatingParentPath != null
+      ) {
+        const placeholderData = createPlaceholderPageData(
+          itemId,
+          currentCreatingParentPath,
+        );
+        return [
+          { id: CREATING_PAGE_VIRTUAL_ID, data: placeholderData },
+          ...children,
+        ];
+      }
+
+      return children;
+    };
+
+    return { getItem, getChildrenWithData };
+  }, [allPagesCount, rootPageId]);
+
+  return dataLoader;
+};

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff