feat: Added none password option and fixed some navbar issues (still present)
This commit is contained in:
@@ -678,7 +678,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
|||||||
decrypted.authType,
|
decrypted.authType,
|
||||||
decrypted.password || null,
|
decrypted.password || null,
|
||||||
decrypted.key || null,
|
decrypted.key || null,
|
||||||
decrypted.keyPassword || decrypted.key_password || null,
|
decrypted.key_password || null,
|
||||||
decrypted.keyType || null,
|
decrypted.keyType || null,
|
||||||
decrypted.autostartPassword || null,
|
decrypted.autostartPassword || null,
|
||||||
decrypted.autostartKey || null,
|
decrypted.autostartKey || null,
|
||||||
@@ -721,9 +721,9 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
|||||||
decrypted.username,
|
decrypted.username,
|
||||||
decrypted.password || null,
|
decrypted.password || null,
|
||||||
decrypted.key || null,
|
decrypted.key || null,
|
||||||
decrypted.privateKey || decrypted.private_key || null,
|
decrypted.private_key || null,
|
||||||
decrypted.publicKey || decrypted.public_key || null,
|
decrypted.public_key || null,
|
||||||
decrypted.keyPassword || decrypted.key_password || null,
|
decrypted.key_password || null,
|
||||||
decrypted.keyType || null,
|
decrypted.keyType || null,
|
||||||
decrypted.detectedKeyType || null,
|
decrypted.detectedKeyType || null,
|
||||||
decrypted.usageCount || 0,
|
decrypted.usageCount || 0,
|
||||||
@@ -1006,7 +1006,6 @@ app.post(
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const importedHosts = importDb
|
const importedHosts = importDb
|
||||||
.prepare("SELECT * FROM ssh_data")
|
.prepare("SELECT * FROM ssh_data")
|
||||||
|
|||||||
@@ -338,6 +338,38 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "password_response": {
|
||||||
|
const passwordData = data as TOTPResponseData; // Same structure
|
||||||
|
if (keyboardInteractiveFinish && passwordData?.code) {
|
||||||
|
const password = passwordData.code;
|
||||||
|
sshLogger.info("Password received from user", {
|
||||||
|
operation: "password_response",
|
||||||
|
userId,
|
||||||
|
passwordLength: password.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
keyboardInteractiveFinish([password]);
|
||||||
|
keyboardInteractiveFinish = null;
|
||||||
|
} else {
|
||||||
|
sshLogger.warn(
|
||||||
|
"Password response received but no callback available",
|
||||||
|
{
|
||||||
|
operation: "password_response_error",
|
||||||
|
userId,
|
||||||
|
hasCallback: !!keyboardInteractiveFinish,
|
||||||
|
hasCode: !!passwordData?.code,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message: "Password authentication state lost. Please reconnect.",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
sshLogger.warn("Unknown message type received", {
|
sshLogger.warn("Unknown message type received", {
|
||||||
operation: "websocket_message_unknown_type",
|
operation: "websocket_message_unknown_type",
|
||||||
@@ -779,7 +811,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Non-TOTP prompts (password, etc.) - respond automatically
|
// Non-TOTP prompts (password, etc.)
|
||||||
if (keyboardInteractiveResponded) {
|
if (keyboardInteractiveResponded) {
|
||||||
sshLogger.warn(
|
sshLogger.warn(
|
||||||
"Already responded to keyboard-interactive, ignoring subsequent prompt",
|
"Already responded to keyboard-interactive, ignoring subsequent prompt",
|
||||||
@@ -793,6 +825,54 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
}
|
}
|
||||||
keyboardInteractiveResponded = true;
|
keyboardInteractiveResponded = true;
|
||||||
|
|
||||||
|
// Check if we have stored credentials for auto-response
|
||||||
|
const hasStoredPassword =
|
||||||
|
resolvedCredentials.password &&
|
||||||
|
resolvedCredentials.authType !== "none";
|
||||||
|
|
||||||
|
if (!hasStoredPassword && resolvedCredentials.authType === "none") {
|
||||||
|
// For "none" auth type, prompt user for all keyboard-interactive inputs
|
||||||
|
const passwordPromptIndex = prompts.findIndex((p) =>
|
||||||
|
/password/i.test(p.prompt),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (passwordPromptIndex !== -1) {
|
||||||
|
// Ask user for password
|
||||||
|
keyboardInteractiveFinish = (userResponses: string[]) => {
|
||||||
|
const userInput = (userResponses[0] || "").trim();
|
||||||
|
|
||||||
|
// Build responses for all prompts
|
||||||
|
const responses = prompts.map((p, index) => {
|
||||||
|
if (index === passwordPromptIndex) {
|
||||||
|
return userInput;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
|
||||||
|
sshLogger.info(
|
||||||
|
"User-provided password being sent to SSH server",
|
||||||
|
{
|
||||||
|
operation: "interactive_password_verification",
|
||||||
|
hostId: id,
|
||||||
|
passwordLength: userInput.length,
|
||||||
|
totalPrompts: prompts.length,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
finish(responses);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "password_required",
|
||||||
|
prompt: prompts[passwordPromptIndex].prompt,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-respond with stored credentials
|
||||||
const responses = prompts.map((p) => {
|
const responses = prompts.map((p) => {
|
||||||
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||||
return resolvedCredentials.password;
|
return resolvedCredentials.password;
|
||||||
@@ -882,7 +962,23 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (resolvedCredentials.authType === "key" && resolvedCredentials.key) {
|
if (resolvedCredentials.authType === "none") {
|
||||||
|
// No credentials provided - rely entirely on keyboard-interactive authentication
|
||||||
|
// This mimics the behavior of the ssh command-line client where it prompts for password/TOTP
|
||||||
|
sshLogger.info(
|
||||||
|
"Using interactive authentication (no stored credentials)",
|
||||||
|
{
|
||||||
|
operation: "ssh_auth_none",
|
||||||
|
hostId: id,
|
||||||
|
ip,
|
||||||
|
username,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Don't set password or privateKey - let keyboard-interactive handle everything
|
||||||
|
} else if (
|
||||||
|
resolvedCredentials.authType === "key" &&
|
||||||
|
resolvedCredentials.key
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
if (
|
if (
|
||||||
!resolvedCredentials.key.includes("-----BEGIN") ||
|
!resolvedCredentials.key.includes("-----BEGIN") ||
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export interface SSHHost {
|
|||||||
folder: string;
|
folder: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
pin: boolean;
|
pin: boolean;
|
||||||
authType: "password" | "key" | "credential";
|
authType: "password" | "key" | "credential" | "none";
|
||||||
password?: string;
|
password?: string;
|
||||||
key?: string;
|
key?: string;
|
||||||
keyPassword?: string;
|
keyPassword?: string;
|
||||||
@@ -48,7 +48,7 @@ export interface SSHHostData {
|
|||||||
folder?: string;
|
folder?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
pin?: boolean;
|
pin?: boolean;
|
||||||
authType: "password" | "key" | "credential";
|
authType: "password" | "key" | "credential" | "none";
|
||||||
password?: string;
|
password?: string;
|
||||||
key?: File | null;
|
key?: File | null;
|
||||||
keyPassword?: string;
|
keyPassword?: string;
|
||||||
@@ -298,7 +298,7 @@ export type ErrorType =
|
|||||||
// AUTHENTICATION TYPES
|
// AUTHENTICATION TYPES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export type AuthType = "password" | "key" | "credential";
|
export type AuthType = "password" | "key" | "credential" | "none";
|
||||||
|
|
||||||
export type KeyType = "rsa" | "ecdsa" | "ed25519";
|
export type KeyType = "rsa" | "ecdsa" | "ed25519";
|
||||||
|
|
||||||
|
|||||||
@@ -90,9 +90,9 @@ export function HostManagerEditor({
|
|||||||
Array<{ id: number; username: string; authType: string }>
|
Array<{ id: number; username: string; authType: string }>
|
||||||
>([]);
|
>([]);
|
||||||
|
|
||||||
const [authTab, setAuthTab] = useState<"password" | "key" | "credential">(
|
const [authTab, setAuthTab] = useState<
|
||||||
"password",
|
"password" | "key" | "credential" | "none"
|
||||||
);
|
>("password");
|
||||||
const [keyInputMethod, setKeyInputMethod] = useState<"upload" | "paste">(
|
const [keyInputMethod, setKeyInputMethod] = useState<"upload" | "paste">(
|
||||||
"upload",
|
"upload",
|
||||||
);
|
);
|
||||||
@@ -179,7 +179,7 @@ export function HostManagerEditor({
|
|||||||
folder: z.string().optional(),
|
folder: z.string().optional(),
|
||||||
tags: z.array(z.string().min(1)).default([]),
|
tags: z.array(z.string().min(1)).default([]),
|
||||||
pin: z.boolean().default(false),
|
pin: z.boolean().default(false),
|
||||||
authType: z.enum(["password", "key", "credential"]),
|
authType: z.enum(["password", "key", "credential", "none"]),
|
||||||
credentialId: z.number().optional().nullable(),
|
credentialId: z.number().optional().nullable(),
|
||||||
password: z.string().optional(),
|
password: z.string().optional(),
|
||||||
key: z.any().optional().nullable(),
|
key: z.any().optional().nullable(),
|
||||||
@@ -241,6 +241,11 @@ export function HostManagerEditor({
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.authType === "none") {
|
||||||
|
// No credentials required for "none" auth type - will use keyboard-interactive
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (data.authType === "password") {
|
if (data.authType === "password") {
|
||||||
if (
|
if (
|
||||||
!data.password ||
|
!data.password ||
|
||||||
@@ -356,7 +361,9 @@ export function HostManagerEditor({
|
|||||||
? "credential"
|
? "credential"
|
||||||
: cleanedHost.key
|
: cleanedHost.key
|
||||||
? "key"
|
? "key"
|
||||||
: "password";
|
: cleanedHost.password
|
||||||
|
? "password"
|
||||||
|
: "none";
|
||||||
setAuthTab(defaultAuthType);
|
setAuthTab(defaultAuthType);
|
||||||
|
|
||||||
const formData = {
|
const formData = {
|
||||||
@@ -367,7 +374,7 @@ export function HostManagerEditor({
|
|||||||
folder: cleanedHost.folder || "",
|
folder: cleanedHost.folder || "",
|
||||||
tags: cleanedHost.tags || [],
|
tags: cleanedHost.tags || [],
|
||||||
pin: Boolean(cleanedHost.pin),
|
pin: Boolean(cleanedHost.pin),
|
||||||
authType: defaultAuthType as "password" | "key" | "credential",
|
authType: defaultAuthType as "password" | "key" | "credential" | "none",
|
||||||
credentialId: null,
|
credentialId: null,
|
||||||
password: "",
|
password: "",
|
||||||
key: null,
|
key: null,
|
||||||
@@ -922,7 +929,8 @@ export function HostManagerEditor({
|
|||||||
const newAuthType = value as
|
const newAuthType = value as
|
||||||
| "password"
|
| "password"
|
||||||
| "key"
|
| "key"
|
||||||
| "credential";
|
| "credential"
|
||||||
|
| "none";
|
||||||
setAuthTab(newAuthType);
|
setAuthTab(newAuthType);
|
||||||
form.setValue("authType", newAuthType);
|
form.setValue("authType", newAuthType);
|
||||||
}}
|
}}
|
||||||
@@ -936,6 +944,7 @@ export function HostManagerEditor({
|
|||||||
<TabsTrigger value="credential">
|
<TabsTrigger value="credential">
|
||||||
{t("hosts.credential")}
|
{t("hosts.credential")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="none">{t("hosts.none")}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="password">
|
<TabsContent value="password">
|
||||||
<FormField
|
<FormField
|
||||||
@@ -1154,6 +1163,19 @@ export function HostManagerEditor({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="none">
|
||||||
|
<Alert className="mt-2">
|
||||||
|
<AlertDescription>
|
||||||
|
<strong>{t("hosts.noneAuthTitle")}</strong>
|
||||||
|
<div className="mt-2">
|
||||||
|
{t("hosts.noneAuthDescription")}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm">
|
||||||
|
{t("hosts.noneAuthDetails")}
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="terminal">
|
<TabsContent value="terminal">
|
||||||
|
|||||||
@@ -613,7 +613,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily:
|
fontFamily:
|
||||||
'"Caskaydia Cove Nerd Font Mono", "SF Mono", Consolas, "Liberation Mono", monospace',
|
'"Caskaydia Cove Nerd Font Mono", "SF Mono", Consolas, "Liberation Mono", monospace',
|
||||||
theme: { background: "#18181b", foreground: "#f7f7f7" },
|
|
||||||
allowTransparency: true,
|
allowTransparency: true,
|
||||||
convertEol: true,
|
convertEol: true,
|
||||||
windowsMode: false,
|
windowsMode: false,
|
||||||
@@ -626,6 +625,8 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
minimumContrastRatio: 1,
|
minimumContrastRatio: 1,
|
||||||
letterSpacing: 0,
|
letterSpacing: 0,
|
||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
|
|
||||||
|
theme: { background: "#18181b", foreground: "#f7f7f7" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const fitAddon = new FitAddon();
|
const fitAddon = new FitAddon();
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ export function Host({ host }: HostProps): React.ReactElement {
|
|||||||
className="min-w-[160px]"
|
className="min-w-[160px]"
|
||||||
>
|
>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
className="font-semibold"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
addTab({ type: "server", title, hostConfig: host })
|
addTab({ type: "server", title, hostConfig: host })
|
||||||
}
|
}
|
||||||
@@ -111,13 +112,17 @@ export function Host({ host }: HostProps): React.ReactElement {
|
|||||||
Open Server Details
|
Open Server Details
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
className="font-semibold"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
addTab({ type: "file_manager", title, hostConfig: host })
|
addTab({ type: "file_manager", title, hostConfig: host })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Open File Manager
|
Open File Manager
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => alert("Settings clicked")}>
|
<DropdownMenuItem
|
||||||
|
className="font-semibold"
|
||||||
|
onClick={() => alert("Settings clicked")}
|
||||||
|
>
|
||||||
Edit
|
Edit
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@@ -58,12 +58,15 @@ export function TopNavbar({
|
|||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
|
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
|
||||||
const [snippetsSidebarOpen, setSnippetsSidebarOpen] = useState(false);
|
const [snippetsSidebarOpen, setSnippetsSidebarOpen] = useState(false);
|
||||||
|
const [justDroppedTabId, setJustDroppedTabId] = useState<number | null>(null); // New state variable
|
||||||
const [dragState, setDragState] = useState<{
|
const [dragState, setDragState] = useState<{
|
||||||
|
draggedId: number | null;
|
||||||
draggedIndex: number | null;
|
draggedIndex: number | null;
|
||||||
currentX: number;
|
currentX: number;
|
||||||
startX: number;
|
startX: number;
|
||||||
targetIndex: number | null;
|
targetIndex: number | null;
|
||||||
}>({
|
}>({
|
||||||
|
draggedId: null,
|
||||||
draggedIndex: null,
|
draggedIndex: null,
|
||||||
currentX: 0,
|
currentX: 0,
|
||||||
startX: 0,
|
startX: 0,
|
||||||
@@ -72,6 +75,8 @@ export function TopNavbar({
|
|||||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const tabRefs = React.useRef<Map<number, HTMLDivElement>>(new Map());
|
const tabRefs = React.useRef<Map<number, HTMLDivElement>>(new Map());
|
||||||
|
|
||||||
|
const prevTabsRef = React.useRef<TabData[]>([]);
|
||||||
|
|
||||||
const handleTabActivate = (tabId: number) => {
|
const handleTabActivate = (tabId: number) => {
|
||||||
setCurrentTab(tabId);
|
setCurrentTab(tabId);
|
||||||
};
|
};
|
||||||
@@ -249,6 +254,37 @@ export function TopNavbar({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (prevTabsRef.current.length > 0 && tabs !== prevTabsRef.current) {
|
||||||
|
// Check if tabs actually changed
|
||||||
|
console.log("Tabs AFTER reorder (IDs and references):");
|
||||||
|
tabs.forEach((newTab, newIdx) => {
|
||||||
|
const oldTab = prevTabsRef.current.find((t) => t.id === newTab.id);
|
||||||
|
console.log(
|
||||||
|
` [${newIdx}] ID: ${newTab.id}, Ref:`,
|
||||||
|
newTab,
|
||||||
|
`(Old Ref:`,
|
||||||
|
oldTab,
|
||||||
|
`)`,
|
||||||
|
);
|
||||||
|
if (oldTab && oldTab !== newTab) {
|
||||||
|
console.warn(` Tab ID ${newTab.id} object reference CHANGED!`);
|
||||||
|
} else if (oldTab && oldTab === newTab) {
|
||||||
|
console.info(` Tab ID ${newTab.id} object reference PRESERVED.`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Clear prevTabsRef.current only after the comparison is done
|
||||||
|
prevTabsRef.current = [];
|
||||||
|
}
|
||||||
|
}, [tabs]); // Depend only on tabs
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (justDroppedTabId !== null) {
|
||||||
|
const timer = setTimeout(() => setJustDroppedTabId(null), 50); // Clear after a short delay
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [justDroppedTabId]);
|
||||||
|
|
||||||
const handleDragStart = (e: React.DragEvent, index: number) => {
|
const handleDragStart = (e: React.DragEvent, index: number) => {
|
||||||
console.log("Drag start:", index, e.clientX);
|
console.log("Drag start:", index, e.clientX);
|
||||||
|
|
||||||
@@ -259,6 +295,7 @@ export function TopNavbar({
|
|||||||
e.dataTransfer.setDragImage(img, 0, 0);
|
e.dataTransfer.setDragImage(img, 0, 0);
|
||||||
|
|
||||||
setDragState({
|
setDragState({
|
||||||
|
draggedId: tabs[index].id,
|
||||||
draggedIndex: index,
|
draggedIndex: index,
|
||||||
startX: e.clientX,
|
startX: e.clientX,
|
||||||
currentX: e.clientX,
|
currentX: e.clientX,
|
||||||
@@ -333,11 +370,25 @@ export function TopNavbar({
|
|||||||
// Moving right - find the rightmost tab whose midpoint we've passed
|
// Moving right - find the rightmost tab whose midpoint we've passed
|
||||||
for (let i = draggedIndex + 1; i < tabBoundaries.length; i++) {
|
for (let i = draggedIndex + 1; i < tabBoundaries.length; i++) {
|
||||||
if (draggedCenter > tabBoundaries[i].mid) {
|
if (draggedCenter > tabBoundaries[i].mid) {
|
||||||
newTargetIndex = i;
|
newTargetIndex = i; // Reverted from i + 1 to i
|
||||||
} else {
|
} else {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Edge case: if dragged past the last tab, target should be at the very end
|
||||||
|
const lastTabIndex = tabBoundaries.length - 1;
|
||||||
|
if (lastTabIndex >= 0) {
|
||||||
|
// Ensure there's at least one tab
|
||||||
|
const lastTabEl = tabRefs.current.get(lastTabIndex);
|
||||||
|
if (lastTabEl) {
|
||||||
|
const lastTabRect = lastTabEl.getBoundingClientRect();
|
||||||
|
const containerRect = containerRef.current.getBoundingClientRect();
|
||||||
|
const lastTabEndInContainer = lastTabRect.right - containerRect.left;
|
||||||
|
if (currentX > lastTabEndInContainer) {
|
||||||
|
newTargetIndex = tabBoundaries.length; // Insert at the very end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return newTargetIndex;
|
return newTargetIndex;
|
||||||
@@ -378,57 +429,40 @@ export function TopNavbar({
|
|||||||
dragState.targetIndex !== null &&
|
dragState.targetIndex !== null &&
|
||||||
dragState.draggedIndex !== dragState.targetIndex
|
dragState.draggedIndex !== dragState.targetIndex
|
||||||
) {
|
) {
|
||||||
|
console.log("Tabs before reorder (IDs and references):");
|
||||||
|
tabs.forEach((tab, idx) =>
|
||||||
|
console.log(` [${idx}] ID: ${tab.id}, Ref:`, tab),
|
||||||
|
);
|
||||||
|
prevTabsRef.current = tabs; // Store current tabs before reorder
|
||||||
reorderTabs(dragState.draggedIndex, dragState.targetIndex);
|
reorderTabs(dragState.draggedIndex, dragState.targetIndex);
|
||||||
|
if (dragState.draggedId !== null) {
|
||||||
// Delay clearing drag state to prevent visual jitter
|
setJustDroppedTabId(dragState.draggedId);
|
||||||
// This allows the reorder to complete and re-render before removing transforms
|
|
||||||
setTimeout(() => {
|
|
||||||
setDragState({
|
|
||||||
draggedIndex: null,
|
|
||||||
startX: 0,
|
|
||||||
currentX: 0,
|
|
||||||
targetIndex: null,
|
|
||||||
});
|
|
||||||
}, 0);
|
|
||||||
} else {
|
|
||||||
// No reorder needed, clear immediately
|
|
||||||
setDragState({
|
|
||||||
draggedIndex: null,
|
|
||||||
startX: 0,
|
|
||||||
currentX: 0,
|
|
||||||
targetIndex: null,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Immediately reset drag state after drop to ensure a single re-render
|
||||||
|
// with updated tabs and cleared drag state.
|
||||||
|
setDragState({
|
||||||
|
draggedId: null,
|
||||||
|
draggedIndex: null,
|
||||||
|
startX: 0,
|
||||||
|
currentX: 0,
|
||||||
|
targetIndex: null,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragEnd = () => {
|
const handleDragEnd = () => {
|
||||||
console.log("Drag end:", dragState);
|
console.log("Drag end:", dragState);
|
||||||
|
|
||||||
if (
|
// Immediately reset drag state. If a drop occurred, handleDrop has already
|
||||||
dragState.draggedIndex !== null &&
|
// initiated the state clear. If the drag was cancelled (e.g., dropped
|
||||||
dragState.targetIndex !== null &&
|
// outside a valid target), this clears the drag state.
|
||||||
dragState.draggedIndex !== dragState.targetIndex
|
|
||||||
) {
|
|
||||||
reorderTabs(dragState.draggedIndex, dragState.targetIndex);
|
|
||||||
|
|
||||||
// Delay clearing drag state to prevent visual jitter
|
|
||||||
setTimeout(() => {
|
|
||||||
setDragState({
|
setDragState({
|
||||||
|
draggedId: null,
|
||||||
draggedIndex: null,
|
draggedIndex: null,
|
||||||
startX: 0,
|
startX: 0,
|
||||||
currentX: 0,
|
currentX: 0,
|
||||||
targetIndex: null,
|
targetIndex: null,
|
||||||
});
|
});
|
||||||
}, 0);
|
|
||||||
} else {
|
|
||||||
// No reorder needed, clear immediately
|
|
||||||
setDragState({
|
|
||||||
draggedIndex: null,
|
|
||||||
startX: 0,
|
|
||||||
currentX: 0,
|
|
||||||
targetIndex: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSplitScreenActive =
|
const isSplitScreenActive =
|
||||||
@@ -492,47 +526,64 @@ export function TopNavbar({
|
|||||||
isSplitScreenActive);
|
isSplitScreenActive);
|
||||||
const disableClose = (isSplitScreenActive && isActive) || isSplit;
|
const disableClose = (isSplitScreenActive && isActive) || isSplit;
|
||||||
|
|
||||||
const isDragging = dragState.draggedIndex === index;
|
const isDraggingThisTab = dragState.draggedIndex === index;
|
||||||
const dragOffset = isDragging
|
const isTheDraggedTab = tab.id === dragState.draggedId;
|
||||||
|
const isDroppedAndSnapping = tab.id === justDroppedTabId; // New condition
|
||||||
|
const dragOffset = isDraggingThisTab
|
||||||
? dragState.currentX - dragState.startX
|
? dragState.currentX - dragState.startX
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
|
// Diagnostic logs
|
||||||
|
if (dragState.draggedIndex !== null) {
|
||||||
|
console.log(
|
||||||
|
`Tab ID: ${tab.id}, Index: ${index}, isDraggingThisTab: ${isDraggingThisTab}, draggedOriginalIndex: ${dragState.draggedIndex}, currentTargetIndex: ${dragState.targetIndex}, isDroppedAndSnapping: ${isDroppedAndSnapping}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate transform
|
// Calculate transform
|
||||||
let transform = "";
|
let transform = "";
|
||||||
if (isDragging) {
|
if (isDraggingThisTab) {
|
||||||
// Dragged tab follows cursor
|
|
||||||
transform = `translateX(${dragOffset}px)`;
|
transform = `translateX(${dragOffset}px)`;
|
||||||
} else if (
|
} else if (
|
||||||
dragState.draggedIndex !== null &&
|
dragState.draggedIndex !== null &&
|
||||||
dragState.targetIndex !== null
|
dragState.targetIndex !== null
|
||||||
) {
|
) {
|
||||||
// Other tabs shift to make room
|
const draggedOriginalIndex = dragState.draggedIndex;
|
||||||
const draggedIndex = dragState.draggedIndex;
|
const currentTargetIndex = dragState.targetIndex;
|
||||||
const targetIndex = dragState.targetIndex;
|
|
||||||
|
|
||||||
|
// Determine if this tab should shift left or right
|
||||||
if (
|
if (
|
||||||
draggedIndex < targetIndex &&
|
draggedOriginalIndex < currentTargetIndex && // Dragging rightwards
|
||||||
index > draggedIndex &&
|
index > draggedOriginalIndex && // This tab is to the right of the original position
|
||||||
index <= targetIndex
|
index <= currentTargetIndex // This tab is at or before the target position
|
||||||
) {
|
) {
|
||||||
// Shifting left
|
// Shift left to make space
|
||||||
const draggedTabEl = tabRefs.current.get(draggedIndex);
|
const draggedTabWidth =
|
||||||
const draggedWidth =
|
tabRefs.current
|
||||||
draggedTabEl?.getBoundingClientRect().width || 0;
|
.get(draggedOriginalIndex)
|
||||||
transform = `translateX(-${draggedWidth + 4}px)`;
|
?.getBoundingClientRect().width || 0;
|
||||||
|
const gap = 4;
|
||||||
|
transform = `translateX(-${draggedTabWidth + gap}px)`;
|
||||||
} else if (
|
} else if (
|
||||||
draggedIndex > targetIndex &&
|
draggedOriginalIndex > currentTargetIndex && // Dragging leftwards
|
||||||
index >= targetIndex &&
|
index >= currentTargetIndex && // This tab is at or after the target position
|
||||||
index < draggedIndex
|
index < draggedOriginalIndex // This tab is to the left of the original position
|
||||||
) {
|
) {
|
||||||
// Shifting right
|
// Shift right to make space
|
||||||
const draggedTabEl = tabRefs.current.get(draggedIndex);
|
const draggedTabWidth =
|
||||||
const draggedWidth =
|
tabRefs.current
|
||||||
draggedTabEl?.getBoundingClientRect().width || 0;
|
.get(draggedOriginalIndex)
|
||||||
transform = `translateX(${draggedWidth + 4}px)`;
|
?.getBoundingClientRect().width || 0;
|
||||||
|
const gap = 4;
|
||||||
|
transform = `translateX(${draggedTabWidth + gap}px)`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Diagnostic log for transform
|
||||||
|
if (dragState.draggedIndex !== null) {
|
||||||
|
console.log(` Tab ID: ${tab.id}, Transform: ${transform}`);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
@@ -554,10 +605,13 @@ export function TopNavbar({
|
|||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
style={{
|
style={{
|
||||||
transform,
|
transform,
|
||||||
transition: isDragging ? "none" : "transform 200ms ease-out",
|
transition:
|
||||||
zIndex: isDragging ? 1000 : 1,
|
isDraggingThisTab || isDroppedAndSnapping
|
||||||
|
? "none"
|
||||||
|
: "transform 200ms ease-out",
|
||||||
|
zIndex: isDraggingThisTab ? 1000 : 1,
|
||||||
position: "relative",
|
position: "relative",
|
||||||
cursor: isDragging ? "grabbing" : "grab",
|
cursor: isDraggingThisTab ? "grabbing" : "grab",
|
||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
WebkitUserSelect: "none",
|
WebkitUserSelect: "none",
|
||||||
}}
|
}}
|
||||||
@@ -592,7 +646,7 @@ export function TopNavbar({
|
|||||||
disableActivate={disableActivate}
|
disableActivate={disableActivate}
|
||||||
disableSplit={disableSplit}
|
disableSplit={disableSplit}
|
||||||
disableClose={disableClose}
|
disableClose={disableClose}
|
||||||
isDragging={isDragging}
|
isDragging={isDraggingThisTab}
|
||||||
isDragOver={false}
|
isDragOver={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user