gopls/internal/golang: factor the 3 web reports

This change factors the common elements of the
three reports:

- the common CSS and JS, previously constants,
  are now assets; only the ad hoc styles
  particular to each page remain in the HTML.
- The disconnect banner element is now created on load,
  in common.js, so no <div> HTML is required.
- objHTML, sourceLink are factored out.

Also:
- use the same font-families as pkg.go.dev.
- use addEventListener instead of clobbering window.onload.

Change-Id: Ic21cc46fc8d92a94b78aa1faf5b2f3012f539e57
Reviewed-on: https://go-review.googlesource.com/c/tools/+/591355
Auto-Submit: Alan Donovan <adonovan@google.com>
Reviewed-by: Robert Findley <rfindley@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/gopls/internal/golang/assembly.go b/gopls/internal/golang/assembly.go
index ca9f61c..dc5b589 100644
--- a/gopls/internal/golang/assembly.go
+++ b/gopls/internal/golang/assembly.go
@@ -50,7 +50,6 @@
 	escape := html.EscapeString
 
 	// Produce the report.
-	// TODO(adonovan): factor with RenderPkgDoc, FreeSymbolsHTML
 	title := fmt.Sprintf("%s assembly for %s",
 		escape(snapshot.View().GOARCH()),
 		escape(symbol))
@@ -59,31 +58,11 @@
 <html>
 <head>
   <meta charset="UTF-8">
-  <style>` + pkgDocStyle + `</style>
   <title>` + escape(title) + `</title>
-  <script type='text/javascript'>
-// httpGET requests a URL for its effects only.
-function httpGET(url) {
-	var xhttp = new XMLHttpRequest();
-	xhttp.open("GET", url, true);
-	xhttp.send();
-	return false; // disable usual <a href=...> behavior
-}
-
-// Start a GET /hang request. If it ever completes, the server
-// has disconnected. Show a banner in that case.
-{
-	var x = new XMLHttpRequest();
-	x.open("GET", "/hang", true);
-	x.onloadend = () => {
-		document.getElementById("disconnected").style.display = 'block';
-	};
-	x.send();
-};
-  </script>
+  <link rel="stylesheet" href="/assets/common.css">
+  <script src="/assets/common.js"></script>
 </head>
 <body>
-<div id='disconnected'>Gopls server has terminated. Page is inactive.</div>
 <h1>` + title + `</h1>
 <p>
   <a href='https://go.dev/doc/asm'>A Quick Guide to Go's Assembler</a>
