diff --git a/.github/workflows/ios-build.yml b/.github/workflows/ios-build.yml index bc396269..6144c5fa 100644 --- a/.github/workflows/ios-build.yml +++ b/.github/workflows/ios-build.yml @@ -69,6 +69,7 @@ jobs: DEST="$( xcodebuild -project EdgeEver.xcodeproj -scheme EdgeEver -showdestinations 2>/dev/null \ | sed -n 's/.*name:\(iPhone[^,}]*\).*/\1/p' \ + | sed 's/[[:space:]]*$//' \ | head -1 )" if [[ -z "$DEST" ]]; then diff --git a/apps/ios/EdgeEver/Data/Preferences/PreferencesStore.swift b/apps/ios/EdgeEver/Data/Preferences/PreferencesStore.swift index 15b742f6..44ae2533 100644 --- a/apps/ios/EdgeEver/Data/Preferences/PreferencesStore.swift +++ b/apps/ios/EdgeEver/Data/Preferences/PreferencesStore.swift @@ -43,6 +43,11 @@ final class PreferencesStore { didSet { defaults.set(useCompression, forKey: Keys.compression) } } + /// Parent notebooks keep listing their sub-notebooks' notes unless turned off. + var showDescendantNotes: Bool { + didSet { defaults.set(showDescendantNotes, forKey: Keys.showDescendantNotes) } + } + /// system | light | dark var theme: String { didSet { defaults.set(theme, forKey: Keys.theme) } @@ -60,6 +65,7 @@ final class PreferencesStore { self.defaults = defaults self.localeCode = defaults.string(forKey: Keys.locale) ?? "system" self.useCompression = defaults.object(forKey: Keys.compression) as? Bool ?? true + self.showDescendantNotes = defaults.object(forKey: Keys.showDescendantNotes) as? Bool ?? true self.theme = defaults.string(forKey: Keys.theme) ?? "system" let densityRaw = defaults.string(forKey: Keys.density) ?? ListDensity.preview.rawValue // Migrate legacy "comfortable" → preview @@ -147,6 +153,7 @@ final class PreferencesStore { private enum Keys { static let locale = "edgeever.ios.locale" static let compression = "edgeever.ios.imageCompression" + static let showDescendantNotes = "edgeever.ios.showDescendantNotes" static let theme = "edgeever.ios.theme" static let density = "edgeever.ios.listDensity" static let aiAssistantLastAction = "edgeever.aiAssistant.lastAction" diff --git a/apps/ios/EdgeEver/DesignSystem/MobileUI.swift b/apps/ios/EdgeEver/DesignSystem/MobileUI.swift index bb218c8c..f3625cf7 100644 --- a/apps/ios/EdgeEver/DesignSystem/MobileUI.swift +++ b/apps/ios/EdgeEver/DesignSystem/MobileUI.swift @@ -121,6 +121,20 @@ enum NotebookHierarchy { return descendantIds } + /// Port of `getNotebookScopeIds`: the notebooks a notebook view lists. + static func scopeIds(notebooks: [Notebook], targetNotebookId: String, includeDescendants: Bool) -> [String] { + includeDescendants ? descendantIds(notebooks: notebooks, targetNotebookId: targetNotebookId) : [targetNotebookId] + } + + /// Port of `getNotebookDescendantMemoCount`: notes in sub-notebooks, not the notebook itself. + /// Expects direct per-notebook counts, as `LocalMirrorRepository.listNotebooks` returns them. + static func descendantMemoCount(notebooks: [Notebook], targetNotebookId: String) -> Int { + let memoCountById = Dictionary(notebooks.map { ($0.id, $0.memoCount) }, uniquingKeysWith: { first, _ in first }) + return descendantIds(notebooks: notebooks, targetNotebookId: targetNotebookId) + .filter { $0 != targetNotebookId } + .reduce(0) { $0 + (memoCountById[$1] ?? 0) } + } + static func treeItems(from notebooks: [Notebook]) -> [NotebookTreeItem] { let byParent = Dictionary(grouping: notebooks) { $0.parentId ?? "" } var items: [NotebookTreeItem] = [] diff --git a/apps/ios/EdgeEver/Features/Workspace/NotesListView.swift b/apps/ios/EdgeEver/Features/Workspace/NotesListView.swift index fb7d6b57..7a451d8a 100644 --- a/apps/ios/EdgeEver/Features/Workspace/NotesListView.swift +++ b/apps/ios/EdgeEver/Features/Workspace/NotesListView.swift @@ -394,7 +394,21 @@ struct NotesListView: View { } } + /// With sub-notebooks hidden, an empty parent must not look like its notes are gone. + private var hiddenDescendantMemoCount: Int { + guard !env.preferences.showDescendantNotes, + store.searchText.trimmingCharacters(in: .whitespaces).isEmpty, + store.filter == .all, + store.selectedTag == nil, + let notebookId = store.selectedNotebookId + else { return 0 } + return NotebookHierarchy.descendantMemoCount(notebooks: store.notebooks, targetNotebookId: notebookId) + } + private var emptyTitle: String { + if hiddenDescendantMemoCount > 0 { + return env.preferences.t("本级暂无笔记", en: "No notes directly in this notebook", ja: "このノートブック直下にノートはありません") + } if !store.searchText.trimmingCharacters(in: .whitespaces).isEmpty { return env.preferences.t("没有找到匹配笔记", en: "No matching notes") } @@ -405,6 +419,14 @@ struct NotesListView: View { } private var emptyDescription: String { + let hiddenCount = hiddenDescendantMemoCount + if hiddenCount > 0 { + return env.preferences.t( + "子笔记本中还有 \(hiddenCount) 条笔记。可以打开子笔记本查看,或在设置中开启“显示子笔记本中的笔记”。", + en: "Its sub-notebooks still contain \(hiddenCount) notes. Open a sub-notebook to see them, or turn on \"Show notes from sub-notebooks\" in Settings.", + ja: "サブノートブックにはまだ \(hiddenCount) 件のノートがあります。サブノートブックを開くか、設定で「サブノートブックのノートを表示」をオンにしてください。" + ) + } if !store.searchText.trimmingCharacters(in: .whitespaces).isEmpty { return env.preferences.t("换个关键词再试", en: "Try another keyword") } diff --git a/apps/ios/EdgeEver/Features/Workspace/SettingsView.swift b/apps/ios/EdgeEver/Features/Workspace/SettingsView.swift index 3b35d58b..477d5802 100644 --- a/apps/ios/EdgeEver/Features/Workspace/SettingsView.swift +++ b/apps/ios/EdgeEver/Features/Workspace/SettingsView.swift @@ -338,6 +338,26 @@ struct SettingsView: View { .tint(AppTheme.accent) .frame(maxWidth: .infinity, alignment: .leading) } + + preferenceBlock( + title: env.preferences.t("显示子笔记本中的笔记", en: "Show notes from sub-notebooks", ja: "サブノートブックのノートを表示"), + description: env.preferences.t( + "开启后,打开父笔记本会同时列出所有子笔记本中的笔记;关闭后只列出直接放在该笔记本中的笔记。", + en: "When on, opening a parent notebook also lists notes from all of its sub-notebooks. When off, only notes stored directly in that notebook are listed.", + ja: "オンにすると、親ノートブックを開いたときにすべてのサブノートブックのノートも一覧表示します。オフにすると、そのノートブックに直接保存されたノートのみを表示します。" + ), + showTopBorder: true + ) { + Toggle("", isOn: Bindable(env.preferences).showDescendantNotes) + .labelsHidden() + .tint(AppTheme.accent) + .accessibilityLabel(env.preferences.t( + "是否在父笔记本中显示子笔记本中的笔记", + en: "Show notes from sub-notebooks in parent notebooks", + ja: "親ノートブックにサブノートブックのノートを表示する" + )) + .frame(maxWidth: .infinity, alignment: .leading) + } // List density lives in list-options sheet (Android NotesActionsModal), not here. } } diff --git a/apps/ios/EdgeEver/Features/Workspace/WorkspaceStore.swift b/apps/ios/EdgeEver/Features/Workspace/WorkspaceStore.swift index dd61cb36..3edd0f50 100644 --- a/apps/ios/EdgeEver/Features/Workspace/WorkspaceStore.swift +++ b/apps/ios/EdgeEver/Features/Workspace/WorkspaceStore.swift @@ -64,7 +64,11 @@ final class WorkspaceStore { let offset = resetOffset ? 0 : (nextOffset ?? memos.count) let notebookIds: [String]? = { guard selectedTag == nil, let selectedNotebookId else { return nil } - return NotebookHierarchy.descendantIds(notebooks: notebooks, targetNotebookId: selectedNotebookId) + return NotebookHierarchy.scopeIds( + notebooks: notebooks, + targetNotebookId: selectedNotebookId, + includeDescendants: env.preferences.showDescendantNotes + ) }() let result = try env.mirror.listMemos( scope: scope, diff --git a/apps/ios/EdgeEver/Features/Workspace/WorkspaceView.swift b/apps/ios/EdgeEver/Features/Workspace/WorkspaceView.swift index 75f18924..f4de5772 100644 --- a/apps/ios/EdgeEver/Features/Workspace/WorkspaceView.swift +++ b/apps/ios/EdgeEver/Features/Workspace/WorkspaceView.swift @@ -210,6 +210,9 @@ struct WorkspaceView: View { .onChange(of: env.bootstrapProgress?.totalCount) { _, _ in store.reload(env: env) } + .onChange(of: env.preferences.showDescendantNotes) { _, _ in + store.reload(env: env) + } .refreshable { await env.runSyncCycle(force: true) store.reload(env: env) diff --git a/apps/ios/Tests/EdgeEverTests/MobileUIParityTests.swift b/apps/ios/Tests/EdgeEverTests/MobileUIParityTests.swift index 16ead062..4c438721 100644 --- a/apps/ios/Tests/EdgeEverTests/MobileUIParityTests.swift +++ b/apps/ios/Tests/EdgeEverTests/MobileUIParityTests.swift @@ -5,7 +5,9 @@ import XCTest /// Exercises shipped parity helpers (same semantics as Android `@edgeever/shared/mobile-ui` + notebooks). final class MobileUIParityTests: XCTestCase { func testUnmatchedSystemLanguageUsesEnglishUI() { - XCTAssertTrue(AppUILocale.usesEnglish(preferenceCode: "system", systemLanguageCode: "ja")) + // Japanese is a first-class UI language, so a Japanese system no longer falls back to English. + XCTAssertFalse(AppUILocale.usesEnglish(preferenceCode: "system", systemLanguageCode: "ja")) + XCTAssertEqual(AppUILocale.language(preferenceCode: "system", systemLanguageCode: "ja"), .japanese) XCTAssertTrue(AppUILocale.usesEnglish(preferenceCode: "system", systemLanguageCode: "fr")) XCTAssertTrue(AppUILocale.usesEnglish(preferenceCode: "system", systemLanguageCode: "en")) XCTAssertTrue(AppUILocale.usesEnglish(preferenceCode: "system", systemLanguageCode: nil)) @@ -117,6 +119,33 @@ final class MobileUIParityTests: XCTestCase { XCTAssertEqual(Set(ids), Set(["root", "child", "grand"])) } + func testNotebookScopeFollowsDescendantPreference() { + let notebooks = [ + makeNotebook(id: "root", parent: nil, name: "Root", order: 0), + makeNotebook(id: "child", parent: "root", name: "Child", order: 0), + makeNotebook(id: "grand", parent: "child", name: "Grand", order: 0), + ] + XCTAssertEqual( + Set(NotebookHierarchy.scopeIds(notebooks: notebooks, targetNotebookId: "root", includeDescendants: true)), + Set(["root", "child", "grand"]) + ) + XCTAssertEqual( + NotebookHierarchy.scopeIds(notebooks: notebooks, targetNotebookId: "root", includeDescendants: false), + ["root"] + ) + } + + func testNotebookDescendantMemoCountExcludesTheNotebookItself() { + let notebooks = [ + makeNotebook(id: "root", parent: nil, name: "Root", order: 0, memoCount: 2), + makeNotebook(id: "child", parent: "root", name: "Child", order: 0, memoCount: 3), + makeNotebook(id: "grand", parent: "child", name: "Grand", order: 0, memoCount: 1), + makeNotebook(id: "other", parent: nil, name: "Other", order: 1, memoCount: 9), + ] + XCTAssertEqual(NotebookHierarchy.descendantMemoCount(notebooks: notebooks, targetNotebookId: "root"), 4) + XCTAssertEqual(NotebookHierarchy.descendantMemoCount(notebooks: notebooks, targetNotebookId: "grand"), 0) + } + func testFilterCollapsedHidesDescendants() { let notebooks = [ makeNotebook(id: "root", parent: nil, name: "Root", order: 0), @@ -341,7 +370,9 @@ final class MobileUIParityTests: XCTestCase { nav.onFinish = { exp.fulfill() } webView.loadHTMLString(html, baseURL: URL(string: "https://edgeever.local/")) } - await fulfillment(of: [exp], timeout: 5) + // The first WKWebView in the test process cold-starts WebKit's helper processes, + // which takes well over 5s on CI simulators. + await fulfillment(of: [exp], timeout: 30) // Poll naturalWidth via JS (give the scheme handler a moment if needed). var width = 0 for _ in 0 ..< 20 { @@ -389,7 +420,7 @@ final class MobileUIParityTests: XCTestCase { XCTAssertEqual(tagged.memos.map(\.id), ["p2"]) } - private func makeNotebook(id: String, parent: String?, name: String, order: Int) -> Notebook { + private func makeNotebook(id: String, parent: String?, name: String, order: Int, memoCount: Int = 0) -> Notebook { Notebook( id: id, parentId: parent, @@ -398,7 +429,7 @@ final class MobileUIParityTests: XCTestCase { icon: nil, color: nil, sortOrder: order, - memoCount: 0, + memoCount: memoCount, lastMemoUpdatedAt: nil, createdAt: EdgeEverDate.nowString(), updatedAt: EdgeEverDate.nowString() diff --git a/apps/mobile/src/lib/mobile-locale.test.mjs b/apps/mobile/src/lib/mobile-locale.test.mjs index c412e9d3..f8deea9e 100644 --- a/apps/mobile/src/lib/mobile-locale.test.mjs +++ b/apps/mobile/src/lib/mobile-locale.test.mjs @@ -18,6 +18,23 @@ describe("mobile locale translation", () => { expect(translateMobileText("筛选:Pinned · 3 条", "en-US")).toBe("Filter: Pinned · 3 notes"); }); + test("uses shared translations for the descendant preference", () => { + expect(translateMobileText("显示子笔记本中的笔记", "en-US")).toBe("Show notes from sub-notebooks"); + expect(translateMobileText("显示子笔记本中的笔记", "ja")).toBe("サブノートブックのノートを表示"); + expect(translateMobileText("是否在父笔记本中显示子笔记本中的笔记", "en-US")).toBe("Show notes from sub-notebooks in parent notebooks"); + }); + + test("translates the hidden sub-notebook notes hint", () => { + const hint = "子笔记本中还有 5 条笔记。可以打开子笔记本查看,或在设置中开启“显示子笔记本中的笔记”。"; + expect(translateMobileText(hint, "en-US")) + .toBe("Its sub-notebooks still contain 5 notes. Open a sub-notebook to see them, or turn on \"Show notes from sub-notebooks\" in Settings."); + expect(translateMobileText(hint, "ja")) + .toBe("サブノートブックにはまだ 5 件のノートがあります。サブノートブックを開くか、設定で「サブノートブックのノートを表示」をオンにしてください。"); + expect(translateMobileText("本级暂无笔记", "en-US")).toBe("No notes directly in this notebook"); + expect(translateMobileText("无法保存“显示子笔记本中的笔记”设置,请稍后重试", "en-US")) + .toBe("Could not save the \"Show notes from sub-notebooks\" setting. Please try again."); + }); + test("prefers specific shared templates over broader mobile-only templates", () => { expect(translateMobileText("永久删除 3 条笔记", "en-US")).toBe("Delete 3 notes permanently"); expect(translateMobileText("当前:Inbox,3 条笔记", "en-US")).toBe("Current: Inbox, 3 notes"); diff --git a/apps/mobile/src/lib/mobile-locale.tsx b/apps/mobile/src/lib/mobile-locale.tsx index dac15982..0632f78a 100644 --- a/apps/mobile/src/lib/mobile-locale.tsx +++ b/apps/mobile/src/lib/mobile-locale.tsx @@ -19,6 +19,9 @@ type MobileLocaleContextValue = { type TranslationPair = { source: string; target: string; pattern?: RegExp; placeholders?: string[] }; const mobileOnlyTranslations = new Map([ + ["本级暂无笔记", "No notes directly in this notebook"], + ["无法保存“显示子笔记本中的笔记”设置,请稍后重试", "Could not save the \"Show notes from sub-notebooks\" setting. Please try again."], + ["子笔记本中还有 {{count}} 条笔记。可以打开子笔记本查看,或在设置中开启“显示子笔记本中的笔记”。", "Its sub-notebooks still contain {{count}} notes. Open a sub-notebook to see them, or turn on \"Show notes from sub-notebooks\" in Settings."], ["返回", "Back"], ["关闭对话框", "Close dialog"], ["切换到深色模式", "Switch to dark mode"], @@ -205,6 +208,9 @@ const mobileOnlyTranslations = new Map([ ]); const mobileOnlyJapanese = new Map([ + ["本级暂无笔记", "このノートブック直下にノートはありません"], + ["无法保存“显示子笔记本中的笔记”设置,请稍后重试", "「サブノートブックのノートを表示」の設定を保存できませんでした。しばらくしてから再試行してください。"], + ["子笔记本中还有 {{count}} 条笔记。可以打开子笔记本查看,或在设置中开启“显示子笔记本中的笔记”。", "サブノートブックにはまだ {{count}} 件のノートがあります。サブノートブックを開くか、設定で「サブノートブックのノートを表示」をオンにしてください。"], ["返回", "戻る"], ["关闭对话框", "ダイアログを閉じる"], ["切换到深色模式", "ダークモードに切り替え"], diff --git a/apps/mobile/src/lib/preferences.test.mjs b/apps/mobile/src/lib/preferences.test.mjs new file mode 100644 index 00000000..4280090f --- /dev/null +++ b/apps/mobile/src/lib/preferences.test.mjs @@ -0,0 +1,46 @@ +import { expect, test } from "bun:test"; + +// Keep the native module mock out of the other mobile tests' module cache. +test("descendant preference defaults safely when native storage fails", () => { + const result = Bun.spawnSync([process.execPath, "--eval", ` + import { mock } from "bun:test"; + import { strict as assert } from "node:assert"; + let stored = null; + let rejects = false; + mock.module("@react-native-async-storage/async-storage", () => ({ default: { + getItem: async () => { if (rejects) throw new Error("storage unavailable"); return stored; }, + setItem: async (_key, value) => { stored = value; }, + } })); + const { readMobileShowDescendantNotes, writeMobileShowDescendantNotes } = + await import(${JSON.stringify(new URL("./preferences.ts", import.meta.url).href)}); + assert.equal(await readMobileShowDescendantNotes(), true); + await writeMobileShowDescendantNotes(false); + assert.equal(await readMobileShowDescendantNotes(), false); + await writeMobileShowDescendantNotes(true); + assert.equal(await readMobileShowDescendantNotes(), true); + rejects = true; + assert.equal(await readMobileShowDescendantNotes(), true); + `], { stdout: "pipe", stderr: "pipe" }); + expect({ exitCode: result.exitCode, stderr: result.stderr.toString() }).toEqual({ exitCode: 0, stderr: "" }); +}); + +test("descendant preference keeps the stored value when saving fails", () => { + const result = Bun.spawnSync([process.execPath, "--eval", ` + import { mock } from "bun:test"; + import { strict as assert } from "node:assert"; + let stored = "false"; + let writesFail = false; + mock.module("@react-native-async-storage/async-storage", () => ({ default: { + getItem: async () => stored, + setItem: async (_key, value) => { if (writesFail) throw new Error("disk full"); stored = value; }, + } })); + const { readMobileShowDescendantNotes, saveMobileShowDescendantNotes } = + await import(${JSON.stringify(new URL("./preferences.ts", import.meta.url).href)}); + assert.deepEqual(await saveMobileShowDescendantNotes(true), { value: true, saved: true }); + writesFail = true; + // The UI must keep showing what the next launch will restore, not the unsaved choice. + assert.deepEqual(await saveMobileShowDescendantNotes(false), { value: true, saved: false }); + assert.equal(await readMobileShowDescendantNotes(), true); + `], { stdout: "pipe", stderr: "pipe" }); + expect({ exitCode: result.exitCode, stderr: result.stderr.toString() }).toEqual({ exitCode: 0, stderr: "" }); +}); diff --git a/apps/mobile/src/lib/preferences.ts b/apps/mobile/src/lib/preferences.ts index 7502a322..9acabc7c 100644 --- a/apps/mobile/src/lib/preferences.ts +++ b/apps/mobile/src/lib/preferences.ts @@ -9,6 +9,7 @@ import { const MEMO_LIST_DENSITY_KEY = "edgeever.mobile.memoListDensity"; const IMAGE_COMPRESSION_KEY = "edgeever.mobile.imageCompressionEnabled"; +const SHOW_DESCENDANT_NOTES_KEY = "edgeever.mobile.showDescendantNotes"; const LOCALE_PREFERENCE_KEY = "edgeever.mobile.localePreference"; const THEME_PREFERENCE_KEY = "edgeever.mobile.themePreference"; @@ -30,6 +31,30 @@ export const readMobileImageCompressionEnabled = async () => { export const writeMobileImageCompressionEnabled = (enabled: boolean) => AsyncStorage.setItem(IMAGE_COMPRESSION_KEY, enabled ? "true" : "false"); +// Parent notebooks keep listing their sub-notebooks' notes unless turned off. +export const readMobileShowDescendantNotes = async () => { + try { + const value = await AsyncStorage.getItem(SHOW_DESCENDANT_NOTES_KEY); + return value !== "false"; + } catch { + // A display preference must not leave all note queries disabled. + return true; + } +}; + +export const writeMobileShowDescendantNotes = (enabled: boolean) => AsyncStorage.setItem(SHOW_DESCENDANT_NOTES_KEY, enabled ? "true" : "false"); + +// Resolves to the value that is actually stored, so the list never shows a scope +// that would silently revert on the next launch. +export const saveMobileShowDescendantNotes = async (enabled: boolean) => { + try { + await writeMobileShowDescendantNotes(enabled); + return { value: enabled, saved: true }; + } catch { + return { value: await readMobileShowDescendantNotes(), saved: false }; + } +}; + export const readMobileLocalePreference = async (): Promise => { const value = await AsyncStorage.getItem(LOCALE_PREFERENCE_KEY); return isMobileLocalePreference(value) ? value : "system"; diff --git a/apps/mobile/src/screens/WorkspaceNotesView.tsx b/apps/mobile/src/screens/WorkspaceNotesView.tsx index f5720785..866781d4 100644 --- a/apps/mobile/src/screens/WorkspaceNotesView.tsx +++ b/apps/mobile/src/screens/WorkspaceNotesView.tsx @@ -1,6 +1,6 @@ import { memo, useRef, type ReactNode } from "react"; import type { MemoFilterMode, MemoSortMode } from "@edgeever/client"; -import { DEFAULT_MEMO_TITLE, getMemoListTimestamp, type MemoSummary, type Notebook } from "@edgeever/shared"; +import { DEFAULT_MEMO_TITLE, getMemoListTimestamp, getNotebookDescendantMemoCount, type MemoSummary, type Notebook } from "@edgeever/shared"; import { MOBILE_UI_METRICS, toggleMobileMemoFilterMode } from "@edgeever/shared/mobile-ui"; import { FlatList, Platform, RefreshControl, View } from "react-native"; import Animated, { FadeInDown, FadeOutUp, LinearTransition, useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated"; @@ -46,6 +46,7 @@ export const NotesView = ({ onSetMemoView, searchText, selectedTag, + showDescendantNotes, totalMemoCount, selectedMemoIds, selectionMode, @@ -80,6 +81,7 @@ export const NotesView = ({ onSetMemoView: (memoView: MemoView) => void; searchText: string; selectedTag: string | null; + showDescendantNotes: boolean; totalMemoCount: number; selectionMode: boolean; selectedMemoIds: Set; @@ -88,6 +90,10 @@ export const NotesView = ({ const { preference: localePreference, translate } = useMobileLocale(); const searchActive = searchText.trim().length > 0; const filterActive = memoFilterMode !== "all" || Boolean(selectedTag); + // With sub-notebooks hidden, an empty parent must not look like its notes are gone. + const hiddenDescendantMemoCount = !showDescendantNotes && memoView === "notebook" && activeNotebook && !searchActive && !filterActive + ? getNotebookDescendantMemoCount(notebooks, activeNotebook.id) + : 0; const searchStatusLabel = translate("正在搜索"); const searchResultLabel = translate(`${totalMemoCount} 条结果`); const exitSearchLabel = translate("退出搜索"); @@ -199,8 +205,8 @@ export const NotesView = ({ : []), ] : undefined} - emptyDescription={searchActive ? "换个关键词再试" : filterActive ? "试试切换筛选条件,或调整搜索关键词。" : memoView === "trash" ? "删除的笔记会显示在这里。" : "先创建一条笔记,之后可以在这里快速预览、搜索和批量整理。"} - emptyTitle={searchActive ? "没有找到匹配笔记" : filterActive ? "没有符合筛选的笔记" : memoView === "trash" ? "回收站为空" : "暂无笔记"} + emptyDescription={hiddenDescendantMemoCount > 0 ? `子笔记本中还有 ${hiddenDescendantMemoCount} 条笔记。可以打开子笔记本查看,或在设置中开启“显示子笔记本中的笔记”。` : searchActive ? "换个关键词再试" : filterActive ? "试试切换筛选条件,或调整搜索关键词。" : memoView === "trash" ? "删除的笔记会显示在这里。" : "先创建一条笔记,之后可以在这里快速预览、搜索和批量整理。"} + emptyTitle={hiddenDescendantMemoCount > 0 ? "本级暂无笔记" : searchActive ? "没有找到匹配笔记" : filterActive ? "没有符合筛选的笔记" : memoView === "trash" ? "回收站为空" : "暂无笔记"} error={error} initialSyncProgress={initialSyncProgress} isError={isError} diff --git a/apps/mobile/src/screens/WorkspaceScreen.tsx b/apps/mobile/src/screens/WorkspaceScreen.tsx index a37aab2c..a5f3b8a4 100644 --- a/apps/mobile/src/screens/WorkspaceScreen.tsx +++ b/apps/mobile/src/screens/WorkspaceScreen.tsx @@ -1,5 +1,5 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient, type InfiniteData, type QueryKey } from "@tanstack/react-query"; +import { useInfiniteQuery, useMutation, useQuery, useQueryClient, type InfiniteData, type QueryKey } from "@tanstack/react-query"; import * as Clipboard from "expo-clipboard"; import type { ListMemosResponse, MemoFilterMode, MemoSortMode } from "@edgeever/client"; import { @@ -19,13 +19,15 @@ import { import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context"; import { Alert, Pressable, Text } from "../components/LocalizedText"; import { ApiRequestError } from "@edgeever/client"; -import { DEFAULT_MEMO_TITLE, getNotebookDescendantIds, hasDiagramDocumentMarker, markdownToDoc, type MemoDetail } from "@edgeever/shared"; +import { DEFAULT_MEMO_TITLE, getNotebookScopeIds, hasDiagramDocumentMarker, markdownToDoc, type MemoDetail } from "@edgeever/shared"; import { MOBILE_UI_METRICS, toggleMobileMemoFilterMode } from "@edgeever/shared/mobile-ui"; import { clearMobileMemoDraft, readMobileMemoDraft, type MobileMemoDraft } from "../lib/mobile-drafts"; import { readMobileImageCompressionEnabled, + readMobileShowDescendantNotes, readMobileMemoListDensity, writeMobileImageCompressionEnabled, + saveMobileShowDescendantNotes, writeMobileMemoListDensity, type MobileLocalePreference, type MobileMemoListDensity, @@ -93,6 +95,7 @@ import { applyOptimisticMemoToCache, createOptimisticMemo, findCachedMemoDetail, + memoQueriesShareScope, type MobileMemoUpdatePayload, } from "./workspace-memo-cache"; import { refreshWorkspaceThemeStyles, styles } from "./workspace-styles"; @@ -157,6 +160,9 @@ export const WorkspaceScreen = ({ const [memoSortMode, setMemoSortMode] = useState("updated-desc"); const [memoListDensity, setMemoListDensity] = useState("preview"); const [imageCompressionEnabled, setImageCompressionEnabled] = useState(true); + // Null until the stored value loads, so lists never flash the wrong notebook scope. + const [showDescendantNotes, setShowDescendantNotes] = useState(null); + const [showDescendantNotesSaveFailed, setShowDescendantNotesSaveFailed] = useState(false); const [selectedMemoId, setSelectedMemoId] = useState(null); const [searchText, setSearchText] = useState(""); const [createOpen, setCreateOpen] = useState(false); @@ -250,13 +256,14 @@ export const WorkspaceScreen = ({ }, [activeNotebookId, localePreference, notebooks, selectedTag]); const activeNotebook = notebooks.find((notebook) => notebook.id === activeNotebookId) ?? null; - const activeNotebookDescendantIds = useMemo( - () => (activeNotebookId === ALL_NOTES_ID || selectedTag ? [] : getNotebookDescendantIds(notebooks, activeNotebookId)), - [activeNotebookId, notebooks, selectedTag] + const activeNotebookScopeIds = useMemo( + () => (activeNotebookId === ALL_NOTES_ID || selectedTag ? [] : getNotebookScopeIds(notebooks, activeNotebookId, showDescendantNotes ?? true)), + [activeNotebookId, notebooks, selectedTag, showDescendantNotes] ); + const memosQueryKey = ["mobile", "memos", memoView, activeNotebookId, memoFilterMode, memoSortMode, activeNotebookScopeIds, selectedTag, "paged-v3"] as const; const memosQuery = useInfiniteQuery({ - queryKey: ["mobile", "memos", memoView, activeNotebookId, memoFilterMode, memoSortMode, activeNotebookDescendantIds, selectedTag, "paged-v3"], + queryKey: memosQueryKey, initialPageParam: 0, queryFn: async ({ pageParam }) => { if (!client) { @@ -264,7 +271,7 @@ export const WorkspaceScreen = ({ } return listLocalMemos(dataScope, { - notebookIds: activeNotebookDescendantIds, + notebookIds: activeNotebookScopeIds, filter: memoFilterMode, tag: memoView === "notebook" ? selectedTag ?? undefined : undefined, limit: 50, @@ -274,12 +281,14 @@ export const WorkspaceScreen = ({ }); }, getNextPageParam: (lastPage) => lastPage.nextCursor ? Number(lastPage.nextCursor) : undefined, - enabled: Boolean(client), - placeholderData: keepPreviousData, + enabled: Boolean(client) && showDescendantNotes !== null, + placeholderData: (previousData, previousQuery) => + previousQuery && memoQueriesShareScope("memos", previousQuery.queryKey, memosQueryKey) ? previousData : undefined, }); + const searchQueryKey = ["mobile", "search", memoView, debouncedSearchText, activeNotebookId, memoFilterMode, memoSortMode, activeNotebookScopeIds, selectedTag, "paged-v5"] as const; const searchQuery = useInfiniteQuery({ - queryKey: ["mobile", "search", memoView, debouncedSearchText, activeNotebookId, memoFilterMode, memoSortMode, activeNotebookDescendantIds, selectedTag, "paged-v5"], + queryKey: searchQueryKey, initialPageParam: 0, queryFn: async ({ pageParam }) => { if (!client) { @@ -288,7 +297,7 @@ export const WorkspaceScreen = ({ return listLocalMemos(dataScope, { q: debouncedSearchText, - notebookIds: activeNotebookDescendantIds, + notebookIds: activeNotebookScopeIds, filter: memoFilterMode, tag: memoView === "notebook" ? selectedTag ?? undefined : undefined, limit: 50, @@ -298,8 +307,9 @@ export const WorkspaceScreen = ({ }); }, getNextPageParam: (lastPage) => lastPage.nextCursor ? Number(lastPage.nextCursor) : undefined, - enabled: Boolean(client && debouncedSearchText.length > 0), - placeholderData: keepPreviousData, + enabled: Boolean(client && debouncedSearchText.length > 0) && showDescendantNotes !== null, + placeholderData: (previousData, previousQuery) => + previousQuery && memoQueriesShareScope("search", previousQuery.queryKey, searchQueryKey) ? previousData : undefined, }); const memoDetailQuery = useQuery({ @@ -725,6 +735,11 @@ export const WorkspaceScreen = ({ setImageCompressionEnabled(enabled); } }); + readMobileShowDescendantNotes().then((enabled) => { + if (mounted) { + setShowDescendantNotes(enabled); + } + }); return () => { mounted = false; @@ -759,6 +774,12 @@ export const WorkspaceScreen = ({ void writeMobileImageCompressionEnabled(enabled); }; + const handleShowDescendantNotesChange = async (enabled: boolean) => { + const result = await saveMobileShowDescendantNotes(enabled); + setShowDescendantNotes(result.value); + setShowDescendantNotesSaveFailed(!result.saved); + }; + const optimisticallyRemoveMemoIds = async (memoIds: string[]): Promise => { const queryKeys: QueryKey[] = [ ["mobile", "memos"], @@ -1210,6 +1231,7 @@ export const WorkspaceScreen = ({ {activeView === "notes" ? ( void handleShowDescendantNotesChange(enabled)} onSignOut={signOut} /> ) : null} diff --git a/apps/mobile/src/screens/WorkspaceSettingsView.tsx b/apps/mobile/src/screens/WorkspaceSettingsView.tsx index cd39f8c2..9d983212 100644 --- a/apps/mobile/src/screens/WorkspaceSettingsView.tsx +++ b/apps/mobile/src/screens/WorkspaceSettingsView.tsx @@ -89,7 +89,10 @@ export const SettingsView = ({ onClose, onImageCompressionChange, onLocalePreferenceChange, + onShowDescendantNotesChange, onSignOut, + showDescendantNotes, + showDescendantNotesSaveFailed, }: { currentUser: AuthUser | null; imageCompressionEnabled: boolean; @@ -97,7 +100,10 @@ export const SettingsView = ({ onClose: () => void; onImageCompressionChange: (enabled: boolean) => void; onLocalePreferenceChange: (locale: MobileLocaleMode) => void; + onShowDescendantNotesChange: (enabled: boolean) => void; onSignOut: () => void; + showDescendantNotes: boolean; + showDescendantNotesSaveFailed: boolean; }) => { const { resolvedTheme, toggleTheme } = useMobileTheme(); const { translate } = useMobileLocale(); @@ -220,6 +226,18 @@ export const SettingsView = ({ + + + + 显示子笔记本中的笔记 + 开启后,打开父笔记本会同时列出所有子笔记本中的笔记;关闭后只列出直接放在该笔记本中的笔记。 + {showDescendantNotesSaveFailed ? 无法保存“显示子笔记本中的笔记”设置,请稍后重试 : null} + + + + + + ); diff --git a/apps/mobile/src/screens/workspace-memo-cache.test.ts b/apps/mobile/src/screens/workspace-memo-cache.test.ts index 592a618d..549c8084 100644 --- a/apps/mobile/src/screens/workspace-memo-cache.test.ts +++ b/apps/mobile/src/screens/workspace-memo-cache.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import type { MemoDetail } from "@edgeever/shared"; -import { createOptimisticMemo, memoMatchesListQuery } from "./workspace-memo-cache"; +import { createOptimisticMemo, memoMatchesListQuery, memoQueriesShareScope } from "./workspace-memo-cache"; const memo: MemoDetail = { id: "memo_1", @@ -59,3 +59,24 @@ describe("mobile workspace memo cache", () => { expect(memoMatchesListQuery(taggedMemo, query("homework"))).toBe(false); }); }); + +describe("memoQueriesShareScope", () => { + const listKey = (notebookId: string, scopeIds: string[], sort = "updated-desc", filter = "all") => + ["mobile", "memos", "notebook", notebookId, filter, sort, scopeIds, null, "paged-v3"]; + const searchKey = (text: string, scopeIds: string[]) => + ["mobile", "search", "notebook", text, "parent", "all", "updated-desc", scopeIds, null, "paged-v5"]; + + test("keeps previous results when only sort, filter or search text changes", () => { + expect(memoQueriesShareScope("memos", listKey("parent", ["parent", "child"]), listKey("parent", ["parent", "child"], "title-asc", "pinned"))).toBe(true); + expect(memoQueriesShareScope("search", searchKey("scope", ["parent"]), searchKey("scopecheck", ["parent"]))).toBe(true); + }); + + test("drops previous results when the sub-notebook setting changes the scope", () => { + expect(memoQueriesShareScope("memos", listKey("parent", ["parent", "child"]), listKey("parent", ["parent"]))).toBe(false); + expect(memoQueriesShareScope("search", searchKey("scopecheck", ["parent", "child"]), searchKey("scopecheck", ["parent"]))).toBe(false); + }); + + test("drops previous results when switching notebooks", () => { + expect(memoQueriesShareScope("memos", listKey("parent", ["parent"]), listKey("other", ["other"]))).toBe(false); + }); +}); diff --git a/apps/mobile/src/screens/workspace-memo-cache.ts b/apps/mobile/src/screens/workspace-memo-cache.ts index 729932fb..b546a32b 100644 --- a/apps/mobile/src/screens/workspace-memo-cache.ts +++ b/apps/mobile/src/screens/workspace-memo-cache.ts @@ -40,6 +40,16 @@ export const createOptimisticMemo = ( }; }; +// Where WorkspaceScreen puts the notebook scope (view, notebook, scope ids, tag) in +// the list and search query keys. +const MEMO_QUERY_SCOPE_INDEXES = { memos: [2, 3, 6, 7], search: [2, 4, 7, 8] } as const; + +// Previous results may stand in while a query loads only when they cover the same +// notebook scope. Otherwise another notebook's notes, or the other sub-notebook +// setting's notes, would be shown with their count as the current scope. +export const memoQueriesShareScope = (kind: keyof typeof MEMO_QUERY_SCOPE_INDEXES, previousKey: readonly unknown[], nextKey: readonly unknown[]) => + MEMO_QUERY_SCOPE_INDEXES[kind].every((index) => JSON.stringify(previousKey[index]) === JSON.stringify(nextKey[index])); + export const memoMatchesListQuery = (memo: MemoSummary, queryKey: readonly unknown[]) => { const view = queryKey[2]; const notebookId = queryKey[3]; diff --git a/apps/web/src/components/NotebookPane.tsx b/apps/web/src/components/NotebookPane.tsx index 65da7c18..98e111bb 100644 --- a/apps/web/src/components/NotebookPane.tsx +++ b/apps/web/src/components/NotebookPane.tsx @@ -49,6 +49,7 @@ import { } from "@/components/ui/dropdown-menu"; import { NotebookTreeItem } from "./NotebookTreeItem"; import { cn } from "@/lib/utils"; +import { useShowDescendantNotesPreference } from "@/lib/descendant-notes-preference"; import { BETA_BADGE_CLASSNAME } from "@/lib/workspace-ui"; import type { Notebook, AuthUser, NoteCreateKind } from "@edgeever/shared"; import type { NotebookNode, NotebookDropPosition, NotebookSortMode } from "@/lib/app-helpers"; @@ -542,6 +543,7 @@ export const NotebookPane = ({ onToggleCollapsed?: () => void; }) => { const { t } = useTranslation(); + const showDescendantNotes = useShowDescendantNotesPreference(); const { unseen: deployedUpdateUnseen } = useDeployedUpdateNotice(); // Temporarily keep template actions out of the primary workspace navigation. const showTemplateEntry = true; @@ -847,6 +849,7 @@ export const NotebookPane = ({ onOpenChange={handleNotebookOpenChange} expandSiblingsRequest={expandSiblingsRequest} onExpandSiblings={handleExpandNotebookSiblings} + showDescendantNotes={showDescendantNotes} /> ))} diff --git a/apps/web/src/components/NotebookTreeItem.tsx b/apps/web/src/components/NotebookTreeItem.tsx index bb4a0d01..34427574 100644 --- a/apps/web/src/components/NotebookTreeItem.tsx +++ b/apps/web/src/components/NotebookTreeItem.tsx @@ -14,7 +14,7 @@ import { setMemoDragPreview, } from "@/lib/app-helpers"; import { cn } from "@/lib/utils"; -import type { Notebook } from "@edgeever/shared"; +import { formatNotebookMemoCount, type Notebook } from "@edgeever/shared"; import { ContextMenu, ContextMenuContent, @@ -41,6 +41,7 @@ export const NotebookTreeItem = ({ onOpenChange, expandSiblingsRequest, onExpandSiblings, + showDescendantNotes, }: { node: NotebookNode; depth: number; @@ -56,10 +57,12 @@ export const NotebookTreeItem = ({ onOpenChange: (notebookId: string, open: boolean) => void; expandSiblingsRequest: { parentId: string | null; token: number } | null; onExpandSiblings: (parentId: string | null) => void; + showDescendantNotes: boolean; }) => { const { t } = useTranslation(); const open = !collapsedNotebookIds.has(node.id); const hasChildren = node.children.length > 0; + const showsSplitCount = !showDescendantNotes && hasChildren; const selected = node.id === selectedNotebookId; const isInbox = node.slug === "inbox"; const hasSelectedDescendant = selectedNotebookId ? notebookTreeContainsId(node.children, selectedNotebookId) : false; @@ -221,9 +224,9 @@ export const NotebookTreeItem = ({ data-notebook-tree-button className="flex min-w-0 flex-1 items-center gap-2 text-left" type="button" - aria-label={selected - ? t("notebookTree.currentWithCount", { name: node.name, count: node.memoCount }) - : t("notebookTree.switchToWithCount", { name: node.name, count: node.memoCount })} + aria-label={showsSplitCount + ? t(selected ? "notebookTree.currentWithSplitCount" : "notebookTree.switchToWithSplitCount", { name: node.name, direct: node.directMemoCount, total: node.memoCount }) + : t(selected ? "notebookTree.currentWithCount" : "notebookTree.switchToWithCount", { name: node.name, count: showDescendantNotes ? node.memoCount : node.directMemoCount })} aria-current={selected ? "page" : undefined} aria-expanded={hasChildren ? open : undefined} onClick={() => onSelect(node.id)} @@ -276,7 +279,7 @@ export const NotebookTreeItem = ({ )} aria-hidden="true" > - {node.memoCount} + {formatNotebookMemoCount({ directCount: node.directMemoCount, totalCount: node.memoCount, hasChildren }, showDescendantNotes)}
@@ -399,6 +402,7 @@ export const NotebookTreeItem = ({ onOpenChange={onOpenChange} expandSiblingsRequest={expandSiblingsRequest} onExpandSiblings={onExpandSiblings} + showDescendantNotes={showDescendantNotes} /> ))} diff --git a/apps/web/src/components/WorkspaceApp.tsx b/apps/web/src/components/WorkspaceApp.tsx index 0fd84de0..f3d7d1d3 100644 --- a/apps/web/src/components/WorkspaceApp.tsx +++ b/apps/web/src/components/WorkspaceApp.tsx @@ -26,6 +26,7 @@ import { AppConfirmDialog, MemoDeleteConfirmDialog, NotebookNameDialog } from ". import { PluginPanelDialog } from "./plugins/PluginPanelDialog"; import { shouldDiscardPluginNoteSearchRequest } from "./editor/note-search"; import { api, getOrCreateClientDeviceId } from "@/lib/api"; +import { useShowDescendantNotesPreference } from "@/lib/descendant-notes-preference"; import { MarkdownExportMemoryLimitError, type MarkdownExportProgress } from "@/lib/markdown-export"; import { exportSelectedMemosAsMarkdownZip } from "@/lib/selected-markdown-export"; import { createPluginScheduleAdapter } from "@/lib/plugins/plugin-schedule-adapter"; @@ -46,7 +47,7 @@ import { } from "@/lib/mobile-editor"; import { cn } from "@/lib/utils"; import { isBrowserOffline, isBrowserOnline } from "@/lib/network-status"; -import { createDefaultDiagramDocument, createDefaultInfographicDocument, createDefaultTableDocument, diagramFallbackMarkdown, getNotebookDescendantIds, hasTableDocumentMarker, infographicFallbackMarkdown, markdownToDoc, parseDiagramDocument, parseInfographicDocument, parseTableDocument, serializeDiagramDocument, serializeInfographicDocument, serializeTableDocument, tableFallbackMarkdown, type NoteCreateKind, type Notebook, type AuthUser, type MemoSummary, type MemoDetail, type MemoTemplate as SavedMemoTemplate } from "@edgeever/shared"; +import { createDefaultDiagramDocument, createDefaultInfographicDocument, createDefaultTableDocument, diagramFallbackMarkdown, getNotebookScopeIds, hasTableDocumentMarker, infographicFallbackMarkdown, markdownToDoc, parseDiagramDocument, parseInfographicDocument, parseTableDocument, serializeDiagramDocument, serializeInfographicDocument, serializeTableDocument, tableFallbackMarkdown, type NoteCreateKind, type Notebook, type AuthUser, type MemoSummary, type MemoDetail, type MemoTemplate as SavedMemoTemplate } from "@edgeever/shared"; import { toggleMobileMemoSelection } from "@edgeever/shared/mobile-ui"; import type { Pane, @@ -1054,15 +1055,20 @@ export const WorkspaceApp = ({ setOnline: setIsOnline, }); - const selectedNotebookDescendantIds = useMemo( - () => (selectedNotebookId && !selectedTag ? getNotebookDescendantIds(notebooks, selectedNotebookId) : []), - [notebooks, selectedNotebookId, selectedTag] + const showDescendantNotes = useShowDescendantNotesPreference(); + useEffect(() => { + // A changed scope must not retain hidden notes in bulk actions. + clearMemoSelection(); + }, [showDescendantNotes, clearMemoSelection]); + const selectedNotebookScopeIds = useMemo( + () => (selectedNotebookId && !selectedTag ? getNotebookScopeIds(notebooks, selectedNotebookId, showDescendantNotes) : []), + [notebooks, selectedNotebookId, selectedTag, showDescendantNotes] ); const memosQuery = useInfiniteQuery({ - queryKey: ["memos", memoView, selectedNotebookId, search, memoFilterMode, memoSortMode, selectedNotebookDescendantIds, selectedTag], + queryKey: ["memos", memoView, selectedNotebookId, search, memoFilterMode, memoSortMode, selectedNotebookScopeIds, selectedTag], queryFn: ({ pageParam }) => repository.listMemos({ notebookId: memoView === "notebook" && !selectedTag ? selectedNotebookId : null, - notebookIds: memoView === "notebook" && !selectedTag ? selectedNotebookDescendantIds : undefined, + notebookIds: memoView === "notebook" && !selectedTag ? selectedNotebookScopeIds : undefined, q: search, tag: memoView === "notebook" ? selectedTag ?? undefined : undefined, trash: memoView === "trash", @@ -3659,7 +3665,7 @@ export const WorkspaceApp = ({ search, memoFilterMode, memoSortMode, - selectedNotebookDescendantIds, + selectedNotebookScopeIds, selectedTag, ], exact: true, diff --git a/apps/web/src/components/settings/PreferenceCard.tsx b/apps/web/src/components/settings/PreferenceCard.tsx index 49bb217c..7268814f 100644 --- a/apps/web/src/components/settings/PreferenceCard.tsx +++ b/apps/web/src/components/settings/PreferenceCard.tsx @@ -1,4 +1,4 @@ -import { AlignHorizontalJustifyCenter, AlignVerticalSpaceAround, AppWindow, BookOpenText, ChartNoAxesCombined, Code2, Image, Keyboard, Languages, MousePointerClick, Palette, Sparkles, SunMoon, Type } from "lucide-react"; +import { AlignHorizontalJustifyCenter, AlignVerticalSpaceAround, AppWindow, BookOpenText, ChartNoAxesCombined, Code2, Image, Keyboard, Languages, ListTree, MousePointerClick, Palette, Sparkles, SunMoon, Type } from "lucide-react"; import { useState, useEffect, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { EditorContentWidth } from "@/lib/editor-content-width"; @@ -23,7 +23,7 @@ import { import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; -import { SETTINGS_ITEM_TITLE_CLASSNAME } from "./settings-ui"; +import { SETTINGS_ITEM_DESCRIPTION_CLASSNAME, SETTINGS_ITEM_ICON_CLASSNAME, SETTINGS_ITEM_TITLE_CLASSNAME } from "./settings-ui"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; @@ -44,6 +44,7 @@ import { } from "@/lib/editor-body-font"; import { applyUiFontPreference, readUiFontPreference, writeUiFontPreference } from "@/lib/ui-font"; import { syncPublishedNoteBodyFont } from "@/lib/published-note-body-font"; +import { useShowDescendantNotesPreference, writeShowDescendantNotesPreference } from "@/lib/descendant-notes-preference"; import { NoteProseCssEditor } from "./NoteProseCssEditor"; import { NoteProseCssPreview } from "./NoteProseCssPreview"; @@ -210,6 +211,7 @@ export const PreferenceCard = ({ onNoteProseChange, }: PreferenceCardProps) => { const { t } = useTranslation(); + const showDescendantNotes = useShowDescendantNotesPreference(); const { preference: appearancePreference, resolvedTheme, setPreference: setAppearancePreference } = useAppearanceTheme(); const { mermaidThemePreference, setMermaidTheme } = useMermaidTheme(); const [cssDialogOpen, setCssDialogOpen] = useState(false); @@ -365,6 +367,23 @@ export const PreferenceCard = ({
+
+
+ +
+
{t("settings.showDescendantNotesTitle")}
+
{t("settings.showDescendantNotesDescription")}
+
+
+
+ +
+
+
diff --git a/apps/web/src/lib/descendant-notes-preference.test.mjs b/apps/web/src/lib/descendant-notes-preference.test.mjs new file mode 100644 index 00000000..5b696daf --- /dev/null +++ b/apps/web/src/lib/descendant-notes-preference.test.mjs @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + readShowDescendantNotesPreference, + resolveStoredShowDescendantNotes, + SHOW_DESCENDANT_NOTES_CHANGED_EVENT, + SHOW_DESCENDANT_NOTES_STORAGE_KEY, + writeShowDescendantNotesPreference, +} from "./descendant-notes-preference.ts"; + +const originalWindow = globalThis.window; +const OriginalCustomEvent = globalThis.CustomEvent; + +afterEach(() => { + globalThis.window = originalWindow; + globalThis.CustomEvent = OriginalCustomEvent; +}); + +const installWindow = () => { + const values = new Map(); + const events = []; + globalThis.CustomEvent ??= class CustomEvent { + constructor(type, init) { + this.type = type; + this.detail = init?.detail; + } + }; + globalThis.window = { + localStorage: { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, String(value)), + }, + dispatchEvent: (event) => { + events.push(event); + return true; + }, + }; + return { events, values }; +}; + +describe("show descendant notes preference", () => { + test("defaults to showing descendant notes unless explicitly disabled", () => { + expect(resolveStoredShowDescendantNotes(null)).toBe(true); + expect(resolveStoredShowDescendantNotes("unsupported")).toBe(true); + expect(resolveStoredShowDescendantNotes("false")).toBe(false); + }); + + test("persists changes and notifies the current document", () => { + const { events, values } = installWindow(); + writeShowDescendantNotesPreference(false); + expect(values.get(SHOW_DESCENDANT_NOTES_STORAGE_KEY)).toBe("false"); + expect(readShowDescendantNotesPreference()).toBe(false); + expect(events.map((event) => [event.type, event.detail])).toEqual([[SHOW_DESCENDANT_NOTES_CHANGED_EVENT, false]]); + }); + + test("falls back to the default when storage is unavailable", () => { + globalThis.window = { localStorage: { getItem: () => { throw new Error("blocked"); } } }; + expect(readShowDescendantNotesPreference()).toBe(true); + }); + + test("keeps the choice for this session when storage rejects the write", () => { + const { events, values } = installWindow(); + values.set(SHOW_DESCENDANT_NOTES_STORAGE_KEY, "true"); + globalThis.window.localStorage.setItem = () => { + throw new Error("QuotaExceededError"); + }; + + writeShowDescendantNotesPreference(false); + expect(readShowDescendantNotesPreference()).toBe(false); + expect(events.map((event) => event.detail)).toEqual([false]); + + // Once storage accepts writes again, the stored value takes over. + const restored = installWindow(); + writeShowDescendantNotesPreference(true); + expect(restored.values.get(SHOW_DESCENDANT_NOTES_STORAGE_KEY)).toBe("true"); + restored.values.set(SHOW_DESCENDANT_NOTES_STORAGE_KEY, "false"); + expect(readShowDescendantNotesPreference()).toBe(false); + }); +}); diff --git a/apps/web/src/lib/descendant-notes-preference.ts b/apps/web/src/lib/descendant-notes-preference.ts new file mode 100644 index 00000000..8e0ee4e5 --- /dev/null +++ b/apps/web/src/lib/descendant-notes-preference.ts @@ -0,0 +1,54 @@ +import { useSyncExternalStore } from "react"; + +export const SHOW_DESCENDANT_NOTES_STORAGE_KEY = "edgeever.showDescendantNotes"; + +export const SHOW_DESCENDANT_NOTES_CHANGED_EVENT = "edgeever:show-descendant-notes-changed"; + +// Parent notebooks keep aggregating their sub-notebooks unless turned off. +export const resolveStoredShowDescendantNotes = (stored: string | null): boolean => + stored !== "false"; + +// Holds the choice while localStorage rejects writes, so the toggle still works +// for the rest of this page session. +let unsavedPreference: boolean | null = null; + +export const readShowDescendantNotesPreference = (): boolean => { + if (typeof window === "undefined") return true; + if (unsavedPreference !== null) return unsavedPreference; + try { + return resolveStoredShowDescendantNotes( + window.localStorage?.getItem(SHOW_DESCENDANT_NOTES_STORAGE_KEY) ?? null, + ); + } catch { + return true; + } +}; + +export const writeShowDescendantNotesPreference = (enabled: boolean) => { + if (typeof window === "undefined") return; + try { + window.localStorage?.setItem(SHOW_DESCENDANT_NOTES_STORAGE_KEY, enabled ? "true" : "false"); + unsavedPreference = null; + } catch { + // Private mode / blocked storage — keep the choice in memory for this session. + unsavedPreference = enabled; + } + window.dispatchEvent( + new CustomEvent(SHOW_DESCENDANT_NOTES_CHANGED_EVENT, { detail: enabled }), + ); +}; + +const subscribe = (onChange: () => void) => { + const handleStorage = (event: StorageEvent) => { + if (event.key === SHOW_DESCENDANT_NOTES_STORAGE_KEY) onChange(); + }; + window.addEventListener(SHOW_DESCENDANT_NOTES_CHANGED_EVENT, onChange); + window.addEventListener("storage", handleStorage); + return () => { + window.removeEventListener(SHOW_DESCENDANT_NOTES_CHANGED_EVENT, onChange); + window.removeEventListener("storage", handleStorage); + }; +}; + +export const useShowDescendantNotesPreference = () => + useSyncExternalStore(subscribe, readShowDescendantNotesPreference, () => true); diff --git a/apps/web/src/lib/repository.ts b/apps/web/src/lib/repository.ts index 09ce5ce1..c3d43896 100644 --- a/apps/web/src/lib/repository.ts +++ b/apps/web/src/lib/repository.ts @@ -442,7 +442,8 @@ export const createWebRepository = (scope: string): EdgeEverRepository => { const remote = await api.listMemos({ notebookId: params.notebookId, - includeDescendants: Boolean(params.notebookIds?.length), + // The scope reaches sub-notebooks only when it lists more than the notebook itself. + includeDescendants: Boolean(params.notebookIds?.some((notebookId) => notebookId !== params.notebookId)), q: params.q, tag: params.tag, trash: params.trash, diff --git a/apps/web/src/lib/utils.test.mjs b/apps/web/src/lib/utils.test.mjs index 64272aaa..e57fae30 100644 --- a/apps/web/src/lib/utils.test.mjs +++ b/apps/web/src/lib/utils.test.mjs @@ -30,6 +30,18 @@ describe("buildNotebookTree", () => { expect(root.children[0].children[0].memoCount).toBe(5); }); + test("keeps each notebook's own count next to the rolled-up total", () => { + const [root] = buildNotebookTree([ + notebook("root", null, 2), + notebook("child", "root", 3), + notebook("grandchild", "child", 5), + ]); + + expect([root.directMemoCount, root.memoCount]).toEqual([2, 10]); + expect([root.children[0].directMemoCount, root.children[0].memoCount]).toEqual([3, 8]); + expect([root.children[0].children[0].directMemoCount, root.children[0].children[0].memoCount]).toEqual([5, 5]); + }); + test("sorts siblings after descendant counts have been rolled up", () => { const roots = buildNotebookTree( [ diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index 70117d46..9854ca07 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -3,6 +3,8 @@ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export type NotebookNode = Notebook & { + // memoCount holds the subtree total; this keeps the notebook's own count. + directMemoCount: number; children: NotebookNode[]; }; @@ -16,7 +18,7 @@ export const buildNotebookTree = (notebooks: Notebook[], compareNodes: NotebookN const nodes = new Map(); for (const notebook of notebooks) { - nodes.set(notebook.id, { ...notebook, children: [] }); + nodes.set(notebook.id, { ...notebook, directMemoCount: notebook.memoCount, children: [] }); } const roots: NotebookNode[] = []; diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index 4683647f..d74b0cb4 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -734,6 +734,8 @@ export const enUS = { switchTo: "Switch to {{name}}", currentWithCount: "Current: {{name}}, {{count}} notes", switchToWithCount: "Switch to {{name}}, {{count}} notes", + currentWithSplitCount: "Current: {{name}}, {{direct}} notes here, {{total}} including sub-notebooks", + switchToWithSplitCount: "Switch to {{name}}, {{direct}} notes here, {{total}} including sub-notebooks", actions: "Notebook actions", actionsAria: "Open actions for {{name}}", newChild: "New child notebook", @@ -756,6 +758,9 @@ export const enUS = { systemLanguage: "Follow system", imageCompressionTitle: "Compress note images", imageCompressionAria: "Compress note images", + showDescendantNotesTitle: "Show notes from sub-notebooks", + showDescendantNotesDescription: "When on, opening a parent notebook also lists notes from all of its sub-notebooks. When off, only notes stored directly in that notebook are listed.", + showDescendantNotesAria: "Show notes from sub-notebooks in parent notebooks", aiSelectionMenuTitle: "Show AI assistant when text is selected", aiSelectionMenuAria: "Show the AI assistant action when text is selected", aiSpaceShortcutTitle: "Open AI with Space in an empty block", diff --git a/packages/shared/src/i18n/ja.ts b/packages/shared/src/i18n/ja.ts index 7db08a65..c0f1368b 100644 --- a/packages/shared/src/i18n/ja.ts +++ b/packages/shared/src/i18n/ja.ts @@ -734,6 +734,8 @@ export const ja = { switchTo: "{{name}} に切り替え", currentWithCount: "現在:{{name}}、{{count}} 件のノート", switchToWithCount: "{{name}} に切り替え、{{count}} 件のノート", + currentWithSplitCount: "現在:{{name}}、直下に {{direct}} 件、サブノートブックを含めて {{total}} 件のノート", + switchToWithSplitCount: "{{name}} に切り替え、直下に {{direct}} 件、サブノートブックを含めて {{total}} 件のノート", actions: "ノートブック操作", actionsAria: "{{name}} の操作を開く", newChild: "子ノートブックを作成", @@ -756,6 +758,9 @@ export const ja = { systemLanguage: "システムに合わせる", imageCompressionTitle: "ノート内の画像を圧縮", imageCompressionAria: "ノート内の画像を圧縮する", + showDescendantNotesTitle: "サブノートブックのノートを表示", + showDescendantNotesDescription: "オンにすると、親ノートブックを開いたときにすべてのサブノートブックのノートも一覧表示します。オフにすると、そのノートブックに直接保存されたノートのみを表示します。", + showDescendantNotesAria: "親ノートブックにサブノートブックのノートを表示する", aiSelectionMenuTitle: "文字選択時に AI アシスタントを表示", aiSelectionMenuAria: "文字選択時に AI アシスタント操作を表示する", aiSpaceShortcutTitle: "空のブロックで Space から AI を開く", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 38477f86..2e954be4 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -734,6 +734,8 @@ export const zhCN = { switchTo: "切换到 {{name}}", currentWithCount: "当前:{{name}},{{count}} 条笔记", switchToWithCount: "切换到 {{name}},{{count}} 条笔记", + currentWithSplitCount: "当前:{{name}},本级 {{direct}} 条笔记,含子笔记本共 {{total}} 条", + switchToWithSplitCount: "切换到 {{name}},本级 {{direct}} 条笔记,含子笔记本共 {{total}} 条", actions: "笔记本操作", actionsAria: "打开 {{name}} 的操作菜单", newChild: "新建子笔记本", @@ -756,6 +758,9 @@ export const zhCN = { systemLanguage: "跟随系统", imageCompressionTitle: "压缩笔记内图片", imageCompressionAria: "是否压缩笔记内图片", + showDescendantNotesTitle: "显示子笔记本中的笔记", + showDescendantNotesDescription: "开启后,打开父笔记本会同时列出所有子笔记本中的笔记;关闭后只列出直接放在该笔记本中的笔记。", + showDescendantNotesAria: "是否在父笔记本中显示子笔记本中的笔记", aiSelectionMenuTitle: "选中文字时显示 AI 助手", aiSelectionMenuAria: "选中文字时是否显示 AI 助手入口", aiSpaceShortcutTitle: "空白段落按 Space 唤起 AI", diff --git a/packages/shared/src/notebooks.test.mjs b/packages/shared/src/notebooks.test.mjs index ad50f6c9..827b0d80 100644 --- a/packages/shared/src/notebooks.test.mjs +++ b/packages/shared/src/notebooks.test.mjs @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { getNotebookDescendantIds } from "./notebooks.ts"; +import { formatNotebookMemoCount, getNotebookDescendantIds, getNotebookDescendantMemoCount, getNotebookScopeIds } from "./notebooks.ts"; -const notebook = (id, parentId) => ({ +const notebook = (id, parentId, memoCount = 0) => ({ id, parentId, name: id, @@ -9,7 +9,7 @@ const notebook = (id, parentId) => ({ icon: null, color: null, sortOrder: 0, - memoCount: 0, + memoCount, lastMemoUpdatedAt: null, createdAt: "", updatedAt: "", @@ -35,3 +35,43 @@ describe("getNotebookDescendantIds", () => { expect(new Set(getNotebookDescendantIds(notebooks, "first"))).toEqual(new Set(["first", "second"])); }); }); + +describe("getNotebookScopeIds", () => { + const notebooks = [notebook("root", null), notebook("child", "root"), notebook("grandchild", "child")]; + + test("covers the whole subtree when descendant notes are shown", () => { + expect(new Set(getNotebookScopeIds(notebooks, "root", true))).toEqual(new Set(["root", "child", "grandchild"])); + }); + + test("covers only the notebook itself when descendant notes are hidden", () => { + expect(getNotebookScopeIds(notebooks, "root", false)).toEqual(["root"]); + }); +}); + +describe("formatNotebookMemoCount", () => { + test("keeps the aggregated total when descendant notes are shown", () => { + expect(formatNotebookMemoCount({ directCount: 2, totalCount: 7, hasChildren: true }, true)).toBe("7"); + }); + + test("shows direct/total for a parent when descendant notes are hidden", () => { + expect(formatNotebookMemoCount({ directCount: 2, totalCount: 7, hasChildren: true }, false)).toBe("2/7"); + }); + + test("shows a single number for a leaf when descendant notes are hidden", () => { + expect(formatNotebookMemoCount({ directCount: 2, totalCount: 2, hasChildren: false }, false)).toBe("2"); + }); +}); + +describe("getNotebookDescendantMemoCount", () => { + test("sums notes in sub-notebooks at every depth but not the notebook itself", () => { + const notebooks = [ + notebook("root", null, 2), + notebook("child", "root", 3), + notebook("grandchild", "child", 1), + notebook("other", null, 9), + ]; + + expect(getNotebookDescendantMemoCount(notebooks, "root")).toBe(4); + expect(getNotebookDescendantMemoCount(notebooks, "grandchild")).toBe(0); + }); +}); diff --git a/packages/shared/src/notebooks.ts b/packages/shared/src/notebooks.ts index e91a8c77..e935f2e3 100644 --- a/packages/shared/src/notebooks.ts +++ b/packages/shared/src/notebooks.ts @@ -31,3 +31,27 @@ export const getNotebookDescendantIds = (notebooks: Notebook[], targetNotebookId return descendantIds; }; + +// Notebooks whose memos a notebook view lists: the notebook alone, or its +// whole subtree when the account shows descendant notes. +export const getNotebookScopeIds = (notebooks: Notebook[], targetNotebookId: string, includeDescendants: boolean) => + includeDescendants ? getNotebookDescendantIds(notebooks, targetNotebookId) : [targetNotebookId]; + +// Notes stored in a notebook's sub-notebooks (not the notebook itself). Expects +// direct per-notebook counts, as the notebook list endpoints return them. +export const getNotebookDescendantMemoCount = (notebooks: Notebook[], targetNotebookId: string) => { + const memoCountById = new Map(notebooks.map((notebook) => [notebook.id, notebook.memoCount])); + return getNotebookDescendantIds(notebooks, targetNotebookId) + .filter((notebookId) => notebookId !== targetNotebookId) + .reduce((total, notebookId) => total + (memoCountById.get(notebookId) ?? 0), 0); +}; + +// A parent notebook shows "direct/total" once descendant notes are hidden, so +// the first number always matches what opening it lists. +export const formatNotebookMemoCount = ( + counts: { directCount: number; totalCount: number; hasChildren: boolean }, + showDescendantNotes: boolean, +) => { + if (showDescendantNotes) return String(counts.totalCount); + return counts.hasChildren ? `${counts.directCount}/${counts.totalCount}` : String(counts.directCount); +};