Remotes view: unified table display for connected and local remotes
- Remotes and Local Remotes merged into a single grid/table layout; upstream connected remotes listed first, local remotes after - Expanding a remote row shows its functions as indented rows; any number of remotes can be expanded simultaneously - Each function row shows name and description, and expands into a detail section (10em min height) with JSON arguments on the left and call output on the right - Connected and local remotes are visually identical; local remotes additionally offer connect/disconnect and delete options plus a connected status - Disconnected local remotes show an editable function interface (args editor + JS body); when connected, functions become callable instead (only one mode shown at a time) - Local remote<->upstream connection matching persisted across view reloads via sessionStorage (browser-session scoped, matching connection lifetime), not across tab reloads - Local remote store and remote-call listener moved to module scope so they survive view reloads; stale matching flags are dropped when the socket connection is gone
This commit is contained in:
1 parent
e1063fc733
commit
4dc776dda4
1 file changed
+467
-333
+467
-333
@@ -1,57 +1,82 @@
|
||||
<script setup>
|
||||
import TitleBar from '../components/TitleBar.vue';
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue';
|
||||
<script>
|
||||
// Module-scope store for local remotes. Lives outside the component so it
|
||||
// survives view reloads (navigation), while connections themselves die with
|
||||
// the websocket (browser session), matching the upstream remote lifecycle.
|
||||
import { ref } from 'vue';
|
||||
import * as backend from '../backend';
|
||||
import * as ui from '../ui';
|
||||
|
||||
/** Connected external tool remotes (initialized with mock data) */
|
||||
const remotes = ref([]);
|
||||
|
||||
/** Tracks expanded remote panels using a Set to support multiple open sections */
|
||||
const expandedRemoteIds = ref(new Set());
|
||||
|
||||
/** Currently selected remote name for invoking a function */
|
||||
const activeRemoteName = ref('');
|
||||
|
||||
/** Currently selected function spec inside the active remote */
|
||||
const activeFunction = ref(null);
|
||||
|
||||
/** JSON string containing arguments for the active function */
|
||||
const activeArgsJson = ref('{}');
|
||||
|
||||
/** Parsed result object of the last successful call (null until called) */
|
||||
const callResult = ref(null);
|
||||
|
||||
/** Error message from the last call attempt (null when no error) */
|
||||
const callError = ref('');
|
||||
|
||||
/** List of locally persisted remotes managed via localStorage */
|
||||
const localRemoteList = ref([]);
|
||||
|
||||
/** Key used to serialize/deserialize local remotes from browser storage */
|
||||
const STORAGE_KEY = 'local_remotes';
|
||||
|
||||
let connectListenerCancel = null;
|
||||
let disconnectListenerCancel = null;
|
||||
/** sessionStorage flag marking a local remote as matched with its upstream
|
||||
* connection. sessionStorage is browser-session scoped, which is exactly the
|
||||
* lifetime of a remote connection, so matching survives view reloads but not
|
||||
* tab reloads. */
|
||||
const connectedFlagKey = (name) => `local_remote_connected:${name}`;
|
||||
|
||||
// Listen for remote-call requests destined for local remotes.
|
||||
// Persists for the session, not just while the tab is visible.
|
||||
// TODO: restore after disconnections
|
||||
let callListenerCancel = null;
|
||||
let idCounter = 0;
|
||||
function nextId() { return ++idCounter; }
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchRemotes();
|
||||
let connectListenerCancel = backend.onAction('remote-connected', ev => {
|
||||
remotes.value.push(ev.data.remote);
|
||||
});
|
||||
let disconnectListenerCancel = backend.onAction('remote-disconnected', ev => {
|
||||
remotes.value = remotes.value.filter((r) => r.name != ev.data.remotename);
|
||||
});
|
||||
/** Local remote definitions persisted in localStorage (connection state excluded) */
|
||||
const localRemotes = ref([]);
|
||||
|
||||
if (callListenerCancel == null) {
|
||||
callListenerCancel = backend.onAction('remote-call', async ({ requestId, remotename, function: fnName, args }) => {
|
||||
function loadLocalRemotes() {
|
||||
let data = [];
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (Array.isArray(parsed)) data = parsed;
|
||||
}
|
||||
} catch (e) {}
|
||||
localRemotes.value = data.map(remote => ({
|
||||
id: nextId(),
|
||||
name: remote.name || '',
|
||||
connected: false,
|
||||
functions: (remote.functions || []).map(fn => ({
|
||||
id: nextId(),
|
||||
name: fn.name || '',
|
||||
description: fn.description || '',
|
||||
arguments: Array.isArray(fn.arguments) ? fn.arguments : [],
|
||||
body: fn.body || '// Return your result object\nreturn {};\n',
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
function saveLocalRemotes() {
|
||||
// 'connected' is connection state, never persisted to localStorage
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(localRemotes.value.map(remote => ({
|
||||
name: remote.name,
|
||||
functions: remote.functions.map(fn => ({
|
||||
name: fn.name,
|
||||
description: fn.description,
|
||||
arguments: fn.arguments,
|
||||
body: fn.body,
|
||||
})),
|
||||
}))));
|
||||
}
|
||||
|
||||
/** Whether a local remote name is remembered as connected this browser session */
|
||||
function isMatchedConnected(name) {
|
||||
try { return sessionStorage.getItem(connectedFlagKey(name)) === '1'; }
|
||||
catch (e) { return false; }
|
||||
}
|
||||
|
||||
/** Set the local<->upstream connection match for this browser session */
|
||||
function setMatchedConnected(remote, connected) {
|
||||
remote.connected = connected;
|
||||
try {
|
||||
if (connected) sessionStorage.setItem(connectedFlagKey(remote.name), '1');
|
||||
else sessionStorage.removeItem(connectedFlagKey(remote.name));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
loadLocalRemotes();
|
||||
|
||||
// Answer calls routed to connected local remotes. The module runs once per
|
||||
// page, so the listener survives view reloads, exactly like the connection.
|
||||
backend.onAction('remote-call', async ({ requestId, remotename, function: fnName, args }) => {
|
||||
console.log(`Received remote call for: ${remotename}, ${fnName}`);
|
||||
const local = localRemoteList.value.find(r => r.name == remotename && r.connected);
|
||||
const local = localRemotes.value.find(r => r.name == remotename && r.connected);
|
||||
if (!local) return;
|
||||
const funcDef = local.functions.find(f => f.name == fnName);
|
||||
if (!funcDef) {
|
||||
@@ -69,110 +94,218 @@ onMounted(async () => {
|
||||
} catch (e) {
|
||||
await backend.sendAction('remote-call-result', { requestId, error: String(e) });
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<script setup>
|
||||
import TitleBar from '../components/TitleBar.vue';
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue';
|
||||
import * as backend from '../backend';
|
||||
import * as ui from '../ui';
|
||||
|
||||
/** Connected upstream remotes (source of truth from the backend) */
|
||||
const upstreamRemotes = ref([]);
|
||||
|
||||
/** Expanded remote rows, keyed by row key; several can be open at once */
|
||||
const expandedRemotes = ref(new Set());
|
||||
|
||||
/** Expanded function rows, keyed by `<rowKey>|<fnKey>` */
|
||||
const expandedFns = ref(new Set());
|
||||
|
||||
/** Per-function call state, keyed like expandedFns */
|
||||
const callStates = reactive({});
|
||||
|
||||
let connectListenerCancel = null;
|
||||
let disconnectListenerCancel = null;
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
upstreamRemotes.value = (await backend.sendAction('remote-list')).remotes || [];
|
||||
} catch (e) {
|
||||
upstreamRemotes.value = [];
|
||||
}
|
||||
// Restore local<->upstream matching from this browser session. A flag
|
||||
// without a matching upstream connection is stale (socket died), drop it.
|
||||
for (const remote of localRemotes.value) {
|
||||
if (remote.connected) continue;
|
||||
if (isMatchedConnected(remote.name) && findUpstream(remote.name)) {
|
||||
remote.connected = true;
|
||||
} else {
|
||||
setMatchedConnected(remote, false);
|
||||
}
|
||||
}
|
||||
|
||||
connectListenerCancel = backend.onAction('remote-connected', ev => {
|
||||
const remote = ev.data.remote;
|
||||
if (!upstreamRemotes.value.some(r => r.name === remote.name)) {
|
||||
upstreamRemotes.value.push(remote);
|
||||
}
|
||||
// If this is the upstream connection of a local remote we initiated
|
||||
// connecting this session, complete the match.
|
||||
const local = localRemotes.value.find(r => r.name === remote.name);
|
||||
if (local && !local.connected && isMatchedConnected(remote.name)) {
|
||||
local.connected = true;
|
||||
}
|
||||
});
|
||||
disconnectListenerCancel = backend.onAction('remote-disconnected', ev => {
|
||||
upstreamRemotes.value = upstreamRemotes.value.filter(r => r.name != ev.data.remotename);
|
||||
// The upstream connection of a matched local remote is gone
|
||||
const local = localRemotes.value.find(r => r.name === ev.data.remotename);
|
||||
if (local && local.connected) setMatchedConnected(local, false);
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(async () => {
|
||||
onUnmounted(() => {
|
||||
if (connectListenerCancel) connectListenerCancel();
|
||||
if (disconnectListenerCancel) disconnectListenerCancel();
|
||||
})
|
||||
});
|
||||
|
||||
async function fetchRemotes() {
|
||||
remotes.value = (await backend.sendAction('remote-list')).remotes;
|
||||
// ---- Unified row model ----
|
||||
|
||||
/** Upstream connection serving a name, if any */
|
||||
function findUpstream(name) {
|
||||
return upstreamRemotes.value.find(r => r.name === name);
|
||||
}
|
||||
|
||||
/** Toggle expansion of a remote detail panel */
|
||||
function toggleRemote(name) {
|
||||
// Create a new Set instance to ensure Vue detects the change
|
||||
const newSet = new Set(expandedRemoteIds.value);
|
||||
if (newSet.has(name)) {
|
||||
newSet.delete(name);
|
||||
} else {
|
||||
newSet.add(name);
|
||||
/**
|
||||
* All rows of the unified table: connected upstream remotes first, local
|
||||
* remotes after. The upstream connection of a connected local remote is
|
||||
* matched into the local row instead of being listed separately.
|
||||
*/
|
||||
const remoteRows = computed(() => {
|
||||
const connectedLocalNames = new Set(
|
||||
localRemotes.value.filter(r => r.connected).map(r => r.name)
|
||||
);
|
||||
const rows = upstreamRemotes.value
|
||||
.filter(r => !connectedLocalNames.has(r.name))
|
||||
.map(r => ({ kind: 'upstream', key: `up:${r.name}`, remote: r }));
|
||||
for (const remote of localRemotes.value) {
|
||||
rows.push({ kind: 'local', key: `local:${remote.id}`, remote });
|
||||
}
|
||||
expandedRemoteIds.value = newSet;
|
||||
return rows;
|
||||
});
|
||||
|
||||
function rowKey(row) { return row.key; }
|
||||
|
||||
/** Row is in the connected state (upstream remotes always are) */
|
||||
function isRowConnected(row) {
|
||||
return row.kind === 'upstream' || row.remote.connected;
|
||||
}
|
||||
|
||||
/** Set the active remote and expose its function list to the caller */
|
||||
function selectRemote(name) {
|
||||
const found = remotes.value.find(r => r.name === name);
|
||||
if (!found) return;
|
||||
if (!activeFunction.value && found.functions.length) {
|
||||
selectFunction(found, found.functions[0]);
|
||||
} else {
|
||||
resetCallState();
|
||||
/** Local remotes disconnected from their upstream show the edit interface */
|
||||
function isEditable(row) {
|
||||
return row.kind === 'local' && !row.remote.connected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Functions listed under a row. Connected remotes (upstream, or local remotes
|
||||
* matched with their upstream) show the upstream function specs; only
|
||||
* disconnected local remotes show the editable local definitions.
|
||||
*/
|
||||
function functionsOf(row) {
|
||||
if (isEditable(row)) return row.remote.functions;
|
||||
if (row.kind === 'upstream') return row.remote.functions || [];
|
||||
const up = findUpstream(row.remote.name);
|
||||
if (up) return up.functions || [];
|
||||
return row.remote.functions.map(f => ({
|
||||
name: f.name,
|
||||
description: f.description,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: f.arguments.reduce((acc, arg) => {
|
||||
acc[arg.name] = { type: arg.type, description: '' };
|
||||
return acc;
|
||||
}, {}),
|
||||
required: f.arguments.map(a => a.name),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// ---- Expansion ----
|
||||
|
||||
function toggleRemote(key) {
|
||||
const set = new Set(expandedRemotes.value);
|
||||
if (set.has(key)) set.delete(key); else set.add(key);
|
||||
expandedRemotes.value = set;
|
||||
}
|
||||
|
||||
function fnKey(row, fn) {
|
||||
return fn.id != null ? `l${fn.id}` : `u${fn.name}`;
|
||||
}
|
||||
|
||||
function fnStateKey(row, fn) {
|
||||
return `${row.key}|${fnKey(row, fn)}`;
|
||||
}
|
||||
|
||||
function toggleFn(row, fn) {
|
||||
const key = fnStateKey(row, fn);
|
||||
const set = new Set(expandedFns.value);
|
||||
if (set.has(key)) set.delete(key); else set.add(key);
|
||||
expandedFns.value = set;
|
||||
}
|
||||
|
||||
// ---- Calling ----
|
||||
|
||||
/** Type-appropriate default argument JSON for a function spec */
|
||||
function defaultArgsJson(fn) {
|
||||
const props = fn.parameters?.properties || {};
|
||||
const out = {};
|
||||
for (const [name, p] of Object.entries(props)) {
|
||||
const type = p.type;
|
||||
out[name] =
|
||||
type === 'string' ? '' :
|
||||
type === 'number' ? 0 :
|
||||
type === 'boolean' ? false :
|
||||
type === 'array' ? [] :
|
||||
type === 'object' ? {} : null;
|
||||
}
|
||||
return JSON.stringify(out, null, 2);
|
||||
}
|
||||
|
||||
/** Activate a specific function for invocation */
|
||||
function selectFunction(remote, fn) {
|
||||
if (remote && fn) {
|
||||
activeRemoteName.value = remote.name;
|
||||
activeFunction.value = fn;
|
||||
} else {
|
||||
activeRemoteName.value = null;
|
||||
activeFunction.value = null;
|
||||
function callStateFor(row, fn) {
|
||||
const key = fnStateKey(row, fn);
|
||||
if (!callStates[key]) {
|
||||
callStates[key] = { args: defaultArgsJson(fn), result: null, error: '', busy: false };
|
||||
}
|
||||
resetCallState();
|
||||
return callStates[key];
|
||||
}
|
||||
|
||||
/** Clear call result/error and reinitialize args JSON */
|
||||
function resetCallState() {
|
||||
callResult.value = null;
|
||||
callError.value = '';
|
||||
activeArgsJson.value = '{}';
|
||||
}
|
||||
|
||||
/** Execute a call against the active remote & function */
|
||||
async function doCall() {
|
||||
callResult.value = null;
|
||||
callError.value = '';
|
||||
if (!activeRemoteName.value || !activeFunction.value) {
|
||||
callError.value = 'Select a remote and function first.';
|
||||
return;
|
||||
}
|
||||
async function doCall(row, fn) {
|
||||
const state = callStateFor(row, fn);
|
||||
state.result = null;
|
||||
state.error = '';
|
||||
let argsParsed;
|
||||
try {
|
||||
argsParsed = activeArgsJson.value.trim() ? JSON.parse(activeArgsJson.value) : {};
|
||||
argsParsed = state.args.trim() ? JSON.parse(state.args) : {};
|
||||
} catch (e) {
|
||||
callError.value = 'Invalid JSON args: ' + e.message;
|
||||
state.error = 'Invalid JSON args: ' + e.message;
|
||||
return;
|
||||
}
|
||||
state.busy = true;
|
||||
try {
|
||||
callResult.value = (await backend.sendAction('remote-call', {
|
||||
remotename: activeRemoteName.value,
|
||||
function: activeFunction.value.name,
|
||||
const response = await backend.sendAction('remote-call', {
|
||||
remotename: row.remote.name,
|
||||
function: fn.name,
|
||||
args: argsParsed,
|
||||
})).result;
|
||||
});
|
||||
state.result = response.result;
|
||||
} catch (e) {
|
||||
callError.value = e.message || String(e);
|
||||
state.error = e.message || String(e);
|
||||
} finally {
|
||||
state.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Local Remotes ----
|
||||
function loadLocalRemoteList() {
|
||||
let data = [];
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (Array.isArray(parsed)) data = parsed;
|
||||
}
|
||||
} catch (e) {}
|
||||
data.forEach(r => {
|
||||
r.connected = false
|
||||
r.functions = r.functions || [];
|
||||
});
|
||||
localRemoteList.value = data;
|
||||
}
|
||||
// ---- Local remotes ----
|
||||
|
||||
function createLocalRemote() {
|
||||
localRemoteList.value.push({ name: '', connected: false });
|
||||
}
|
||||
|
||||
function saveLocalRemote() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(localRemoteList.value));
|
||||
localRemotes.value.push({
|
||||
id: nextId(),
|
||||
name: '',
|
||||
connected: false,
|
||||
functions: [],
|
||||
});
|
||||
saveLocalRemotes();
|
||||
}
|
||||
|
||||
async function connectLocalRemote(remote) {
|
||||
@@ -180,11 +313,13 @@ async function connectLocalRemote(remote) {
|
||||
ui.alert("Cannot connect remote with empty name");
|
||||
return;
|
||||
}
|
||||
let result = await backend.sendAction('remote-connect', {
|
||||
let result;
|
||||
try {
|
||||
result = await backend.sendAction('remote-connect', {
|
||||
remotename: remote.name,
|
||||
functions: remote.functions?.map(f => ({
|
||||
functions: remote.functions.map(f => ({
|
||||
name: f.name,
|
||||
description: '',
|
||||
description: f.description || '',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: f.arguments.reduce((acc, arg) => {
|
||||
@@ -193,73 +328,63 @@ async function connectLocalRemote(remote) {
|
||||
}, {}),
|
||||
required: f.arguments.map(a => a.name)
|
||||
}
|
||||
})) || [],
|
||||
})),
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
ui.alert(e.message || String(e));
|
||||
return;
|
||||
}
|
||||
if (result.error) {
|
||||
ui.alert(result.error);
|
||||
} else {
|
||||
remote.connected = true;
|
||||
// persist the chosen name
|
||||
saveLocalRemote();
|
||||
return;
|
||||
}
|
||||
// Remember the local<->upstream match for this browser session
|
||||
setMatchedConnected(remote, true);
|
||||
saveLocalRemotes();
|
||||
}
|
||||
|
||||
async function disconnectLocalRemote(remote) {
|
||||
let result = await backend.sendAction('remote-disconnect', {
|
||||
remotename: remote.name
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
ui.alert(result.error);
|
||||
try {
|
||||
const result = await backend.sendAction('remote-disconnect', { remotename: remote.name });
|
||||
if (result.error) ui.alert(result.error);
|
||||
} catch (e) {
|
||||
// Socket gone: the upstream connection is dead either way
|
||||
}
|
||||
// always "shown" disconnected
|
||||
remote.connected = false;
|
||||
setMatchedConnected(remote, false);
|
||||
}
|
||||
|
||||
function removeLocalRemote(remote) {
|
||||
if (remote.connected) disconnectLocalRemote(remote);
|
||||
const index = localRemoteList.value.indexOf(remote);
|
||||
if (index > -1) localRemoteList.value.splice(index, 1);
|
||||
saveLocalRemote();
|
||||
const index = localRemotes.value.indexOf(remote);
|
||||
if (index > -1) localRemotes.value.splice(index, 1);
|
||||
saveLocalRemotes();
|
||||
}
|
||||
|
||||
function addFunctionToRemote(remote) {
|
||||
if (!remote.functions) remote.functions = [];
|
||||
remote.functions.push({
|
||||
id: nextId(),
|
||||
name: `function_${Date.now()}`,
|
||||
description: '',
|
||||
arguments: [{ name: 'arg0', type: 'string' }],
|
||||
body: '// Return your result object\nreturn {};\n'
|
||||
});
|
||||
saveLocalRemote();
|
||||
saveLocalRemotes();
|
||||
}
|
||||
|
||||
function removeFunctionFromRemote(remote, index) {
|
||||
remote.functions.splice(index, 1);
|
||||
saveLocalRemote();
|
||||
function removeFunctionFromRemote(remote, fn) {
|
||||
const index = remote.functions.indexOf(fn);
|
||||
if (index > -1) remote.functions.splice(index, 1);
|
||||
saveLocalRemotes();
|
||||
}
|
||||
|
||||
function addArgument(fn) {
|
||||
const idx = fn.arguments.length;
|
||||
fn.arguments.push({ name: `arg${idx}`, type: 'string' });
|
||||
saveLocalRemote();
|
||||
fn.arguments.push({ name: `arg${fn.arguments.length}`, type: 'string' });
|
||||
saveLocalRemotes();
|
||||
}
|
||||
|
||||
function removeArgument(fn, argIdx) {
|
||||
fn.arguments.splice(argIdx, 1);
|
||||
saveLocalRemote();
|
||||
}
|
||||
|
||||
loadLocalRemoteList();
|
||||
|
||||
// ---- UI State and Helpers ---- //
|
||||
const fnExpandedSet = ref(new Set());
|
||||
|
||||
function toggleFnExpanded(index, fIdx) {
|
||||
const key = `${index}-${fIdx}`;
|
||||
const newSet = new Set(fnExpandedSet.value);
|
||||
if (newSet.has(key)) newSet.delete(key); else newSet.add(key);
|
||||
fnExpandedSet.value = newSet;
|
||||
saveLocalRemotes();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -267,139 +392,140 @@ function toggleFnExpanded(index, fIdx) {
|
||||
<main>
|
||||
<TitleBar><h1>Remotes</h1></TitleBar>
|
||||
|
||||
<!-- ============================== -->
|
||||
<!-- Active Function Call Panel -->
|
||||
<!-- ============================== -->
|
||||
<section v-if="activeRemoteName && activeFunction" class="section">
|
||||
<div class="section-header">
|
||||
<h2>Invoke: {{ activeFunction.name }}</h2>
|
||||
<section class="section">
|
||||
<!-- Unified table: connected remotes first, local remotes after -->
|
||||
<div class="rt">
|
||||
<div class="rt-row rt-head">
|
||||
<div class="col-arrow"></div>
|
||||
<div class="col-name">Remote</div>
|
||||
<div class="col-status">Status</div>
|
||||
<div class="col-options">Options</div>
|
||||
</div>
|
||||
|
||||
<div class="call-layout">
|
||||
<div class="call-inputs">
|
||||
<template v-for="row in remoteRows" :key="rowKey(row)">
|
||||
<!-- Remote row -->
|
||||
<div class="rt-row rt-remote" :class="{ open: expandedRemotes.has(row.key) }">
|
||||
<div class="col-arrow toggle" @click="toggleRemote(row.key)">
|
||||
{{ expandedRemotes.has(row.key) ? '▼' : '▶' }}
|
||||
</div>
|
||||
<div class="col-name">
|
||||
<input
|
||||
v-if="row.kind === 'local' && !row.remote.connected"
|
||||
type="text"
|
||||
v-model="row.remote.name"
|
||||
placeholder="Remote name..."
|
||||
class="name-input"
|
||||
@change="saveLocalRemotes"
|
||||
/>
|
||||
<strong v-else>{{ row.remote.name }}</strong>
|
||||
</div>
|
||||
<div class="col-status">
|
||||
<span class="status-dot" :class="{ active: isRowConnected(row) }"></span>
|
||||
<span class="status-text">{{ isRowConnected(row) ? 'Connected' : 'Disconnected' }}</span>
|
||||
</div>
|
||||
<div class="col-options">
|
||||
<template v-if="row.kind === 'local'">
|
||||
<button v-if="!row.remote.connected" class="btn btn-primary btn-sm" @click="connectLocalRemote(row.remote)">Connect</button>
|
||||
<button v-else class="btn btn-secondary btn-sm" @click="disconnectLocalRemote(row.remote)">Disconnect</button>
|
||||
<button class="btn btn-danger btn-sm" @click="removeLocalRemote(row.remote)">Delete</button>
|
||||
</template>
|
||||
<span v-else class="muted">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded: function rows -->
|
||||
<template v-if="expandedRemotes.has(row.key)">
|
||||
<div class="rt-row rt-fnhead">
|
||||
<div class="col-arrow"></div>
|
||||
<div class="col-status muted">Functions ({{ functionsOf(row).length }})</div>
|
||||
<div class="col-options">
|
||||
<button
|
||||
v-if="isEditable(row)"
|
||||
class="btn btn-primary btn-xs"
|
||||
@click="addFunctionToRemote(row.remote)"
|
||||
>+ Add Function</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="functionsOf(row).length === 0" class="rt-row rt-fn empty-row">
|
||||
<div class="col-arrow"></div>
|
||||
<div class="col-status muted">No functions.</div>
|
||||
<div class="col-options"></div>
|
||||
</div>
|
||||
|
||||
<template v-for="fn in functionsOf(row)" :key="fnKey(row, fn)">
|
||||
<!-- Function row -->
|
||||
<div class="rt-row rt-fn">
|
||||
<div class="col-arrow toggle" @click="toggleFn(row, fn)">
|
||||
{{ expandedFns.has(fnStateKey(row, fn)) ? '▼' : '▶' }}
|
||||
</div>
|
||||
<div class="col-name">
|
||||
<input
|
||||
v-if="isEditable(row)"
|
||||
type="text"
|
||||
v-model="fn.name"
|
||||
class="name-input mono"
|
||||
@change="saveLocalRemotes"
|
||||
/>
|
||||
<span v-else class="mono">{{ fn.name }}</span>
|
||||
</div>
|
||||
<div class="col-status">
|
||||
<input
|
||||
v-if="isEditable(row)"
|
||||
type="text"
|
||||
v-model="fn.description"
|
||||
placeholder="Description..."
|
||||
class="name-input"
|
||||
@change="saveLocalRemotes"
|
||||
/>
|
||||
<span v-else class="muted">{{ fn.description }}</span>
|
||||
</div>
|
||||
<div class="col-options">
|
||||
<button
|
||||
v-if="isEditable(row)"
|
||||
class="btn btn-danger btn-xs"
|
||||
@click="removeFunctionFromRemote(row.remote, fn)"
|
||||
>Remove</button>
|
||||
<span v-else class="muted">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded function detail: left = arguments, right = output/body -->
|
||||
<div v-if="expandedFns.has(fnStateKey(row, fn))" class="rt-row rt-fndetail">
|
||||
<div class="col-arrow"></div>
|
||||
<div class="detail">
|
||||
<!-- Callable mode (connected remotes) -->
|
||||
<template v-if="!isEditable(row)">
|
||||
<div class="detail-left">
|
||||
<label>Arguments (JSON)</label>
|
||||
<textarea
|
||||
v-model="activeArgsJson"
|
||||
rows="6"
|
||||
v-model="callStateFor(row, fn).args"
|
||||
placeholder='{"param": "value"}'
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
<button class="btn btn-primary" @click="doCall">Execute Call</button>
|
||||
<button class="btn" @click="selectFunction(null, null)">Cancel</button>
|
||||
</div>
|
||||
|
||||
<div class="call-output">
|
||||
<div v-if="callResult !== null" class="output-block success">
|
||||
<strong>Result</strong>
|
||||
<pre>{{ JSON.stringify(callResult, null, 2) }}</pre>
|
||||
</div>
|
||||
<div v-if="callError" class="output-block error">
|
||||
<strong>Error</strong>
|
||||
<pre>{{ callError }}</pre>
|
||||
</div>
|
||||
<div v-if="callResult === null && callError === ''" class="output-placeholder">
|
||||
Call will appear here
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ======================== -->
|
||||
<!-- External Remotes Section -->
|
||||
<!-- ======================== -->
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>Remotes</h2>
|
||||
<span class="count-badge">{{ remotes.length }} registered</span>
|
||||
</div>
|
||||
|
||||
<div v-if="remotes.length === 0" class="empty-state">No remotes connected.</div>
|
||||
|
||||
<div v-for="remote in remotes" :key="remote.name" class="card">
|
||||
<div class="card-header" @click="toggleRemote(remote.name)">
|
||||
<div class="header-left">
|
||||
<strong>{{ remote.name }}</strong>
|
||||
<span class="status-dot" :class="{ active: remote.connected }"></span>
|
||||
<span class="status-text">{{ remote.connected ? 'Connected' : 'Disconnected' }}</span>
|
||||
</div>
|
||||
<span class="expand-icon">{{ expandedRemoteIds.has(remote.name) ? '▼' : '▶' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="expandedRemoteIds.has(remote.name)" class="card-body">
|
||||
<div class="functions-label">Functions (click to activate):</div>
|
||||
<div class="func-grid">
|
||||
<button
|
||||
v-for="(fn, idx) in remote.functions"
|
||||
:key="idx"
|
||||
class="func-card"
|
||||
:class="{ active: activeRemoteName === remote.name && activeFunction?.name === fn.name }"
|
||||
@click.stop="selectFunction(remote, fn)"
|
||||
>
|
||||
<div class="func-name">{{ fn.name }}</div>
|
||||
<div class="func-desc">{{ fn.description }}</div>
|
||||
<button class="btn btn-primary btn-sm" :disabled="callStateFor(row, fn).busy" @click="doCall(row, fn)">
|
||||
{{ callStateFor(row, fn).busy ? 'Calling...' : 'Execute Call' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="detail-right">
|
||||
<label>Output</label>
|
||||
<pre v-if="callStateFor(row, fn).result !== null" class="output success">{{ JSON.stringify(callStateFor(row, fn).result, null, 2) }}</pre>
|
||||
<pre v-else-if="callStateFor(row, fn).error" class="output error">{{ callStateFor(row, fn).error }}</pre>
|
||||
<div v-else class="output placeholder">Call output will appear here</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ========================== -->
|
||||
<!-- Local Remotes Section -->
|
||||
<!-- ========================== -->
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>Local Remotes</h2>
|
||||
</div>
|
||||
<div class="remote-list">
|
||||
<div v-for="(remote, index) in localRemoteList" :key="index" class="card">
|
||||
<!-- Name / Connect bar -->
|
||||
<div class="card-header">
|
||||
<div class="view-mode" style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<input
|
||||
v-if="!remote.connected"
|
||||
type="text"
|
||||
v-model="remote.name"
|
||||
placeholder="Enter remote name..."
|
||||
class="input-field"
|
||||
/>
|
||||
<span v-else><strong>{{ remote.name }}</strong></span>
|
||||
<template v-if="!remote.connected">
|
||||
<button class="btn btn-primary" @click="connectLocalRemote(remote)">Connect</button>
|
||||
</template>
|
||||
|
||||
<!-- Editable mode (disconnected local remotes) -->
|
||||
<template v-else>
|
||||
<span class="status-dot active"></span>
|
||||
<button class="btn btn-secondary" @click="disconnectLocalRemote(remote)">Disconnect</button>
|
||||
</template>
|
||||
<button class="btn btn-danger" @click="removeLocalRemote(remote)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded: function definitions -->
|
||||
<div class="card-body">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem;">
|
||||
<strong>Functions</strong>
|
||||
<button class="btn btn-primary" @click="addFunctionToRemote(remote)">+ Add Function</button>
|
||||
</div>
|
||||
<div v-if="!remote.functions || remote.functions.length === 0" style="color:var(--color-text-mute);font-size:0.85em;">No functions yet.</div>
|
||||
<div v-for="(fn, fIdx) in remote.functions" :key="fIdx" class="func-def-block">
|
||||
<div class="func-def-header">
|
||||
<input type="text" v-model="fn.name" class="input-field"/>
|
||||
<button class="btn btn-danger btn-sm" @click="removeFunctionFromRemote(remote, fIdx)">Remove</button>
|
||||
<span style="cursor:pointer;" @click.stop="toggleFnExpanded(index, fIdx)">
|
||||
{{ fnExpandedSet.has(`${index}-${fIdx}`) ? 'collapse' : 'expand' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Arguments table -->
|
||||
<div class="func-def-row">
|
||||
<div class="args-table">
|
||||
<table>
|
||||
<div class="detail-left">
|
||||
<label>Arguments</label>
|
||||
<table class="args-table">
|
||||
<thead><tr><th>Name</th><th>Type</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="(arg, aIdx) in fn.arguments" :key="aIdx">
|
||||
<td><input type="text" v-model="arg.name" class="input-small" :disabled="remote.connected" /></td>
|
||||
<td><input type="text" v-model="arg.name" class="input-small" @change="saveLocalRemotes" /></td>
|
||||
<td>
|
||||
<select v-model="arg.type" class="input-small" :disabled="remote.connected">
|
||||
<select v-model="arg.type" class="input-small" @change="saveLocalRemotes">
|
||||
<option value="string">string</option>
|
||||
<option value="number">number</option>
|
||||
<option value="boolean">boolean</option>
|
||||
@@ -411,81 +537,89 @@ function toggleFnExpanded(index, fIdx) {
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<button class="btn btn-secondary btn-xs" @click="addArgument(fn)" :disabled="remote.connected">+ Arg</button>
|
||||
<button class="btn btn-secondary btn-xs" @click="addArgument(fn)">+ Arg</button>
|
||||
</div>
|
||||
<div class="detail-right">
|
||||
<label>Function Body (JavaScript)</label>
|
||||
<textarea
|
||||
v-model="fn.body"
|
||||
placeholder="return {};"
|
||||
spellcheck="false"
|
||||
class="js-editor"
|
||||
@change="saveLocalRemotes"
|
||||
></textarea>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- JS body -->
|
||||
<div v-if="fnExpandedSet.has(`${index}-${fIdx}`)" class="func-def-row">
|
||||
<textarea v-model="fn.body" rows="4" placeholder="return {};" spellcheck="false" class="js-editor" :readonly="remote.connected"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="remoteRows.length === 0" class="empty-state">No remotes.</div>
|
||||
<div class="table-footer">
|
||||
<button class="btn btn-primary" @click="createLocalRemote">+ Create Local Remote</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ---------- Shared utilities ---------- */
|
||||
.section { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--color-border); }
|
||||
.section-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; }
|
||||
.count-badge { font-size: 0.8em; color: var(--color-text-mute); background: var(--color-background-muted); padding: 2px 8px; border-radius: 10px; }
|
||||
/* ---------- Section ---------- */
|
||||
.section { margin-top: 1.5rem; }
|
||||
.empty-state { padding: 1em; color: var(--color-text-mute); }
|
||||
.muted { color: var(--color-text-mute); font-size: 0.85em; }
|
||||
.mono { font-family: monospace; font-size: 0.9em; }
|
||||
|
||||
/* ---------- Card layout ---------- */
|
||||
.card { border: 1px solid var(--color-border); border-radius: var(--border-radius); margin-bottom: 0.75rem; background: var(--color-background-soft); }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem; cursor: pointer; }
|
||||
.header-left { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #9ca3af; }
|
||||
/* ---------- Table-like layout ---------- */
|
||||
.rt { border: 1px solid var(--color-border); border-radius: var(--border-radius); background: var(--color-background-soft); overflow: hidden; }
|
||||
.rt-row { display: grid; grid-template-columns: 2.5rem minmax(10rem, 1.3fr) minmax(10rem, 1.7fr) minmax(9rem, auto); align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--color-border); }
|
||||
.rt-row:last-child { border-bottom: none; }
|
||||
.rt-head { font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-mute); background: var(--color-background-muted); }
|
||||
.col-arrow { text-align: center; color: var(--color-text-mute); }
|
||||
.col-arrow.toggle { cursor: pointer; user-select: none; }
|
||||
.col-arrow.toggle:hover { color: var(--color-text-heading); }
|
||||
.col-status, .col-options { display: flex; align-items: center; gap: 0.4rem; min-width: 0; }
|
||||
|
||||
/* Remote rows */
|
||||
.rt-remote { background: var(--color-background-soft); }
|
||||
.rt-remote.open { border-bottom-style: dashed; }
|
||||
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #9ca3af; flex: none; }
|
||||
.status-dot.active { background: #22c55e; box-shadow: 0 0 4px rgba(34,197,94,0.5); }
|
||||
.status-text { font-size: 0.85em; color: var(--color-text-mute); }
|
||||
.expand-icon { color: var(--color-text-mute); }
|
||||
.card-body { padding: 0 1rem 1rem; }
|
||||
.functions-label { font-weight: bold; font-size: 0.85em; color: var(--color-text-heading); margin-bottom: 0.5rem; }
|
||||
.name-input { width: 100%; min-width: 0; padding: 0.35rem 0.5rem; border: 1px solid var(--color-border); border-radius: var(--border-radius); background: var(--color-background); }
|
||||
|
||||
/* ---------- Function grid ---------- */
|
||||
.func-grid { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.func-card { text-align: left; border: 1px solid var(--color-border); border-radius: var(--border-radius); padding: 0.5rem 0.75rem; cursor: pointer; background: var(--color-background); transition: all 0.15s ease; }
|
||||
.func-card:hover { background: var(--color-background-hover); }
|
||||
.func-card.active { border-color: var(--color-primary); background: var(--color-primary-light, rgba(79,70,229,0.1)); }
|
||||
.func-name { font-weight: bold; font-family: monospace; font-size: 0.9em; }
|
||||
.func-desc { font-size: 0.8em; color: var(--color-text-mute); margin-top: 2px; }
|
||||
/* Function rows (indented under their remote) */
|
||||
.rt-fnhead, .rt-fn, .rt-fndetail { padding-left: 1.5rem; background: var(--color-background); }
|
||||
.rt-fn .col-arrow { color: var(--color-text-mute); }
|
||||
.rt-fn.empty-row { color: var(--color-text-mute); font-style: italic; }
|
||||
|
||||
/* ---------- Call panel ---------- */
|
||||
.call-layout { display: flex; gap: 1rem; }
|
||||
.call-inputs, .call-output { flex: 1; }
|
||||
.call-inputs label { display: block; font-weight: bold; margin-bottom: 0.25rem; }
|
||||
.call-inputs textarea { width: 100%; padding: 0.5rem; border: 1px solid var(--color-border); border-radius: var(--border-radius); resize: vertical; font-family: monospace; font-size: 0.85em; margin-bottom: 0.5rem; }
|
||||
.output-block { margin-bottom: 0.75rem; }
|
||||
.output-block.success pre { background: var(--color-success-bg, #3c5c47); padding: 0.5rem; border-radius: var(--border-radius); overflow-x: auto; }
|
||||
.output-block.error pre { background: var(--color-error-bg, #5e4242); padding: 0.5rem; border-radius: var(--border-radius); overflow-x: auto; }
|
||||
.output-placeholder { padding: 1em; color: var(--color-text-mute); font-style: italic; }
|
||||
/* Function detail: arguments left, output right, at least 10em tall */
|
||||
.rt-fndetail { display: block; }
|
||||
.rt-fndetail .detail { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; min-height: 10em; }
|
||||
.detail-left, .detail-right { display: flex; flex-direction: column; min-width: 0; }
|
||||
.detail label { font-weight: bold; font-size: 0.85em; margin-bottom: 0.35rem; }
|
||||
.detail textarea { width: 100%; flex: 1; min-height: 8em; padding: 0.5rem; border: 1px solid var(--color-border); border-radius: var(--border-radius); resize: vertical; font-family: monospace; font-size: 0.85em; box-sizing: border-box; }
|
||||
.detail textarea { background: var(--color-background-soft); }
|
||||
.detail .btn { margin-top: 0.4rem; align-self: flex-start; }
|
||||
.output { flex: 1; min-height: 8em; margin: 0; padding: 0.5rem; border-radius: var(--border-radius); overflow: auto; font-family: monospace; font-size: 0.85em; white-space: pre-wrap; }
|
||||
.output.success { background: var(--color-success-bg, #3c5c47); }
|
||||
.output.error { background: var(--color-error-bg, #5e4242); }
|
||||
.output.placeholder { border: 1px dashed var(--color-border); color: var(--color-text-mute); font-style: italic; display: flex; align-items: center; justify-content: center; }
|
||||
|
||||
/* ---------- Buttons (shared across sections) ---------- */
|
||||
/* ---------- Arguments editor ---------- */
|
||||
.args-table { width: 100%; border-collapse: collapse; font-size: 0.85em; margin-bottom: 0.4rem; }
|
||||
.args-table th { text-align: left; color: var(--color-text-mute); padding: 0.2em 0.3em; border-bottom: 1px solid var(--color-border); }
|
||||
.args-table td { padding: 0.2em 0.3em; }
|
||||
.input-small { width: 100%; padding: 0.2em 0.4em; border: 1px solid var(--color-border); border-radius: 4px; font-size: 0.85em; box-sizing: border-box; background: var(--color-background); }
|
||||
|
||||
/* ---------- Misc ---------- */
|
||||
.table-footer { margin-top: 0.75rem; }
|
||||
.btn { padding: 0.5em 1em; border-radius: var(--border-radius); border: none; cursor: pointer; font-weight: 500; }
|
||||
.btn-primary { background: var(--color-primary, #4f46e5); color: #fff; }
|
||||
.btn-secondary { background: var(--color-muted, #9ca3af); color: #fff; }
|
||||
.btn-danger { background: var(--color-error, #ef4444); color: #fff; }
|
||||
|
||||
/* ---------- Local remotes ---------- */
|
||||
.remote-list { margin-top: 1rem; }
|
||||
.remote-entry { display: flex; align-items: center; gap: 1rem; padding: 1rem; border: 1px solid var(--color-border); border-radius: var(--border-radius); margin-bottom: 1rem; background-color: var(--color-background-soft); }
|
||||
.view-mode { display: flex; align-items: center; gap: 1rem; flex-grow: 1; }
|
||||
.status-text { font-weight: bold; }
|
||||
.input-field { flex-grow: 1; padding: 0.5rem; border: 1px solid var(--color-border); border-radius: var(--border-radius); }
|
||||
.func-def-block { border: 1px solid var(--color-border); border-radius: var(--border-radius); margin-bottom: 0.5rem; padding: 0.5rem; background: var(--color-background); }
|
||||
.func-def-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.35rem; font-family: monospace; }
|
||||
.func-def-row { margin-bottom: 0.25rem; }
|
||||
.args-table table { width: 100%; border-collapse: collapse; font-size: 0.85em; }
|
||||
.args-table th { text-align: left; color: var(--color-text-mute); padding: 0.2em 0.3em; border-bottom: 1px solid var(--color-border); }
|
||||
.args-table td { padding: 0.2em 0.3em; }
|
||||
.args-table input[type="text"] { width: 6em; padding: 0.2em 0.4em; border: 1px solid var(--color-border); border-radius: 4px; font-size: 0.85em; }
|
||||
.args-table select { padding: 0.2em 0.4em; border: 1px solid var(--color-border); border-radius: 4px; font-size: 0.85em; }
|
||||
.js-editor { width: 100%; min-height: 3em; max-height: 30em; max-width: 100%; resize: both; padding: 0.5rem; border: 1px solid var(--color-border); border-radius: var(--border-radius); font-family: monospace; font-size: 0.85em; box-sizing: border-box; }
|
||||
.btn-sm { padding: 0.25em 0.6em; font-size: 0.8em; }
|
||||
.btn-xs { padding: 0.15em 0.4em; font-size: 0.75em; }
|
||||
.input-small { padding: 0.2em 0.4em; border: 1px solid var(--color-border); border-radius: 4px; font-size: 0.85em; }
|
||||
</style>
|
||||
Reference in new issue
Block a user