@@ -101,18 +80,6 @@
 <pre>
 `)
 
-	// sourceLink returns HTML for a link to open a file in the client editor.
-	// TODO(adonovan): factor with two other copies.
-	sourceLink := func(text, url string) string {
-		// The /open URL returns nothing but has the side effect
-		// of causing the LSP client to open the requested file.
-		// So we use onclick to prevent the browser from navigating.
-		// We keep the href attribute as it causes the <a> to render
-		// as a link: blue, underlined, with URL hover information.
-		return fmt.Sprintf(`<a href="%[1]s" onclick='return httpGET("%[1]s")'>%[2]s</a>`,
-			escape(url), text)
-	}
-
 	// insnRx matches an assembly instruction line.
 	// Submatch groups are: (offset-hex-dec, file-line-column, instruction).
 	insnRx := regexp.MustCompile(`^(\s+0x[0-9a-f ]+)\(([^)]*)\)\s+(.*)$`)
@@ -145,7 +112,7 @@
 			if file, linenum, ok := cutLast(parts[2], ":"); ok && !strings.HasPrefix(file, "<") {
 				if linenum, err := strconv.Atoi(linenum); err == nil {
 					text := fmt.Sprintf("L%04d", linenum)
-					link = sourceLink(text, web.OpenURL(file, linenum, 1))
+					link = sourceLink(text, web.SrcURL(file, linenum, 1))
 				}
 			}
 			fmt.Fprintf(&buf, "%s\t%s\t%s", escape(parts[1]), link, escape(parts[3]))
diff --git a/gopls/internal/golang/freesymbols.go b/gopls/internal/golang/freesymbols.go
index 0bf0d9c..6a71c20 100644
--- a/gopls/internal/golang/freesymbols.go
+++ b/gopls/internal/golang/freesymbols.go
@@ -161,40 +161,11 @@
 .col-local { color: #0cb7c9 }
 li { font-family: monospace; }
 p { max-width: 6in; }
-#disconnected {
-  position: fixed;
-  top: 1em;
-  left: 1em;
-  display: none; /* initially */
-  background-color: white;
-  border: thick solid red;
-  padding: 2em;
-}
 </style>
-<!-- TODO(adonovan): factor with RenderPackageDoc -->
-  <script type='text/javascript'>
-// httpGET requests a URL for its effects only.
-function httpGET(url) {
-	var xhttp = new XMLHttpRequest();
-	xhttp.open("GET", url, true);
-	xhttp.send();
-	return false; // disable usual <a href=...> behavior
-}
-
-// Start a GET /hang request. If it ever completes, the server
-// has disconnected. Show a banner in that case.
-{
-	var x = new XMLHttpRequest();
-	x.open("GET", "/hang", true);
-	x.onloadend = () => {
-		document.getElementById("disconnected").style.display = 'block';
-	};
-	x.send();
-};
-  </script>
+  <script src="/assets/common.js"></script>
+  <link rel="stylesheet" href="/assets/common.css">
 </head>
 <body>
-<div id='disconnected'>Gopls server has terminated. Page is inactive.</div>
 <h1>Free symbols</h1>
 <p>
   The selected code contains references to these free* symbols:
@@ -219,28 +190,6 @@
 	}
 	buf.WriteString("</ul>\n")
 
-	// sourceLink returns HTML for a link to open a file in the client editor.
-	// TODO(adonovan): factor with RenderPackageDoc.
-	sourceLink := func(text, url string) string {
-		// The /open URL returns nothing but has the side effect
-		// of causing the LSP client to open the requested file.
-		// So we use onclick to prevent the browser from navigating.
-		// We keep the href attribute as it causes the <a> to render
-		// as a link: blue, underlined, with URL hover information.
-		return fmt.Sprintf(`<a href="%[1]s" onclick='return httpGET("%[1]s")'>%[2]s</a>`,
-			html.EscapeString(url), text)
-	}
-
-	// objHTML returns HTML for obj.Name(), possibly as a link.
-	// TODO(adonovan): factor with RenderPackageDoc.
-	objHTML := func(obj types.Object) string {
-		text := obj.Name()
-		if posn := safetoken.StartPosition(pkg.FileSet(), obj.Pos()); posn.IsValid() {
-			return sourceLink(text, web.OpenURL(posn.Filename, posn.Line, posn.Column))
-		}
-		return text
-	}
-
 	// -- package and local symbols --
 
 	showSymbols := func(scope, title string, symbols []Symbol) {
@@ -253,7 +202,7 @@
 				if i > 0 {
 					buf.WriteByte('.')
 				}
-				buf.WriteString(objHTML(obj))
+				buf.WriteString(objHTML(pkg.FileSet(), web, obj))
 			}
 			fmt.Fprintf(&buf, " %s</li>\n", html.EscapeString(sym.Type))
 		}
@@ -452,3 +401,26 @@
 	ast.Inspect(path[0], visit)
 	return free
 }
+
+// objHTML returns HTML for obj.Name(), possibly marked up as a link
+// to the web server that, when visited, opens the declaration in the
+// client editor.
+func objHTML(fset *token.FileSet, web Web, obj types.Object) string {
+	text := obj.Name()
+	if posn := safetoken.StartPosition(fset, obj.Pos()); posn.IsValid() {
+		url := web.SrcURL(posn.Filename, posn.Line, posn.Column)
+		return sourceLink(text, url)
+	}
+	return text
+}
+
+// sourceLink returns HTML for a link to open a file in the client editor.
+func sourceLink(text, url string) string {
+	// The /src URL returns nothing but has the side effect
+	// of causing the LSP client to open the requested file.
+	// So we use onclick to prevent the browser from navigating.
+	// We keep the href attribute as it causes the <a> to render
+	// as a link: blue, underlined, with URL hover information.
+	return fmt.Sprintf(`<a href="%[1]s" onclick='return httpGET("%[1]s")'>%[2]s</a>`,
+		html.EscapeString(url), text)
+}
diff --git a/gopls/internal/golang/pkgdoc.go b/gopls/internal/golang/pkgdoc.go
index 94e00be..93761d6 100644
--- a/gopls/internal/golang/pkgdoc.go
+++ b/gopls/internal/golang/pkgdoc.go
@@ -17,7 +17,6 @@
 // - list promoted methods---we have type information!
 // - gather Example tests, following go/doc and pkgsite.
 // - add option for doc.AllDecls: show non-exported symbols too.
-// - abbreviate long signatures by replacing parameters 4 onwards with "...".
 // - style the <li> bullets in the index as invisible.
 // - add push notifications such as didChange -> reload.
 // - there appears to be a maximum file size beyond which the
@@ -25,9 +24,9 @@
 // - modify JS httpGET function to give a transient visual indication
 //   when clicking a source link that the editor is being navigated
 //   (in case it doesn't raise itself, like VS Code).
-// - move this into a new package, golang/pkgdoc, and then
+// - move this into a new package, golang/web, and then
 //   split out the various helpers without fear of polluting
-//   the golang package namespace.
+//   the golang package namespace?
 // - show "Deprecated" chip when appropriate.
 
 import (
@@ -58,8 +57,8 @@
 	// PkgURL forms URLs of package or symbol documentation.
 	PkgURL(viewID string, path PackagePath, fragment string) protocol.URI
 
-	// OpenURL forms URLs that cause the editor to open a file at a specific position.
-	OpenURL(filename string, line, col8 int) protocol.URI
+	// SrcURL forms URLs that cause the editor to open a file at a specific position.
+	SrcURL(filename string, line, col8 int) protocol.URI
 }
 
 // PackageDocHTML formats the package documentation page.
@@ -199,39 +198,40 @@
 <html>
 <head>
   <meta charset="UTF-8">
-  <style>` + pkgDocStyle + `</style>
   <title>` + title + `</title>
-  <script type='text/javascript'>
-// httpGET requests a URL for its effects only.
-function httpGET(url) {
-	var xhttp = new XMLHttpRequest();
-	xhttp.open("GET", url, true);
-	xhttp.send();
-	return false; // disable usual <a href=...> behavior
+  <link rel="stylesheet" href="/assets/common.css">
+  <script src="/assets/common.js"></script>
+  <style>
+.lit { color: darkgreen; }
+
+header {
+  position: sticky;
+  top: 0;
+  left: 0;
+  width: 100%;
+  padding: 0.3em;
 }
 
-window.onload = () => {
+#pkgsite { height: 1.5em; }
+
+#hdr-Selector {
+  margin-right: 0.3em;
+  float: right;
+  min-width: 25em;
+  padding: 0.3em;
+}
+  </style>
+  <script type='text/javascript'>
+window.addEventListener('load', function() {
 	// Hook up the navigation selector.
 	document.getElementById('hdr-Selector').onchange = (e) => {
 		window.location.href = e.target.value;
 	};
-};
-
-// Start a GET /hang request. If it ever completes, the server
-// has disconnected. Show a banner in that case.
-{
-	var x = new XMLHttpRequest();
-	x.open("GET", "/hang", true);
-	x.onloadend = () => {
-		document.getElementById("disconnected").style.display = 'block';
-	};
-	x.send();
-};
+});
   </script>
 </head>
 <body>
 <header>
-<div id='disconnected'>Gopls server has terminated. Page is inactive.</div>
 <select id='hdr-Selector'>
 <optgroup label="Documentation">
   <option label="Overview" value="#hdr-Overview"/>
@@ -316,26 +316,6 @@
 
 	// -- main element --
 
-	// sourceLink returns HTML for a link to open a file in the client editor.
-	sourceLink := func(text, url string) string {
-		// The /open URL returns nothing but has the side effect
-		// of causing the LSP client to open the requested file.
-		// So we use onclick to prevent the browser from navigating.
-		// We keep the href attribute as it causes the <a> to render
-		// as a link: blue, underlined, with URL hover information.
-		return fmt.Sprintf(`<a href="%[1]s" onclick='return httpGET("%[1]s")'>%[2]s</a>`,
-			escape(url), escape(text))
-	}
-
-	// objHTML returns HTML for obj.Name(), possibly as a link.
-	objHTML := func(obj types.Object) string {
-		text := obj.Name()
-		if posn := safetoken.StartPosition(pkg.FileSet(), obj.Pos()); posn.IsValid() {
-			return sourceLink(text, web.OpenURL(posn.Filename, posn.Line, posn.Column))
-		}
-		return text
-	}
-
 	// nodeHTML returns HTML markup for a syntax tree.
 	// It replaces referring identifiers with links,
 	// and adds style spans for strings and comments.
@@ -613,7 +593,7 @@
 		for _, docfn := range funcs {
 			obj := scope.Lookup(docfn.Name).(*types.Func)
 			fmt.Fprintf(&buf, "<h3 id='%s'>func %s</h3>\n",
-				docfn.Name, objHTML(obj))
+				docfn.Name, objHTML(pkg.FileSet(), web, obj))
 
 			// decl: func F(params) results
 			fmt.Fprintf(&buf, "<pre class='code'>%s</pre>\n",
@@ -631,7 +611,8 @@
 		tname := scope.Lookup(doctype.Name).(*types.TypeName)
 
 		// title and source link
-		fmt.Fprintf(&buf, "<h3 id='%s'>type %s</a></h3>\n", doctype.Name, objHTML(tname))
+		fmt.Fprintf(&buf, "<h3 id='%s'>type %s</a></h3>\n",
+			doctype.Name, objHTML(pkg.FileSet(), web, tname))
 
 		// declaration
 		// TODO(adonovan): excise non-exported struct fields somehow.
@@ -652,7 +633,7 @@
 			method, _, _ := types.LookupFieldOrMethod(tname.Type(), true, tname.Pkg(), docmethod.Name)
 			fmt.Fprintf(&buf, "<h4 id='%s.%s'>func (%s) %s</h4>\n",
 				doctype.Name, docmethod.Name,
-				doctype.Name, objHTML(method))
+				doctype.Name, objHTML(pkg.FileSet(), web, method))
 
 			// decl: func (x T) M(params) results
 			fmt.Fprintf(&buf, "<pre class='code'>%s</pre>\n",
@@ -668,7 +649,7 @@
 	fmt.Fprintf(&buf, "<h2 id='hdr-SourceFiles'>Source files</h2>\n")
 	for _, filename := range docpkg.Filenames {
 		fmt.Fprintf(&buf, "<div class='comment'>%s</div>\n",
-			sourceLink(filepath.Base(filename), web.OpenURL(filename, 1, 1)))
+			sourceLink(filepath.Base(filename), web.SrcURL(filename, 1, 1)))
 	}
 
 	fmt.Fprintf(&buf, "</main>\n")
@@ -693,135 +674,3 @@
 	}
 	return slice
 }
-
-// (partly taken from pkgsite's typography.css)
-const pkgDocStyle = `
-body {
-  font-family: Helvetica, Arial, sans-serif;
-  font-size: 1rem;
-  line-height: normal;
-}
-
-h1 {
-  font-size: 1.5rem;
-}
-
-h2 {
-  font-size: 1.375rem;
-}
-
-h3 {
-  font-size: 1.25rem;
-}
-
-h4 {
-  font-size: 1.125rem;
-}
-
-h5 {
-  font-size: 1rem;
-}
-
-h6 {
-  font-size: 0.875rem;
-}
-
-h1,
-h2,
-h3,
-h4 {
-  font-weight: 600;
-  line-height: 1.25em;
-  word-break: break-word;
-}
-
-h5,
-h6 {
-  font-weight: 500;
-  line-height: 1.3em;
-  word-break: break-word;
-}
-
-p {
-  font-size: 1rem;
-  line-height: 1.5rem;
-  max-width: 60rem;
-}
-
-strong {
-  font-weight: 600;
-}
-
-code,
-pre,
-textarea.code {
-  font-family: Consolas, 'Liberation Mono', Menlo, monospace;
-  font-size: 0.875rem;
-  line-height: 1.5em;
-}
-
-pre,
-textarea.code {
-  background-color: #eee;
-  border: 3px;
-  border-radius: 3px
-  color: black;
-  overflow-x: auto;
-  padding: 0.625rem;
-  tab-size: 4;
-  white-space: pre;
-}
-
-button,
-input,
-select,
-textarea {
-  font: inherit;
-}
-
-a,
-a:link,
-a:visited {
-  color: rgb(0, 125, 156);
-  text-decoration: none;
-}
-
-a:hover,
-a:focus {
-  color: rgb(0, 125, 156);
-  text-decoration: underline;
-}
-
-a:hover > * {
-  text-decoration: underline;
-}
-
-.lit { color: darkgreen; }
-
-#pkgsite { height: 1.5em; }
-
-header {
-  position: sticky;
-  top: 0;
-  left: 0;
-  width: 100%;
-  padding: 0.3em;
-}
-
-#hdr-Selector {
-  margin-right: 0.3em;
-  float: right;
-  min-width: 25em;
-  padding: 0.3em;
-}
-
-#disconnected {
-  position: fixed;
-  top: 1em;
-  left: 1em;
-  display: none; /* initially */
-  background-color: white;
-  border: thick solid red;
-  padding: 2em;
-}
-`
diff --git a/gopls/internal/server/assets/common.css b/gopls/internal/server/assets/common.css
new file mode 100644
index 0000000..16baa4f
--- /dev/null
+++ b/gopls/internal/server/assets/common.css
@@ -0,0 +1,116 @@
+/* Copyright 2024 The Go Authors. All rights reserved.
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file.
+ */
+
+/* inspired by pkg.go.dev's typography.css */
+
+body {
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
+  font-size: 1rem;
+  line-height: normal;
+}
+
+h1 {
+  font-size: 1.5rem;
+}
+
+h2 {
+  font-size: 1.375rem;
+}
+
+h3 {
+  font-size: 1.25rem;
+}
+
+h4 {
+  font-size: 1.125rem;
+}
+
+h5 {
+  font-size: 1rem;
+}
+
+h6 {
+  font-size: 0.875rem;
+}
+
+h1,
+h2,
+h3,
+h4 {
+  font-weight: 600;
+  line-height: 1.25em;
+  word-break: break-word;
+}
+
+h5,
+h6 {
+  font-weight: 500;
+  line-height: 1.3em;
+  word-break: break-word;
+}
+
+p {
+  font-size: 1rem;
+  line-height: 1.5rem;
+  max-width: 60rem;
+}
+
+strong {
+  font-weight: 600;
+}
+
+code,
+pre,
+textarea.code {
+  font-family: Consolas, 'Liberation Mono', Menlo, monospace;
+  font-size: 0.875rem;
+  line-height: 1.5em;
+}
+
+pre,
+textarea.code {
+  background-color: #eee;
+  border: 3px;
+  border-radius: 3px
+  color: black;
+  overflow-x: auto;
+  padding: 0.625rem;
+  tab-size: 4;
+  white-space: pre;
+}
+
+button,
+input,
+select,
+textarea {
+  font: inherit;
+}
+
+a,
+a:link,
+a:visited {
+  color: rgb(0, 125, 156);
+  text-decoration: none;
+}
+
+a:hover,
+a:focus {
+  color: rgb(0, 125, 156);
+  text-decoration: underline;
+}
+
+a:hover > * {
+  text-decoration: underline;
+}
+
+#disconnected {
+  position: fixed;
+  top: 1em;
+  left: 1em;
+  display: none; /* initially */
+  background-color: white;
+  border: thick solid red;
+  padding: 2em;
+}
diff --git a/gopls/internal/server/assets/common.js b/gopls/internal/server/assets/common.js
new file mode 100644
index 0000000..1233456
--- /dev/null
+++ b/gopls/internal/server/assets/common.js
@@ -0,0 +1,28 @@
+// Copyright 2024 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// httpGET requests a URL for its effects only.
+// (It is needed for /open URLs; see objHTML.)
+function httpGET(url) {
+	var x = new XMLHttpRequest();
+	x.open("GET", url, true);
+	x.send();
+	return false; // disable usual <a href=...> behavior
+}
+
+// disconnect banner
+window.addEventListener('load', function() {
+	// Create a hidden <div id='disconnected'> element.
+	var banner = document.createElement("div");
+	banner.id = "disconnected";
+	banner.innerText = "Gopls server has terminated. Page is inactive.";
+	document.body.appendChild(banner);
+
+	// Start a GET /hang request. If it ever completes, the server
+	// has disconnected. Reveal the banner in that case.
+	var x = new XMLHttpRequest();
+	x.open("GET", "/hang", true);
+	x.onloadend = () => { banner.style.display = "block"; };
+	x.send();
+});
diff --git a/gopls/internal/server/server.go b/gopls/internal/server/server.go
index a414942..80e64bb 100644
--- a/gopls/internal/server/server.go
+++ b/gopls/internal/server/server.go
@@ -286,9 +286,9 @@
 		mux:    webMux,
 	}
 
-	// The /open handler allows the browser to request that the
-	// LSP client editor open a file; see web.urlToOpen.
-	webMux.HandleFunc("/open", func(w http.ResponseWriter, req *http.Request) {
+	// The /src handler allows the browser to request that the
+	// LSP client editor open a file; see web.SrcURL.
+	webMux.HandleFunc("/src", func(w http.ResponseWriter, req *http.Request) {
 		if err := req.ParseForm(); err != nil {
 			http.Error(w, err.Error(), http.StatusBadRequest)
 			return
@@ -463,16 +463,16 @@
 //go:embed assets/*
 var assets embed.FS
 
-// OpenURL returns an /open URL that, when visited, causes the client
+// SrcURL returns a /src URL that, when visited, causes the client
 // editor to open the specified file/line/column (in 1-based UTF-8
 // coordinates).
 //
 // (Rendering may generate hundreds of positions across files of many
 // packages, so don't convert to LSP coordinates yet: wait until the
 // URL is opened.)
-func (w *web) OpenURL(filename string, line, col8 int) protocol.URI {
+func (w *web) SrcURL(filename string, line, col8 int) protocol.URI {
 	return w.url(
-		"open",
+		"src",
 		fmt.Sprintf("file=%s&line=%d&col=%d", url.QueryEscape(filename), line, col8),
 		"")
 }
diff --git a/gopls/internal/test/integration/misc/webserver_test.go b/gopls/internal/test/integration/misc/webserver_test.go
index ae89745..0a692ec 100644
--- a/gopls/internal/test/integration/misc/webserver_test.go
+++ b/gopls/internal/test/integration/misc/webserver_test.go
@@ -61,14 +61,14 @@
 		// (We don't have a DOM or JS interpreter so we have
 		// to know something of the document internals here.)
 		rx := regexp.MustCompile(`<h3 id='NewFunc'.*httpGET\("(.*)"\)`)
-		openURL := html.UnescapeString(string(rx.FindSubmatch(doc2)[1]))
+		srcURL := html.UnescapeString(string(rx.FindSubmatch(doc2)[1]))
 
 		// Fetch the document. Its result isn't important,
 		// but it must have the side effect of another showDocument
 		// downcall, this time for a "file:" URL, causing the
 		// client editor to navigate to the source file.
-		t.Log("extracted /open URL", openURL)
-		get(t, openURL)
+		t.Log("extracted /src URL", srcURL)
+		get(t, srcURL)
 
 		// Check that that shown location is that of NewFunc.
 		shownSource := shownDocument(t, env, "file:")