From b677d121cecdda1c2e9bac1eb1e70bcd949238b9 Mon Sep 17 00:00:00 2001 From: Dmitri Shuralyov Date: Sat, 25 Feb 2017 00:51:32 -0500 Subject: [PATCH] Render HTML on frontend. This is a large change that primarily moves HTML rendering and display logic from backend to frontend (#67). Previously, the HTML for displaying updates was rendered on backend and streamed to browser. This worked surprisingly well and got me far, but in order to be able to have more fine grained control over frontend details, it was no longer viable to keep doing that. Now, the HTML is fully rendered on frontend, and most of the logic resides on the frontend. The backend provides services to the frontend. See issue #67 for full rationale why this is desired. Implement a long-standing feature request of having an "Update All" button (#6). This is both made possible and easy thanks to the frontend HTML rendering. This change partially helps #63, but also enables potential future changes to help it even more. In general, the move to frontend HTML rendering will help potentially achieve some of enhancements described in #8. Close #66 by removing the popup altogether. It wasn't well implemented, so it's better to remove. In the future, a better replacement implementation of the notification (without the modal popup) can be considered. Resolves #67. Closes #66. Helps #63. Helps #8. Resolves #6. --- assets/_data/script/main.go | 76 -------- assets/_data/style.css | 6 - assets/assets.go | 3 +- cmd/Go-Package-Store/dev.go | 194 ++++++++++++++++++-- cmd/Go-Package-Store/errorhandler.go | 52 ++++++ cmd/Go-Package-Store/index.go | 71 ++++++++ cmd/Go-Package-Store/main.go | 261 +++++++-------------------- cmd/Go-Package-Store/update.go | 111 +++++++----- cmd/Go-Package-Store/updates.go | 54 ++++++ component/header.go | 169 +++++++++++++++++ component/presentation.go | 113 +++++++----- frontend/main.go | 251 ++++++++++++++++++++++++++ updater/mock.go | 2 +- workspace/workspace.go | 26 ++- 14 files changed, 1002 insertions(+), 387 deletions(-) delete mode 100644 assets/_data/script/main.go create mode 100644 cmd/Go-Package-Store/errorhandler.go create mode 100644 cmd/Go-Package-Store/index.go create mode 100644 cmd/Go-Package-Store/updates.go create mode 100644 component/header.go create mode 100644 frontend/main.go diff --git a/assets/_data/script/main.go b/assets/_data/script/main.go deleted file mode 100644 index b0508ee..0000000 --- a/assets/_data/script/main.go +++ /dev/null @@ -1,76 +0,0 @@ -// +build js - -package main - -import ( - "net/url" - - "github.com/gopherjs/gopherjs/js" - "github.com/shurcooL/go/gopherjs_http/jsutil" - "honnef.co/go/js/dom" - "honnef.co/go/js/xhr" -) - -var document = dom.GetWindow().Document().(dom.HTMLDocument) - -func main() { - js.Global.Set("UpdateRepository", jsutil.Wrap(UpdateRepository)) -} - -// UpdateRepository updates specified repository. -// repoRoot is the import path corresponding to the root of the repository. -func UpdateRepository(event dom.Event, repoRoot string) { - event.PreventDefault() - if event.(*dom.MouseEvent).Button != 0 { - return - } - - repoUpdate := document.GetElementByID(repoRoot) - updateButton := repoUpdate.GetElementsByClassName("update-button")[0].(*dom.HTMLAnchorElement) - - updateButton.SetTextContent("Updating...") - updateButton.AddEventListener("click", false, func(event dom.Event) { event.PreventDefault() }) - updateButton.SetTabIndex(-1) - updateButton.Class().Add("disabled") - - go func() { - req := xhr.NewRequest("POST", "/-/update") - req.SetRequestHeader("Content-Type", "application/x-www-form-urlencoded") - err := req.Send(url.Values{"repo_root": {repoRoot}}.Encode()) - if err != nil { - println(err.Error()) - return - } - - // Hide the "Updating..." label. - updateButton.Style().SetProperty("display", "none", "") - - // Show "No Updates Available" if there are no remaining updates. - if !anyUpdatesRemaining() { - document.GetElementByID("no_updates").(dom.HTMLElement).Style().SetProperty("display", "", "") - } - - // Move this Go package to "Installed Updates" list. - installedUpdates := document.GetElementByID("installed_updates").(dom.HTMLElement) - installedUpdates.Style().SetProperty("display", "", "") - installedUpdates.ParentNode().InsertBefore(repoUpdate, installedUpdates.NextSibling()) // Insert after. - }() -} - -// anyUpdatesRemaining reports if there's at least one available or in-flight update. -func anyUpdatesRemaining() bool { - updates := document.GetElementsByClassName("go-package-update") - for _, update := range updates { - els := update.GetElementsByClassName("update-button") - if len(els) == 0 { - // There may not be any matches if this package was already updated and has no update button. - continue - } - updateButton := els[0].(*dom.HTMLAnchorElement) - updateButtonVisible := updateButton.Style().GetPropertyValue("display") != "none" - if updateButtonVisible { - return true - } - } - return false -} diff --git a/assets/_data/style.css b/assets/_data/style.css index 4ce1a7c..329fa20 100644 --- a/assets/_data/style.css +++ b/assets/_data/style.css @@ -26,12 +26,6 @@ h2 { margin-bottom: 40px; } -.disabled { - pointer-events: none; - cursor: default; - color: gray; - text-decoration: none; -} span.smaller { font-size: 11px; color: gray; diff --git a/assets/assets.go b/assets/assets.go index 5a32edf..ab4a6cf 100644 --- a/assets/assets.go +++ b/assets/assets.go @@ -13,7 +13,8 @@ import ( // Assets contains assets for Go Package Store. var Assets = union.New(map[string]http.FileSystem{ - "/assets": gopherjs_http.NewFS(http.Dir(importPathToDir("github.com/shurcooL/Go-Package-Store/assets/_data"))), + "/assets": gopherjs_http.NewFS(http.Dir(importPathToDir("github.com/shurcooL/Go-Package-Store/assets/_data"))), + "/frontend.js": gopherjs_http.Package("github.com/shurcooL/Go-Package-Store/frontend"), }) func importPathToDir(importPath string) string { diff --git a/cmd/Go-Package-Store/dev.go b/cmd/Go-Package-Store/dev.go index 05c638c..1b1bdc0 100644 --- a/cmd/Go-Package-Store/dev.go +++ b/cmd/Go-Package-Store/dev.go @@ -4,43 +4,213 @@ package main import ( "errors" + "io" "net/http" "time" "github.com/shurcooL/Go-Package-Store" + gpscomponent "github.com/shurcooL/Go-Package-Store/component" "github.com/shurcooL/Go-Package-Store/workspace" + "github.com/shurcooL/htmlg" "github.com/shurcooL/httperror" ) +import _ "net/http/pprof" + const production = false func init() { - http.HandleFunc("/mock.html", mockHandler) + http.Handle("/mock.html", errorHandler(mockHandler)) + http.Handle("/component.html", errorHandler(componentHandler)) } -func mockHandler(w http.ResponseWriter, req *http.Request) { +func mockHandler(w http.ResponseWriter, req *http.Request) error { if req.Method != "GET" { - httperror.HandleMethod(w, httperror.Method{Allowed: []string{"GET"}}) - return + return httperror.Method{Allowed: []string{"GET"}} } // Reset the pipeline and populate it with mock repo presentations, // complete with artificial delays (to simulate processing time). c.pipeline = workspace.NewPipeline(wd) go func() { - for _, repoPresentation := range mockRepoPresentations { - repoPresentation := repoPresentation - time.Sleep(time.Second) - c.pipeline.AddPresented(&repoPresentation) + for _, rp := range mockWorkspaceRPs { + time.Sleep(5 * time.Second) + rp := rp + c.pipeline.AddPresented(&rp) } - time.Sleep(time.Second) + time.Sleep(5 * time.Second) c.pipeline.Done() }() - mainHandler(w, req) + return indexHandler(w, req) +} + +func componentHandler(w http.ResponseWriter, req *http.Request) error { + if req.Method != "GET" { + return httperror.Method{Allowed: []string{"GET"}} + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + _, err := io.WriteString(w, ` + + Go Package Store + + + `) + if err != nil { + return err + } + + err = htmlg.RenderComponents(w, gpscomponent.Header{}) + if err != nil { + return err + } + + _, err = io.WriteString(w, `
`) + if err != nil { + return err + } + + err = htmlg.RenderComponents(w, gpscomponent.UpdatesHeader{ + RPs: nil, + CheckingUpdates: false, + }) + if err != nil { + return err + } + + err = htmlg.RenderComponents(w, gpscomponent.UpdatesHeader{ + RPs: mockComponentRPs, + CheckingUpdates: true, + }) + if err != nil { + return err + } + + err = htmlg.RenderComponents(w, + mockComponentRPs[0], + mockComponentRPs[1], + mockComponentRPs[2], + ) + if err != nil { + return err + } + + err = htmlg.RenderComponents(w, gpscomponent.InstalledUpdates) + if err != nil { + return err + } + + err = htmlg.RenderComponents(w, mockComponentRPs[3]) + if err != nil { + return err + } + + _, err = io.WriteString(w, `
`) + if err != nil { + return err + } + + _, err = io.WriteString(w, ``) + return err +} + +var mockComponentRPs = []*gpscomponent.RepoPresentation{ + { + RepoRoot: "github.com/gopherjs/gopherjs", + ImportPathPattern: "github.com/gopherjs/gopherjs/...", + LocalRevision: "", + RemoteRevision: "", + HomeURL: "https://github.com/gopherjs/gopherjs", + ImageURL: "https://avatars.githubusercontent.com/u/6654647?v=3", + Changes: []gpscomponent.Change{ + { + Message: "improved reflect support for blocking functions", + URL: "https://github.com/gopherjs/gopherjs/commit/87bf7e405aa3df6df0dcbb9385713f997408d7b9", + Comments: gpscomponent.Comments{ + Count: 0, + URL: "", + }, + }, + { + Message: "small cleanup", + URL: "https://github.com/gopherjs/gopherjs/commit/77a838f965881a888416bae38f790f76bb1f64bd", + Comments: gpscomponent.Comments{ + Count: 1, + URL: "https://www.example.com/", + }, + }, + { + Message: "replaced js.This and js.Arguments by js.MakeFunc", + URL: "https://github.com/gopherjs/gopherjs/commit/29dd054a0753760fe6e826ded0982a1bf69f702a", + Comments: gpscomponent.Comments{ + Count: 0, + URL: "", + }, + }, + }, + Error: "", + UpdateState: gpscomponent.Available, + UpdateSupported: true, + }, + { + RepoRoot: "golang.org/x/image", + ImportPathPattern: "golang.org/x/image/...", + LocalRevision: "", + RemoteRevision: "", + HomeURL: "http://golang.org/x/image", + ImageURL: "https://avatars.githubusercontent.com/u/4314092?v=3", + Changes: []gpscomponent.Change{ + { + Message: "draw: generate code paths for image.Gray sources.", + URL: "https://github.com/golang/image/commit/f510ad81a1256ee96a2870647b74fa144a30c249", + Comments: gpscomponent.Comments{ + Count: 0, + URL: "", + }, + }, + }, + Error: "", + UpdateState: gpscomponent.Updating, + UpdateSupported: true, + }, + { + RepoRoot: "unknown.com/package", + ImportPathPattern: "unknown.com/package/...", + LocalRevision: "abcdef0123456789000000000000000000000000", + RemoteRevision: "d34db33f01010101010101010101010101010101", + HomeURL: "https://unknown.com/package", + ImageURL: "https://github.com/images/gravatars/gravatar-user-420.png", + Changes: nil, + Error: "", + UpdateState: gpscomponent.Available, + UpdateSupported: true, + }, + { + RepoRoot: "golang.org/x/image", + ImportPathPattern: "golang.org/x/image/...", + LocalRevision: "", + RemoteRevision: "", + HomeURL: "http://golang.org/x/image", + ImageURL: "https://avatars.githubusercontent.com/u/4314092?v=3", + Changes: []gpscomponent.Change{ + { + Message: "draw: generate code paths for image.Gray sources.", + URL: "https://github.com/golang/image/commit/f510ad81a1256ee96a2870647b74fa144a30c249", + Comments: gpscomponent.Comments{ + Count: 0, + URL: "", + }, + }, + }, + Error: "", + UpdateState: gpscomponent.Updated, + UpdateSupported: true, + }, } -var mockRepoPresentations = []workspace.RepoPresentation{ +var mockWorkspaceRPs = []workspace.RepoPresentation{ { Repo: &gps.Repo{ Root: (string)("github.com/gopherjs/gopherjs"), @@ -129,7 +299,7 @@ var mockRepoPresentations = []workspace.RepoPresentation{ }, { - Updated: true, + UpdateState: workspace.Updated, Repo: &gps.Repo{ Root: (string)("github.com/influxdb/influxdb"), diff --git a/cmd/Go-Package-Store/errorhandler.go b/cmd/Go-Package-Store/errorhandler.go new file mode 100644 index 0000000..f2a81cd --- /dev/null +++ b/cmd/Go-Package-Store/errorhandler.go @@ -0,0 +1,52 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "os" + + "github.com/shurcooL/httperror" +) + +// errorHandler factors error handling out of the HTTP handler. +type errorHandler func(w http.ResponseWriter, req *http.Request) error + +func (h errorHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { + err := h(w, req) + if err == nil { + // Do nothing. + return + } + if err, ok := httperror.IsMethod(err); ok { + httperror.HandleMethod(w, err) + return + } + if err, ok := httperror.IsRedirect(err); ok { + http.Redirect(w, req, err.URL, http.StatusSeeOther) + return + } + if err, ok := httperror.IsBadRequest(err); ok { + httperror.HandleBadRequest(w, err) + return + } + if err, ok := httperror.IsHTTP(err); ok { + code := err.Code + error := fmt.Sprintf("%d %s\n\n%v", code, http.StatusText(code), err) + http.Error(w, error, code) + return + } + if os.IsNotExist(err) { + log.Println(err) + http.Error(w, "404 Not Found\n\n"+err.Error(), http.StatusNotFound) + return + } + if os.IsPermission(err) { + log.Println(err) + http.Error(w, "403 Forbidden\n\n"+err.Error(), http.StatusUnauthorized) + return + } + + log.Println(err) + http.Error(w, "500 Internal Server Error\n\n"+err.Error(), http.StatusInternalServerError) +} diff --git a/cmd/Go-Package-Store/index.go b/cmd/Go-Package-Store/index.go new file mode 100644 index 0000000..63544dc --- /dev/null +++ b/cmd/Go-Package-Store/index.go @@ -0,0 +1,71 @@ +// Go Package Store displays updates for the Go packages in your GOPATH. +package main + +import ( + "html/template" + "io" + "net/http" + + gpscomponent "github.com/shurcooL/Go-Package-Store/component" + "github.com/shurcooL/htmlg" + "github.com/shurcooL/httperror" +) + +var headHTML = template.Must(template.New("").Parse(` + + Go Package Store + + + {{if .Production}}{{end}} + + `)) + +func indexHandler(w http.ResponseWriter, req *http.Request) error { + if req.Method != "GET" { + return httperror.Method{Allowed: []string{"GET"}} + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + data := struct{ Production bool }{production} + err := headHTML.Execute(w, data) + if err != nil { + return err + } + + renderInitialBody(w) + + _, err = io.WriteString(w, ``) + return err +} + +func renderInitialBody(w io.Writer) error { + err := htmlg.RenderComponents(w, gpscomponent.Header{}) + if err != nil { + return err + } + + _, err = io.WriteString(w, `
`) + if err != nil { + return err + } + + err = htmlg.RenderComponents(w, gpscomponent.UpdatesHeader{ + CheckingUpdates: true, + }) + if err != nil { + return err + } + + _, err = io.WriteString(w, `
`) + return err +} diff --git a/cmd/Go-Package-Store/main.go b/cmd/Go-Package-Store/main.go index ffda511..9028388 100644 --- a/cmd/Go-Package-Store/main.go +++ b/cmd/Go-Package-Store/main.go @@ -5,9 +5,6 @@ import ( "bufio" "flag" "fmt" - "html/template" - "io" - "io/ioutil" "log" "net" "net/http" @@ -17,164 +14,28 @@ import ( "github.com/gregjones/httpcache" "github.com/gregjones/httpcache/diskcache" + "github.com/shurcooL/Go-Package-Store" "github.com/shurcooL/Go-Package-Store/assets" - gpscomponent "github.com/shurcooL/Go-Package-Store/component" "github.com/shurcooL/Go-Package-Store/presenter/github" "github.com/shurcooL/Go-Package-Store/presenter/gitiles" "github.com/shurcooL/Go-Package-Store/updater" "github.com/shurcooL/Go-Package-Store/workspace" "github.com/shurcooL/go/open" "github.com/shurcooL/go/ospath" - "github.com/shurcooL/htmlg" - "github.com/shurcooL/httperror" "github.com/shurcooL/httpgzip" - "golang.org/x/net/websocket" "golang.org/x/oauth2" ) +// c is a global context. var c = struct { pipeline *workspace.Pipeline - updateHandler *updateHandler -}{updateHandler: &updateHandler{updateRequests: make(chan updateRequest)}} - -var headHTML = template.Must(template.New("").Parse(` - - Go Package Store - - - {{if .Production}}{{end}} - - -
- Updates -
- - {{if .Production}}{{end}} - -
-
-

Checking for updates...

- `)) - -var tailHTML = template.Must(template.New("").Parse(` - -
-
- -`)) - -// mainHandler is the handler for the index page. -func mainHandler(w http.ResponseWriter, req *http.Request) { - if req.Method != "GET" { - httperror.HandleMethod(w, httperror.Method{Allowed: []string{"GET"}}) - return - } - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Header().Set("X-Content-Type-Options", "nosniff") - - data := struct { - Production bool - HTTPAddr string - }{ - Production: production, - HTTPAddr: *httpFlag, - } - err := headHTML.Execute(w, data) - if err != nil { - log.Println("Execute headHTML:", err) - return - } - - flusher := w.(http.Flusher) - flusher.Flush() - - var updatesAvailable = 0 - var wroteInstalledUpdatesHeader bool - - for rp := range c.pipeline.RepoPresentations() { - if !rp.Updated { - updatesAvailable++ - } - - if rp.Updated && !wroteInstalledUpdatesHeader { - // Make 'Installed Updates' header visible now. - io.WriteString(w, `

Installed Updates

`) - - wroteInstalledUpdatesHeader = true - } - - var cs []gpscomponent.Change - for _, c := range rp.Presentation.Changes { - cs = append(cs, gpscomponent.Change{ - Message: c.Message, - URL: c.URL, - Comments: gpscomponent.Comments{Count: c.Comments.Count, URL: c.Comments.URL}, - }) - } - repoPresentation := gpscomponent.RepoPresentation{ - RepoRoot: rp.Repo.Root, - ImportPathPattern: rp.Repo.ImportPathPattern(), - LocalRevision: rp.Repo.Local.Revision, - RemoteRevision: rp.Repo.Remote.Revision, - HomeURL: rp.Presentation.HomeURL, - ImageURL: rp.Presentation.ImageURL, - Changes: cs, - Updated: rp.Updated, - UpdateSupported: c.updateHandler.updater != nil, - } - if err := rp.Presentation.Error; err != nil { - repoPresentation.Error = err.Error() - } - err := htmlg.RenderComponents(w, repoPresentation) - if err != nil { - log.Println("RenderComponents repoPresentation:", err) - return - } - - flusher.Flush() - } - - if !wroteInstalledUpdatesHeader { - // TODO: Make installed_updates available before all packages finish loading, so that it works when you update a package early. This will likely require a fully dynamically rendered frontend. - // Append 'Installed Updates' header, but keep it hidden. - io.WriteString(w, ``) - } - - if updatesAvailable == 0 { - io.WriteString(w, ``) - } - - err = tailHTML.Execute(w, nil) - if err != nil { - log.Println("Execute tailHTML:", err) - return - } -} - -// WebSocket handler, to exit when client tab is closed. -func openedHandler(ws *websocket.Conn) { - // Wait until connection is closed. - io.Copy(ioutil.Discard, ws) - - //fmt.Println("Exiting, since the client tab was closed (detected closed WebSocket connection).") - //close(updateRequests) -} + // updater is set based on the source of Go packages. If nil, it means + // we don't have support to update Go packages from the current source. + // It's used to update repos in the backend, and if set to nil, to disable + // the frontend UI for updating packages. + updater gps.Updater +}{} var ( httpFlag = flag.String("http", "localhost:7043", "Listen for HTTP connections on this address.") @@ -212,7 +73,39 @@ func main() { log.SetFlags(0) c.pipeline = workspace.NewPipeline(wd) + registerPresenters(c.pipeline) + c.updater = populatePipelineAndCreateUpdater(c.pipeline) + if c.updater != nil { + updateWorker := NewUpdateWorker(c.updater) + updateWorker.Start() + http.Handle("/api/update", errorHandler(updateWorker.Handler)) + } + http.Handle("/api/updates", errorHandler(updatesHandler)) + http.Handle("/updates", errorHandler(indexHandler)) + fileServer := httpgzip.FileServer(assets.Assets, httpgzip.FileServerOptions{ServeError: httpgzip.Detailed}) + http.Handle("/assets/", fileServer) + http.Handle("/frontend.js", fileServer) + + // Start listening first. + listener, err := net.Listen("tcp", *httpFlag) + if err != nil { + log.Fatalln(fmt.Errorf("failed to listen on %q: %v", *httpFlag, err)) + } + + if production { + // Open a browser tab and navigate to the main page. + go open.Open("http://" + *httpFlag + "/updates") + } + + fmt.Println("Go Package Store server is running at http://" + *httpFlag + "/updates.") + + err = http.Serve(listener, nil) + if err != nil { + log.Fatalln(err) + } +} +func registerPresenters(pipeline *workspace.Pipeline) { // If we can have access to a cache directory on this system, use it for // caching HTTP requests of presenters. cacheDir, err := ospath.CacheDir("github.com/shurcooL/Go-Package-Store") @@ -240,7 +133,7 @@ func main() { } } - c.pipeline.RegisterPresenter(github.NewPresenter(&http.Client{Transport: transport})) + pipeline.RegisterPresenter(github.NewPresenter(&http.Client{Transport: transport})) } // Register Gitiles presenter. @@ -255,34 +148,36 @@ func main() { } } - c.pipeline.RegisterPresenter(gitiles.NewPresenter(&http.Client{Transport: transport})) + pipeline.RegisterPresenter(gitiles.NewPresenter(&http.Client{Transport: transport})) } +} +func populatePipelineAndCreateUpdater(pipeline *workspace.Pipeline) gps.Updater { switch { case !production: fmt.Println("Using no real packages (hit /mock.html endpoint for mocks).") - c.pipeline.Done() - c.updateHandler.updater = updater.Mock{} + pipeline.Done() + return updater.Mock{} default: fmt.Println("Using all Go packages in GOPATH.") go func() { // This needs to happen in the background because sending input will be blocked on processing. forEachRepository(func(r workspace.LocalRepo) { - c.pipeline.AddRepository(r) + pipeline.AddRepository(r) }) - c.pipeline.Done() + pipeline.Done() }() - c.updateHandler.updater = updater.Gopath{} + return updater.Gopath{} case *stdinFlag: fmt.Println("Reading the list of newline separated Go packages from stdin.") go func() { // This needs to happen in the background because sending input will be blocked on processing. br := bufio.NewReader(os.Stdin) for line, err := br.ReadString('\n'); err == nil; line, err = br.ReadString('\n') { importPath := line[:len(line)-1] // Trim last newline. - c.pipeline.AddImportPath(importPath) + pipeline.AddImportPath(importPath) } - c.pipeline.Done() + pipeline.Done() }() - c.updateHandler.updater = updater.Gopath{} + return updater.Gopath{} case *godepsFlag != "": fmt.Println("Reading the list of Go packages from Godeps.json file:", *godepsFlag) g, err := readGodeps(*godepsFlag) @@ -291,11 +186,11 @@ func main() { } go func() { // This needs to happen in the background because sending input will be blocked on processing. for _, dependency := range g.Deps { - c.pipeline.AddRevision(dependency.ImportPath, dependency.Rev) + pipeline.AddRevision(dependency.ImportPath, dependency.Rev) } - c.pipeline.Done() + pipeline.Done() }() - c.updateHandler.updater = nil + return nil case *govendorFlag != "": fmt.Println("Reading the list of Go packages from vendor.json file:", *govendorFlag) v, err := readGovendor(*govendorFlag) @@ -304,17 +199,18 @@ func main() { } go func() { // This needs to happen in the background because sending input will be blocked on processing. for _, dependency := range v.Package { - c.pipeline.AddRevision(dependency.Path, dependency.Revision) + pipeline.AddRevision(dependency.Path, dependency.Revision) } - c.pipeline.Done() + pipeline.Done() }() // TODO: Consider setting a better directory for govendor command than current working directory. // Perhaps the parent directory of vendor.json file? - if gu, err := updater.NewGovendor(""); err == nil { - c.updateHandler.updater = gu - } else { + gu, err := updater.NewGovendor("") + if err != nil { log.Println("govendor updater is not available:", err) + gu = nil } + return gu case *gitSubrepoFlag != "": if _, err := exec.LookPath("git"); err != nil { log.Fatalln(fmt.Errorf("git binary is required, but not available: %v", err)) @@ -322,43 +218,14 @@ func main() { fmt.Println("Using Go packages vendored using git-subrepo in the specified vendor directory.") go func() { // This needs to happen in the background because sending input will be blocked on processing. err := forEachGitSubrepo(*gitSubrepoFlag, func(s workspace.Subrepo) { - c.pipeline.AddSubrepo(s) + pipeline.AddSubrepo(s) }) if err != nil { log.Println("warning: there was problem iterating over subrepos:", err) } - c.pipeline.Done() + pipeline.Done() }() - c.updateHandler.updater = nil // An updater for this can easily be added by anyone who uses this style of vendoring. - } - - http.HandleFunc("/index.html", mainHandler) - http.Handle("/favicon.ico", http.NotFoundHandler()) - fileServer := httpgzip.FileServer(assets.Assets, httpgzip.FileServerOptions{ServeError: httpgzip.Detailed}) - http.Handle("/assets/", fileServer) - http.Handle("/assets/octicons/", http.StripPrefix("/assets", fileServer)) - http.Handle("/opened", websocket.Handler(openedHandler)) // Exit server when client tab is closed. - if c.updateHandler.updater != nil { - http.Handle("/-/update", c.updateHandler) - go c.updateHandler.Worker() - } - - // Start listening first. - listener, err := net.Listen("tcp", *httpFlag) - if err != nil { - log.Fatalf("failed to listen on %q: %v\n", *httpFlag, err) - } - - if production { - // Open a browser tab and navigate to the main page. - go open.Open("http://" + *httpFlag + "/index.html") - } - - fmt.Println("Go Package Store server is running at http://" + *httpFlag + "/index.html.") - - err = http.Serve(listener, nil) - if err != nil { - log.Fatalln(err) + return nil // An updater for this can easily be added by anyone who uses this style of vendoring. } } diff --git a/cmd/Go-Package-Store/update.go b/cmd/Go-Package-Store/update.go index 9e6cd86..f0fbf2d 100644 --- a/cmd/Go-Package-Store/update.go +++ b/cmd/Go-Package-Store/update.go @@ -6,81 +6,102 @@ import ( "net/http" "github.com/shurcooL/Go-Package-Store" + "github.com/shurcooL/Go-Package-Store/workspace" "github.com/shurcooL/httperror" ) -// updateHandler is a handler for update requests. -type updateHandler struct { - updateRequests chan updateRequest +func NewUpdateWorker(updater gps.Updater) updateWorker { + return updateWorker{ + updater: updater, + updateRequests: make(chan updateRequest), + } +} - // updater is set based on the source of Go packages. If nil, it means - // we don't have support to update Go packages from the current source. - // It's used to update repos in the backend, and if set to nil, to disable - // the frontend UI for updating packages. - updater gps.Updater +type updateWorker struct { + updater gps.Updater + updateRequests chan updateRequest } type updateRequest struct { - root string - responseChan chan error + Root string + ResponseChan chan error } -// ServeHTTP handles update requests. -func (u *updateHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { +// Handler for update endpoint. +func (u updateWorker) Handler(w http.ResponseWriter, req *http.Request) error { if req.Method != "POST" { - httperror.HandleMethod(w, httperror.Method{Allowed: []string{"POST"}}) - return + return httperror.Method{Allowed: []string{"POST"}} } - root := req.PostFormValue("repo_root") - - updateRequest := updateRequest{ - root: root, - responseChan: make(chan error), + ur := updateRequest{ + Root: req.PostFormValue("RepoRoot"), + ResponseChan: make(chan error), } - u.updateRequests <- updateRequest + u.updateRequests <- ur - err := <-updateRequest.responseChan + err := <-ur.ResponseChan // TODO: Display error in frontend. if err != nil { - log.Println(err) + log.Println("update error:", err) } + + return nil +} + +// Start performing sequential updates of Go packages. It does not update +// in parallel to avoid race conditions. +func (u updateWorker) Start() { + go u.run() } -// Worker is a sequential updater of Go packages. It does not update them in parallel -// to avoid race conditions or other problems. -func (u *updateHandler) Worker() { - for updateRequest := range u.updateRequests { +func (u updateWorker) run() { + for ur := range u.updateRequests { c.pipeline.GoPackageList.Lock() - repoPresentation, ok := c.pipeline.GoPackageList.List[updateRequest.root] + rp, ok := c.pipeline.GoPackageList.List[ur.Root] c.pipeline.GoPackageList.Unlock() if !ok { - updateRequest.responseChan <- fmt.Errorf("root %q not found", updateRequest.root) + ur.ResponseChan <- fmt.Errorf("root %q not found", ur.Root) continue } - if repoPresentation.Updated { - updateRequest.responseChan <- fmt.Errorf("root %q already updated", updateRequest.root) + if rp.UpdateState != workspace.Available { + ur.ResponseChan <- fmt.Errorf("root %q not available for update: %v", ur.Root, rp.UpdateState) continue } - err := u.updater.Update(repoPresentation.Repo) - if err == nil { - // Mark repo as updated. + // Mark repo as updating. + c.pipeline.GoPackageList.Lock() + c.pipeline.GoPackageList.List[ur.Root].UpdateState = workspace.Updating + c.pipeline.GoPackageList.Unlock() + + updateError := u.updater.Update(rp.Repo) + + if updateError == nil { + // Move down and mark repo as updated. c.pipeline.GoPackageList.Lock() - // Move it down the OrderedList towards all other updated. - { - var i, j int - for ; c.pipeline.GoPackageList.OrderedList[i].Repo.Root != updateRequest.root; i++ { // i is the current package about to be updated. - } - for j = len(c.pipeline.GoPackageList.OrderedList) - 1; c.pipeline.GoPackageList.OrderedList[j].Updated; j-- { // j is the last not-updated package. - } - c.pipeline.GoPackageList.OrderedList[i], c.pipeline.GoPackageList.OrderedList[j] = - c.pipeline.GoPackageList.OrderedList[j], c.pipeline.GoPackageList.OrderedList[i] - } - c.pipeline.GoPackageList.List[updateRequest.root].Updated = true + moveDown(c.pipeline.GoPackageList.OrderedList, ur.Root) + c.pipeline.GoPackageList.List[ur.Root].UpdateState = workspace.Updated c.pipeline.GoPackageList.Unlock() } - updateRequest.responseChan <- err + + ur.ResponseChan <- updateError fmt.Println("\nDone.") } } + +// TODO: Currently lots of logic (for manipulating repo presentations as they +// get updated, etc.) haphazardly present both in backend and frontend, +// need to think about that. Probably want to unify workspace.RepoPresentation +// and component.RepoPresentation types, maybe. Try it. +// Also probably want to try separating available updates from completed updates. +// That should simplify some logic, and will make it easier to maintain history +// of updates in the future. + +// moveDown moves root down the orderedList towards all other updated. +func moveDown(orderedList []*workspace.RepoPresentation, root string) { + var i int + for ; orderedList[i].Repo.Root != root; i++ { // i is the current package about to be updated. + } + for ; i+1 < len(orderedList) && orderedList[i+1].UpdateState != workspace.Updated; i++ { + orderedList[i], orderedList[i+1] = orderedList[i+1], orderedList[i] // Swap the two. + } +} diff --git a/cmd/Go-Package-Store/updates.go b/cmd/Go-Package-Store/updates.go new file mode 100644 index 0000000..14a5627 --- /dev/null +++ b/cmd/Go-Package-Store/updates.go @@ -0,0 +1,54 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + + gpscomponent "github.com/shurcooL/Go-Package-Store/component" + "github.com/shurcooL/httperror" +) + +func updatesHandler(w http.ResponseWriter, req *http.Request) error { + if req.Method != "GET" { + return httperror.Method{Allowed: []string{"GET"}} + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Content-Type-Options", "nosniff") + jw := json.NewEncoder(w) + jw.SetIndent("", "\t") + flusher, ok := w.(http.Flusher) + if !ok { + return fmt.Errorf("ResponseWriter %v is not a Flusher", w) + } + for rp := range c.pipeline.RepoPresentations() { + var cs []gpscomponent.Change + for _, c := range rp.Presentation.Changes { + cs = append(cs, gpscomponent.Change{ + Message: c.Message, + URL: c.URL, + Comments: gpscomponent.Comments{Count: c.Comments.Count, URL: c.Comments.URL}, + }) + } + repoPresentation := gpscomponent.RepoPresentation{ + RepoRoot: rp.Repo.Root, + ImportPathPattern: rp.Repo.ImportPathPattern(), + LocalRevision: rp.Repo.Local.Revision, + RemoteRevision: rp.Repo.Remote.Revision, + HomeURL: rp.Presentation.HomeURL, + ImageURL: rp.Presentation.ImageURL, + Changes: cs, + UpdateState: gpscomponent.UpdateState(rp.UpdateState), + UpdateSupported: c.updater != nil, + } + if err := rp.Presentation.Error; err != nil { + repoPresentation.Error = err.Error() + } + err := jw.Encode(repoPresentation) + if err != nil { + return fmt.Errorf("error encoding repoPresentation: %v", err) + } + flusher.Flush() + } + return nil +} diff --git a/component/header.go b/component/header.go new file mode 100644 index 0000000..732fadf --- /dev/null +++ b/component/header.go @@ -0,0 +1,169 @@ +package component + +import ( + "fmt" + + "github.com/shurcooL/htmlg" + "golang.org/x/net/html" + "golang.org/x/net/html/atom" +) + +type Header struct{} + +func (Header) Render() []*html.Node { + // TODO: Make this much nicer. + /* +
+ Updates +
+ */ + div := &html.Node{ + Type: html.ElementNode, Data: atom.Div.String(), + Attr: []html.Attribute{ + {Key: atom.Style.String(), Val: "width: 100%; text-align: center; background-color: hsl(209, 51%, 92%);"}, + }, + FirstChild: &html.Node{ + Type: html.ElementNode, Data: atom.Span.String(), + Attr: []html.Attribute{ + {Key: atom.Style.String(), Val: "background-color: hsl(209, 51%, 88%); padding: 15px; display: inline-block;"}, + }, + FirstChild: htmlg.Text("Updates"), + }, + } + return []*html.Node{div} +} + +// UpdatesHeader combines checkingForUpdates, noUpdates and updatesHeading +// into one high level component. +type UpdatesHeader struct { + RPs []*RepoPresentation + CheckingUpdates bool +} + +func (u UpdatesHeader) Render() []*html.Node { + var ns []*html.Node + // Show "Checking for updates..." while still checking. + if u.CheckingUpdates { + ns = append(ns, checkingForUpdates.Render()...) + } + available, updating, supported := u.status() + // Show "No Updates Available" if we're done checking and there are no remaining updates. + if !u.CheckingUpdates && available == 0 && !updating { + ns = append(ns, noUpdates.Render()...) + } + // Show number of updates available and Update All button. + ns = append(ns, updatesHeading{ + Available: available, + Updating: updating, + UpdateSupported: supported, // TODO: Fetch this value from backend once. + }.Render()...) + return ns +} + +// status returns available, updating, supported updates in u.RPs. +func (u UpdatesHeader) status() (available uint, updating bool, supported bool) { + for _, rp := range u.RPs { + switch rp.UpdateState { + case Available: + available++ + supported = rp.UpdateSupported + case Updating: + updating = true + } + } + return available, updating, supported +} + +// updatesHeading is a heading that displays number of updates available, +// whether updates are installing, and an Update All button. +type updatesHeading struct { + Available uint + Updating bool + + // TODO: Find a place for this. + UpdateSupported bool +} + +func (u updatesHeading) Render() []*html.Node { + if u.Available == 0 && !u.Updating { + return nil + } + h4 := &html.Node{ + Type: html.ElementNode, Data: atom.H4.String(), + Attr: []html.Attribute{ + {Key: atom.Style.String(), Val: "text-align: left;"}, + }, + } + if u.Updating { + h4.AppendChild(htmlg.Text("Updates Installing...")) + } else { + h4.AppendChild(htmlg.Text(fmt.Sprintf("%d Updates Available", u.Available))) + } + h4.AppendChild(&html.Node{ + Type: html.ElementNode, Data: atom.Span.String(), + Attr: []html.Attribute{ + {Key: atom.Style.String(), Val: "float: right;"}, + }, + FirstChild: u.updateAllButton(), + }) + return []*html.Node{h4} +} + +func (u updatesHeading) updateAllButton() *html.Node { + if !u.UpdateSupported { + return &html.Node{ + Type: html.ElementNode, Data: atom.Span.String(), + Attr: []html.Attribute{ + {Key: atom.Style.String(), Val: "color: gray; cursor: default;"}, + {Key: atom.Title.String(), Val: "Updating repos is not currently supported for this source of repos."}, + }, + FirstChild: htmlg.Text("Update All"), + } + } + switch { + case u.Available > 0: + return &html.Node{ + Type: html.ElementNode, Data: atom.A.String(), + Attr: []html.Attribute{ + {Key: atom.Href.String(), Val: "/api/update-all"}, // TODO: Should it be a separate endpoint or what? + {Key: atom.Onclick.String(), Val: "UpdateAll(event);"}, + }, + FirstChild: htmlg.Text("Update All"), + } + case u.Available == 0: + return &html.Node{ + Type: html.ElementNode, Data: atom.Span.String(), + Attr: []html.Attribute{ + {Key: atom.Style.String(), Val: "color: gray; cursor: default;"}, + }, + FirstChild: htmlg.Text("Update All"), + } + default: + panic("unreachable") + } +} + +// InstalledUpdates is a heading for installed updates. +var InstalledUpdates = heading{Heading: atom.H3, Text: "Installed Updates"} + +var checkingForUpdates = heading{Heading: atom.H2, Text: "Checking for updates..."} + +var noUpdates = heading{Heading: atom.H2, Text: "No Updates Available"} + +type heading struct { + Heading atom.Atom + Text string +} + +func (h heading) Render() []*html.Node { + // TODO: Make this much nicer. + // <{{.Heading}} style="text-align: center;">{{.Text}} + hn := &html.Node{ + Type: html.ElementNode, Data: h.Heading.String(), + Attr: []html.Attribute{ + {Key: atom.Style.String(), Val: "text-align: center;"}, + }, + FirstChild: htmlg.Text(h.Text), + } + return []*html.Node{hn} +} diff --git a/component/presentation.go b/component/presentation.go index 3f10e62..94ef588 100644 --- a/component/presentation.go +++ b/component/presentation.go @@ -21,12 +21,20 @@ type RepoPresentation struct { Changes []Change Error string - Updated bool + UpdateState UpdateState // TODO: Find a place for this. UpdateSupported bool } +type UpdateState uint8 + +const ( + Available UpdateState = iota + Updating + Updated +) + func (p RepoPresentation) Render() []*html.Node { // TODO: Make this much nicer. /* @@ -34,7 +42,7 @@ func (p RepoPresentation) Render() []*html.Node {
{{.importPathPattern()}} - {{if (not .Updated)}}{{.updateButton()}}{{end}} +
{{.updateState()}}
@@ -55,13 +63,13 @@ func (p RepoPresentation) Render() []*html.Node { FirstChild: p.importPathPattern(), }, ) - if !p.Updated { + if n := p.updateState(); n != nil { innerDiv1.AppendChild(&html.Node{ Type: html.ElementNode, Data: atom.Div.String(), Attr: []html.Attribute{ {Key: atom.Style.String(), Val: "float: right;"}, }, - FirstChild: p.updateButton(), + FirstChild: n, }) } innerDiv2 := htmlg.DivClass("list-entry-body", @@ -95,32 +103,6 @@ func (p RepoPresentation) Render() []*html.Node { return []*html.Node{div} } -func (p RepoPresentation) presentationChangesAndError() []*html.Node { - /* - {{render (presentationchanges .)}} - {{with .Presentation.Error}} -

Error: {{.}}

- {{end}} - */ - var ns []*html.Node - ns = append(ns, PresentationChanges{ - Changes: p.Changes, - LocalRevision: p.LocalRevision, - RemoteRevision: p.RemoteRevision, - }.Render()...) - if p.Error != "" { - n := &html.Node{ - Type: html.ElementNode, Data: atom.P.String(), - Attr: []html.Attribute{{Key: atom.Class.String(), Val: "presentation-error"}}, - } - n.AppendChild(htmlg.Strong("Error:")) - n.AppendChild(htmlg.Text(" ")) - n.AppendChild(htmlg.Text(p.Error)) - ns = append(ns, n) - } - return ns -} - // TODO: Turn this into a maybeLink, etc. func (p RepoPresentation) importPathPattern() *html.Node { /* @@ -149,37 +131,78 @@ func (p RepoPresentation) importPathPattern() *html.Node { return importPathPattern } -func (p RepoPresentation) updateButton() *html.Node { +func (p RepoPresentation) updateState() *html.Node { /* -
- {{if updateSupported}} - Update - {{else}} - Update - {{end}} -
+ {{if not updateSupported}} + Update + {{else if eq p.UpdateState Available}} + Update + {{else if eq p.UpdateState Updating}} + Updating... + {{else if eq p.UpdateState Updated}} + {{/* Nothing. * /}} + {{end}} */ - if p.UpdateSupported { + if !p.UpdateSupported { + return &html.Node{ + Type: html.ElementNode, Data: atom.Span.String(), + Attr: []html.Attribute{ + {Key: atom.Style.String(), Val: "color: gray; cursor: default;"}, + {Key: atom.Title.String(), Val: "Updating repos is not currently supported for this source of repos."}, + }, + FirstChild: htmlg.Text("Update"), + } + } + switch p.UpdateState { + case Available: return &html.Node{ Type: html.ElementNode, Data: atom.A.String(), Attr: []html.Attribute{ - {Key: atom.Href.String(), Val: "/-/update"}, + {Key: atom.Href.String(), Val: "/api/update"}, {Key: atom.Onclick.String(), Val: fmt.Sprintf("UpdateRepository(event, %q);", strconv.Quote(p.RepoRoot))}, - {Key: atom.Class.String(), Val: "update-button"}, - {Key: atom.Title.String(), Val: fmt.Sprintf("go get -u -d %s", p.ImportPathPattern)}, + //{Key: atom.Title.String(), Val: fmt.Sprintf("go get -u -d %s", p.ImportPathPattern)}, }, FirstChild: htmlg.Text("Update"), } - } else { + case Updating: return &html.Node{ Type: html.ElementNode, Data: atom.Span.String(), Attr: []html.Attribute{ {Key: atom.Style.String(), Val: "color: gray; cursor: default;"}, - {Key: atom.Title.String(), Val: "Updating repos is not currently supported for this source of repos."}, }, - FirstChild: htmlg.Text("Update"), + FirstChild: htmlg.Text("Updating..."), + } + case Updated: + return nil + default: + panic("unreachable") + } +} + +func (p RepoPresentation) presentationChangesAndError() []*html.Node { + /* + {{render (presentationchanges .)}} + {{with .Presentation.Error}} +

Error: {{.}}

+ {{end}} + */ + var ns []*html.Node + ns = append(ns, PresentationChanges{ + Changes: p.Changes, + LocalRevision: p.LocalRevision, + RemoteRevision: p.RemoteRevision, + }.Render()...) + if p.Error != "" { + n := &html.Node{ + Type: html.ElementNode, Data: atom.P.String(), + Attr: []html.Attribute{{Key: atom.Class.String(), Val: "presentation-error"}}, } + n.AppendChild(htmlg.Strong("Error:")) + n.AppendChild(htmlg.Text(" ")) + n.AppendChild(htmlg.Text(p.Error)) + ns = append(ns, n) } + return ns } type PresentationChanges struct { diff --git a/frontend/main.go b/frontend/main.go new file mode 100644 index 0000000..fe01aee --- /dev/null +++ b/frontend/main.go @@ -0,0 +1,251 @@ +// Command frontend runs on frontend of Go Package Store. +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "log" + "net/http" + "net/url" + "sync" + + "github.com/gopherjs/gopherjs/js" + gpscomponent "github.com/shurcooL/Go-Package-Store/component" + "github.com/shurcooL/go/gopherjs_http/jsutil" + "github.com/shurcooL/htmlg" + "honnef.co/go/js/dom" +) + +var document = dom.GetWindow().Document().(dom.HTMLDocument) + +func main() { + js.Global.Set("UpdateRepository", jsutil.Wrap(UpdateRepository)) + js.Global.Set("UpdateAll", jsutil.Wrap(UpdateAll)) + + switch readyState := document.ReadyState(); readyState { + case "loading": + document.AddEventListener("DOMContentLoaded", false, func(dom.Event) { + go run() + }) + case "interactive", "complete": + run() + default: + panic(fmt.Errorf("internal error: unexpected document.ReadyState value: %v", readyState)) + } +} + +func run() { + err := stream() + if err != nil { + log.Println(err) + } +} + +func stream() error { + // TODO: Initial render might not be needed if the server prerenders initial state. + err := renderBody() + if err != nil { + return err + } + + resp, err := http.Get("/api/updates") + if err != nil { + return err + } + defer resp.Body.Close() + dec := json.NewDecoder(resp.Body) + for { + var rp gpscomponent.RepoPresentation + err := dec.Decode(&rp) + if err == io.EOF { + break + } else if err != nil { + return err + } + + rpsMu.Lock() + rps = append(rps, &rp) + moveUp(rps, &rp) + rpsMu.Unlock() + + err = renderBody() + if err != nil { + return err + } + } + checkingUpdates = false + + err = renderBody() + return err +} + +var ( + rpsMu sync.Mutex // TODO: Move towards a channel-based unified state manipulator. + rps []*gpscomponent.RepoPresentation + + checkingUpdates = true +) + +func renderBody() error { + rpsMu.Lock() + defer rpsMu.Unlock() + + var buf bytes.Buffer + + err := htmlg.RenderComponents(&buf, gpscomponent.Header{}) + if err != nil { + return err + } + + _, err = io.WriteString(&buf, `
`) + if err != nil { + return err + } + + err = htmlg.RenderComponents(&buf, gpscomponent.UpdatesHeader{ + RPs: rps, + CheckingUpdates: checkingUpdates, + }) + if err != nil { + return err + } + + wroteInstalledUpdates := false + for _, rp := range rps { + if rp.UpdateState == gpscomponent.Updated && !wroteInstalledUpdates { + err = htmlg.RenderComponents(&buf, gpscomponent.InstalledUpdates) + if err != nil { + return err + } + wroteInstalledUpdates = true + } + + err := htmlg.RenderComponents(&buf, rp) + if err != nil { + return err + } + } + + _, err = io.WriteString(&buf, `
`) + if err != nil { + return err + } + + document.Body().SetInnerHTML(buf.String()) + return nil +} + +// UpdateAll marks all available updates as updating, and performs updates in background in sequence. +func UpdateAll(event dom.Event) { + event.PreventDefault() + if event.(*dom.MouseEvent).Button != 0 { + return + } + + var updates []string // Repo roots to update. + + rpsMu.Lock() + for _, rp := range rps { + if rp.UpdateState == gpscomponent.Available { + updates = append(updates, rp.RepoRoot) + rp.UpdateState = gpscomponent.Updating + } + } + rpsMu.Unlock() + + err := renderBody() + if err != nil { + log.Println(err) + return + } + + go func() { + for _, root := range updates { + update(root) + } + }() +} + +// UpdateRepository updates specified repository. +// root is the import path corresponding to the root of the repository. +func UpdateRepository(event dom.Event, root string) { + event.PreventDefault() + if event.(*dom.MouseEvent).Button != 0 { + return + } + + rpsMu.Lock() + for _, rp := range rps { + if rp.RepoRoot == root { + rp.UpdateState = gpscomponent.Updating + break + } + } + rpsMu.Unlock() + + err := renderBody() + if err != nil { + log.Println(err) + return + } + + go update(root) +} + +// update updates specified repository. +// root is the import path corresponding to the root of the repository. +func update(root string) { + resp, err := http.PostForm("/api/update", url.Values{"RepoRoot": {root}}) + if err != nil { + log.Println(err) + return + } + defer resp.Body.Close() + + // TODO: Check response for success or not, etc. + // This is a great chance to display update errors in frontend! + _, err = io.Copy(ioutil.Discard, resp.Body) + if err != nil { + log.Println(err) + return + } + + rpsMu.Lock() + moveDown(rps, root) + for _, rp := range rps { + if rp.RepoRoot == root { + rp.UpdateState = gpscomponent.Updated + break + } + } + rpsMu.Unlock() + + err = renderBody() + if err != nil { + log.Println(err) + return + } +} + +// moveDown moves root down the rps towards all other updated. +func moveDown(rps []*gpscomponent.RepoPresentation, root string) { + var i int + for ; rps[i].RepoRoot != root; i++ { // i is the current package about to be updated. + } + for ; i+1 < len(rps) && rps[i+1].UpdateState != gpscomponent.Updated; i++ { + rps[i], rps[i+1] = rps[i+1], rps[i] // Swap the two. + } +} + +// moveUp moves last entry up the rps above all other updated entries, unless rp is already updated. +func moveUp(rps []*gpscomponent.RepoPresentation, rp *gpscomponent.RepoPresentation) { + if rp.UpdateState == gpscomponent.Updated { + return + } + for i := len(rps) - 1; i-1 >= 0 && rps[i-1].UpdateState == gpscomponent.Updated; i-- { + rps[i], rps[i-1] = rps[i-1], rps[i] // Swap the two. + } +} diff --git a/updater/mock.go b/updater/mock.go index 98b626e..8cbb647 100644 --- a/updater/mock.go +++ b/updater/mock.go @@ -11,7 +11,7 @@ type Mock struct{} func (Mock) Update(repo *gps.Repo) error { fmt.Println("Mock: got update request:", repo.Root) - const mockDelay = time.Second + const mockDelay = 3 * time.Second fmt.Printf("pretending to update (actually sleeping for %v)", mockDelay) time.Sleep(mockDelay) return nil diff --git a/workspace/workspace.go b/workspace/workspace.go index e5796a6..fff1a1f 100644 --- a/workspace/workspace.go +++ b/workspace/workspace.go @@ -26,12 +26,18 @@ type RepoPresentation struct { Repo *gps.Repo Presentation *gps.Presentation - // TODO: Next up, use updateState with 3 states (notUpdated, updating, updated). - // Do that to track the intermediate state when a package is in the process - // of being updated. - Updated bool + UpdateState UpdateState } +// TODO: Dedup. +type UpdateState uint8 + +const ( + Available UpdateState = iota + Updating + Updated +) + // Pipeline for processing a Go workspace, where each repo has local and remote components. type Pipeline struct { wd string // Working directory. Used to resolve relative import paths. @@ -288,11 +294,13 @@ Outer: // Append repoPresentation to current list. p.GoPackageList.Lock() p.GoPackageList.OrderedList = append(p.GoPackageList.OrderedList, repoPresentation) + moveUp(p.GoPackageList.OrderedList, repoPresentation) p.GoPackageList.List[repoPresentation.Repo.Root] = repoPresentation p.GoPackageList.Unlock() // Send new repoPresentation to all existing observers. for ch := range p.observers { + // TODO: If an observer isn't listening, this will block. Should we defend against that here? ch <- repoPresentation } // New observer request. @@ -331,6 +339,16 @@ Outer: } } +// moveUp moves last entry up the orderedList above all other updated entries, unless rp is already updated. +func moveUp(orderedList []*RepoPresentation, rp *RepoPresentation) { + if rp.UpdateState == Updated { + return + } + for i := len(orderedList) - 1; i-1 >= 0 && orderedList[i-1].UpdateState == Updated; i-- { + orderedList[i], orderedList[i-1] = orderedList[i-1], orderedList[i] // Swap the two. + } +} + // importPathWorker sends unique repositories to phase 2. func (p *Pipeline) importPathWorker(wg *sync.WaitGroup) { defer wg.Done()