knowledge-tree.tsx 63 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639
  1. import { useCallback, useEffect, useMemo, useRef, useState } from "react"
  2. import { BookOpen, ChevronDown, ChevronRight, FileText, Folder, FolderInput, FolderOpen, Globe, Loader2, MessageCircle, Pencil, Plus, Trash2, Check, X } from "lucide-react"
  3. import { useTranslation } from "react-i18next"
  4. import { ScrollArea } from "@/components/ui/scroll-area"
  5. import { Button } from "@/components/ui/button"
  6. import { useWikiStore } from "@/stores/wiki-store"
  7. import { deleteFile, fileExists, listDirectory, readFile, writeFile, openFileLocation, copyFile } from "@/commands/fs"
  8. import type { FileNode } from "@/types/wiki"
  9. import { buildChapterWordCountLabel, getChapterStatusLabel } from "@/lib/chapter-display"
  10. import { normalizePath } from "@/lib/path-utils"
  11. import { countChapterBodyWords } from "@/lib/chapter-word-count"
  12. import { normalizeChapterStatus, type ChapterStatus } from "@/lib/novel/chapter-meta"
  13. import { moveFileToTrash } from "@/lib/trash"
  14. import { makeChapterFileName, makeDefaultChapterTitle, makeSafeFileSlug } from "@/lib/wiki-filename"
  15. import { useImportProgressStore } from "@/stores/import-progress-store"
  16. import { saveLastReadChapter } from "@/lib/project-store"
  17. import type { ReferenceToken } from "@/lib/reference/types"
  18. interface WikiPageInfo {
  19. path: string
  20. title: string
  21. type: "chapter" | "outline"
  22. chapterNumber?: number
  23. tags: string[]
  24. origin?: string
  25. status?: ChapterStatus
  26. statusLabel?: string
  27. wordCount?: number
  28. wordCountLabel?: string
  29. }
  30. export interface KnowledgeCreateRequest {
  31. kind: "chapter" | "outline" | "volume" | "folder"
  32. parentDir?: string
  33. }
  34. interface KnowledgeTreeProps {
  35. filterType: "chapter" | "outline"
  36. refreshKey?: number
  37. pendingPages?: WikiPageInfo[]
  38. onRemovePendingPage?: (pagePath: string) => void
  39. onRequestCreate?: (request: KnowledgeCreateRequest) => void
  40. onSendToChat?: (token: ReferenceToken) => void
  41. onSendToOutline?: (token: ReferenceToken) => void
  42. }
  43. interface CreateMenuState {
  44. x: number
  45. y: number
  46. parentDir?: string
  47. targetFolderName?: string
  48. targetFolderPath?: string
  49. }
  50. function parseChineseNumber(input: string): number | null {
  51. const digitMap: Record<string, number> = {
  52. 零: 0,
  53. 一: 1,
  54. 二: 2,
  55. 两: 2,
  56. 三: 3,
  57. 四: 4,
  58. 五: 5,
  59. 六: 6,
  60. 七: 7,
  61. 八: 8,
  62. 九: 9,
  63. }
  64. if (/^\d+$/.test(input)) return Number.parseInt(input, 10)
  65. if (input === "十") return 10
  66. const tenIndex = input.indexOf("十")
  67. if (tenIndex >= 0) {
  68. const left = input.slice(0, tenIndex)
  69. const right = input.slice(tenIndex + 1)
  70. const tens = left ? (digitMap[left] ?? 0) : 1
  71. const units = right ? (digitMap[right] ?? 0) : 0
  72. return tens * 10 + units
  73. }
  74. let value = 0
  75. for (const ch of input) {
  76. const digit = digitMap[ch]
  77. if (digit === undefined) return null
  78. value = value * 10 + digit
  79. }
  80. return value
  81. }
  82. function extractPageOrderFromTitle(title: string): number | null {
  83. const titleMatch = title.match(/第\s*([0-9零一二两三四五六七八九十]+)\s*[章节卷]/)
  84. if (titleMatch?.[1]) {
  85. const value = parseChineseNumber(titleMatch[1])
  86. if (value !== null) return value
  87. }
  88. const numberMatch = title.match(/(\d+)/)
  89. if (numberMatch?.[1]) return Number.parseInt(numberMatch[1], 10)
  90. return null
  91. }
  92. function extractChapterNumberFromContent(content: string): number | null {
  93. const match = content.match(/^chapter_number:\s*(\d+)\s*$/m)
  94. if (!match?.[1]) return null
  95. const parsed = Number.parseInt(match[1], 10)
  96. return Number.isFinite(parsed) && parsed > 0 ? parsed : null
  97. }
  98. function getDirName(path: string): string {
  99. const normalized = normalizePath(path)
  100. const index = normalized.lastIndexOf("/")
  101. return index >= 0 ? normalized.slice(0, index) : ""
  102. }
  103. function truncateReferenceTitle(title: string, maxLen = 20): string {
  104. return title.length > maxLen ? `${title.slice(0, maxLen)}...` : title
  105. }
  106. function createChapterReferenceToken(page: WikiPageInfo): ReferenceToken {
  107. return {
  108. id: globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2),
  109. category: "chapter",
  110. title: page.title,
  111. path: page.path,
  112. displayTitle: truncateReferenceTitle(page.title),
  113. }
  114. }
  115. async function getUniquePagePath(dir: string, fileName: string, excludePath?: string): Promise<string> {
  116. const firstPath = `${dir}/${fileName}`
  117. if (firstPath === excludePath || !(await fileExists(firstPath))) return firstPath
  118. const extensionIndex = fileName.lastIndexOf(".")
  119. const stem = extensionIndex > 0 ? fileName.slice(0, extensionIndex) : fileName
  120. const extension = extensionIndex > 0 ? fileName.slice(extensionIndex) : ""
  121. for (let index = 2; index <= 99; index += 1) {
  122. const candidate = `${dir}/${stem}-${index}${extension}`
  123. if (candidate === excludePath || !(await fileExists(candidate))) return candidate
  124. }
  125. return `${dir}/${stem}-${Date.now()}${extension}`
  126. }
  127. function flattenMdFiles(nodes: FileNode[]): FileNode[] {
  128. const files: FileNode[] = []
  129. for (const node of nodes) {
  130. if (node.is_dir && node.children) {
  131. files.push(...flattenMdFiles(node.children))
  132. continue
  133. }
  134. if (!node.is_dir && node.name.endsWith(".md")) {
  135. files.push(node)
  136. }
  137. }
  138. return files
  139. }
  140. function flattenAllFiles(nodes: FileNode[]): FileNode[] {
  141. const files: FileNode[] = []
  142. for (const node of nodes) {
  143. if (node.is_dir && node.children) {
  144. files.push(...flattenAllFiles(node.children))
  145. continue
  146. }
  147. if (!node.is_dir) files.push(node)
  148. }
  149. return files
  150. }
  151. async function cleanupDeletedSourceMemory(
  152. projectPath: string,
  153. input: { kind: "chapter" | "outline"; pagePath: string; content?: string },
  154. ): Promise<void> {
  155. const { deleteNovelSourceMemory } = await import("@/lib/novel/delete-source-memory")
  156. await deleteNovelSourceMemory(projectPath, input)
  157. }
  158. function parsePageInfo(path: string, fileName: string, content: string): WikiPageInfo | null {
  159. const normalizedPath = normalizePath(path)
  160. const type = normalizedPath.includes("/wiki/chapters/") ? "chapter" : normalizedPath.includes("/wiki/outlines/") ? "outline" : null
  161. if (!type) return null
  162. let title = fileName.replace(/\.md$/, "").replace(/-/g, " ")
  163. const tags: string[] = []
  164. let origin: string | undefined
  165. let status: ChapterStatus | undefined
  166. const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/)
  167. if (frontmatterMatch) {
  168. const frontmatter = frontmatterMatch[1]
  169. const titleMatch = frontmatter.match(/^title:\s*["']?(.+?)["']?\s*$/m)
  170. if (titleMatch?.[1]) title = titleMatch[1].trim()
  171. const tagsMatch = frontmatter.match(/^tags:\s*\[(.+?)\]/m)
  172. if (tagsMatch?.[1]) {
  173. tags.push(...tagsMatch[1].split(",").map((value) => value.trim().replace(/["']/g, "")))
  174. }
  175. const originMatch = frontmatter.match(/^origin:\s*(.+)$/m)
  176. if (originMatch?.[1]) origin = originMatch[1].trim()
  177. if (type === "chapter") {
  178. const statusMatch = frontmatter.match(/^chapter_status:\s*["']?(.+?)["']?\s*$/m)
  179. status = normalizeChapterStatus(statusMatch?.[1])
  180. }
  181. }
  182. if (title === fileName.replace(/\.md$/, "").replace(/-/g, " ")) {
  183. const headingMatch = content.match(/^#\s+(.+)$/m)
  184. if (headingMatch?.[1]) title = headingMatch[1].trim()
  185. }
  186. const wordCount = type === "chapter" ? countChapterBodyWords(content) : undefined
  187. const chapterNumber = type === "chapter"
  188. ? (extractChapterNumberFromContent(content) ?? extractPageOrderFromTitle(title) ?? undefined)
  189. : undefined
  190. return {
  191. path: normalizedPath,
  192. title,
  193. type,
  194. chapterNumber,
  195. tags,
  196. origin,
  197. status,
  198. statusLabel: status ? getChapterStatusLabel(status) : undefined,
  199. wordCount,
  200. wordCountLabel: typeof wordCount === "number" ? buildChapterWordCountLabel(wordCount) : undefined,
  201. }
  202. }
  203. function findNodeByPath(nodes: FileNode[], targetPath: string): FileNode | null {
  204. for (const node of nodes) {
  205. if (normalizePath(node.path) === targetPath) return node
  206. if (node.is_dir && node.children) {
  207. const childMatch = findNodeByPath(node.children, targetPath)
  208. if (childMatch) return childMatch
  209. }
  210. }
  211. return null
  212. }
  213. function sortFileNodes(
  214. nodes: readonly FileNode[],
  215. pageInfoByPath: Map<string, WikiPageInfo>,
  216. filterType: "chapter" | "outline",
  217. ): FileNode[] {
  218. return [...nodes].sort((left, right) => {
  219. if (left.is_dir && !right.is_dir) return -1
  220. if (!left.is_dir && right.is_dir) return 1
  221. if (left.is_dir && right.is_dir) return left.name.localeCompare(right.name, "zh-CN")
  222. const leftInfo = pageInfoByPath.get(normalizePath(left.path))
  223. const rightInfo = pageInfoByPath.get(normalizePath(right.path))
  224. if (filterType === "chapter" || filterType === "outline") {
  225. const leftOrder = leftInfo?.chapterNumber ?? extractPageOrderFromTitle(leftInfo?.title ?? left.name)
  226. const rightOrder = rightInfo?.chapterNumber ?? extractPageOrderFromTitle(rightInfo?.title ?? right.name)
  227. if (leftOrder !== null && rightOrder !== null && leftOrder !== rightOrder) {
  228. return leftOrder - rightOrder
  229. }
  230. if (leftOrder !== null && rightOrder === null) return -1
  231. if (leftOrder === null && rightOrder !== null) return 1
  232. }
  233. return (leftInfo?.title ?? left.name).localeCompare(rightInfo?.title ?? right.name, "zh-CN")
  234. })
  235. }
  236. function countMarkdownDescendants(node: FileNode): number {
  237. if (!node.is_dir) return node.name.endsWith(".md") ? 1 : 0
  238. return (node.children ?? []).reduce((total, child) => total + countMarkdownDescendants(child), 0)
  239. }
  240. export function KnowledgeTree({
  241. filterType,
  242. refreshKey,
  243. pendingPages = [],
  244. onRemovePendingPage,
  245. onRequestCreate,
  246. onSendToChat,
  247. onSendToOutline,
  248. }: KnowledgeTreeProps) {
  249. const { t } = useTranslation()
  250. const project = useWikiStore((s) => s.project)
  251. const selectedFile = useWikiStore((s) => s.selectedFile)
  252. const setSelectedFile = useWikiStore((s) => s.setSelectedFile)
  253. const fileTree = useWikiStore((s) => s.fileTree)
  254. const setFileTree = useWikiStore((s) => s.setFileTree)
  255. const bumpDataVersion = useWikiStore((s) => s.bumpDataVersion)
  256. const dataVersion = useWikiStore((s) => s.dataVersion)
  257. const [pages, setPages] = useState<WikiPageInfo[]>([])
  258. const [collapsedFolders, setCollapsedFolders] = useState<Record<string, boolean>>({})
  259. const [armedPath, setArmedPath] = useState<string | null>(null)
  260. const [deletingPath, setDeletingPath] = useState<string | null>(null)
  261. const [createMenu, setCreateMenu] = useState<CreateMenuState | null>(null)
  262. const [pageMenu, setPageMenu] = useState<{ path: string; x: number; y: number } | null>(null)
  263. const [renamingPath, setRenamingPath] = useState<string | null>(null)
  264. const [renameValue, setRenameValue] = useState("")
  265. const [renamingBusy, setRenamingBusy] = useState(false)
  266. const [moveMenuTarget, setMoveMenuTarget] = useState<string | null>(null)
  267. const [chapterBatchMode, setChapterBatchMode] = useState(false)
  268. const [selectedChapterPaths, setSelectedChapterPaths] = useState<Set<string>>(() => new Set())
  269. const [dragSource, setDragSource] = useState<string | null>(null)
  270. const [dragInsertIndex, setDragInsertIndex] = useState<number | null>(null)
  271. const [isDragging, setIsDragging] = useState(false)
  272. const dragSourceRef = useRef<string | null>(null)
  273. const dragInsertIndexRef = useRef<number | null>(null)
  274. const dragTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
  275. const activePointerIdRef = useRef<number | null>(null)
  276. const pendingPointerPositionRef = useRef<{ x: number; y: number } | null>(null)
  277. const lastPointerTypeRef = useRef<string>("mouse")
  278. const removeGlobalPointerListenersRef = useRef<(() => void) | null>(null)
  279. const containerRef = useRef<HTMLDivElement>(null)
  280. useEffect(() => { dragSourceRef.current = dragSource }, [dragSource])
  281. useEffect(() => { dragInsertIndexRef.current = dragInsertIndex }, [dragInsertIndex])
  282. const loadPages = useCallback(async () => {
  283. if (!project) return
  284. const projectPath = normalizePath(project.path)
  285. try {
  286. const wikiTree = await listDirectory(`${projectPath}/wiki`)
  287. const nextPages: WikiPageInfo[] = []
  288. for (const file of flattenMdFiles(wikiTree)) {
  289. if (file.name === "index.md" || file.name === "log.md") continue
  290. try {
  291. const content = await readFile(file.path)
  292. const info = parsePageInfo(file.path, file.name, content)
  293. if (info) nextPages.push(info)
  294. } catch {
  295. // Ignore unreadable pages in the navigator.
  296. }
  297. }
  298. setPages(nextPages)
  299. } catch (error) {
  300. console.error("[KnowledgeTree] loadPages failed:", error)
  301. setPages([])
  302. }
  303. }, [project])
  304. useEffect(() => {
  305. void loadPages()
  306. }, [loadPages, fileTree, dataVersion, refreshKey])
  307. useEffect(() => {
  308. if (!armedPath) return
  309. const handlePointerDown = (event: MouseEvent) => {
  310. const target = event.target
  311. if (!(target instanceof Node)) return
  312. if (containerRef.current?.contains(target)) return
  313. setArmedPath(null)
  314. }
  315. const handleEscape = (event: KeyboardEvent) => {
  316. if (event.key === "Escape") setArmedPath(null)
  317. }
  318. document.addEventListener("mousedown", handlePointerDown)
  319. document.addEventListener("keydown", handleEscape)
  320. return () => {
  321. document.removeEventListener("mousedown", handlePointerDown)
  322. document.removeEventListener("keydown", handleEscape)
  323. }
  324. }, [armedPath])
  325. useEffect(() => {
  326. const closeMenus = () => {
  327. setCreateMenu(null)
  328. setPageMenu(null)
  329. }
  330. document.addEventListener("mousedown", closeMenus)
  331. document.addEventListener("keydown", closeMenus)
  332. return () => {
  333. document.removeEventListener("mousedown", closeMenus)
  334. document.removeEventListener("keydown", closeMenus)
  335. }
  336. }, [])
  337. const pageInfoByPath = useMemo(() => {
  338. const map = new Map<string, WikiPageInfo>()
  339. for (const page of pages) {
  340. if (page.type === filterType) map.set(page.path, page)
  341. }
  342. for (const page of pendingPages) {
  343. if (page.type === filterType) map.set(normalizePath(page.path), page)
  344. }
  345. return map
  346. }, [filterType, pages, pendingPages])
  347. const effectivePages = useMemo(() => [...pageInfoByPath.values()], [pageInfoByPath])
  348. const sortedChapterPages = useMemo(() => {
  349. return effectivePages
  350. .filter((page): page is WikiPageInfo & { type: "chapter" } => page.type === "chapter")
  351. .sort((left, right) => {
  352. const leftOrder = left.chapterNumber ?? extractPageOrderFromTitle(left.title)
  353. const rightOrder = right.chapterNumber ?? extractPageOrderFromTitle(right.title)
  354. if (leftOrder !== null && rightOrder !== null && leftOrder !== rightOrder) return leftOrder - rightOrder
  355. if (leftOrder !== null && rightOrder === null) return -1
  356. if (leftOrder === null && rightOrder !== null) return 1
  357. return left.title.localeCompare(right.title, "zh-CN")
  358. })
  359. }, [effectivePages])
  360. const selectableChapterPaths = useMemo(() => sortedChapterPages.map((page) => page.path), [sortedChapterPages])
  361. const selectedChapterCount = selectedChapterPaths.size
  362. const allChaptersSelected = selectableChapterPaths.length > 0
  363. && selectableChapterPaths.every((path) => selectedChapterPaths.has(path))
  364. useEffect(() => {
  365. if (filterType !== "chapter") {
  366. setChapterBatchMode(false)
  367. setSelectedChapterPaths((previous) => previous.size > 0 ? new Set() : previous)
  368. return
  369. }
  370. const validPaths = new Set(selectableChapterPaths)
  371. setSelectedChapterPaths((previous) => {
  372. const next = new Set<string>()
  373. for (const path of previous) {
  374. if (validPaths.has(path)) next.add(path)
  375. }
  376. return next.size === previous.size ? previous : next
  377. })
  378. }, [filterType, selectableChapterPaths])
  379. const sectionRootPath = useMemo(() => {
  380. if (!project) return null
  381. return `${normalizePath(project.path)}/wiki/${filterType === "chapter" ? "chapters" : "outlines"}`
  382. }, [filterType, project])
  383. const sectionNodes = useMemo(() => {
  384. if (!sectionRootPath) return []
  385. const sectionNode = findNodeByPath(fileTree, sectionRootPath)
  386. return sectionNode?.children ?? []
  387. }, [fileTree, sectionRootPath])
  388. const volumeFolders = useMemo(() => {
  389. return sectionNodes.filter((node) => node.is_dir).map((node) => ({
  390. name: node.name,
  391. path: normalizePath(node.path),
  392. }))
  393. }, [sectionNodes])
  394. const handleMoveChaptersToVolume = useCallback(async (sourcePaths: string[], targetVolumePath: string) => {
  395. if (!project) return
  396. const normalizedTargetVolumePath = normalizePath(targetVolumePath)
  397. const normalizedSources = Array.from(new Set(sourcePaths.map((sourcePath) => normalizePath(sourcePath))))
  398. const plans: Array<{ source: string; dest: string }> = []
  399. const plannedDestinations = new Set<string>()
  400. for (const normalizedSource of normalizedSources) {
  401. if (getDirName(normalizedSource) === normalizedTargetVolumePath) continue
  402. const fileName = normalizedSource.split("/").pop()
  403. if (!fileName) continue
  404. const destPath = `${normalizedTargetVolumePath}/${fileName}`
  405. if (normalizedSource === destPath) continue
  406. if (plannedDestinations.has(destPath) || await fileExists(destPath)) {
  407. window.alert(t("knowledgeTree.moveTargetExists"))
  408. return
  409. }
  410. plannedDestinations.add(destPath)
  411. plans.push({ source: normalizedSource, dest: destPath })
  412. }
  413. if (plans.length === 0) {
  414. setMoveMenuTarget(null)
  415. setPageMenu(null)
  416. return
  417. }
  418. try {
  419. for (const plan of plans) {
  420. await copyFile(plan.source, plan.dest)
  421. await deleteFile(plan.source)
  422. }
  423. await loadPages()
  424. const tree = await listDirectory(normalizePath(project.path))
  425. setFileTree(tree)
  426. bumpDataVersion()
  427. setSelectedChapterPaths(new Set())
  428. setChapterBatchMode(false)
  429. const selectedMove = selectedFile ? plans.find((plan) => plan.source === normalizePath(selectedFile)) : undefined
  430. if (selectedMove) {
  431. setSelectedFile(selectedMove.dest)
  432. }
  433. } catch (error) {
  434. console.error("[KnowledgeTree] move to volume failed:", error)
  435. } finally {
  436. setMoveMenuTarget(null)
  437. setPageMenu(null)
  438. }
  439. }, [project, t, loadPages, setFileTree, bumpDataVersion, selectedFile, setSelectedFile])
  440. const toggleChapterSelection = useCallback((pagePath: string) => {
  441. const normalizedPath = normalizePath(pagePath)
  442. setSelectedChapterPaths((previous) => {
  443. const next = new Set(previous)
  444. if (next.has(normalizedPath)) next.delete(normalizedPath)
  445. else next.add(normalizedPath)
  446. return next
  447. })
  448. }, [])
  449. const handleToggleAllChapters = useCallback(() => {
  450. setSelectedChapterPaths((previous) => {
  451. if (selectableChapterPaths.length > 0 && selectableChapterPaths.every((path) => previous.has(path))) {
  452. return new Set()
  453. }
  454. return new Set(selectableChapterPaths)
  455. })
  456. }, [selectableChapterPaths])
  457. const exitChapterBatchMode = useCallback(() => {
  458. setChapterBatchMode(false)
  459. setSelectedChapterPaths(new Set())
  460. }, [])
  461. const handleDeleteClick = useCallback(async (pagePath: string) => {
  462. if (!project) return
  463. if (armedPath !== pagePath) {
  464. setArmedPath(pagePath)
  465. return
  466. }
  467. setArmedPath(null)
  468. setDeletingPath(pagePath)
  469. try {
  470. const projectPath = normalizePath(project.path)
  471. let sourceContent: string | undefined
  472. if (filterType === "chapter") {
  473. try {
  474. sourceContent = await readFile(pagePath)
  475. } catch { /* ignore */ }
  476. }
  477. await moveFileToTrash(projectPath, pagePath, filterType)
  478. try {
  479. await cleanupDeletedSourceMemory(projectPath, {
  480. kind: filterType,
  481. pagePath,
  482. content: sourceContent,
  483. })
  484. } catch (e) {
  485. // 记忆清理失败不影响文件删除本身
  486. console.warn("[KnowledgeTree] 清理关联记忆失败:", e)
  487. }
  488. await loadPages()
  489. onRemovePendingPage?.(pagePath)
  490. const tree = await listDirectory(projectPath)
  491. setFileTree(tree)
  492. bumpDataVersion()
  493. if (selectedFile === pagePath) setSelectedFile(null)
  494. } catch (error) {
  495. console.error("[KnowledgeTree] delete failed:", error)
  496. } finally {
  497. setDeletingPath(null)
  498. }
  499. }, [project, armedPath, filterType, loadPages, onRemovePendingPage, setFileTree, bumpDataVersion, selectedFile, setSelectedFile])
  500. const handleDeleteFolder = useCallback(async (folderPath: string) => {
  501. if (!project) return
  502. const normalizedFolderPath = normalizePath(folderPath)
  503. const folderNode = findNodeByPath(sectionNodes, normalizedFolderPath)
  504. if (!folderNode?.is_dir) return
  505. const mdFiles = flattenMdFiles([folderNode]).map((file) => normalizePath(file.path))
  506. const folderName = folderNode.name
  507. const fileKind = filterType === "chapter" ? "chapter" : "outline"
  508. const confirmed = window.confirm(
  509. mdFiles.length > 0
  510. ? t("knowledgeTree.deleteFolderConfirm", { name: folderName, count: mdFiles.length })
  511. : t("knowledgeTree.deleteEmptyFolderConfirm", { name: folderName }),
  512. )
  513. if (!confirmed) return
  514. setArmedPath(null)
  515. setDeletingPath(normalizedFolderPath)
  516. try {
  517. const projectPath = normalizePath(project.path)
  518. for (const filePath of mdFiles) {
  519. try {
  520. await moveFileToTrash(projectPath, filePath, fileKind)
  521. await cleanupDeletedSourceMemory(projectPath, {
  522. kind: fileKind,
  523. pagePath: filePath,
  524. })
  525. } catch (e) {
  526. // 单个文件删除失败不影响整体流程(如文件已不存在的幽灵条目)
  527. console.warn("[KnowledgeTree] 删除文件失败,继续处理下一个:", filePath, e)
  528. }
  529. onRemovePendingPage?.(filePath)
  530. }
  531. // 尝试删除文件夹:如果目录不存在或已空,直接删除;
  532. // 这样能清理掉那些文件已丢失、只剩空壳目录的幽灵条目
  533. try {
  534. const remainingNodes = await listDirectory(normalizedFolderPath).catch(() => [])
  535. if (flattenAllFiles(remainingNodes).length === 0) {
  536. await deleteFile(normalizedFolderPath)
  537. } else {
  538. window.alert(t("knowledgeTree.deleteFolderBlocked", { name: folderName }))
  539. }
  540. } catch {
  541. // 目录可能已经不存在了,直接视为删除成功
  542. }
  543. await loadPages()
  544. const tree = await listDirectory(projectPath)
  545. setFileTree(tree)
  546. bumpDataVersion()
  547. if (selectedFile?.startsWith(`${normalizedFolderPath}/`)) {
  548. setSelectedFile(null)
  549. }
  550. } catch (error) {
  551. console.error("[KnowledgeTree] folder delete failed:", error)
  552. } finally {
  553. setDeletingPath(null)
  554. }
  555. }, [project, filterType, sectionNodes, t, loadPages, setFileTree, bumpDataVersion, selectedFile, setSelectedFile, onRemovePendingPage])
  556. const updatePageTitleContent = useCallback((content: string, newTitle: string, newChapterNumber?: number | null) => {
  557. const escapedTitle = newTitle.replace(/"/g, '\\"')
  558. let next = content
  559. const frontmatterMatch = next.match(/^---\n([\s\S]*?)\n---/)
  560. if (frontmatterMatch) {
  561. let frontmatterBody = frontmatterMatch[1]
  562. if (/^title:\s*.*$/m.test(frontmatterBody)) {
  563. frontmatterBody = frontmatterBody.replace(/^title:\s*.*$/m, `title: "${escapedTitle}"`)
  564. } else {
  565. frontmatterBody = `${frontmatterBody}\ntitle: "${escapedTitle}"`
  566. }
  567. if (typeof newChapterNumber === "number" && newChapterNumber > 0) {
  568. if (/^chapter_number:\s*.*$/m.test(frontmatterBody)) {
  569. frontmatterBody = frontmatterBody.replace(/^chapter_number:\s*.*$/m, `chapter_number: ${newChapterNumber}`)
  570. } else {
  571. frontmatterBody = `${frontmatterBody}\nchapter_number: ${newChapterNumber}`
  572. }
  573. }
  574. next = next.replace(/^---\n[\s\S]*?\n---/, `---\n${frontmatterBody}\n---`)
  575. }
  576. if (/^#\s+.+$/m.test(next)) {
  577. next = next.replace(/^#\s+.+$/m, `# ${newTitle}`)
  578. }
  579. return next
  580. }, [])
  581. const updateChapterNumberContent = useCallback((content: string, newChapterNumber: number) => {
  582. if (!Number.isFinite(newChapterNumber) || newChapterNumber <= 0) return content
  583. const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/)
  584. if (!frontmatterMatch) {
  585. return `---\nchapter_number: ${newChapterNumber}\n---\n${content}`
  586. }
  587. let frontmatterBody = frontmatterMatch[1]
  588. if (/^chapter_number:\s*.*$/m.test(frontmatterBody)) {
  589. frontmatterBody = frontmatterBody.replace(/^chapter_number:\s*.*$/m, `chapter_number: ${newChapterNumber}`)
  590. } else {
  591. frontmatterBody = `${frontmatterBody}\nchapter_number: ${newChapterNumber}`
  592. }
  593. return content.replace(/^---\n[\s\S]*?\n---/, `---\n${frontmatterBody}\n---`)
  594. }, [])
  595. const startRenamePage = useCallback((page: WikiPageInfo) => {
  596. setPageMenu(null)
  597. setIsDragging(false)
  598. setDragSource(null)
  599. setDragInsertIndex(null)
  600. dragSourceRef.current = null
  601. dragInsertIndexRef.current = null
  602. if (dragTimerRef.current) {
  603. clearTimeout(dragTimerRef.current)
  604. dragTimerRef.current = null
  605. }
  606. removeGlobalPointerListenersRef.current?.()
  607. removeGlobalPointerListenersRef.current = null
  608. activePointerIdRef.current = null
  609. pendingPointerPositionRef.current = null
  610. setRenamingPath(page.path)
  611. setRenameValue(page.title)
  612. }, [])
  613. const submitRenamePage = useCallback(async () => {
  614. if (!renamingPath || renamingBusy) return
  615. const newTitle = renameValue.trim()
  616. if (!newTitle) {
  617. setRenamingPath(null)
  618. setRenameValue("")
  619. return
  620. }
  621. const current = effectivePages.find((page) => page.path === renamingPath)
  622. if (!current || current.title === newTitle) {
  623. setRenamingPath(null)
  624. setRenameValue("")
  625. return
  626. }
  627. setRenamingBusy(true)
  628. try {
  629. const duplicate = effectivePages.some((page) => page.path !== renamingPath && page.type === current.type && page.title.trim() === newTitle)
  630. if (duplicate) return
  631. const content = await readFile(renamingPath)
  632. const oldChapterNumber = extractChapterNumberFromContent(content) ?? extractPageOrderFromTitle(current.title)
  633. const userChapterNumber = extractPageOrderFromTitle(newTitle)
  634. const chapterNumber = (current.type === "chapter" && userChapterNumber !== null) ? userChapterNumber : oldChapterNumber
  635. const chapterNumberConflict = current.type === "chapter"
  636. && typeof chapterNumber === "number"
  637. && effectivePages.some((page) => (
  638. page.path !== renamingPath
  639. && page.type === "chapter"
  640. && page.chapterNumber === chapterNumber
  641. ))
  642. if (chapterNumberConflict) return
  643. const normalizedTitle = current.type === "chapter" && chapterNumber
  644. ? makeDefaultChapterTitle(chapterNumber, newTitle)
  645. : newTitle
  646. const nextContent = updatePageTitleContent(content, normalizedTitle, current.type === "chapter" ? chapterNumber : null)
  647. const targetFileName = current.type === "chapter"
  648. ? makeChapterFileName(normalizedTitle, chapterNumber)
  649. : `${makeSafeFileSlug(newTitle)}.md`
  650. const targetPath = await getUniquePagePath(getDirName(renamingPath), targetFileName, renamingPath)
  651. await writeFile(targetPath, nextContent)
  652. if (targetPath !== renamingPath) {
  653. await deleteFile(renamingPath)
  654. if (selectedFile === renamingPath) setSelectedFile(targetPath)
  655. onRemovePendingPage?.(renamingPath)
  656. }
  657. await loadPages()
  658. if (project) {
  659. const tree = await listDirectory(normalizePath(project.path))
  660. setFileTree(tree)
  661. }
  662. bumpDataVersion()
  663. } catch (error) {
  664. console.error("[KnowledgeTree] rename failed:", error)
  665. } finally {
  666. setRenamingBusy(false)
  667. setRenamingPath(null)
  668. setRenameValue("")
  669. }
  670. }, [renamingPath, renamingBusy, renameValue, effectivePages, updatePageTitleContent, selectedFile, setSelectedFile, onRemovePendingPage, loadPages, project, setFileTree, bumpDataVersion])
  671. const cancelRenamePage = useCallback(() => {
  672. if (renamingBusy) return
  673. setRenamingPath(null)
  674. setRenameValue("")
  675. }, [renamingBusy])
  676. const executeChapterReorder = useCallback(async (sourcePath: string, targetIndex: number) => {
  677. if (!project) return
  678. const sourceIndex = sortedChapterPages.findIndex((page) => page.path === sourcePath)
  679. if (sourceIndex < 0) return
  680. const effectiveTarget = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex
  681. if (sourceIndex === effectiveTarget) return
  682. const reordered = [...sortedChapterPages]
  683. const [moved] = reordered.splice(sourceIndex, 1)
  684. reordered.splice(effectiveTarget, 0, moved)
  685. try {
  686. const writes: { path: string; content: string; nextNumber: number }[] = []
  687. for (let i = 0; i < reordered.length; i += 1) {
  688. const page = reordered[i]
  689. const nextNumber = i + 1
  690. const currentNumber = page.chapterNumber ?? extractPageOrderFromTitle(page.title)
  691. if (currentNumber === nextNumber) continue
  692. const content = await readFile(page.path)
  693. writes.push({ path: page.path, content, nextNumber })
  694. }
  695. if (writes.length === 0) return
  696. const appliedWrites: { path: string; content: string }[] = []
  697. try {
  698. for (const item of writes) {
  699. await writeFile(item.path, updateChapterNumberContent(item.content, item.nextNumber))
  700. appliedWrites.push({ path: item.path, content: item.content })
  701. }
  702. } catch (error) {
  703. for (const item of appliedWrites.reverse()) {
  704. try {
  705. await writeFile(item.path, item.content)
  706. } catch {
  707. // Ignore rollback failures and preserve the original error.
  708. }
  709. }
  710. throw error
  711. }
  712. await loadPages()
  713. const tree = await listDirectory(normalizePath(project.path))
  714. setFileTree(tree)
  715. bumpDataVersion()
  716. } catch (error) {
  717. console.error("[KnowledgeTree] chapter reorder failed:", error)
  718. }
  719. }, [project, sortedChapterPages, updateChapterNumberContent, loadPages, setFileTree, bumpDataVersion])
  720. const finishDragInteraction = useCallback(() => {
  721. if (dragTimerRef.current) {
  722. clearTimeout(dragTimerRef.current)
  723. dragTimerRef.current = null
  724. }
  725. const sourcePath = dragSourceRef.current
  726. const targetIndex = dragInsertIndexRef.current
  727. removeGlobalPointerListenersRef.current?.()
  728. removeGlobalPointerListenersRef.current = null
  729. activePointerIdRef.current = null
  730. pendingPointerPositionRef.current = null
  731. dragSourceRef.current = null
  732. dragInsertIndexRef.current = null
  733. setIsDragging(false)
  734. setDragSource(null)
  735. setDragInsertIndex(null)
  736. if (sourcePath && targetIndex !== null) {
  737. void executeChapterReorder(sourcePath, targetIndex)
  738. }
  739. }, [executeChapterReorder])
  740. const updateDragInsertFromPoint = useCallback((clientX: number, clientY: number) => {
  741. const target = document.elementFromPoint(clientX, clientY)
  742. const row = target instanceof HTMLElement ? target.closest<HTMLElement>("[data-page-path]") : null
  743. if (!row) return
  744. const pagePath = row.dataset.pagePath
  745. if (!pagePath) return
  746. const rowIndex = sortedChapterPages.findIndex((page) => page.path === pagePath)
  747. if (rowIndex < 0) return
  748. const rect = row.getBoundingClientRect()
  749. const isBottomHalf = (clientY - rect.top) >= rect.height / 2
  750. const insertIndex = isBottomHalf ? rowIndex + 1 : rowIndex
  751. dragInsertIndexRef.current = insertIndex
  752. setDragInsertIndex(insertIndex)
  753. }, [sortedChapterPages])
  754. const handleContainerPointerMove = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
  755. if (activePointerIdRef.current !== event.pointerId) return
  756. pendingPointerPositionRef.current = { x: event.clientX, y: event.clientY }
  757. if (!dragSourceRef.current) return
  758. updateDragInsertFromPoint(event.clientX, event.clientY)
  759. }, [updateDragInsertFromPoint])
  760. const handleItemPointerDown = useCallback((event: React.PointerEvent, pagePath: string) => {
  761. if (filterType !== "chapter" || renamingPath) return
  762. lastPointerTypeRef.current = event.pointerType || "mouse"
  763. if (event.pointerType !== "mouse") {
  764. event.preventDefault()
  765. return
  766. }
  767. if (event.button !== 0 || selectedFile !== pagePath || isDragging) return
  768. event.preventDefault()
  769. dragTimerRef.current && clearTimeout(dragTimerRef.current)
  770. activePointerIdRef.current = event.pointerId
  771. pendingPointerPositionRef.current = { x: event.clientX, y: event.clientY }
  772. removeGlobalPointerListenersRef.current?.()
  773. const handlePointerFinish = (pointerEvent: PointerEvent) => {
  774. if (activePointerIdRef.current !== pointerEvent.pointerId) return
  775. pendingPointerPositionRef.current = { x: pointerEvent.clientX, y: pointerEvent.clientY }
  776. finishDragInteraction()
  777. }
  778. const handleGlobalPointerMove = (pointerEvent: PointerEvent) => {
  779. if (activePointerIdRef.current !== pointerEvent.pointerId) return
  780. pendingPointerPositionRef.current = { x: pointerEvent.clientX, y: pointerEvent.clientY }
  781. if (!dragSourceRef.current) return
  782. updateDragInsertFromPoint(pointerEvent.clientX, pointerEvent.clientY)
  783. pointerEvent.preventDefault()
  784. }
  785. window.addEventListener("pointerup", handlePointerFinish)
  786. window.addEventListener("pointercancel", handlePointerFinish)
  787. window.addEventListener("pointermove", handleGlobalPointerMove)
  788. removeGlobalPointerListenersRef.current = () => {
  789. window.removeEventListener("pointerup", handlePointerFinish)
  790. window.removeEventListener("pointercancel", handlePointerFinish)
  791. window.removeEventListener("pointermove", handleGlobalPointerMove)
  792. }
  793. dragTimerRef.current = setTimeout(() => {
  794. dragTimerRef.current = null
  795. dragSourceRef.current = pagePath
  796. setDragSource(pagePath)
  797. setIsDragging(true)
  798. const pointerPosition = pendingPointerPositionRef.current
  799. if (pointerPosition) {
  800. updateDragInsertFromPoint(pointerPosition.x, pointerPosition.y)
  801. }
  802. }, 300)
  803. }, [filterType, renamingPath, selectedFile, isDragging, finishDragInteraction, updateDragInsertFromPoint])
  804. const handlePageClick = useCallback((pagePath: string) => {
  805. setArmedPath(null)
  806. if (renamingPath === pagePath) return
  807. setSelectedFile(pagePath)
  808. // 保存最后阅读的章节路径,用于启动时自动打开(仅章节保存,大纲不保存)
  809. if (pagePath.replace(/\\/g, "/").includes("/wiki/chapters/")) {
  810. saveLastReadChapter(pagePath).catch(() => {})
  811. }
  812. // 同时保存到sessionStorage,用于视图切换时恢复章节定位
  813. if (pagePath.replace(/\\/g, "/").includes("/wiki/chapters/")) {
  814. try {
  815. sessionStorage.setItem("lk-last-chapter-path", pagePath)
  816. } catch { /* ignore quota errors */ }
  817. }
  818. }, [renamingPath, setSelectedFile])
  819. const toggleFolder = useCallback((folderPath: string) => {
  820. setCollapsedFolders((previous) => ({ ...previous, [folderPath]: !previous[folderPath] }))
  821. }, [])
  822. const openCreateMenu = useCallback((event: React.MouseEvent, parentDir?: string, targetFolderName?: string) => {
  823. event.preventDefault()
  824. event.stopPropagation()
  825. const rect = containerRef.current?.getBoundingClientRect()
  826. setCreateMenu({
  827. parentDir,
  828. targetFolderName,
  829. targetFolderPath: parentDir,
  830. x: rect ? event.clientX - rect.left : event.clientX,
  831. y: rect ? event.clientY - rect.top : event.clientY,
  832. })
  833. setPageMenu(null)
  834. }, [])
  835. const openPageMenu = useCallback((event: React.MouseEvent, pagePath: string) => {
  836. event.preventDefault()
  837. event.stopPropagation()
  838. const rect = containerRef.current?.getBoundingClientRect()
  839. setPageMenu({
  840. path: pagePath,
  841. x: rect ? event.clientX - rect.left : event.clientX,
  842. y: rect ? event.clientY - rect.top : event.clientY,
  843. })
  844. setCreateMenu(null)
  845. }, [])
  846. const handlePageContextMenu = useCallback((event: React.MouseEvent, pagePath: string) => {
  847. if (filterType === "chapter" && lastPointerTypeRef.current !== "mouse") {
  848. event.preventDefault()
  849. event.stopPropagation()
  850. return
  851. }
  852. openPageMenu(event, pagePath)
  853. }, [filterType, openPageMenu])
  854. const handleBlankContextMenu = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
  855. const target = event.target
  856. if (!(target instanceof HTMLElement)) return
  857. if (target.closest("[data-knowledge-interactive='true']")) return
  858. openCreateMenu(event)
  859. }, [openCreateMenu])
  860. useEffect(() => {
  861. const container = containerRef.current
  862. if (!container || filterType !== "chapter") return
  863. const handleSelectStart = (event: Event) => {
  864. const target = event.target
  865. if (!(target instanceof HTMLElement)) return
  866. if (!target.closest("[data-page-path]")) return
  867. event.preventDefault()
  868. }
  869. container.addEventListener("selectstart", handleSelectStart)
  870. return () => {
  871. container.removeEventListener("selectstart", handleSelectStart)
  872. }
  873. }, [filterType])
  874. useEffect(() => {
  875. return () => {
  876. if (dragTimerRef.current) {
  877. clearTimeout(dragTimerRef.current)
  878. dragTimerRef.current = null
  879. }
  880. removeGlobalPointerListenersRef.current?.()
  881. removeGlobalPointerListenersRef.current = null
  882. activePointerIdRef.current = null
  883. pendingPointerPositionRef.current = null
  884. }
  885. }, [])
  886. const rootLabel = filterType === "chapter" ? t("sidebar.knowledge") : t("sidebar.files")
  887. const emptyLabel = filterType === "chapter"
  888. ? t("knowledgeTree.emptyFiltered", { label: t("trash.kindChapter") })
  889. : t("knowledgeTree.emptyFiltered", { label: t("trash.kindOutline") })
  890. const renderNodes = useCallback((nodes: FileNode[], depth = 0) => {
  891. const chapterIndexMap = new Map<string, number>()
  892. sortedChapterPages.forEach((page, index) => chapterIndexMap.set(page.path, index))
  893. return sortFileNodes(nodes, pageInfoByPath, filterType).flatMap((node) => {
  894. const normalizedPath = normalizePath(node.path)
  895. if (node.is_dir) {
  896. const isCollapsed = collapsedFolders[normalizedPath] ?? false
  897. const folderRow = (
  898. <div key={normalizedPath}>
  899. <div
  900. data-knowledge-interactive="true"
  901. className="group flex items-center gap-1 rounded-md px-2 py-1.5 text-sm text-muted-foreground qm-hover"
  902. style={{ paddingLeft: `${depth * 16 + 8}px` }}
  903. onContextMenu={(event) => openCreateMenu(event, normalizedPath, node.name)}
  904. >
  905. <button
  906. type="button"
  907. onClick={() => toggleFolder(normalizedPath)}
  908. className="flex flex-1 items-center gap-1.5 text-left"
  909. >
  910. {isCollapsed ? (
  911. <ChevronRight className="h-3.5 w-3.5 shrink-0" />
  912. ) : (
  913. <ChevronDown className="h-3.5 w-3.5 shrink-0" />
  914. )}
  915. <Folder className="h-4 w-4 shrink-0 text-amber-500" />
  916. <span className="truncate font-medium">{node.name}</span>
  917. <span className="ml-auto text-[10px] text-muted-foreground/60">{countMarkdownDescendants(node)}</span>
  918. </button>
  919. </div>
  920. {!isCollapsed && node.children && renderNodes(node.children, depth + 1)}
  921. </div>
  922. )
  923. return [folderRow]
  924. }
  925. if (!node.name.endsWith(".md")) return []
  926. const page = pageInfoByPath.get(normalizedPath) ?? {
  927. path: normalizedPath,
  928. title: node.name.replace(/\.md$/, "").replace(/-/g, " "),
  929. type: filterType,
  930. tags: [],
  931. }
  932. const isSelected = selectedFile === normalizedPath
  933. const isChapterChecked = filterType === "chapter" && selectedChapterPaths.has(normalizedPath)
  934. const isArmed = armedPath === normalizedPath
  935. const isDeleting = deletingPath === normalizedPath
  936. const isDragSource = dragSource === normalizedPath
  937. const chapterIndex = chapterIndexMap.get(normalizedPath)
  938. const isInsertTarget = isDragging && dragInsertIndex !== null && chapterIndex !== undefined && chapterIndex === dragInsertIndex && !isDragSource
  939. return [
  940. <div
  941. key={normalizedPath}
  942. data-knowledge-interactive="true"
  943. data-page-path={normalizedPath}
  944. className={`group flex items-center gap-1 rounded-md ${isSelected ? "qm-selected" : "qm-hover"} ${isChapterChecked ? "ring-1 ring-primary/35" : ""} ${isDragSource ? "ring-2 ring-primary/50" : ""} ${isInsertTarget ? "border-t-[3px] border-primary/70" : ""}`}
  945. onContextMenu={(event) => handlePageContextMenu(event, normalizedPath)}
  946. onPointerDown={(event) => handleItemPointerDown(event, normalizedPath)}
  947. style={{
  948. marginLeft: `${depth * 16}px`,
  949. userSelect: filterType === "chapter" ? "none" : undefined,
  950. WebkitUserSelect: filterType === "chapter" ? "none" : undefined,
  951. WebkitTouchCallout: filterType === "chapter" ? "none" : undefined,
  952. }}
  953. >
  954. {filterType === "chapter" && chapterBatchMode && (
  955. <input
  956. type="checkbox"
  957. checked={isChapterChecked}
  958. aria-label={t("knowledgeTree.selectChapter", { title: page.title })}
  959. className="ml-1 h-3.5 w-3.5 shrink-0 accent-primary"
  960. onPointerDown={(event) => event.stopPropagation()}
  961. onClick={(event) => event.stopPropagation()}
  962. onChange={(event) => {
  963. event.stopPropagation()
  964. toggleChapterSelection(normalizedPath)
  965. }}
  966. />
  967. )}
  968. <button
  969. type="button"
  970. onClick={() => handlePageClick(normalizedPath)}
  971. disabled={renamingPath === normalizedPath}
  972. className={`flex min-w-0 flex-1 items-center justify-between gap-2 px-2 py-1 text-left text-sm ${
  973. isSelected ? "qm-selected-muted" : "text-muted-foreground group-hover:text-foreground"
  974. }`}
  975. title={normalizedPath}
  976. >
  977. {page.origin === "web-clip" ? <Globe className="h-3 w-3 shrink-0 text-blue-400" /> : <FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
  978. {renamingPath === normalizedPath ? (
  979. <input
  980. type="text"
  981. value={renameValue}
  982. onChange={(event) => setRenameValue(event.target.value)}
  983. onMouseDown={(event) => event.stopPropagation()}
  984. onClick={(event) => event.stopPropagation()}
  985. onFocus={(event) => event.stopPropagation()}
  986. onBlur={() => void submitRenamePage()}
  987. onKeyDown={(event) => {
  988. event.stopPropagation()
  989. if (event.key === "Enter") {
  990. event.preventDefault()
  991. void submitRenamePage()
  992. } else if (event.key === "Escape") {
  993. event.preventDefault()
  994. cancelRenamePage()
  995. }
  996. }}
  997. className="w-full rounded border bg-background px-1.5 py-0.5 text-xs outline-none focus:ring-1 focus:ring-ring"
  998. autoFocus
  999. disabled={renamingBusy}
  1000. />
  1001. ) : (
  1002. <>
  1003. <span className="min-w-0 flex-1 truncate">{page.title}</span>
  1004. {page.type === "chapter" && page.wordCountLabel && (
  1005. <span className={`shrink-0 text-right text-[11px] ${isSelected ? "qm-selected-muted" : "text-muted-foreground"}`}>
  1006. {page.wordCountLabel}
  1007. </span>
  1008. )}
  1009. </>
  1010. )}
  1011. </button>
  1012. <DeleteButton
  1013. armed={isArmed}
  1014. deleting={isDeleting}
  1015. className={`mr-1 transition-opacity ${isArmed || isDeleting ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}
  1016. onClick={() => void handleDeleteClick(normalizedPath)}
  1017. name={page.title}
  1018. />
  1019. </div>,
  1020. ]
  1021. })
  1022. }, [
  1023. pageInfoByPath,
  1024. filterType,
  1025. collapsedFolders,
  1026. selectedFile,
  1027. armedPath,
  1028. deletingPath,
  1029. renamingPath,
  1030. renameValue,
  1031. renamingBusy,
  1032. openCreateMenu,
  1033. toggleFolder,
  1034. submitRenamePage,
  1035. cancelRenamePage,
  1036. handleDeleteClick,
  1037. handleItemPointerDown,
  1038. handlePageContextMenu,
  1039. handlePageClick,
  1040. chapterBatchMode,
  1041. selectedChapterPaths,
  1042. toggleChapterSelection,
  1043. dragSource,
  1044. dragInsertIndex,
  1045. isDragging,
  1046. sortedChapterPages,
  1047. t,
  1048. ])
  1049. if (!project) {
  1050. return (
  1051. <div className="flex h-full items-center justify-center p-4 text-sm text-muted-foreground">
  1052. {t("knowledgeTree.noProject")}
  1053. </div>
  1054. )
  1055. }
  1056. return (
  1057. <ScrollArea className="h-full">
  1058. <div
  1059. ref={containerRef}
  1060. className="relative flex min-h-full flex-col p-2 select-none"
  1061. style={{
  1062. userSelect: filterType === "chapter" ? "none" : undefined,
  1063. WebkitUserSelect: filterType === "chapter" ? "none" : undefined,
  1064. WebkitTouchCallout: filterType === "chapter" ? "none" : undefined,
  1065. }}
  1066. onClick={() => {
  1067. setCreateMenu(null)
  1068. setPageMenu(null)
  1069. }}
  1070. onContextMenu={handleBlankContextMenu}
  1071. onPointerMove={handleContainerPointerMove}
  1072. >
  1073. <div className="mb-2 flex items-center justify-between gap-2 px-2 text-xs font-semibold uppercase text-muted-foreground">
  1074. <span>{rootLabel}</span>
  1075. </div>
  1076. {filterType === "chapter" && chapterBatchMode && selectableChapterPaths.length > 0 && (
  1077. <div className="mb-2 flex items-center gap-2 px-2 text-xs">
  1078. <button
  1079. type="button"
  1080. className="rounded border px-2 py-1 hover:bg-accent"
  1081. onClick={(event) => {
  1082. event.stopPropagation()
  1083. handleToggleAllChapters()
  1084. }}
  1085. >
  1086. {allChaptersSelected ? t("knowledgeTree.clearChapterSelection") : t("knowledgeTree.selectAllChapters")}
  1087. </button>
  1088. <span className="min-w-0 flex-1 truncate text-muted-foreground">
  1089. {selectedChapterCount > 0
  1090. ? t("knowledgeTree.selectedChapters", { count: selectedChapterCount })
  1091. : t("knowledgeTree.selectChaptersHint")}
  1092. </span>
  1093. {selectedChapterCount > 0 && volumeFolders.length > 0 && (
  1094. <select
  1095. aria-label={t("knowledgeTree.moveSelectedToVolume")}
  1096. value=""
  1097. className="h-7 max-w-[120px] rounded border bg-background px-2 text-xs"
  1098. onClick={(event) => event.stopPropagation()}
  1099. onChange={(event) => {
  1100. const targetVolumePath = event.target.value
  1101. if (targetVolumePath) {
  1102. void handleMoveChaptersToVolume(Array.from(selectedChapterPaths), targetVolumePath)
  1103. }
  1104. }}
  1105. >
  1106. <option value="">{t("knowledgeTree.moveSelectedToVolume")}</option>
  1107. {volumeFolders.map((vol) => (
  1108. <option key={vol.path} value={vol.path}>{vol.name}</option>
  1109. ))}
  1110. </select>
  1111. )}
  1112. <button
  1113. type="button"
  1114. className="rounded border px-2 py-1 hover:bg-accent"
  1115. onClick={(event) => {
  1116. event.stopPropagation()
  1117. exitChapterBatchMode()
  1118. }}
  1119. >
  1120. {t("knowledgeTree.exitBatchMode")}
  1121. </button>
  1122. </div>
  1123. )}
  1124. {sectionNodes.length === 0 && pendingPages.length === 0 ? (
  1125. <div className="px-2 py-4 text-center text-xs text-muted-foreground">{emptyLabel}</div>
  1126. ) : (
  1127. renderNodes(sectionNodes)
  1128. )}
  1129. {createMenu && (
  1130. <div
  1131. className="absolute z-20 w-40 rounded-md border bg-background py-1 text-xs shadow-lg"
  1132. style={{ left: createMenu.x, top: createMenu.y }}
  1133. onMouseDown={(event) => event.stopPropagation()}
  1134. onClick={(event) => event.stopPropagation()}
  1135. >
  1136. <button
  1137. type="button"
  1138. className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"
  1139. onClick={() => {
  1140. onRequestCreate?.({ kind: filterType, parentDir: createMenu.parentDir })
  1141. setCreateMenu(null)
  1142. }}
  1143. >
  1144. <Plus className="h-3.5 w-3.5" />
  1145. {filterType === "chapter" ? t("sidebar.newChapter") : t("sidebar.newOutline")}
  1146. </button>
  1147. <button
  1148. type="button"
  1149. className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"
  1150. onClick={() => {
  1151. onRequestCreate?.({ kind: filterType === "chapter" ? "volume" : "folder", parentDir: createMenu.parentDir })
  1152. setCreateMenu(null)
  1153. }}
  1154. >
  1155. <Folder className="h-3.5 w-3.5" />
  1156. {filterType === "chapter" ? t("sidebar.newVolume") : t("sidebar.newFolder")}
  1157. </button>
  1158. {createMenu.targetFolderPath ? (
  1159. <button
  1160. type="button"
  1161. className="flex w-full items-center gap-2 px-3 py-2 text-left text-destructive hover:bg-accent"
  1162. onClick={() => {
  1163. const targetFolderPath = createMenu.targetFolderPath
  1164. setCreateMenu(null)
  1165. if (targetFolderPath) {
  1166. void handleDeleteFolder(targetFolderPath)
  1167. }
  1168. }}
  1169. >
  1170. <Trash2 className="h-3.5 w-3.5" />
  1171. {filterType === "chapter" ? t("knowledgeTree.deleteVolume") : t("knowledgeTree.deleteFolder")}
  1172. </button>
  1173. ) : null}
  1174. </div>
  1175. )}
  1176. {pageMenu && (
  1177. <div
  1178. className="absolute z-20 w-40 rounded-md border bg-background py-1 text-xs shadow-lg"
  1179. style={{ left: pageMenu.x, top: pageMenu.y }}
  1180. onMouseDown={(event) => event.stopPropagation()}
  1181. onClick={(event) => event.stopPropagation()}
  1182. >
  1183. <button
  1184. type="button"
  1185. className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"
  1186. onClick={() => {
  1187. const target = pageInfoByPath.get(pageMenu.path)
  1188. onRequestCreate?.({ kind: filterType, parentDir: target ? getDirName(target.path) : undefined })
  1189. setPageMenu(null)
  1190. }}
  1191. >
  1192. <Plus className="h-3.5 w-3.5" />
  1193. {filterType === "chapter" ? t("sidebar.newChapter") : t("sidebar.newOutline")}
  1194. </button>
  1195. <button
  1196. type="button"
  1197. className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"
  1198. onClick={() => {
  1199. const target = pageInfoByPath.get(pageMenu.path)
  1200. onRequestCreate?.({
  1201. kind: filterType === "chapter" ? "volume" : "folder",
  1202. parentDir: target ? getDirName(target.path) : undefined,
  1203. })
  1204. setPageMenu(null)
  1205. }}
  1206. >
  1207. <Folder className="h-3.5 w-3.5" />
  1208. {filterType === "chapter" ? t("sidebar.newVolume") : t("sidebar.newFolder")}
  1209. </button>
  1210. <button
  1211. type="button"
  1212. className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"
  1213. onClick={() => {
  1214. const target = pageInfoByPath.get(pageMenu.path)
  1215. if (target) startRenamePage(target)
  1216. }}
  1217. >
  1218. <Pencil className="h-3.5 w-3.5" />
  1219. {t("knowledgeTree.rename")}
  1220. </button>
  1221. {filterType === "chapter" && volumeFolders.length > 0 && (
  1222. <div>
  1223. <button
  1224. type="button"
  1225. className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"
  1226. onClick={() => setMoveMenuTarget(moveMenuTarget === pageMenu.path ? null : pageMenu.path)}
  1227. >
  1228. <FolderInput className="h-3.5 w-3.5" />
  1229. {t("knowledgeTree.moveToVolume")}
  1230. <ChevronRight className="ml-auto h-3 w-3" />
  1231. </button>
  1232. {moveMenuTarget === pageMenu.path && (
  1233. <div className="max-h-48 overflow-y-auto border-t bg-background py-1 text-xs">
  1234. <button
  1235. type="button"
  1236. className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"
  1237. onClick={() => {
  1238. setChapterBatchMode(true)
  1239. setSelectedChapterPaths(new Set())
  1240. setMoveMenuTarget(null)
  1241. setPageMenu(null)
  1242. }}
  1243. >
  1244. <Check className="h-3 w-3 shrink-0 text-muted-foreground" />
  1245. <span>{t("knowledgeTree.batchChapters")}</span>
  1246. </button>
  1247. {volumeFolders.map((vol) => {
  1248. const sourcePaths = selectedChapterPaths.has(pageMenu.path)
  1249. ? Array.from(selectedChapterPaths)
  1250. : [pageMenu.path]
  1251. const isCurrentVolume = sourcePaths.every((sourcePath) => getDirName(sourcePath) === vol.path)
  1252. return (
  1253. <button
  1254. key={vol.path}
  1255. type="button"
  1256. className={`flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent ${isCurrentVolume ? "opacity-50 cursor-not-allowed" : ""}`}
  1257. disabled={isCurrentVolume}
  1258. onClick={() => {
  1259. if (!isCurrentVolume) {
  1260. void handleMoveChaptersToVolume(sourcePaths, vol.path)
  1261. }
  1262. }}
  1263. >
  1264. <Folder className="h-3 w-3 shrink-0 text-amber-500" />
  1265. <span className="truncate">{vol.name}</span>
  1266. {isCurrentVolume && <span className="ml-auto text-[10px] text-muted-foreground">当前</span>}
  1267. </button>
  1268. )
  1269. })}
  1270. </div>
  1271. )}
  1272. </div>
  1273. )}
  1274. <button
  1275. type="button"
  1276. className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"
  1277. onClick={() => {
  1278. openFileLocation(pageMenu.path).catch((err) => console.error("打开文件位置失败:", err))
  1279. }}
  1280. >
  1281. <FolderOpen className="h-4 w-4" />
  1282. 打开文件所在位置
  1283. </button>
  1284. {filterType === "chapter" && (onSendToChat || onSendToOutline) && (
  1285. <div className="mt-1 border-t pt-1">
  1286. {onSendToChat && (
  1287. <button
  1288. type="button"
  1289. className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"
  1290. onClick={() => {
  1291. const target = pageInfoByPath.get(pageMenu.path)
  1292. if (target) onSendToChat(createChapterReferenceToken(target))
  1293. setPageMenu(null)
  1294. }}
  1295. >
  1296. <MessageCircle className="h-3.5 w-3.5" />
  1297. 发送到AI会话
  1298. </button>
  1299. )}
  1300. {onSendToOutline && (
  1301. <button
  1302. type="button"
  1303. className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"
  1304. onClick={() => {
  1305. const target = pageInfoByPath.get(pageMenu.path)
  1306. if (target) onSendToOutline(createChapterReferenceToken(target))
  1307. setPageMenu(null)
  1308. }}
  1309. >
  1310. <FileText className="h-3.5 w-3.5" />
  1311. 发送到AI大纲
  1312. </button>
  1313. )}
  1314. </div>
  1315. )}
  1316. </div>
  1317. )}
  1318. </div>
  1319. </ScrollArea>
  1320. )
  1321. }
  1322. export function RawSourcesSection({ onCancelExtraction }: { onCancelExtraction?: () => void }) {
  1323. const project = useWikiStore((s) => s.project)
  1324. const tasks = useImportProgressStore((s) => s.tasks)
  1325. const [expanded, setExpanded] = useState(false)
  1326. const projectTasks = useMemo(() => {
  1327. if (!project) return []
  1328. const projectPath = normalizePath(project.path)
  1329. return tasks
  1330. .filter((task) => task.projectPath === projectPath)
  1331. .sort((a, b) => b.updatedAt - a.updatedAt)
  1332. }, [project, tasks])
  1333. const runningTasks = projectTasks.filter((t) => t.status === "running")
  1334. const hasRunning = runningTasks.length > 0
  1335. const hasAnyTask = projectTasks.length > 0
  1336. useEffect(() => {
  1337. if (hasRunning) setExpanded(true)
  1338. }, [hasRunning])
  1339. return (
  1340. <div className="shrink-0 border-t bg-background/95 p-2 backdrop-blur">
  1341. <button
  1342. type="button"
  1343. onClick={() => setExpanded((previous) => !previous)}
  1344. className="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-sm qm-hover"
  1345. >
  1346. {expanded ? (
  1347. <ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
  1348. ) : (
  1349. <ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
  1350. )}
  1351. <BookOpen className="h-3.5 w-3.5 shrink-0 text-amber-600" />
  1352. <span className="flex-1 text-left font-medium text-muted-foreground">提取中</span>
  1353. {hasRunning ? (
  1354. <span className="text-xs text-primary">{runningTasks.length} 个任务运行中</span>
  1355. ) : null}
  1356. </button>
  1357. {expanded && (
  1358. <div className="ml-3 max-h-64 space-y-2 overflow-y-auto pr-1 text-xs text-muted-foreground">
  1359. {hasAnyTask ? (
  1360. projectTasks.slice(0, 20).map((task) => {
  1361. const kindLabel =
  1362. task.kind === "outline" ? "AI 大纲" :
  1363. task.kind === "outline_generation" ? "生成大纲" :
  1364. task.kind === "outline_refinement" ? "细化生成" :
  1365. "章节"
  1366. const progressPercent = task.total > 0
  1367. ? Math.round((task.completed / task.total) * 100)
  1368. : 0
  1369. const isRunning = task.status === "running"
  1370. return (
  1371. <div key={task.id} className="space-y-1 rounded-md bg-muted/30 px-2 py-1.5">
  1372. <div className="flex items-center justify-between gap-2">
  1373. <span className="min-w-0 truncate flex items-center gap-1">
  1374. {isRunning ? (
  1375. <Loader2 className="h-3 w-3 shrink-0 animate-spin text-primary" />
  1376. ) : task.status === "done" ? (
  1377. <Check className="h-3 w-3 shrink-0 text-emerald-500" />
  1378. ) : task.status === "error" ? (
  1379. <X className="h-3 w-3 shrink-0 text-destructive" />
  1380. ) : task.status === "cancelled" ? (
  1381. <X className="h-3 w-3 shrink-0 text-muted-foreground" />
  1382. ) : null}
  1383. <span className={isRunning ? "text-foreground font-medium" : ""}>
  1384. {isRunning
  1385. ? task.cancelling
  1386. ? `正在取消${kindLabel}提取...`
  1387. : task.currentTitle || `${kindLabel}提取中`
  1388. : task.status === "done"
  1389. ? `${kindLabel}提取完成`
  1390. : task.status === "error"
  1391. ? `${kindLabel}提取失败`
  1392. : task.status === "cancelled"
  1393. ? `${kindLabel}提取已取消`
  1394. : task.message ?? ""}
  1395. </span>
  1396. </span>
  1397. <span className="flex items-center gap-1.5 shrink-0">
  1398. {isRunning && (
  1399. <>
  1400. <span className="text-muted-foreground">{progressPercent}%</span>
  1401. <Button
  1402. type="button"
  1403. size="sm"
  1404. variant="outline"
  1405. className="h-5 px-1.5 text-xs text-destructive border-destructive/30 hover:bg-destructive/10"
  1406. onClick={() => {
  1407. useImportProgressStore.getState().cancelTask(task.id)
  1408. onCancelExtraction?.()
  1409. }}
  1410. disabled={task.cancelling}
  1411. >
  1412. 停止
  1413. </Button>
  1414. </>
  1415. )}
  1416. </span>
  1417. </div>
  1418. {isRunning && (
  1419. <div className="h-1 overflow-hidden rounded-full bg-border">
  1420. <div
  1421. className="h-full rounded-full bg-primary transition-all"
  1422. style={{ width: `${progressPercent}%` }}
  1423. />
  1424. </div>
  1425. )}
  1426. {isRunning && task.completed > 0 && (
  1427. <span className="text-muted-foreground">
  1428. {task.completed}/{task.total} · {kindLabel}
  1429. </span>
  1430. )}
  1431. </div>
  1432. )
  1433. })
  1434. ) : (
  1435. <div className="rounded-md bg-muted/40 px-2 py-2">暂无提取任务</div>
  1436. )}
  1437. </div>
  1438. )}
  1439. </div>
  1440. )
  1441. }
  1442. function DeleteButton({
  1443. armed,
  1444. deleting,
  1445. onClick,
  1446. name,
  1447. className = "",
  1448. }: {
  1449. armed: boolean
  1450. deleting: boolean
  1451. onClick: () => void
  1452. name: string
  1453. className?: string
  1454. }) {
  1455. const { t } = useTranslation()
  1456. if (deleting) {
  1457. return (
  1458. <Button
  1459. variant="ghost"
  1460. size="icon"
  1461. className={`h-6 w-6 shrink-0 cursor-default ${className}`}
  1462. disabled
  1463. title={t("knowledgeTree.deletingTitle", { name })}
  1464. >
  1465. <Trash2 className="h-3 w-3 animate-pulse text-destructive" />
  1466. </Button>
  1467. )
  1468. }
  1469. if (armed) {
  1470. return (
  1471. <Button
  1472. variant="destructive"
  1473. size="sm"
  1474. className={`h-6 shrink-0 px-1.5 text-[10px] font-semibold animate-pulse ${className}`}
  1475. onClick={(event) => {
  1476. event.stopPropagation()
  1477. onClick()
  1478. }}
  1479. title={t("knowledgeTree.confirmDeleteTitle", { name })}
  1480. >
  1481. <Trash2 className="mr-0.5 h-3 w-3" />
  1482. {t("knowledgeTree.confirmDelete")}
  1483. </Button>
  1484. )
  1485. }
  1486. return (
  1487. <Button
  1488. variant="ghost"
  1489. size="icon"
  1490. className={`h-6 w-6 shrink-0 text-muted-foreground hover:text-destructive ${className}`}
  1491. onClick={(event) => {
  1492. event.stopPropagation()
  1493. onClick()
  1494. }}
  1495. title={t("knowledgeTree.deleteTitle", { name })}
  1496. >
  1497. <Trash2 className="h-3 w-3" />
  1498. </Button>
  1499. )
  1500. }