Skip to content

Commit 4d321b5

Browse files
committed
feat(ux): add portal link copy on c
1 parent 73b61e5 commit 4d321b5

8 files changed

Lines changed: 243 additions & 7 deletions

File tree

AGENTS.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,10 @@ pkg/
233233
│ ├── tasks.go
234234
│ └── tasks_test.go # Task manager tests
235235
└── utils/ # Utilities
236-
└── logger.go # Debug logging (opt-in via LAZYAZURE_DEBUG)
236+
├── logger.go # Debug logging (opt-in via LAZYAZURE_DEBUG)
237+
├── clipboard.go # Clipboard operations (cross-platform)
238+
├── portal_urls.go # Azure Portal URL generation
239+
└── portal_urls_test.go # Portal URL tests
237240
```
238241

239242
### 10. Code Style
@@ -279,7 +282,9 @@ pkg/
279282

280283
## Session Checklist
281284

282-
Before finishing a session:
285+
**CRITICAL: Complete this checklist BEFORE committing any changes.**
286+
287+
Before finishing a session or committing changes:
283288

284289
- [ ] Code builds without errors: `go build .`
285290
- [ ] Tests have been updated or added per the guideline in this file
@@ -290,6 +295,8 @@ Before finishing a session:
290295
- [ ] Documentation updated if needed (README.md, this file)
291296
- [ ] File organization in README and AGENTS is updated
292297

298+
**Do not commit until all checklist items are verified!**
299+
293300
## Key Lessons from Development
294301

295302
1. **Terminal UI is hard**: Threading + UI event loops require careful coordination

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,10 @@ lazyazure/
167167
│ ├── tasks/
168168
│ │ └── tasks.go # Async task management
169169
│ └── utils/
170-
│ └── logger.go # Debug logging utility
170+
│ ├── logger.go # Debug logging utility
171+
│ ├── clipboard.go # Clipboard operations
172+
│ ├── portal_urls.go # Azure Portal URL generation
173+
│ └── portal_urls_test.go # Portal URL tests
171174
```
172175

