Skip to content

Commit 22ff93f

Browse files
author
Idan Attias
committed
feat(tui): add styled console hyperlinks for projects, buckets, folders, and objects
Introduces clickable Google Cloud Console links in the metadata/preview pane using the OSC 8 terminal escape sequence. Links are styled with blue color and underline for better visibility. - Added currentProjectID to Model to track project context during navigation. - Implemented terminalHyperlink utility for OSC 8 sequences. - Updated previewView to render console links for all item types. - Added comprehensive unit tests and updated UI snapshots. - Fixed a regression in truncation tests by stripping ANSI escape sequences during verification.
1 parent edfd234 commit 22ff93f

12 files changed

+63
-20
lines changed

internal/tui/buckets_view_test.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ func TestModel_BucketMetadataDeterministicLabels(t *testing.T) {
219219
client := &mockGCSClient{}
220220
mModel := tui.NewModel([]string{"test-project-1"}, client, "/tmp", false, false)
221221
m := &mModel
222+
m, _ = updateModel(m, tea.WindowSizeMsg{Width: 200, Height: 50})
222223

223224
m, _ = updateModel(m, tui.BucketsPageMsg{ProjectID: "test-project-1", Buckets: []string{"test-bucket"}})
224225

@@ -242,6 +243,9 @@ func TestModel_BucketMetadataDeterministicLabels(t *testing.T) {
242243
view1 := m.View()
243244

244245
assert.Assert(t, strings.Contains(view1, "a-label:"), "Should show a-label")
246+
assert.Assert(t, strings.Contains(view1, "Console Link:"), "Console Link should be visible")
247+
assert.Assert(t, strings.Contains(view1, "https://console.cloud.google.com/storage/browser/test-bucket?project=test-project-1"), "Bucket Console URL should be correct")
248+
assert.Assert(t, strings.Contains(view1, "Link"), "Link text should be visible")
245249

246250
idxA := strings.Index(view1, "a-label")
247251
idxM := strings.Index(view1, "m-label")
@@ -263,7 +267,7 @@ func TestModel_Init_ShowsLoadingProjectInfo(t *testing.T) {
263267
// Create model
264268
mModel := tui.NewModel([]string{projectID}, client, "/tmp", false, false)
265269
m := &mModel
266-
m, _ = updateModel(m, tea.WindowSizeMsg{Width: 100, Height: 20})
270+
m, _ = updateModel(m, tea.WindowSizeMsg{Width: 200, Height: 20})
267271

268272
// Call Init and get commands
269273
cmd := m.Init()
@@ -303,4 +307,7 @@ func TestModel_Init_ShowsLoadingProjectInfo(t *testing.T) {
303307
assert.Assert(t, !strings.Contains(view, "Loading project info..."), "Loading project info should disappear after metadata arrives")
304308
assert.Assert(t, strings.Contains(view, "Project Name:"), "Metadata should be visible")
305309
assert.Assert(t, strings.Contains(view, "My Cool Project"), "Project name should be visible")
310+
assert.Assert(t, strings.Contains(view, "Console Link:"), "Console Link should be visible")
311+
assert.Assert(t, strings.Contains(view, "https://console.cloud.google.com/welcome?project=test-project"), "Project Console URL should be correct")
312+
assert.Assert(t, strings.Contains(view, "Link"), "Link text should be visible")
306313
}

internal/tui/model.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ type Model struct {
8080
// Buckets View
8181
projects []gcs.ProjectBuckets
8282
collapsedProjects map[string]struct{}
83+
currentProjectID string
8384
cursor int // used for buckets or objects depending on state
8485
bucketCursor int // stores the cursor position in the bucket list
8586
targetBucketCursor string

internal/tui/objects_view_test.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package tui_test
22

33
import (
44
"fmt"
5+
"regexp"
56
"strings"
67
"testing"
78
"time"
@@ -251,6 +252,9 @@ func TestModel_SelectObject(t *testing.T) {
251252
assert.Assert(t, strings.Contains(view, "obj1"))
252253
assert.Assert(t, strings.Contains(view, "1.0 KB"))
253254
assert.Assert(t, strings.Contains(view, "text/plain"))
255+
assert.Assert(t, strings.Contains(view, "Console Link:"), "Console Link should be visible")
256+
assert.Assert(t, strings.Contains(view, "https://console.cloud.google.com/storage/browser/_details/b1/obj1?project=p1"), "Object Console URL should be correct")
257+
assert.Assert(t, strings.Contains(view, "Link"), "Link text should be visible")
254258
}
255259

256260
func TestModel_SelectPrefix(t *testing.T) {
@@ -289,6 +293,9 @@ func TestModel_SelectPrefix(t *testing.T) {
289293
assert.Assert(t, strings.Contains(view, "folder1/"))
290294
assert.Assert(t, strings.Contains(view, "Folder"))
291295
assert.Assert(t, strings.Contains(view, "test-user"))
296+
assert.Assert(t, strings.Contains(view, "Console Link:"), "Console Link should be visible")
297+
assert.Assert(t, strings.Contains(view, "https://console.cloud.google.com/storage/browser/b/folder1;tab=objects?project=p1"), "Folder Console URL should be correct")
298+
assert.Assert(t, strings.Contains(view, "Link"), "Link text should be visible")
292299
}
293300

294301
func TestModel_Pagination_Objects(t *testing.T) {
@@ -343,6 +350,13 @@ func TestModel_StaleObjectsMsg(t *testing.T) {
343350
}
344351
}
345352

353+
func stripLinks(s string) string {
354+
re := regexp.MustCompile(`\x1b]8;;.*?\x1b\\`)
355+
s = re.ReplaceAllString(s, "")
356+
re2 := regexp.MustCompile(`\x1b]8;;\x1b\\`)
357+
return re2.ReplaceAllString(s, "")
358+
}
359+
346360
func TestModel_Truncation(t *testing.T) {
347361
longName := "this_is_a_very_long_object_name_that_should_be_truncated_to_fit_in_the_column"
348362
client := &mockGCSClient{
@@ -360,15 +374,15 @@ func TestModel_Truncation(t *testing.T) {
360374
m, _ = pressKey(m, 'j')
361375

362376
view := m.View()
363-
assert.Assert(t, !strings.Contains(view, longName), "View should NOT contain the full long bucket name")
377+
assert.Assert(t, !strings.Contains(stripLinks(view), longName), "View should NOT contain the full long bucket name")
364378

365379
// 2. Check Object truncation
366380
m, _ = pressKeyType(m, tea.KeyEnter)
367381
m, _ = updateModel(m, tui.ObjectsMsg{Bucket: longName, Prefix: "", List: client.objects})
368382

369383
view = m.View()
370384
assert.Assert(t, strings.Contains(view, "..."), "View should contain ellipsis for truncated object name")
371-
assert.Assert(t, !strings.Contains(view, longName), "View should NOT contain the full long object name")
385+
assert.Assert(t, !strings.Contains(stripLinks(view), longName), "View should NOT contain the full long object name")
372386
}
373387

374388
func TestModel_PreviewBinaryContent(t *testing.T) {

internal/tui/update.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1314,6 +1314,7 @@ func (m *Model) handleRightKey() (tea.Model, tea.Cmd) {
13141314
}
13151315

13161316
m.currentBucket = item.BucketName
1317+
m.currentProjectID = item.ProjectID
13171318

13181319
// Save the index in the filtered list to restore later.
13191320
m.bucketCursor = m.cursor

internal/tui/utils.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,10 @@ func humanizeSize(bytes int64) string {
147147
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
148148
}
149149

150+
func terminalHyperlink(url, text string) string {
151+
return fmt.Sprintf("\x1b]8;;%s\x1b\\%s\x1b]8;;\x1b\\", url, text)
152+
}
153+
150154
var (
151155
fallbackExactIcons = map[string]string{
152156
"dockerfile": "🐳 ",

internal/tui/views.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ var (
1919
bucketInfoValStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#CDD6F4"))
2020
bucketInfoErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#F28FAD"))
2121
bucketInfoLabelsStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#F5C2E7"))
22+
bucketInfoLinkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#89B4FA")).Underline(true)
2223
)
2324

2425
func (m *Model) renderSpinner() string {
@@ -207,6 +208,9 @@ func (m *Model) previewView(width int) string {
207208

208209
displayName := getDisplayName(prefix.Name, m.currentPrefix)
209210
fmt.Fprintf(&s, "%s %s\n", keyStyle.Render("Name:"), valStyle.Render(truncate(displayName, width-10)))
211+
folderURL := fmt.Sprintf("https://console.cloud.google.com/storage/browser/%s/%s;tab=objects?project=%s", m.currentBucket, strings.TrimSuffix(prefix.Name, "/"), m.currentProjectID)
212+
fmt.Fprintf(&s, "%s %s\n", keyStyle.Render("Console Link:"), valStyle.Render(terminalHyperlink(folderURL, bucketInfoLinkStyle.Render("Link"))))
213+
210214
if !prefix.Fetched {
211215
fmt.Fprintf(&s, "%s %s\n", keyStyle.Render("Type:"), valStyle.Render("Folder"))
212216
fmt.Fprintf(&s, "\n%s Loading metadata...\n", m.renderSpinner())
@@ -239,6 +243,9 @@ func (m *Model) previewView(width int) string {
239243
if m.showMetadata {
240244
// Detailed Metadata View
241245
fmt.Fprintf(&s, "%s %s\n", keyStyle.Render("Name:"), valStyle.Render(truncate(obj.Name, width-10)))
246+
objectURL := fmt.Sprintf("https://console.cloud.google.com/storage/browser/_details/%s/%s?project=%s", m.currentBucket, obj.Name, m.currentProjectID)
247+
fmt.Fprintf(&s, "%s %s\n", keyStyle.Render("Console Link:"), valStyle.Render(terminalHyperlink(objectURL, bucketInfoLinkStyle.Render("Link"))))
248+
242249
fmt.Fprintf(&s, "%s %s\n", keyStyle.Render("Bucket:"), valStyle.Render(truncate(obj.Bucket, width-12)))
243250
s.WriteString("\n")
244251

@@ -295,6 +302,9 @@ func (m *Model) previewView(width int) string {
295302
// Standard Preview
296303
displayName := getDisplayName(obj.Name, m.currentPrefix)
297304
fmt.Fprintf(&s, "%s %s\n", keyStyle.Render("Name:"), valStyle.Render(truncate(displayName, width-10)))
305+
objectURL := fmt.Sprintf("https://console.cloud.google.com/storage/browser/_details/%s/%s?project=%s", m.currentBucket, obj.Name, m.currentProjectID)
306+
fmt.Fprintf(&s, "%s %s\n", keyStyle.Render("Console Link:"), valStyle.Render(terminalHyperlink(objectURL, bucketInfoLinkStyle.Render("Link"))))
307+
298308
fmt.Fprintf(&s, "%s %s\n", keyStyle.Render("Size:"), valStyle.Render(humanizeSize(obj.Size)))
299309

300310
contentType := obj.ContentType
@@ -404,7 +414,9 @@ func (m *Model) previewView(width int) string {
404414
if m.cursor < len(filtered) {
405415
item := filtered[m.cursor]
406416
if item.IsProject {
407-
s.WriteString(bucketInfoProjectStyle.Render("Project: ") + item.ProjectID + "\n\n")
417+
s.WriteString(bucketInfoProjectStyle.Render("Project: ") + item.ProjectID + "\n")
418+
projectURL := fmt.Sprintf("https://console.cloud.google.com/welcome?project=%s", item.ProjectID)
419+
fmt.Fprintf(&s, "%s %s\n\n", bucketInfoKeyStyle.Render("Console Link:"), bucketInfoValStyle.Render(terminalHyperlink(projectURL, bucketInfoLinkStyle.Render("Link"))))
408420

409421
if m.previewContent == "Loading project info..." || m.previewContent == clearImagesEsc+"Loading project info..." {
410422
fmt.Fprintf(&s, "\n%s Loading project info...\n", m.renderSpinner())
@@ -449,6 +461,8 @@ func (m *Model) previewView(width int) string {
449461
}
450462
} else {
451463
fmt.Fprintf(&s, "%s %s\n", bucketInfoKeyStyle.Render("Bucket:"), bucketInfoValStyle.Render(truncate(item.BucketName, width-10)))
464+
bucketURL := fmt.Sprintf("https://console.cloud.google.com/storage/browser/%s?project=%s", item.BucketName, item.ProjectID)
465+
fmt.Fprintf(&s, "%s %s\n\n", bucketInfoKeyStyle.Render("Console Link:"), bucketInfoValStyle.Render(terminalHyperlink(bucketURL, bucketInfoLinkStyle.Render("Link"))))
452466

453467
if m.previewContent == "Loading..." || m.previewContent == clearImagesEsc+"Loading..." {
454468
fmt.Fprintf(&s, "\n%s Loading metadata...\n", m.renderSpinner())

tests/testdata/TestSnapshot_HelpMenu.golden

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
│ Buckets ││  ││ Project Information │
55
│ ││ ││  │
66
│ ▼ prod-project  ┃ ││ ││ Project: prod-project │
7+
│ ││ ││ Console Link: ]8;;https://console.cloud.google.com/welcome?project=prod-project\Link]8;;\ │
78
│ ││ ││  │
8-
 ││ ││ Project Name: Mock Project prod-project │
9-
╰───────────────────────╯╰────────────────────────────╯│  │
9+
╰───────────────────────╯╰────────────────────────────╯│ Project Name: Mock Project prod-project │
10+
│  │
1011
│ Total Buckets: 1 │
1112
│  │
1213
╰───────────────────────────────────────────╯

tests/testdata/TestSnapshot_InitialBucketsView.golden

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44
│ Buckets ││ Objects in assets ││ Bucket Information │
55
│ ││  ││  │
66
│ ▼ prod-project ││ * Loading... ││ Bucket: assets │
7-
│  📦 assets  ││ ││  │
8-
│  📦 logs ││ ││ * Loading metadata... │
7+
│  📦 assets  ││ ││ Console Link: ]8;;https://console.cloud.google.com/storage/browser/assets?project=prod-project\Link]8;;\ │
8+
│  📦 logs ││ ││  │
9+
│ ││ ││  │
10+
│ ││ ││ * Loading metadata... │
911
│ ││ ││  │
10-
│ ││ ││ │
11-
│ ││ ││ │
1212
│ ││ ││ │
1313
│ ││ ││ │
1414
│ ││ ││ │

0 commit comments

Comments
 (0)