Link worktree file paths in Markdown to the Files tab
This commit is contained in:
@@ -18,6 +18,14 @@ enum AppLink {
|
||||
return c.url!
|
||||
}
|
||||
|
||||
static func file(_ taskId: String, _ path: String) -> URL {
|
||||
var c = URLComponents()
|
||||
c.scheme = "byre-app"
|
||||
c.host = "file"
|
||||
c.queryItems = [URLQueryItem(name: "task", value: taskId), URLQueryItem(name: "path", value: path)]
|
||||
return c.url!
|
||||
}
|
||||
|
||||
static func todo(_ projectId: String, _ title: String, discuss: Bool) -> URL {
|
||||
var c = URLComponents()
|
||||
c.scheme = "byre-app"
|
||||
@@ -44,6 +52,8 @@ func openAppLink(_ url: URL, _ store: Store, _ overlays: Overlays) -> OpenURLAct
|
||||
switch c.host {
|
||||
case "route":
|
||||
store.route(q["hash"] ?? "")
|
||||
case "file":
|
||||
store.openFile(q["task"] ?? "", q["path"] ?? "")
|
||||
case "todo":
|
||||
store.startTodo(q["project"] ?? "", q["title"] ?? "", discuss: q["discuss"] == "1", overlays)
|
||||
case "suggest":
|
||||
@@ -110,14 +120,50 @@ private let httpLink = Re(#"^https?://"#, caseless: true)
|
||||
private let taskHref = Re(#"^#/task/(\w+)(/\w+)?$"#)
|
||||
private let todoHref = Re(#"^#/todo/(\w+)/(.+)$"#)
|
||||
private let suggestHref = Re(#"^#/suggest/(\w+)/([^/]+)/(.+)$"#)
|
||||
private let anyScheme = Re(#"^[a-z][a-z0-9+.-]*:"#, caseless: true)
|
||||
private let pathLine = #"(?::\d+(?:[-:]\d+)?|#L\d+(?:-L?\d+)?)?"#
|
||||
private let pathFile = #"[\w+@-][\w.+@-]*\.[A-Za-z]\w*"#
|
||||
private let codePath = Re(#"^(?:\./)?((?:[\w.+@-]+/)+(?:"# + pathFile + ")?)" + pathLine + "$")
|
||||
private let barePath = Re(#"(?<![\w./~@+-])(?:\./)?((?:[\w.+@-]+/)+"# + pathFile + ")" + pathLine)
|
||||
private let rootedPath = Re(#"^((?:[\w.+@-]+/)*"# + pathFile + ")" + pathLine)
|
||||
|
||||
private func normalPath(_ path: String) -> String? {
|
||||
var out: [String] = []
|
||||
for part in path.split(separator: "/", omittingEmptySubsequences: true) {
|
||||
if part == "." {
|
||||
continue
|
||||
}
|
||||
if part == ".." {
|
||||
if out.popLast() == nil {
|
||||
return nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
out.append(String(part))
|
||||
}
|
||||
if out.isEmpty {
|
||||
return nil
|
||||
}
|
||||
return out.joined(separator: "/") + (path.hasSuffix("/") ? "/" : "")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct InlineRenderer {
|
||||
let store: Store
|
||||
let size: CGFloat
|
||||
let taskId: String?
|
||||
var weight: CGFloat = 400
|
||||
var dir = ""
|
||||
let weight: CGFloat
|
||||
let dir: String
|
||||
private let root: String
|
||||
|
||||
init(store: Store, size: CGFloat, taskId: String?, weight: CGFloat = 400, dir: String = "") {
|
||||
self.store = store
|
||||
self.size = size
|
||||
self.taskId = taskId
|
||||
self.weight = weight
|
||||
self.dir = dir
|
||||
root = taskId.flatMap { store.findTask($0)?.worktreePath } ?? ""
|
||||
}
|
||||
|
||||
private struct Style {
|
||||
var bold = false
|
||||
@@ -173,11 +219,16 @@ struct InlineRenderer {
|
||||
for item in items {
|
||||
switch item {
|
||||
case .text(let t):
|
||||
append(run(t, style), &out)
|
||||
text(t, style, &out)
|
||||
case .lineBreak:
|
||||
append(run("\n", style), &out)
|
||||
case .code(let c):
|
||||
var a = run(c, style)
|
||||
var s = style
|
||||
if s.link == nil, let path = codeFile(c) {
|
||||
s.link = path
|
||||
s.color = Palette.accent
|
||||
}
|
||||
var a = run(c, s)
|
||||
a.backgroundColor = Palette.surface
|
||||
a.appKit.backgroundColor = NSColor(Palette.surface)
|
||||
append(a, &out)
|
||||
@@ -233,9 +284,82 @@ struct InlineRenderer {
|
||||
render(inner, s, &out)
|
||||
return
|
||||
}
|
||||
if let url = hrefFile(href) {
|
||||
s.link = url
|
||||
s.color = Palette.accent
|
||||
render(inner, s, &out)
|
||||
return
|
||||
}
|
||||
render(inner, style, &out)
|
||||
}
|
||||
|
||||
private func fileLink(_ path: String) -> URL? {
|
||||
guard let taskId, !root.isEmpty, let p = normalPath(path) else {
|
||||
return nil
|
||||
}
|
||||
return AppLink.file(taskId, p)
|
||||
}
|
||||
|
||||
private func codeFile(_ code: String) -> URL? {
|
||||
var c = code
|
||||
if c.hasPrefix(root + "/") {
|
||||
c = String(c.dropFirst(root.count + 1))
|
||||
if let m = rootedPath.exec(c), m[0] == c {
|
||||
return fileLink(m[1]!)
|
||||
}
|
||||
}
|
||||
return codePath.exec(c).flatMap { fileLink($0[1]!) }
|
||||
}
|
||||
|
||||
private func hrefFile(_ href: String) -> URL? {
|
||||
let bare = href.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false).first.map { String($0).replacingOccurrences(of: #":\d+(?:[-:]\d+)?$"#, with: "", options: .regularExpression) } ?? ""
|
||||
if bare.isEmpty || anyScheme.test(bare) {
|
||||
return nil
|
||||
}
|
||||
guard var p = bare.removingPercentEncoding else {
|
||||
return nil
|
||||
}
|
||||
if p.hasPrefix("/") {
|
||||
guard p.hasPrefix(root + "/") else {
|
||||
return nil
|
||||
}
|
||||
p = String(p.dropFirst(root.count + 1))
|
||||
} else if !dir.isEmpty {
|
||||
p = "\(dir)/\(p)"
|
||||
}
|
||||
return fileLink(p)
|
||||
}
|
||||
|
||||
private func text(_ t: String, _ style: Style, _ out: inout [MDSegment]) {
|
||||
if style.link != nil || root.isEmpty {
|
||||
append(run(t, style), &out)
|
||||
return
|
||||
}
|
||||
var rest = t
|
||||
while !rest.isEmpty {
|
||||
var found = barePath.match(rest).map { (range: $0.range, path: $0.groups[1]!) }
|
||||
if let r = rest.range(of: root + "/"), found == nil || r.lowerBound <= found!.range.lowerBound {
|
||||
let tail = String(rest[r.upperBound...])
|
||||
if let m = rootedPath.match(tail) {
|
||||
let end = rest.index(r.upperBound, offsetBy: tail.distance(from: tail.startIndex, to: m.range.upperBound))
|
||||
found = (r.lowerBound..<end, m.groups[1]!)
|
||||
}
|
||||
}
|
||||
guard let found else {
|
||||
append(run(rest, style), &out)
|
||||
return
|
||||
}
|
||||
append(run(String(rest[..<found.range.lowerBound]), style), &out)
|
||||
var s = style
|
||||
if let url = fileLink(found.path) {
|
||||
s.link = url
|
||||
s.color = Palette.accent
|
||||
}
|
||||
append(run(String(rest[found.range]), s), &out)
|
||||
rest = String(rest[found.range.upperBound...])
|
||||
}
|
||||
}
|
||||
|
||||
private func todo(_ projectId: String, _ title: String, _ brief: String?, _ inner: [MDInline], _ style: Style, _ out: inout [MDSegment]) {
|
||||
let task = (store.tasks[projectId] ?? []).first { $0.status == .active && (brief == nil ? $0.todoItem == title : $0.title == title) }
|
||||
var s = style
|
||||
|
||||
Reference in New Issue
Block a user