173176
## Project Status

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ require (
1414
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
1515
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
1616
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
17+
github.com/atotto/clipboard v0.1.4 // indirect
1718
github.com/dlclark/regexp2 v1.11.5 // indirect
1819
github.com/gdamore/encoding v1.0.0 // indirect
1920
github.com/gdamore/tcell/v2 v2.7.4 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgv
2020
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
2121
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
2222
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
23+
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
24+
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
2325
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
2426
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2527
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

pkg/gui/gui.go

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,12 @@ func (gui *Gui) setupKeybindings() error {
333333
}
334334
utils.Log("setupKeybindings: Refresh key set")
335335

336+
// Copy portal link (global)
337+
if err := gui.g.SetKeybinding("", 'c', gocui.ModNone, gui.copyPortalUrl); err != nil {
338+
return err
339+
}
340+
utils.Log("setupKeybindings: Copy portal link key set")
341+
336342
// Panel switching with Tab key
337343
if err := gui.g.SetKeybinding("", gocui.KeyTab, gocui.ModNone, gui.switchPanel); err != nil {
338344
return err
@@ -906,6 +912,75 @@ func (gui *Gui) nextTab(g *gocui.Gui, v *gocui.View) error {
906912
return nil
907913
}
908914

915+
// copyPortalUrl copies the Azure Portal URL for the currently selected item to clipboard
916+
func (gui *Gui) copyPortalUrl(g *gocui.Gui, v *gocui.View) error {
917+
gui.mu.RLock()
918+
selectedSub := gui.selectedSub
919+
selectedRG := gui.selectedRG
920+
selectedRes := gui.selectedRes
921+
activePanel := gui.activePanel
922+
gui.mu.RUnlock()
923+
924+
var url string
925+
var itemType string
926+
927+
// Build URL based on what's selected and active panel
928+
switch activePanel {
929+
case "subscriptions":
930+
if selectedSub == nil {
931+
gui.showTemporaryStatus("No subscription selected")
932+
return nil
933+
}
934+
url = utils.BuildSubscriptionPortalURL(selectedSub.TenantID, selectedSub.ID)
935+
itemType = "subscription"
936+
case "resourcegroups":
937+
if selectedRG == nil || selectedSub == nil {
938+
gui.showTemporaryStatus("No resource group selected")
939+
return nil
940+
}
941+
url = utils.BuildResourceGroupPortalURL(selectedSub.TenantID, selectedRG.SubscriptionID, selectedRG.Name)
942+
itemType = "resource group"
943+
case "resources", "main":
944+
if selectedRes == nil || selectedSub == nil {
945+
gui.showTemporaryStatus("No resource selected")
946+
return nil
947+
}
948+
url = utils.BuildResourcePortalURL(selectedSub.TenantID, selectedRes.ID)
949+
itemType = "resource"
950+
default:
951+
gui.showTemporaryStatus("No item selected")
952+
return nil
953+
}
954+
955+
// Copy to clipboard
956+
if err := utils.CopyToClipboard(url); err != nil {
957+
gui.showTemporaryStatus(fmt.Sprintf("Failed to copy: %v", err))
958+
return nil
959+
}
960+
961+
gui.showTemporaryStatus(fmt.Sprintf("Copied %s portal link to clipboard", itemType))
962+
return nil
963+
}
964+
965+
// showTemporaryStatus shows a temporary status message that reverts after a delay
966+
func (gui *Gui) showTemporaryStatus(message string) {
967+
if gui.statusView == nil {
968+
return
969+
}
970+
971+
gui.statusView.Clear()
972+
fmt.Fprint(gui.statusView, message)
973+
974+
// Restore normal status after 2 seconds
975+
go func() {
976+
time.Sleep(2 * time.Second)
977+
gui.g.UpdateAsync(func(g *gocui.Gui) error {
978+
gui.updateStatus()
979+
return nil
980+
})
981+
}()
982+
}
983+
909984
// ANSI color codes
910985
const (
911986
// Color 114 from 256-color palette (github-dark key color) + bold
@@ -1139,13 +1214,13 @@ func (gui *Gui) updateStatus() {
11391214
var status string
11401215
switch activePanel {
11411216
case "subscriptions":
1142-
status = fmt.Sprintf("↑↓: Navigate | Enter: Load RGs | Tab: Switch | r: Refresh | q: Quit | Subs: %d", subCount)
1217+
status = fmt.Sprintf("↑↓: Navigate | Enter: Load RGs | c: Copy Link | Tab: Switch | r: Refresh | q: Quit | Subs: %d", subCount)
11431218
case "resourcegroups":
1144-
status = fmt.Sprintf("↑↓: Navigate | Enter: Load Resources | Tab: Switch | []: Tabs | r: Refresh | q: Quit | RGs: %d", rgCount)
1219+
status = fmt.Sprintf("↑↓: Navigate | Enter: Load Resources | c: Copy Link | Tab: Switch | []: Tabs | r: Refresh | q: Quit | RGs: %d", rgCount)
11451220
case "resources":
1146-
status = fmt.Sprintf("↑↓: Navigate | Enter: View Details | Tab: Switch | []: Tabs | r: Refresh | q: Quit | Resources: %d", resCount)
1221+
status = fmt.Sprintf("↑↓: Navigate | Enter: View Details | c: Copy Link | Tab: Switch | []: Tabs | r: Refresh | q: Quit | Resources: %d", resCount)
11471222
case "main":
1148-
status = fmt.Sprintf("↑/↓ or j/k: Scroll | PgUp/PgDn: Page | Tab: Back to List | []: Tabs | r: Refresh | q: Quit")
1223+
status = fmt.Sprintf("↑/↓ or j/k: Scroll | PgUp/PgDn: Page | c: Copy Link | Tab: Back to List | []: Tabs | r: Refresh | q: Quit")
11491224
default:
11501225
status = fmt.Sprintf("↑↓: Navigate | Tab: Switch | r: Refresh | q: Quit")
11511226
}

pkg/utils/clipboard.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package utils
2+
3+
import (
4+
"github.com/atotto/clipboard"
5+
)
6+
7+
// CopyToClipboard copies text to the system clipboard
8+
func CopyToClipboard(text string) error {
9+
return clipboard.WriteAll(text)
10+
}
11+
12+
// IsClipboardAvailable checks if the clipboard is available on the system
13+
func IsClipboardAvailable() bool {
14+
// The atotto/clipboard library handles this internally for each platform
15+
// Just try to read from clipboard to check if it's working
16+
_, err := clipboard.ReadAll()
17+
return err == nil
18+
}

pkg/utils/portal_urls.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package utils
2+
3+
import "fmt"
4+
5+
// BuildSubscriptionPortalURL builds the Azure Portal URL for a subscription
6+
func BuildSubscriptionPortalURL(tenantID, subscriptionID string) string {
7+
return fmt.Sprintf("https://portal.azure.com/#@%s/resource/subscriptions/%s/overview", tenantID, subscriptionID)
8+
}
9+
10+
// BuildResourceGroupPortalURL builds the Azure Portal URL for a resource group
11+
func BuildResourceGroupPortalURL(tenantID, subscriptionID, resourceGroupName string) string {
12+
return fmt.Sprintf("https://portal.azure.com/#@%s/resource/subscriptions/%s/resourceGroups/%s/overview", tenantID, subscriptionID, resourceGroupName)
13+
}
14+
15+
// BuildResourcePortalURL builds the Azure Portal URL for a resource
16+
// Note: resourceID should start with / (e.g., /subscriptions/...)
17+
func BuildResourcePortalURL(tenantID, resourceID string) string {
18+
// resourceID already starts with /, so we don't need an extra one
19+
return fmt.Sprintf("https://portal.azure.com/#@%s/resource%s/overview", tenantID, resourceID)
20+
}

pkg/utils/portal_urls_test.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package utils
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestBuildSubscriptionPortalURL(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
tenantID string
12+
subscriptionID string
13+
want string
14+
}{
15+
{
16+
name: "valid subscription URL",
17+
tenantID: "12345678-1234-1234-1234-123456789012",
18+
subscriptionID: "87654321-4321-4321-4321-210987654321",
19+
want: "https://portal.azure.com/#@12345678-1234-1234-1234-123456789012/resource/subscriptions/87654321-4321-4321-4321-210987654321/overview",
20+
},
21+
}
22+
23+
for _, tt := range tests {
24+
t.Run(tt.name, func(t *testing.T) {
25+
got := BuildSubscriptionPortalURL(tt.tenantID, tt.subscriptionID)
26+
if got != tt.want {
27+
t.Errorf("BuildSubscriptionPortalURL() = %v, want %v", got, tt.want)
28+
}
29+
// Verify no double slashes (except in protocol)
30+
if strings.Contains(got, "://") {
31+
path := strings.SplitN(got, "://", 2)[1]
32+
if strings.Contains(path, "//") {
33+
t.Errorf("URL contains double slashes: %v", got)
34+
}
35+
}
36+
})
37+
}
38+
}
39+
40+
func TestBuildResourceGroupPortalURL(t *testing.T) {
41+
tests := []struct {
42+
name string
43+
tenantID string
44+
subscriptionID string
45+
resourceGroupName string
46+
want string
47+
}{
48+
{
49+
name: "valid resource group URL",
50+
tenantID: "12345678-1234-1234-1234-123456789012",
51+
subscriptionID: "87654321-4321-4321-4321-210987654321",
52+
resourceGroupName: "my-resource-group",
53+
want: "https://portal.azure.com/#@12345678-1234-1234-1234-123456789012/resource/subscriptions/87654321-4321-4321-4321-210987654321/resourceGroups/my-resource-group/overview",
54+
},
55+
}
56+
57+
for _, tt := range tests {
58+
t.Run(tt.name, func(t *testing.T) {
59+
got := BuildResourceGroupPortalURL(tt.tenantID, tt.subscriptionID, tt.resourceGroupName)
60+
if got != tt.want {
61+
t.Errorf("BuildResourceGroupPortalURL() = %v, want %v", got, tt.want)
62+
}
63+
// Verify no double slashes (except in protocol)
64+
if strings.Contains(got, "://") {
65+
path := strings.SplitN(got, "://", 2)[1]
66+
if strings.Contains(path, "//") {
67+
t.Errorf("URL contains double slashes: %v", got)
68+
}
69+
}
70+
})
71+
}
72+
}
73+
74+
func TestBuildResourcePortalURL(t *testing.T) {
75+
tests := []struct {
76+
name string
77+
tenantID string
78+
resourceID string
79+
want string
80+
}{
81+
{
82+
name: "valid resource URL with leading slash",
83+
tenantID: "12345678-1234-1234-1234-123456789012",
84+
resourceID: "/subscriptions/87654321-4321-4321-4321-210987654321/resourceGroups/my-rg/providers/Microsoft.Compute/virtualMachines/my-vm",
85+
want: "https://portal.azure.com/#@12345678-1234-1234-1234-123456789012/resource/subscriptions/87654321-4321-4321-4321-210987654321/resourceGroups/my-rg/providers/Microsoft.Compute/virtualMachines/my-vm/overview",
86+
},
87+
{
88+
name: "resource URL should not have double slashes",
89+
tenantID: "tenant-123",
90+
resourceID: "/subscriptions/sub-123/resourceGroups/rg-1/providers/Microsoft.Storage/storageAccounts/account1",
91+
want: "https://portal.azure.com/#@tenant-123/resource/subscriptions/sub-123/resourceGroups/rg-1/providers/Microsoft.Storage/storageAccounts/account1/overview",
92+
},
93+
}
94+
95+
for _, tt := range tests {
96+
t.Run(tt.name, func(t *testing.T) {
97+
got := BuildResourcePortalURL(tt.tenantID, tt.resourceID)
98+
if got != tt.want {
99+
t.Errorf("BuildResourcePortalURL() = %v, want %v", got, tt.want)
100+
}
101+
// Verify no double slashes (except in protocol)
102+
if strings.Contains(got, "://") {
103+
path := strings.SplitN(got, "://", 2)[1]
104+
if strings.Contains(path, "//") {
105+
t.Errorf("URL contains double slashes: %v", got)
106+
}
107+
}
108+
})
109+
}
110+
}

0 commit comments

Comments
 (0)