Modeline: clock and git branch !35

merged merged by cmc on 2026-10-05 17:42 UTC · krz/orgstar:modeline-clock-git into main

4 files changed, +109 −3

Layout: unified · split

Sources/OrgApp/GitBranch.swift added +41
@@ -0,0 +1,41 @@
1import Foundation
2
3/// The checked-out branch of the repository holding a file, read from `HEAD` without running
4/// git.
5public enum GitBranch {
6 /// The branch name, the first 7 characters of the commit when HEAD is detached, or nil
7 /// outside a repository.
8 public static func current(for path: String) -> String? {
9 var directory = URL(fileURLWithPath: path).deletingLastPathComponent()
10 while true {
11 let dotGit = directory.appendingPathComponent(".git")
12 if let head = head(dotGit) { return parse(head) }
13 let parent = directory.deletingLastPathComponent()
14 if parent.path == directory.path { return nil }
15 directory = parent
16 }
17 }
18
19 /// `HEAD` of a `.git` folder, or of the folder a `.git` file (worktree, submodule) names.
20 static func head(_ dotGit: URL) -> String? {
21 var isDirectory: ObjCBool = false
22 guard FileManager.default.fileExists(atPath: dotGit.path, isDirectory: &isDirectory) else { return nil }
23 var gitDir = dotGit
24 if !isDirectory.boolValue {
25 guard let link = try? String(contentsOf: dotGit, encoding: .utf8),
26 let line = link.split(separator: "\n").first, line.hasPrefix("gitdir: ") else { return nil }
27 let target = String(line.dropFirst("gitdir: ".count))
28 gitDir = target.hasPrefix("/") ? URL(fileURLWithPath: target) : dotGit.deletingLastPathComponent().appendingPathComponent(target)
29 }
30 return try? String(contentsOf: gitDir.appendingPathComponent("HEAD"), encoding: .utf8)
31 }
32
33 static func parse(_ head: String) -> String? {
34 let line = head.trimmingCharacters(in: .whitespacesAndNewlines)
35 if line.hasPrefix("ref: ") {
36 let ref = line.dropFirst("ref: ".count)
37 return ref.hasPrefix("refs/heads/") ? String(ref.dropFirst("refs/heads/".count)) : String(ref)
38 }
39 return line.isEmpty ? nil : String(line.prefix(7))
40 }
41}
Sources/Orgstar/ContentView.swift +1 −1
@@ -66,7 +66,7 @@ struct ContentView: View {
6666 EditorView(session: session, commands: commands, keymap: keymap, modal: preset.isModal, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), options: options, onAppCommand: perform, onExternal: runExternal)
6767 .safeAreaInset(edge: .bottom, spacing: 0) {
6868 VStack(spacing: 0) {
69 ModeLine(session: session, commands: commands)
69 ModeLine(session: session, commands: commands, clock: clock, perform: perform)
7070 EchoArea(commands: commands)
7171 }
7272 }
Sources/Orgstar/ModeLine.swift +36 −2
@@ -4,13 +4,17 @@ import OrgPresentation
44import SwiftUI
55
66/// The line under the editor: evil state, unsaved mark and the headings around the caret on
7/// the left; the selection's counts, line and column, the file's words and anything unusual
8/// about its encoding on the right.
7/// the left; the running clock, the selection's counts, line and column, the file's words,
8/// anything unusual about its encoding and the git branch on the right.
99struct ModeLine: View {
1010 let session: DocumentSession
1111 let commands: AppCommands
12 let clock: ClockModel
13 /// Runs an app command (clock out, go to the clocked entry).
14 let perform: (String) -> Void
1215 @State private var words: Int?
1316 @State private var crlf = false
17 @State private var branch: String?
1418
1519 var body: some View {
1620 let text = commands.editor?.document.text ?? ""
@@ -26,6 +30,23 @@ struct ModeLine: View {
2630 }
2731 breadcrumb(at: selection.location)
2832 Spacer(minLength: 12)
33 if let running = clock.running {
34 Menu {
35 Button("Clock Out") { perform("app.clock.out") }
36 Button("Cancel Clock") { perform("app.clock.cancel") }
37 Button("Go to Clocked Entry") { perform("app.clock.goto") }
38 } label: {
39 TimelineView(.periodic(from: .now, by: 30)) { context in
40 Text("⏱ \(clockElapsed(running, now: context.date)) \(running.heading)")
41 .font(.system(.caption, design: .monospaced))
42 }
43 }
44 .menuStyle(.borderlessButton)
45 .menuIndicator(.hidden)
46 .fixedSize()
47 .foregroundStyle(.primary)
48 .help("Clocked in since \(running.startedAt.formatted(date: .omitted, time: .shortened))")
49 }
2950 if selection.length > 0, let region = region(text, selection) {
3051 Text("\(region.lines) \(region.lines == 1 ? "line" : "lines"), \(region.words) \(region.words == 1 ? "word" : "words"), \(region.characters) chars")
3152 .help("Selection (count-words-region)")
@@ -40,6 +61,11 @@ struct ModeLine: View {
4061 ForEach(encodingNotes, id: \.self) { note in
4162 Text(note).foregroundStyle(.orange)
4263 }
64 if let branch {
65 Label(branch, systemImage: "arrow.triangle.branch")
66 .labelStyle(.titleAndIcon)
67 .help("Git branch")
68 }
4369 }
4470 .font(.system(.caption, design: .monospaced))
4571 .monospacedDigit()
@@ -49,6 +75,14 @@ struct ModeLine: View {
4975 .frame(height: 22)
5076 .background(.bar)
5177 .overlay(alignment: .top) { Divider() }
78 .task(id: session.path) {
79 // HEAD changes outside the app, so look again every few seconds.
80 while !Task.isCancelled {
81 let path = session.path
82 branch = await Task.detached(priority: .utility) { path.flatMap(GitBranch.current) }.value
83 try? await Task.sleep(for: .seconds(5))
84 }
85 }
5286 .task(id: WordsKey(path: session.path, version: session.editVersion)) {
5387 guard let text = commands.editor?.document.text ?? session.document?.text else {
5488 words = nil
Tests/OrgAppTests/GitBranchTests.swift added +31
@@ -0,0 +1,31 @@
1import Foundation
2import Testing
3@testable import OrgApp
4
5struct GitBranchTests {
6 func folder() throws -> URL {
7 let url = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-git-\(UUID().uuidString)")
8 try FileManager.default.createDirectory(at: url.appendingPathComponent("notes/sub"), withIntermediateDirectories: true)
9 return url
10 }
11
12 @Test func branchDetachedWorktreeAndNone() throws {
13 let root = try folder()
14 defer { try? FileManager.default.removeItem(at: root) }
15 let file = root.appendingPathComponent("notes/sub/a.org").path
16 #expect(GitBranch.current(for: file) == nil)
17
18 try FileManager.default.createDirectory(at: root.appendingPathComponent(".git"), withIntermediateDirectories: true)
19 try "ref: refs/heads/feature/x\n".write(to: root.appendingPathComponent(".git/HEAD"), atomically: true, encoding: .utf8)
20 #expect(GitBranch.current(for: file) == "feature/x")
21
22 try "0123456789abcdef0123456789abcdef01234567\n".write(to: root.appendingPathComponent(".git/HEAD"), atomically: true, encoding: .utf8)
23 #expect(GitBranch.current(for: file) == "0123456")
24
25 let worktree = root.appendingPathComponent("notes/.git")
26 try FileManager.default.createDirectory(at: root.appendingPathComponent(".git/worktrees/w"), withIntermediateDirectories: true)
27 try "ref: refs/heads/wt\n".write(to: root.appendingPathComponent(".git/worktrees/w/HEAD"), atomically: true, encoding: .utf8)
28 try "gitdir: ../.git/worktrees/w\n".write(to: worktree, atomically: true, encoding: .utf8)
29 #expect(GitBranch.current(for: file) == "wt")
30 }
31}