session maangement updates
This commit is contained in:
@@ -97,6 +97,19 @@ export interface TerminalContent {
|
|||||||
layout?: 'desktop' | 'landscape' | 'portrait';
|
layout?: 'desktop' | 'landscape' | 'portrait';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExternalTmuxSession {
|
||||||
|
name: string;
|
||||||
|
'working-dir': string;
|
||||||
|
workingDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportedSession {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
'working-dir': string;
|
||||||
|
workingDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
private baseUrl: string;
|
private baseUrl: string;
|
||||||
|
|
||||||
@@ -197,6 +210,18 @@ class ApiClient {
|
|||||||
body: JSON.stringify({ mode })
|
body: JSON.stringify({ mode })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// External tmux sessions
|
||||||
|
async getExternalTmuxSessions(): Promise<ExternalTmuxSession[]> {
|
||||||
|
return this.request<ExternalTmuxSession[]>('/tmux/external');
|
||||||
|
}
|
||||||
|
|
||||||
|
async importTmuxSession(name: string): Promise<ImportedSession> {
|
||||||
|
return this.request<ImportedSession>('/tmux/import', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name })
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = new ApiClient();
|
export const api = new ApiClient();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Message, PermissionDenial, ToolInput, Session } from '$lib/api';
|
import type { Message, PermissionDenial, ToolInput, Session } from '$lib/api';
|
||||||
import { onMount, afterUpdate, tick } from 'svelte';
|
import { afterUpdate, tick } from 'svelte';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import FileDiff from './FileDiff.svelte';
|
import FileDiff from './FileDiff.svelte';
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
|
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let collapsedMessages: Set<string> = new Set();
|
let collapsedMessages: Set<string> = new Set();
|
||||||
|
let hasScrolledInitially = false;
|
||||||
|
|
||||||
const COLLAPSE_THRESHOLD = 5; // show toggle if more than this many lines
|
const COLLAPSE_THRESHOLD = 5; // show toggle if more than this many lines
|
||||||
|
|
||||||
@@ -31,6 +32,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scroll to bottom when messages first load
|
||||||
|
$: if (messages.length > 0 && container && !hasScrolledInitially) {
|
||||||
|
hasScrolledInitially = true;
|
||||||
|
// Use tick + setTimeout to ensure DOM is fully rendered
|
||||||
|
tick().then(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
scrollToBottom();
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function toggleCollapse(id: string) {
|
function toggleCollapse(id: string) {
|
||||||
if (collapsedMessages.has(id)) {
|
if (collapsedMessages.has(id)) {
|
||||||
collapsedMessages.delete(id);
|
collapsedMessages.delete(id);
|
||||||
@@ -59,16 +71,6 @@
|
|||||||
collapsedMessages = collapsedMessages; // trigger reactivity
|
collapsedMessages = collapsedMessages; // trigger reactivity
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
await tick();
|
|
||||||
// Use double requestAnimationFrame to ensure DOM is fully laid out before scrolling
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
scrollToBottom();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterUpdate(() => {
|
afterUpdate(() => {
|
||||||
// Use requestAnimationFrame for consistent scroll behavior
|
// Use requestAnimationFrame for consistent scroll behavior
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
let lastHash: number | null = null;
|
let lastHash: number | null = null;
|
||||||
let lastFrameId: number | null = null; // Track frame ordering to prevent out-of-order updates
|
let lastFrameId: number | null = null; // Track frame ordering to prevent out-of-order updates
|
||||||
let initialLoadComplete = false; // Track whether initial load has happened
|
let initialLoadComplete = false; // Track whether initial load has happened
|
||||||
let screenMode: 'desktop' | 'landscape' | 'portrait' = 'landscape';
|
let screenMode: 'fullscreen' | 'desktop' | 'landscape' | 'portrait' = 'landscape';
|
||||||
let resizing = false;
|
let resizing = false;
|
||||||
let inputBuffer = '';
|
let inputBuffer = '';
|
||||||
let batchTimeout: ReturnType<typeof setTimeout> | null = null;
|
let batchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -112,23 +112,25 @@
|
|||||||
case 'diff':
|
case 'diff':
|
||||||
// Partial update - apply changes to specific lines
|
// Partial update - apply changes to specific lines
|
||||||
if (diff.changes) {
|
if (diff.changes) {
|
||||||
|
// Create a working copy to avoid in-place mutation issues
|
||||||
|
let newLines = [...terminalLines];
|
||||||
// Ensure array is long enough
|
// Ensure array is long enough
|
||||||
while (terminalLines.length < totalLines) {
|
while (newLines.length < totalLines) {
|
||||||
terminalLines.push('');
|
newLines.push('');
|
||||||
}
|
}
|
||||||
// Truncate if needed
|
// Truncate if needed
|
||||||
if (terminalLines.length > totalLines) {
|
if (newLines.length > totalLines) {
|
||||||
terminalLines = terminalLines.slice(0, totalLines);
|
newLines = newLines.slice(0, totalLines);
|
||||||
}
|
}
|
||||||
// Apply changes
|
// Apply changes
|
||||||
for (const [lineNumStr, content] of Object.entries(diff.changes)) {
|
for (const [lineNumStr, content] of Object.entries(diff.changes)) {
|
||||||
const lineNum = parseInt(lineNumStr, 10);
|
const lineNum = parseInt(lineNumStr, 10);
|
||||||
if (lineNum >= 0 && lineNum < totalLines) {
|
if (lineNum >= 0 && lineNum < totalLines) {
|
||||||
terminalLines[lineNum] = content ?? '';
|
newLines[lineNum] = content ?? '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Trigger reactivity
|
// Assign new array reference to trigger reactivity
|
||||||
terminalLines = terminalLines;
|
terminalLines = newLines;
|
||||||
}
|
}
|
||||||
lastHash = diff.hash ?? null;
|
lastHash = diff.hash ?? null;
|
||||||
if (frameId !== undefined) lastFrameId = frameId;
|
if (frameId !== undefined) lastFrameId = frameId;
|
||||||
@@ -423,8 +425,10 @@
|
|||||||
disabled={!isAlive}
|
disabled={!isAlive}
|
||||||
class="px-1.5 py-0.5 bg-cyan-600/80 hover:bg-cyan-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-white transition-colors"
|
class="px-1.5 py-0.5 bg-cyan-600/80 hover:bg-cyan-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-white transition-colors"
|
||||||
>⇤</button>
|
>⇤</button>
|
||||||
<!-- Text zoom -->
|
<!-- Screen size selector (pushed right on wider screens) -->
|
||||||
<span class="w-px h-3 bg-zinc-700 ml-auto"></span>
|
<span class="w-px h-3 bg-zinc-700 ml-auto"></span>
|
||||||
|
<!-- Text zoom (hidden on mobile portrait, visible on landscape/desktop) -->
|
||||||
|
<div class="hidden landscape:flex sm:flex items-center gap-0.5">
|
||||||
<button
|
<button
|
||||||
on:click={zoomOut}
|
on:click={zoomOut}
|
||||||
disabled={fontScale <= fontScales[0]}
|
disabled={fontScale <= fontScales[0]}
|
||||||
@@ -438,8 +442,8 @@
|
|||||||
class="px-1 py-0.5 bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-zinc-200 transition-colors"
|
class="px-1 py-0.5 bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-zinc-200 transition-colors"
|
||||||
title="Zoom in"
|
title="Zoom in"
|
||||||
>+</button>
|
>+</button>
|
||||||
<!-- Screen size selector -->
|
|
||||||
<span class="w-px h-3 bg-zinc-700"></span>
|
<span class="w-px h-3 bg-zinc-700"></span>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
on:click={() => resizeScreen('portrait')}
|
on:click={() => resizeScreen('portrait')}
|
||||||
disabled={resizing}
|
disabled={resizing}
|
||||||
@@ -454,7 +458,7 @@
|
|||||||
on:click={() => resizeScreen('landscape')}
|
on:click={() => resizeScreen('landscape')}
|
||||||
disabled={resizing}
|
disabled={resizing}
|
||||||
class="px-1 py-0.5 rounded-sm text-[10px] font-mono transition-colors {screenMode === 'landscape' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'} disabled:opacity-50"
|
class="px-1 py-0.5 rounded-sm text-[10px] font-mono transition-colors {screenMode === 'landscape' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'} disabled:opacity-50"
|
||||||
title="Landscape (100x30)"
|
title="Landscape (65x24)"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-2.5 w-3 inline-block" fill="none" viewBox="0 0 16 10" stroke="currentColor" stroke-width="1.5">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-2.5 w-3 inline-block" fill="none" viewBox="0 0 16 10" stroke="currentColor" stroke-width="1.5">
|
||||||
<rect x="1" y="1" width="14" height="8" rx="1" />
|
<rect x="1" y="1" width="14" height="8" rx="1" />
|
||||||
@@ -464,11 +468,21 @@
|
|||||||
on:click={() => resizeScreen('desktop')}
|
on:click={() => resizeScreen('desktop')}
|
||||||
disabled={resizing}
|
disabled={resizing}
|
||||||
class="px-1 py-0.5 rounded-sm text-[10px] font-mono transition-colors {screenMode === 'desktop' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'} disabled:opacity-50"
|
class="px-1 py-0.5 rounded-sm text-[10px] font-mono transition-colors {screenMode === 'desktop' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'} disabled:opacity-50"
|
||||||
title="Desktop (180x50)"
|
title="Split screen (100x40)"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3.5 inline-block" fill="none" viewBox="0 0 20 16" stroke="currentColor" stroke-width="1.5">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3.5 inline-block" fill="none" viewBox="0 0 20 14" stroke="currentColor" stroke-width="1.5">
|
||||||
<rect x="1" y="1" width="18" height="11" rx="1" />
|
<rect x="1" y="1" width="8" height="12" rx="1" />
|
||||||
<path d="M6 14h8M10 12v2" />
|
<rect x="11" y="1" width="8" height="12" rx="1" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
on:click={() => resizeScreen('fullscreen')}
|
||||||
|
disabled={resizing}
|
||||||
|
class="px-1 py-0.5 rounded-sm text-[10px] font-mono transition-colors {screenMode === 'fullscreen' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'} disabled:opacity-50"
|
||||||
|
title="Fullscreen (180x60)"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-4 inline-block" fill="none" viewBox="0 0 22 14" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<rect x="1" y="1" width="20" height="12" rx="1" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<span class="w-px h-3 bg-zinc-700"></span>
|
<span class="w-px h-3 bg-zinc-700"></span>
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { sessions, sortedSessions, processingSessions } from '$lib/stores/sessions';
|
import { sessions, sortedSessions, processingSessions } from '$lib/stores/sessions';
|
||||||
import type { Session } from '$lib/api';
|
import { api, type Session, type ExternalTmuxSession } from '$lib/api';
|
||||||
import SessionCard from '$lib/components/SessionCard.svelte';
|
import SessionCard from '$lib/components/SessionCard.svelte';
|
||||||
import PushToggle from '$lib/components/PushToggle.svelte';
|
import PushToggle from '$lib/components/PushToggle.svelte';
|
||||||
|
|
||||||
let showNewSessionMenu = false;
|
let showNewSessionMenu = false;
|
||||||
|
let showImportMenu = false;
|
||||||
let creating = false;
|
let creating = false;
|
||||||
|
let importing = false;
|
||||||
|
let externalSessions: ExternalTmuxSession[] = [];
|
||||||
|
let loadingExternal = false;
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
await sessions.load();
|
await sessions.load();
|
||||||
@@ -30,13 +34,47 @@
|
|||||||
await sessions.delete(id);
|
await sessions.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadExternalSessions() {
|
||||||
|
loadingExternal = true;
|
||||||
|
try {
|
||||||
|
externalSessions = await api.getExternalTmuxSessions();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load external sessions:', e);
|
||||||
|
externalSessions = [];
|
||||||
|
} finally {
|
||||||
|
loadingExternal = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleImportMenu() {
|
||||||
|
showImportMenu = !showImportMenu;
|
||||||
|
showNewSessionMenu = false;
|
||||||
|
if (showImportMenu) {
|
||||||
|
await loadExternalSessions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importSession(name: string) {
|
||||||
|
importing = true;
|
||||||
|
showImportMenu = false;
|
||||||
|
try {
|
||||||
|
const imported = await api.importTmuxSession(name);
|
||||||
|
await sessions.load();
|
||||||
|
await goto(`/session/${imported.id}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to import session:', e);
|
||||||
|
} finally {
|
||||||
|
importing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Spiceflow</title>
|
<title>Spiceflow</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<svelte:window on:click={() => (showNewSessionMenu = false)} />
|
<svelte:window on:click={() => { showNewSessionMenu = false; showImportMenu = false; }} />
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="flex-shrink-0 border-b border-zinc-800 px-4 py-3">
|
<header class="flex-shrink-0 border-b border-zinc-800 px-4 py-3">
|
||||||
@@ -49,9 +87,64 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<PushToggle />
|
<PushToggle />
|
||||||
|
|
||||||
|
<!-- Import tmux session button -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<button
|
<button
|
||||||
on:click|stopPropagation={() => (showNewSessionMenu = !showNewSessionMenu)}
|
on:click|stopPropagation={toggleImportMenu}
|
||||||
|
disabled={importing}
|
||||||
|
class="btn btn-secondary p-2"
|
||||||
|
title="Import tmux session"
|
||||||
|
>
|
||||||
|
{#if importing}
|
||||||
|
<svg class="h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if showImportMenu}
|
||||||
|
<div
|
||||||
|
class="absolute right-0 top-full mt-1 bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl z-50 min-w-[200px] overflow-hidden"
|
||||||
|
>
|
||||||
|
<div class="px-4 py-2 border-b border-zinc-700">
|
||||||
|
<span class="text-xs text-zinc-400 font-medium">Import tmux session</span>
|
||||||
|
</div>
|
||||||
|
{#if loadingExternal}
|
||||||
|
<div class="px-4 py-3 text-sm text-zinc-500 flex items-center gap-2">
|
||||||
|
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
{:else if externalSessions.length === 0}
|
||||||
|
<div class="px-4 py-3 text-sm text-zinc-500">
|
||||||
|
No external tmux sessions
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each externalSessions as session}
|
||||||
|
<button
|
||||||
|
on:click={() => importSession(session.name)}
|
||||||
|
class="w-full px-4 py-2.5 text-left text-sm hover:bg-zinc-700 flex items-center gap-2 transition-colors"
|
||||||
|
>
|
||||||
|
<span class="w-2 h-2 rounded-full bg-cyan-500"></span>
|
||||||
|
<span class="truncate">{session.name}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New session button -->
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
on:click|stopPropagation={() => { showNewSessionMenu = !showNewSessionMenu; showImportMenu = false; }}
|
||||||
disabled={creating}
|
disabled={creating}
|
||||||
class="btn btn-primary p-2"
|
class="btn btn-primary p-2"
|
||||||
title="New Session"
|
title="New Session"
|
||||||
|
|||||||
+1
-1
@@ -97,7 +97,7 @@ if [ "$HAS_NREPL_EVAL" = true ]; then
|
|||||||
# Use inotifywait (efficient, event-based)
|
# Use inotifywait (efficient, event-based)
|
||||||
(
|
(
|
||||||
cd "$ROOT_DIR/server"
|
cd "$ROOT_DIR/server"
|
||||||
while inotifywait -r -e modify,create,delete --include '.*\.clj$' src/ 2>/dev/null; do
|
while inotifywait -r -e modify,create,delete,move --include '.*\.clj$' src/ 2>/dev/null; do
|
||||||
echo -e "${YELLOW}File change detected, reloading...${NC}"
|
echo -e "${YELLOW}File change detected, reloading...${NC}"
|
||||||
clj-nrepl-eval -p $NREPL_PORT "(user/reset)" > /dev/null 2>&1 &
|
clj-nrepl-eval -p $NREPL_PORT "(user/reset)" > /dev/null 2>&1 &
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -339,13 +339,14 @@
|
|||||||
|
|
||||||
;; Screen size presets for different device orientations
|
;; Screen size presets for different device orientations
|
||||||
(def ^:private screen-sizes
|
(def ^:private screen-sizes
|
||||||
{:desktop {:width 180 :height 50}
|
{:fullscreen {:width 180 :height 60}
|
||||||
:landscape {:width 100 :height 30}
|
:desktop {:width 100 :height 40}
|
||||||
|
:landscape {:width 65 :height 24}
|
||||||
:portrait {:width 40 :height 35}})
|
:portrait {:width 40 :height 35}})
|
||||||
|
|
||||||
(defn resize-session
|
(defn resize-session
|
||||||
"Resize a tmux session window to a preset size.
|
"Resize a tmux session window to a preset size.
|
||||||
mode should be :desktop, :landscape, or :portrait.
|
mode should be :fullscreen, :desktop, :landscape, or :portrait.
|
||||||
Returns true on success, nil on failure."
|
Returns true on success, nil on failure."
|
||||||
[session-name mode]
|
[session-name mode]
|
||||||
(when (and session-name mode)
|
(when (and session-name mode)
|
||||||
@@ -388,3 +389,34 @@
|
|||||||
;; Only return the mode if it's an exact match or very close
|
;; Only return the mode if it's an exact match or very close
|
||||||
(when (and closest (<= (:distance closest) 5))
|
(when (and closest (<= (:distance closest) 5))
|
||||||
(:mode closest)))))
|
(:mode closest)))))
|
||||||
|
|
||||||
|
(defn list-external-sessions
|
||||||
|
"List all tmux sessions that are NOT managed by spiceflow (don't have spiceflow- prefix).
|
||||||
|
Returns a list of maps with :name and :working-dir keys."
|
||||||
|
[]
|
||||||
|
(when-let [output (run-tmux "list-sessions" "-F" "#{session_name}:#{pane_current_path}")]
|
||||||
|
(->> (str/split-lines output)
|
||||||
|
(remove #(str/starts-with? % session-prefix))
|
||||||
|
(map (fn [line]
|
||||||
|
(let [[name path] (str/split line #":" 2)]
|
||||||
|
{:name name
|
||||||
|
:working-dir (or path (System/getProperty "user.home"))})))
|
||||||
|
vec)))
|
||||||
|
|
||||||
|
(defn import-session
|
||||||
|
"Import an external tmux session by renaming it to have the spiceflow prefix.
|
||||||
|
Returns the new session info on success, nil on failure."
|
||||||
|
[external-name]
|
||||||
|
(when external-name
|
||||||
|
;; Don't import if already a spiceflow session
|
||||||
|
(when-not (str/starts-with? external-name session-prefix)
|
||||||
|
(let [new-name (str session-prefix external-name)]
|
||||||
|
(when (rename-session external-name new-name)
|
||||||
|
;; Set up pipe-pane for the imported session
|
||||||
|
(let [output-file (output-file-path new-name)]
|
||||||
|
(spit output-file "")
|
||||||
|
(run-tmux "pipe-pane" "-t" new-name (str "cat >> " output-file)))
|
||||||
|
{:id new-name
|
||||||
|
:name new-name
|
||||||
|
:working-dir (or (run-tmux "display-message" "-t" new-name "-p" "#{pane_current_path}")
|
||||||
|
(System/getProperty "user.home"))})))))
|
||||||
|
|||||||
@@ -265,44 +265,56 @@
|
|||||||
(do
|
(do
|
||||||
(tmux/send-keys-raw id input)
|
(tmux/send-keys-raw id input)
|
||||||
;; Broadcast terminal update with diff after input
|
;; Broadcast terminal update with diff after input
|
||||||
|
;; Always broadcast to ensure client receives update, even if content unchanged
|
||||||
(future
|
(future
|
||||||
(Thread/sleep 100) ;; Small delay to let terminal update
|
(Thread/sleep 100) ;; Small delay to let terminal update
|
||||||
(let [{:keys [content diff changed]} (terminal-diff/capture-with-diff id tmux/capture-pane)]
|
(let [{:keys [content diff]} (terminal-diff/capture-with-diff id tmux/capture-pane)]
|
||||||
(when changed
|
|
||||||
(broadcast-fn id {:event :terminal-update
|
(broadcast-fn id {:event :terminal-update
|
||||||
:content (or content "")
|
:content (or content "")
|
||||||
:diff diff}))))
|
:diff diff})))
|
||||||
(json-response {:status "sent"}))
|
(json-response {:status "sent"}))
|
||||||
(error-response 400 "Tmux session not alive"))
|
(error-response 400 "Tmux session not alive"))
|
||||||
(error-response 400 "Not a tmux session")))))
|
(error-response 400 "Not a tmux session")))))
|
||||||
|
|
||||||
(defn terminal-resize-handler
|
(defn terminal-resize-handler
|
||||||
"Resize a tmux session to a preset screen size.
|
"Resize a tmux session to a preset screen size.
|
||||||
Mode can be: desktop, landscape, or portrait."
|
Mode can be: fullscreen, desktop, landscape, or portrait."
|
||||||
[_store]
|
[_store]
|
||||||
(fn [request]
|
(fn [request]
|
||||||
(let [id (get-in request [:path-params :id])
|
(let [id (get-in request [:path-params :id])
|
||||||
mode (keyword (get-in request [:body :mode]))]
|
mode (keyword (get-in request [:body :mode]))]
|
||||||
(if (tmux-session-id? id)
|
(if (tmux-session-id? id)
|
||||||
(if (tmux/session-alive? id)
|
(if (tmux/session-alive? id)
|
||||||
(if (#{:desktop :landscape :portrait} mode)
|
(if (#{:fullscreen :desktop :landscape :portrait} mode)
|
||||||
(if (tmux/resize-session id mode)
|
(if (tmux/resize-session id mode)
|
||||||
(json-response {:status "resized" :mode (name mode)})
|
(json-response {:status "resized" :mode (name mode)})
|
||||||
(error-response 500 "Failed to resize tmux session"))
|
(error-response 500 "Failed to resize tmux session"))
|
||||||
(error-response 400 "Invalid mode. Must be: desktop, landscape, or portrait"))
|
(error-response 400 "Invalid mode. Must be: fullscreen, desktop, landscape, or portrait"))
|
||||||
(error-response 400 "Tmux session not alive"))
|
(error-response 400 "Tmux session not alive"))
|
||||||
(error-response 400 "Not a tmux session")))))
|
(error-response 400 "Not a tmux session")))))
|
||||||
|
|
||||||
|
;; External tmux session handlers
|
||||||
|
(defn list-external-tmux-handler
|
||||||
|
"List all external tmux sessions (not managed by spiceflow)"
|
||||||
|
[_request]
|
||||||
|
(json-response (tmux/list-external-sessions)))
|
||||||
|
|
||||||
|
(defn import-tmux-handler
|
||||||
|
"Import an external tmux session by renaming it to be managed by spiceflow"
|
||||||
|
[request]
|
||||||
|
(let [session-name (get-in request [:body :name])]
|
||||||
|
(if session-name
|
||||||
|
(if-let [result (tmux/import-session session-name)]
|
||||||
|
(-> (json-response result)
|
||||||
|
(response/status 201))
|
||||||
|
(error-response 400 "Failed to import session. It may not exist or is already managed by spiceflow."))
|
||||||
|
(error-response 400 "Session name is required"))))
|
||||||
|
|
||||||
;; Health check
|
;; Health check
|
||||||
(defn health-handler
|
(defn health-handler
|
||||||
[_request]
|
[_request]
|
||||||
(json-response {:status "ok" :service "spiceflow"}))
|
(json-response {:status "ok" :service "spiceflow"}))
|
||||||
|
|
||||||
;; Test endpoint for verifying hot reload
|
|
||||||
(defn ping-handler
|
|
||||||
[_request]
|
|
||||||
(json-response {:pong true :time (str (java.time.Instant/now))}))
|
|
||||||
|
|
||||||
;; Push notification handlers
|
;; Push notification handlers
|
||||||
(defn vapid-key-handler
|
(defn vapid-key-handler
|
||||||
"Return the public VAPID key for push subscriptions"
|
"Return the public VAPID key for push subscriptions"
|
||||||
@@ -345,7 +357,6 @@
|
|||||||
[store broadcast-fn push-store]
|
[store broadcast-fn push-store]
|
||||||
[["/api"
|
[["/api"
|
||||||
["/health" {:get health-handler}]
|
["/health" {:get health-handler}]
|
||||||
["/ping" {:get ping-handler}]
|
|
||||||
["/sessions" {:get (list-sessions-handler store)
|
["/sessions" {:get (list-sessions-handler store)
|
||||||
:post (create-session-handler store)}]
|
:post (create-session-handler store)}]
|
||||||
["/sessions/:id" {:get (get-session-handler store)
|
["/sessions/:id" {:get (get-session-handler store)
|
||||||
@@ -356,6 +367,8 @@
|
|||||||
["/sessions/:id/terminal" {:get (terminal-capture-handler store)}]
|
["/sessions/:id/terminal" {:get (terminal-capture-handler store)}]
|
||||||
["/sessions/:id/terminal/input" {:post (terminal-input-handler store broadcast-fn)}]
|
["/sessions/:id/terminal/input" {:post (terminal-input-handler store broadcast-fn)}]
|
||||||
["/sessions/:id/terminal/resize" {:post (terminal-resize-handler store)}]
|
["/sessions/:id/terminal/resize" {:post (terminal-resize-handler store)}]
|
||||||
|
["/tmux/external" {:get list-external-tmux-handler}]
|
||||||
|
["/tmux/import" {:post import-tmux-handler}]
|
||||||
["/push/vapid-key" {:get (vapid-key-handler push-store)}]
|
["/push/vapid-key" {:get (vapid-key-handler push-store)}]
|
||||||
["/push/subscribe" {:post (subscribe-handler push-store)}]
|
["/push/subscribe" {:post (subscribe-handler push-store)}]
|
||||||
["/push/unsubscribe" {:post (unsubscribe-handler push-store)}]]])
|
["/push/unsubscribe" {:post (unsubscribe-handler push-store)}]]])
|
||||||
|
|||||||
Reference in New Issue
Block a user