diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84fef96..b7dd756 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,3 +63,52 @@ jobs: TestResults.xcresult xcodebuild.log if-no-files-found: ignore + + app: + name: Build app artifact + runs-on: macos-15 + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Select newest Xcode + run: | + newest="$(ls -d /Applications/Xcode_*.app 2>/dev/null \ + | grep -E '/Xcode_[0-9.]+\.app$' | sort -V | tail -1)" + if [ -n "$newest" ]; then + echo "Selecting $newest" + sudo xcode-select -s "$newest/Contents/Developer" + else + echo "No Xcode_.app found; using image default." + fi + + # Unsigned Release build. The app runs locally after the download's + # quarantine flag is cleared (see the PR notes for the xattr command). + - name: Build Scout.app + run: | + set -o pipefail + xcodebuild build \ + -project Scout.xcodeproj \ + -scheme Scout \ + -configuration Release \ + -destination 'platform=macOS' \ + -derivedDataPath build \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGN_IDENTITY="" \ + | tee build-app.log + + # ditto preserves the bundle's symlinks/permissions; upload-artifact then + # wraps this zip (so the download unzips to Scout.zip → Scout.app). + - name: Package Scout.app + run: | + cd build/Build/Products/Release + ditto -c -k --sequesterRsrc --keepParent Scout.app "$GITHUB_WORKSPACE/Scout.zip" + + - name: Upload Scout.app + uses: actions/upload-artifact@v7 + with: + name: Scout-app + path: Scout.zip + if-no-files-found: error diff --git a/Scout.xcodeproj/project.pbxproj b/Scout.xcodeproj/project.pbxproj index 16bc7b3..ec8fc09 100644 --- a/Scout.xcodeproj/project.pbxproj +++ b/Scout.xcodeproj/project.pbxproj @@ -6,6 +6,10 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + BEEE45A02F9599AB0078191D /* Grape in Frameworks */ = {isa = PBXBuildFile; productRef = BEEE45A22F9599AB0078191D /* Grape */; }; +/* End PBXBuildFile section */ + /* Begin PBXContainerItemProxy section */ BEEE456A2F9599AA0078191D /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; @@ -39,6 +43,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + BEEE45A02F9599AB0078191D /* Grape in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -89,6 +94,9 @@ BEEE45562F95613D0078191D /* Scout */, ); name = Scout; + packageProductDependencies = ( + BEEE45A22F9599AB0078191D /* Grape */, + ); productName = Scout; productReference = BEEE45542F95613D0078191D /* Scout.app */; productType = "com.apple.product-type.application"; @@ -142,6 +150,9 @@ ); mainGroup = BEEE454B2F95613D0078191D; minimizedProjectReferenceProxies = 1; + packageReferences = ( + BEEE45A12F9599AB0078191D /* XCRemoteSwiftPackageReference "Grape" */, + ); preferredProjectObjectVersion = 77; productRefGroup = BEEE45552F95613D0078191D /* Products */; projectDirPath = ""; @@ -195,6 +206,25 @@ }; /* End PBXTargetDependency section */ +/* Begin XCRemoteSwiftPackageReference section */ + BEEE45A12F9599AB0078191D /* XCRemoteSwiftPackageReference "Grape" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SwiftGraphs/Grape"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.1.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + BEEE45A22F9599AB0078191D /* Grape */ = { + isa = XCSwiftPackageProductDependency; + package = BEEE45A12F9599AB0078191D /* XCRemoteSwiftPackageReference "Grape" */; + productName = Grape; + }; +/* End XCSwiftPackageProductDependency section */ + /* Begin XCBuildConfiguration section */ BEEE455D2F95613E0078191D /* Debug */ = { isa = XCBuildConfiguration; diff --git a/Scout/ActionItems/Views/InlineMarkdownText.swift b/Scout/ActionItems/Views/InlineMarkdownText.swift index 39dc6f9..92e46be 100644 --- a/Scout/ActionItems/Views/InlineMarkdownText.swift +++ b/Scout/ActionItems/Views/InlineMarkdownText.swift @@ -2,9 +2,26 @@ import SwiftUI import Foundation import AppKit +/// Optional in-app handler for `[[wikilink]]` clicks. When set and it returns +/// `true`, the click is considered handled in-app (e.g. the Knowledge Base +/// navigates to the target note); otherwise `InlineMarkdownText` falls back to +/// its default Linear/Obsidian opening. Nil everywhere except the KB, so other +/// surfaces keep their existing behavior. +private struct KBWikilinkHandlerKey: EnvironmentKey { + static let defaultValue: ((String) -> Bool)? = nil +} + +extension EnvironmentValues { + var kbWikilinkHandler: ((String) -> Bool)? { + get { self[KBWikilinkHandlerKey.self] } + set { self[KBWikilinkHandlerKey.self] = newValue } + } +} + struct InlineMarkdownText: View { let raw: String private let attributed: AttributedString + @Environment(\.kbWikilinkHandler) private var kbWikilinkHandler init(_ raw: String) { self.raw = raw @@ -64,6 +81,9 @@ struct InlineMarkdownText: View { private func openWikilink(target: String) -> OpenURLAction.Result { let decoded = target.removingPercentEncoding ?? target + // In-app navigation first (Knowledge Base). If the handler resolves the + // target it returns true and we stop; otherwise fall back to Linear/Obsidian. + if let kbWikilinkHandler, kbWikilinkHandler(decoded) { return .handled } let linearRe = try! NSRegularExpression(pattern: #"^[A-Z]{2,10}-\d+$"#) if linearRe.firstMatch(in: decoded, range: NSRange(location: 0, length: (decoded as NSString).length)) != nil { let workspace = UserDefaults.standard.string(forKey: "linearWorkspace") ?? "" diff --git a/Scout/KnowledgeBase/KnowledgeBaseFileWriter.swift b/Scout/KnowledgeBase/KnowledgeBaseFileWriter.swift new file mode 100644 index 0000000..2894f29 --- /dev/null +++ b/Scout/KnowledgeBase/KnowledgeBaseFileWriter.swift @@ -0,0 +1,238 @@ +import Combine +import Foundation + +enum KBWriterError: Error, Equatable { + case emptyName + case alreadyExists(String) + case notFound(String) + case readFailed(String) + case writeFailed(String) + /// The file changed on disk since the editor loaded it (a scout-plugin + /// session or another editor wrote it). Surfaced rather than clobbered so + /// the user can reload and reconcile. + case conflict(file: String) + case outsideKnowledgeBase(String) +} + +/// Serializes knowledge-base file mutations (save, create, delete, rename) and +/// git-commits each change scoped to the touched path(s). +/// +/// Unlike `PerFileItemWriter` (which rewrites a single frontmatter field via +/// `GuardedFileWrite`'s re-apply-on-conflict strategy), the KB editor saves the +/// *whole* file. Re-applying a full-document overwrite onto a concurrently +/// changed file would silently clobber the other writer — so `save` uses a +/// baseline-mtime guard that *fails* on conflict instead of merging. +actor KnowledgeBaseFileWriter { + private let scoutDirectory: URL + private let gitService: GitServiceProtocol? + private var tail: Task? + + init(scoutDirectory: URL, gitService: GitServiceProtocol?) { + // Resolve symlinks so the in-KB guard and repo-relative paths match the + // tree's symlink-resolved file URLs (see KnowledgeBaseService.init). + self.scoutDirectory = scoutDirectory.resolvingSymlinksInPath() + self.gitService = gitService + } + + /// Overwrite `fileURL` with `contents`, but only if its modification date + /// still matches `baseline` (the mtime captured when the editor loaded it). + /// A `nil` baseline means "the file didn't exist at load" — used when saving + /// a freshly created note. Commits the single path on success. + func save( + fileURL: URL, + contents: String, + baseline: Date?, + label: String + ) async throws { + try ensureInsideKB(fileURL) + let previous = tail + let task = Task { [scoutDirectory, gitService] in + _ = await previous?.value + return try await Self.performSave( + fileURL: fileURL, contents: contents, baseline: baseline, + label: label, scoutDirectory: scoutDirectory, gitService: gitService) + } + tail = Task { _ = try? await task.value } + return try await task.value + } + + /// Create a new `.md` note named `name` (slug, extension optional) inside + /// `directory`. Fails if a file of that name already exists. Returns the new + /// file's URL. + @discardableResult + func createFile(in directory: URL, name: String, initialContents: String) async throws -> URL { + try ensureInsideKB(directory) + let previous = tail + let task = Task { [scoutDirectory, gitService] in + _ = await previous?.value + return try await Self.performCreate( + directory: directory, name: name, initialContents: initialContents, + scoutDirectory: scoutDirectory, gitService: gitService) + } + tail = Task { _ = try? await task.value } + return try await task.value + } + + /// Delete `fileURL` and commit the removal. + func delete(fileURL: URL, label: String) async throws { + try ensureInsideKB(fileURL) + let previous = tail + let task = Task { [scoutDirectory, gitService] in + _ = await previous?.value + return try await Self.performDelete( + fileURL: fileURL, label: label, + scoutDirectory: scoutDirectory, gitService: gitService) + } + tail = Task { _ = try? await task.value } + return try await task.value + } + + /// Rename `fileURL` to `newName` (within the same directory). Returns the new + /// URL. Fails if the destination already exists. + @discardableResult + func rename(fileURL: URL, to newName: String) async throws -> URL { + try ensureInsideKB(fileURL) + let previous = tail + let task = Task { [scoutDirectory, gitService] in + _ = await previous?.value + return try await Self.performRename( + fileURL: fileURL, newName: newName, + scoutDirectory: scoutDirectory, gitService: gitService) + } + tail = Task { _ = try? await task.value } + return try await task.value + } + + // MARK: - Guard + + /// Reject any path that resolves outside `scoutDirectory/knowledge-base` — + /// defense against a crafted selection escaping the KB root. + private func ensureInsideKB(_ url: URL) throws { + let kbRoot = scoutDirectory.appendingPathComponent("knowledge-base") + .resolvingSymlinksInPath().path + "/" + let resolved = url.resolvingSymlinksInPath().path + if !(resolved + "/").hasPrefix(kbRoot) && resolved + "/" != kbRoot { + throw KBWriterError.outsideKnowledgeBase(url.lastPathComponent) + } + } + + // MARK: - perform (off-actor) + + private static func performSave( + fileURL: URL, contents: String, baseline: Date?, label: String, + scoutDirectory: URL, gitService: GitServiceProtocol? + ) async throws { + let fm = FileManager.default + let exists = fm.fileExists(atPath: fileURL.path) + if exists { + // Conflict check: the file must not have changed since the editor + // captured `baseline`. A nil baseline on an existing file means the + // caller couldn't read the mtime — treat as a conflict to be safe. + guard let baseline else { throw KBWriterError.conflict(file: fileURL.lastPathComponent) } + let current = GuardedFileWrite.fsModificationDate(fileURL) + if let current, abs(current.timeIntervalSince(baseline)) > 0.0005 { + throw KBWriterError.conflict(file: fileURL.lastPathComponent) + } + } + do { + try fm.createDirectory(at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + try contents.write(to: fileURL, atomically: true, encoding: .utf8) + } catch { + throw KBWriterError.writeFailed(error.localizedDescription) + } + let rel = relativePathInRepo(fileURL: fileURL, repo: scoutDirectory) + try? await gitService?.commitPaths([rel], message: "app: edit \(label)") + } + + private static func performCreate( + directory: URL, name: String, initialContents: String, + scoutDirectory: URL, gitService: GitServiceProtocol? + ) async throws -> URL { + let fileName = try normalizedFileName(name) + let dest = directory.appendingPathComponent(fileName) + let fm = FileManager.default + if fm.fileExists(atPath: dest.path) { + throw KBWriterError.alreadyExists(fileName) + } + do { + try fm.createDirectory(at: directory, withIntermediateDirectories: true) + try initialContents.write(to: dest, atomically: true, encoding: .utf8) + } catch { + throw KBWriterError.writeFailed(error.localizedDescription) + } + let rel = relativePathInRepo(fileURL: dest, repo: scoutDirectory) + try? await gitService?.commitPaths([rel], message: "app: create \(rel)") + return dest + } + + private static func performDelete( + fileURL: URL, label: String, + scoutDirectory: URL, gitService: GitServiceProtocol? + ) async throws { + let fm = FileManager.default + guard fm.fileExists(atPath: fileURL.path) else { + throw KBWriterError.notFound(fileURL.lastPathComponent) + } + let rel = relativePathInRepo(fileURL: fileURL, repo: scoutDirectory) + do { try fm.removeItem(at: fileURL) } + catch { throw KBWriterError.writeFailed(error.localizedDescription) } + // `git add -- ` stages the deletion (git ≥ 2.0). + try? await gitService?.commitPaths([rel], message: "app: delete \(label)") + } + + private static func performRename( + fileURL: URL, newName: String, + scoutDirectory: URL, gitService: GitServiceProtocol? + ) async throws -> URL { + let fm = FileManager.default + guard fm.fileExists(atPath: fileURL.path) else { + throw KBWriterError.notFound(fileURL.lastPathComponent) + } + // Preserve the original extension if the new name doesn't carry one. + let originalExt = fileURL.pathExtension + var fileName = try normalizedFileName(newName, defaultExtension: originalExt) + if (fileName as NSString).pathExtension.isEmpty && !originalExt.isEmpty { + fileName += ".\(originalExt)" + } + let dest = fileURL.deletingLastPathComponent().appendingPathComponent(fileName) + if fm.fileExists(atPath: dest.path) { + throw KBWriterError.alreadyExists(fileName) + } + let oldRel = relativePathInRepo(fileURL: fileURL, repo: scoutDirectory) + let newRel = relativePathInRepo(fileURL: dest, repo: scoutDirectory) + do { try fm.moveItem(at: fileURL, to: dest) } + catch { throw KBWriterError.writeFailed(error.localizedDescription) } + try? await gitService?.commitPaths([oldRel, newRel], message: "app: rename \(oldRel) → \(newRel)") + return dest + } + + // MARK: - pure helpers + + /// Validate and normalize a user-entered file name. Rejects empty names and + /// path separators (no creating files outside the chosen directory). Adds a + /// `.md` extension when none is present. + static func normalizedFileName(_ raw: String, defaultExtension: String = "md") throws -> String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { throw KBWriterError.emptyName } + guard !trimmed.contains("/"), !trimmed.contains("\\"), trimmed != ".", trimmed != ".." else { + throw KBWriterError.writeFailed("Name cannot contain path separators") + } + if (trimmed as NSString).pathExtension.isEmpty { + return "\(trimmed).\(defaultExtension)" + } + return trimmed + } + + static func relativePathInRepo(fileURL: URL, repo: URL) -> String { + let full = fileURL.resolvingSymlinksInPath().path + let prefix = repo.resolvingSymlinksInPath().path + "/" + return full.hasPrefix(prefix) ? String(full.dropFirst(prefix.count)) : fileURL.lastPathComponent + } +} + +/// Actors can't be `@EnvironmentObject`; wrap for SwiftUI injection. +final class KnowledgeBaseWriterBox: ObservableObject { + let writer: KnowledgeBaseFileWriter + init(writer: KnowledgeBaseFileWriter) { self.writer = writer } +} diff --git a/Scout/KnowledgeBase/KnowledgeBaseService.swift b/Scout/KnowledgeBase/KnowledgeBaseService.swift new file mode 100644 index 0000000..ce462cf --- /dev/null +++ b/Scout/KnowledgeBase/KnowledgeBaseService.swift @@ -0,0 +1,337 @@ +import Combine +import Foundation +import SwiftUI + +/// Builds and maintains the knowledge-base file tree under +/// `~/Scout/knowledge-base/`, keeping it in sync via FSEvents. +/// +/// Mirrors `PerFileDocumentService`'s lifecycle (load-on-appear, debounced +/// reparse on file events) but produces a recursive `KBNode` tree rather than a +/// flat item list, since the KB is an arbitrarily-nested folder of `.md`/`.yaml` +/// notes the user browses and edits in place. +@MainActor +final class KnowledgeBaseService: ObservableObject { + enum State: Equatable { + case idle, loading, loaded + case missing(URL) + case failed(String) + } + + @Published private(set) var tree: [KBNode] = [] + @Published private(set) var state: State = .idle + /// Wikilink graph index, rebuilt on every reparse. Powers backlinks, + /// in-app wikilink navigation and the local graph. + @Published private(set) var index: KBIndex = .empty + + /// The scout directory; the KB lives in its `knowledge-base/` subfolder. + let scoutDirectory: URL + /// Root scanned for the tree: `scoutDirectory/knowledge-base`. + let kbDirectory: URL + + private let fileEvents: any FileSystemEventSource + private var watchTask: Task? + + /// Directory entries never surfaced in the tree. + private static let ignoredNames: Set = [ + ".git", ".obsidian", ".scout-cache", ".scout-logs", ".scout-state", + "node_modules", ".DS_Store", + ] + /// File extensions the tree shows (and the editor can open). + private static let visibleExtensions: Set = ["md", "yaml", "yml"] + + init(scoutDirectory: URL, fileEvents: any FileSystemEventSource) { + // `contentsOfDirectory(at:)` returns symlink-resolved file URLs, so + // resolve the root too. Otherwise, when ~/Scout is a symlink, the tree's + // node URLs (real path) and this root (symlink path) mismatch — breaking + // relative-path stripping and the writer's in-KB guard ("people.md is + // outside the knowledge base"). + let resolved = scoutDirectory.resolvingSymlinksInPath() + self.scoutDirectory = resolved + self.kbDirectory = resolved.appendingPathComponent("knowledge-base") + self.fileEvents = fileEvents + } + + /// Build the tree and start watching. Call once when the view appears. + func load() { + state = .loading + reparse() + startWatching() + } + + /// Re-scan immediately — called by the view after a write (create/delete/ + /// rename) so the tree reflects the change without waiting on FSEvents. + func reload() { reparse() } + + /// Read a file's full text contents, or nil if unreadable. + func readFile(_ url: URL) -> String? { + try? String(contentsOf: url, encoding: .utf8) + } + + // MARK: - Tree building + + private func reparse() { + var isDir: ObjCBool = false + guard FileManager.default.fileExists(atPath: kbDirectory.path, isDirectory: &isDir), + isDir.boolValue else { + tree = [] + state = .missing(kbDirectory) + return + } + tree = Self.buildChildren(of: kbDirectory, scoutDirectory: scoutDirectory) + index = Self.buildIndex(tree: tree, scoutDirectory: scoutDirectory) + state = .loaded + } + + /// Recursively build the sorted child nodes of `directory`. Directories sort + /// before files; both alphabetically (case-insensitive). Empty directories + /// (no visible descendants) are pruned so the tree stays readable. + nonisolated static func buildChildren(of directory: URL, scoutDirectory: URL) -> [KBNode] { + let fm = FileManager.default + guard let entries = try? fm.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { return [] } + + var dirs: [KBNode] = [] + var files: [KBNode] = [] + + for url in entries { + let name = url.lastPathComponent + if ignoredNames.contains(name) { continue } + + let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory ?? false + let rel = relativePath(of: url, in: scoutDirectory) + + if isDirectory { + let children = buildChildren(of: url, scoutDirectory: scoutDirectory) + guard !children.isEmpty else { continue } // prune empties + dirs.append(KBNode(kind: .directory, url: url, relativePath: rel, + name: name, ext: "", children: children)) + } else { + let ext = url.pathExtension.lowercased() + guard visibleExtensions.contains(ext) else { continue } + files.append(KBNode(kind: .file, url: url, relativePath: rel, + name: name, ext: ext, children: [])) + } + } + + let byName: (KBNode, KBNode) -> Bool = { + $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending + } + return dirs.sorted(by: byName) + files.sorted(by: byName) + } + + /// Path of `url` relative to `scoutDirectory`, or the last component if it + /// somehow lies outside (shouldn't happen for KB files). + nonisolated static func relativePath(of url: URL, in scoutDirectory: URL) -> String { + let full = url.standardizedFileURL.path + let prefix = scoutDirectory.standardizedFileURL.path + "/" + return full.hasPrefix(prefix) ? String(full.dropFirst(prefix.count)) : url.lastPathComponent + } + + // MARK: - Graph index + + /// Build the wikilink index from the current tree: a stem→path map plus each + /// file's outgoing link targets. Reads every note once (cheap for a KB of a + /// few hundred KB); reused for backlinks, navigation and the graph. + nonisolated static func buildIndex(tree: [KBNode], scoutDirectory: URL) -> KBIndex { + let files = tree.flatMap(\.allFiles).filter { $0.ext == "md" } + var stemToPath: [String: String] = [:] + var outByFile: [String: [String]] = [:] + for file in files { + stemToPath[file.displayName.lowercased()] = file.relativePath + } + for file in files { + guard let text = try? String(contentsOf: file.url, encoding: .utf8) else { continue } + outByFile[file.relativePath] = extractWikilinks(text) + } + return KBIndex(stemToPath: stemToPath, outByFile: outByFile) + } + + /// Extract `[[target]]` / `[[target|alias]]` link targets (the part before + /// `|`), de-duplicated, preserving original case and first-seen order. + nonisolated static func extractWikilinks(_ text: String) -> [String] { + guard let re = try? NSRegularExpression(pattern: #"\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]"#) else { return [] } + let ns = text as NSString + var seen = Set() + var result: [String] = [] + for m in re.matches(in: text, range: NSRange(location: 0, length: ns.length)) { + let raw = ns.substring(with: m.range(at: 1)).trimmingCharacters(in: .whitespaces) + if !raw.isEmpty, seen.insert(raw.lowercased()).inserted { result.append(raw) } + } + return result + } + + /// Resolve a wikilink target (e.g. `groupon`, possibly with spaces) to a + /// repo-relative path, or nil if no matching note exists. + func resolveWikilink(_ target: String) -> String? { + index.stemToPath[target.lowercased()] + } + + /// Outgoing links of a note, each with its resolved target (nil = dangling). + func outgoingLinks(for relPath: String) -> [KBLink] { + (index.outByFile[relPath] ?? []).map { + KBLink(target: $0, resolved: index.stemToPath[$0.lowercased()]) + } + } + + /// Notes that link to `relPath`, with a one-line excerpt around the link. + func backlinks(for relPath: String) -> [KBBacklink] { + let targetStem = (KBNode.displayName(forPath: relPath)).lowercased() + var results: [KBBacklink] = [] + for (from, targets) in index.outByFile { + guard from != relPath else { continue } + guard targets.contains(where: { index.stemToPath[$0.lowercased()] == relPath }) else { continue } + let url = scoutDirectory.appendingPathComponent(from) + let excerpt = Self.excerpt(in: url, mentioning: targetStem) + results.append(KBBacklink(path: from, + name: KBNode.displayName(forPath: from), + excerpt: excerpt)) + } + return results.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + private static func excerpt(in url: URL, mentioning stem: String) -> String { + guard let text = try? String(contentsOf: url, encoding: .utf8) else { return "" } + let needle = "[[" + stem + let line = text.components(separatedBy: "\n") + .first { $0.lowercased().contains(needle) } + return (line ?? "").trimmingCharacters(in: .whitespaces).prefix(140).description + } + + /// Build the local subgraph centred on `relPath`: BFS over undirected + /// wikilink edges out to `depth` hops, capped at `maxNodes` by degree. + func localGraph(around relPath: String, depth: Int = 2, maxNodes: Int = 26) -> KBGraph { + // Full undirected edge set. + var edgeSet = Set() + for (from, targets) in index.outByFile { + for t in targets { + guard let to = index.stemToPath[t.lowercased()], to != from else { continue } + // Normalize direction so duplicates collapse. + let (a, b) = from < to ? (from, to) : (to, from) + edgeSet.insert(KBGraphEdge(from: a, to: b)) + } + } + // Adjacency. + var adj: [String: Set] = [:] + for e in edgeSet { + adj[e.from, default: []].insert(e.to) + adj[e.to, default: []].insert(e.from) + } + // BFS from center. + var visited: Set = [relPath] + var frontier: [String] = [relPath] + for _ in 0.. Int = { adj[$0]?.count ?? 0 } + + // Cap: always keep the center, then the highest-degree neighbours. + var kept = Array(visited) + if kept.count > maxNodes { + kept = [relPath] + kept.filter { $0 != relPath } + .sorted { degree($0) > degree($1) } + .prefix(maxNodes - 1) + } + let keptSet = Set(kept) + + let nodes = kept.map { path in + KBGraphNode(id: path, + label: KBNode.displayName(forPath: path), + group: KBEntityGroup.of(path), + degree: degree(path), + isCenter: path == relPath) + } + let edges = edgeSet.filter { keptSet.contains($0.from) && keptSet.contains($0.to) } + return KBGraph(nodes: nodes, edges: Array(edges)) + } + + /// Count of notes and unique links across the whole KB (for the overview). + func graphStats() -> (notes: Int, links: Int) { + let notes = tree.flatMap(\.allFiles).filter { $0.ext == "md" }.count + var edgeSet = Set() + for (from, targets) in index.outByFile { + for t in targets { + guard let to = index.stemToPath[t.lowercased()], to != from else { continue } + let (a, b) = from < to ? (from, to) : (to, from) + edgeSet.insert(KBGraphEdge(from: a, to: b)) + } + } + return (notes, edgeSet.count) + } + + /// The whole-KB graph: every markdown note plus the unique wikilink edges + /// between them. Feeds the global graph on the overview. + func fullGraph() -> KBGraph { + var edgeSet = Set() + for (from, targets) in index.outByFile { + for t in targets { + guard let to = index.stemToPath[t.lowercased()], to != from else { continue } + let (a, b) = from < to ? (from, to) : (to, from) + edgeSet.insert(KBGraphEdge(from: a, to: b)) + } + } + var degree: [String: Int] = [:] + for e in edgeSet { degree[e.from, default: 0] += 1; degree[e.to, default: 0] += 1 } + let nodes = tree.flatMap(\.allFiles).filter { $0.ext == "md" }.map { file in + KBGraphNode(id: file.relativePath, label: file.displayName, + group: KBEntityGroup.of(file.relativePath), + degree: degree[file.relativePath] ?? 0, isCenter: false) + } + return KBGraph(nodes: nodes, edges: Array(edgeSet)) + } + + /// Full-text search across note names and contents, returning a snippet for + /// the first matching line. Capped at 30 hits. + func searchContent(_ query: String) -> [KBSearchHit] { + let q = query.lowercased() + guard q.count >= 2 else { return [] } + var hits: [KBSearchHit] = [] + for file in tree.flatMap(\.allFiles) where file.ext == "md" { + let nameMatch = file.displayName.lowercased().contains(q) + || file.relativePath.lowercased().contains(q) + guard let text = try? String(contentsOf: file.url, encoding: .utf8) else { + if nameMatch { + hits.append(KBSearchHit(path: file.relativePath, name: file.displayName, snippet: "")) + } + continue + } + if let line = text.components(separatedBy: "\n").first(where: { $0.lowercased().contains(q) }) { + hits.append(KBSearchHit(path: file.relativePath, name: file.displayName, + snippet: line.trimmingCharacters(in: .whitespaces).prefix(120).description)) + } else if nameMatch { + hits.append(KBSearchHit(path: file.relativePath, name: file.displayName, snippet: "")) + } + if hits.count >= 30 { break } + } + return hits + } + + // MARK: - Watching + + private func startWatching() { + watchTask?.cancel() + let stream = fileEvents.events(for: kbDirectory) + watchTask = Task { [weak self] in + var debounce: Task? + for await _ in stream { + guard self != nil else { return } + debounce?.cancel() + debounce = Task { [weak self] in + try? await Task.sleep(nanoseconds: 250_000_000) + self?.reparse() + } + } + } + } + + deinit { watchTask?.cancel() } +} diff --git a/Scout/KnowledgeBase/Models/KBDocSegment.swift b/Scout/KnowledgeBase/Models/KBDocSegment.swift new file mode 100644 index 0000000..5cbbbdb --- /dev/null +++ b/Scout/KnowledgeBase/Models/KBDocSegment.swift @@ -0,0 +1,139 @@ +import Foundation + +/// A structural block of a markdown document together with the exact source +/// line range it occupies. Powers in-place block editing: editing a segment +/// rewrites only `lineStart...lineEnd` of the source, leaving everything else +/// byte-identical (so the plugin's structured tokens are never disturbed). +nonisolated struct KBDocSegment: Identifiable, Equatable { + enum Kind: Equatable { + case heading(Int), paragraph, list, quote, code, table, rule, frontmatter + } + + let kind: Kind + let lineStart: Int // inclusive index into the source's line array + let lineEnd: Int // inclusive + let raw: String // source lines [start...end] joined by "\n" + + // Table-only payload (rendered cells + the source line index of each row). + var headers: [String] = [] + var rows: [[String]] = [] + var rowLines: [Int] = [] + + var id: Int { lineStart } + + // MARK: - Parsing + + static func segments(from source: String) -> [KBDocSegment] { + let lines = source.components(separatedBy: "\n") + var segs: [KBDocSegment] = [] + var i = 0 + + func make(_ kind: Kind, _ start: Int, _ end: Int) -> KBDocSegment { + KBDocSegment(kind: kind, lineStart: start, lineEnd: end, + raw: lines[start...end].joined(separator: "\n")) + } + + // Leading frontmatter. + if lines.first?.trimmingCharacters(in: .whitespaces) == "---" { + var j = 1 + while j < lines.count, lines[j].trimmingCharacters(in: .whitespaces) != "---" { j += 1 } + if j < lines.count { segs.append(make(.frontmatter, 0, j)); i = j + 1 } + } + + while i < lines.count { + let line = lines[i] + let t = line.trimmingCharacters(in: .whitespaces) + + if t.isEmpty { i += 1; continue } + + // Fenced code block. + if t.hasPrefix("```") { + var j = i + 1 + while j < lines.count, !lines[j].trimmingCharacters(in: .whitespaces).hasPrefix("```") { j += 1 } + let end = j < lines.count ? j : lines.count - 1 + segs.append(make(.code, i, end)); i = end + 1; continue + } + + // Table. + if t.contains("|"), i + 1 < lines.count, KBMarkdownPreview.isTableSeparator(lines[i + 1]) { + let start = i + let headers = KBMarkdownPreview.splitRow(line) + var rows: [[String]] = [] + var rowLines: [Int] = [] + var j = i + 2 + while j < lines.count { + let rt = lines[j].trimmingCharacters(in: .whitespaces) + if rt.isEmpty || !rt.contains("|") { break } + if KBMarkdownPreview.isTableSeparator(lines[j]) { j += 1; continue } + var cells = KBMarkdownPreview.splitRow(lines[j]) + if cells.count < headers.count { + cells += Array(repeating: "", count: headers.count - cells.count) + } else if cells.count > headers.count { + cells = Array(cells.prefix(headers.count)) + } + rows.append(cells); rowLines.append(j); j += 1 + } + var seg = make(.table, start, j - 1) + seg.headers = headers; seg.rows = rows; seg.rowLines = rowLines + segs.append(seg); i = j; continue + } + + // Horizontal rule. + if t == "---" || t == "***" || t == "___" { segs.append(make(.rule, i, i)); i += 1; continue } + + // Heading. + if KBMarkdownPreview.parseHeading(t) != nil { + var level = 0 + for c in t { if c == "#" { level += 1 } else { break } } + segs.append(make(.heading(level), i, i)); i += 1; continue + } + + // Blockquote (consecutive `>` lines). + if t.hasPrefix(">") { + var j = i + while j < lines.count, lines[j].trimmingCharacters(in: .whitespaces).hasPrefix(">") { j += 1 } + segs.append(make(.quote, i, j - 1)); i = j; continue + } + + // List item (one line each). + if KBMarkdownPreview.parseListItem(line) != nil { segs.append(make(.list, i, i)); i += 1; continue } + + // Paragraph: consecutive "normal" lines. + var j = i + while j < lines.count { + let lt = lines[j].trimmingCharacters(in: .whitespaces) + if lt.isEmpty || lt.hasPrefix("#") || lt.hasPrefix(">") || lt.hasPrefix("```") + || lt == "---" || lt == "***" || lt == "___" { break } + if KBMarkdownPreview.parseListItem(lines[j]) != nil { break } + if lt.contains("|"), j + 1 < lines.count, KBMarkdownPreview.isTableSeparator(lines[j + 1]) { break } + j += 1 + } + segs.append(make(.paragraph, i, j - 1)); i = max(j, i + 1) + } + return segs + } + + // MARK: - Splicing + + /// Replace source lines `start...end` with `newText` (which may be multi-line). + static func replaceLines(in source: String, start: Int, end: Int, with newText: String) -> String { + var lines = source.components(separatedBy: "\n") + guard start >= 0, end < lines.count, start <= end else { return source } + lines.replaceSubrange(start...end, with: newText.components(separatedBy: "\n")) + return lines.joined(separator: "\n") + } + + /// Rewrite a single table cell on `sourceLine`, re-escaping `|` so the pipe + /// stays cell content (not a column break) — matching how the KB writes + /// `[[people\|Alias]]` inside cells. + static func replaceCell(in source: String, sourceLine: Int, col: Int, value: String) -> String { + var lines = source.components(separatedBy: "\n") + guard sourceLine >= 0, sourceLine < lines.count else { return source } + var cells = KBMarkdownPreview.splitRow(lines[sourceLine]) + guard col < cells.count else { return source } + cells[col] = value.trimmingCharacters(in: .whitespaces) + let escaped = cells.map { $0.replacingOccurrences(of: "|", with: "\\|") } + lines[sourceLine] = "| " + escaped.joined(separator: " | ") + " |" + return lines.joined(separator: "\n") + } +} diff --git a/Scout/KnowledgeBase/Models/KBGraph.swift b/Scout/KnowledgeBase/Models/KBGraph.swift new file mode 100644 index 0000000..f7a022b --- /dev/null +++ b/Scout/KnowledgeBase/Models/KBGraph.swift @@ -0,0 +1,97 @@ +import SwiftUI + +/// Entity category of a knowledge-base note, derived from its path. Drives the +/// node color in the local graph and the legend. Kept deliberately small so the +/// graph reads as a map, not a stoplight. +nonisolated enum KBEntityGroup: String, Equatable, Hashable, CaseIterable { + case people, projects, issues, channels, ontology, research, other + + /// Classify a note by its repo-relative path. + static func of(_ relPath: String) -> KBEntityGroup { + let p = relPath.lowercased() + if p.contains("/people") || p.hasSuffix("people.md") { return .people } + if p.contains("/projects/") || p.hasSuffix("projects.md") { return .projects } + if p.contains("issue") { return .issues } + if p.contains("channel") { return .channels } + if p.contains("/ontology/") { return .ontology } + if p.contains("research") || p.contains("review") { return .research } + return .other + } + + var label: String { + switch self { + case .people: return "People" + case .projects: return "Projects" + case .issues: return "Issues" + case .channels: return "Channels" + case .ontology: return "Ontology" + case .research: return "Research" + case .other: return "Other" + } + } + + var color: Color { + switch self { + case .people: return DS.Priority.personal + case .projects: return DS.SlotType.consolidation + case .issues: return DS.Priority.urgent + case .channels: return DS.Accent.fill + case .ontology: return DS.SlotType.dreaming + case .research: return DS.SlotType.research + case .other: return DS.Ink.p3 + } + } +} + +/// One outgoing `[[wikilink]]` from a note, with its resolved target path (nil +/// when the link points at a note that doesn't exist in the KB). +nonisolated struct KBLink: Identifiable, Equatable { + let target: String // original link text (before any `|alias`) + let resolved: String? // repo-relative path, or nil if dangling + var id: String { target } +} + +/// A note that links *to* the current one. +nonisolated struct KBBacklink: Identifiable, Equatable { + let path: String + let name: String + let excerpt: String + var id: String { path } +} + +/// A content-search hit. +nonisolated struct KBSearchHit: Identifiable, Equatable { + let path: String + let name: String + let snippet: String + var id: String { path } +} + +// MARK: - Graph + +nonisolated struct KBGraphNode: Identifiable, Equatable { + let id: String // repo-relative path + let label: String + let group: KBEntityGroup + let degree: Int + let isCenter: Bool +} + +nonisolated struct KBGraphEdge: Equatable, Hashable { + let from: String + let to: String +} + +nonisolated struct KBGraph: Equatable { + let nodes: [KBGraphNode] + let edges: [KBGraphEdge] + static let empty = KBGraph(nodes: [], edges: []) +} + +/// Precomputed wikilink index: each note's display stem → its path, and each +/// note's outgoing link targets (original case). Rebuilt on every reparse. +nonisolated struct KBIndex: Equatable { + let stemToPath: [String: String] + let outByFile: [String: [String]] + static let empty = KBIndex(stemToPath: [:], outByFile: [:]) +} diff --git a/Scout/KnowledgeBase/Models/KBNode.swift b/Scout/KnowledgeBase/Models/KBNode.swift new file mode 100644 index 0000000..f0bf385 --- /dev/null +++ b/Scout/KnowledgeBase/Models/KBNode.swift @@ -0,0 +1,56 @@ +import Foundation + +/// One node in the knowledge-base file tree: either a directory (with children) +/// or an editable file. Built by `KnowledgeBaseService` from the on-disk +/// `~/Scout/knowledge-base/` tree; consumed by `KBTreeView`. +/// +/// `id` is the path relative to the scout directory so selection survives a +/// reparse (FSEvents reload) as long as the file still exists at the same path. +nonisolated struct KBNode: Identifiable, Equatable, Hashable { + enum Kind: Equatable, Hashable { case directory, file } + + let kind: Kind + /// Absolute on-disk location. + let url: URL + /// Path relative to the scout directory, e.g. `knowledge-base/people.md`. + let relativePath: String + /// Display name (last path component). + let name: String + /// Lowercased file extension without the dot (`md`, `yaml`, `yml`), or "" for dirs. + let ext: String + /// Sorted children — empty for files. + let children: [KBNode] + + var id: String { relativePath } + + var isDirectory: Bool { kind == .directory } + + /// Name with the markdown/yaml extension stripped for display in the tree. + var displayName: String { + guard kind == .file else { return name } + switch ext { + case "md", "yaml", "yml": return (name as NSString).deletingPathExtension + default: return name + } + } + + /// True when this file can be opened in the source editor (text formats). + var isEditable: Bool { + kind == .file && ["md", "yaml", "yml"].contains(ext) + } + + /// Recursively collect every file node under this subtree (depth-first). + var allFiles: [KBNode] { + switch kind { + case .file: return [self] + case .directory: return children.flatMap(\.allFiles) + } + } + + /// Display name (extension stripped) for an arbitrary repo-relative path — + /// used by graph/backlink code that works with paths, not nodes. + static func displayName(forPath relPath: String) -> String { + let base = (relPath as NSString).lastPathComponent + return (base as NSString).deletingPathExtension + } +} diff --git a/Scout/KnowledgeBase/Views/KBEditableView.swift b/Scout/KnowledgeBase/Views/KBEditableView.swift new file mode 100644 index 0000000..74cf904 --- /dev/null +++ b/Scout/KnowledgeBase/Views/KBEditableView.swift @@ -0,0 +1,317 @@ +import SwiftUI + +/// A rendered markdown document you edit in place: double-click a paragraph, +/// heading, list item, quote, code block or table cell to edit just that piece; +/// single-click a `[[wikilink]]` still navigates. Every edit replaces only that +/// block's exact source range (or one table cell), so the rest of the file — +/// and the plugin's structured tokens — stays byte-for-byte untouched. +struct KBEditableView: View { + @Binding var source: String + + private let readingWidth: CGFloat = 760 + @State private var editing: Int? = nil // lineStart of the segment being edited + @State private var buffer: String = "" + @State private var showMeta = false + + var body: some View { + let segs = KBDocSegment.segments(from: source) + let parts = partition(segs) + ScrollView { + HStack(spacing: 0) { + Spacer(minLength: 0) + VStack(alignment: .leading, spacing: 12) { + if let title = parts.title { segmentView(title) } + if !parts.history.isEmpty { metadataDisclosure(parts.history) } + ForEach(parts.rest) { segmentView($0) } + } + .frame(maxWidth: readingWidth, alignment: .leading) + Spacer(minLength: 0) + } + .padding(.horizontal, 24).padding(.vertical, 18) + } + } + + // MARK: - Segment view (rendered or editing) + + @ViewBuilder + private func segmentView(_ seg: KBDocSegment) -> some View { + if editing == seg.id { + inlineEditor(seg) + } else if case .table = seg.kind { + KBEditableTableView(headers: seg.headers, rows: seg.rows, rowLines: seg.rowLines) { line, col, value in + source = KBDocSegment.replaceCell(in: source, sourceLine: line, col: col, value: value) + } + } else { + rendered(seg) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture(count: 2) { startEdit(seg) } + .help("Double-click to edit") + } + } + + private func inlineEditor(_ seg: KBDocSegment) -> some View { + VStack(alignment: .leading, spacing: 6) { + TextEditor(text: $buffer) + .font(DS.mono(12.5)).foregroundStyle(DS.Ink.p1) + .scrollContentBackground(.hidden) + .frame(minHeight: editorHeight(seg)) + .padding(8) + .background(RoundedRectangle(cornerRadius: 6).fill(DS.Paper.sunk)) + .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(DS.Accent.fill.opacity(0.6), lineWidth: 1)) + HStack(spacing: 8) { + Spacer() + Button("Cancel") { editing = nil } + .buttonStyle(.plain).font(DS.sans(12)).foregroundStyle(DS.Ink.p3) + .keyboardShortcut(.cancelAction) + Button("Save") { commit(seg) } + .buttonStyle(.plain).font(DS.sans(12, weight: .semibold)).foregroundStyle(.white) + .padding(.horizontal, 12).padding(.vertical, 5) + .background(Capsule().fill(DS.Accent.fill)) + .keyboardShortcut(.return, modifiers: .command) + } + } + .padding(.vertical, 2) + } + + private func editorHeight(_ seg: KBDocSegment) -> CGFloat { + let lines = seg.raw.components(separatedBy: "\n").count + return min(360, max(40, CGFloat(lines) * 20 + 16)) + } + + private func startEdit(_ seg: KBDocSegment) { + buffer = seg.raw + editing = seg.id + } + + private func commit(_ seg: KBDocSegment) { + source = KBDocSegment.replaceLines(in: source, start: seg.lineStart, end: seg.lineEnd, with: buffer) + editing = nil + } + + // MARK: - Rendering + + @ViewBuilder + private func rendered(_ seg: KBDocSegment) -> some View { + switch seg.kind { + case .heading(let level): + InlineMarkdownText(headingText(seg.raw)) + .font(DS.serif(headingSize(level), weight: level <= 2 ? .semibold : .medium)) + .foregroundStyle(DS.Ink.p1).fixedSize(horizontal: false, vertical: true) + .padding(.top, level <= 2 ? 8 : 2) + case .paragraph: + InlineMarkdownText(seg.raw) + .font(DS.serif(14)).foregroundStyle(DS.Ink.p2).lineSpacing(2) + .fixedSize(horizontal: false, vertical: true) + case .list: + renderedList(seg.raw) + case .quote: + HStack(spacing: 10) { + Rectangle().fill(DS.Accent.fill.opacity(0.5)).frame(width: 2) + InlineMarkdownText(seg.raw.replacingOccurrences(of: "> ", with: "").replacingOccurrences(of: ">", with: "")) + .font(DS.serif(14)).foregroundStyle(DS.Ink.p3) + .fixedSize(horizontal: false, vertical: true) + } + case .code: + Text(stripFence(seg.raw)) + .font(DS.mono(12)).foregroundStyle(DS.Ink.p1) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12).padding(.vertical, 10) + .neumorphicPressed(cornerRadius: 6).fixedSize(horizontal: false, vertical: true) + case .rule: + EditorialRule().padding(.vertical, 2) + case .frontmatter: + metaText(seg.raw) + case .table: + EmptyView() // handled in segmentView + } + } + + private func renderedList(_ raw: String) -> some View { + let (ordinal, text, depth) = listParts(raw) + return HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(ordinal).font(DS.sans(13)).foregroundStyle(DS.Ink.p3).frame(minWidth: 14, alignment: .trailing) + InlineMarkdownText(text).font(DS.serif(14)).foregroundStyle(DS.Ink.p2) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.leading, CGFloat(depth) * 16) + } + + private func metaText(_ raw: String) -> some View { + Text(raw).font(DS.mono(11)).foregroundStyle(DS.Ink.p3) + .frame(maxWidth: .infinity, alignment: .leading).textSelection(.enabled) + } + + @ViewBuilder + private func metadataDisclosure(_ history: [KBDocSegment]) -> some View { + VStack(alignment: .leading, spacing: 8) { + Button { withAnimation(.easeInOut(duration: 0.15)) { showMeta.toggle() } } label: { + HStack(spacing: 6) { + Image(systemName: showMeta ? "chevron.down" : "chevron.right") + .font(.system(size: 9, weight: .semibold)).foregroundStyle(DS.Ink.p4) + Text("History & properties").font(DS.sans(11, weight: .medium)).foregroundStyle(DS.Ink.p3) + Spacer(minLength: 0) + }.contentShape(Rectangle()) + }.buttonStyle(.plain) + if showMeta { + VStack(alignment: .leading, spacing: 8) { + ForEach(history) { seg in + rendered(seg) + .contentShape(Rectangle()) + .onTapGesture(count: 2) { startEdit(seg) } + } + }.padding(.leading, 14) + } + } + .padding(10).neumorphicPressed(cornerRadius: 6) + } + + // MARK: - Partition (collapse leading frontmatter + changelog) + + private func partition(_ segs: [KBDocSegment]) -> (title: KBDocSegment?, history: [KBDocSegment], rest: [KBDocSegment]) { + var title: KBDocSegment? = nil + var history: [KBDocSegment] = [] + var rest: [KBDocSegment] = [] + var leading = true + for seg in segs { + if leading { + if title == nil, history.isEmpty, case .heading(let lvl) = seg.kind, lvl == 1 { + title = seg; continue + } + if seg.kind == .frontmatter || isMetadata(seg) { history.append(seg); continue } + leading = false + } + rest.append(seg) + } + return (title, history, rest) + } + + private func isMetadata(_ seg: KBDocSegment) -> Bool { + guard seg.kind == .paragraph else { return false } + let t = seg.raw.trimmingCharacters(in: .whitespaces) + return t.hasPrefix("**Last updated:**") || t.hasPrefix("**Prev:**") || t.hasPrefix("**Parent:**") + } + + // MARK: - Text helpers + + private func headingText(_ raw: String) -> String { + let t = raw.trimmingCharacters(in: .whitespaces) + return String(t.drop { $0 == "#" }).trimmingCharacters(in: .whitespaces) + } + private func headingSize(_ level: Int) -> CGFloat { + switch level { case 1: return 23; case 2: return 18.5; case 3: return 16; default: return 14.5 } + } + private func stripFence(_ raw: String) -> String { + var lines = raw.components(separatedBy: "\n") + if lines.first?.trimmingCharacters(in: .whitespaces).hasPrefix("```") == true { lines.removeFirst() } + if lines.last?.trimmingCharacters(in: .whitespaces).hasPrefix("```") == true { lines.removeLast() } + return lines.joined(separator: "\n") + } + private func listParts(_ raw: String) -> (ordinal: String, text: String, depth: Int) { + let leadingWS = raw.prefix { $0 == " " || $0 == "\t" } + let depth = leadingWS.reduce(0) { $0 + ($1 == "\t" ? 1 : 0) } + (leadingWS.filter { $0 == " " }.count / 2) + let content = raw[leadingWS.endIndex...] + if let first = content.first, "-*+".contains(first), content.dropFirst().first == " " { + return ("•", String(content.dropFirst(2)).trimmingCharacters(in: .whitespaces), depth) + } + let digits = content.prefix { $0.isNumber } + if !digits.isEmpty, content[digits.endIndex...].first == "." { + let after = content[digits.endIndex...].dropFirst() + return ("\(digits).", String(after).trimmingCharacters(in: .whitespaces), depth) + } + return ("•", String(content).trimmingCharacters(in: .whitespaces), depth) + } +} + +/// A table whose cells you edit by double-clicking. Renders like the read-only +/// table but swaps the double-clicked cell for a text field; committing calls +/// back with the cell's source line + column so only that cell is rewritten. +struct KBEditableTableView: View { + let headers: [String] + let rows: [[String]] + let rowLines: [Int] + let onEditCell: (_ sourceLine: Int, _ col: Int, _ value: String) -> Void + + @State private var editing: EditKey? = nil + @State private var buffer = "" + + private struct EditKey: Equatable { let row: Int; let col: Int } + + private let minColumnWidth: CGFloat = 70 + private let maxColumnWidth: CGFloat = 300 + private let hPad: CGFloat = 10 + private let charWidth: CGFloat = 6.5 + + var body: some View { + let widths = columnWidths() + ScrollView(.horizontal, showsIndicators: true) { + VStack(alignment: .leading, spacing: 0) { + headerRow(widths) + ForEach(Array(rows.enumerated()), id: \.offset) { r, cells in + bodyRow(r, cells: cells, widths: widths, + background: r.isMultiple(of: 2) ? .clear : DS.Paper.sunk.opacity(0.35)) + } + } + .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(DS.Rule.soft, lineWidth: 0.5)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + + private func headerRow(_ widths: [CGFloat]) -> some View { + HStack(alignment: .top, spacing: 0) { + ForEach(Array(widths.enumerated()), id: \.offset) { i, width in + InlineMarkdownText(i < headers.count ? headers[i] : "") + .font(DS.sans(12, weight: .semibold)).foregroundStyle(DS.Ink.p1) + .padding(.horizontal, hPad).padding(.vertical, 6) + .frame(width: width, alignment: .topLeading) + } + } + .background(DS.Paper.sunk) + .overlay(alignment: .bottom) { Rectangle().fill(DS.Rule.soft).frame(height: 0.5) } + } + + private func bodyRow(_ r: Int, cells: [String], widths: [CGFloat], background: Color) -> some View { + HStack(alignment: .top, spacing: 0) { + ForEach(Array(widths.enumerated()), id: \.offset) { c, width in + cellView(r, c, value: c < cells.count ? cells[c] : "", width: width) + } + } + .background(background) + .overlay(alignment: .bottom) { Rectangle().fill(DS.Rule.soft).frame(height: 0.5) } + } + + @ViewBuilder + private func cellView(_ r: Int, _ c: Int, value: String, width: CGFloat) -> some View { + if editing == EditKey(row: r, col: c) { + TextField("", text: $buffer) + .textFieldStyle(.plain).font(DS.serif(12.5)).foregroundStyle(DS.Ink.p1) + .padding(.horizontal, hPad - 2).padding(.vertical, 4) + .background(RoundedRectangle(cornerRadius: 4).fill(DS.Paper.base)) + .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(DS.Accent.fill.opacity(0.6), lineWidth: 1)) + .frame(width: width, alignment: .topLeading) + .onSubmit { commit(r, c) } + .onExitCommand { editing = nil } + } else { + InlineMarkdownText(value) + .font(DS.serif(12.5)).foregroundStyle(DS.Ink.p2) + .padding(.horizontal, hPad).padding(.vertical, 6) + .frame(width: width, alignment: .topLeading) + .contentShape(Rectangle()) + .onTapGesture(count: 2) { buffer = value; editing = EditKey(row: r, col: c) } + .help("Double-click to edit") + } + } + + private func commit(_ r: Int, _ c: Int) { + if r < rowLines.count { onEditCell(rowLines[r], c, buffer) } + editing = nil + } + + private func columnWidths() -> [CGFloat] { + (0.. Void + /// Called after a rename — parent re-selects the file at its new URL. + let onRenamed: (URL) -> Void + + private enum Mode: String { case read, rich, source } + + @State private var draft: String = "" + @State private var originalText: String = "" + @State private var baseline: Date? = nil + // Markdown opens in "Read": rendered, but you edit in place (double-click a + // block/cell). Rich (live markdown) and Source (raw) are one toggle away. + // loadFile() forces Source for non-markdown (YAML). + @State private var mode: Mode = .read + @State private var isSaving = false + @State private var errorMessage: String? = nil + @State private var showConflict = false + /// File changed on disk while the user had unsaved edits. + @State private var externallyChanged = false + @State private var showRename = false + @State private var renameText = "" + @State private var showDeleteConfirm = false + + private var isDirty: Bool { draft != originalText } + private var isMarkdown: Bool { node.ext == "md" } + + var body: some View { + VStack(spacing: 0) { + header + EditorialRule() + if externallyChanged { changedOnDiskBanner } + content + } + .background(Color.clear) + .onChange(of: node.id) { _, _ in loadFile() } + .onAppear { loadFile() } + // Service reparses on FSEvents; re-check whether our open file moved + // out from under us. + .onReceive(service.objectWillChange) { _ in + DispatchQueue.main.async { detectExternalChange() } + } + .alert("File changed on disk", isPresented: $showConflict) { + Button("Overwrite", role: .destructive) { forceSave() } + Button("Reload", role: .cancel) { loadFile() } + } message: { + Text("\(node.name) was modified by another process since you opened it. Overwrite it with your version, or reload to discard your changes?") + } + .alert("Couldn't save", isPresented: Binding( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + )) { + Button("OK", role: .cancel) { errorMessage = nil } + } message: { + Text(errorMessage ?? "") + } + .alert("Delete \(node.displayName)?", isPresented: $showDeleteConfirm) { + Button("Delete", role: .destructive) { performDelete() } + Button("Cancel", role: .cancel) {} + } message: { + Text("This removes \(node.relativePath) and commits the deletion. This can't be undone from the app.") + } + .sheet(isPresented: $showRename) { renameSheet } + } + + // MARK: - Header + + private var header: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + breadcrumb + HStack(spacing: 6) { + Text(node.name).font(DS.mono(11)).foregroundStyle(DS.Ink.p3) + if isDirty { + Circle().fill(DS.Accent.fill).frame(width: 6, height: 6) + Text("unsaved").font(DS.sans(10.5)).foregroundStyle(DS.Accent.ink) + } + } + } + Spacer() + + if isMarkdown { + EditorialSegmentedControl( + selection: Binding(get: { mode }, + set: { mode = $0 }), + options: [(label: "Read", value: .read), + (label: "Rich", value: .rich), + (label: "Source", value: .source)], + minSegmentWidth: 54 + ) + } + + Button { loadFile() } label: { Label("Reload", systemImage: "arrow.clockwise") } + .buttonStyle(.plain).foregroundStyle(DS.Ink.p3).font(DS.sans(12)) + .help("Reload from disk (discards unsaved changes)") + + Button(action: save) { + Label("Save", systemImage: "checkmark") + .font(DS.sans(12, weight: .semibold)) + } + .buttonStyle(.plain) + .foregroundStyle(isDirty && !isSaving ? .white : DS.Ink.p4) + .padding(.horizontal, 12).padding(.vertical, 6) + .background(Capsule().fill(isDirty && !isSaving ? DS.Accent.fill : DS.Paper.sunk)) + .disabled(!isDirty || isSaving) + .keyboardShortcut("s", modifiers: .command) + + Menu { + Button("Rename…") { renameText = node.displayName; showRename = true } + Button("Reveal in Finder") { NSWorkspace.shared.activateFileViewerSelecting([node.url]) } + Divider() + Button("Delete…", role: .destructive) { showDeleteConfirm = true } + } label: { + Image(systemName: "ellipsis.circle").font(.system(size: 15)).foregroundStyle(DS.Ink.p3) + } + .menuStyle(.borderlessButton).frame(width: 28) + } + .padding(.horizontal, 20).padding(.vertical, 12) + } + + private var breadcrumb: some View { + let parts = node.relativePath.components(separatedBy: "/") + return HStack(spacing: 4) { + ForEach(Array(parts.enumerated()), id: \.offset) { i, part in + if i > 0 { Text("/").font(DS.sans(11)).foregroundStyle(DS.Ink.p4) } + Text((part as NSString).deletingPathExtension.isEmpty ? part : (i == parts.count - 1 ? (part as NSString).deletingPathExtension : part)) + .font(DS.sans(11, weight: i == parts.count - 1 ? .semibold : .regular)) + .foregroundStyle(i == parts.count - 1 ? DS.Ink.p1 : DS.Ink.p3) + } + } + } + + private var changedOnDiskBanner: some View { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(DS.Status.warn) + Text("This file changed on disk. Reload to see the new version, or save to overwrite it.") + .font(DS.sans(12)).foregroundStyle(DS.Ink.p2) + Spacer() + Button("Reload") { loadFile() }.buttonStyle(.plain) + .font(DS.sans(12, weight: .semibold)).foregroundStyle(DS.Accent.ink) + } + .padding(.horizontal, 20).padding(.vertical, 8) + .background(DS.Accent.wash) + } + + // MARK: - Content + + @ViewBuilder + private var content: some View { + if !isMarkdown { + KBSourceEditor(text: $draft) + } else { + switch mode { + case .read: + // Rendered, but editable in place: double-click a paragraph, + // heading, list item or table cell to edit just that piece. + KBEditableView(source: $draft) + case .rich: + KBLiveEditor(text: $draft) + case .source: + KBSourceEditor(text: $draft) + } + } + } + + // MARK: - Actions + + private func loadFile() { + let text = service.readFile(node.url) ?? "" + draft = text + originalText = text + baseline = GuardedFileWrite.fsModificationDate(node.url) + externallyChanged = false + if !isMarkdown { mode = .source } + } + + /// Compare the open file's on-disk mtime against our baseline. Silently + /// reloads if the user has no unsaved edits; otherwise flags the banner. + private func detectExternalChange() { + guard FileManager.default.fileExists(atPath: node.url.path) else { return } + let current = GuardedFileWrite.fsModificationDate(node.url) + guard let current, let baseline, + abs(current.timeIntervalSince(baseline)) > 0.0005 else { return } + if isDirty { + externallyChanged = true + } else { + loadFile() + } + } + + private func save() { + guard isDirty, !isSaving else { return } + isSaving = true + let contents = draft + let captured = baseline + Task { + do { + try await writer.save(fileURL: node.url, contents: contents, + baseline: captured, label: node.displayName) + await MainActor.run { + originalText = contents + baseline = GuardedFileWrite.fsModificationDate(node.url) + externallyChanged = false + isSaving = false + } + } catch KBWriterError.conflict { + await MainActor.run { isSaving = false; showConflict = true } + } catch { + await MainActor.run { isSaving = false; errorMessage = describe(error) } + } + } + } + + /// Overwrite despite a detected conflict — re-baseline to the current disk + /// mtime so the guard passes, then save. + private func forceSave() { + let contents = draft + let current = GuardedFileWrite.fsModificationDate(node.url) + isSaving = true + Task { + do { + try await writer.save(fileURL: node.url, contents: contents, + baseline: current, label: node.displayName) + await MainActor.run { + originalText = contents + baseline = GuardedFileWrite.fsModificationDate(node.url) + externallyChanged = false + isSaving = false + } + } catch { + await MainActor.run { isSaving = false; errorMessage = describe(error) } + } + } + } + + private func performDelete() { + Task { + do { + try await writer.delete(fileURL: node.url, label: node.displayName) + await MainActor.run { service.reload(); onDeleted() } + } catch { + await MainActor.run { errorMessage = describe(error) } + } + } + } + + private func performRename() { + let newName = renameText + Task { + do { + let dest = try await writer.rename(fileURL: node.url, to: newName) + await MainActor.run { service.reload(); onRenamed(dest); showRename = false } + } catch { + await MainActor.run { errorMessage = describe(error); showRename = false } + } + } + } + + private var renameSheet: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Rename note").font(DS.serif(16, weight: .semibold)).foregroundStyle(DS.Ink.p1) + TextField("New name", text: $renameText) + .textFieldStyle(.roundedBorder).font(DS.sans(13)) + .onSubmit(performRename) + HStack { + Spacer() + Button("Cancel") { showRename = false }.keyboardShortcut(.cancelAction) + Button("Rename") { performRename() }.keyboardShortcut(.defaultAction) + .disabled(renameText.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .padding(20).frame(width: 360) + } + + private func describe(_ error: Error) -> String { + switch error { + case KBWriterError.alreadyExists(let n): return "A file named \(n) already exists." + case KBWriterError.notFound(let n): return "\(n) no longer exists." + case KBWriterError.writeFailed(let m): return m + case KBWriterError.readFailed(let m): return m + case KBWriterError.outsideKnowledgeBase(let n): return "\(n) is outside the knowledge base." + case KBWriterError.emptyName: return "The name can't be empty." + case KBWriterError.conflict(let f): return "\(f) changed on disk." + default: return error.localizedDescription + } + } +} + +/// Plain-text source editor with a monospaced font over the recessed paper +/// surface. Wraps `TextEditor` so the KB editor reads markdown/YAML source the +/// way the plugin writes it. +struct KBSourceEditor: View { + @Binding var text: String + + var body: some View { + TextEditor(text: $text) + .font(DS.mono(12.5)) + .foregroundStyle(DS.Ink.p1) + .scrollContentBackground(.hidden) + .background(DS.Paper.sunk) + .padding(.horizontal, 12).padding(.vertical, 10) + } +} diff --git a/Scout/KnowledgeBase/Views/KBLiveEditor.swift b/Scout/KnowledgeBase/Views/KBLiveEditor.swift new file mode 100644 index 0000000..8c1693c --- /dev/null +++ b/Scout/KnowledgeBase/Views/KBLiveEditor.swift @@ -0,0 +1,192 @@ +import SwiftUI +import AppKit + +/// A "live preview" markdown editor (Obsidian-style): the text you edit is exact +/// markdown — nothing is converted or round-tripped — but it's styled inline as +/// you type (headings enlarged, **bold**/_italic_ rendered, `code`, `[[wikilinks]]`, +/// links, tags, tables and code fences highlighted, syntax markers dimmed). +/// +/// Because the underlying string is never transformed, the KB's load-bearing +/// tokens (`[[wikilinks]]`, `[#TAG]`, tables, frontmatter) are preserved exactly +/// for the scout-plugin — unlike a true WYSIWYG model that re-serializes. +struct KBLiveEditor: NSViewRepresentable { + @Binding var text: String + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + func makeNSView(context: Context) -> NSScrollView { + let scroll = NSTextView.scrollableTextView() + scroll.borderType = .noBorder + scroll.drawsBackground = true + scroll.backgroundColor = NSColor(DS.Paper.sunk) + guard let tv = scroll.documentView as? NSTextView else { return scroll } + + tv.delegate = context.coordinator + tv.isRichText = false + tv.allowsUndo = true + tv.isAutomaticQuoteSubstitutionEnabled = false + tv.isAutomaticDashSubstitutionEnabled = false + tv.isAutomaticTextReplacementEnabled = false + tv.textContainerInset = NSSize(width: 16, height: 14) + tv.backgroundColor = NSColor(DS.Paper.sunk) + tv.drawsBackground = true + tv.typingAttributes = Coordinator.baseAttributes + tv.string = text + context.coordinator.textView = tv + context.coordinator.highlight() + return scroll + } + + func updateNSView(_ scroll: NSScrollView, context: Context) { + context.coordinator.parent = self + guard let tv = scroll.documentView as? NSTextView else { return } + if tv.string != text { + context.coordinator.isProgrammatic = true + let sel = tv.selectedRange() + tv.string = text + let len = (text as NSString).length + tv.setSelectedRange(NSRange(location: min(sel.location, len), length: 0)) + context.coordinator.highlight() + context.coordinator.isProgrammatic = false + } + } + + final class Coordinator: NSObject, NSTextViewDelegate { + var parent: KBLiveEditor + weak var textView: NSTextView? + var isProgrammatic = false + private var restyleItem: DispatchWorkItem? + + init(_ parent: KBLiveEditor) { self.parent = parent } + + func textDidChange(_ notification: Notification) { + guard let tv = textView, !isProgrammatic else { return } + parent.text = tv.string + scheduleHighlight() + } + + /// Debounce restyling so typing in a large note stays smooth. + private func scheduleHighlight() { + restyleItem?.cancel() + let item = DispatchWorkItem { [weak self] in self?.highlight() } + restyleItem = item + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12, execute: item) + } + + // MARK: - Fonts & colors + + static let bodyFont = serif(14) + static let baseAttributes: [NSAttributedString.Key: Any] = [ + .font: bodyFont, .foregroundColor: NSColor(DS.Ink.p1), + ] + + static func serif(_ size: CGFloat) -> NSFont { + NSFont(name: "Newsreader", size: size) + ?? NSFont(name: "New York", size: size) + ?? NSFont.systemFont(ofSize: size) + } + static func mono(_ size: CGFloat) -> NSFont { + NSFont(name: "JetBrains Mono", size: size) + ?? NSFont.monospacedSystemFont(ofSize: size, weight: .regular) + } + static func bold(_ font: NSFont) -> NSFont { + NSFontManager.shared.convert(font, toHaveTrait: .boldFontMask) + } + static func italic(_ font: NSFont) -> NSFont { + NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) + } + static func headingSize(_ level: Int) -> CGFloat { + switch level { case 1: return 23; case 2: return 19; case 3: return 16.5; default: return 14.5 } + } + + private var faint: NSColor { NSColor(DS.Ink.p4) } + private var accent: NSColor { NSColor(DS.Accent.ink) } + + // MARK: - Highlighting + + func highlight() { + guard let tv = textView, let storage = tv.textStorage else { return } + let ns = tv.string as NSString + let full = NSRange(location: 0, length: ns.length) + storage.beginEditing() + storage.setAttributes(Self.baseAttributes, range: full) + + // Block-level + forEach(#"^---\n[\s\S]*?\n---"#, in: ns) { m in // frontmatter + self.setFont(Self.mono(11), m.range, in: storage) + storage.addAttribute(.foregroundColor, value: self.faint, range: m.range) + } + forEach(#"```[\s\S]*?```"#, in: ns) { m in // fenced code + self.setFont(Self.mono(12), m.range, in: storage) + storage.addAttribute(.foregroundColor, value: NSColor(DS.Ink.p2), range: m.range) + } + forEach(#"^\s*\|.*\|\s*$"#, options: [.anchorsMatchLines], in: ns) { m in // table rows + self.setFont(Self.mono(11.5), m.range, in: storage) + } + + // Inline + forEach(#"\*\*([^*\n]+)\*\*"#, in: ns) { m in // bold + self.setFont(Self.bold(Self.bodyFont), m.range, in: storage) + self.dimMarkers(m.range, markerLen: 2, in: storage) + } + forEach(#"(?\s?.*$"#, options: [.anchorsMatchLines], in: ns) { m in // blockquote + storage.addAttribute(.foregroundColor, value: NSColor(DS.Ink.p3), range: m.range) + } + forEach(#"^(\s*)([-*+]|\d+\.)\s"#, options: [.anchorsMatchLines], in: ns) { m in // list markers + storage.addAttribute(.foregroundColor, value: self.accent, range: m.range(at: 2)) + } + + // Headings last so the line font wins over any inline styling. + forEach(#"^(#{1,6})\s+.*$"#, options: [.anchorsMatchLines], in: ns) { m in + let level = m.range(at: 1).length + self.setFont(Self.bold(Self.serif(Self.headingSize(level))), m.range, in: storage) + storage.addAttribute(.foregroundColor, value: NSColor(DS.Ink.p1), range: m.range) + storage.addAttribute(.foregroundColor, value: self.faint, range: m.range(at: 1)) // dim ### + } + + storage.endEditing() + } + + private func setFont(_ font: NSFont, _ range: NSRange, in storage: NSTextStorage) { + storage.addAttribute(.font, value: font, range: range) + } + + /// Dim the leading and trailing `markerLen` characters of a delimited + /// span (e.g. the `**` or `[[`/`]]`) so syntax recedes but stays visible. + private func dimMarkers(_ range: NSRange, markerLen: Int, in storage: NSTextStorage) { + guard range.length >= markerLen * 2 else { return } + storage.addAttribute(.foregroundColor, value: faint, + range: NSRange(location: range.location, length: markerLen)) + storage.addAttribute(.foregroundColor, value: faint, + range: NSRange(location: range.location + range.length - markerLen, length: markerLen)) + } + + private func forEach(_ pattern: String, + options: NSRegularExpression.Options = [], + in text: NSString, + _ body: (NSTextCheckingResult) -> Void) { + guard let re = try? NSRegularExpression(pattern: pattern, options: options) else { return } + re.enumerateMatches(in: text as String, range: NSRange(location: 0, length: text.length)) { m, _, _ in + if let m { body(m) } + } + } + } +} diff --git a/Scout/KnowledgeBase/Views/KBLocalGraphView.swift b/Scout/KnowledgeBase/Views/KBLocalGraphView.swift new file mode 100644 index 0000000..16d1d19 --- /dev/null +++ b/Scout/KnowledgeBase/Views/KBLocalGraphView.swift @@ -0,0 +1,125 @@ +import SwiftUI +import Grape + +/// Force-directed graph rendered with Grape (native d3-force for SwiftUI). Shared +/// by the per-note local graph (right panel) and the whole-KB global graph +/// (overview). Tapping a node navigates to it; drag pans, pinch zooms. +struct KBGraphCanvas: View { + let graph: KBGraph + let onNavigate: (String) -> Void + /// Only label nodes at/above this degree (keeps a dense global graph legible). + /// The center is always labeled. + let labelMinDegree: Int + + @State private var state: ForceDirectedGraphState + + init(graph: KBGraph, onNavigate: @escaping (String) -> Void, + labelMinDegree: Int = 1, initialScale: Double = 1.8) { + self.graph = graph + self.onNavigate = onNavigate + self.labelMinDegree = labelMinDegree + // Start zoomed in so node labels are legible without manual pinching; + // the simulation runs (initialIsRunning) and the user can pan/zoom. + _state = State(initialValue: ForceDirectedGraphState( + initialIsRunning: true, + initialModelTransform: .identity.scale(by: initialScale) + )) + } + + var body: some View { + // Grape renders node symbols and text labels at a fixed pixel size — the + // viewport zoom only spreads node positions apart, it does NOT scale + // symbols/text. To get Obsidian-like behavior (zoom in → bigger, readable + // labels) we read the live zoom and multiply node radius + label font by + // it. `state` is Observable, so a pinch re-renders and re-rasterizes the + // labels at the new size. + let z = min(5.0, max(0.6, Double(state.modelTransform.scale))) + return ForceDirectedGraph(states: state) { + Series(graph.nodes) { node in + NodeMark(id: node.id) + .symbol(Circle()) + .symbolSize(radius: radius(node) * z) + .foregroundStyle(node.group.color) + .stroke() + .annotation(alignment: .bottom, offset: .init(dx: 0, dy: 1)) { () -> Text? in + guard node.isCenter || node.degree >= labelMinDegree else { return nil } + return Text(node.label) + .font(DS.sans(CGFloat((node.isCenter ? 9.0 : 8.0) * z), + weight: node.isCenter ? .bold : .medium)) + .foregroundColor(DS.Ink.p1) + } + } + Series(graph.edges) { edge in + LinkMark(from: edge.from, to: edge.to) + } + } force: { + .manyBody(strength: -45) + .center() + .link(originalLength: 26.0, stiffness: .weightedByDegree { _, _ in 1.0 }) + } + .graphOverlay { proxy in + Rectangle().fill(.clear).contentShape(Rectangle()) + .withGraphDragGesture(proxy, of: String.self) + .withGraphMagnifyGesture(proxy) + .withGraphTapGesture(proxy, of: String.self) { onNavigate($0) } + } + } + + /// Base node radius (multiplied by the live zoom in `body`). Kept generous + /// so the node is an easy target for the drag-to-reposition gesture — the + /// drag hit area is derived from the symbol size. + private func radius(_ node: KBGraphNode) -> Double { + node.isCenter ? 5.2 : max(3.4, min(6.2, 3.4 + Double(node.degree) * 0.4)) + } +} + +/// Legend of entity-type colors present in a graph. +struct KBGraphLegend: View { + let groups: [KBEntityGroup] + var body: some View { + FlowLayout(spacing: 8) { + ForEach(groups, id: \.self) { g in + HStack(spacing: 4) { + Circle().fill(g.color).frame(width: 7, height: 7) + Text(g.label).font(DS.sans(9.5)).foregroundStyle(DS.Ink.p3) + } + } + } + .padding(.horizontal, 8) + } +} + +/// The per-note neighbourhood graph for the right panel, with empty state. +struct KBLocalGraphView: View { + let graph: KBGraph + let onNavigate: (String) -> Void + + var body: some View { + VStack(spacing: 8) { + if graph.nodes.count <= 1 { + emptyState + } else { + KBGraphCanvas(graph: graph, onNavigate: onNavigate) + KBGraphLegend(groups: presentGroups) + } + } + } + + private var presentGroups: [KBEntityGroup] { + Array(Set(graph.nodes.map(\.group))).sorted { $0.label < $1.label } + } + + private var emptyState: some View { + VStack(spacing: 6) { + Image(systemName: "point.3.connected.trianglepath.dotted") + .font(.system(size: 22)).foregroundStyle(DS.Ink.p4) + Text("No linked notes") + .font(DS.sans(11)).foregroundStyle(DS.Ink.p4) + Text("This note has no [[wikilinks]] to or from other notes.") + .font(DS.sans(10)).foregroundStyle(DS.Ink.p4) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(16) + } +} diff --git a/Scout/KnowledgeBase/Views/KBMarkdownPreview.swift b/Scout/KnowledgeBase/Views/KBMarkdownPreview.swift new file mode 100644 index 0000000..da611e7 --- /dev/null +++ b/Scout/KnowledgeBase/Views/KBMarkdownPreview.swift @@ -0,0 +1,435 @@ +import SwiftUI + +/// Read-mode rendering of a knowledge-base markdown document. Splits the source +/// line-by-line into structural blocks (headings, lists, tables, blockquotes, +/// code fences, rules, prose) and renders each with the editorial design system. +/// Inline formatting and `[[wikilinks]]` are delegated to `InlineMarkdownText`. +/// +/// For business readability the view (a) constrains prose to a comfortable +/// reading column, (b) renders GitHub-style tables as a real grid, and (c) +/// collapses the noisy leading metadata (`**Last updated:** / **Prev:** / +/// **Parent:**` changelog + YAML frontmatter) into a closed disclosure so the +/// substance sits at the top. +struct KBMarkdownPreview: View { + let source: String + + /// Comfortable reading measure for prose; tables/code may exceed it (they + /// scroll horizontally inside their own container). + private let readingWidth: CGFloat = 760 + + @State private var showMeta = false + + var body: some View { + let (frontmatter, body) = Self.splitFrontmatter(source) + let parts = Self.partition(Self.parse(body)) + let hasMeta = !parts.history.isEmpty || (frontmatter?.isEmpty == false) + + HStack(spacing: 0) { + Spacer(minLength: 0) + VStack(alignment: .leading, spacing: 12) { + if let title = parts.title { render(title) } + if hasMeta { metadataDisclosure(frontmatter: frontmatter, history: parts.history) } + ForEach(Array(parts.rest.enumerated()), id: \.offset) { _, block in + render(block) + } + } + .frame(maxWidth: readingWidth, alignment: .leading) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + // MARK: - Metadata disclosure + + @ViewBuilder + private func metadataDisclosure(frontmatter: [String]?, history: [Block]) -> some View { + VStack(alignment: .leading, spacing: 8) { + Button { withAnimation(.easeInOut(duration: 0.15)) { showMeta.toggle() } } label: { + HStack(spacing: 6) { + Image(systemName: showMeta ? "chevron.down" : "chevron.right") + .font(.system(size: 9, weight: .semibold)).foregroundStyle(DS.Ink.p4) + Text("History & properties") + .font(DS.sans(11, weight: .medium)).foregroundStyle(DS.Ink.p3) + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if showMeta { + VStack(alignment: .leading, spacing: 8) { + if let frontmatter, !frontmatter.isEmpty { + VStack(alignment: .leading, spacing: 3) { + ForEach(Array(frontmatter.enumerated()), id: \.offset) { _, line in + Text(line).font(DS.mono(11)).foregroundStyle(DS.Ink.p3) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + ForEach(Array(history.enumerated()), id: \.offset) { _, block in + render(block) + } + } + .padding(.leading, 14) + } + } + .padding(10) + .neumorphicPressed(cornerRadius: 6) + } + + // MARK: - Block rendering + + @ViewBuilder + private func render(_ block: Block) -> some View { + switch block { + case .heading(let level, let text): + InlineMarkdownText(text) + .font(DS.serif(headingSize(level), weight: level <= 2 ? .semibold : .medium)) + .foregroundStyle(DS.Ink.p1) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, level <= 2 ? 8 : 2) + + case .prose(let text): + InlineMarkdownText(text) + .font(DS.serif(14)) + .foregroundStyle(DS.Ink.p2) + .textSelection(.enabled) + .lineSpacing(2) + .fixedSize(horizontal: false, vertical: true) + + case .listItem(let depth, let ordinal, let text): + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(ordinal ?? "•") + .font(DS.sans(13)) + .foregroundStyle(DS.Ink.p3) + .frame(minWidth: 14, alignment: .trailing) + InlineMarkdownText(text) + .font(DS.serif(14)) + .foregroundStyle(DS.Ink.p2) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.leading, CGFloat(depth) * 16) + + case .quote(let text): + HStack(spacing: 10) { + Rectangle().fill(DS.Accent.fill.opacity(0.5)).frame(width: 2) + InlineMarkdownText(text) + .font(DS.serif(14)) + .foregroundStyle(DS.Ink.p3) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + + case .code(let code): + Text(code) + .font(DS.mono(12)) + .foregroundStyle(DS.Ink.p1) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .neumorphicPressed(cornerRadius: 6) + .fixedSize(horizontal: false, vertical: true) + + case .table(let headers, let rows): + KBTableBlockView(headers: headers, rows: rows) + + case .rule: + EditorialRule().padding(.vertical, 2) + } + } + + private func headingSize(_ level: Int) -> CGFloat { + switch level { + case 1: return 23 + case 2: return 18.5 + case 3: return 16 + default: return 14.5 + } + } + + // MARK: - Parsing + + enum Block: Equatable { + case heading(level: Int, text: String) + case prose(String) + case listItem(depth: Int, ordinal: String?, text: String) + case quote(String) + case code(String) + case table(headers: [String], rows: [[String]]) + case rule + } + + /// Separate a leading `--- ... ---` YAML frontmatter block (if present) from + /// the document body. Returns the frontmatter lines (without the fences) and + /// the remaining body text. + static func splitFrontmatter(_ text: String) -> (frontmatter: [String]?, body: String) { + let lines = text.components(separatedBy: "\n") + guard lines.first?.trimmingCharacters(in: .whitespaces) == "---" else { + return (nil, text) + } + var i = 1 + while i < lines.count { + if lines[i].trimmingCharacters(in: .whitespaces) == "---" { + let fm = Array(lines[1.. (title: Block?, history: [Block], rest: [Block]) { + var title: Block? = nil + var history: [Block] = [] + var rest: [Block] = [] + var leading = true + for block in blocks { + if leading { + if title == nil, history.isEmpty, + case .heading(let lvl, _) = block, lvl == 1 { + title = block + continue + } + if isMetadata(block) { history.append(block); continue } + leading = false + } + rest.append(block) + } + return (title, history, rest) + } + + /// A prose block is "metadata" when it's the file's changelog/parent header + /// (`**Last updated:**`, `**Prev:**`, `**Parent:**`). + static func isMetadata(_ block: Block) -> Bool { + guard case .prose(let text) = block else { return false } + let t = text.trimmingCharacters(in: .whitespaces) + return t.hasPrefix("**Last updated:**") + || t.hasPrefix("**Prev:**") + || t.hasPrefix("**Parent:**") + } + + static func parse(_ body: String) -> [Block] { + var blocks: [Block] = [] + var prose: [String] = [] + var inCode = false + var code: [String] = [] + let lines = body.components(separatedBy: "\n") + + func flushProse() { + let joined = prose.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + if !joined.isEmpty { blocks.append(.prose(joined)) } + prose.removeAll(keepingCapacity: true) + } + + var i = 0 + while i < lines.count { + let line = lines[i] + let trimmed = line.trimmingCharacters(in: .whitespaces) + + // Code fence (takes precedence so `|` inside code isn't a table). + if trimmed.hasPrefix("```") { + if inCode { + blocks.append(.code(code.joined(separator: "\n"))) + code.removeAll(keepingCapacity: true) + inCode = false + } else { + flushProse() + inCode = true + } + i += 1; continue + } + if inCode { code.append(line); i += 1; continue } + + // GitHub-style table: a row line followed by a `|---|` separator. + if trimmed.contains("|"), i + 1 < lines.count, isTableSeparator(lines[i + 1]) { + flushProse() + let headers = splitRow(line) + var rows: [[String]] = [] + i += 2 // skip header + separator + while i < lines.count { + let rowTrimmed = lines[i].trimmingCharacters(in: .whitespaces) + if rowTrimmed.isEmpty || !rowTrimmed.contains("|") { break } + if isTableSeparator(lines[i]) { i += 1; continue } + var cells = splitRow(lines[i]) + if cells.count < headers.count { + cells += Array(repeating: "", count: headers.count - cells.count) + } else if cells.count > headers.count { + cells = Array(cells.prefix(headers.count)) + } + rows.append(cells) + i += 1 + } + blocks.append(.table(headers: headers, rows: rows)) + continue + } + + if trimmed.isEmpty { flushProse(); i += 1; continue } + + if trimmed == "---" || trimmed == "***" || trimmed == "___" { + flushProse(); blocks.append(.rule); i += 1; continue + } + + if let heading = parseHeading(trimmed) { + flushProse(); blocks.append(heading); i += 1; continue + } + + if trimmed.hasPrefix(">") { + flushProse() + blocks.append(.quote(String(trimmed.dropFirst()).trimmingCharacters(in: .whitespaces))) + i += 1; continue + } + + if let item = parseListItem(line) { + flushProse(); blocks.append(item); i += 1; continue + } + + prose.append(line); i += 1 + } + if inCode { blocks.append(.code(code.joined(separator: "\n"))) } + flushProse() + return blocks + } + + static func parseHeading(_ trimmed: String) -> Block? { + guard trimmed.hasPrefix("#") else { return nil } + var level = 0 + for ch in trimmed { if ch == "#" { level += 1 } else { break } } + guard level >= 1, level <= 6 else { return nil } + let rest = trimmed.dropFirst(level) + guard rest.first == " " else { return nil } + return .heading(level: level, text: rest.trimmingCharacters(in: .whitespaces)) + } + + static func parseListItem(_ line: String) -> Block? { + let leading = line.prefix { $0 == " " || $0 == "\t" } + let depth = leading.reduce(0) { $0 + ($1 == "\t" ? 1 : 0) } + (leading.filter { $0 == " " }.count / 2) + let content = line[leading.endIndex...] + + if let first = content.first, "-*+".contains(first), + content.dropFirst().first == " " { + return .listItem(depth: depth, ordinal: nil, + text: content.dropFirst(2).trimmingCharacters(in: .whitespaces)) + } + let digits = content.prefix { $0.isNumber } + if !digits.isEmpty { + let afterDigits = content[digits.endIndex...] + if afterDigits.first == ".", afterDigits.dropFirst().first == " " { + return .listItem(depth: depth, ordinal: "\(digits).", + text: afterDigits.dropFirst(2).trimmingCharacters(in: .whitespaces)) + } + } + return nil + } + + // MARK: - Table helpers + + /// A `|---|:--:|` row: only pipes, dashes, colons and spaces, with at least + /// one dash. Distinguishes a table separator from a `---` horizontal rule + /// (which has no pipe). + static func isTableSeparator(_ line: String) -> Bool { + let t = line.trimmingCharacters(in: .whitespaces) + guard t.contains("-"), t.contains("|") else { return false } + return t.allSatisfy { "|-: ".contains($0) } + } + + /// Split one table row into trimmed cells, honoring `\|` escapes (the KB uses + /// them inside wikilinks like `[[people\|Alias]]`) and dropping the empty + /// cells produced by leading/trailing pipes. + static func splitRow(_ line: String) -> [String] { + let trimmed = line.trimmingCharacters(in: .whitespaces) + var cells: [String] = [] + var current = "" + var prevBackslash = false + for ch in trimmed { + if ch == "|" { + if prevBackslash { + current.removeLast() // drop the escaping backslash + current.append("|") + prevBackslash = false + } else { + cells.append(current); current = "" + } + continue + } + current.append(ch) + prevBackslash = (ch == "\\") + } + cells.append(current) + if cells.first?.trimmingCharacters(in: .whitespaces).isEmpty == true { cells.removeFirst() } + if cells.last?.trimmingCharacters(in: .whitespaces).isEmpty == true { cells.removeLast() } + return cells.map { $0.trimmingCharacters(in: .whitespaces) } + } +} + +/// A GitHub-style table. Each column gets a fixed width (narrow for IDs, capped +/// for verbose columns which then wrap); rows are `HStack`s whose height follows +/// the tallest cell, so a long cell can't overflow onto neighbouring rows (the +/// failure mode of `Grid` with multiline cells). Scrolls horizontally when the +/// column total exceeds the reading column. +struct KBTableBlockView: View { + let headers: [String] + let rows: [[String]] + + private let minColumnWidth: CGFloat = 70 + private let maxColumnWidth: CGFloat = 300 + private let hPad: CGFloat = 10 + private let charWidth: CGFloat = 6.5 + + var body: some View { + let widths = columnWidths() + ScrollView(.horizontal, showsIndicators: true) { + VStack(alignment: .leading, spacing: 0) { + row(headers, widths: widths, header: true, background: DS.Paper.sunk) + ForEach(Array(rows.enumerated()), id: \.offset) { idx, cells in + row(cells, widths: widths, header: false, + background: idx.isMultiple(of: 2) ? .clear : DS.Paper.sunk.opacity(0.35)) + } + } + .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(DS.Rule.soft, lineWidth: 0.5)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + + private func row(_ cells: [String], widths: [CGFloat], header: Bool, background: Color) -> some View { + HStack(alignment: .top, spacing: 0) { + ForEach(Array(widths.enumerated()), id: \.offset) { i, width in + cell(i < cells.count ? cells[i] : "", header: header, width: width) + } + } + .background(background) + .overlay(alignment: .bottom) { Rectangle().fill(DS.Rule.soft).frame(height: 0.5) } + } + + private func cell(_ text: String, header: Bool, width: CGFloat) -> some View { + InlineMarkdownText(text) + .font(header ? DS.sans(12, weight: .semibold) : DS.serif(12.5)) + .foregroundStyle(header ? DS.Ink.p1 : DS.Ink.p2) + .textSelection(.enabled) + .multilineTextAlignment(.leading) + .padding(.horizontal, hPad) + .padding(.vertical, 6) + .frame(width: width, alignment: .topLeading) + } + + /// Per-column width from the longest cell, clamped to [min, max]; verbose + /// columns hit the cap and wrap. Padding is added on top of the text width. + private func columnWidths() -> [CGFloat] { + (0.. Void + + /// Canonical hub notes, in display order. Filtered to those present on disk. + private static let quickLinks: [(path: String, label: String, icon: String)] = [ + ("knowledge-base/knowledge-base.md", "Index", "book.closed"), + ("knowledge-base/people.md", "People", "person.2"), + ("knowledge-base/issues.md", "Issues", "exclamationmark.triangle"), + ("knowledge-base/projects/projects.md", "Projects", "folder"), + ("knowledge-base/channels.md", "Channels", "number"), + ("knowledge-base/research-queue.md", "Research", "magnifyingglass"), + ("knowledge-base/review-queue.md", "Review", "checkmark.circle"), + ("knowledge-base/ontology/schema.yaml", "Ontology", "square.grid.3x3"), + ] + + var body: some View { + let stats = service.graphStats() + let present = Set(service.tree.flatMap(\.allFiles).map(\.relativePath)) + let links = Self.quickLinks.filter { present.contains($0.path) } + let kbGraph = service.fullGraph() + + ScrollView { + VStack(alignment: .leading, spacing: 28) { + VStack(alignment: .leading, spacing: 4) { + Text("Knowledge Base") + .font(DS.serif(24, weight: .semibold)).foregroundStyle(DS.Ink.p1) + Text("\(stats.notes) notes · \(stats.links) connections") + .font(DS.sans(13)).foregroundStyle(DS.Ink.p3) + } + + if !links.isEmpty { + VStack(alignment: .leading, spacing: 10) { + Text("QUICK ACCESS").font(DS.sans(10, weight: .semibold)).tracking(0.6) + .foregroundStyle(DS.Ink.p4) + LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], + alignment: .leading, spacing: 12) { + ForEach(links, id: \.path) { ql in + Button { onNavigate(ql.path) } label: { + HStack(spacing: 10) { + Image(systemName: ql.icon).font(.system(size: 15)) + .foregroundStyle(DS.Accent.ink).frame(width: 20) + Text(ql.label).font(DS.sans(13, weight: .medium)) + .foregroundStyle(DS.Ink.p1) + Spacer(minLength: 0) + } + .padding(.horizontal, 14).padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .editorialCard(padding: 0, neumorphic: true) + .contentShape(Rectangle()) + } + .buttonStyle(.plainHit) + } + } + } + } + + if kbGraph.edges.count > 0 { + VStack(alignment: .leading, spacing: 10) { + Text("MAP").font(DS.sans(10, weight: .semibold)).tracking(0.6) + .foregroundStyle(DS.Ink.p4) + KBGraphCanvas(graph: kbGraph, onNavigate: onNavigate, + labelMinDegree: 3, initialScale: 2.0) + .frame(height: 460) + .frame(maxWidth: 1100) + .background(RoundedRectangle(cornerRadius: 8).fill(DS.Paper.sunk.opacity(0.4))) + .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(DS.Rule.soft, lineWidth: 0.5)) + KBGraphLegend(groups: Array(Set(kbGraph.nodes.map(\.group))) + .sorted { $0.label < $1.label }) + } + } + + Text("Pick a note from the tree, or search above. Click a person, project or `[[link]]` to jump between connected notes.") + .font(DS.sans(12)).foregroundStyle(DS.Ink.p4) + .frame(maxWidth: 460, alignment: .leading) + } + .padding(32) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} diff --git a/Scout/KnowledgeBase/Views/KBRightPanel.swift b/Scout/KnowledgeBase/Views/KBRightPanel.swift new file mode 100644 index 0000000..f3905f0 --- /dev/null +++ b/Scout/KnowledgeBase/Views/KBRightPanel.swift @@ -0,0 +1,107 @@ +import SwiftUI + +/// Right pane for the selected note: a Links/Graph toggle. Links lists outgoing +/// `[[wikilinks]]` and backlinks (both navigable); Graph shows the local +/// neighbourhood. Both read from `KnowledgeBaseService`'s wikilink index. +struct KBRightPanel: View { + let relPath: String + @ObservedObject var service: KnowledgeBaseService + let onNavigate: (String) -> Void + + private enum Tab: String { case links, graph } + @State private var tab: Tab = .links + + var body: some View { + VStack(spacing: 0) { + EditorialSegmentedControl( + selection: $tab, + options: [(label: "Links", value: .links), (label: "Graph", value: .graph)], + minSegmentWidth: 70 + ) + .padding(12) + EditorialRule() + + switch tab { + case .links: + ScrollView { linksContent.padding(12) } + case .graph: + KBLocalGraphView(graph: service.localGraph(around: relPath), + onNavigate: onNavigate) + .padding(.bottom, 8) + // Rebuild (fresh simulation, re-centered) whenever the open + // note changes, so the graph always reflects the current page. + .id(relPath) + } + } + .frame(maxHeight: .infinity) + .background(DS.Paper.sunk.opacity(0.35)) + } + + private var linksContent: some View { + let outgoing = service.outgoingLinks(for: relPath) + let backlinks = service.backlinks(for: relPath) + return VStack(alignment: .leading, spacing: 18) { + section(title: "Links from this note", count: outgoing.count) { + if outgoing.isEmpty { + emptyLine("No outgoing links") + } else { + FlowLayout(spacing: 6) { + ForEach(outgoing) { link in + Button { if let r = link.resolved { onNavigate(r) } } label: { + Text(link.target) + .font(DS.sans(11)) + .foregroundStyle(link.resolved == nil ? DS.Ink.p4 : DS.Accent.ink) + .padding(.horizontal, 8).padding(.vertical, 3) + .background(EditorialChipBackground()) + .opacity(link.resolved == nil ? 0.6 : 1) + } + .buttonStyle(.plain) + .disabled(link.resolved == nil) + .help(link.resolved == nil ? "Note not found" : link.resolved!) + } + } + } + } + + section(title: "Linked from", count: backlinks.count) { + if backlinks.isEmpty { + emptyLine("No notes link here") + } else { + VStack(alignment: .leading, spacing: 8) { + ForEach(backlinks) { bl in + Button { onNavigate(bl.path) } label: { + VStack(alignment: .leading, spacing: 2) { + Text(bl.name).font(DS.sans(12, weight: .medium)).foregroundStyle(DS.Ink.p1) + if !bl.excerpt.isEmpty { + Text(bl.excerpt).font(DS.sans(10.5)).foregroundStyle(DS.Ink.p3) + .lineLimit(2).multilineTextAlignment(.leading) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + } + } + } + } + + @ViewBuilder + private func section(title: String, count: Int, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Text(title.uppercased()).font(DS.sans(10, weight: .semibold)).tracking(0.5) + .foregroundStyle(DS.Ink.p4) + Text("\(count)").font(DS.mono(10)).foregroundStyle(DS.Ink.p4) + Spacer(minLength: 0) + } + content() + } + } + + private func emptyLine(_ text: String) -> some View { + Text(text).font(DS.sans(11)).foregroundStyle(DS.Ink.p4) + } +} diff --git a/Scout/KnowledgeBase/Views/KBTreeView.swift b/Scout/KnowledgeBase/Views/KBTreeView.swift new file mode 100644 index 0000000..7d574dd --- /dev/null +++ b/Scout/KnowledgeBase/Views/KBTreeView.swift @@ -0,0 +1,99 @@ +import SwiftUI + +/// Left pane: the knowledge-base file tree. Folders expand/collapse; files +/// select into the editor. Custom-styled to match the editorial sidebar rather +/// than using `List`'s blue native selection chrome. +struct KBTreeView: View { + let nodes: [KBNode] + @Binding var selectedPath: String? + @Binding var expanded: Set + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 1) { + ForEach(nodes) { node in + KBTreeRow(node: node, depth: 0, + selectedPath: $selectedPath, expanded: $expanded) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 8) + } + .background(DS.Paper.sunk.opacity(0.4)) + } +} + +private struct KBTreeRow: View { + let node: KBNode + let depth: Int + @Binding var selectedPath: String? + @Binding var expanded: Set + + private var isExpanded: Bool { expanded.contains(node.relativePath) } + private var isSelected: Bool { selectedPath == node.relativePath } + + var body: some View { + if node.isDirectory { + Button { toggle() } label: { dirLabel }.buttonStyle(.plainHit) + if isExpanded { + ForEach(node.children) { child in + KBTreeRow(node: child, depth: depth + 1, + selectedPath: $selectedPath, expanded: $expanded) + } + } + } else { + Button { selectedPath = node.relativePath } label: { fileLabel } + .buttonStyle(.plainHit) + } + } + + private var dirLabel: some View { + HStack(spacing: 6) { + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(DS.Ink.p4) + .frame(width: 10) + Image(systemName: isExpanded ? "folder.fill" : "folder") + .font(.system(size: 11)).foregroundStyle(DS.Ink.p3) + Text(node.name).font(DS.sans(12.5, weight: .medium)).foregroundStyle(DS.Ink.p2) + .lineLimit(1) + Spacer(minLength: 0) + } + .padding(.leading, CGFloat(depth) * 14 + 4) + .padding(.vertical, 4).padding(.trailing, 6) + .contentShape(Rectangle()) + } + + private var fileLabel: some View { + HStack(spacing: 6) { + Image(systemName: glyph) + .font(.system(size: 10)).foregroundStyle(isSelected ? DS.Accent.ink : DS.Ink.p4) + .frame(width: 10) + Text(node.displayName) + .font(DS.sans(12.5)) + .foregroundStyle(isSelected ? DS.Ink.p1 : DS.Ink.p2) + .lineLimit(1) + Spacer(minLength: 0) + } + .padding(.leading, CGFloat(depth) * 14 + 20) + .padding(.vertical, 4).padding(.trailing, 6) + .background { + if isSelected { + RoundedRectangle(cornerRadius: 6).fill(DS.Accent.wash) + } + } + .contentShape(Rectangle()) + } + + private var glyph: String { + switch node.ext { + case "yaml", "yml": return "tablecells" + default: return "doc.text" + } + } + + private func toggle() { + if isExpanded { expanded.remove(node.relativePath) } + else { expanded.insert(node.relativePath) } + } +} diff --git a/Scout/KnowledgeBase/Views/KnowledgeBaseView.swift b/Scout/KnowledgeBase/Views/KnowledgeBaseView.swift new file mode 100644 index 0000000..b60ac0e --- /dev/null +++ b/Scout/KnowledgeBase/Views/KnowledgeBaseView.swift @@ -0,0 +1,364 @@ +import SwiftUI +import AppKit + +/// Knowledge Base tab: a file browser + editor + links/graph panel over +/// `~/Scout/knowledge-base/`. Left is the tree (with full-text search and "New +/// note"); center reads/edits the selected file (or shows an overview); right +/// shows the note's links and local graph. All writes go through +/// `KnowledgeBaseFileWriter` (atomic + git-committed). +struct KnowledgeBaseView: View { + @EnvironmentObject var service: KnowledgeBaseService + @EnvironmentObject var writerBox: KnowledgeBaseWriterBox + + @State private var selectedPath: String? = nil + @State private var expanded: Set = [] + @State private var searchQuery: String = "" + @State private var searchHits: [KBSearchHit] = [] + @State private var searchTask: Task? = nil + @State private var showNewFile = false + @State private var errorMessage: String? = nil + /// Width of the Links/Graph panel — drag its left edge to resize. Persisted. + @AppStorage("kbRightPanelWidth") private var rightPanelWidth: Double = 300 + + private var selectedNode: KBNode? { + guard let selectedPath else { return nil } + return Self.findNode(path: selectedPath, in: service.tree) + } + + var body: some View { + HStack(spacing: 0) { + leftPane.frame(width: 268) + divider + centerPane.frame(maxWidth: .infinity, maxHeight: .infinity) + if let node = selectedNode, node.isEditable { + PaneResizeHandle(width: $rightPanelWidth, minWidth: 220, maxWidth: 640) + KBRightPanel(relPath: node.relativePath, service: service, + onNavigate: { navigate(toPath: $0) }) + .frame(width: CGFloat(rightPanelWidth)) + } + } + .onAppear { service.load() } + .onChange(of: searchQuery) { _, q in scheduleSearch(q) } + .sheet(isPresented: $showNewFile) { + KBNewFileSheet( + directories: Self.directories(in: service.tree, kbRoot: service.kbDirectory), + defaultDirectory: defaultNewFileDirectory(), + onCreate: createFile + ) + } + .alert("Couldn't create note", isPresented: Binding( + get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } } + )) { + Button("OK", role: .cancel) { errorMessage = nil } + } message: { Text(errorMessage ?? "") } + } + + private var divider: some View { Rectangle().fill(DS.Rule.soft).frame(width: 0.5) } + + // MARK: - Left pane + + private var leftPane: some View { + VStack(spacing: 0) { + HStack(spacing: 8) { + Text("Knowledge Base") + .font(DS.serif(15, weight: .semibold)).foregroundStyle(DS.Ink.p1) + Spacer() + Button { showNewFile = true } label: { + Image(systemName: "plus").font(.system(size: 12, weight: .semibold)) + .foregroundStyle(DS.Accent.ink) + } + .buttonStyle(.plainHit).help("New note") + } + .padding(.horizontal, 14).padding(.top, 14).padding(.bottom, 8) + + searchField + .padding(.horizontal, 12).padding(.bottom, 8) + + EditorialRule() + + if searchQuery.isEmpty { + treeOrState + } else { + searchResults + } + } + .background( + LinearGradient(colors: [DS.Paper.sunk, DS.Paper.base], + startPoint: .leading, endPoint: .trailing) + ) + } + + private var searchField: some View { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass").font(.system(size: 11)).foregroundStyle(DS.Ink.p4) + TextField("Search notes…", text: $searchQuery) + .textFieldStyle(.plain).font(DS.sans(12.5)).foregroundStyle(DS.Ink.p1) + if !searchQuery.isEmpty { + Button { searchQuery = "" } label: { + Image(systemName: "xmark.circle.fill").font(.system(size: 11)).foregroundStyle(DS.Ink.p4) + }.buttonStyle(.plain) + } + } + .padding(.horizontal, 10).padding(.vertical, 6) + .neumorphicPressed(cornerRadius: 7) + } + + @ViewBuilder + private var treeOrState: some View { + switch service.state { + case .missing: + emptyState(icon: "folder.badge.questionmark", + title: "No knowledge base", + detail: "Expected \(service.kbDirectory.path). Install the scout-plugin and run /scout-setup.") + case .failed(let msg): + emptyState(icon: "exclamationmark.triangle", title: "Couldn't read the knowledge base", detail: msg) + default: + if service.tree.isEmpty { + emptyState(icon: "doc.text", title: "Empty knowledge base", + detail: "No notes yet. Use + to create the first one.") + } else { + KBTreeView(nodes: service.tree, selectedPath: $selectedPath, expanded: $expanded) + } + } + } + + private var searchResults: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 1) { + if searchHits.isEmpty { + Text(searchQuery.count < 2 ? "Type to search…" : "No results") + .font(DS.sans(12)).foregroundStyle(DS.Ink.p4) + .padding(.horizontal, 12).padding(.vertical, 10) + } + ForEach(searchHits) { hit in + Button { navigate(toPath: hit.path) } label: { + VStack(alignment: .leading, spacing: 2) { + Text(hit.name).font(DS.sans(12.5, weight: .medium)).foregroundStyle(DS.Ink.p1) + Text(hit.path).font(DS.mono(10)).foregroundStyle(DS.Ink.p4).lineLimit(1) + if !hit.snippet.isEmpty { + Text(hit.snippet).font(DS.sans(10.5)).foregroundStyle(DS.Ink.p3) + .lineLimit(2).multilineTextAlignment(.leading) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10).padding(.vertical, 6) + .contentShape(Rectangle()) + } + .buttonStyle(.plainHit) + } + } + .padding(.horizontal, 8).padding(.vertical, 8) + } + } + + // MARK: - Center pane + + @ViewBuilder + private var centerPane: some View { + if let node = selectedNode, node.isEditable { + KBEditorView( + node: node, + service: service, + writer: writerBox.writer, + onDeleted: { selectedPath = nil }, + onRenamed: { url in + selectedPath = KnowledgeBaseService.relativePath(of: url, in: service.scoutDirectory) + } + ) + .id(node.id) + .environment(\.kbWikilinkHandler, { target in + if let path = service.resolveWikilink(target) { + navigate(toPath: path) + return true + } + return false + }) + } else { + KBOverviewView(service: service, onNavigate: { navigate(toPath: $0) }) + } + } + + private func emptyState(icon: String, title: String, detail: String) -> some View { + VStack(spacing: 10) { + Image(systemName: icon).font(.system(size: 30)).foregroundStyle(DS.Ink.p4) + Text(title).font(DS.serif(16, weight: .semibold)).foregroundStyle(DS.Ink.p2) + Text(detail).font(DS.sans(12)).foregroundStyle(DS.Ink.p4) + .multilineTextAlignment(.center).frame(maxWidth: 320) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(24) + } + + // MARK: - Actions + + /// Select a note by path, expanding its ancestor folders and clearing search. + private func navigate(toPath path: String) { + let parts = path.components(separatedBy: "/") + if parts.count > 1 { + for i in 1..= 2 else { searchHits = []; return } + searchTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 200_000_000) + if Task.isCancelled { return } + searchHits = service.searchContent(query) + } + } + + /// Directory the New-note sheet defaults to: the folder of the current + /// selection, else the KB root. + private func defaultNewFileDirectory() -> URL { + if let node = selectedNode { + return node.isDirectory ? node.url : node.url.deletingLastPathComponent() + } + return service.kbDirectory + } + + private func createFile(name: String, directory: URL) { + Task { + do { + let slug = (name as NSString).deletingPathExtension + let title = slug.replacingOccurrences(of: "-", with: " ") + let initial = "# \(title)\n\n" + let dest = try await writerBox.writer.createFile( + in: directory, name: name, initialContents: initial) + await MainActor.run { + service.reload() + showNewFile = false + navigate(toPath: KnowledgeBaseService.relativePath(of: dest, in: service.scoutDirectory)) + } + } catch { + await MainActor.run { errorMessage = describe(error); showNewFile = false } + } + } + } + + private func describe(_ error: Error) -> String { + switch error { + case KBWriterError.alreadyExists(let n): return "A file named \(n) already exists." + case KBWriterError.emptyName: return "The name can't be empty." + case KBWriterError.writeFailed(let m): return m + default: return error.localizedDescription + } + } + + // MARK: - Tree helpers + + static func findNode(path: String, in nodes: [KBNode]) -> KBNode? { + for node in nodes { + if node.relativePath == path { return node } + if node.isDirectory, let hit = findNode(path: path, in: node.children) { return hit } + } + return nil + } + + /// All directory nodes (plus the KB root) for the New-note folder picker. + static func directories(in nodes: [KBNode], kbRoot: URL) -> [(label: String, url: URL)] { + var result: [(label: String, url: URL)] = [(label: "knowledge-base", url: kbRoot)] + func walk(_ nodes: [KBNode]) { + for node in nodes where node.isDirectory { + result.append((label: node.relativePath, url: node.url)) + walk(node.children) + } + } + walk(nodes) + return result + } +} + +/// Sheet for creating a new note: name + target folder. +struct KBNewFileSheet: View { + let directories: [(label: String, url: URL)] + let defaultDirectory: URL + let onCreate: (_ name: String, _ directory: URL) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var name = "" + @State private var directoryPath: String = "" + + private var selectedURL: URL { + directories.first { $0.url.path == directoryPath }?.url ?? defaultDirectory + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("New note").font(DS.serif(16, weight: .semibold)).foregroundStyle(DS.Ink.p1) + + VStack(alignment: .leading, spacing: 4) { + Text("FOLDER").font(DS.sans(10, weight: .medium)).tracking(0.6).foregroundStyle(DS.Ink.p4) + Picker("", selection: $directoryPath) { + ForEach(directories, id: \.url.path) { dir in + Text(dir.label).tag(dir.url.path) + } + } + .labelsHidden().pickerStyle(.menu) + } + + VStack(alignment: .leading, spacing: 4) { + Text("NAME").font(DS.sans(10, weight: .medium)).tracking(0.6).foregroundStyle(DS.Ink.p4) + TextField("my-note", text: $name) + .textFieldStyle(.roundedBorder).font(DS.sans(13)) + .onSubmit(create) + Text(".md is added automatically").font(DS.sans(10.5)).foregroundStyle(DS.Ink.p4) + } + + HStack { + Spacer() + Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction) + Button("Create") { create() }.keyboardShortcut(.defaultAction) + .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .padding(20).frame(width: 380) + .onAppear { directoryPath = defaultDirectory.path } + } + + private func create() { + let trimmed = name.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return } + onCreate(trimmed, selectedURL) + } +} + +/// A draggable vertical divider that resizes the pane to its right. Drag left to +/// widen, right to narrow; shows a resize cursor on hover. Width is clamped to +/// [minWidth, maxWidth]. +struct PaneResizeHandle: View { + @Binding var width: Double + let minWidth: Double + let maxWidth: Double + + @State private var dragStartWidth: Double? = nil + + var body: some View { + Rectangle() + .fill(DS.Rule.soft) + .frame(width: 0.5) + .overlay( + Rectangle() + .fill(Color.clear) + .frame(width: 9) + .contentShape(Rectangle()) + .onHover { inside in + if inside { NSCursor.resizeLeftRight.push() } else { NSCursor.pop() } + } + .gesture( + DragGesture(coordinateSpace: .global) + .onChanged { value in + let base = dragStartWidth ?? width + if dragStartWidth == nil { dragStartWidth = width } + // Divider sits on the pane's left edge: dragging + // left (negative dx) widens the pane. + width = min(maxWidth, max(minWidth, base - Double(value.translation.width))) + } + .onEnded { _ in dragStartWidth = nil } + ) + ) + } +} diff --git a/Scout/Shell/AppState.swift b/Scout/Shell/AppState.swift index d3638b7..d4bd929 100644 --- a/Scout/Shell/AppState.swift +++ b/Scout/Shell/AppState.swift @@ -52,6 +52,10 @@ final class AppState: ObservableObject { let researchDocumentService: PerFileDocumentService let perFileWriterBox: PerFileItemWriterBox + // Knowledge Base (browse + edit ~/Scout/knowledge-base/) + let knowledgeBaseService: KnowledgeBaseService + let knowledgeBaseWriterBox: KnowledgeBaseWriterBox + private var previousStatus: [Run.ID: RunStatus] = [:] private var cancellables: Set = [] @@ -170,6 +174,11 @@ final class AppState: ObservableObject { let perFileWriter = PerFileItemWriter(scoutDirectory: scoutDir, gitService: git) let perFileWriterBox = PerFileItemWriterBox(writer: perFileWriter) + // Knowledge Base: tree service over `knowledge-base/` + whole-file writer. + let kbService = KnowledgeBaseService(scoutDirectory: scoutDir, fileEvents: watcher) + let kbWriter = KnowledgeBaseFileWriter(scoutDirectory: scoutDir, gitService: git) + let kbWriterBox = KnowledgeBaseWriterBox(writer: kbWriter) + self.fileWatcher = watcher self.gitService = git self.trackerService = tracker @@ -189,6 +198,8 @@ final class AppState: ObservableObject { self.wishlistDocumentService = wishlistDoc self.researchDocumentService = researchDoc self.perFileWriterBox = perFileWriterBox + self.knowledgeBaseService = kbService + self.knowledgeBaseWriterBox = kbWriterBox self.scoutDirectory = scoutDir self.actionItemsDirectory = actionItemsDir self.runner = runner diff --git a/Scout/Shell/MainWindowView.swift b/Scout/Shell/MainWindowView.swift index 06b5a06..6fc0d51 100644 --- a/Scout/Shell/MainWindowView.swift +++ b/Scout/Shell/MainWindowView.swift @@ -58,6 +58,10 @@ struct MainWindowView: View { PerFileListView(config: .research) .environmentObject(appState.researchDocumentService) .environmentObject(appState.perFileWriterBox) + case .knowledgeBase: + KnowledgeBaseView() + .environmentObject(appState.knowledgeBaseService) + .environmentObject(appState.knowledgeBaseWriterBox) case .settings: SettingsView() } @@ -65,7 +69,7 @@ struct MainWindowView: View { } enum SidebarItem: Hashable { - case controlCenter, actionItems, schedules, proposals, wishlist, research, settings + case controlCenter, actionItems, schedules, proposals, wishlist, research, knowledgeBase, settings /// Short label shown in the bottom status bar's "view" cell. var statusLabel: String { @@ -76,6 +80,7 @@ enum SidebarItem: Hashable { case .proposals: return "proposals" case .wishlist: return "wishlist" case .research: return "research" + case .knowledgeBase: return "knowledge" case .settings: return "settings" } } diff --git a/Scout/Shell/SidebarView.swift b/Scout/Shell/SidebarView.swift index 10b162b..54ef1e9 100644 --- a/Scout/Shell/SidebarView.swift +++ b/Scout/Shell/SidebarView.swift @@ -22,6 +22,7 @@ struct SidebarView: View { row(.proposals, label: "Proposals", system: "lightbulb", badge: proposalsBadge) row(.wishlist, label: "Wishlist", system: "star", badge: wishlistBadge) row(.research, label: "Research", system: "magnifyingglass", badge: researchBadge) + row(.knowledgeBase, label: "Knowledge Base", system: "books.vertical") Spacer().frame(height: 10) groupLabel("App") row(.settings, label: "Settings", system: "gearshape") diff --git a/ScoutTests/KnowledgeBase/KnowledgeBaseTests.swift b/ScoutTests/KnowledgeBase/KnowledgeBaseTests.swift new file mode 100644 index 0000000..20826f5 --- /dev/null +++ b/ScoutTests/KnowledgeBase/KnowledgeBaseTests.swift @@ -0,0 +1,404 @@ +// ScoutTests/KnowledgeBase/KnowledgeBaseTests.swift +import Foundation +import Testing +@testable import Scout + +@Suite("KnowledgeBaseFileWriter pure helpers") +struct KnowledgeBaseFileWriterPureTests { + @Test func normalizedAddsMarkdownExtension() throws { + #expect(try KnowledgeBaseFileWriter.normalizedFileName("my-note") == "my-note.md") + } + @Test func normalizedKeepsExistingExtension() throws { + #expect(try KnowledgeBaseFileWriter.normalizedFileName("schema.yaml") == "schema.yaml") + #expect(try KnowledgeBaseFileWriter.normalizedFileName("notes.md") == "notes.md") + } + @Test func normalizedTrimsWhitespace() throws { + #expect(try KnowledgeBaseFileWriter.normalizedFileName(" spaced ") == "spaced.md") + } + @Test func normalizedRejectsEmpty() { + #expect(throws: KBWriterError.emptyName) { + _ = try KnowledgeBaseFileWriter.normalizedFileName(" ") + } + } + @Test func normalizedRejectsPathSeparators() { + #expect(throws: (any Error).self) { + _ = try KnowledgeBaseFileWriter.normalizedFileName("a/b") + } + #expect(throws: (any Error).self) { + _ = try KnowledgeBaseFileWriter.normalizedFileName("..") + } + } + @Test func relativePathStripsRepoPrefix() { + let repo = URL(fileURLWithPath: "/Users/x/Scout") + let file = URL(fileURLWithPath: "/Users/x/Scout/knowledge-base/people.md") + #expect(KnowledgeBaseFileWriter.relativePathInRepo(fileURL: file, repo: repo) + == "knowledge-base/people.md") + } +} + +@Suite("KnowledgeBaseService tree builder") +struct KnowledgeBaseServiceTreeTests { + /// Build a throwaway KB tree on disk, returning its scout root. + private func makeTree() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("kbtest-\(UUID().uuidString)") + let kb = root.appendingPathComponent("knowledge-base") + let projects = kb.appendingPathComponent("projects") + try FileManager.default.createDirectory(at: projects, withIntermediateDirectories: true) + try "# People".write(to: kb.appendingPathComponent("people.md"), atomically: true, encoding: .utf8) + try "a: 1".write(to: kb.appendingPathComponent("schema.yaml"), atomically: true, encoding: .utf8) + try "ignore".write(to: kb.appendingPathComponent("notes.txt"), atomically: true, encoding: .utf8) + try "# Scout".write(to: projects.appendingPathComponent("scout.md"), atomically: true, encoding: .utf8) + // An empty directory should be pruned. + try FileManager.default.createDirectory( + at: kb.appendingPathComponent("empty"), withIntermediateDirectories: true) + return root + } + + @Test func buildsSortedTreeDirsBeforeFiles() throws { + let root = try makeTree() + defer { try? FileManager.default.removeItem(at: root) } + let kb = root.appendingPathComponent("knowledge-base") + let nodes = KnowledgeBaseService.buildChildren(of: kb, scoutDirectory: root) + + // Directory ("projects") sorts before files; "empty" pruned; .txt excluded. + #expect(nodes.map(\.name) == ["projects", "people.md", "schema.yaml"]) + #expect(nodes[0].isDirectory) + #expect(nodes[0].children.map(\.name) == ["scout.md"]) + #expect(!nodes.contains { $0.name == "notes.txt" }) + #expect(!nodes.contains { $0.name == "empty" }) + } + + @Test func nodeRelativePathsAreRepoRelative() throws { + let root = try makeTree() + defer { try? FileManager.default.removeItem(at: root) } + let kb = root.appendingPathComponent("knowledge-base") + let nodes = KnowledgeBaseService.buildChildren(of: kb, scoutDirectory: root) + let scout = nodes.first { $0.name == "projects" }!.children.first! + #expect(scout.relativePath == "knowledge-base/projects/scout.md") + #expect(scout.ext == "md") + #expect(scout.displayName == "scout") + #expect(scout.isEditable) + } + + @Test func allFilesFlattensSubtree() throws { + let root = try makeTree() + defer { try? FileManager.default.removeItem(at: root) } + let kb = root.appendingPathComponent("knowledge-base") + let nodes = KnowledgeBaseService.buildChildren(of: kb, scoutDirectory: root) + let files = nodes.flatMap(\.allFiles).map(\.name).sorted() + #expect(files == ["people.md", "schema.yaml", "scout.md"]) + } +} + +@Suite("KBMarkdownPreview parser") +struct KBMarkdownPreviewParserTests { + @Test func splitsFrontmatter() { + let text = "---\ntitle: X\ntags: a\n---\n\n# Heading\nbody" + let (fm, body) = KBMarkdownPreview.splitFrontmatter(text) + #expect(fm == ["title: X", "tags: a"]) + #expect(body == "# Heading\nbody") + } + @Test func noFrontmatterWhenAbsent() { + let (fm, body) = KBMarkdownPreview.splitFrontmatter("# Heading\nbody") + #expect(fm == nil) + #expect(body == "# Heading\nbody") + } + @Test func unterminatedFenceTreatedAsBody() { + let (fm, _) = KBMarkdownPreview.splitFrontmatter("---\ntitle: X\nno closing") + #expect(fm == nil) + } + @Test func parsesHeadingLevels() { + #expect(KBMarkdownPreview.parseHeading("## Sub") == .heading(level: 2, text: "Sub")) + #expect(KBMarkdownPreview.parseHeading("###### Deep") == .heading(level: 6, text: "Deep")) + #expect(KBMarkdownPreview.parseHeading("#NoSpace") == nil) // needs a space + #expect(KBMarkdownPreview.parseHeading("####### TooDeep") == nil) // 7 hashes invalid + } + @Test func parsesUnorderedAndOrderedLists() { + #expect(KBMarkdownPreview.parseListItem("- item") == .listItem(depth: 0, ordinal: nil, text: "item")) + #expect(KBMarkdownPreview.parseListItem(" - nested") == .listItem(depth: 1, ordinal: nil, text: "nested")) + #expect(KBMarkdownPreview.parseListItem("1. first") == .listItem(depth: 0, ordinal: "1.", text: "first")) + #expect(KBMarkdownPreview.parseListItem("plain") == nil) + } + @Test func parseProducesMixedBlocks() { + let blocks = KBMarkdownPreview.parse("# Title\n\npara one\n\n- a\n- b\n\n```\ncode\n```\n\n> quote") + #expect(blocks.contains(.heading(level: 1, text: "Title"))) + #expect(blocks.contains(.prose("para one"))) + #expect(blocks.contains(.listItem(depth: 0, ordinal: nil, text: "a"))) + #expect(blocks.contains(.code("code"))) + #expect(blocks.contains(.quote("quote"))) + } + @Test func horizontalRuleBecomesRuleBlock() { + let blocks = KBMarkdownPreview.parse("above\n\n---\n\nbelow") + #expect(blocks.contains(.rule)) + #expect(blocks.contains(.prose("above"))) + #expect(blocks.contains(.prose("below"))) + } +} + +@Suite("KBMarkdownPreview tables") +struct KBMarkdownPreviewTableTests { + @Test func detectsSeparatorButNotHorizontalRule() { + #expect(KBMarkdownPreview.isTableSeparator("|---|---|")) + #expect(KBMarkdownPreview.isTableSeparator("| :--- | ---: |")) + #expect(!KBMarkdownPreview.isTableSeparator("---")) // hr, no pipe + #expect(!KBMarkdownPreview.isTableSeparator("| a | b |")) // content row + } + @Test func splitsRowDroppingOuterPipes() { + #expect(KBMarkdownPreview.splitRow("| Name | Role | Email |") == ["Name", "Role", "Email"]) + } + @Test func splitsRowHonoringEscapedPipeInWikilink() { + // `[[people\|Jordan]]` — the escaped pipe is cell content, not a column break. + let cells = KBMarkdownPreview.splitRow("| Jan | sees [[people\\|Jordan]] | x |") + #expect(cells == ["Jan", "sees [[people|Jordan]]", "x"]) + } + @Test func parsesTableBlockWithPaddedRows() { + let md = "| A | B | C |\n|---|---|---|\n| 1 | 2 | 3 |\n| 4 | 5 |" + let blocks = KBMarkdownPreview.parse(md) + #expect(blocks == [ + .table(headers: ["A", "B", "C"], rows: [["1", "2", "3"], ["4", "5", ""]]) + ]) + } + @Test func tableEndsAtBlankLine() { + let md = "| A | B |\n|---|---|\n| 1 | 2 |\n\nafter" + let blocks = KBMarkdownPreview.parse(md) + #expect(blocks.contains(.table(headers: ["A", "B"], rows: [["1", "2"]]))) + #expect(blocks.contains(.prose("after"))) + } +} + +@Suite("KB wikilink extraction") +struct KBWikilinkExtractionTests { + @Test func extractsTargetsBeforePipeDeduped() { + let links = KnowledgeBaseService.extractWikilinks( + "see [[groupon]] and [[people|Alias]] and [[groupon]] again") + #expect(links == ["groupon", "people"]) + } + @Test func ignoresEmptyAndMalformed() { + #expect(KnowledgeBaseService.extractWikilinks("no links here").isEmpty) + #expect(KnowledgeBaseService.extractWikilinks("[[ ]]").isEmpty) + } +} + +@MainActor +@Suite("KnowledgeBaseService graph") +struct KBServiceGraphTests { + /// A small linked KB: people ←→ scout/groupon, with groupon → scout too. + private func makeLinkedKB() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("kbgraph-\(UUID().uuidString)") + let kb = root.appendingPathComponent("knowledge-base") + let projects = kb.appendingPathComponent("projects") + try FileManager.default.createDirectory(at: projects, withIntermediateDirectories: true) + try "# People\nWorks on [[groupon]] and [[scout]]." + .write(to: kb.appendingPathComponent("people.md"), atomically: true, encoding: .utf8) + try "# Scout\nLed by [[people|Someone]]." + .write(to: projects.appendingPathComponent("scout.md"), atomically: true, encoding: .utf8) + try "# Groupon\nWith [[people]] and related to [[scout]]." + .write(to: projects.appendingPathComponent("groupon.md"), atomically: true, encoding: .utf8) + return root + } + + @Test func resolvesLinksBacklinksAndLocalGraph() throws { + let root = try makeLinkedKB() + defer { try? FileManager.default.removeItem(at: root) } + let svc = KnowledgeBaseService(scoutDirectory: root, fileEvents: NoopFS()) + svc.load() + + #expect(svc.resolveWikilink("scout") == "knowledge-base/projects/scout.md") + #expect(svc.resolveWikilink("nonexistent") == nil) + + let out = svc.outgoingLinks(for: "knowledge-base/people.md") + #expect(Set(out.map(\.target)) == ["groupon", "scout"]) + #expect(out.allSatisfy { $0.resolved != nil }) + + let back = Set(svc.backlinks(for: "knowledge-base/people.md").map(\.path)) + #expect(back == ["knowledge-base/projects/scout.md", "knowledge-base/projects/groupon.md"]) + + let g = svc.localGraph(around: "knowledge-base/people.md") + #expect(g.nodes.count == 3) + #expect(g.nodes.contains { $0.id == "knowledge-base/people.md" && $0.isCenter }) + #expect(!g.edges.isEmpty) + + let stats = svc.graphStats() + #expect(stats.notes == 3) + #expect(stats.links == 3) // people–scout, people–groupon, scout–groupon + } + + @Test func contentSearchReturnsSnippet() throws { + let root = try makeLinkedKB() + defer { try? FileManager.default.removeItem(at: root) } + let svc = KnowledgeBaseService(scoutDirectory: root, fileEvents: NoopFS()) + svc.load() + let hits = svc.searchContent("groupon") + #expect(hits.contains { $0.path == "knowledge-base/projects/groupon.md" }) + #expect(hits.contains { $0.path == "knowledge-base/people.md" }) + } +} + +@MainActor +@Suite("KnowledgeBaseService full graph") +struct KBFullGraphTests { + @Test func fullGraphHasAllNotesAndEdges() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("kbfull-\(UUID().uuidString)") + let kb = root.appendingPathComponent("knowledge-base") + try FileManager.default.createDirectory(at: kb, withIntermediateDirectories: true) + try "# A\nlinks [[b]]".write(to: kb.appendingPathComponent("a.md"), atomically: true, encoding: .utf8) + try "# B\nno links".write(to: kb.appendingPathComponent("b.md"), atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: root) } + + let svc = KnowledgeBaseService(scoutDirectory: root, fileEvents: NoopFS()) + svc.load() + let g = svc.fullGraph() + #expect(g.nodes.count == 2) + #expect(g.edges.count == 1) + #expect(g.nodes.allSatisfy { !$0.isCenter }) + } +} + +@Suite("KBDocSegment parse + splice") +struct KBDocSegmentTests { + private let src = """ + --- + type: person + --- + # Title + + First para. + + - item one + - item two + + | A | B | + |---|---| + | 1 | 2 | + """ + + @Test func parsesSegmentsWithLineRanges() { + let segs = KBDocSegment.segments(from: src) + #expect(segs.contains { $0.kind == .frontmatter && $0.lineStart == 0 && $0.lineEnd == 2 }) + #expect(segs.contains { $0.kind == .heading(1) && $0.lineStart == 3 }) + #expect(segs.contains { $0.kind == .paragraph && $0.raw == "First para." }) + #expect(segs.filter { $0.kind == .list }.count == 2) + let table = segs.first { $0.kind == .table } + #expect(table?.headers == ["A", "B"]) + #expect(table?.rows == [["1", "2"]]) + #expect(table?.rowLines == [12]) + } + + @Test func replaceLinesRewritesOnlyThatBlock() { + let segs = KBDocSegment.segments(from: src) + let para = segs.first { $0.kind == .paragraph }! + let out = KBDocSegment.replaceLines(in: src, start: para.lineStart, end: para.lineEnd, with: "Edited para.") + #expect(out.contains("Edited para.")) + #expect(!out.contains("First para.")) + #expect(out.contains("# Title")) // untouched + #expect(out.contains("| 1 | 2 |")) // untouched + } + + @Test func replaceCellRewritesOneCell() { + let out = KBDocSegment.replaceCell(in: src, sourceLine: 12, col: 1, value: "99") + #expect(out.contains("| 1 | 99 |")) + #expect(!out.contains("| 1 | 2 |")) + } + + @Test func replaceCellEscapesPipes() { + let out = KBDocSegment.replaceCell(in: src, sourceLine: 12, col: 0, value: "a|b") + #expect(out.contains(#"| a\|b | 2 |"#)) + } + + @Test func replaceLinesAcceptsMultilineReplacement() { + let segs = KBDocSegment.segments(from: src) + let para = segs.first { $0.kind == .paragraph }! + let out = KBDocSegment.replaceLines(in: src, start: para.lineStart, end: para.lineEnd, + with: "Line one\nLine two") + #expect(out.contains("Line one\nLine two")) + #expect(out.contains("# Title")) + } + + @Test func replaceLinesOutOfRangeIsNoOp() { + #expect(KBDocSegment.replaceLines(in: src, start: 999, end: 1000, with: "x") == src) + } + + @Test func replaceCellOutOfRangeIsNoOp() { + #expect(KBDocSegment.replaceCell(in: src, sourceLine: 12, col: 9, value: "x") == src) + #expect(KBDocSegment.replaceCell(in: src, sourceLine: 999, col: 0, value: "x") == src) + } + + @Test func codeFenceIsOneSegmentIncludingFences() { + let doc = "before\n\n```swift\nlet x = 1\n```\n\nafter" + let segs = KBDocSegment.segments(from: doc) + let code = segs.first { $0.kind == .code } + #expect(code?.raw == "```swift\nlet x = 1\n```") + #expect(segs.contains { $0.kind == .paragraph && $0.raw == "before" }) + #expect(segs.contains { $0.kind == .paragraph && $0.raw == "after" }) + } + + @Test func blockquoteGroupsConsecutiveLines() { + let doc = "> line a\n> line b\n\nnormal" + let segs = KBDocSegment.segments(from: doc) + let quote = segs.first { $0.kind == .quote } + #expect(quote?.lineStart == 0 && quote?.lineEnd == 1) + } + + @Test func multilineParagraphSpansLines() { + let doc = "one\ntwo\nthree" + let segs = KBDocSegment.segments(from: doc) + #expect(segs.count == 1) + #expect(segs[0].kind == .paragraph) + #expect(segs[0].lineStart == 0 && segs[0].lineEnd == 2) + } +} + +@Suite("KnowledgeBaseFileWriter symlink paths") +struct KBWriterSymlinkTests { + @Test func relativePathResolvesSymlinkedRepo() throws { + let fm = FileManager.default + let real = fm.temporaryDirectory.appendingPathComponent("kbreal-\(UUID().uuidString)") + let kb = real.appendingPathComponent("knowledge-base") + try fm.createDirectory(at: kb, withIntermediateDirectories: true) + let file = kb.appendingPathComponent("people.md") + try "x".write(to: file, atomically: true, encoding: .utf8) + let link = fm.temporaryDirectory.appendingPathComponent("kblink-\(UUID().uuidString)") + try fm.createSymbolicLink(at: link, withDestinationURL: real) + defer { try? fm.removeItem(at: link); try? fm.removeItem(at: real) } + + // Repo via symlink, file via the real (symlink-resolved) path — the exact + // mismatch that produced "people.md is outside the knowledge base". + #expect(KnowledgeBaseFileWriter.relativePathInRepo(fileURL: file, repo: link) + == "knowledge-base/people.md") + // Both via the symlink path. + let fileViaLink = link.appendingPathComponent("knowledge-base/people.md") + #expect(KnowledgeBaseFileWriter.relativePathInRepo(fileURL: fileViaLink, repo: link) + == "knowledge-base/people.md") + } +} + +@Suite("KBMarkdownPreview metadata collapse") +struct KBMarkdownPreviewPartitionTests { + @Test func collapsesChangelogAfterTitle() { + let blocks = KBMarkdownPreview.parse( + "# People\n**Last updated:** today\n**Prev:** yesterday\n\nReal intro.\n\n## Team") + let parts = KBMarkdownPreview.partition(blocks) + #expect(parts.title == .heading(level: 1, text: "People")) + // The changelog para collapses into history; the intro stays in the body. + #expect(parts.history.count == 1) + #expect(parts.rest.contains(.prose("Real intro."))) + #expect(parts.rest.contains(.heading(level: 2, text: "Team"))) + #expect(!parts.rest.contains { KBMarkdownPreview.isMetadata($0) }) + } + @Test func noTitleNoCollapseWhenPlain() { + let blocks = KBMarkdownPreview.parse("Just a normal note.\n\nSecond paragraph.") + let parts = KBMarkdownPreview.partition(blocks) + #expect(parts.title == nil) + #expect(parts.history.isEmpty) + #expect(parts.rest.count == 2) + } + @Test func identifiesMetadataProse() { + #expect(KBMarkdownPreview.isMetadata(.prose("**Parent:** [[knowledge-base]]"))) + #expect(KBMarkdownPreview.isMetadata(.prose("**Prev:** 2026-05-20 ..."))) + #expect(!KBMarkdownPreview.isMetadata(.prose("Normal text **bold** inside"))) + #expect(!KBMarkdownPreview.isMetadata(.heading(level: 1, text: "Title"))) + } +}