Skip to content

Commit

Permalink
feat(terminal): add terminal emulator support (#7)
Browse files Browse the repository at this point in the history
* wip(frontend): terminal + bottom pane

* refactor(frontend/resizeHandler): consolidate repeated code reusable approach

* feat(frontend/terminal): split terminal into it's own component

* wip(terminal): initial structure

* feat(frontend/terminal): add xterm.js + terminal with resizing

* feat(frontend/terminal): visual enhancements

* feat(frontend/terminal): multiple tabs

* feat(frontend/terminal): shell selector

* feat(frontend/terminal): improve terminal lifecycle and state handling

1. Better cleanup of terminal instances
2. Proper handling of component lifecycle
3. Prevention of operations on destroyed terminals
4. Improved state management in the store
5. Prevent line wrap

* feat(frontend/terminal): add tab navigation controls

- Add scroll buttons for terminal tabs (left/right navigation)
- Implement smooth tab scrolling behavior
- Hide scrollbars while maintaining scroll functionality
- Add visual separators between navigation controls
- Auto-scroll to active tab when selected

* fix(frontend/tabs): enhance tab scrolling behavior

- Add smooth centering of tabs in container
- Add delay for DOM updates before scrolling
- Return new tab ID from store for immediate reference
- Improve tab scroll positioning calculation

* feat(backend): add terminal service and core functionality

- Add terminal service for managing multiple terminal instances
- Implement terminal screen handling with tcell library
- Add terminal event handling and lifecycle management
- Add terminal input/output processing
- Implement terminal resize functionality
- Add cursor position tracking and screen content management
- Add terminal options configuration support

* feat(terminal): implement terminal integration with backend communication

- Add TerminalService with terminal instance management
- Implement terminal event handling and lifecycle management
- Add terminal resize functionality and cursor tracking
- Integrate terminal frontend with backend communication
- Add terminal input/output handling
- Implement terminal creation and destruction endpoints
- Add terminal event notification system

* feat(terminal): switch terminal backend from tcell to pty

- Migrate terminal backend from tcell to pty for full TTY support
- Add base64 encoding/decoding for terminal data
- Add debug logs for terminal operations
- Enhance terminal event handling with PTY lifecycle
- Improve error handling and terminal cleanup
- Add TERM environment variable configuration
- Remove screen.go and simplify terminal architecture
- Update terminal resizing to use PTY native functions

* fix(frontend/terminal): adjust terminal sizing for bottom status bar

- Calculate terminal rows accounting for status bar height
- Add TODO comment for future dynamic status bar height handling

* fix(frontend/terminal): enhance terminal component stability and performance

- Refactor terminal component for better lifecycle management
- Improve terminal initialization and cleanup logic
- Add base64 encoding for input handling
- Optimize terminal resizing and event handling
- Fix terminal tab visibility with absolute positioning
- Add isInitialized flag to prevent duplicate initialization

* fix(frontend/terminal): improve terminal component stability and responsiveness

- Add active state detection and auto-focus for terminals
- Implement debounced resize handling with 100ms delay
- Fix terminal tab switching behavior and focus management
- Add resize timeout cleanup on component destroy
- Adjust status bar height calculation to 3 rows
- Fix type safety with null checks

* feat(frontend): improve terminal focus management and keyboard shortcuts

- Add automatic terminal focus handling when switching tabs
- Implement ctrl+j shortcut to open terminal panel
- Add terminal state management with bottomPaneStore
- Improve keyboard context switching between editor and terminal
- Add focus method to XtermComponent for external control
- Update tab click behavior to focus terminal automatically
- Fix keyboard context handling in bottom pane collapse state

* feat(frontend): improve keyboard context management and editor focus handling

- Add focus tracking and restoration between editor and terminal
- Implement Alt+J shortcut to return to previous context
- Add keyboard event handling for terminal shortcuts
- Improve editor focus management with focusStore integration
- Format keyboard store code for better readability

* feat(terminal): add xterm addons and improve terminal management

- Add xterm addons (fit, search, webgl) for enhanced terminal functionality
- Implement terminal manager for better instance tracking and lifecycle
- Add terminal ID-based management and cleanup
- Update terminal service to handle ID-based operations
- Refactor terminal creation and destruction logic
  • Loading branch information
nathabonfim59 authored Dec 20, 2024
1 parent dfc7fa2 commit e8a931c
Show file tree
Hide file tree
Showing 25 changed files with 1,254 additions and 172 deletions.
36 changes: 32 additions & 4 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,17 @@ import (

"github.com/edit4i/editor/internal/db"
"github.com/edit4i/editor/internal/service"
"github.com/edit4i/editor/internal/terminal"
"github.com/wailsapp/wails/v2/pkg/runtime"
)

// App struct
type App struct {
ctx context.Context
projects *service.ProjectsService
files *service.FileService
config *service.ConfigService
ctx context.Context
projects *service.ProjectsService
files *service.FileService
config *service.ConfigService
terminalService *service.TerminalService
}

// NewApp creates a new App application struct
Expand Down Expand Up @@ -43,6 +45,12 @@ func (a *App) startup(ctx context.Context) {
panic(fmt.Errorf("Failed to initialize ConfigService: %v", err))
}
a.config = config

// Initialize terminal service with event handler
a.terminalService = service.NewTerminalService(func(id string, event *terminal.Event) {
// Emit terminal events to frontend
runtime.EventsEmit(a.ctx, fmt.Sprintf("terminal:%s", id), event)
})
}

