Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6902252
Add Knowledge Base tab: browse and edit ~/Scout/knowledge-base/ in-app
yustme Jun 29, 2026
a86c49c
Import Combine in KnowledgeBaseFileWriter for ObservableObject confor…
yustme Jun 29, 2026
fddc0a8
Import Combine in KBEditorView for objectWillChange access
yustme Jun 29, 2026
8fa425a
CI: build and upload Scout.app artifact
yustme Jun 29, 2026
780f581
KB Phase 1: read-first preview with tables, reading width, collapsed …
yustme Jun 29, 2026
ace5b63
KB Phase 2: in-app navigation, backlinks, local graph, full-text sear…
yustme Jun 29, 2026
221a47d
KB graph: render with Grape (native d3-force) + global KB graph
yustme Jun 29, 2026
f5c4232
Fix Grape link force: originalLength needs a float literal
yustme Jun 29, 2026
8fefbb0
KB fixes: table cell overflow + graph label legibility
yustme Jun 29, 2026
da1ec6e
KB graph: scale node labels with zoom (Obsidian-like)
yustme Jun 29, 2026
4ed661d
KB graph: scale nodes + labels by live zoom
yustme Jun 29, 2026
66f5bf3
KB graph: larger nodes so drag-to-reposition is easy to grab
yustme Jun 29, 2026
114c09a
KB editor: add native live (rich) markdown editor as a third mode
yustme Jun 29, 2026
3c60cf4
KB: edit-in-place on the rendered view (block + table cell)
yustme Jun 29, 2026
b1b1ef9
KB: fix 'outside the knowledge base' save error under symlinked ~/Scout
yustme Jun 29, 2026
974927d
KB: resizable Links/Graph panel + graph always scoped to open note
yustme Jun 29, 2026
e251b87
Tests: KBDocSegment edge cases + splice safety + symlink-resolved rep…
yustme Jun 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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_<version>.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
30 changes: 30 additions & 0 deletions Scout.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -39,6 +43,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
BEEE45A02F9599AB0078191D /* Grape in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -89,6 +94,9 @@
BEEE45562F95613D0078191D /* Scout */,
);
name = Scout;
packageProductDependencies = (
BEEE45A22F9599AB0078191D /* Grape */,
);
productName = Scout;
productReference = BEEE45542F95613D0078191D /* Scout.app */;
productType = "com.apple.product-type.application";
Expand Down Expand Up @@ -142,6 +150,9 @@
);
mainGroup = BEEE454B2F95613D0078191D;
minimizedProjectReferenceProxies = 1;
packageReferences = (
BEEE45A12F9599AB0078191D /* XCRemoteSwiftPackageReference "Grape" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = BEEE45552F95613D0078191D /* Products */;
projectDirPath = "";
Expand Down Expand Up @@ -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;
Expand Down
20 changes: 20 additions & 0 deletions Scout/ActionItems/Views/InlineMarkdownText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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") ?? ""
Expand Down
238 changes: 238 additions & 0 deletions Scout/KnowledgeBase/KnowledgeBaseFileWriter.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Never>?

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 -- <path>` 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 }
}
Loading