Skip to content

Commit 87e69da

Browse files
committed
feat: cmd-click links, paths, inline file viewer
1 parent 1255edd commit 87e69da

6 files changed

Lines changed: 331 additions & 40 deletions

File tree

Notchy/FilePreviewView.swift

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import SwiftUI
2+
import AppKit
3+
4+
/// A request to preview a file inline in the panel (⌘-click on a path).
5+
struct FilePreviewRequest: Equatable {
6+
let url: URL
7+
let line: Int?
8+
}
9+
10+
/// Read-only inline file viewer shown as an overlay inside the panel.
11+
/// Text renders in an NSTextView — the find bar works there, and
12+
/// TerminalPanel's focus-restore whitelist already covers NSTextView, so it
13+
/// keeps keyboard focus inside the non-activating panel. Images render
14+
/// natively; binaries and huge files open in the default external app instead.
15+
struct FilePreviewView: View {
16+
let request: FilePreviewRequest
17+
let onDismiss: () -> Void
18+
19+
private enum Content {
20+
case text(String)
21+
case image(NSImage)
22+
}
23+
24+
@State private var content: Content?
25+
@State private var escMonitor: Any?
26+
27+
private nonisolated static let maxPreviewBytes = 5 * 1024 * 1024
28+
private nonisolated static let imageExtensions: Set<String> = [
29+
"png", "jpg", "jpeg", "gif", "heic", "webp", "tiff", "bmp", "icns"
30+
]
31+
32+
var body: some View {
33+
VStack(spacing: 0) {
34+
header
35+
Divider().overlay(Color.white.opacity(0.1))
36+
switch content {
37+
case .text(let string):
38+
TextFileView(text: string, highlightLine: request.line)
39+
case .image(let image):
40+
ScrollView([.horizontal, .vertical]) {
41+
Image(nsImage: image)
42+
.padding(12)
43+
}
44+
case nil:
45+
ProgressView()
46+
.frame(maxWidth: .infinity, maxHeight: .infinity)
47+
}
48+
}
49+
.background(Color(nsColor: NSColor(white: 0.1, alpha: 1)))
50+
.clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous))
51+
.overlay {
52+
RoundedRectangle(cornerRadius: 9, style: .continuous)
53+
.strokeBorder(.white.opacity(0.15), lineWidth: 1)
54+
}
55+
.task(id: request) { await load() }
56+
.onAppear {
57+
escMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
58+
guard event.keyCode == 53 else { return event } // Esc
59+
onDismiss()
60+
return nil
61+
}
62+
}
63+
.onDisappear {
64+
if let escMonitor { NSEvent.removeMonitor(escMonitor) }
65+
escMonitor = nil
66+
}
67+
}
68+
69+
private var header: some View {
70+
HStack(spacing: 10) {
71+
VStack(alignment: .leading, spacing: 1) {
72+
HStack(spacing: 0) {
73+
Text(request.url.lastPathComponent)
74+
.font(.system(size: 12, weight: .semibold))
75+
if let line = request.line {
76+
Text(":\(line)")
77+
.font(.system(size: 12))
78+
.foregroundStyle(.secondary)
79+
}
80+
}
81+
Text(request.url.deletingLastPathComponent().path)
82+
.font(.system(size: 10))
83+
.foregroundStyle(.secondary)
84+
.lineLimit(1)
85+
.truncationMode(.middle)
86+
}
87+
Spacer()
88+
Button {
89+
NSWorkspace.shared.activateFileViewerSelecting([request.url])
90+
} label: {
91+
Image(systemName: "folder")
92+
}
93+
.buttonStyle(.plain)
94+
.help(L10n.shared.revealInFinder)
95+
Button {
96+
NSWorkspace.shared.open(request.url)
97+
} label: {
98+
Image(systemName: "arrow.up.forward.app")
99+
}
100+
.buttonStyle(.plain)
101+
.help(L10n.shared.openInDefaultApp)
102+
Button(action: onDismiss) {
103+
NotchyIcon(kind: .close)
104+
}
105+
.buttonStyle(.plain)
106+
.help(L10n.shared.close)
107+
}
108+
.padding(.horizontal, 12)
109+
.padding(.vertical, 8)
110+
.foregroundStyle(.white)
111+
}
112+
113+
private func load() async {
114+
let url = request.url
115+
let result: Content? = await Task.detached(priority: .userInitiated) { () -> Content? in
116+
let size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0
117+
guard size <= Self.maxPreviewBytes else { return nil }
118+
if Self.imageExtensions.contains(url.pathExtension.lowercased()) {
119+
guard let image = NSImage(contentsOf: url) else { return nil }
120+
return .image(image)
121+
}
122+
guard let data = try? Data(contentsOf: url),
123+
let string = String(data: data, encoding: .utf8) else { return nil }
124+
return .text(string)
125+
}.value
126+
127+
if let result {
128+
content = result
129+
} else {
130+
// Binary, unreadable, or too large — hand off to the default app.
131+
NSWorkspace.shared.open(url)
132+
onDismiss()
133+
}
134+
}
135+
}
136+
137+
/// NSTextView-backed read-only text display, mirroring HistoryViewerPanel's
138+
/// configuration (find bar, incremental search, monospaced).
139+
private struct TextFileView: NSViewRepresentable {
140+
let text: String
141+
let highlightLine: Int?
142+
143+
func makeNSView(context: Context) -> NSScrollView {
144+
let scrollView = NSTextView.scrollableTextView()
145+
scrollView.hasVerticalScroller = true
146+
scrollView.drawsBackground = false
147+
148+
let textView = scrollView.documentView as! NSTextView
149+
textView.isEditable = false
150+
textView.isSelectable = true
151+
textView.drawsBackground = false
152+
textView.textColor = NSColor(white: 0.9, alpha: 1)
153+
textView.font = .monospacedSystemFont(ofSize: 12, weight: .regular)
154+
textView.textContainerInset = NSSize(width: 12, height: 12)
155+
textView.isAutomaticQuoteSubstitutionEnabled = false
156+
textView.isAutomaticDashSubstitutionEnabled = false
157+
textView.isAutomaticTextReplacementEnabled = false
158+
textView.usesFindBar = true
159+
textView.isIncrementalSearchingEnabled = true
160+
161+
// Take keyboard focus so ⌘F/arrows work; the panel's focus-restore
162+
// whitelist (NSTextView) keeps it here until the preview closes.
163+
DispatchQueue.main.async {
164+
textView.window?.makeFirstResponder(textView)
165+
}
166+
return scrollView
167+
}
168+
169+
func updateNSView(_ scrollView: NSScrollView, context: Context) {
170+
guard let textView = scrollView.documentView as? NSTextView else { return }
171+
guard textView.string != text else { return }
172+
textView.string = text
173+
guard let line = highlightLine else { return }
174+
let range = Self.range(ofLine: line, in: text)
175+
// Defer until after layout so the scroll target exists.
176+
DispatchQueue.main.async {
177+
textView.scrollRangeToVisible(range)
178+
textView.showFindIndicator(for: range)
179+
}
180+
}
181+
182+
/// NSRange of a 1-based line number (the whole line, without its newline).
183+
private static func range(ofLine line: Int, in text: String) -> NSRange {
184+
var current = 1
185+
var start = text.startIndex
186+
while current < line, let newline = text[start...].firstIndex(of: "\n") {
187+
start = text.index(after: newline)
188+
current += 1
189+
}
190+
let end = text[start...].firstIndex(of: "\n") ?? text.endIndex
191+
return NSRange(start..<end, in: text)
192+
}
193+
}

