400 lines
28 KiB
Vue
400 lines
28 KiB
Vue
<!-- SPEC: PJ2026-010102 HWPOD 工具;PJ2026-010405 云端控制台。 -->
|
||
<script setup lang="ts">
|
||
import { BaseTree } from "@he-tree/vue";
|
||
import "@he-tree/vue/style/default.css";
|
||
import { ChevronLeft, CircleCheck, CircleX, Code2, Download, File, Folder, Hammer, LayoutPanelTop, LoaderCircle, Plug, RefreshCw, Send, Square, Terminal } from "lucide-vue-next";
|
||
import { computed, nextTick, onMounted, ref } from "vue";
|
||
import ConfirmDialog from "@/components/common/ConfirmDialog.vue";
|
||
import ContentViewer from "@/components/common/ContentViewer.vue";
|
||
import LoadingState from "@/components/common/LoadingState.vue";
|
||
import { useHwpodStore } from "@/stores/hwpod";
|
||
import type { HwpodOperationResponse, HwpodWorkspaceEntry } from "@/types";
|
||
import { presentBuildOperation, workspacePreviewMode } from "./hwpod-workspace-presentation";
|
||
|
||
type WorkspaceNode = HwpodWorkspaceEntry & { children?: WorkspaceNode[]; loaded?: boolean };
|
||
|
||
const props = defineProps<{ hwpodId: string; initialPath?: string }>();
|
||
const emit = defineEmits<{ back: []; navigate: [path: string] }>();
|
||
const hwpod = useHwpodStore();
|
||
const tree = ref<WorkspaceNode[]>([]);
|
||
const selectedPath = ref(props.initialPath || ".");
|
||
const preview = ref("");
|
||
const previewMeta = ref<{ sizeBytes: number; truncated: boolean } | null>(null);
|
||
const busy = ref<string | null>(null);
|
||
const rootLoading = ref(true);
|
||
const previewLoading = ref(false);
|
||
const loadingDirectoryPath = ref<string | null>(null);
|
||
const buildResultMode = ref<"summary" | "raw">("summary");
|
||
const localError = ref<string | null>(null);
|
||
const downloadConfirmOpen = ref(false);
|
||
const uartPort = ref("uart1");
|
||
const uartBaudRate = ref(115200);
|
||
const uartData = ref("");
|
||
const uartOutput = ref("");
|
||
const buildOperation = ref<HwpodOperationResponse | null>(null);
|
||
const previousBuildOperationId = ref<string | null>(null);
|
||
let previewRequestId = 0;
|
||
const terminalStatus = computed(() => hwpod.lastOperation?.status ?? "idle");
|
||
const previewMode = computed(() => workspacePreviewMode(selectedPath.value));
|
||
const activeBuildOperation = computed(() => {
|
||
if (busy.value !== "debug.build") return buildOperation.value;
|
||
const current = hwpod.lastOperation;
|
||
return current?.operationId && current.operationId !== previousBuildOperationId.value ? current : buildOperation.value;
|
||
});
|
||
const buildView = computed(() => presentBuildOperation(activeBuildOperation.value, busy.value === "debug.build"));
|
||
const device = computed(() => hwpod.deviceTopology?.items.find((item) => item.hwpodId === props.hwpodId) ?? null);
|
||
|
||
onMounted(async () => {
|
||
try {
|
||
await loadRoot();
|
||
if (props.initialPath && props.initialPath !== ".") await openInitialPath(props.initialPath);
|
||
} catch { /* Local loading surfaces own the visible error. */ }
|
||
});
|
||
|
||
async function loadRoot(): Promise<void> {
|
||
rootLoading.value = true;
|
||
try {
|
||
const output = await operationOutput("workspace.ls", { path: ".", maxEntries: 1000 }, false);
|
||
tree.value = workspaceEntries(output);
|
||
} finally {
|
||
rootLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function selectNode(stat: { data: WorkspaceNode; open?: boolean }): Promise<void> {
|
||
const node = stat.data;
|
||
selectedPath.value = node.path;
|
||
emit("navigate", node.path);
|
||
if (node.type === "dir") {
|
||
if (!node.loaded) {
|
||
loadingDirectoryPath.value = node.path;
|
||
try {
|
||
const output = await operationOutput("workspace.ls", { path: node.path, maxEntries: 1000 }, false);
|
||
node.children = workspaceEntries(output);
|
||
node.loaded = true;
|
||
tree.value = [...tree.value];
|
||
await nextTick();
|
||
} finally {
|
||
loadingDirectoryPath.value = null;
|
||
}
|
||
}
|
||
stat.open = true;
|
||
preview.value = "";
|
||
previewMeta.value = null;
|
||
return;
|
||
}
|
||
if (node.type !== "file") return;
|
||
const requestId = ++previewRequestId;
|
||
previewLoading.value = true;
|
||
try {
|
||
const output = await operationOutput("workspace.cat", { path: node.path, maxBytes: 262144 }, false);
|
||
if (requestId !== previewRequestId) return;
|
||
if (output.previewable === false) throw new Error(blockerSummary(output) || "该文件不支持文本预览");
|
||
preview.value = typeof output.content === "string" ? output.content : "";
|
||
previewMeta.value = { sizeBytes: numberValue(output.sizeBytes), truncated: output.truncated === true };
|
||
} finally {
|
||
if (requestId === previewRequestId) previewLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function openInitialPath(path: string): Promise<void> {
|
||
const segments = path.replace(/\\/gu, "/").split("/").filter(Boolean);
|
||
let nodes = tree.value;
|
||
let current = "";
|
||
for (let index = 0; index < segments.length; index += 1) {
|
||
current = current ? `${current}/${segments[index]}` : segments[index];
|
||
const node = nodes.find((entry) => entry.path === current);
|
||
if (!node) return;
|
||
if (index === segments.length - 1) {
|
||
await selectNode({ data: node });
|
||
return;
|
||
}
|
||
if (node.type !== "dir") return;
|
||
if (!node.loaded) {
|
||
const output = await operationOutput("workspace.ls", { path: node.path, maxEntries: 1000 }, false);
|
||
node.children = workspaceEntries(output);
|
||
node.loaded = true;
|
||
}
|
||
nodes = node.children ?? [];
|
||
}
|
||
tree.value = [...tree.value];
|
||
}
|
||
|
||
async function runAction(intent: string, args: Record<string, unknown> = {}): Promise<void> {
|
||
try { await operationOutput(intent, args); }
|
||
catch { /* The bounded operation panel owns the visible error. */ }
|
||
}
|
||
|
||
async function runBuild(): Promise<void> {
|
||
previousBuildOperationId.value = hwpod.lastOperation?.operationId ?? null;
|
||
buildOperation.value = null;
|
||
await runAction("debug.build", { wait: true });
|
||
}
|
||
|
||
async function confirmDownload(): Promise<void> {
|
||
downloadConfirmOpen.value = false;
|
||
await runAction("debug.download");
|
||
}
|
||
|
||
async function readUart(): Promise<void> {
|
||
const output = await operationOutput("io.uart.read", uartArgs());
|
||
uartOutput.value = typeof output.stdout === "string" ? output.stdout : JSON.stringify(output.response ?? output, null, 2);
|
||
}
|
||
|
||
async function writeUart(): Promise<void> {
|
||
const data = uartData.value;
|
||
if (!data) return;
|
||
await operationOutput("io.uart.write", { ...uartArgs(), data });
|
||
uartData.value = "";
|
||
}
|
||
|
||
function uartArgs(): Record<string, unknown> {
|
||
return { port: uartPort.value, baudRate: uartBaudRate.value };
|
||
}
|
||
|
||
async function operationOutput(intent: string, args: Record<string, unknown>, trackBusy = true): Promise<Record<string, unknown>> {
|
||
if (trackBusy) busy.value = intent;
|
||
localError.value = null;
|
||
try {
|
||
const operation = await hwpod.executeOperation(props.hwpodId, intent, args, 900_000);
|
||
if (intent === "debug.build") buildOperation.value = operation;
|
||
const activity = record(operation.result);
|
||
if (activity.ok === false) throw new Error(errorSummary(activity) || `${intent} 执行失败`);
|
||
const response = record(activity.response);
|
||
if (response.ok === false) throw new Error(errorSummary(response) || `${intent} 执行失败`);
|
||
const result = Array.isArray(response.results) ? record(response.results[0]) : {};
|
||
if (result.ok === false) throw new Error(errorSummary(result) || `${intent} 执行失败`);
|
||
return record(result.output);
|
||
} catch (error) {
|
||
localError.value = error instanceof Error ? error.message : String(error);
|
||
throw error;
|
||
} finally {
|
||
if (trackBusy && busy.value === intent) busy.value = null;
|
||
}
|
||
}
|
||
|
||
function workspaceEntries(output: Record<string, unknown>): WorkspaceNode[] {
|
||
return Array.isArray(output.entries) ? output.entries.map((entry) => {
|
||
const value = record(entry);
|
||
const type = value.type === "dir" || value.type === "file" ? value.type : "other";
|
||
return {
|
||
name: String(value.name ?? ""),
|
||
path: String(value.path ?? value.name ?? ""),
|
||
type,
|
||
sizeBytes: value.sizeBytes == null ? null : numberValue(value.sizeBytes),
|
||
modifiedAt: typeof value.modifiedAt === "string" ? value.modifiedAt : null,
|
||
...(type === "dir" ? { children: [], loaded: false } : {}),
|
||
};
|
||
}) : [];
|
||
}
|
||
|
||
function record(value: unknown): Record<string, unknown> { return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
|
||
function numberValue(value: unknown): number { return Number.isFinite(Number(value)) ? Number(value) : 0; }
|
||
function errorSummary(value: Record<string, unknown>): string { return String(record(value.error).message ?? record(value.blocker).summary ?? value.summary ?? ""); }
|
||
function blockerSummary(value: Record<string, unknown>): string { return String(record(value.blocker).summary ?? ""); }
|
||
function operationLabel(operation: HwpodOperationResponse | null): string { return operation ? `${operation.status} · ${operation.operationId}` : "尚未执行"; }
|
||
</script>
|
||
|
||
<template>
|
||
<section class="operations-workspace" data-testid="hwpod-operations-workspace">
|
||
<header class="workspace-toolbar">
|
||
<button class="icon-command" type="button" title="返回设备资源" @click="emit('back')"><ChevronLeft :size="17" /></button>
|
||
<strong>{{ hwpodId }}</strong>
|
||
<code>{{ selectedPath }}</code>
|
||
<button class="icon-command" type="button" title="刷新工作区" :disabled="rootLoading" @click="loadRoot"><RefreshCw :class="{ spin: rootLoading }" :size="16" /></button>
|
||
<span class="operation-state" :data-status="terminalStatus">{{ operationLabel(hwpod.lastOperation) }}</span>
|
||
</header>
|
||
|
||
<div class="workspace-columns">
|
||
<section class="file-pane" aria-label="工作区文件">
|
||
<div class="pane-heading"><h2>工作区</h2><LoadingState v-if="rootLoading && tree.length" label="刷新中" compact /></div>
|
||
<LoadingState v-if="rootLoading && !tree.length" class="pane-loading" label="正在加载工作区" />
|
||
<BaseTree v-else v-model="tree" class="file-tree" :virtualization="true" :virtualization-prerender-count="30" :default-open="false" aria-label="HWPOD 工作区文件树" @click:node="selectNode">
|
||
<template #default="{ node, stat, indentStyle }">
|
||
<div class="tree-row" :class="{ selected: selectedPath === node.path }" :style="indentStyle">
|
||
<LoaderCircle v-if="loadingDirectoryPath === node.path" class="spin" :size="15" />
|
||
<Folder v-else-if="node.type === 'dir'" :size="15" :fill="stat.open ? 'currentColor' : 'none'" />
|
||
<File v-else :size="15" />
|
||
<span>{{ node.name }}</span>
|
||
</div>
|
||
</template>
|
||
</BaseTree>
|
||
</section>
|
||
|
||
<section class="preview-pane" aria-label="文件预览" :aria-busy="previewLoading ? 'true' : 'false'">
|
||
<div class="preview-stage">
|
||
<ContentViewer
|
||
v-if="preview"
|
||
class="workspace-content-viewer"
|
||
:content="preview"
|
||
:mode="previewMode"
|
||
title="文件预览"
|
||
:filename="selectedPath"
|
||
:line-numbers="previewMode !== 'markdown'"
|
||
/>
|
||
<div v-else-if="!previewLoading" class="empty-preview">选择文件查看内容</div>
|
||
<div v-if="previewLoading" class="preview-loading"><LoadingState label="正在加载文件内容" compact /></div>
|
||
</div>
|
||
</section>
|
||
|
||
<aside class="operation-pane" aria-label="HWPOD 操作">
|
||
<section>
|
||
<h2><Hammer :size="16" /> 调试</h2>
|
||
<div class="action-row">
|
||
<button type="button" :disabled="Boolean(busy)" @click="runBuild"><Hammer :size="15" /> 编译</button>
|
||
<button type="button" :disabled="Boolean(busy)" @click="downloadConfirmOpen = true"><Download :size="15" /> 下载</button>
|
||
</div>
|
||
<div class="build-result" :data-tone="buildView.tone" aria-live="polite">
|
||
<div class="build-result-toolbar">
|
||
<div class="build-result-heading">
|
||
<LoaderCircle v-if="buildView.tone === 'running'" class="spin" :size="15" />
|
||
<CircleCheck v-else-if="buildView.tone === 'ok'" :size="15" />
|
||
<CircleX v-else-if="buildView.tone === 'error'" :size="15" />
|
||
<span class="status-dot" v-else aria-hidden="true" />
|
||
<strong>{{ buildView.label }}</strong>
|
||
</div>
|
||
<div class="result-mode-switch" role="group" aria-label="编译结果显示方式">
|
||
<button type="button" title="结构化结果" :data-active="buildResultMode === 'summary'" @click="buildResultMode = 'summary'"><LayoutPanelTop :size="13" />结果</button>
|
||
<button type="button" title="原始输出" :data-active="buildResultMode === 'raw'" @click="buildResultMode = 'raw'"><Code2 :size="13" />原始</button>
|
||
</div>
|
||
</div>
|
||
<div v-if="buildView.operationId" class="build-identities">
|
||
<code :title="buildView.operationId">{{ buildView.operationId }}</code>
|
||
<code v-if="buildView.jobId">job {{ buildView.jobId }}</code>
|
||
</div>
|
||
<div v-if="buildView.tone === 'running'" class="build-progress" aria-label="编译进行中"><span /></div>
|
||
<div v-if="buildResultMode === 'summary'" class="build-summary">
|
||
<p v-if="buildView.message">{{ buildView.message }}</p>
|
||
<dl v-if="buildView.exitCode !== null || buildView.warningCount !== null || buildView.errorCount !== null || buildView.elapsedMs !== null" class="build-facts">
|
||
<div v-if="buildView.exitCode !== null"><dt>退出码</dt><dd>{{ buildView.exitCode }}</dd></div>
|
||
<div v-if="buildView.errorCount !== null"><dt>错误</dt><dd>{{ buildView.errorCount }}</dd></div>
|
||
<div v-if="buildView.warningCount !== null"><dt>告警</dt><dd>{{ buildView.warningCount }}</dd></div>
|
||
<div v-if="buildView.elapsedMs !== null"><dt>耗时</dt><dd>{{ (buildView.elapsedMs / 1000).toFixed(2) }}s</dd></div>
|
||
</dl>
|
||
<ul v-if="buildView.artifacts.length" class="build-artifacts">
|
||
<li v-for="artifact in buildView.artifacts" :key="`${artifact.kind}:${artifact.path}`"><strong>{{ artifact.kind }}</strong><code :title="artifact.path">{{ artifact.path }}</code></li>
|
||
</ul>
|
||
<p v-if="!buildView.message && buildView.exitCode === null && !buildView.artifacts.length" class="build-empty">编译后在此显示结构化结果</p>
|
||
</div>
|
||
<pre v-else class="build-log">{{ buildView.log || "暂无原始输出" }}</pre>
|
||
</div>
|
||
</section>
|
||
<section>
|
||
<h2><Terminal :size="16" /> 串口</h2>
|
||
<label>端口<input v-model="uartPort" /></label>
|
||
<label>波特率<input v-model.number="uartBaudRate" type="number" min="1" /></label>
|
||
<div class="icon-actions">
|
||
<button type="button" title="打开串口" :disabled="Boolean(busy)" @click="runAction('io.uart.open', uartArgs())"><Plug :size="16" /></button>
|
||
<button type="button" title="关闭串口" :disabled="Boolean(busy)" @click="runAction('io.uart.close', uartArgs())"><Square :size="15" /></button>
|
||
<button type="button" title="读取串口" :disabled="Boolean(busy)" @click="readUart"><RefreshCw :size="15" /></button>
|
||
</div>
|
||
<div class="send-row"><input v-model="uartData" aria-label="串口发送内容" @keyup.enter="writeUart" /><button type="button" title="发送串口数据" :disabled="Boolean(busy) || !uartData" @click="writeUart"><Send :size="15" /></button></div>
|
||
<pre class="uart-output">{{ uartOutput || "等待串口数据" }}</pre>
|
||
</section>
|
||
<p v-if="busy && busy !== 'debug.build'" class="operation-message">{{ busy }} · {{ terminalStatus }}</p>
|
||
<p v-if="localError" class="operation-error" role="alert">{{ localError }}</p>
|
||
</aside>
|
||
</div>
|
||
|
||
<footer class="workspace-statusbar" aria-label="HWPOD 工作区状态" aria-live="polite">
|
||
<span :data-tone="device?.online ? 'ok' : 'error'"><i />Node {{ device?.online ? "在线" : "离线" }}</span>
|
||
<span :data-tone="device?.available ? 'ok' : 'warn'"><i />HWPOD {{ device?.available ? "可用" : "不可用" }}</span>
|
||
<span class="status-path" :title="selectedPath"><span>{{ selectedPath }}</span><small v-if="previewMeta">{{ previewMeta.sizeBytes }} bytes<span v-if="previewMeta.truncated"> · 已截断</span></small></span>
|
||
<span class="status-operation" :data-tone="buildView.tone"><i />{{ buildView.label }}</span>
|
||
</footer>
|
||
|
||
<ConfirmDialog :open="downloadConfirmOpen" title="下载固件" message="下载会写入当前连接的目标板。确认继续?" confirm-label="确认下载" :pending="busy === 'debug.download'" @close="downloadConfirmOpen = false" @confirm="confirmDownload" />
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.operations-workspace { display: grid; height: 100%; min-width: 0; min-height: 0; grid-template-rows: auto minmax(0, 1fr) 28px; border: 1px solid var(--console-border-strong); background: var(--console-surface-raised); }
|
||
.workspace-toolbar { display: grid; min-width: 0; grid-template-columns: auto auto minmax(80px, 1fr) auto minmax(160px, auto); align-items: center; gap: 8px; min-height: 42px; padding: 5px 8px; border-bottom: 1px solid var(--console-border-strong); }
|
||
.workspace-toolbar strong, .workspace-toolbar code, .operation-state { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.workspace-toolbar code, .operation-state { color: var(--console-graphite-600); font-size: 11px; }
|
||
.icon-command, .icon-actions button, .send-row button { display: inline-grid; width: 31px; height: 31px; place-items: center; border: 1px solid var(--console-border-strong); border-radius: var(--console-radius-sm); background: #fff; color: var(--console-graphite-800); cursor: pointer; }
|
||
.workspace-columns { display: grid; min-width: 0; min-height: 0; grid-template-columns: minmax(210px, .85fr) minmax(300px, 1.5fr) minmax(240px, .9fr); }
|
||
.file-pane, .preview-pane, .operation-pane { min-width: 0; min-height: 0; padding: 10px; overflow: auto; overscroll-behavior: contain; }
|
||
.file-pane, .preview-pane { border-right: 1px solid var(--console-border-strong); }
|
||
h2 { display: flex; align-items: center; gap: 6px; margin: 0 0 9px; color: var(--console-graphite-700); font-size: 12px; }
|
||
.pane-heading { display: flex; min-height: 28px; align-items: flex-start; justify-content: space-between; gap: 8px; }
|
||
.pane-heading :deep(.loading-state) { color: var(--console-cyan-700); font-size: 10px; }
|
||
.pane-loading { height: calc(100% - 28px); }
|
||
.file-tree { height: calc(100% - 28px); min-height: 280px; overflow: auto; }
|
||
.tree-row { display: flex; min-width: 0; align-items: center; gap: 6px; min-height: 30px; padding-right: 6px; color: var(--console-graphite-700); cursor: pointer; }
|
||
.tree-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.tree-row.selected { background: var(--console-cyan-100); color: var(--console-cyan-700); }
|
||
.preview-pane { display: grid; grid-template-rows: minmax(0, 1fr); overflow: hidden; }
|
||
.preview-stage { position: relative; display: grid; height: 100%; min-width: 0; min-height: 0; grid-template-rows: minmax(0, 1fr); overflow: hidden; }
|
||
.preview-pane :deep(.workspace-content-viewer) { height: 100%; max-height: 100%; min-height: 0; grid-template-rows: 36px minmax(0, 1fr); overflow: hidden; }
|
||
.preview-pane :deep(.content-viewer-toolbar) { min-height: 36px; flex-wrap: nowrap; gap: 5px; padding: 3px 5px; }
|
||
.preview-pane :deep(.content-viewer-toolbar strong) { min-width: 0; overflow: hidden; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||
.preview-pane :deep(.content-viewer-search) { width: min(210px, 40%); min-height: 28px; flex: 0 1 210px; padding-block: 2px; font-size: 10px; }
|
||
.preview-pane :deep(.content-viewer-toolbar .icon-button) { width: 28px; height: 28px; min-height: 28px; flex: 0 0 28px; padding: 0; }
|
||
.preview-pane :deep(.content-viewer-body) { height: 100%; min-height: 0; overflow-x: auto; overflow-y: scroll; overscroll-behavior: contain; scrollbar-gutter: stable; }
|
||
.preview-pane :deep(.content-viewer-markdown) { font-size: 12px; line-height: 1.65; }
|
||
.uart-output { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--console-font-mono); font-size: 11px; line-height: 1.55; }
|
||
.empty-preview { display: grid; min-height: 180px; place-items: center; color: var(--console-graphite-500); font-size: 12px; }
|
||
.preview-loading { position: absolute; z-index: 3; inset: 0; display: grid; place-items: center; background: rgb(246 248 246 / 84%); backdrop-filter: blur(1px); }
|
||
.preview-loading :deep(.loading-state) { padding: 8px 12px; border: 1px solid var(--console-border); border-radius: var(--console-radius-sm); background: var(--console-surface-raised); color: var(--console-cyan-700); box-shadow: var(--console-shadow-sm); }
|
||
.operation-pane { display: grid; align-content: start; gap: 18px; background: var(--console-surface-muted); }
|
||
.operation-pane section { display: grid; gap: 8px; }
|
||
.operation-pane label { display: grid; gap: 4px; color: var(--console-graphite-600); font-size: 10px; font-weight: 700; }
|
||
.operation-pane input { min-width: 0; height: 32px; border: 1px solid var(--console-border-strong); border-radius: var(--console-radius-sm); background: #fff; padding: 0 8px; }
|
||
.action-row, .icon-actions, .send-row { display: flex; gap: 6px; }
|
||
.action-row button { display: inline-flex; min-height: 32px; align-items: center; gap: 6px; border: 1px solid var(--console-border-strong); border-radius: var(--console-radius-sm); background: #fff; padding: 0 10px; color: var(--console-graphite-800); font-weight: 700; cursor: pointer; }
|
||
.build-result { display: grid; min-width: 0; gap: 6px; padding: 8px; border: 1px solid var(--console-border); border-left: 3px solid var(--console-graphite-500); background: var(--console-surface-raised); }
|
||
.build-result[data-tone="running"] { border-left-color: var(--console-cyan-700); }
|
||
.build-result[data-tone="ok"] { border-left-color: #2c6b5d; }
|
||
.build-result[data-tone="warn"] { border-left-color: var(--console-amber-500); }
|
||
.build-result[data-tone="error"] { border-left-color: var(--console-red-700); }
|
||
.build-result-toolbar { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; }
|
||
.build-result-heading { display: flex; min-width: 0; align-items: center; gap: 6px; font-size: 11px; }
|
||
.build-result-heading strong { min-width: 0; flex: 1; }
|
||
.build-result-heading > span { color: var(--console-graphite-600); font-family: var(--console-font-mono); font-size: 10px; }
|
||
.status-dot { width: 7px; height: 7px; flex: 0 0 auto; border-radius: 50%; background: var(--console-graphite-500); }
|
||
.build-identities { display: grid; gap: 2px; }
|
||
.build-identities code { overflow: hidden; color: var(--console-graphite-600); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||
.result-mode-switch { display: inline-flex; flex: 0 0 auto; padding: 2px; border: 1px solid var(--console-border-strong); border-radius: var(--console-radius-sm); background: var(--console-border); }
|
||
.result-mode-switch button { display: inline-flex; min-height: 24px; align-items: center; gap: 4px; border: 0; border-radius: 3px; background: transparent; padding: 0 7px; color: var(--console-graphite-600); font-size: 9px; font-weight: 750; cursor: pointer; }
|
||
.result-mode-switch button[data-active="true"] { background: var(--console-surface-raised); color: var(--console-graphite-900); box-shadow: 0 1px 2px rgb(28 38 34 / 12%); }
|
||
.build-progress { height: 3px; overflow: hidden; background: var(--console-border); }
|
||
.build-progress span { display: block; width: 42%; height: 100%; background: var(--console-cyan-700); animation: build-progress 1.1s ease-in-out infinite alternate; }
|
||
.build-summary { display: grid; min-width: 0; gap: 7px; }
|
||
.build-summary > p { margin: 0; color: var(--console-graphite-700); font-size: 10px; line-height: 1.45; }
|
||
.build-facts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; border: 1px solid var(--console-border); }
|
||
.build-facts div { min-width: 0; padding: 5px 6px; border-right: 1px solid var(--console-border); background: var(--console-surface-muted); }
|
||
.build-facts div:last-child { border-right: 0; }
|
||
.build-facts dt, .build-facts dd { margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.build-facts dt { color: var(--console-graphite-500); font-size: 8px; font-weight: 750; }
|
||
.build-facts dd { margin-top: 2px; color: var(--console-graphite-900); font-family: var(--console-font-mono); font-size: 10px; font-weight: 750; }
|
||
.build-artifacts { display: grid; gap: 3px; margin: 0; padding: 0; list-style: none; }
|
||
.build-artifacts li { display: grid; min-width: 0; grid-template-columns: 54px minmax(0, 1fr); align-items: center; gap: 6px; padding: 4px 6px; border: 1px solid var(--console-border); background: #fff; }
|
||
.build-artifacts strong { color: var(--console-cyan-700); font-size: 8px; }
|
||
.build-artifacts code { overflow: hidden; color: var(--console-graphite-700); font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
|
||
.build-empty { color: var(--console-graphite-500) !important; }
|
||
.build-log { max-height: 180px; margin: 0; padding: 7px; overflow: auto; overscroll-behavior: contain; background: #171c1a; color: #d8eee5; font-family: var(--console-font-mono); font-size: 9px; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||
.spin { animation: spin .9s linear infinite; }
|
||
.send-row input { flex: 1; }
|
||
button:disabled { opacity: .45; cursor: wait; }
|
||
.uart-output { max-height: 190px; min-height: 88px; padding: 8px; overflow: auto; border: 1px solid var(--console-border-strong); background: #171c1a; color: #d8eee5; }
|
||
.operation-message { color: var(--console-cyan-700); font-size: 11px; }
|
||
.operation-error { margin: 0; padding: 8px; border-left: 3px solid var(--console-red-700); background: var(--console-red-100); color: var(--console-red-700); font-size: 11px; }
|
||
.workspace-statusbar { display: grid; min-width: 0; grid-template-columns: auto auto minmax(80px, 1fr) minmax(150px, auto); align-items: center; gap: 0; border-top: 1px solid var(--console-border-strong); background: #edf1ef; color: var(--console-graphite-700); font-family: var(--console-font-mono); font-size: 9px; }
|
||
.workspace-statusbar > span { display: flex; min-width: 0; height: 100%; align-items: center; gap: 5px; padding: 0 9px; border-right: 1px solid var(--console-border); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.workspace-statusbar i { width: 6px; height: 6px; flex: 0 0 auto; border-radius: 50%; background: var(--console-graphite-500); }
|
||
.workspace-statusbar [data-tone="ok"] i { background: #2c6b5d; }
|
||
.workspace-statusbar [data-tone="running"] i { background: var(--console-cyan-700); }
|
||
.workspace-statusbar [data-tone="warn"] i { background: var(--console-amber-500); }
|
||
.workspace-statusbar [data-tone="error"] i { background: var(--console-red-700); }
|
||
.status-path { justify-content: flex-start; }
|
||
.status-path > span { direction: rtl; min-width: 0; overflow: hidden; text-align: left; text-overflow: ellipsis; }
|
||
.status-path small { flex: 0 0 auto; color: var(--console-graphite-500); font-size: 8px; }
|
||
.status-operation { justify-content: flex-end; border-right: 0 !important; }
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
@keyframes build-progress { from { transform: translateX(-20%); } to { transform: translateX(160%); } }
|
||
@media (max-width: 980px) { .workspace-columns { grid-template-columns: minmax(190px, .8fr) minmax(280px, 1.2fr); } .operation-pane { grid-column: 1 / -1; grid-template-columns: repeat(2, minmax(0, 1fr)); border-top: 1px solid var(--console-border-strong); } }
|
||
@media (max-width: 680px) { .workspace-toolbar { grid-template-columns: auto minmax(0, 1fr) auto; } .workspace-toolbar code, .operation-state { grid-column: 1 / -1; } .workspace-columns { display: block; overflow: auto; } .file-pane, .preview-pane { min-height: 300px; border-right: 0; border-bottom: 1px solid var(--console-border-strong); } .operation-pane { display: grid; grid-template-columns: 1fr; } .build-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); } .workspace-statusbar { grid-template-columns: auto auto minmax(0, 1fr); } .status-operation { display: none !important; } }
|
||
@media (prefers-reduced-motion: reduce) { .spin, .build-progress span { animation: none; } }
|
||
</style>
|