✨ Mission Explorer UX: resizable sidebar, file icons, source/PR links, YAML formatting#4065
Conversation
Tree sidebar: - File-type icons: orange (YAML), green (MD), blue (JSON) - Hover tooltips on truncated filenames (title attribute) - Resizable sidebar with drag handle (180px–500px) - Source button (↗) opens file on GitHub in new tab - PR button (⑂) opens GitHub edit view to create a PR - Refresh button properly re-fetches instead of toggle Detail view: - step.yaml now renders as a separate formatted YAML block with proper whitespace-pre-wrap and copy button - step.command uses pre instead of code for newline preservation - Simpler kubectl command (not heredoc with embedded YAML) Signed-off-by: Andrew Anderson <andy@clubanderson.com>
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: The full list of commands accepted by this bot can be found here. DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
✅ Deploy Preview for kubestellarconsole ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
👋 Hey @clubanderson — thanks for opening this PR!
This is an automated message. |
|
Thank you for your contribution! Your PR has been merged. Check out what's new:
Stay connected: Slack #kubestellar-dev | Multi-Cluster Survey |
There was a problem hiding this comment.
Pull request overview
UX improvements to the Mission Explorer experience in the web console, focused on making mission/file browsing and step details easier to read and act on.
Changes:
- Improves step detail rendering by preserving command newlines and showing a dedicated YAML block with copy support.
- Enhances the mission tree with file-type icons, filename tooltips, and GitHub “view source” / “edit/create PR” links for GitHub-backed files.
- Adds a resizable left sidebar and updates refresh behavior to re-fetch tree contents.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| web/src/lib/missions/fileParser.ts | Simplifies generated kubectl command for wrapped Kubernetes resources. |
| web/src/components/missions/MissionDetailView.tsx | Renders step.command and step.yaml in <pre> blocks with copy buttons for better formatting. |
| web/src/components/missions/MissionBrowser.tsx | Adds resizable sidebar UI and updates tree refresh behavior. |
| web/src/components/missions/browser/TreeNodeItem.tsx | Adds file-type icons, title tooltips, and GitHub source/edit links for GitHub files. |
| onRefresh={(node.id === 'github' || node.id === 'local') ? (child) => { | ||
| // Mark node as unloaded to force re-fetch | ||
| // Reset node to unloaded state and collapse | ||
| setTreeNodes((prev) => | ||
| updateNodeInTree(prev, child.id, { | ||
| loaded: false, | ||
| loading: false, | ||
| children: [], | ||
| isEmpty: false, | ||
| }) | ||
| ) | ||
| // Collapse and re-expand to trigger load | ||
| setExpandedNodes((prev) => { | ||
| const next = new Set(prev) | ||
| next.delete(child.id) | ||
| return next | ||
| }) | ||
| // Re-expand after a tick to trigger the useEffect | ||
| // Re-expand with a fresh node reference so toggleNode sees loaded=false | ||
| setTimeout(() => { | ||
| toggleNode(child) | ||
| selectNode(child) | ||
| }, 50) | ||
| const freshNode: TreeNode = { ...child, loaded: false, loading: false, children: [] } | ||
| toggleNode(freshNode) | ||
| selectNode(freshNode) | ||
| }, 100) |
There was a problem hiding this comment.
The refresh handler schedules toggleNode(child) via setTimeout, but toggleNode reads expandedNodes from its closure. Because expandedNodes is updated just before the timeout, the toggleNode function captured here can be stale and may treat the node as still-expanded (collapsing it instead of reloading). Consider refactoring toggleNode to avoid relying on captured expandedNodes (e.g., use a functional setExpandedNodes to decide expand/collapse), or avoid the timeout + toggleNode indirection by explicitly triggering the reload after state updates (e.g., a dedicated reloadNode(nodeId) that fetches children regardless of prior expansion state).
| onMouseDown={(e) => { | ||
| e.preventDefault() | ||
| isDraggingRef.current = true | ||
| const startX = e.clientX | ||
| const startWidth = sidebarWidth | ||
| const onMouseMove = (moveEvent: MouseEvent) => { | ||
| if (!isDraggingRef.current) return | ||
| const delta = moveEvent.clientX - startX | ||
| const newWidth = Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, startWidth + delta)) | ||
| setSidebarWidth(newWidth) | ||
| } | ||
| const onMouseUp = () => { | ||
| isDraggingRef.current = false | ||
| document.removeEventListener('mousemove', onMouseMove) | ||
| document.removeEventListener('mouseup', onMouseUp) | ||
| } | ||
| document.addEventListener('mousemove', onMouseMove) | ||
| document.addEventListener('mouseup', onMouseUp) |
There was a problem hiding this comment.
The sidebar resize handle adds mousemove/mouseup listeners on document inside onMouseDown, but those listeners won't be removed if the component unmounts while dragging (e.g., dialog closes mid-drag), potentially leaving dangling listeners and calling setSidebarWidth on an unmounted component. Suggest storing handlers in refs and removing them in a useEffect cleanup (or using pointer events + setPointerCapture / AbortController) to guarantee cleanup on unmount/cancel.
| onMouseDown={(e) => { | |
| e.preventDefault() | |
| isDraggingRef.current = true | |
| const startX = e.clientX | |
| const startWidth = sidebarWidth | |
| const onMouseMove = (moveEvent: MouseEvent) => { | |
| if (!isDraggingRef.current) return | |
| const delta = moveEvent.clientX - startX | |
| const newWidth = Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, startWidth + delta)) | |
| setSidebarWidth(newWidth) | |
| } | |
| const onMouseUp = () => { | |
| isDraggingRef.current = false | |
| document.removeEventListener('mousemove', onMouseMove) | |
| document.removeEventListener('mouseup', onMouseUp) | |
| } | |
| document.addEventListener('mousemove', onMouseMove) | |
| document.addEventListener('mouseup', onMouseUp) | |
| onPointerDown={(e) => { | |
| e.preventDefault() | |
| isDraggingRef.current = true | |
| const startX = e.clientX | |
| const startWidth = sidebarWidth | |
| const handle = e.currentTarget | |
| const pointerId = e.pointerId | |
| if (handle.setPointerCapture) { | |
| handle.setPointerCapture(pointerId) | |
| } | |
| const onPointerMove = (moveEvent: PointerEvent) => { | |
| if (!isDraggingRef.current) return | |
| const delta = moveEvent.clientX - startX | |
| const newWidth = Math.min( | |
| MAX_SIDEBAR_WIDTH, | |
| Math.max(MIN_SIDEBAR_WIDTH, startWidth + delta), | |
| ) | |
| setSidebarWidth(newWidth) | |
| } | |
| const onPointerUp = (upEvent: PointerEvent) => { | |
| isDraggingRef.current = false | |
| if (handle.releasePointerCapture) { | |
| handle.releasePointerCapture(pointerId) | |
| } | |
| handle.removeEventListener('pointermove', onPointerMove) | |
| handle.removeEventListener('pointerup', onPointerUp) | |
| } | |
| handle.addEventListener('pointermove', onPointerMove) | |
| handle.addEventListener('pointerup', onPointerUp) |
| const sourceUrl = `https://github.com/${owner}/${repo}/blob/main/${filePath}` | ||
| const editUrl = `https://github.com/${owner}/${repo}/edit/main/${filePath}` |
There was a problem hiding this comment.
The generated GitHub URLs are hardcoded to the main branch (blob/main and edit/main). Repos can have a different default branch (e.g., master), which will make these links 404. Consider using a branch-agnostic ref like HEAD in the URL, or retrieving and storing the repo default branch when listing repos and using that here.
| const sourceUrl = `https://github.com/${owner}/${repo}/blob/main/${filePath}` | |
| const editUrl = `https://github.com/${owner}/${repo}/edit/main/${filePath}` | |
| const sourceUrl = `https://github.com/${owner}/${repo}/blob/HEAD/${filePath}` | |
| const editUrl = `https://github.com/${owner}/${repo}/edit/HEAD/${filePath}` |
| <a | ||
| href={sourceUrl} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| onClick={(e) => e.stopPropagation()} | ||
| className="p-1 min-h-7 min-w-7 rounded hover:bg-blue-500/20 text-muted-foreground hover:text-blue-400 transition-colors flex-shrink-0 flex items-center justify-center" | ||
| title="View source on GitHub" | ||
| > | ||
| <ExternalLink className="w-3 h-3" /> | ||
| </a> | ||
| <a | ||
| href={editUrl} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| onClick={(e) => e.stopPropagation()} | ||
| className="p-1 min-h-7 min-w-7 rounded hover:bg-green-500/20 text-muted-foreground hover:text-green-400 transition-colors flex-shrink-0 flex items-center justify-center" | ||
| title="Edit and create PR on GitHub" | ||
| > | ||
| <GitPullRequest className="w-3 h-3" /> | ||
| </a> |
There was a problem hiding this comment.
These GitHub action links are icon-only. They have title, but they should also include accessible names (e.g., aria-label) so screen readers can announce their purpose consistently with other icon-only controls in the codebase.
🔄 Auto-Applying Copilot Code ReviewCopilot code review found 2 code suggestion(s) and 2 general comment(s). @copilot Please apply all of the following code review suggestions:
Also address these general comments:
Push all fixes in a single commit. Run Auto-generated by copilot-review-apply workflow. |
Summary
UX improvements for the Mission Explorer file browsing experience:
Tree sidebar:
Detail view:
step.yamlnow renders as a separate formatted YAML block with proper indentationstep.commanduses<pre>for newline preservationkubectl apply -f <name>.yamlinstead of heredocTest plan