Notchy/Localization.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ final class L10n {
150150
var paste: String { isSpanish ? "Pegar" : "Paste" }
151151
var revealInFinder: String { isSpanish ? "Mostrar en Finder" : "Reveal in Finder" }
152152
var openInFinder: String { isSpanish ? "Abrir en Finder" : "Open in Finder" }
153+
var openInDefaultApp: String { isSpanish ? "Abrir con la app predeterminada" : "Open with Default App" }
153154
var copyPath: String { isSpanish ? "Copiar ruta" : "Copy Path" }
154155

155156
// MARK: - Search

Notchy/PanelContentView.swift

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,13 @@ struct PanelContentView: View {
6060
return false
6161
}
6262

63+
/// Any overlay/dialog that must keep the panel from auto-hiding on
64+
/// resign-key (mirrored into SessionStore.isShowingDialog).
65+
private var anyDialogVisible: Bool {
66+
showRestoreConfirmation || showClaudeMenu || showSettings
67+
|| sessionStore.showCommandPalette || sessionStore.filePreview != nil
68+
}
69+
6370
private var foregroundOpacity: Double {
6471
sessionStore.isWindowFocused ? 1.0 : 0.6
6572
}
@@ -271,23 +278,38 @@ struct PanelContentView: View {
271278
}
272279
}
273280
}
281+
.overlay {
282+
if let preview = sessionStore.filePreview {
283+
ZStack {
284+
Color.black.opacity(0.3)
285+
.onTapGesture { sessionStore.filePreview = nil }
286+
FilePreviewView(request: preview) {
287+
sessionStore.filePreview = nil
288+
}
289+
.padding(20)
290+
}
291+
}
292+
}
274293
.onAppear {
275294
sessionStore.refreshLastCheckpoint()
276295
}
277296
.onChange(of: sessionStore.activeSessionId) {
278297
sessionStore.refreshLastCheckpoint()
279298
}
280299
.onChange(of: showRestoreConfirmation) {
281-
sessionStore.isShowingDialog = showRestoreConfirmation || showClaudeMenu || showSettings || sessionStore.showCommandPalette
300+
sessionStore.isShowingDialog = anyDialogVisible
282301
}
283302
.onChange(of: showClaudeMenu) {
284-
sessionStore.isShowingDialog = showRestoreConfirmation || showClaudeMenu || showSettings || sessionStore.showCommandPalette
303+
sessionStore.isShowingDialog = anyDialogVisible
285304
}
286305
.onChange(of: showSettings) {
287-
sessionStore.isShowingDialog = showRestoreConfirmation || showClaudeMenu || showSettings || sessionStore.showCommandPalette
306+
sessionStore.isShowingDialog = anyDialogVisible
288307
}
289308
.onChange(of: sessionStore.showCommandPalette) {
290-
sessionStore.isShowingDialog = showRestoreConfirmation || showClaudeMenu || showSettings || sessionStore.showCommandPalette
309+
sessionStore.isShowingDialog = anyDialogVisible
310+
}
311+
.onChange(of: sessionStore.filePreview) {
312+
sessionStore.isShowingDialog = anyDialogVisible
291313
}
292314
.alert(L10n.shared.restoreCheckpointTitle, isPresented: $showRestoreConfirmation) {
293315
Button(L10n.shared.restoreLastCheckpoint, role: .destructive) {

Notchy/SessionStore.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ class SessionStore {
5252
var isWindowFocused = true
5353
var isShowingDialog = false
5454
var showCommandPalette = false
55+
/// Non-nil while the inline file preview overlay is open (⌘-click on a path).
56+
var filePreview: FilePreviewRequest?
5557
var currentTheme: TerminalTheme = TerminalTheme.theme(forId: TerminalManager.shared.currentThemeId)
5658

5759
/// The most recent checkpoint for the active session, used to show the undo button

0 commit comments

Comments
 (0)