1package app23import (4 "errors"5 "log/slog"6 "net/http"7 "sync/atomic"89 "github.com/FelineStateMachine/atlas/internal/app/hostenv"10 "github.com/FelineStateMachine/atlas/internal/logging"11)1213// Adding a volume from inside the application: the host puts a picker in front14// of the reader, what they chose is validated and copied into the library, and15// the library is rescanned -- the same road a hand-dropped file takes, except16// that a drop is only noticed at the next launch (issue #5 §2, §4.6).17//18// The response is a stream of rows rather than one answer at the end, because19// a hundred-megabyte bundle takes long enough that silence reads as failure.20// Each row is flushed as it happens.21//22// ONE RUN IS ONE ROW. The stream is a stream of *states of the same row*, not23// a stream of rows: every write in this file renders the whole region with the24// one row that is true right now, and the region is morphed onto itself25// (internal/app/partials.go). An append would make each state a line of its26// own and leave every line on screen for the life of the page, which is what27// this used to do -- a cancelled picker alone left two of them.28//29// The row carries the run's number as its element id, so the morph has an30// identity to work against: within a run the id is the same and the row is31// updated in place, and a *second* run's row is a different element, which is32// what makes a fresh dismissal animation start rather than the finished one33// being inherited. The number is a name on the page and nothing else -- it is34// not application state and nobody remembers it -- so a counter for the35// process is all it takes.36var importRuns atomic.Uint643738// ImportRow is one line of an import's progress, as the rows region renders it.39type ImportRow struct {40 // Run is which import this row belongs to. It is the row's identity on41 // the page: one run's row is one element, morphed from state to state.42 Run uint644344 // State is one of "picking", "installing", "installed", "unchanged",45 // "refused". It is a class name as much as a word: the CSS system styles46 // a row by it.47 State string4849 // Detail is what to say: a file name, a volume title, an error.50 Detail string51}5253// Fading says whether a row takes itself off the screen. An import that54// finished well is news for a moment and clutter after that, so the good ends55// are marked here and faded out by the stylesheet; a refusal is the opposite,56// and stays until the next import replaces it (it is the only thing left to57// read about what went wrong).58func (r ImportRow) Fading() bool {59 return r.State == "installed" || r.State == "unchanged"60}6162// handleImport runs one import.63func (a *App) handleImport(w http.ResponseWriter, r *http.Request) {64 w.Header().Set("Content-Type", "text/html; charset=utf-8")65 w.Header().Set("Cache-Control", "no-store")66 control := http.NewResponseController(w)6768 run := importRuns.Add(1)69 row := func(state, detail string) {70 a.writeRegion(w, []ImportRow{{Run: run, State: state, Detail: detail}})71 _ = control.Flush()72 }73 // quiet takes the row away again: the region is rendered with nothing in74 // it, which is how a run that turned out to be nothing at all leaves the75 // screen exactly as it found it.76 quiet := func() {77 a.writeRegion(w, nil)78 _ = control.Flush()79 }8081 row("picking", "choosing a bundle")82 chosen, name, err := a.env.PickFile(r.Context())83 switch {84 case errors.Is(err, hostenv.ErrNoSelection):85 // A cancelled picker is silent. The reader closed a dialog they86 // opened; they know what they did, and being told about it is the87 // application talking to itself. The reference implementation said88 // nothing here either.89 quiet()90 return91 case errors.Is(err, hostenv.ErrNotAvailable):92 // The status is set before anything is written for a host that93 // cannot pick at all, which is the one case where the answer is94 // known before the stream starts.95 slog.Warn("import asked of a host with no picker", logging.Op("install"))96 row("refused", "this host has no file picker; drop the bundle into the library instead")97 return98 case err != nil:99 row("refused", err.Error())100 return101 }102 defer chosen.Close()103104 row("installing", name)105 installed, err := a.env.Volumes().Install(name, chosen)106 if err != nil {107 slog.Warn("import refused", logging.Op("install"),108 logging.Path(name), slog.Any("error", err))109 row("refused", err.Error())110 return111 }112 if installed.Already {113 row("unchanged", installed.Title+" is already installed at this build")114 } else {115 row("installed", installed.Title)116 }117118 // The rescan happened inside the store; what it moved is announced to119 // every open page, which is how a second window learns about an import120 // performed in the first.121 a.announce(installed.Changed)122}123124// writeRegion renders the import region, holding the one row that is true now125// -- or nothing, which is a region that is not on screen at all.126func (a *App) writeRegion(w http.ResponseWriter, rows []ImportRow) {127 body, err := renderPartials([]string{"import"}, View{Rows: rows})128 if err != nil {129 slog.Error("rendering an import row", logging.Op("render"), slog.Any("error", err))130 return131 }132 _, _ = w.Write(body)133}