* fix: Resolve database encryption atomicity issues and enhance debugging (#430)

* fix: Resolve database encryption atomicity issues and enhance debugging

This commit addresses critical data corruption issues caused by non-atomic
file writes during database encryption, and adds comprehensive diagnostic
logging to help debug encryption-related failures.

**Problem:**
Users reported "Unsupported state or unable to authenticate data" errors
when starting the application after system crashes or Docker container
restarts. The root cause was non-atomic writes of encrypted database files:

1. Encrypted data file written (step 1)
2. Metadata file written (step 2)
→ If process crashes between steps 1 and 2, files become inconsistent
→ New IV/tag in data file, old IV/tag in metadata
→ GCM authentication fails on next startup
→ User data permanently inaccessible

**Solution - Atomic Writes:**

1. Write-to-temp + atomic-rename pattern:
   - Write to temporary files (*.tmp-timestamp-pid)
   - Perform atomic rename operations
   - Clean up temp files on failure

2. Data integrity validation:
   - Add dataSize field to metadata
   - Verify file size before decryption
   - Early detection of corrupted writes

3. Enhanced error diagnostics:
   - Key fingerprints (SHA256 prefix) for verification
   - File modification timestamps
   - Detailed GCM auth failure messages
   - Automatic diagnostic info generation

**Changes:**

database-file-encryption.ts:
- Implement atomic write pattern in encryptDatabaseFromBuffer
- Implement atomic write pattern in encryptDatabaseFile
- Add dataSize field to EncryptedFileMetadata interface
- Validate file size before decryption in decryptDatabaseToBuffer
- Enhanced error messages for GCM auth failures
- Add getDiagnosticInfo() function for comprehensive debugging
- Add debug logging for all encryption/decryption operations

system-crypto.ts:
- Add detailed logging for DATABASE_KEY initialization
- Log key source (env var vs .env file)
- Add key fingerprints to all log messages
- Better error messages when key loading fails

db/index.ts:
- Automatically generate diagnostic info on decryption failure
- Log detailed debugging information to help users troubleshoot

**Debugging Info Added:**

- Key initialization: source, fingerprint, length, path
- Encryption: original size, encrypted size, IV/tag prefixes, temp paths
- Decryption: file timestamps, metadata content, key fingerprint matching
- Auth failures: .env file status, key availability, file consistency
- File diagnostics: existence, readability, size validation, mtime comparison

**Backward Compatibility:**
- dataSize field is optional (metadata.dataSize?: number)
- Old encrypted files without dataSize continue to work
- No migration required

**Testing:**
- Compiled successfully
- No breaking changes to existing APIs
- Graceful handling of legacy v1 encrypted files

Fixes data loss issues reported by users experiencing container restarts
and system crashes during database saves.

* fix: Cleanup PR

* Update src/backend/utils/database-file-encryption.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/backend/utils/database-file-encryption.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/backend/utils/database-file-encryption.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/backend/utils/database-file-encryption.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/backend/utils/database-file-encryption.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: Merge metadata and DB into 1 file

* fix: Add initial command palette

* Feature/german language support (#431)

* Update translation.json

Fixed some translation issues for German, made it more user friendly and common.

* Update translation.json

added updated block for serverStats

* Update translation.json

Added translations

* Update translation.json

Removed duplicate of "free":"Free"

* feat: Finalize command palette

* fix: Several bug fixes for terminals, server stats, and general feature improvements

* feat: Enhanced security, UI improvements, and animations (#432)

* fix: Remove empty catch blocks and add error logging

* refactor: Modularize server stats widget collectors

* feat: Add i18n support for terminal customization and login stats

- Add comprehensive terminal customization translations (60+ keys) for appearance, behavior, and advanced settings across all 4 languages
- Add SSH login statistics translations
- Update HostManagerEditor to use i18n for all terminal customization UI elements
- Update LoginStatsWidget to use i18n for all UI text
- Add missing logger imports in backend files for improved debugging

* feat: Add keyboard shortcut enhancements with Kbd component

- Add shadcn kbd component for displaying keyboard shortcuts
- Enhance file manager context menu to display shortcuts with Kbd component
- Add 5 new keyboard shortcuts to file manager:
  - Ctrl+D: Download selected files
  - Ctrl+N: Create new file
  - Ctrl+Shift+N: Create new folder
  - Ctrl+U: Upload files
  - Enter: Open/run selected file
- Add keyboard shortcut hints to command palette footer
- Create helper function to parse and render keyboard shortcuts

* feat: Add i18n support for command palette

- Add commandPalette translation section with 22 keys to all 4 languages
- Update CommandPalette component to use i18n for all UI text
- Translate search placeholder, group headings, menu items, and shortcut hints
- Support multilingual command palette interface

* feat: Add smooth transitions and animations to UI

- Add fade-in/fade-out transition to command palette (200ms)
- Add scale animation to command palette on open/close
- Add smooth popup animation to context menu (150ms)
- Add visual feedback for file selection with ring effect
- Add hover scale effect to file grid items
- Add transition-all to list view items for consistent behavior
- Zero JavaScript overhead, pure CSS transitions
- All animations under 200ms for instant feel

* feat: Add button active state and dashboard card animations

- Add active:scale-95 to all buttons for tactile click feedback
- Add hover border effect to dashboard cards (150ms transition)
- Add pulse animation to dashboard loading states
- Pure CSS transitions with zero JavaScript overhead
- Improves enterprise-level feel of UI

* feat: Add smooth macOS-style page transitions

- Add fullscreen crossfade transition for login/logout (300ms fade-out + 400ms fade-in)
- Add slide-in-from-right animation for all page switches (Dashboard, Terminal, SSH Manager, Admin, Profile)
- Fix TypeScript compilation by adding esModuleInterop to tsconfig.node.json
- Pass handleLogout from DesktopApp to LeftSidebar for consistent transition behavior

All page transitions now use Tailwind animate-in utilities with 300ms duration for smooth, native-feeling UX

* fix: Add key prop to force animation re-trigger on tab switch

Each page container now has key={currentTab} to ensure React unmounts and remounts the element on every tab switch, properly triggering the slide-in animation

* revert: Remove page transition animations

Page switching animations were not noticeable enough and felt unnecessary.
Keep only the login/logout fullscreen crossfade transitions which provide clear visual feedback for authentication state changes

* feat: Add ripple effect to login/logout transitions

Add three-layer expanding ripple animation during fadeOut phase:
- Ripples expand from screen center using primary theme color
- Each layer has staggered delay (0ms, 150ms, 300ms) for wave effect
- Ripples fade out as they expand to create elegant visual feedback
- Uses pure CSS keyframe animation, no external libraries

Total animation: 800ms ripple + 300ms screen fade

* feat: Add smooth TERMIX logo animation to transitions

Changes:
- Extend transition duration from 300ms/400ms to 800ms/600ms for more elegant feel
- Reduce ripple intensity from /20,/15,/10 to /8,/5 for subtlety
- Slow down ripple animation from 0.8s to 2s with cubic-bezier easing
- Add centered TERMIX logo with monospace font and subtitle
- Logo fades in from 80% scale, holds, then fades out at 110% scale
- Total effect: 1.2s logo animation synced with 2s ripple waves

Creates a premium, branded transition experience

* feat: Enhance transition animation with premium details

Timing adjustments:
- Extend fadeOut from 800ms to 1200ms
- Extend fadeIn from 600ms to 800ms
- Slow background fade to 700ms for elegance

Visual enhancements:
- Add 4-layer ripple waves (10%, 7%, 5%, 3% opacity) with staggered delays
- Ripple animation extended to 2.5s with refined opacity curve
- Logo blur effect: starts at 8px, sharpens to 0px, exits at 4px
- Logo glow effect: triple-layer text-shadow using primary theme color
- Increase logo size from text-6xl to text-7xl
- Subtitle delayed fade-in from bottom with smooth slide animation

Creates a cinematic, polished brand experience

* feat: Redesign login page with split-screen cinematic layout

Major redesign of authentication page:

Left Side (40% width):
- Full-height gradient background using primary theme color
- Large TERMIX logo with glow effect
- Subtitle and tagline
- Infinite animated ripple waves (3 layers)
- Hidden on mobile, shows brand identity

Right Side (60% width):
- Centered glassmorphism card with backdrop blur
- Refined tab switcher with pill-style active state
- Enlarged title with gradient text effect
- Added welcome subtitles for better UX
- Card slides in from bottom on load
- All existing functionality preserved

Visual enhancements:
- Tab navigation: segmented control style in muted container
- Active tab: white background with subtle shadow
- Smooth 200ms transitions on all interactions
- Card: rounded-2xl, shadow-xl, semi-transparent border

Creates premium, modern login experience matching transition animations

* feat: Update login page theme colors and add i18n support

- Changed login page gradient from blue to match dark theme colors
- Updated ripple effects to use theme primary color
- Added i18n translation keys for login page (auth.tagline, auth.description, auth.welcomeBack, auth.createAccount, auth.continueExternal)
- Updated all language files (en, zh, de, ru, pt-BR) with new translations
- Fixed TypeScript compilation issues by clearing build cache

* refactor: Use shadcn Tabs component and fix modal styling

- Replace custom tab navigation with shadcn Tabs component
- Restore border-2 border-dark-border for modal consistency
- Remove circular icon from login success message
- Simplify authentication success display

* refactor: Remove ripple effects and gradient from login page

- Remove animated ripple background effects
- Remove gradient background, use solid color (bg-dark-bg-darker)
- Remove text-shadow glow effect from logo
- Simplify brand showcase to clean, minimal design

* feat: Add decorative slash and remove subtitle from login page

- Add decorative slash divider with gradient lines below TERMIX logo
- Remove subtitle text (welcomeBack and createAccount)
- Simplify page title to show only the main heading

* feat: Add diagonal line pattern background to login page

- Replace decorative slash with subtle diagonal line pattern background
- Use repeating-linear-gradient at 45deg angle
- Set very low opacity (0.03) for subtle effect
- Pattern uses theme primary color

* fix: Display diagonal line pattern on login background

- Combine background color and pattern in single style attribute
- Use white semi-transparent lines (rgba 0.03 opacity)
- 45deg angle, 35px spacing, 2px width
- Remove separate overlay div to ensure pattern visibility

* security: Fix user enumeration vulnerability in login

- Unify error messages for invalid username and incorrect password
- Both return 401 status with 'Invalid username or password'
- Prevent attackers from enumerating valid usernames
- Maintain detailed logging for debugging purposes
- Changed from 404 'User not found' to generic auth failure message

* security: Add login rate limiting to prevent brute force attacks

- Implement LoginRateLimiter with IP and username-based tracking
- Block after 5 failed attempts within 15 minutes
- Lock account/IP for 15 minutes after threshold
- Automatic cleanup of expired entries every 5 minutes
- Track remaining attempts in logs for monitoring
- Return 429 status with remaining time on rate limit
- Reset counters on successful login
- Dual protection: both IP-based and username-based limits

* French translation (#434)

* Adding French Language

* Enhancements

* feat: Replace the old ssh tools system with a new dedicated sidebar

* fix: Merge zac/luke

* fix: Finalize new sidebar, improve and loading animations

* Added ability to close non-primary tabs involved in a split view (#435)

* fix: General bug fixes/small feature improvements

* feat: General UI improvements and translation updates

* fix: Command history and file manager styling issues

* feat: General bug fixes, added server stat commands, improved split screen, link accounts, etc

* fix: add Accept header for OIDC callback request (#436)

* Delete DOWNLOADS.md

* fix: add Accept header for OIDC callback request

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* fix: More bug fixes and QOL fixes

* fix: Server stats not respecting interval and fixed SSH toool type issues

* fix: Remove github links

* fix: Delete account spacing

* fix: Increment version

* fix: Unable to delete hosts and add nginx for terminal

* fix: Unable to delete hosts

* fix: Unable to delete hosts

* fix: Unable to delete hosts

* fix: OIDC/local account linking breaking both logins

* chore: File cleanup

* feat: Max terminal tab size and save current file manager sorting type

* fix: Terminal display issue, migrate host editor to use combobox

* feat: Add snippet folder/customization system

* fix: Fix OIDC linking and prep release

* fix: Increment version

---------

Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Max <herzmaximilian@gmail.com>
Co-authored-by: SlimGary <trash.slim@gmail.com>
Co-authored-by: jarrah31 <jarrah31@gmail.com>
Co-authored-by: Kf637 <mail@kf637.tech>
This commit is contained in:
Luke Gustafson
2025-11-17 09:46:05 -06:00
committed by GitHub
co-authored by ZacharyZcR Copilot Max SlimGary jarrah31 Kf637
parent 38a59f3579
commit 8366c99b0f
104 changed files with 16069 additions and 2820 deletions
+69 -47
View File
@@ -35,10 +35,14 @@ interface TabData {
interface TerminalViewProps {
isTopbarOpen?: boolean;
rightSidebarOpen?: boolean;
rightSidebarWidth?: number;
}
export function AppView({
isTopbarOpen = true,
rightSidebarOpen = false,
rightSidebarWidth = 400,
}: TerminalViewProps): React.ReactElement {
const { tabs, currentTab, allSplitScreenTab, removeTab } = useTabs() as {
tabs: TabData[];
@@ -66,16 +70,17 @@ export function AppView({
);
const [ready, setReady] = useState<boolean>(true);
const [resetKey, setResetKey] = useState<number>(0);
const previousStylesRef = useRef<Record<number, React.CSSProperties>>({});
const updatePanelRects = () => {
const updatePanelRects = React.useCallback(() => {
const next: Record<string, DOMRect | null> = {};
Object.entries(panelRefs.current).forEach(([id, el]) => {
if (el) next[id] = el.getBoundingClientRect();
});
setPanelRects(next);
};
}, []);
const fitActiveAndNotify = () => {
const fitActiveAndNotify = React.useCallback(() => {
const visibleIds: number[] = [];
if (allSplitScreenTab.length === 0) {
if (currentTab) visibleIds.push(currentTab);
@@ -91,10 +96,10 @@ export function AppView({
if (ref?.refresh) ref.refresh();
}
});
};
}, [allSplitScreenTab, currentTab, terminalTabs]);
const layoutScheduleRef = useRef<number | null>(null);
const scheduleMeasureAndFit = () => {
const scheduleMeasureAndFit = React.useCallback(() => {
if (layoutScheduleRef.current)
cancelAnimationFrame(layoutScheduleRef.current);
layoutScheduleRef.current = requestAnimationFrame(() => {
@@ -103,18 +108,16 @@ export function AppView({
fitActiveAndNotify();
});
});
};
}, [updatePanelRects, fitActiveAndNotify]);
const hideThenFit = () => {
setReady(false);
const hideThenFit = React.useCallback(() => {
requestAnimationFrame(() => {
updatePanelRects();
requestAnimationFrame(() => {
fitActiveAndNotify();
setReady(true);
});
});
};
}, [updatePanelRects, fitActiveAndNotify]);
const prevStateRef = useRef({
terminalTabsLength: terminalTabs.length,
@@ -154,11 +157,20 @@ export function AppView({
terminalTabs.length,
allSplitScreenTab.join(","),
terminalTabs,
hideThenFit,
]);
useEffect(() => {
scheduleMeasureAndFit();
}, [allSplitScreenTab.length, isTopbarOpen, sidebarState, resetKey]);
}, [
scheduleMeasureAndFit,
allSplitScreenTab.length,
isTopbarOpen,
sidebarState,
resetKey,
rightSidebarOpen,
rightSidebarWidth,
]);
useEffect(() => {
const roContainer = containerRef.current
@@ -170,7 +182,7 @@ export function AppView({
if (containerRef.current && roContainer)
roContainer.observe(containerRef.current);
return () => roContainer?.disconnect();
}, []);
}, [updatePanelRects, fitActiveAndNotify]);
useEffect(() => {
const onWinResize = () => {
@@ -179,7 +191,7 @@ export function AppView({
};
window.addEventListener("resize", onWinResize);
return () => window.removeEventListener("resize", onWinResize);
}, []);
}, [updatePanelRects, fitActiveAndNotify]);
const HEADER_H = 28;
@@ -191,46 +203,47 @@ export function AppView({
const renderTerminalsLayer = () => {
const styles: Record<number, React.CSSProperties> = {};
const splitTabs = terminalTabs.filter((tab: TabData) =>
allSplitScreenTab.includes(tab.id),
);
const layoutTabs = allSplitScreenTab
.map((tabId) => terminalTabs.find((tab: TabData) => tab.id === tabId))
.filter((t): t is TabData => t !== null && t !== undefined);
const mainTab = terminalTabs.find((tab: TabData) => tab.id === currentTab);
const layoutTabs = [
mainTab,
...splitTabs.filter(
(t: TabData) => t && t.id !== (mainTab && (mainTab as TabData).id),
),
].filter((t): t is TabData => t !== null && t !== undefined);
if (allSplitScreenTab.length === 0 && mainTab) {
const isFileManagerTab = mainTab.type === "file_manager";
styles[mainTab.id] = {
position: "absolute",
const newStyle = {
position: "absolute" as const,
top: isFileManagerTab ? 0 : 4,
left: isFileManagerTab ? 0 : 4,
right: isFileManagerTab ? 0 : 4,
bottom: isFileManagerTab ? 0 : 4,
zIndex: 20,
display: "block",
pointerEvents: "auto",
opacity: ready ? 1 : 0,
display: "block" as const,
pointerEvents: "auto" as const,
opacity: 1,
transition: "opacity 150ms ease-in-out",
};
styles[mainTab.id] = newStyle;
previousStylesRef.current[mainTab.id] = newStyle;
} else {
layoutTabs.forEach((t: TabData) => {
const rect = panelRects[String(t.id)];
const parentRect = containerRef.current?.getBoundingClientRect();
if (rect && parentRect) {
styles[t.id] = {
position: "absolute",
const newStyle = {
position: "absolute" as const,
top: rect.top - parentRect.top + HEADER_H + 4,
left: rect.left - parentRect.left + 4,
width: rect.width - 8,
height: rect.height - HEADER_H - 8,
zIndex: 20,
display: "block",
pointerEvents: "auto",
opacity: ready ? 1 : 0,
display: "block" as const,
pointerEvents: "auto" as const,
opacity: 1,
transition: "opacity 150ms ease-in-out",
};
styles[t.id] = newStyle;
previousStylesRef.current[t.id] = newStyle;
}
});
}
@@ -244,17 +257,29 @@ export function AppView({
const isVisible =
hasStyle || (allSplitScreenTab.length === 0 && t.id === currentTab);
const previousStyle = previousStylesRef.current[t.id];
const isFileManagerTab = t.type === "file_manager";
const standardStyle = {
position: "absolute" as const,
top: isFileManagerTab ? 0 : 4,
left: isFileManagerTab ? 0 : 4,
right: isFileManagerTab ? 0 : 4,
bottom: isFileManagerTab ? 0 : 4,
};
const finalStyle: React.CSSProperties = hasStyle
? { ...styles[t.id], overflow: "hidden" }
: ({
position: "absolute",
inset: 0,
visibility: "hidden",
...(previousStyle || standardStyle),
opacity: 0,
pointerEvents: "none",
zIndex: 0,
transition: "opacity 150ms ease-in-out",
overflow: "hidden",
} as React.CSSProperties);
const effectiveVisible = isVisible && ready;
const effectiveVisible = isVisible;
const isTerminal = t.type === "terminal";
const terminalConfig = {
@@ -325,16 +350,9 @@ export function AppView({
};
const renderSplitOverlays = () => {
const splitTabs = terminalTabs.filter((tab: TabData) =>
allSplitScreenTab.includes(tab.id),
);
const mainTab = terminalTabs.find((tab: TabData) => tab.id === currentTab);
const layoutTabs = [
mainTab,
...splitTabs.filter(
(t: TabData) => t && t.id !== (mainTab && (mainTab as TabData).id),
),
].filter((t): t is TabData => t !== null && t !== undefined);
const layoutTabs = allSplitScreenTab
.map((tabId) => terminalTabs.find((tab: TabData) => tab.id === tabId))
.filter((t): t is TabData => t !== null && t !== undefined);
if (allSplitScreenTab.length === 0) return null;
const handleStyle = {
@@ -648,10 +666,14 @@ export function AppView({
style={{
background: containerBackground,
marginLeft: leftMarginPx,
marginRight: 17,
marginRight: rightSidebarOpen
? `calc(var(--right-sidebar-width, ${rightSidebarWidth}px) + 8px)`
: 17,
marginTop: topMarginPx,
marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
transition:
"margin-left 200ms linear, margin-right 200ms linear, margin-top 200ms linear",
}}
>
{renderTerminalsLayer()}
+86 -26
View File
@@ -34,8 +34,9 @@ import {
import { Input } from "@/components/ui/input.tsx";
import { Button } from "@/components/ui/button.tsx";
import { FolderCard } from "@/ui/desktop/navigation/hosts/FolderCard.tsx";
import { getSSHHosts } from "@/ui/main-axios.ts";
import { getSSHHosts, getSSHFolders } from "@/ui/main-axios.ts";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import type { SSHFolder } from "@/types/index.ts";
interface SSHHost {
id: number;
@@ -65,6 +66,7 @@ interface SidebarProps {
isAdmin?: boolean;
username?: string | null;
children?: React.ReactNode;
onLogout?: () => void;
}
async function handleLogout() {
@@ -87,6 +89,7 @@ export function LeftSidebar({
isAdmin,
username,
children,
onLogout,
}: SidebarProps): React.ReactElement {
const { t } = useTranslation();
@@ -112,7 +115,11 @@ export function LeftSidebar({
Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
const openSshManagerTab = () => {
if (sshManagerTab || isSplitScreenActive) return;
if (isSplitScreenActive) return;
if (sshManagerTab) {
setCurrentTab(sshManagerTab.id);
return;
}
const id = addTab({ type: "ssh_manager", title: "Host Manager" });
setCurrentTab(id);
};
@@ -143,6 +150,22 @@ export function LeftSidebar({
const prevHostsRef = React.useRef<SSHHost[]>([]);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [folderMetadata, setFolderMetadata] = useState<Map<string, SSHFolder>>(
new Map(),
);
const fetchFolderMetadata = React.useCallback(async () => {
try {
const folders = await getSSHFolders();
const metadataMap = new Map<string, SSHFolder>();
folders.forEach((folder) => {
metadataMap.set(folder.name, folder);
});
setFolderMetadata(metadataMap);
} catch (error) {
console.error("Failed to fetch folder metadata:", error);
}
}, []);
const fetchHosts = React.useCallback(async () => {
try {
@@ -208,17 +231,25 @@ export function LeftSidebar({
React.useEffect(() => {
fetchHosts();
const interval = setInterval(fetchHosts, 300000);
fetchFolderMetadata();
const interval = setInterval(() => {
fetchHosts();
fetchFolderMetadata();
}, 300000);
return () => clearInterval(interval);
}, [fetchHosts]);
}, [fetchHosts, fetchFolderMetadata]);
React.useEffect(() => {
const handleHostsChanged = () => {
fetchHosts();
fetchFolderMetadata();
};
const handleCredentialsChanged = () => {
fetchHosts();
};
const handleFoldersChanged = () => {
fetchFolderMetadata();
};
window.addEventListener(
"ssh-hosts:changed",
handleHostsChanged as EventListener,
@@ -227,6 +258,10 @@ export function LeftSidebar({
"credentials:changed",
handleCredentialsChanged as EventListener,
);
window.addEventListener(
"folders:changed",
handleFoldersChanged as EventListener,
);
return () => {
window.removeEventListener(
"ssh-hosts:changed",
@@ -236,8 +271,12 @@ export function LeftSidebar({
"credentials:changed",
handleCredentialsChanged as EventListener,
);
window.removeEventListener(
"folders:changed",
handleFoldersChanged as EventListener,
);
};
}, [fetchHosts]);
}, [fetchHosts, fetchFolderMetadata]);
React.useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(search), 200);
@@ -250,7 +289,11 @@ export function LeftSidebar({
const [sidebarWidth, setSidebarWidth] = useState<number>(() => {
const saved = localStorage.getItem("leftSidebarWidth");
return saved !== null ? parseInt(saved, 10) : 250;
const defaultWidth = 250;
const savedWidth = saved !== null ? parseInt(saved, 10) : defaultWidth;
const minWidth = Math.min(200, Math.floor(window.innerWidth * 0.15));
const maxWidth = Math.floor(window.innerWidth * 0.3);
return Math.min(savedWidth, Math.max(minWidth, maxWidth));
});
const [isResizing, setIsResizing] = useState(false);
@@ -261,6 +304,20 @@ export function LeftSidebar({
localStorage.setItem("leftSidebarWidth", String(sidebarWidth));
}, [sidebarWidth]);
React.useEffect(() => {
const handleResize = () => {
const minWidth = Math.min(200, Math.floor(window.innerWidth * 0.15));
const maxWidth = Math.floor(window.innerWidth * 0.3);
if (sidebarWidth > maxWidth) {
setSidebarWidth(Math.max(minWidth, maxWidth));
} else if (sidebarWidth < minWidth) {
setSidebarWidth(minWidth);
}
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [sidebarWidth]);
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
@@ -275,8 +332,8 @@ export function LeftSidebar({
if (startXRef.current == null) return;
const dx = e.clientX - startXRef.current;
const newWidth = Math.round(startWidthRef.current + dx);
const minWidth = 200;
const maxWidth = Math.round(window.innerWidth * 0.5);
const minWidth = Math.min(200, Math.floor(window.innerWidth * 0.15));
const maxWidth = Math.round(window.innerWidth * 0.3);
if (newWidth >= minWidth && newWidth <= maxWidth) {
setSidebarWidth(newWidth);
} else if (newWidth < minWidth) {
@@ -355,14 +412,14 @@ export function LeftSidebar({
}, []);
return (
<div className="min-h-svh">
<div className="h-screen w-screen overflow-hidden">
<SidebarProvider
open={isSidebarOpen}
style={
{ "--sidebar-width": `${sidebarWidth}px` } as React.CSSProperties
}
>
<div className="flex h-screen w-full">
<div className="flex h-screen w-screen overflow-hidden">
<Sidebar variant="floating">
<SidebarHeader>
<SidebarGroupLabel className="text-lg font-bold text-white">
@@ -394,13 +451,11 @@ export function LeftSidebar({
className="m-2 flex flex-row font-semibold border-2 !border-dark-border"
variant="outline"
onClick={openSshManagerTab}
disabled={!!sshManagerTab || isSplitScreenActive}
disabled={isSplitScreenActive}
title={
sshManagerTab
? t("interface.sshManagerAlreadyOpen")
: isSplitScreenActive
? t("interface.disabledDuringSplitScreen")
: undefined
isSplitScreenActive
? t("interface.disabledDuringSplitScreen")
: undefined
}
>
<HardDrive strokeWidth="2.5" />
@@ -435,15 +490,20 @@ export function LeftSidebar({
</div>
)}
{sortedFolders.map((folder, idx) => (
<FolderCard
key={`folder-${folder}-${hostsByFolder[folder]?.length || 0}`}
folderName={folder}
hosts={getSortedHosts(hostsByFolder[folder])}
isFirst={idx === 0}
isLast={idx === sortedFolders.length - 1}
/>
))}
{sortedFolders.map((folder, idx) => {
const metadata = folderMetadata.get(folder);
return (
<FolderCard
key={`folder-${folder}-${hostsByFolder[folder]?.length || 0}`}
folderName={folder}
hosts={getSortedHosts(hostsByFolder[folder])}
isFirst={idx === 0}
isLast={idx === sortedFolders.length - 1}
folderColor={metadata?.color}
folderIcon={metadata?.icon}
/>
);
})}
</SidebarGroup>
</SidebarContent>
<Separator className="p-0.25 mt-1 mb-1" />
@@ -486,7 +546,7 @@ export function LeftSidebar({
)}
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onClick={handleLogout}
onClick={onLogout || handleLogout}
>
<span>{t("common.logout")}</span>
</DropdownMenuItem>
+2 -2
View File
@@ -137,10 +137,10 @@ export function SSHAuthDialog({
return (
<div
className="absolute inset-0 z-50 flex items-center justify-center bg-dark-bg"
className="absolute inset-0 z-9999 flex items-center justify-center bg-dark-bg animate-in fade-in duration-200"
style={{ backgroundColor }}
>
<Card className="w-full max-w-2xl mx-4 border-2">
<Card className="w-full max-w-2xl mx-4 border-2 animate-in fade-in zoom-in-95 duration-200">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="w-5 h-5" />
+2 -2
View File
@@ -25,12 +25,12 @@ export function TOTPDialog({
if (!isOpen) return null;
return (
<div className="absolute inset-0 flex items-center justify-center z-50">
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
<div
className="absolute inset-0 bg-dark-bg rounded-md"
style={{ backgroundColor: backgroundColor || undefined }}
/>
<div className="bg-dark-bg border-2 border-dark-border rounded-lg p-6 max-w-md w-full mx-4 relative z-10">
<div className="bg-dark-bg border-2 border-dark-border rounded-lg p-6 max-w-md w-full mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
<div className="mb-4 flex items-center gap-2">
<Shield className="w-5 h-5 text-primary" />
<h3 className="text-lg font-semibold">
+89 -34
View File
@@ -2,14 +2,13 @@ import React, { useState } from "react";
import { flushSync } from "react-dom";
import { useSidebar } from "@/components/ui/sidebar.tsx";
import { Button } from "@/components/ui/button.tsx";
import { ChevronDown, ChevronUpIcon } from "lucide-react";
import { ChevronDown, ChevronUpIcon, Hammer } from "lucide-react";
import { Tab } from "@/ui/desktop/navigation/tabs/Tab.tsx";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import { useTranslation } from "react-i18next";
import { TabDropdown } from "@/ui/desktop/navigation/tabs/TabDropdown.tsx";
import { SnippetsSidebar } from "@/ui/desktop/apps/terminal/SnippetsSidebar.tsx";
import { SSHToolsSidebar } from "@/ui/desktop/apps/tools/SSHToolsSidebar.tsx";
import { ToolsMenu } from "@/ui/desktop/apps/tools/ToolsMenu.tsx";
import { useCommandHistory } from "@/ui/desktop/apps/terminal/command-history/CommandHistoryContext.tsx";
interface TabData {
id: number;
@@ -26,11 +25,15 @@ interface TabData {
interface TopNavbarProps {
isTopbarOpen: boolean;
setIsTopbarOpen: (open: boolean) => void;
onOpenCommandPalette: () => void;
onRightSidebarStateChange?: (isOpen: boolean, width: number) => void;
}
export function TopNavbar({
isTopbarOpen,
setIsTopbarOpen,
onOpenCommandPalette,
onRightSidebarStateChange,
}: TopNavbarProps): React.ReactElement {
const { state } = useSidebar();
const {
@@ -53,9 +56,56 @@ export function TopNavbar({
const leftPosition =
state === "collapsed" ? "26px" : "calc(var(--sidebar-width) + 8px)";
const { t } = useTranslation();
const commandHistory = useCommandHistory();
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
const [snippetsSidebarOpen, setSnippetsSidebarOpen] = useState(false);
const [toolsSidebarOpen, setToolsSidebarOpen] = useState(false);
const [commandHistoryTabActive, setCommandHistoryTabActive] = useState(false);
const [splitScreenTabActive, setSplitScreenTabActive] = useState(false);
const [rightSidebarWidth, setRightSidebarWidth] = useState<number>(() => {
const saved = localStorage.getItem("rightSidebarWidth");
const defaultWidth = 400;
const savedWidth = saved !== null ? parseInt(saved, 10) : defaultWidth;
const minWidth = Math.min(300, Math.floor(window.innerWidth * 0.2));
const maxWidth = Math.floor(window.innerWidth * 0.3);
return Math.min(savedWidth, Math.max(minWidth, maxWidth));
});
React.useEffect(() => {
localStorage.setItem("rightSidebarWidth", String(rightSidebarWidth));
}, [rightSidebarWidth]);
React.useEffect(() => {
const handleResize = () => {
const minWidth = Math.min(300, Math.floor(window.innerWidth * 0.2));
const maxWidth = Math.floor(window.innerWidth * 0.3);
if (rightSidebarWidth > maxWidth) {
setRightSidebarWidth(Math.max(minWidth, maxWidth));
} else if (rightSidebarWidth < minWidth) {
setRightSidebarWidth(minWidth);
}
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [rightSidebarWidth]);
React.useEffect(() => {
if (onRightSidebarStateChange) {
onRightSidebarStateChange(toolsSidebarOpen, rightSidebarWidth);
}
}, [toolsSidebarOpen, rightSidebarWidth, onRightSidebarStateChange]);
const openCommandHistorySidebar = React.useCallback(() => {
setToolsSidebarOpen(true);
setCommandHistoryTabActive(true);
}, []);
React.useEffect(() => {
commandHistory.setOpenCommandHistory(openCommandHistorySidebar);
}, [commandHistory, openCommandHistorySidebar]);
const rightPosition = toolsSidebarOpen
? `calc(var(--right-sidebar-width, ${rightSidebarWidth}px) + 8px)`
: "17px";
const [justDroppedTabId, setJustDroppedTabId] = useState<number | null>(null);
const [isInDropAnimation, setIsInDropAnimation] = useState(false);
const [dragState, setDragState] = useState<{
@@ -82,7 +132,9 @@ export function TopNavbar({
};
const handleTabSplit = (tabId: number) => {
setSplitScreenTab(tabId);
setToolsSidebarOpen(true);
setCommandHistoryTabActive(false);
setSplitScreenTabActive(true);
};
const handleTabClose = (tabId: number) => {
@@ -296,12 +348,13 @@ export function TopNavbar({
return (
<div>
<div
className="fixed z-10 h-[50px] border-2 border-dark-border rounded-lg transition-all duration-200 ease-linear flex flex-row transform-none m-0 p-0"
className="fixed z-10 h-[50px] border-2 border-dark-border rounded-lg flex flex-row transform-none m-0 p-0"
style={{
top: isTopbarOpen ? "0.5rem" : "-3rem",
left: leftPosition,
right: "17px",
right: rightPosition,
backgroundColor: "#18181b",
transition: "top 200ms linear, left 200ms linear, right 200ms linear",
}}
>
<div
@@ -320,17 +373,7 @@ export function TopNavbar({
const isAdmin = tab.type === "admin";
const isUserProfile = tab.type === "user_profile";
const isSplittable = isTerminal || isServer || isFileManager;
const isSplitButtonDisabled =
(isActive && !isSplitScreenActive) ||
((allSplitScreenTab?.length || 0) >= 3 && !isSplit);
const disableSplit =
!isSplittable ||
isSplitButtonDisabled ||
isActive ||
currentTabIsHome ||
currentTabIsSshManager ||
currentTabIsAdmin ||
currentTabIsUserProfile;
const disableSplit = !isSplittable;
const disableActivate =
isSplit ||
((tab.type === "home" ||
@@ -339,8 +382,7 @@ export function TopNavbar({
tab.type === "user_profile") &&
isSplitScreenActive);
const isHome = tab.type === "home";
const disableClose =
(isSplitScreenActive && isActive) || isSplit || isHome;
const disableClose = isHome;
const isDraggingThisTab = dragState.draggedIndex === index;
const isTheDraggedTab = tab.id === dragState.draggedId;
@@ -428,6 +470,7 @@ export function TopNavbar({
WebkitUserSelect: "none",
flex: tab.type === "home" ? "0 0 auto" : "1 1 150px",
minWidth: tab.type === "home" ? "auto" : "150px",
maxWidth: tab.type === "home" ? "auto" : "450px",
display: "flex",
}}
>
@@ -473,10 +516,14 @@ export function TopNavbar({
<div className="flex items-center justify-center gap-2 flex-1 px-2">
<TabDropdown />
<ToolsMenu
onOpenSshTools={() => setToolsSheetOpen(true)}
onOpenSnippets={() => setSnippetsSidebarOpen(true)}
/>
<Button
variant="outline"
onClick={() => setToolsSidebarOpen(!toolsSidebarOpen)}
className="w-[30px] h-[30px] border-dark-border"
title={t("nav.tools")}
>
<Hammer className="h-4 w-4" />
</Button>
<Button
variant="outline"
@@ -494,7 +541,7 @@ export function TopNavbar({
className="fixed top-0 cursor-pointer flex items-center justify-center rounded-bl-md rounded-br-md"
style={{
left: leftPosition,
right: "17px",
right: rightPosition,
height: "10px",
zIndex: 9999,
backgroundColor: "#18181b",
@@ -507,14 +554,22 @@ export function TopNavbar({
)}
<SSHToolsSidebar
isOpen={toolsSheetOpen}
onClose={() => setToolsSheetOpen(false)}
/>
<SnippetsSidebar
isOpen={snippetsSidebarOpen}
onClose={() => setSnippetsSidebarOpen(false)}
onExecute={handleSnippetExecute}
isOpen={toolsSidebarOpen}
onClose={() => setToolsSidebarOpen(false)}
onSnippetExecute={handleSnippetExecute}
sidebarWidth={rightSidebarWidth}
setSidebarWidth={setRightSidebarWidth}
initialTab={
commandHistoryTabActive
? "command-history"
: splitScreenTabActive
? "split-screen"
: undefined
}
onTabChange={() => {
setCommandHistoryTabActive(false);
setSplitScreenTabActive(false);
}}
/>
</div>
);
@@ -0,0 +1,61 @@
import React from "react";
import { cn } from "@/lib/utils.ts";
interface SimpleLoaderProps {
visible: boolean;
message?: string;
className?: string;
backgroundColor?: string;
}
export function SimpleLoader({
visible,
message,
className,
backgroundColor,
}: SimpleLoaderProps) {
if (!visible) {
return null;
}
return (
<>
<style>
{`
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.simple-spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(255, 255, 255, 0.1);
border-top-color: rgba(255, 255, 255, 0.8);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
`}
</style>
<div
className={cn(
"absolute inset-0 flex items-center justify-center z-50",
className,
)}
style={{ backgroundColor: backgroundColor || "#18181b" }}
>
<div className="flex flex-col items-center gap-4">
<div className="simple-spinner"></div>
{message && (
<p className="text-sm text-gray-300 font-medium">{message}</p>
)}
</div>
</div>
</>
);
}
+46 -2
View File
@@ -1,6 +1,18 @@
import React, { useState } from "react";
import { CardTitle } from "@/components/ui/card.tsx";
import { ChevronDown, Folder } from "lucide-react";
import {
ChevronDown,
Folder,
Server,
Cloud,
Database,
Box,
Package,
Layers,
Archive,
HardDrive,
Globe,
} from "lucide-react";
import { Button } from "@/components/ui/button.tsx";
import { Host } from "@/ui/desktop/navigation/hosts/Host.tsx";
import { Separator } from "@/components/ui/separator.tsx";
@@ -40,11 +52,15 @@ interface FolderCardProps {
hosts: SSHHost[];
isFirst: boolean;
isLast: boolean;
folderColor?: string;
folderIcon?: string;
}
export function FolderCard({
folderName,
hosts,
folderColor,
folderIcon,
}: FolderCardProps): React.ReactElement {
const [isExpanded, setIsExpanded] = useState(true);
@@ -52,6 +68,30 @@ export function FolderCard({
setIsExpanded(!isExpanded);
};
const iconMap: Record<
string,
React.ComponentType<{
size?: number;
strokeWidth?: number;
className?: string;
style?: React.CSSProperties;
}>
> = {
Folder,
Server,
Cloud,
Database,
Box,
Package,
Layers,
Archive,
HardDrive,
Globe,
};
const FolderIcon =
folderIcon && iconMap[folderIcon] ? iconMap[folderIcon] : Folder;
return (
<div className="bg-dark-bg-darker border-2 border-dark-border rounded-lg overflow-hidden p-0 m-0">
<div
@@ -59,7 +99,11 @@ export function FolderCard({
>
<div className="flex gap-2 pr-10">
<div className="flex-shrink-0 flex items-center">
<Folder size={16} strokeWidth={3} />
<FolderIcon
size={16}
strokeWidth={3}
style={folderColor ? { color: folderColor } : undefined}
/>
</div>
<div className="flex-1 min-w-0">
<CardTitle className="mb-0 leading-tight break-words text-md">
+18 -2
View File
@@ -27,6 +27,8 @@ interface TabProps {
disableClose?: boolean;
isDragging?: boolean;
isDragOver?: boolean;
isValidDropTarget?: boolean;
isHoveredDropTarget?: boolean;
}
export function Tab({
@@ -44,6 +46,8 @@ export function Tab({
disableClose = false,
isDragging = false,
isDragOver = false,
isValidDropTarget = false,
isHoveredDropTarget = false,
}: TabProps): React.ReactElement {
const { t } = useTranslation();
@@ -54,12 +58,21 @@ export function Tab({
isDragOver &&
"bg-background/40 text-muted-foreground border-border opacity-60",
isDragging && "opacity-70",
isHoveredDropTarget &&
"bg-blue-500/20 border-blue-500 ring-2 ring-blue-500/50",
!isHoveredDropTarget &&
isValidDropTarget &&
"border-blue-400/50 bg-background/90",
!isDragOver &&
!isDragging &&
!isValidDropTarget &&
!isHoveredDropTarget &&
isActive &&
"bg-background text-foreground border-border z-10",
!isDragOver &&
!isDragging &&
!isValidDropTarget &&
!isHoveredDropTarget &&
!isActive &&
"bg-background/80 text-muted-foreground border-border hover:bg-background/90",
);
@@ -130,7 +143,7 @@ export function Tab({
onClick={!disableActivate ? onActivate : undefined}
style={{
marginBottom: "-2px",
borderBottom: isActive ? "2px solid white" : "none",
borderBottom: isActive || isSplit ? "2px solid white" : "none",
}}
>
<div className="flex items-center gap-1.5 flex-1 min-w-0">
@@ -162,7 +175,10 @@ export function Tab({
}
>
<SeparatorVertical
className={cn("h-4 w-4", isSplit && "text-white")}
className={cn(
"h-4 w-4",
isSplit ? "text-white" : "text-muted-foreground",
)}
/>
</Button>
)}
+37 -5
View File
@@ -49,13 +49,24 @@ interface TabProviderProps {
export function TabProvider({ children }: TabProviderProps) {
const { t } = useTranslation();
const [tabs, setTabs] = useState<Tab[]>([
{ id: 1, type: "home", title: t("nav.home") },
const [tabs, setTabs] = useState<Tab[]>(() => [
{ id: 1, type: "home", title: "Home" },
]);
const [currentTab, setCurrentTab] = useState<number>(1);
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
const nextTabId = useRef(2);
// Update home tab title when translation changes
React.useEffect(() => {
setTabs((prev) =>
prev.map((tab) =>
tab.id === 1 && tab.type === "home"
? { ...tab, title: t("nav.home") }
: tab,
),
);
}, [t]);
function computeUniqueTitle(
tabType: Tab["type"],
desiredTitle: string | undefined,
@@ -156,11 +167,32 @@ export function TabProvider({ children }: TabProviderProps) {
}
setTabs((prev) => prev.filter((tab) => tab.id !== tabId));
setAllSplitScreenTab((prev) => prev.filter((id) => id !== tabId));
// Remove from split screen
setAllSplitScreenTab((prev) => {
const newSplits = prev.filter((id) => id !== tabId);
// Auto-clear split mode if only 1 or fewer tabs remain in split
if (newSplits.length <= 1) {
return [];
}
return newSplits;
});
if (currentTab === tabId) {
const remainingTabs = tabs.filter((tab) => tab.id !== tabId);
setCurrentTab(remainingTabs.length > 0 ? remainingTabs[0].id : 1);
if (remainingTabs.length > 0) {
// Try to set current tab to another split tab first, if any remain
const remainingSplitTabs = allSplitScreenTab.filter(
(id) => id !== tabId,
);
if (remainingSplitTabs.length > 0) {
setCurrentTab(remainingSplitTabs[0]);
} else {
setCurrentTab(remainingTabs[0].id);
}
} else {
setCurrentTab(1); // Home tab
}
}
};
@@ -168,7 +200,7 @@ export function TabProvider({ children }: TabProviderProps) {
setAllSplitScreenTab((prev) => {
if (prev.includes(tabId)) {
return prev.filter((id) => id !== tabId);
} else if (prev.length < 3) {
} else if (prev.length < 4) {
return [...prev, tabId];
}
return prev;