feat(notebooks): add toggle to hide notes from sub-notebooks

Refs #157
This commit is contained in:
LuoQianhong authored and GitHub committed 2026-10-02 01:08:04 +08:00
1 parent 17490040f7
commit a820b5e2b4
31 files changed
+575 -41

No files matched your search

+1
View File
@@ -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
@@ -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"
@@ -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] = []
@@ -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")
}
@@ -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.
}
}
@@ -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,
@@ -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)
@@ -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()
@@ -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");
+6
View File
@@ -19,6 +19,9 @@ type MobileLocaleContextValue = {
type TranslationPair = { source: string; target: string; pattern?: RegExp; placeholders?: string[] };
const mobileOnlyTranslations = new Map<string, string>([
["本级暂无笔记", "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<string, string>([
]);
const mobileOnlyJapanese = new Map<string, string>([
["本级暂无笔记", "このノートブック直下にノートはありません"],
["无法保存“显示子笔记本中的笔记”设置,请稍后重试", "「サブノートブックのノートを表示」の設定を保存できませんでした。しばらくしてから再試行してください。"],
["子笔记本中还有 {{count}} 条笔记。可以打开子笔记本查看,或在设置中开启“显示子笔记本中的笔记”。", "サブノートブックにはまだ {{count}} 件のノートがあります。サブノートブックを開くか、設定で「サブノートブックのノートを表示」をオンにしてください。"],
["返回", "戻る"],
["关闭对话框", "ダイアログを閉じる"],
["切换到深色模式", "ダークモードに切り替え"],
+46
View File
@@ -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: "" });
});
+25
View File
@@ -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<MobileLocalePreference> => {
const value = await AsyncStorage.getItem(LOCALE_PREFERENCE_KEY);
return isMobileLocalePreference(value) ? value : "system";
@@ -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<string>;
@@ -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}
+38 -13
View File
@@ -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<MemoSortMode>("updated-desc");
const [memoListDensity, setMemoListDensity] = useState<MobileMemoListDensity>("preview");
const [imageCompressionEnabled, setImageCompressionEnabled] = useState(true);
// Null until the stored value loads, so lists never flash the wrong notebook scope.
const [showDescendantNotes, setShowDescendantNotes] = useState<boolean | null>(null);
const [showDescendantNotesSaveFailed, setShowDescendantNotesSaveFailed] = useState(false);
const [selectedMemoId, setSelectedMemoId] = useState<string | null>(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<MobileMemoListCacheSnapshot> => {
const queryKeys: QueryKey[] = [
["mobile", "memos"],
@@ -1210,6 +1231,7 @@ export const WorkspaceScreen = ({
{activeView === "notes" ? (
<NotesView
activeNotebook={activeNotebook}
showDescendantNotes={showDescendantNotes ?? true}
initialSyncProgress={initialMirrorSyncProgress}
isLoading={notebooksQuery.isLoading || (searchActive ? searchQuery.isLoading : memosQuery.isLoading) || (isInitialMirrorStatusPending && visibleMemos.length === 0)}
isLoadingMore={searchActive ? searchQuery.isFetchingNextPage : memosQuery.isFetchingNextPage}
@@ -1266,6 +1288,9 @@ export const WorkspaceScreen = ({
onLocalePreferenceChange={handleLocalePreferenceChange}
imageCompressionEnabled={imageCompressionEnabled}
onImageCompressionChange={handleImageCompressionChange}
showDescendantNotes={showDescendantNotes ?? true}
showDescendantNotesSaveFailed={showDescendantNotesSaveFailed}
onShowDescendantNotesChange={(enabled) => void handleShowDescendantNotesChange(enabled)}
onSignOut={signOut}
/>
) : null}
@@ -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 = ({
</View>
</View>
</View>
<View style={styles.settingsContentRow}>
<View style={styles.preferenceStack}>
<View style={styles.preferenceText}>
<Text style={styles.settingsRowTitle}>显示子笔记本中的笔记</Text>
<Text style={styles.settingsRowDescription}>开启后,打开父笔记本会同时列出所有子笔记本中的笔记;关闭后只列出直接放在该笔记本中的笔记。</Text>
{showDescendantNotesSaveFailed ? <Text accessibilityRole="alert" style={styles.errorText}>无法保存“显示子笔记本中的笔记”设置,请稍后重试</Text> : null}
</View>
<View style={styles.settingsSwitchStart}>
<Switch accessibilityLabel={translate("是否在父笔记本中显示子笔记本中的笔记")} onValueChange={onShowDescendantNotesChange} value={showDescendantNotes} />
</View>
</View>
</View>
</SettingsGroup>
</View>
);
@@ -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);
});
});
@@ -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];
+3
View File
@@ -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}
/>
))}
</div>
+9 -5
View File
@@ -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)}
</span>
</button>
<div ref={actionsRef} className="relative shrink-0">
@@ -399,6 +402,7 @@ export const NotebookTreeItem = ({
onOpenChange={onOpenChange}
expandSiblingsRequest={expandSiblingsRequest}
onExpandSiblings={onExpandSiblings}
showDescendantNotes={showDescendantNotes}
/>
))}
</m.div>
+13 -7
View File
@@ -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,
@@ -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 = ({
</div>
</div>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-start gap-3">
<ListTree className={SETTINGS_ITEM_ICON_CLASSNAME} />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.showDescendantNotesTitle")}</div>
<div className={SETTINGS_ITEM_DESCRIPTION_CLASSNAME}>{t("settings.showDescendantNotesDescription")}</div>
</div>
</div>
<div className="flex w-full shrink-0 justify-start sm:w-44 sm:justify-end">
<Switch
checked={showDescendantNotes}
onCheckedChange={writeShowDescendantNotesPreference}
aria-label={t("settings.showDescendantNotesAria")}
/>
</div>
</div>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<AppWindow className="h-4 w-4 shrink-0 text-slate-500" />
@@ -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);
});
});
@@ -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);
+2 -1
View File
@@ -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,
+12
View File
@@ -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(
[
+3 -1
View File
@@ -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<string, NotebookNode>();
for (const notebook of notebooks) {
nodes.set(notebook.id, { ...notebook, children: [] });
nodes.set(notebook.id, { ...notebook, directMemoCount: notebook.memoCount, children: [] });
}
const roots: NotebookNode[] = [];
+5
View File
@@ -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",
+5
View File
@@ -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 を開く",
+5
View File
@@ -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",
+43 -3
View File
@@ -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);
});
});
+24
View File
@@ -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);
};