Skip to content

Commit 29bff6d

Browse files
committed
Updated to WS Settings page
1 parent 15c01f7 commit 29bff6d

File tree

4 files changed

+185
-14
lines changed

4 files changed

+185
-14
lines changed

tui/messages.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,10 @@ const (
106106
// /api/v2/account/details — one of the accountResourceType constants.
107107
type accountTypeLoadedMsg accountResourceType
108108

109+
// wsLatestChangeMsg carries the parsed latest-change-at timestamp for the
110+
// selected workspace. nil means the field was absent or the call failed.
111+
type wsLatestChangeMsg *time.Time
112+
109113
// fetchErrMsg wraps any error returned from an async fetch.
110114
type fetchErrMsg struct{ err error }
111115

@@ -190,6 +194,39 @@ func loadAccountToken(c *client.TfxClient, userID string) tea.Cmd {
190194
}
191195
}
192196

197+
// loadWorkspaceLatestChange fetches /api/v2/workspaces/:id and extracts the
198+
// latest-change-at attribute, which go-tfe does not decode into tfe.Workspace.
199+
// Silently returns nil on any error.
200+
func loadWorkspaceLatestChange(c *client.TfxClient, wsID string) tea.Cmd {
201+
return func() tea.Msg {
202+
url := fmt.Sprintf("https://%s/api/v2/workspaces/%s", c.Hostname, wsID)
203+
req, err := http.NewRequestWithContext(c.Context, http.MethodGet, url, nil)
204+
if err != nil {
205+
return wsLatestChangeMsg(nil)
206+
}
207+
req.Header.Set("Authorization", "Bearer "+c.Token)
208+
req.Header.Set("Content-Type", "application/vnd.api+json")
209+
210+
resp, err := c.HTTPClient.Do(req)
211+
if err != nil {
212+
return wsLatestChangeMsg(nil)
213+
}
214+
defer resp.Body.Close()
215+
216+
var payload struct {
217+
Data struct {
218+
Attributes struct {
219+
LatestChangeAt *time.Time `json:"latest-change-at"`
220+
} `json:"attributes"`
221+
} `json:"data"`
222+
}
223+
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
224+
return wsLatestChangeMsg(nil)
225+
}
226+
return wsLatestChangeMsg(payload.Data.Attributes.LatestChangeAt)
227+
}
228+
}
229+
193230
func loadOrganizations(c *client.TfxClient) tea.Cmd {
194231
return func() tea.Msg {
195232
orgs, err := data.FetchOrganizations(c, "")

tui/model.go

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ var wsTabs = []struct {
3434
label string
3535
view viewType
3636
}{
37+
{"Settings", viewWorkspaceSettings},
3738
{"Runs", viewRuns},
3839
{"Variables", viewVariables},
3940
{"Config Versions", viewConfigVersions},
@@ -46,11 +47,12 @@ const (
4647
viewOrganizations viewType = iota // Phase 6: top-level org list (entry point)
4748
viewProjects
4849
viewWorkspaces
50+
viewWorkspaceSettings // Settings tab (first workspace sub-view tab)
4951
viewRuns
5052
viewVariables // Phase 5
5153
viewConfigVersions // Phase 5
5254
viewStateVersions // Phase 5
53-
viewWorkspaceDetail // workspace settings detail (d key from workspace list)
55+
viewWorkspaceDetail // workspace detail (d key from workspace list or sub-views)
5456
viewOrgDetail // organization detail (d key from org list)
5557
viewProjectDetail // project detail (d key from project list)
5658
viewRunDetail // run detail (enter from run list) — Phase 7
@@ -143,8 +145,10 @@ type Model struct {
143145
svFiltering bool
144146

145147
// Workspace detail state
146-
wsDetScroll int
147-
wsDetPrevView viewType // view to return to when esc-ing from workspace detail
148+
wsDetScroll int
149+
wsDetPrevView viewType // view to return to when esc-ing from workspace detail
150+
wsSettingsScroll int // scroll offset for the Settings tab
151+
wsLatestChange *time.Time // latest-change-at from API; nil until loaded
148152

149153
// Organization detail state
150154
orgDetScroll int
@@ -273,6 +277,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
273277
case accountTokenLoadedMsg:
274278
m.accountToken = (*tfe.UserToken)(msg)
275279

280+
case wsLatestChangeMsg:
281+
m.wsLatestChange = (*time.Time)(msg)
282+
276283
case orgsLoadedMsg:
277284
m.orgs = []*tfe.Organization(msg)
278285
m.errMsg = ""
@@ -481,6 +488,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
481488
return m.handleProjectsKey(msg)
482489
case viewWorkspaces:
483490
return m.handleWorkspacesKey(msg)
491+
case viewWorkspaceSettings:
492+
return m.handleWorkspaceSettingsKey(msg)
484493
case viewRuns:
485494
return m.handleRunsKey(msg)
486495
case viewVariables:
@@ -575,7 +584,7 @@ func (m Model) navigateBack() (tea.Model, tea.Cmd) {
575584
m.wsCursor, m.wsOffset = 0, 0
576585
m.wsFilter, m.wsFiltering = "", false
577586
m.selectedProj = nil
578-
case viewRuns, viewVariables, viewConfigVersions, viewStateVersions:
587+
case viewWorkspaceSettings, viewRuns, viewVariables, viewConfigVersions, viewStateVersions:
579588
// All workspace tab views return to the workspace list and clear sub-view data.
580589
m.currentView = viewWorkspaces
581590
m.runs = nil
@@ -722,10 +731,10 @@ func (m Model) refresh() (tea.Model, tea.Cmd) {
722731
}
723732

724733
// isWorkspaceSubView returns true when the current view is one of the
725-
// workspace tab views (Runs, Variables, Config Versions, State Versions).
734+
// workspace tab views (Settings, Runs, Variables, Config Versions, State Versions).
726735
func (m Model) isWorkspaceSubView() bool {
727736
switch m.currentView {
728-
case viewRuns, viewVariables, viewConfigVersions, viewStateVersions:
737+
case viewWorkspaceSettings, viewRuns, viewVariables, viewConfigVersions, viewStateVersions:
729738
return true
730739
}
731740
return false
@@ -738,6 +747,11 @@ func (m Model) switchWsTab(target viewType) (tea.Model, tea.Cmd) {
738747
m.errMsg = ""
739748

740749
switch target {
750+
case viewWorkspaceSettings:
751+
if m.wsLatestChange == nil && m.selectedWS != nil {
752+
return m, loadWorkspaceLatestChange(m.c, m.selectedWS.ID)
753+
}
754+
return m, nil
741755
case viewRuns:
742756
if m.runs != nil {
743757
return m, nil
@@ -1311,10 +1325,11 @@ func (m Model) handleWorkspacesKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
13111325
}
13121326
sel := filtered[m.wsCursor]
13131327
m.selectedWS = sel
1314-
m.loading = true
13151328
m.errMsg = ""
1316-
m.runCursor, m.runOffset, m.runFilter = 0, 0, ""
1317-
return m, tea.Batch(loadRuns(m.c, sel.ID), tickSpinner())
1329+
m.wsSettingsScroll = 0
1330+
m.wsLatestChange = nil
1331+
m.currentView = viewWorkspaceSettings
1332+
return m, loadWorkspaceLatestChange(m.c, sel.ID)
13181333
case "v":
13191334
if n == 0 || m.wsCursor >= n {
13201335
break
@@ -1389,6 +1404,44 @@ func (m Model) handleWorkspaceDetailKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd
13891404
return m, nil
13901405
}
13911406

1407+
func (m Model) handleWorkspaceSettingsKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
1408+
switch msg.String() {
1409+
case "up", "k":
1410+
if m.wsSettingsScroll > 0 {
1411+
m.wsSettingsScroll--
1412+
}
1413+
case "down", "j":
1414+
m.wsSettingsScroll++
1415+
case "g":
1416+
m.wsSettingsScroll = 0
1417+
case "G":
1418+
m.wsSettingsScroll = 9999 // clamped to max by renderWorkspaceSettingsContent
1419+
case "left":
1420+
// First tab — nothing to the left.
1421+
case "right":
1422+
return m.switchWsTab(viewRuns)
1423+
case "u":
1424+
if url := m.wsURL(); url != "" {
1425+
if err := copyToClipboard(url); err == nil {
1426+
m.clipFeedback = "✓ workspace URL copied"
1427+
} else {
1428+
m.clipFeedback = "clipboard unavailable"
1429+
}
1430+
}
1431+
return m, nil
1432+
case "U":
1433+
if url := m.wsURL(); url != "" {
1434+
if err := openBrowser(url); err == nil {
1435+
m.clipFeedback = "✓ opening in browser"
1436+
} else {
1437+
m.clipFeedback = "could not open browser"
1438+
}
1439+
}
1440+
return m, nil
1441+
}
1442+
return m, nil
1443+
}
1444+
13921445
func (m Model) handleRunsKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
13931446
filtered := filteredRuns(m)
13941447
n := len(filtered)
@@ -1421,7 +1474,7 @@ func (m Model) handleRunsKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
14211474
case "/":
14221475
m.runFiltering = true
14231476
case "left":
1424-
// First tab — nothing to the left.
1477+
return m.switchWsTab(viewWorkspaceSettings)
14251478
case "right":
14261479
return m.switchWsTab(viewVariables)
14271480
case "enter":
@@ -1965,6 +2018,8 @@ func (m Model) renderContent() string {
19652018
return m.renderProjectsContent()
19662019
case viewWorkspaces:
19672020
return m.renderWorkspacesContent()
2021+
case viewWorkspaceSettings:
2022+
return m.renderWorkspaceSettingsContent()
19682023
case viewRuns:
19692024
return m.renderRunsContent()
19702025
case viewVariables:
@@ -2284,6 +2339,11 @@ func (m Model) breadcrumbLine() string {
22842339
return orgPart + sep +
22852340
breadcrumbBarStyle.Render(fmt.Sprintf("project: %s", projName)) +
22862341
sep + breadcrumbActiveStyle.Render("workspaces ")
2342+
case viewWorkspaceSettings:
2343+
return orgPart + sep +
2344+
breadcrumbBarStyle.Render(fmt.Sprintf("project: %s", projName)) +
2345+
sep + breadcrumbBarStyle.Render(fmt.Sprintf("workspace: %s", wsName)) +
2346+
sep + breadcrumbActiveStyle.Render("settings ")
22872347
case viewRuns:
22882348
return orgPart + sep +
22892349
breadcrumbBarStyle.Render(fmt.Sprintf("project: %s", projName)) +

tui/wsdetail.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package tui
66
import (
77
"fmt"
88
"strings"
9+
"time"
910

1011
tfe "github.com/hashicorp/go-tfe"
1112
)
@@ -24,6 +25,11 @@ type wsDetailSection struct {
2425
rows []wsDetailRow
2526
}
2627

28+
// timestampWithRelative formats a time as "2006-01-02 15:04 UTC (Xd/h/m ago)".
29+
func timestampWithRelative(t time.Time) string {
30+
return fmt.Sprintf("%s (%s)", t.UTC().Format("2006-01-02 15:04 UTC"), relativeTime(t))
31+
}
32+
2733
// boolYesNo returns "yes" or "no" for a bool value.
2834
func boolYesNo(b bool) string {
2935
if b {
@@ -44,10 +50,7 @@ func buildWorkspaceDetailSections(ws *tfe.Workspace) []wsDetailSection {
4450
general.rows = append(general.rows, wsDetailRow{"Description", ws.Description})
4551
}
4652
if !ws.CreatedAt.IsZero() {
47-
general.rows = append(general.rows, wsDetailRow{"Created", ws.CreatedAt.UTC().Format("2006-01-02 15:04 UTC")})
48-
}
49-
if !ws.UpdatedAt.IsZero() {
50-
general.rows = append(general.rows, wsDetailRow{"Updated", relativeTime(ws.UpdatedAt)})
53+
general.rows = append(general.rows, wsDetailRow{"Created", timestampWithRelative(ws.CreatedAt)})
5154
}
5255

5356
// ── Configuration ─────────────────────────────────────────────────────────

tui/wssettings.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Copyright (c) Tom Straub (github.com/straubt1) 2025
2+
// SPDX-License-Identifier: MIT
3+
4+
package tui
5+
6+
import "strings"
7+
8+
// renderWorkspaceSettingsContent renders the Settings tab for the selected
9+
// workspace. It shows the same fields as the workspace detail view (d key)
10+
// but embedded inside the tab strip rather than as a standalone view.
11+
func (m Model) renderWorkspaceSettingsContent() string {
12+
tabStrip := m.renderWorkspaceTabStrip()
13+
14+
if m.selectedWS == nil {
15+
h := m.contentHeight() - 1
16+
lines := make([]string, h)
17+
for i := range lines {
18+
lines[i] = contentStyle.Width(m.innerWidth()).Render("")
19+
}
20+
return tabStrip + "\n" + strings.Join(lines, "\n")
21+
}
22+
23+
sections := buildWorkspaceDetailSections(m.selectedWS)
24+
25+
// Inject "Last Updated" into the General section (index 0) using the
26+
// latest-change-at value fetched separately (not in tfe.Workspace).
27+
lastUpdated := "…"
28+
if m.wsLatestChange != nil {
29+
lastUpdated = timestampWithRelative(*m.wsLatestChange)
30+
}
31+
if len(sections) > 0 {
32+
sections[0].rows = append(sections[0].rows, wsDetailRow{"Last Updated", lastUpdated})
33+
}
34+
35+
var all []string
36+
all = append(all, contentStyle.Width(m.innerWidth()).Render("")) // top padding
37+
for si, sec := range sections {
38+
all = append(all, m.renderDetailSectionHeader(sec.title))
39+
for _, row := range sec.rows {
40+
all = append(all, m.renderDetailKV(row.label, row.value))
41+
}
42+
if si < len(sections)-1 {
43+
all = append(all, contentStyle.Width(m.innerWidth()).Render(""))
44+
}
45+
}
46+
all = append(all, contentStyle.Width(m.innerWidth()).Render("")) // bottom padding
47+
48+
// Visible rows = content height minus tab strip row.
49+
h := m.contentHeight() - 1
50+
51+
maxScroll := len(all) - h
52+
if maxScroll < 0 {
53+
maxScroll = 0
54+
}
55+
start := m.wsSettingsScroll
56+
if start > maxScroll {
57+
start = maxScroll
58+
}
59+
60+
visible := all[start:]
61+
if len(visible) > h {
62+
visible = visible[:h]
63+
}
64+
out := make([]string, h)
65+
copy(out, visible)
66+
for i := len(visible); i < h; i++ {
67+
out[i] = contentStyle.Width(m.innerWidth()).Render("")
68+
}
69+
70+
return tabStrip + "\n" + strings.Join(out, "\n")
71+
}

0 commit comments

Comments
 (0)