Skip to content

Commit 30bbfd0

Browse files
committed
Feat: Support and continous using environment variables in breadcrumb paths.
1 parent 3394859 commit 30bbfd0

12 files changed

Lines changed: 348 additions & 49 deletions

GUI/Sources/BreadCrumbNav/BreadCrumbControlWrapper.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,7 @@ struct BreadCrumbControlWrapper: View {
256256

257257
// MARK: - Helpers
258258
private var currentPath: String {
259-
appState.path(for: panelSide)
259+
appState.breadcrumbDisplayPath(for: panelSide)
260260
}
261261

262262
private var navigator: PathNavigationService {
@@ -277,7 +277,12 @@ struct BreadCrumbControlWrapper: View {
277277
return true
278278
}
279279

280-
let url = URL(fileURLWithPath: path)
280+
guard let resolved = PathEnvironmentResolver.expand(path) else {
281+
log.error("Path contains unresolved environment variable: \(path)")
282+
return false
283+
}
284+
285+
let url = URL(fileURLWithPath: (resolved.expanded as NSString).expandingTildeInPath)
281286
guard FileManager.default.fileExists(atPath: url.path) else {
282287
log.error("Path does not exist: \(path)")
283288
return false

GUI/Sources/BreadCrumbNav/BreadCrumbView.swift

Lines changed: 58 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ struct BreadCrumbView: View {
9292
let text: String // shown (may be truncated)
9393
let fullName: String // full name for tooltip + hover-expand
9494
let originalIndex: Int // index in pathComponents for navigation
95+
let isEnvironmentVariable: Bool
9596
var isTruncated: Bool { text != fullName }
9697
}
9798

@@ -100,12 +101,14 @@ struct BreadCrumbView: View {
100101
/// remote → ["SFTP demo@host", "pub", "docs"] (first segment = origin label)
101102
/// archive → ["archive.zip", "subdir"]
102103
/// local → ["Users", "senat", "Develop"]
103-
private var pathComponents: [String] {
104+
private var pathComponents: [BreadCrumbDisplayComponent] {
104105
let panelURL = panelURL
105106

106107
// ── Remote (SFTP / FTP) ──────────────────────────────────────────────
107108
if AppState.isRemotePath(panelURL) {
108-
return remoteComponents(for: panelURL)
109+
return remoteComponents(for: panelURL).map {
110+
BreadCrumbDisplayComponent(text: $0, isEnvironmentVariable: false)
111+
}
109112
}
110113

111114
// ── Archive (virtual) ────────────────────────────────────────────────
@@ -117,14 +120,24 @@ struct BreadCrumbView: View {
117120
currentPath: panelURL.path,
118121
archiveName: archiveURL.lastPathComponent,
119122
tempDir: tempDir.standardizedFileURL.path
120-
)
123+
).map { BreadCrumbDisplayComponent(text: $0, isEnvironmentVariable: false) }
121124
}
122125

123126
// ── Local filesystem ─────────────────────────────────────────────────
124-
return panelURL.path
125-
.split(separator: "/")
126-
.map(String.init)
127-
.filter { !$0.isEmpty }
127+
return PathEnvironmentResolver.displayComponents(from: appState.breadcrumbDisplayPath(for: panelSide))
128+
}
129+
130+
private var pathComponentTexts: [String] {
131+
pathComponents.map(\.text)
132+
}
133+
134+
private var localDisplayPath: String {
135+
appState.breadcrumbDisplayPath(for: panelSide)
136+
}
137+
138+
private func makeLocalDisplayPath(through index: Int) -> String {
139+
let joined = pathComponentTexts.prefix(index + 1).joined(separator: "/")
140+
return localDisplayPath.hasPrefix("/") ? "/" + joined : joined
128141
}
129142

130143
// MARK: - remoteComponents
@@ -196,20 +209,32 @@ struct BreadCrumbView: View {
196209
guard !components.isEmpty else { return [] }
197210

198211
if components.count == 1 {
199-
return [DisplaySegment(text: components[0], fullName: components[0], originalIndex: 0)]
212+
return [
213+
DisplaySegment(
214+
text: components[0].text,
215+
fullName: components[0].text,
216+
originalIndex: 0,
217+
isEnvironmentVariable: components[0].isEnvironmentVariable
218+
)
219+
]
200220
}
201221

202222
let charWidth: CGFloat = 7.5
203223
let totalSepWidth = CGFloat(components.count - 1) * separatorWidth
204224
let budgetForText = availableWidth - totalSepWidth - 16
205225

206-
let widths = components.map { CGFloat($0.count) * charWidth }
226+
let widths = components.map { CGFloat($0.text.count) * charWidth }
207227
let totalWidth = widths.reduce(0, +)
208228

209229
if totalWidth <= budgetForText {
210230
return components.enumerated()
211-
.map { i, name in
212-
DisplaySegment(text: name, fullName: name, originalIndex: i)
231+
.map { i, component in
232+
DisplaySegment(
233+
text: component.text,
234+
fullName: component.text,
235+
originalIndex: i,
236+
isEnvironmentVariable: component.isEnvironmentVariable
237+
)
213238
}
214239
}
215240

@@ -222,8 +247,9 @@ struct BreadCrumbView: View {
222247
var priority: Int
223248
}
224249
var segs = components.enumerated()
225-
.map { i, name in
226-
Seg(
250+
.map { i, component in
251+
let name = component.text
252+
return Seg(
227253
index: i, name: name, display: name, width: widths[i],
228254
priority: truncPriority(index: i, total: components.count, len: name.count))
229255
}
@@ -243,7 +269,14 @@ struct BreadCrumbView: View {
243269
segs[idx].width = newWidth
244270
segs[idx].priority = 0
245271
}
246-
return segs.map { DisplaySegment(text: $0.display, fullName: $0.name, originalIndex: $0.index) }
272+
return segs.map {
273+
DisplaySegment(
274+
text: $0.display,
275+
fullName: $0.name,
276+
originalIndex: $0.index,
277+
isEnvironmentVariable: components[$0.index].isEnvironmentVariable
278+
)
279+
}
247280
}
248281

249282
// MARK: - truncPriority — never truncate first/last; longer middle first
@@ -267,20 +300,20 @@ struct BreadCrumbView: View {
267300
return origin + "/"
268301
}
269302

270-
let parts = Array(pathComponents[1...segment.originalIndex])
303+
let parts = Array(pathComponentTexts[1...segment.originalIndex])
271304
return origin + "/" + parts.joined(separator: "/")
272305
}
273306

274307
private func archiveTargetPath(for segment: DisplaySegment) -> String? {
275308
guard let tempDir = archiveTempDir else { return nil }
276309
guard segment.originalIndex > 0 else { return nil }
277310

278-
let sub = Array(pathComponents[1...segment.originalIndex])
311+
let sub = Array(pathComponentTexts[1...segment.originalIndex])
279312
return tempDir.standardizedFileURL.path + "/" + sub.joined(separator: "/")
280313
}
281314

282315
private func localTargetPath(for segment: DisplaySegment) -> String {
283-
("/" + pathComponents.prefix(segment.originalIndex + 1).joined(separator: "/"))
316+
makeLocalDisplayPath(through: segment.originalIndex)
284317
.replacingOccurrences(of: "//", with: "/")
285318
}
286319

@@ -297,6 +330,8 @@ struct BreadCrumbView: View {
297330
ExpandableSegmentButton(
298331
segment: segment,
299332
textColor: textColor,
333+
variableTextColor: colorStore.activeTheme.breadcrumbVariableColor,
334+
variableItalic: colorStore.breadcrumbVariableItalic,
300335
fontSize: fontSize,
301336
onTap: { handleTap(segment: segment) },
302337
helpText: tooltip(for: segment),
@@ -336,19 +371,19 @@ struct BreadCrumbView: View {
336371
if segment.originalIndex == 0 {
337372
return "🌐 \(segment.fullName) — tap to go to root"
338373
}
339-
let parts = Array(pathComponents[1...segment.originalIndex])
374+
let parts = Array(pathComponentTexts[1...segment.originalIndex])
340375
return "📂 /\(parts.joined(separator: "/"))"
341376
}
342377

343378
if isInsideArchive {
344379
if segment.originalIndex == 0 {
345380
return "📦 \(segment.fullName) — tap to exit archive"
346381
}
347-
let parts = pathComponents.prefix(segment.originalIndex + 1)
382+
let parts = pathComponentTexts.prefix(segment.originalIndex + 1)
348383
return "📂 \(parts.joined(separator: "/"))"
349384
}
350385

351-
let fullPath = "/" + pathComponents.prefix(segment.originalIndex + 1).joined(separator: "/")
386+
let fullPath = makeLocalDisplayPath(through: segment.originalIndex)
352387
return "📂 Open \(fullPath)"
353388
}
354389

@@ -360,7 +395,7 @@ struct BreadCrumbView: View {
360395
?? segment.fullName
361396
}
362397

363-
let parts = Array(pathComponents[1...segment.originalIndex])
398+
let parts = Array(pathComponentTexts[1...segment.originalIndex])
364399
return "/" + parts.joined(separator: "/")
365400
}
366401

@@ -370,7 +405,7 @@ struct BreadCrumbView: View {
370405
}
371406

372407
guard let tempDir = archiveTempDir else { return "" }
373-
let sub = Array(pathComponents[1...segment.originalIndex])
408+
let sub = Array(pathComponentTexts[1...segment.originalIndex])
374409
return tempDir.standardizedFileURL.path + "/" + sub.joined(separator: "/")
375410
}
376411

@@ -383,7 +418,7 @@ struct BreadCrumbView: View {
383418
} else if isInsideArchive {
384419
pathToCopy = archiveCopyPath(for: segment)
385420
} else {
386-
pathToCopy = "/" + pathComponents.prefix(segment.originalIndex + 1).joined(separator: "/")
421+
pathToCopy = makeLocalDisplayPath(through: segment.originalIndex)
387422
}
388423

389424
NSPasteboard.general.clearContents()

GUI/Sources/BreadCrumbNav/ExpandableSegmentButton.swift

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ struct ExpandableSegmentButton: View {
1616

1717
let segment: BreadCrumbView.DisplaySegment
1818
let textColor: Color // breadcrumbText(Active|Inactive) from theme
19+
let variableTextColor: Color
20+
let variableItalic: Bool
1921
let fontSize: CGFloat // breadcrumbFontSize from theme
2022
let onTap: () -> Void
2123
let helpText: String
@@ -28,11 +30,20 @@ struct ExpandableSegmentButton: View {
2830
(isHovered && segment.isTruncated) ? segment.fullName : segment.text
2931
}
3032

33+
private var displayColor: Color {
34+
segment.isEnvironmentVariable ? variableTextColor : textColor
35+
}
36+
37+
private var displayFont: Font {
38+
let base = Font.system(size: fontSize, weight: .regular, design: .rounded)
39+
return segment.isEnvironmentVariable && variableItalic ? base.italic() : base
40+
}
41+
3142
var body: some View {
3243
Button(action: onTap) {
3344
Text(displayText)
34-
.font(.system(size: fontSize, weight: .regular, design: .rounded))
35-
.foregroundStyle(textColor)
45+
.font(displayFont)
46+
.foregroundStyle(displayColor)
3647
.kerning(0.1)
3748
.padding(.vertical, 2)
3849
.padding(.horizontal, isHovered && segment.isTruncated ? 4 : 0)

GUI/Sources/BreadCrumbNav/PathAutoCompleteField.swift

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -117,12 +117,15 @@ struct PathAutoCompleteField: View {
117117

118118
// MARK: - Update Suggestions
119119
private func updateSuggestions(for path: String) {
120-
guard isValidAbsolutePath(path) else {
120+
guard let resolvedPath = expandedPath(path),
121+
isValidAbsolutePath(resolvedPath)
122+
else {
121123
dismissPopup()
122124
return
123125
}
124126

125-
let (dirURL, prefix) = splitPathAndPrefix(path)
127+
let (dirURL, _) = splitPathAndPrefix(resolvedPath)
128+
let prefix = splitDisplayPathAndPrefix(path).prefix
126129

127130
guard directoryExists(dirURL) else {
128131
dismissPopup()
@@ -144,6 +147,11 @@ struct PathAutoCompleteField: View {
144147
!path.isEmpty && path.hasPrefix("/")
145148
}
146149

150+
private func expandedPath(_ path: String) -> String? {
151+
guard let resolved = PathEnvironmentResolver.expand(path) else { return nil }
152+
return (resolved.expanded as NSString).expandingTildeInPath
153+
}
154+
147155
private func directoryExists(_ url: URL) -> Bool {
148156
FileManager.default.fileExists(atPath: url.path)
149157
}
@@ -215,10 +223,11 @@ struct PathAutoCompleteField: View {
215223

216224
// MARK: - Apply Suggestion
217225
private func applySuggestion(_ name: String) {
218-
let (dirURL, _) = splitPathAndPrefix(text)
219-
let fullPath = dirURL.appendingPathComponent(name).path
226+
let displayParts = splitDisplayPathAndPrefix(text)
227+
let fullPath = appendDisplayComponent(name, to: displayParts.directory)
228+
let resolvedFullPath = expandedPath(fullPath) ?? fullPath
220229
suppressOnChange = true
221-
if isDirAtURL(URL(fileURLWithPath: fullPath)) {
230+
if isDirAtURL(URL(fileURLWithPath: resolvedFullPath)) {
222231
text = fullPath + "/"
223232
} else {
224233
text = fullPath
@@ -256,6 +265,22 @@ struct PathAutoCompleteField: View {
256265
}
257266

258267
// MARK: - Helpers
268+
private func splitDisplayPathAndPrefix(_ path: String) -> (directory: String, prefix: String) {
269+
if path.hasSuffix("/") {
270+
return (String(path.dropLast()), "")
271+
}
272+
273+
let nsPath = path as NSString
274+
let directory = nsPath.deletingLastPathComponent
275+
return (directory == "." ? "" : directory, nsPath.lastPathComponent)
276+
}
277+
278+
private func appendDisplayComponent(_ component: String, to directory: String) -> String {
279+
guard !directory.isEmpty else { return component }
280+
guard directory != "/" else { return "/" + component }
281+
return directory + "/" + component
282+
}
283+
259284
private func splitPathAndPrefix(_ path: String) -> (URL, String) {
260285
if path.hasSuffix("/") {
261286
return (URL(fileURLWithPath: path), "")
@@ -265,7 +290,7 @@ struct PathAutoCompleteField: View {
265290
}
266291
}
267292

268-
private func currentPrefix() -> String { splitPathAndPrefix(text).1 }
293+
private func currentPrefix() -> String { splitDisplayPathAndPrefix(text).prefix }
269294

270295
private func isDirAtURL(_ url: URL) -> Bool {
271296
var isDir: ObjCBool = false

0 commit comments

Comments
 (0)