// GetRecentProjects returns the list of recent projects
Expand Down Expand Up @@ -139,3 +147,23 @@ func (a *App) RenameFile(oldPath, newPath string) error {
func (a *App) DeleteFile(path string) error {
return a.files.DeleteFile(path)
}

// CreateTerminal creates a new terminal instance
func (a *App) CreateTerminal(id string, shell string) error {
return a.terminalService.CreateTerminal(id, shell)
}

// DestroyTerminal destroys a terminal instance
func (a *App) DestroyTerminal(id string) error {
return a.terminalService.DestroyTerminal(id)
}

// ResizeTerminal resizes a terminal instance
func (a *App) ResizeTerminal(id string, cols int, rows int) error {
return a.terminalService.ResizeTerminal(id, cols, rows)
}

// HandleInput handles terminal input from the frontend
func (a *App) HandleInput(id string, data []byte) error {
return a.terminalService.HandleInput(id, data)
}
50 changes: 49 additions & 1 deletion frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,14 @@
"vite": "^4.4.0"
},
"dependencies": {
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"lucide-svelte": "^0.294.0",
"monaco-editor": "^0.52.0",
"monaco-vim": "^0.4.1",
"svelte-spa-router": "^4.0.1"
"svelte-spa-router": "^4.0.1",
"tailwind-merge": "^2.5.5"
}
}
2 changes: 1 addition & 1 deletion frontend/package.json.md5
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3a813aba7d4e8dc094101503060a82b6
2be276b66fa69ca283f64ddc7d9337aa
7 changes: 3 additions & 4 deletions frontend/src/lib/components/Select.svelte
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<script lang="ts">
import { twMerge } from "tailwind-merge";
export let value: string;
export let options: string[];
export let disabled: boolean = false;
Expand Down Expand Up @@ -26,10 +28,7 @@
compact: 'py-0.5 px-2'
};
$: classes = `
${baseClasses}
${variantClasses[variant]}
`;
$: classes = twMerge(baseClasses, variantClasses[variant], $$props.class || '');
</script>

