Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 63 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,55 @@ fn write_config(patch: serde_json::Value) -> Result<(), String> {
write_json(&path, &current)
}

/// App-data files owned by the desktop shell (workspaces / layout state).
/// Confined to a plain file name directly under the per-app config dir —
/// no separators, no dotfiles, so the frontend can't reach anything else.
fn valid_app_data_name(file: &str) -> bool {
!file.is_empty()
&& file.len() <= 64
&& !file.starts_with('.')
&& file
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
}

fn app_data_path(app: &AppHandle, file: &str) -> Result<PathBuf, String> {
if !valid_app_data_name(file) {
return Err(format!("invalid app-data file name: {file}"));
}
let dir = app
.path()
.app_config_dir()
.map_err(|e| format!("app config dir unavailable: {e}"))?;
Ok(dir.join(file))
}

/// Reads a desktop app-data file; `None` when it doesn't exist yet.
#[tauri::command]
fn app_data_read(app: AppHandle, file: String) -> Result<Option<String>, String> {
let path = app_data_path(&app, &file)?;
match std::fs::read_to_string(&path) {
Ok(text) => Ok(Some(text)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(format!("读取 {} 失败:{e}", path.display())),
}
}

/// Writes a desktop app-data file. Write-then-rename so a crash mid-write
/// can't leave a truncated file behind (the previous content survives).
#[tauri::command]
fn app_data_write(app: AppHandle, file: String, content: String) -> Result<(), String> {
let path = app_data_path(&app, &file)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let mut tmp = path.clone();
tmp.set_file_name(format!("{file}.tmp"));
std::fs::write(&tmp, content.as_bytes())
.map_err(|e| format!("写入 {} 失败:{e}", tmp.display()))?;
std::fs::rename(&tmp, &path).map_err(|e| format!("写入 {} 失败:{e}", path.display()))
}

/// Returns the provider names the user is authenticated with. JuCode is now
/// an OAuth login (tokens live in the top-level `jucode` block, not the
/// `providers` map), so it's reported as "jucode" whenever a refresh token
Expand Down Expand Up @@ -2442,6 +2491,8 @@ pub fn run() {
close_session,
read_config,
write_config,
app_data_read,
app_data_write,
read_auth_providers,
set_auth_key,
remove_auth_key,
Expand Down Expand Up @@ -2502,7 +2553,18 @@ pub fn run() {

#[cfg(test)]
mod tests {
use super::read_json_strict;
use super::{read_json_strict, valid_app_data_name};

#[test]
fn app_data_names_stay_inside_the_config_dir() {
assert!(valid_app_data_name("workspaces.json"));
assert!(valid_app_data_name("layout-v1.json"));
assert!(!valid_app_data_name(""));
assert!(!valid_app_data_name(".hidden"));
assert!(!valid_app_data_name("../auth.json"));
assert!(!valid_app_data_name("nested/file.json"));
assert!(!valid_app_data_name("back\\slash.json"));
}

fn tmp(name: &str) -> std::path::PathBuf {
let p = std::env::temp_dir().join(format!("jucode-test-{}-{}", std::process::id(), name));
Expand Down
92 changes: 82 additions & 10 deletions src/lib/RightDock.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,20 @@
import DiagnosticsPanel from './DiagnosticsPanel.svelte';
import TerminalPanel from './TerminalPanel.svelte';
import BrowserPanel from './BrowserPanel.svelte';
import Mosaic from '$lib/workbench/Mosaic.svelte';
import {
activateTab,
deserializeLayout,
emptyLayout,
leavesOf,
openTab,
serializeLayout,
singleLeafLayout,
type TileLayout,
type TileTab
} from '$lib/workbench/tiles';
import { workspaces } from '$lib/workbench/workspaceStore.svelte';
import { prefs } from '$lib/prefs.svelte';
import { browser } from '$lib/browser.svelte';
import type { Goal, PlanStep, TurnDiff, ChatState } from '$lib/chat.svelte';
import type { WorktreeMeta } from '$lib/types';
Expand Down Expand Up @@ -151,8 +165,45 @@
// A browser open (agent tool call, typed URL, element pick) reveals the tab.
$effect(() => {
if (browser.openSignal === 0) return;
untrack(() => openPanel('browser'));
untrack(() => (prefs.mosaic ? mosaicAdd(null, 'browser') : openPanel('browser')));
});

// ---------- mosaic (tiled) mode — behind the `mosaic` feature flag ----------
// The tile layout is workspace state: it loads from and persists to the
// active workspace's app-data entry (never localStorage). When a workspace
// has no tile layout yet, the legacy single-stack dock tabs seed one leaf.
let tiles = $state<TileLayout>(emptyLayout());
$effect(() => {
void workspaces.activeId; // rebuild when the active workspace changes
tiles = untrack(() => initialTiles());
});
function initialTiles(): TileLayout {
const parsed = deserializeLayout(workspaces.active?.layout ?? null);
if (parsed?.root) return parsed;
return singleLeafLayout(
openTabs.map((t) => ({ id: t.id, panel: t.panel })),
active
);
}
function setTiles(next: TileLayout) {
tiles = next;
workspaces.updateLayout(serializeLayout(next));
}
function mosaicAdd(leafId: string | null, panel: string) {
// Same singleton rule as openPanel: the embedded browser is one native
// webview, so a second tab would fight over it — refocus instead.
if (panel === 'browser') {
const existing = leavesOf(tiles.root)
.flatMap((l) => l.tabs)
.find((tab) => tab.panel === 'browser');
if (existing) {
setTiles(activateTab(tiles, existing.id));
return;
}
}
setTiles(openTab(tiles, leafId, { id: newId(), panel }));
}
const mosaicLabel = (tab: TileTab) => labelOf(tab.panel);
function closeTab(id: string) {
const idx = openTabs.findIndex((t) => t.id === id);
openTabs = openTabs.filter((t) => t.id !== id);
Expand Down Expand Up @@ -187,6 +238,34 @@
}
</script>

{#snippet panelBody(kind: string)}
{#if kind === 'plan'}<PlanPanel {plan} />
{:else if kind === 'goal'}<GoalPanel {goal} />
{:else if kind === 'changes'}<ChangesPanel {cwd} files={changed} onRevert={onRevertFile} />
{:else if kind === 'turns'}<TurnsPanel {turns} onOpenFile={onOpenFile} />
{:else if kind === 'files'}<FilesPanel rootDir={cwd} />
{:else if kind === 'git'}<GitPanel {cwd} {worktree} {llm} {onOpenTask} {onTaskRemoved} />
{:else if kind === 'term'}<TerminalPanel {cwd} />
{:else if kind === 'browser'}<BrowserPanel />
{:else if kind === 'diag'}<DiagnosticsPanel {chat} />{/if}
{/snippet}

{#if prefs.mosaic}
<div class="dock">
<Mosaic
layout={tiles}
onchange={setTiles}
label={mosaicLabel}
addOptions={PANELS.map((p) => ({ key: p.key, label: labelOf(p.key) }))}
onAdd={mosaicAdd}
emptyText={t('dock.dock.empty')}
>
{#snippet panel(tab)}
{@render panelBody(tab.panel)}
{/snippet}
</Mosaic>
</div>
{:else}
<div class="dock">
<div class="tabbar">
<div class="tabs" bind:this={bar}>
Expand Down Expand Up @@ -232,15 +311,7 @@
<div class="content">
{#each visibleTabs as tab (tab.id)}
<div class="pane" class:hidden={tab.id !== active}>
{#if tab.panel === 'plan'}<PlanPanel {plan} />
{:else if tab.panel === 'goal'}<GoalPanel {goal} />
{:else if tab.panel === 'changes'}<ChangesPanel {cwd} files={changed} onRevert={onRevertFile} />
{:else if tab.panel === 'turns'}<TurnsPanel {turns} onOpenFile={onOpenFile} />
{:else if tab.panel === 'files'}<FilesPanel rootDir={cwd} />
{:else if tab.panel === 'git'}<GitPanel {cwd} {worktree} {llm} {onOpenTask} {onTaskRemoved} />
{:else if tab.panel === 'term'}<TerminalPanel {cwd} />
{:else if tab.panel === 'browser'}<BrowserPanel />
{:else if tab.panel === 'diag'}<DiagnosticsPanel {chat} />{/if}
{@render panelBody(tab.panel)}
</div>
{/each}
{#if visibleTabs.length === 0}
Expand All @@ -259,6 +330,7 @@
{/if}
</div>
</div>
{/if}

<style>
.dock {
Expand Down
13 changes: 13 additions & 0 deletions src/lib/Settings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,19 @@
</div>
{/if}

<div class="group">
<div class="glabel">{t('settings.behavior.mosaic')}</div>
<Segmented
value={prefs.mosaic ? 'on' : 'off'}
options={[
{ value: 'on', label: t('settings.behavior.mosaicOn') },
{ value: 'off', label: t('settings.behavior.mosaicOff') }
]}
onChange={(v) => prefs.setMosaic(v === 'on')}
/>
<p class="hint mt">{t('settings.behavior.mosaicHint')}</p>
</div>

<BackendSection />

<div class="group">
Expand Down
Loading
Loading