implement editor-above-Claude split in workspace
Editor opens above Claude via Ctrl+E with a draggable divider at 35% height (clamped 15-70%). Ctrl+W or Escape closes it. The workspace no longer renders a tab bar — Claude is the always-visible primary surface per D-047/D-048; the editor is a split overlay per D-049, not a tab. The _WorkspaceSlot separates editor from primary content tabs and renders them as a vertical split when the editor is open. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,17 @@ heading, and (b) bumping `project.yaml` `version:` in the same commit.
|
||||
- Right panel (context) icon rail — bottom section switcher matching
|
||||
the left sidebar rail pattern (D-047, T-034).
|
||||
|
||||
- Editor-above-Claude mode — `Ctrl+E` opens the editor as a split
|
||||
above Claude in the middle column with a draggable divider;
|
||||
`Ctrl+W` or `Escape` closes it. Prompt bar Y stays fixed
|
||||
(D-049, T-035).
|
||||
|
||||
### Changed
|
||||
|
||||
- Workspace renders Claude as the always-visible primary surface
|
||||
instead of showing a tab bar (D-047, D-048). The editor is a
|
||||
split overlay, not a tab.
|
||||
|
||||
- Syntax highlighting via tree-sitter (dart:ffi to vendored
|
||||
libtree-sitter.so with embedded wasmtime). 48 grammar WASM files,
|
||||
48 highlight queries. Colors map to theme syntax tokens.
|
||||
|
||||
@@ -228,6 +228,10 @@ class SlotHost extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
if (slot == Slots.workspace) {
|
||||
return _WorkspaceSlot(tabs: tabs, active: active);
|
||||
}
|
||||
|
||||
return Container(
|
||||
color: tokens.panelBackground,
|
||||
child: Column(
|
||||
@@ -310,6 +314,96 @@ class _SidebarSlot extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _WorkspaceSlot extends StatelessWidget {
|
||||
const _WorkspaceSlot({required this.tabs, required this.active});
|
||||
|
||||
final List<TabContribution> tabs;
|
||||
final TabContribution active;
|
||||
|
||||
static const _editorTabId = 'editor.active';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.arrangement,
|
||||
builder: (ctx, _) {
|
||||
final editorOpen = kernel.arrangement.editorOpen;
|
||||
final editorTab = tabs.where((t) => t.id == _editorTabId).firstOrNull;
|
||||
final primaryTabs = tabs.where((t) => t.id != _editorTabId).toList();
|
||||
final primary = primaryTabs.contains(active) ? active : (primaryTabs.isNotEmpty ? primaryTabs.first : active);
|
||||
|
||||
if (!editorOpen || editorTab == null) {
|
||||
return Container(color: tokens.panelBackground, child: primary.build(ctx));
|
||||
}
|
||||
|
||||
final ratio = kernel.arrangement.editorRatio;
|
||||
return Container(
|
||||
color: tokens.panelBackground,
|
||||
child: LayoutBuilder(
|
||||
builder: (ctx, constraints) {
|
||||
final totalHeight = constraints.maxHeight;
|
||||
final editorHeight = (totalHeight * ratio).clamp(60.0, totalHeight - 60.0);
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(height: editorHeight, child: editorTab.build(ctx)),
|
||||
_EditorDragHandle(arrangement: kernel.arrangement, totalHeight: totalHeight),
|
||||
Expanded(child: primary.build(ctx)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EditorDragHandle extends StatefulWidget {
|
||||
const _EditorDragHandle({required this.arrangement, required this.totalHeight});
|
||||
|
||||
final LayoutArrangement arrangement;
|
||||
final double totalHeight;
|
||||
|
||||
@override
|
||||
State<_EditorDragHandle> createState() => _EditorDragHandleState();
|
||||
}
|
||||
|
||||
class _EditorDragHandleState extends State<_EditorDragHandle> {
|
||||
bool _hovered = false;
|
||||
double? _dragStartRatio;
|
||||
double? _dragStartY;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeRow,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: Listener(
|
||||
onPointerDown: (e) {
|
||||
_dragStartRatio = widget.arrangement.editorRatio;
|
||||
_dragStartY = e.position.dy;
|
||||
},
|
||||
onPointerMove: (e) {
|
||||
final startR = _dragStartRatio;
|
||||
final startY = _dragStartY;
|
||||
if (startR == null || startY == null || widget.totalHeight <= 0) return;
|
||||
final deltaRatio = (e.position.dy - startY) / widget.totalHeight;
|
||||
widget.arrangement.setEditorRatio(startR + deltaRatio);
|
||||
},
|
||||
onPointerUp: (_) {
|
||||
_dragStartRatio = null;
|
||||
_dragStartY = null;
|
||||
},
|
||||
child: Container(height: 4, color: _hovered ? tokens.panelActiveBorder : tokens.panelBorder),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ContextSlot extends StatelessWidget {
|
||||
const _ContextSlot({
|
||||
required this.tabs,
|
||||
|
||||
@@ -81,6 +81,21 @@ class DefaultLayoutExtension extends ClideExtension {
|
||||
defaultBinding: 'escape',
|
||||
run: _exitFocusMode,
|
||||
),
|
||||
// Editor split (D-049, D-054)
|
||||
CommandContribution(
|
||||
id: 'editor.open',
|
||||
command: 'editor.open',
|
||||
title: 'Open Editor',
|
||||
defaultBinding: 'ctrl+e',
|
||||
run: _openEditor,
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'editor.close',
|
||||
command: 'editor.close',
|
||||
title: 'Close Editor',
|
||||
defaultBinding: 'ctrl+w',
|
||||
run: _closeEditor,
|
||||
),
|
||||
// Sidebar section switching (D-054): alt+1 through alt+5
|
||||
for (var i = 0; i < 5; i++)
|
||||
CommandContribution(
|
||||
@@ -182,6 +197,10 @@ class DefaultLayoutExtension extends ClideExtension {
|
||||
ctx.arrangement.exitFocusMode();
|
||||
return IpcResponse.ok(id: '', data: {'focusMode': false});
|
||||
}
|
||||
if (ctx.arrangement.editorOpen) {
|
||||
ctx.arrangement.closeEditor();
|
||||
return IpcResponse.ok(id: '', data: {'editorOpen': false});
|
||||
}
|
||||
if (ctx.palette.isOpen) {
|
||||
ctx.palette.toggle();
|
||||
return IpcResponse.ok(id: '', data: {'palette': false});
|
||||
@@ -189,6 +208,23 @@ class DefaultLayoutExtension extends ClideExtension {
|
||||
return IpcResponse.ok(id: '', data: {});
|
||||
}
|
||||
|
||||
Future<IpcResponse> _openEditor(List<String> args) async {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) return _notActivated();
|
||||
ctx.arrangement.openEditor();
|
||||
return IpcResponse.ok(id: '', data: {'editorOpen': true});
|
||||
}
|
||||
|
||||
Future<IpcResponse> _closeEditor(List<String> args) async {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) return _notActivated();
|
||||
if (ctx.arrangement.editorOpen) {
|
||||
ctx.arrangement.closeEditor();
|
||||
return IpcResponse.ok(id: '', data: {'editorOpen': false});
|
||||
}
|
||||
return IpcResponse.ok(id: '', data: {});
|
||||
}
|
||||
|
||||
Future<IpcResponse> _switchSidebarSection(int index) async {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) return _notActivated();
|
||||
|
||||
@@ -11,6 +11,9 @@ class LayoutArrangement extends ChangeNotifier {
|
||||
Map<SlotId, _SlotState>? _focusModeSnapshot;
|
||||
SlotId? _focusModeSlot;
|
||||
|
||||
bool _editorOpen = false;
|
||||
double _editorRatio = 0.35;
|
||||
|
||||
void applyPreset(LayoutPresetContribution preset) {
|
||||
_state.clear();
|
||||
_focusModeSnapshot = null;
|
||||
@@ -37,6 +40,8 @@ class LayoutArrangement extends ChangeNotifier {
|
||||
bool isCollapsed(SlotId id) => _state[id]?.collapsed ?? false;
|
||||
bool get isInFocusMode => _focusModeSlot != null;
|
||||
SlotId? get focusModeSlot => _focusModeSlot;
|
||||
bool get editorOpen => _editorOpen;
|
||||
double get editorRatio => _editorRatio;
|
||||
|
||||
void setSize(SlotId id, double size) {
|
||||
final s = _state[id];
|
||||
@@ -100,6 +105,30 @@ class LayoutArrangement extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
void openEditor() {
|
||||
if (_editorOpen) return;
|
||||
_editorOpen = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void closeEditor() {
|
||||
if (!_editorOpen) return;
|
||||
_editorOpen = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void toggleEditor() {
|
||||
_editorOpen = !_editorOpen;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setEditorRatio(double ratio) {
|
||||
final clamped = ratio.clamp(0.15, 0.70);
|
||||
if (_editorRatio == clamped) return;
|
||||
_editorRatio = clamped;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void registerSlotsInto(PanelRegistry registry, LayoutPresetContribution preset) {
|
||||
for (final slot in preset.slots) {
|
||||
registry.registerSlot(SlotDefinition(
|
||||
|
||||
@@ -100,5 +100,42 @@ void main() {
|
||||
a.applyPreset(classicPreset());
|
||||
expect(a.isInFocusMode, false);
|
||||
});
|
||||
|
||||
test('openEditor sets editorOpen', () {
|
||||
final a = LayoutArrangement()..applyPreset(classicPreset());
|
||||
expect(a.editorOpen, false);
|
||||
a.openEditor();
|
||||
expect(a.editorOpen, true);
|
||||
});
|
||||
|
||||
test('closeEditor clears editorOpen', () {
|
||||
final a = LayoutArrangement()..applyPreset(classicPreset());
|
||||
a.openEditor();
|
||||
a.closeEditor();
|
||||
expect(a.editorOpen, false);
|
||||
});
|
||||
|
||||
test('toggleEditor flips editorOpen', () {
|
||||
final a = LayoutArrangement()..applyPreset(classicPreset());
|
||||
a.toggleEditor();
|
||||
expect(a.editorOpen, true);
|
||||
a.toggleEditor();
|
||||
expect(a.editorOpen, false);
|
||||
});
|
||||
|
||||
test('setEditorRatio clamps to 0.15–0.70', () {
|
||||
final a = LayoutArrangement()..applyPreset(classicPreset());
|
||||
a.setEditorRatio(0.05);
|
||||
expect(a.editorRatio, 0.15);
|
||||
a.setEditorRatio(0.90);
|
||||
expect(a.editorRatio, 0.70);
|
||||
a.setEditorRatio(0.40);
|
||||
expect(a.editorRatio, 0.40);
|
||||
});
|
||||
|
||||
test('editorRatio defaults to 0.35', () {
|
||||
final a = LayoutArrangement()..applyPreset(classicPreset());
|
||||
expect(a.editorRatio, 0.35);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user