<div class="relative">
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/lib/editor/Editor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@
}, 100);
}
// Watch for focus changes
$: if (editor && $focusStore.activeContext?.component === 'editor') {
// Focus editor when it becomes active
editor.focus();
}
// Watch for file changes
$: if ($fileStore.activeFilePath) {
handleFileChange($fileStore.activeFilePath);
Expand Down
87 changes: 52 additions & 35 deletions frontend/src/lib/editor/ResizeHandle.svelte
Original file line number Diff line number Diff line change
@@ -1,38 +1,54 @@
<script lang="ts">
import { ChevronLeft, ChevronRight } from 'lucide-svelte';
import { ChevronLeft, ChevronRight, ChevronUp, ChevronDown } from 'lucide-svelte';
import { fade } from 'svelte/transition';
export let side: 'left' | 'right';
export let onResize: (width: number) => void;
export let currentWidth: number;
export let orientation: 'horizontal' | 'vertical' = 'vertical';
export let side: 'left' | 'right' | 'top' | 'bottom' = 'right';
export let size: number;
export let minSize = 200;
export let maxSize = 600;
let isDragging = false;
let isHovering = false;
let hoverTimeout: NodeJS.Timeout;
let showHandle = false;
let startX: number;
let startWidth: number;
let startPos: number;
let startSize: number;
const handleConfig = {
left: { icon: ChevronLeft, position: 'top-1/2 -translate-y-1/2 -translate-x-1/2 left-0' },
right: { icon: ChevronRight, position: 'top-1/2 -translate-y-1/2 translate-x-1/2 right-0' },
top: { icon: ChevronUp, position: 'left-1/2 -translate-x-1/2 -translate-y-1/2 top-0' },
bottom: { icon: ChevronDown, position: 'left-1/2 -translate-x-1/2 translate-y-1/2 bottom-0' }
};
function handleMouseDown(e: MouseEvent) {
e.preventDefault();
isDragging = true;
startX = e.clientX;
startWidth = currentWidth;
showHandle = true;
startPos = orientation === 'vertical' ? e.clientX : e.clientY;
startSize = size;
document.body.style.userSelect = 'none';
document.body.style.cursor = 'ew-resize';
document.body.style.cursor = orientation === 'vertical' ? 'ew-resize' : 'ns-resize';
const mouseMoveHandler = (e: MouseEvent) => {
if (!isDragging) return;
e.preventDefault();
const dx = e.clientX - startX;
const newWidth = side === 'left'
? Math.max(200, Math.min(600, startWidth + dx))
: Math.max(200, Math.min(600, startWidth - dx));
onResize(newWidth);
const delta = orientation === 'vertical'
? e.clientX - startPos
: e.clientY - startPos;
let newSize;
if (side === 'left' || side === 'top') {
newSize = Math.max(minSize, Math.min(maxSize, startSize - delta));
} else {
newSize = Math.max(minSize, Math.min(maxSize, startSize + delta));
}
size = newSize;
};
const mouseUpHandler = () => {
isDragging = false;
showHandle = false;
document.removeEventListener('mousemove', mouseMoveHandler);
document.removeEventListener('mouseup', mouseUpHandler);
document.body.style.userSelect = '';
Expand All @@ -45,20 +61,29 @@
function handleMouseEnter() {
isHovering = true;
hoverTimeout = setTimeout(() => {
showHandle = true;
}, 1500);
showHandle = true;
}
function handleMouseLeave() {
isHovering = false;
showHandle = false;
clearTimeout(hoverTimeout);
if (!isDragging) {
isHovering = false;
showHandle = false;
}
}
$: isVertical = orientation === 'vertical';
$: resizeClasses = isVertical
? 'w-1 hover:w-[5px] transition-all duration-200 ease-out cursor-ew-resize'
: 'h-1 hover:h-[5px] transition-all duration-200 ease-out cursor-ns-resize';
$: tooltipPositionClasses = isVertical
? `top-1/2 -translate-y-1/2 ${side === 'left' ? 'left-4' : 'right-4'}`
: `left-1/2 -translate-x-1/2 ${side === 'top' ? 'top-4' : 'bottom-4'}`;
</script>

<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="relative w-1 hover:w-[5px] transition-all duration-200 ease-out cursor-ew-resize group select-none"
class={`relative group select-none z-[9999] ${resizeClasses}`}
class:bg-sky-500={isDragging}
class:border-sky-500={isDragging}
class:border-2={isDragging}
on:mousedown={handleMouseDown}
Expand All @@ -71,26 +96,18 @@
transition:fade={{ duration: 150 }}
/>

{#if (showHandle || isDragging) && side === 'left'}
<div
class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 left-0 bg-gray-700 rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
class:opacity-100={isDragging}
>
<ChevronLeft size={16} class="text-gray-300" />
</div>
{/if}

{#if (showHandle || isDragging) && side === 'right'}
{#if showHandle || isDragging}
<div
class="absolute top-1/2 -translate-y-1/2 translate-x-1/2 right-0 bg-gray-700 rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
class={`absolute ${handleConfig[side].position} bg-gray-700 rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-[9999]`}
class:bg-sky-500={isDragging}
class:opacity-100={isDragging}
>
<ChevronRight size={16} class="text-gray-300" />
<svelte:component this={handleConfig[side].icon} size={16} class="text-white" />
</div>
{/if}

<div
class="absolute top-1/2 -translate-y-1/2 {side === 'left' ? 'left-4' : 'right-4'} whitespace-nowrap bg-gray-800 text-gray-300 text-xs py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none select-none"
class={`absolute whitespace-nowrap bg-gray-800 text-gray-300 text-xs py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none select-none z-[9999] ${tooltipPositionClasses}`}
class:opacity-100={isDragging}
>
Drag to resize
Expand Down
59 changes: 59 additions & 0 deletions frontend/src/lib/editor/panes/BottomPane.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { addKeyboardContext, removeKeyboardContext } from '@/stores/keyboardStore';
import type { BottomPaneState } from '@/types/ui';
import TerminalPane from '@/lib/editor/panes/TerminalPane.svelte';
import { bottomPaneStore } from '@/stores/bottomPaneStore';
export let state: BottomPaneState;
export let height: number;
onMount(() => {
if (!state.collapsed) {
addKeyboardContext('bottomPane');
}
});
onDestroy(() => {
removeKeyboardContext('bottomPane');
});
// Watch for state changes
$: if (!state.collapsed) {
addKeyboardContext('bottomPane');
} else {
removeKeyboardContext('bottomPane');
}
</script>

<div class="w-full flex flex-col overflow-hidden border-t border-gray-800" style="height: {height}px">
<div class="flex items-center justify-between h-[35px] px-4 border-b border-gray-800">
<div class="flex items-center space-x-2">
<span class="text-sm font-medium">
{#if state.activeSection === 'terminal'}
Terminal
{:else if state.activeSection === 'problems'}
Problems
{:else if state.activeSection === 'output'}
Output
{/if}
</span>
</div>
</div>

<div class="flex-1 overflow-auto">
{#if state.activeSection === 'terminal'}
<TerminalPane {height} />
{:else if state.activeSection === 'problems'}
<div class="p-2">
<!-- Problems content will go here -->
<p class="text-gray-500">No problems found.</p>
</div>
{:else if state.activeSection === 'output'}
<div class="p-2">
<!-- Output content will go here -->
<p class="text-gray-500">No output to display.</p>
</div>
{/if}
</div>
</div>
Loading

0 comments on commit e8a931c

Please sign in to comment.