fix: file manager incorrectly decoding/encoding when editing files (#476)

* fix: electron build errors and skip macos job

* fix: testflight submit failure

* fix: made submit job match build type

* fix: resolve Vite build warnings for mixed static/dynamic imports (#473)

* Update Crowdin configuration file

* Update Crowdin configuration file

* fix: resolve Vite build warnings for mixed static/dynamic imports

- Convert all dynamic imports of main-axios.ts to static imports (10 files)
- Convert all dynamic imports of sonner to static imports (4 files)
- Add manual chunking configuration to vite.config.ts for better bundle splitting
  - react-vendor: React and React DOM
  - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge
  - monaco: Monaco Editor
  - codemirror: CodeMirror and related packages
- Increase chunkSizeWarningLimit to 1000kB

This resolves Vite warnings about mixed import strategies preventing
proper code-splitting.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>

* fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent)

---------

Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>
This commit was merged in pull request #476.
This commit is contained in:
Luke Gustafson
2026-01-02 00:33:10 -06:00
committed by GitHub
parent fc87146e4b
commit 9936ef469d
18 changed files with 172 additions and 67 deletions

View File

@@ -13,7 +13,7 @@ import { AdminSettings } from "@/ui/desktop/apps/admin/AdminSettings.tsx";
import { UserProfile } from "@/ui/desktop/user/UserProfile.tsx";
import { Toaster } from "@/components/ui/sonner.tsx";
import { CommandPalette } from "@/ui/desktop/apps/command-palette/CommandPalette.tsx";
import { getUserInfo } from "@/ui/main-axios.ts";
import { getUserInfo, logoutUser, isElectron } from "@/ui/main-axios.ts";
import { useTheme } from "@/components/theme-provider";
function AppContent() {
@@ -163,7 +163,6 @@ function AppContent() {
setTimeout(async () => {
try {
const { logoutUser, isElectron } = await import("@/ui/main-axios.ts");
await logoutUser();
if (isElectron()) {

View File

@@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button.tsx";
import { getUserAlerts, dismissAlert } from "@/ui/main-axios.ts";
import { useTranslation } from "react-i18next";
import type { TermixAlert } from "../../../../../../types";
import { toast } from "sonner";
interface AlertManagerProps {
userId: string | null;
@@ -53,7 +54,6 @@ export function AlertManager({
setAlerts(sortedAlerts);
setCurrentAlertIndex(0);
} catch {
const { toast } = await import("sonner");
toast.error(t("homepage.failedToLoadAlerts"));
setError(t("homepage.failedToLoadAlerts"));
} finally {

View File

@@ -18,6 +18,7 @@ import {
keepaliveDockerSession,
verifyDockerTOTP,
logActivity,
getSSHHosts,
} from "@/ui/main-axios.ts";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
import { AlertCircle } from "lucide-react";
@@ -121,7 +122,6 @@ export function DockerManager({
const fetchLatestHostConfig = async () => {
if (hostConfig?.id) {
try {
const { getSSHHosts } = await import("@/ui/main-axios.ts");
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) {
@@ -138,7 +138,6 @@ export function DockerManager({
const handleHostsChanged = async () => {
if (hostConfig?.id) {
try {
const { getSSHHosts } = await import("@/ui/main-axios.ts");
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) {

View File

@@ -333,13 +333,7 @@ export function FileViewer({
const ext = fileName.split(".").pop()?.toLowerCase() || "";
if (ext === "svg") {
try {
const base64 = btoa(unescape(encodeURIComponent(content)));
return `data:image/svg+xml;base64,${base64}`;
} catch (e) {
console.error("Failed to encode SVG:", e);
return "";
}
return `data:image/svg+xml;base64,${content}`;
}
return `data:image/*;base64,${content}`;

View File

@@ -47,6 +47,22 @@ interface FileWindowProps {
onFileNotFound?: (file: FileItem) => void;
}
function isDisplayableText(str: string): boolean {
let printable = 0;
for (let i = 0; i < Math.min(str.length, 1000); i++) {
const code = str.charCodeAt(i);
if (
(code >= 32 && code <= 126) ||
code === 9 ||
code === 10 ||
code === 13
) {
printable++;
}
}
return printable / Math.min(str.length, 1000) > 0.85;
}
export function FileWindow({
windowId,
file,
@@ -106,7 +122,19 @@ export function FileWindow({
await ensureSSHConnection();
const response = await readSSHFile(sshSessionId, file.path);
const fileContent = response.content || "";
let fileContent = response.content || "";
if (response.encoding === "base64") {
try {
const decoded = atob(fileContent);
if (isDisplayableText(decoded)) {
fileContent = decoded;
}
} catch (err) {
console.error("Failed to decode base64 content:", err);
}
}
setContent(fileContent);
setPendingContent(fileContent);

View File

@@ -11,6 +11,8 @@ import {
submitMetricsTOTP,
executeSnippet,
logActivity,
sendMetricsHeartbeat,
getSSHHosts,
type ServerMetrics,
} from "@/ui/main-axios.ts";
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
@@ -145,7 +147,6 @@ export function ServerStats({
const heartbeatInterval = setInterval(async () => {
try {
const { sendMetricsHeartbeat } = await import("@/ui/main-axios.ts");
await sendMetricsHeartbeat(viewerSessionId);
} catch (error) {
console.error("Failed to send heartbeat:", error);
@@ -273,7 +274,6 @@ export function ServerStats({
const fetchLatestHostConfig = async () => {
if (hostConfig?.id) {
try {
const { getSSHHosts } = await import("@/ui/main-axios.ts");
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) {
@@ -290,7 +290,6 @@ export function ServerStats({
const handleHostsChanged = async () => {
if (hostConfig?.id) {
try {
const { getSSHHosts } = await import("@/ui/main-axios.ts");
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) {

View File

@@ -18,6 +18,8 @@ import {
isElectron,
logActivity,
getSnippets,
deleteCommandFromHistory,
getCommandHistory,
} from "@/ui/main-axios.ts";
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx";
@@ -212,8 +214,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
if (showHistoryDialog && hostConfig.id) {
setIsLoadingHistory(true);
setIsLoadingRef.current(true);
import("@/ui/main-axios.ts")
.then((module) => module.getCommandHistory(hostConfig.id!))
getCommandHistory(hostConfig.id!)
.then((history) => {
setCommandHistory(history);
setCommandHistoryContextRef.current(history);
@@ -235,8 +236,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
localStorage.getItem("commandAutocomplete") === "true";
if (hostConfig.id && autocompleteEnabled) {
import("@/ui/main-axios.ts")
.then((module) => module.getCommandHistory(hostConfig.id!))
getCommandHistory(hostConfig.id!)
.then((history) => {
autocompleteHistory.current = history;
})
@@ -1107,8 +1107,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
if (!hostConfig.id) return;
try {
const { deleteCommandFromHistory } =
await import("@/ui/main-axios.ts");
await deleteCommandFromHistory(hostConfig.id, command);
setCommandHistory((prev) => {

View File

@@ -3,6 +3,7 @@ import { useSidebar } from "@/components/ui/sidebar.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import { Tunnel } from "@/ui/desktop/apps/features/tunnel/Tunnel.tsx";
import { useTranslation } from "react-i18next";
import { getSSHHosts } from "@/ui/main-axios.ts";
interface HostConfig {
id: number;
@@ -44,7 +45,6 @@ export function TunnelManager({
const fetchLatestHostConfig = async () => {
if (hostConfig?.id) {
try {
const { getSSHHosts } = await import("@/ui/main-axios.ts");
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) {
@@ -61,7 +61,6 @@ export function TunnelManager({
const handleHostsChanged = async () => {
if (hostConfig?.id) {
try {
const { getSSHHosts } = await import("@/ui/main-axios.ts");
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) {

View File

@@ -5,6 +5,7 @@ import { FormControl, FormItem, FormLabel } from "@/components/ui/form.tsx";
import { getCredentials } from "@/ui/main-axios.ts";
import { useTranslation } from "react-i18next";
import type { Credential } from "../../../../../types";
import { toast } from "sonner";
interface CredentialSelectorProps {
value?: number | null;
@@ -36,7 +37,6 @@ export function CredentialSelector({
: data.credentials || data.data || [];
setCredentials(credentialsArray);
} catch {
const { toast } = await import("sonner");
toast.error(t("credentials.failedToFetchCredentials"));
setCredentials([]);
} finally {

View File

@@ -52,6 +52,7 @@ import {
getHostAccess,
revokeHostAccess,
getSSHHostById,
notifyHostCreatedOrUpdated,
type Role,
type AccessRecord,
} from "@/ui/main-axios.ts";
@@ -819,8 +820,6 @@ export function HostManagerEditor({
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
if (savedHost?.id) {
const { notifyHostCreatedOrUpdated } =
await import("@/ui/main-axios.ts");
notifyHostCreatedOrUpdated(savedHost.id);
}
} catch (error) {

View File

@@ -26,6 +26,7 @@ import {
updateFolderMetadata,
deleteAllHostsInFolder,
getServerStatusById,
refreshServerPolling,
} from "@/ui/main-axios.ts";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
@@ -313,7 +314,6 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
await fetchHosts();
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
const { refreshServerPolling } = await import("@/ui/main-axios.ts");
refreshServerPolling();
} catch {
toast.error(t("hosts.failedToDeleteHost"));

View File

@@ -18,7 +18,7 @@ import {
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext";
import { getServerStatusById } from "@/ui/main-axios";
import { getServerStatusById, getSSHHosts } from "@/ui/main-axios";
import type { HostProps } from "../../../../types";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
import { useTranslation } from "react-i18next";
@@ -47,7 +47,6 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
useEffect(() => {
const handleHostsChanged = async () => {
const { getSSHHosts } = await import("@/ui/main-axios.ts");
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === host.id);
if (updatedHost) {

View File

@@ -42,6 +42,7 @@ import { PasswordReset } from "@/ui/desktop/user/PasswordReset.tsx";
import { useTranslation } from "react-i18next";
import { LanguageSwitcher } from "@/ui/desktop/user/LanguageSwitcher.tsx";
import { useSidebar } from "@/components/ui/sidebar.tsx";
import { toast } from "sonner";
interface UserProfileProps {
isTopbarOpen?: boolean;
@@ -144,7 +145,6 @@ export function UserProfile({
const info = await getVersionInfo();
setVersionInfo({ version: info.localVersion });
} catch {
const { toast } = await import("sonner");
toast.error(t("user.failedToLoadVersionInfo"));
}
};

View File

@@ -1,4 +1,5 @@
import axios, { AxiosError, type AxiosInstance } from "axios";
import { toast } from "sonner";
import type {
SSHHost,
SSHHostData,
@@ -446,9 +447,7 @@ function createApiInstance(
if (isSessionExpired && typeof window !== "undefined") {
console.warn("Session expired - please log in again");
import("sonner").then(({ toast }) => {
toast.warning("Session expired. Please log in again.");
});
toast.warning("Session expired. Please log in again.");
}
}
}
@@ -1421,7 +1420,11 @@ export async function identifySSHSymlink(
export async function readSSHFile(
sessionId: string,
path: string,
): Promise<{ content: string; path: string }> {
): Promise<{
content: string;
path: string;
encoding?: "base64" | "utf8";
}> {
try {
const response = await fileManagerApi.get("/ssh/readFile", {
params: { sessionId, path },