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()