-
Notifications
You must be signed in to change notification settings - Fork 0
feat: redesign editor tabs with breadcrumb bar and header actions #65
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1800da6
6d7c91c
65f2df8
a149f89
58664f0
54a0a7e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| import { useMemo } from 'react' | ||
|
|
||
| export type LspStatus = 'healthy' | 'starting' | 'down' | 'none' | ||
|
|
||
| interface Props { | ||
| filePath: string | ||
| workspaceRoot?: string | null | ||
| // TODO: wire to real LSP status feed once LSP is implemented. | ||
| lspStatus?: LspStatus | ||
| } | ||
|
|
||
| function relativeSegments(filePath: string, root?: string | null): string[] { | ||
| if (!filePath) return [] | ||
| let rel = filePath | ||
| const normalizedRoot = root?.replace(/\/+$/, '') | ||
| if ( | ||
| normalizedRoot | ||
| && (filePath === normalizedRoot || filePath.startsWith(`${normalizedRoot}/`)) | ||
| ) { | ||
| rel = filePath.slice(normalizedRoot.length) | ||
| } | ||
| return rel.split('/').filter(Boolean) | ||
| } | ||
|
|
||
| const LSP_LABELS: Record<LspStatus, string> = { | ||
| healthy: 'LSP', | ||
| starting: 'LSP starting', | ||
| down: 'LSP down', | ||
| none: 'No LSP', | ||
| } | ||
|
|
||
| export function EditorBreadcrumbBar({ filePath, workspaceRoot, lspStatus = 'none' }: Props) { | ||
| const segments = useMemo( | ||
| () => relativeSegments(filePath, workspaceRoot ?? null), | ||
| [filePath, workspaceRoot], | ||
| ) | ||
|
|
||
| return ( | ||
| <div className="editor-breadcrumb"> | ||
| <div className="editor-breadcrumb__tools"> | ||
| {/* Stub: outline / symbol list */} | ||
| <button type="button" className="editor-breadcrumb__icon-btn" aria-label="Outline" title="Outline" disabled> | ||
| <svg width="14" height="14" viewBox="0 0 14 14"> | ||
| <circle cx="2" cy="3" r="1" fill="currentColor" /> | ||
| <circle cx="2" cy="7" r="1" fill="currentColor" /> | ||
| <circle cx="2" cy="11" r="1" fill="currentColor" /> | ||
| <path d="M5 3h7M5 7h7M5 11h7" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /> | ||
| </svg> | ||
| </button> | ||
| {/* Stub: in-file search */} | ||
| <button type="button" className="editor-breadcrumb__icon-btn" aria-label="Search in file" title="Search in file" disabled> | ||
| <svg width="14" height="14" viewBox="0 0 14 14"> | ||
| <circle cx="6" cy="6" r="3.5" stroke="currentColor" strokeWidth="1.2" fill="none" /> | ||
| <path d="M9 9l3 3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" /> | ||
| </svg> | ||
| </button> | ||
| {/* Stub: back / forward navigation */} | ||
| <button type="button" className="editor-breadcrumb__icon-btn" aria-label="Back" title="Back" disabled> | ||
| <svg width="14" height="14" viewBox="0 0 14 14"> | ||
| <path d="M9 2L4 7l5 5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" fill="none" /> | ||
| </svg> | ||
| </button> | ||
| <button type="button" className="editor-breadcrumb__icon-btn" aria-label="Forward" title="Forward" disabled> | ||
| <svg width="14" height="14" viewBox="0 0 14 14"> | ||
| <path d="M5 2l5 5-5 5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" fill="none" /> | ||
| </svg> | ||
| </button> | ||
| </div> | ||
|
|
||
| <nav className="editor-breadcrumb__path" aria-label="File path"> | ||
| {segments.map((seg, i) => { | ||
| const isLast = i === segments.length - 1 | ||
| return ( | ||
| <span key={`${i}-${seg}`} className="editor-breadcrumb__segment-wrap"> | ||
| <button | ||
| type="button" | ||
| className={ | ||
| 'editor-breadcrumb__segment' + | ||
| (isLast ? ' editor-breadcrumb__segment--last' : '') | ||
| } | ||
| disabled | ||
| > | ||
| {seg} | ||
| </button> | ||
| {!isLast && <span className="editor-breadcrumb__chevron">βΊ</span>} | ||
|
Cheezeiii365 marked this conversation as resolved.
|
||
| </span> | ||
| ) | ||
| })} | ||
| </nav> | ||
|
|
||
| <div className="editor-breadcrumb__right"> | ||
| {lspStatus !== 'none' && ( | ||
| <span | ||
| className={`editor-breadcrumb__lsp editor-breadcrumb__lsp--${lspStatus}`} | ||
| title={LSP_LABELS[lspStatus]} | ||
| > | ||
| <span className="editor-breadcrumb__lsp-dot" /> | ||
| {LSP_LABELS[lspStatus]} | ||
| </span> | ||
| )} | ||
| </div> | ||
| </div> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,20 +1,56 @@ | ||
| import { DockviewDefaultTab, type IDockviewPanelHeaderProps } from 'dockview-react' | ||
| import { isDocumentDirty } from '../../lib/editor/documentStore' | ||
| import { useEffect, useState } from 'react' | ||
| import type { IDockviewPanelHeaderProps } from 'dockview-react' | ||
| import { isDocumentDirty, onDocumentSessionChanged } from '../../lib/editor/documentStore' | ||
| import { FileTypeIcon } from '../FileTree/FileTypeIcon' | ||
|
|
||
| interface EditorTabParams { | ||
| filePath: string | ||
| workspaceId?: string | ||
| } | ||
|
|
||
| export function EditorTab(props: IDockviewPanelHeaderProps<EditorTabParams>) { | ||
| const handleClose = () => { | ||
| const filePath = props.params.filePath | ||
| const workspaceId = props.params.workspaceId | ||
| const { filePath, workspaceId } = props.params | ||
| const name = filePath?.split('/').pop() ?? filePath ?? 'untitled' | ||
|
|
||
| const [dirty, setDirty] = useState(() => isDocumentDirty(workspaceId, filePath)) | ||
|
|
||
| useEffect(() => { | ||
| setDirty(isDocumentDirty(workspaceId, filePath)) | ||
| return onDocumentSessionChanged((wk, path) => { | ||
| if (path === filePath && wk === (workspaceId ?? '')) { | ||
| setDirty(isDocumentDirty(workspaceId, filePath)) | ||
| } | ||
| }) | ||
| }, [filePath, workspaceId]) | ||
|
|
||
| const handleClose = (e: React.MouseEvent) => { | ||
| e.stopPropagation() | ||
| if (filePath && isDocumentDirty(workspaceId, filePath)) { | ||
| if (!window.confirm('Discard unsaved changes?')) return | ||
| } | ||
| props.api.close() | ||
| } | ||
|
|
||
| return <DockviewDefaultTab {...props} closeActionOverride={handleClose} /> | ||
| return ( | ||
| <div className="editor-tab" title={filePath}> | ||
| <span className="editor-tab__icon"> | ||
| <FileTypeIcon name={name} /> | ||
| </span> | ||
| <span className="editor-tab__name">{name}</span> | ||
| <span className={`editor-tab__trailing${dirty ? ' editor-tab__trailing--dirty' : ''}`}> | ||
| {dirty && <span className="editor-tab__dirty" aria-label="Unsaved changes" />} | ||
| <button | ||
| type="button" | ||
| className="editor-tab__close" | ||
| onMouseDown={(e) => e.stopPropagation()} | ||
| onClick={handleClose} | ||
| aria-label="Close tab" | ||
| > | ||
| <svg width="10" height="10" viewBox="0 0 10 10"> | ||
| <path d="M1 1l8 8M9 1l-8 8" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" /> | ||
| </svg> | ||
| </button> | ||
| </span> | ||
| </div> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| import type { IDockviewHeaderActionsProps } from 'dockview-react' | ||
| import { executeCommand } from '../../commands/CommandRegistry' | ||
|
|
||
| /** | ||
| * Right-side actions rendered in every Dockview group header. | ||
| * Visual stubs β wire to real commands as they land. | ||
| */ | ||
| export function EditorTabActions(_props: IDockviewHeaderActionsProps) { | ||
| return ( | ||
| <div className="editor-tab-actions"> | ||
| <button | ||
| type="button" | ||
| className="editor-tab-actions__btn" | ||
| title="New file" | ||
| aria-label="New file" | ||
| // TODO: wire to new-file command | ||
| > | ||
| <svg width="14" height="14" viewBox="0 0 14 14"> | ||
| <path d="M7 2v10M2 7h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> | ||
| </svg> | ||
| </button> | ||
| <button | ||
| type="button" | ||
| className="editor-tab-actions__btn" | ||
| title="Plugins" | ||
| aria-label="Plugins" | ||
| // TODO: wire to plugins panel | ||
| > | ||
| <svg width="14" height="14" viewBox="0 0 14 14"> | ||
| <path | ||
| d="M5 1.5h2a1 1 0 011 1V4h1.5a1 1 0 011 1v1.5H12a1 1 0 011 1v2a1 1 0 01-1 1h-1.5V12a1 1 0 01-1 1H8v-1.5a1.25 1.25 0 10-2 0V13H4.5a1 1 0 01-1-1v-1.5H2a1 1 0 01-1-1V8.5h1.5a1.25 1.25 0 100-2.5H1V5a1 1 0 011-1h2V2.5a1 1 0 011-1z" | ||
| stroke="currentColor" | ||
| strokeWidth="1.1" | ||
| fill="none" | ||
| strokeLinejoin="round" | ||
| /> | ||
| </svg> | ||
| </button> | ||
|
Comment on lines
+11
to
+38
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Disable the placeholder header actions until they are wired.
π€ Prompt for AI Agents |
||
| <button | ||
| type="button" | ||
| className="editor-tab-actions__btn" | ||
| title="Split editor" | ||
| aria-label="Split editor" | ||
| onClick={() => executeCommand('editor.splitVertical')} | ||
| > | ||
| <svg width="14" height="14" viewBox="0 0 14 14"> | ||
| <rect x="1.5" y="2" width="11" height="10" rx="1" stroke="currentColor" strokeWidth="1.1" fill="none" /> | ||
| <path d="M7 2v10" stroke="currentColor" strokeWidth="1.1" /> | ||
| </svg> | ||
| </button> | ||
| </div> | ||
| ) | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.