<script lang="ts">
import type { AppIconName } from "../layout/AppIcon.svelte";
import { contextMenu as placeMenu } from "../../utils/menu.js";
import { panel } from "../../stores/panel.svelte.js";
import { diagnostics } from "../../stores/diagnostics.svelte.js";
import { explorer } from "../../stores/explorer.svelte.js";
import { workbench } from "../../stores/workbench.svelte.js";
import { DiagnosticSeverity, type Diagnostic } from "../../api/types/workbench.js";
import AppIcon from "../layout/AppIcon.svelte";
import ActivityPanel from "./ActivityPanel.svelte";
import OutputPanel from "./OutputPanel.svelte";
import { ops } from "../../stores/ops.svelte.js";
let panelDropTarget: number | null = $state(null);
let panelDragSourceIndex: number | null = $state(null);
interface PanelDragData {
type: "panel-tab";
index: number;
}
function onPanelDragStart(e: DragEvent, index: number) {
if (!e.dataTransfer) return;
const data: PanelDragData = { type: "panel-tab", index };
e.dataTransfer.setData("application/x-wsb-panel", JSON.stringify(data));
e.dataTransfer.effectAllowed = "move";
panelDragSourceIndex = index;
}
function computePanelDropIndex(e: DragEvent): number {
const bar = (e.currentTarget as HTMLElement).closest(".panel-tabs") ?? e.currentTarget as HTMLElement;
const tabElements = bar.querySelectorAll<HTMLElement>(":scope > .panel-tab");
for (let i = 0; i < tabElements.length; i++) {
const rect = tabElements[i]!.getBoundingClientRect();
if (e.clientX < rect.left + rect.width / 2) return i;
}
return panel.sortedEntries.length;
}
function onPanelTabsDragOver(e: DragEvent) {
if (!e.dataTransfer?.types.includes("application/x-wsb-panel")) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
panelDropTarget = computePanelDropIndex(e);
}
function onPanelTabsDragLeave(e: DragEvent) {
const bar = e.currentTarget as HTMLElement;
if (!bar.contains(e.relatedTarget as Node)) {
panelDropTarget = null;
}
}
function onPanelTabsDrop(e: DragEvent) {
e.preventDefault();
const insertAt = panelDropTarget ?? panel.sortedEntries.length;
panelDropTarget = null;
panelDragSourceIndex = null;
const raw = e.dataTransfer?.getData("application/x-wsb-panel");
if (!raw) return;
const data: PanelDragData = JSON.parse(raw);
let toIndex = insertAt;
if (data.index < toIndex) toIndex--;
panel.reorder(data.index, toIndex);
}
function onPanelDragEnd() {
panelDropTarget = null;
panelDragSourceIndex = null;
}
let panelCtxMenu: { x: number; y: number; index: number; entryId: string; source: string } | null = $state(null);
function portal(node: HTMLElement) {
document.body.appendChild(node);
return { destroy() { node.remove(); } };
}
function handlePanelCtx(e: MouseEvent, index: number, entryId: string, source: string) {
e.preventDefault();
panelCtxMenu = { x: e.clientX, y: e.clientY, index, entryId, source };
}
function closePanelCtx() { panelCtxMenu = null; }
function panelMoveLeft() {
if (!panelCtxMenu || panelCtxMenu.index <= 0) return;
panel.reorder(panelCtxMenu.index, panelCtxMenu.index - 1);
closePanelCtx();
}
function panelMoveRight() {
if (!panelCtxMenu || panelCtxMenu.index >= panel.sortedEntries.length - 1) return;
panel.reorder(panelCtxMenu.index, panelCtxMenu.index + 1);
closePanelCtx();
}
function panelClose() {
if (!panelCtxMenu) return;
panel.removePanel(panelCtxMenu.entryId);
closePanelCtx();
}
$effect(() => {
if (!panelCtxMenu) return;
function handler() { panelCtxMenu = null; }
const id = setTimeout(() => {
document.addEventListener("click", handler, { once: true });
document.addEventListener("contextmenu", handler, { once: true });
}, 0);
return () => {
clearTimeout(id);
document.removeEventListener("click", handler);
document.removeEventListener("contextmenu", handler);
};
});
$effect(() => {
if (!panelCtxMenu) return;
function handler(e: KeyboardEvent) { if (e.key === "Escape") panelCtxMenu = null; }
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
});
function severityIcon(severity: number): AppIconName {
switch (severity) {
case DiagnosticSeverity.Error:
return "circle-x";
case DiagnosticSeverity.Warning:
return "triangle-alert";
case DiagnosticSeverity.Information:
return "info";
case DiagnosticSeverity.Hint:
return "lightbulb";
default:
return "circle";
}
}
function severityClass(severity: number): string {
switch (severity) {
case DiagnosticSeverity.Error:
return "severity-error";
case DiagnosticSeverity.Warning:
return "severity-warning";
case DiagnosticSeverity.Information:
return "severity-info";
case DiagnosticSeverity.Hint:
return "severity-hint";
default:
return "";
}
}
function worktreePathOf(uri: string): string | null {
const root = explorer.root;
if (!root) return null;
let path: string;
try {
path = decodeURIComponent(new URL(uri).pathname);
} catch {
return null;
}
const prefix = `${root.replace(/\/$/, "")}/`;
return path.startsWith(prefix) ? path.slice(prefix.length) : null;
}
function uriToFilename(uri: string): string {
try {
return new URL(uri).pathname.split("/").pop() ?? uri;
} catch {
return uri;
}
}
</script>
{#snippet problem(diag: Diagnostic)}
<AppIcon name={severityIcon(diag.severity)} size={12} />
<span class="problems-message">{diag.message}</span>
{#if diag.source}
<span class="problems-source">{diag.source}</span>
{/if}
<span class="problems-location">
[{diag.range.start_line + 1}:{diag.range.start_character + 1}]
</span>
{/snippet}
<div class="bottom-panel">
<div class="panel-tabbar">
<div class="panel-tabs" ondragover={onPanelTabsDragOver} ondrop={onPanelTabsDrop} ondragleave={onPanelTabsDragLeave}>
{#each panel.sortedEntries as entry, i (entry.id)}
{#if panelDropTarget === i}
<div class="panel-drop-indicator"></div>
{/if}
<button
class="panel-tab"
class:active={panel.activeEntryId === entry.id}
class:dragging={panelDragSourceIndex === i}
onclick={() => panel.activateEntry(entry.id)}
oncontextmenu={(e) => handlePanelCtx(e, i, entry.id, entry.source)}
draggable="true"
ondragstart={(e) => onPanelDragStart(e, i)}
ondragend={onPanelDragEnd}
>
{entry.title}
{#if entry.id === "problems" && (diagnostics.errorCount > 0 || diagnostics.warningCount > 0)}
<span class="tab-badge">
{#if diagnostics.errorCount > 0}
<span class="badge-count severity-error">{diagnostics.errorCount}</span>
{/if}
{#if diagnostics.warningCount > 0}
<span class="badge-count severity-warning">{diagnostics.warningCount}</span>
{/if}
</span>
{:else if entry.id === "activity" && ops.active.length > 0}
<span class="tab-badge">
<span class="badge-count severity-running">{ops.active.length}</span>
</span>
{/if}
</button>
{/each}
{#if panelDropTarget === panel.sortedEntries.length}
<div class="panel-drop-indicator"></div>
{/if}
</div>
<div class="panel-actions">
<button
class="panel-action-btn"
onclick={() => (workbench.panelOpen = false)}
title="Close panel"
aria-label="Close panel"
>
<AppIcon name="x" size={12} />
</button>
</div>
</div>
<div class="panel-content">
<div class="panel-slot" class:panel-slot-hidden={panel.activeEntryId !== "output"}>
<OutputPanel />
</div>
<div class="panel-slot" class:panel-slot-hidden={panel.activeEntryId !== "activity"}>
<ActivityPanel />
</div>
{#if panel.activeEntryId === "problems"}
<div class="panel-slot">
{#if diagnostics.entries.length === 0}
<div class="panel-placeholder">No problems detected.</div>
{:else}
<div class="problems-list">
{#each diagnostics.entries as entry (entry.uri)}
{@const path = worktreePathOf(entry.uri)}
<div class="problems-file">
<div class="problems-file-header">
<span class="problems-filename">{uriToFilename(entry.uri)}</span>
<span class="problems-file-count">{entry.diagnostics.length}</span>
</div>
{#each entry.diagnostics as diag, i (i)}
{#if path !== null}
<button
type="button"
class="problems-item problems-item-openable {severityClass(diag.severity)}"
title={path}
onclick={() =>
workbench.openFile(path, false, { line: diag.range.start_line + 1, column: diag.range.start_character + 1 })}
>
{@render problem(diag)}
</button>
{:else}
<div class="problems-item {severityClass(diag.severity)}" title={entry.uri}>
{@render problem(diag)}
</div>
{/if}
{/each}
</div>
{/each}
</div>
{/if}
</div>
{:else if panel.activeEntryId !== "output" && panel.activeEntryId !== "activity"}
<div class="panel-slot">
<div class="panel-placeholder">
{panel.activeEntry.title} — extension panel
</div>
</div>
{/if}
</div>
</div>
{#if panelCtxMenu}
<div
class="context-menu"
use:placeMenu={panelCtxMenu}
>
<button class="context-menu-item" onclick={panelMoveLeft} disabled={panelCtxMenu.index <= 0}>Move Left</button>
<button class="context-menu-item" onclick={panelMoveRight} disabled={panelCtxMenu.index >= panel.sortedEntries.length - 1}>Move Right</button>
{#if panelCtxMenu.source === "extension"}
<div class="context-menu-separator"></div>
<button class="context-menu-item danger" onclick={panelClose}>Close Panel</button>
{/if}
</div>
{/if}
<style>
.bottom-panel {
display: flex;
flex-direction: column;
height: 100%;
background: var(--vscode-tab-activeBackground);
border-top: 1px solid var(--vscode-panel-border);
}
.panel-tabbar {
display: flex;
align-items: stretch;
justify-content: space-between;
height: var(--wsb-row-height-compact);
flex-shrink: 0;
background: color-mix(in srgb, var(--vscode-sideBar-background) 94%, black 6%);
border-bottom: 1px solid var(--vscode-panel-border);
}
.panel-tabs {
display: flex;
align-items: stretch;
gap: 0;
}
.panel-tab {
padding: 0 var(--wb-spacing-16);
font-size: var(--wb-font-size-11);
font-weight: 500;
color: var(--vscode-disabledForeground);
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 2px solid transparent;
margin-bottom: var(--wb-spacing-neg-1);
display: flex;
align-items: center;
gap: var(--wb-spacing-6);
}
.panel-tab:hover {
color: var(--vscode-editor-foreground);
background: var(--vscode-list-hoverBackground);
}
.panel-tab.active {
color: var(--vscode-editor-foreground);
border-bottom-color: var(--vscode-focusBorder);
}
.panel-tab.dragging {
opacity: 0.4;
}
.tab-badge {
display: inline-flex;
gap: var(--wb-spacing-4);
align-items: center;
}
.badge-count {
font-size: var(--wb-font-size-10);
font-weight: 600;
padding: 0 var(--wb-spacing-4);
border-radius: var(--wb-corner-radius-8);
line-height: var(--wb-line-height-16);
}
.badge-count.severity-error {
background: color-mix(in srgb, var(--vscode-errorForeground) 20%, transparent);
color: var(--vscode-errorForeground);
}
.badge-count.severity-warning {
background: color-mix(in srgb, var(--vscode-editorWarning-foreground) 20%, transparent);
color: var(--vscode-editorWarning-foreground);
}
.badge-count.severity-running {
background: color-mix(in srgb, var(--vscode-testing-iconQueued, #ccb233) 20%, transparent);
color: var(--vscode-testing-iconQueued, #ccb233);
}
.panel-drop-indicator {
width: var(--wb-control-size-2);
flex-shrink: 0;
background: var(--vscode-focusBorder);
border-radius: var(--wb-corner-radius-1);
margin: var(--wb-spacing-4) 0;
}
.panel-actions {
display: flex;
align-items: center;
padding: 0 var(--wb-spacing-8);
gap: var(--wb-spacing-4);
}
.panel-action-btn {
width: var(--wb-control-size-22);
height: var(--wb-control-size-22);
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--wsb-radius-sm);
color: var(--vscode-disabledForeground);
}
.panel-action-btn:hover {
background: var(--vscode-list-hoverBackground);
color: var(--vscode-editor-foreground);
}
.panel-content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
.panel-slot {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.panel-slot-hidden {
display: none;
}
.panel-placeholder {
padding: var(--wb-spacing-16);
font-size: var(--wb-font-size-12);
color: var(--vscode-disabledForeground);
}
.problems-list {
font-size: var(--wb-font-size-12);
}
.problems-file {
border-bottom: 1px solid var(--vscode-panel-border);
}
.problems-file-header {
display: flex;
align-items: center;
gap: var(--wb-spacing-8);
padding: var(--wb-spacing-4) var(--wb-spacing-12);
font-weight: 600;
color: var(--vscode-editor-foreground);
background: color-mix(in srgb, var(--vscode-sideBar-background) 60%, transparent);
}
.problems-file-count {
font-size: var(--wb-font-size-10);
font-weight: 600;
color: var(--vscode-disabledForeground);
background: var(--vscode-list-hoverBackground);
padding: 0 var(--wb-spacing-5);
border-radius: var(--wb-corner-radius-8);
line-height: var(--wb-line-height-16);
}
.problems-item {
display: flex;
align-items: baseline;
gap: var(--wb-spacing-6);
padding: var(--wb-spacing-2) var(--wb-spacing-12) var(--wb-spacing-2) var(--wb-spacing-24);
color: var(--vscode-foreground);
}
.problems-item-openable {
cursor: pointer;
width: 100%;
border: none;
background: none;
color: inherit;
font: inherit;
text-align: left;
}
.problems-item:hover {
background: var(--vscode-list-hoverBackground);
}
.problems-item.severity-error {
color: var(--vscode-errorForeground);
}
.problems-item.severity-warning {
color: var(--vscode-editorWarning-foreground);
}
.problems-item.severity-info {
color: var(--vscode-focusBorder);
}
.problems-item.severity-hint {
color: var(--vscode-disabledForeground);
}
.problems-message {
flex: 1;
min-width: 0;
color: var(--vscode-editor-foreground);
}
.problems-source {
color: var(--vscode-disabledForeground);
font-style: italic;
flex-shrink: 0;
}
.problems-location {
color: var(--vscode-disabledForeground);
font-family: var(--wsb-font-mono);
font-size: var(--wb-font-size-11);
flex-shrink: 0;
}
</style>