Skip to content

Commit 1deb518

Browse files
committed
fix(hot reload context)
1 parent 3f7a678 commit 1deb518

5 files changed

Lines changed: 188 additions & 7 deletions

File tree

internal/ui/app_context.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,18 @@ func (a *App) SetDefaultContext(contextName string) {
7878
return
7979
}
8080

81+
a.ReloadContext(contextName)
82+
}
83+
84+
// ReloadContext rebuilds the Docker client for contextName, even when it
85+
// is already the active context. Used after editing a context (endpoint
86+
// or credentials change) so changes apply without restarting d4s.
87+
func (a *App) ReloadContext(contextName string) {
88+
contextName = strings.TrimSpace(contextName)
89+
if contextName == "" {
90+
return
91+
}
92+
8193
a.SetFlashPending(fmt.Sprintf("switching context to %s...", contextName))
8294
a.SetPaused(true)
8395
a.StopAutoRefresh()
@@ -94,6 +106,9 @@ func (a *App) SetDefaultContext(contextName string) {
94106
a.ActiveFilter = ""
95107
for _, v := range a.Views {
96108
v.SetLoading(true)
109+
// Free the fetch guard held by any fetch still running against the
110+
// old endpoint (possibly hung), and mark its results as stale.
111+
v.InvalidateFetch()
97112
}
98113
a.RestoreFocus()
99114
a.UpdateShortcuts()

internal/ui/app_view.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,10 +132,11 @@ func (a *App) RefreshCurrentView() {
132132

133133
// Skip this tick if the previous fetch is still in flight
134134
// (slow SSH transport): avoids piling up redundant API calls.
135-
if !v.TryAcquireFetch() {
135+
gen, acquired := v.TryAcquireFetch()
136+
if !acquired {
136137
return
137138
}
138-
defer v.ReleaseFetch()
139+
defer v.ReleaseFetch(gen)
139140

140141
var err error
141142
var data []dao.Resource
@@ -161,6 +162,11 @@ func (a *App) RefreshCurrentView() {
161162
}
162163

163164
a.SafeQueueUpdateDraw(func() {
165+
// Drop stale results if the context was reloaded mid-fetch
166+
if v.FetchGen() != gen {
167+
return
168+
}
169+
164170
// Check if page changed while fetching?
165171
currentPage, _ := a.Pages.GetFrontPage()
166172
if currentPage != page {

internal/ui/common/common.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ type AppController interface {
7777

7878
// Context Management
7979
SetDefaultContext(contextName string)
80+
ReloadContext(contextName string)
8081

8182
// Port-Forward Management
8283
GetPortForwardManager() *portforward.Manager

internal/ui/components/view/view.go

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,34 @@ import (
1818
// TryAcquireFetch reports whether a new background fetch may start.
1919
// It returns false while another fetch for this view is still running,
2020
// preventing refresh ticks from piling up on slow (SSH) transports.
21-
func (v *ResourceView) TryAcquireFetch() bool {
22-
return v.fetchInFlight.CompareAndSwap(false, true)
21+
// The returned generation must be passed back to ReleaseFetch.
22+
func (v *ResourceView) TryAcquireFetch() (int64, bool) {
23+
if !v.fetchInFlight.CompareAndSwap(false, true) {
24+
return 0, false
25+
}
26+
return v.fetchGen.Load(), true
27+
}
28+
29+
// ReleaseFetch clears the in-flight guard, unless the fetch was
30+
// invalidated (context reload) while it was running: in that case the
31+
// guard now belongs to the new context's fetches.
32+
func (v *ResourceView) ReleaseFetch(gen int64) {
33+
if v.fetchGen.Load() == gen {
34+
v.fetchInFlight.Store(false)
35+
}
36+
}
37+
38+
// FetchGen returns the current fetch generation. A fetch whose acquired
39+
// generation no longer matches must discard its results (stale context).
40+
func (v *ResourceView) FetchGen() int64 {
41+
return v.fetchGen.Load()
2342
}
2443

25-
func (v *ResourceView) ReleaseFetch() {
44+
// InvalidateFetch cancels any in-flight fetch: its results will be
45+
// dropped and the guard is freed so the new context can fetch
46+
// immediately instead of waiting for the old (possibly hung) transport.
47+
func (v *ResourceView) InvalidateFetch() {
48+
v.fetchGen.Add(1)
2649
v.fetchInFlight.Store(false)
2750
}
2851

@@ -47,6 +70,7 @@ type ResourceView struct {
4770

4871
// Guard against overlapping background fetches (slow SSH transports)
4972
fetchInFlight atomic.Bool
73+
fetchGen atomic.Int64
5074

5175
// Pinned sort: always applied first (unless user sorts on this column)
5276
PinnedSortColumn string // Column name (e.g. "ANON"), resolved dynamically

internal/ui/views/contexts/contexts.go

Lines changed: 137 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,11 @@ func Edit(app common.AppController, v *view.ResourceView) {
243243
}
244244
}
245245

246+
if strings.HasPrefix(currentEndpoint, "ssh://") {
247+
editSSH(app, id, currentDesc, currentEndpoint)
248+
return
249+
}
250+
246251
fields := []dialogs.FormField{
247252
{Name: "description", Label: "Description", Type: dialogs.FieldTypeInput, Default: currentDesc},
248253
{Name: "host", Label: "Docker Host", Type: dialogs.FieldTypeInput, Default: currentEndpoint},
@@ -256,13 +261,143 @@ func Edit(app common.AppController, v *view.ResourceView) {
256261

257262
app.RunInBackground(func() {
258263
err := app.GetDocker().UpdateContext(id, description, host)
264+
isCurrent := app.GetDocker().ContextName == id
259265
app.GetTviewApp().QueueUpdateDraw(func() {
260266
if err != nil {
261267
app.AppendFlashError(fmt.Sprintf("failed to update context: %v", err))
268+
app.RefreshCurrentView()
269+
return
270+
}
271+
app.AppendFlashSuccess(fmt.Sprintf("context %s updated", id))
272+
if isCurrent {
273+
// Rebuild the client so the new endpoint applies immediately
274+
app.ReloadContext(id)
262275
} else {
263-
app.AppendFlashSuccess(fmt.Sprintf("context %s updated", id))
276+
app.RefreshCurrentView()
277+
}
278+
})
279+
})
280+
})
281+
}
282+
283+
// parseSSHURL splits "ssh://user@ip[:port][/socket/path]" into the host
284+
// part and the optional remote socket path.
285+
func parseSSHURL(endpoint string) (host, socket string) {
286+
rest := strings.TrimPrefix(endpoint, "ssh://")
287+
if idx := strings.Index(rest, "/"); idx >= 0 {
288+
return rest[:idx], rest[idx:]
289+
}
290+
return rest, ""
291+
}
292+
293+
func editSSH(app common.AppController, id, currentDesc, currentEndpoint string) {
294+
existing, _ := secrets.Load(id)
295+
296+
currentAuth := secrets.AuthTypeKey
297+
if existing != nil && existing.AuthType != "" {
298+
currentAuth = existing.AuthType
299+
}
300+
301+
items := []dialogs.PickerItem{
302+
{Label: "SSH Key", Description: "authenticate with a private key", Value: secrets.AuthTypeKey},
303+
{Label: "Password", Description: "authenticate with a password", Value: secrets.AuthTypePassword},
304+
}
305+
for i := range items {
306+
if items[i].Value == currentAuth {
307+
items[i].Description += " (current)"
308+
}
309+
}
310+
311+
dialogs.ShowPicker(app, "Authentication Method", items, func(authType string) {
312+
showSSHEditForm(app, id, currentDesc, currentEndpoint, authType, existing)
313+
})
314+
}
315+
316+
func showSSHEditForm(app common.AppController, id, currentDesc, currentEndpoint, authType string, existing *secrets.SSHCredentials) {
317+
currentHost, currentSocket := parseSSHURL(currentEndpoint)
318+
if currentSocket == "" {
319+
currentSocket = "/var/run/docker.sock"
320+
}
321+
322+
// Pre-fill credentials only when the auth method is unchanged
323+
var defaultKey, defaultPassphrase, defaultPassword string
324+
if existing != nil && existing.AuthType == authType {
325+
defaultKey = existing.KeyPath
326+
defaultPassphrase = existing.Passphrase
327+
defaultPassword = existing.Password
328+
}
329+
330+
fields := []dialogs.FormField{
331+
{Name: "description", Label: "Description", Type: dialogs.FieldTypeInput, Default: currentDesc},
332+
{Name: "host", Label: "Host (user@ip)", Type: dialogs.FieldTypeInput, Default: currentHost},
333+
}
334+
335+
if authType == secrets.AuthTypeKey {
336+
fields = append(fields,
337+
dialogs.FormField{Name: "key", Label: "SSH Key", Type: dialogs.FieldTypeInput, Default: defaultKey, Placeholder: "~/.ssh/id_ed25519"},
338+
dialogs.FormField{Name: "passphrase", Label: "Passphrase (optional)", Type: dialogs.FieldTypeInput, Default: defaultPassphrase, Secret: true},
339+
)
340+
} else {
341+
fields = append(fields,
342+
dialogs.FormField{Name: "password", Label: "Password", Type: dialogs.FieldTypeInput, Default: defaultPassword, Secret: true},
343+
)
344+
}
345+
346+
fields = append(fields,
347+
dialogs.FormField{Name: "socket", Label: "Docker Socket", Type: dialogs.FieldTypeInput, Default: currentSocket},
348+
)
349+
350+
dialogs.ShowFormWithDescription(app, fmt.Sprintf("Edit Context: %s", id), "Updates the SSH context and its credentials", fields, func(result dialogs.FormResult) {
351+
description := result["description"]
352+
host := strings.TrimSpace(result["host"])
353+
socket := strings.TrimSpace(result["socket"])
354+
355+
if host == "" {
356+
app.SetFlashError("host is required")
357+
return
358+
}
359+
if authType == secrets.AuthTypePassword && result["password"] == "" {
360+
app.SetFlashError("password is required")
361+
return
362+
}
363+
364+
creds := secrets.SSHCredentials{
365+
AuthType: authType,
366+
KeyPath: expandHome(strings.TrimSpace(result["key"])),
367+
Passphrase: result["passphrase"],
368+
Password: result["password"],
369+
}
370+
371+
sshURL := fmt.Sprintf("ssh://%s", host)
372+
if socket != "" && socket != "/var/run/docker.sock" {
373+
sshURL = fmt.Sprintf("ssh://%s%s", host, socket)
374+
}
375+
376+
app.AppendFlashPending(fmt.Sprintf("updating context %s...", id))
377+
378+
app.RunInBackground(func() {
379+
err := app.GetDocker().UpdateContext(id, description, sshURL)
380+
if err == nil {
381+
if kerr := secrets.Save(id, creds); kerr != nil {
382+
app.GetTviewApp().QueueUpdateDraw(func() {
383+
app.AppendFlashError(fmt.Sprintf("context updated but credentials not saved: %v", kerr))
384+
})
385+
}
386+
}
387+
isCurrent := app.GetDocker().ContextName == id
388+
app.GetTviewApp().QueueUpdateDraw(func() {
389+
if err != nil {
390+
app.AppendFlashError(fmt.Sprintf("failed to update context: %v", err))
391+
app.RefreshCurrentView()
392+
return
393+
}
394+
app.AppendFlashSuccess(fmt.Sprintf("SSH context '%s' updated", id))
395+
if isCurrent {
396+
// Rebuild the client so the new endpoint/credentials apply immediately
397+
app.ReloadContext(id)
398+
} else {
399+
app.RefreshCurrentView()
264400
}
265-
app.RefreshCurrentView()
266401
})
267402
})
268403
})

0 commit comments

Comments
 (0)