Merge branch 'main' into feature/scheduled-send
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { EmailComposer, type ComposerDraftData } from "@/components/email/email-composer";
|
||||
import { ErrorBoundary, ComposerErrorFallback } from "@/components/error";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useProTabStore, type ProComposeTabData } from "@/stores/pro-tab-store";
|
||||
import { debug } from "@/lib/debug";
|
||||
|
||||
interface ProComposeTabBodyProps {
|
||||
tabId: string;
|
||||
data: ProComposeTabData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a standalone `<EmailComposer />` inside its own Pro tab. Sending,
|
||||
* draft autosave, and discard all flow through the shared `email-store`, so
|
||||
* the result is identical to composing inline in the mail page — the
|
||||
* composer is just hosted in its own tab instead of in the right pane.
|
||||
*/
|
||||
export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
||||
const t = useTranslations();
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const sendEmail = useEmailStore((s) => s.sendEmail);
|
||||
const fetchEmails = useEmailStore((s) => s.fetchEmails);
|
||||
const selectedMailbox = useEmailStore((s) => s.selectedMailbox);
|
||||
const closeTab = useProTabStore((s) => s.closeTab);
|
||||
const updateTabTitle = useProTabStore((s) => s.updateTabTitle);
|
||||
const updateComposeDraft = useProTabStore((s) => s.updateComposeDraft);
|
||||
|
||||
// Keep stable references for the callbacks below so the composer's
|
||||
// `key={sessionId}` doesn't churn.
|
||||
const tabIdRef = useRef(tabId);
|
||||
tabIdRef.current = tabId;
|
||||
|
||||
const handleSend = useCallback(async (sendData: Parameters<NonNullable<React.ComponentProps<typeof EmailComposer>['onSend']>>[0]) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
await sendEmail(
|
||||
client,
|
||||
sendData.to,
|
||||
sendData.subject,
|
||||
sendData.body,
|
||||
sendData.cc,
|
||||
sendData.bcc,
|
||||
sendData.identityId,
|
||||
sendData.fromEmail,
|
||||
sendData.draftId,
|
||||
sendData.fromName,
|
||||
sendData.htmlBody,
|
||||
sendData.attachments,
|
||||
sendData.inReplyTo,
|
||||
sendData.references,
|
||||
sendData.delayedUntil,
|
||||
sendData.envelopeMailFrom,
|
||||
);
|
||||
|
||||
// Mark the original message as $answered / $forwarded so the standard
|
||||
// viewer and list reflect the action (same behaviour as inline compose).
|
||||
if (data.sourceEmailId && (data.mode === 'reply' || data.mode === 'replyAll')) {
|
||||
try {
|
||||
await client.setKeyword(data.sourceEmailId, '$answered');
|
||||
} catch (e) {
|
||||
debug.error('Failed to set $answered keyword:', e);
|
||||
}
|
||||
} else if (data.sourceEmailId && data.mode === 'forward') {
|
||||
try {
|
||||
await client.setKeyword(data.sourceEmailId, '$forwarded');
|
||||
} catch (e) {
|
||||
debug.error('Failed to set $forwarded keyword:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the currently-active mail list so the new sent message /
|
||||
// updated keyword status shows up.
|
||||
await fetchEmails(client, selectedMailbox);
|
||||
closeTab(tabIdRef.current);
|
||||
} catch (error) {
|
||||
console.error('Failed to send email:', error);
|
||||
toast.error(t('notifications.error_sending'));
|
||||
}
|
||||
}, [client, sendEmail, fetchEmails, selectedMailbox, closeTab, data.sourceEmailId, data.mode, t]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
closeTab(tabIdRef.current);
|
||||
}, [closeTab]);
|
||||
|
||||
const handleDiscardDraft = useCallback(async (draftId: string) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
await client.deleteEmail(draftId);
|
||||
} catch (error) {
|
||||
console.error('Failed to discard draft:', error);
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
const handleSaveState = useCallback((state: ComposerDraftData) => {
|
||||
updateComposeDraft(tabIdRef.current, state);
|
||||
// Keep the tab title in sync with the working subject.
|
||||
const subject = state.subject?.trim() || t('email_composer.new_message');
|
||||
updateTabTitle(tabIdRef.current, subject);
|
||||
}, [updateComposeDraft, updateTabTitle, t]);
|
||||
|
||||
// On first mount, ensure the tab title matches whatever subject we were
|
||||
// initialised with (replies start with "Re: …", forwards with "Fwd: …").
|
||||
useEffect(() => {
|
||||
const initialSubject = data.initialData?.subject?.trim()
|
||||
?? data.replyTo?.subject
|
||||
?? '';
|
||||
const title = initialSubject || t('email_composer.new_message');
|
||||
updateTabTitle(tabIdRef.current, title);
|
||||
// Run once on mount only.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-background">
|
||||
<ErrorBoundary fallback={ComposerErrorFallback}>
|
||||
<EmailComposer
|
||||
key={data.sessionId}
|
||||
mode={data.initialData?.mode ?? data.mode}
|
||||
replyTo={data.replyTo}
|
||||
initialDraftText={data.initialDraftText}
|
||||
initialData={data.initialData}
|
||||
onSend={handleSend}
|
||||
onClose={handleClose}
|
||||
onDiscardDraft={handleDiscardDraft}
|
||||
onSaveState={handleSaveState}
|
||||
className="flex-1"
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState, useMemo, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { EmailViewer } from "@/components/email/email-viewer";
|
||||
import { ErrorBoundary, EmailViewerErrorFallback } from "@/components/error";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/stores/pro-tab-store";
|
||||
import type { Email } from "@/lib/jmap/types";
|
||||
|
||||
interface ProEmailTabBodyProps {
|
||||
tabId: string;
|
||||
data: ProEmailTabData;
|
||||
}
|
||||
|
||||
function buildReplyContext(email: Email): ProReplyContext {
|
||||
const textPartId = email.textBody?.[0]?.partId ?? '';
|
||||
const htmlPartId = email.htmlBody?.[0]?.partId ?? '';
|
||||
return {
|
||||
from: email.from,
|
||||
replyToAddresses: email.replyTo,
|
||||
to: email.to,
|
||||
cc: email.cc,
|
||||
bcc: email.bcc,
|
||||
subject: email.subject,
|
||||
body: email.bodyValues?.[textPartId]?.value || email.preview || '',
|
||||
htmlBody: email.bodyValues?.[htmlPartId]?.value || undefined,
|
||||
receivedAt: email.receivedAt,
|
||||
accountId: email.accountId,
|
||||
attachments: email.attachments,
|
||||
messageId: email.messageId,
|
||||
inReplyTo: email.inReplyTo,
|
||||
references: email.references,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single email in its own Pro tab. Fetches the email content on
|
||||
* mount via `email-store.fetchEmailContent` so the tab is self-sufficient —
|
||||
* it doesn't depend on what the Mail tab has selected.
|
||||
*/
|
||||
export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
const t = useTranslations();
|
||||
const tNotifications = useTranslations('notifications');
|
||||
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const fetchEmailContent = useEmailStore((s) => s.fetchEmailContent);
|
||||
const deleteEmail = useEmailStore((s) => s.deleteEmail);
|
||||
const markAsRead = useEmailStore((s) => s.markAsRead);
|
||||
const toggleStar = useEmailStore((s) => s.toggleStar);
|
||||
const moveToMailbox = useEmailStore((s) => s.moveToMailbox);
|
||||
const setEmailKeywordsLocal = useEmailStore((s) => s.setEmailKeywordsLocal);
|
||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const settingsKeywords = useSettingsStore((s) => s.emailKeywords);
|
||||
|
||||
const closeTab = useProTabStore((s) => s.closeTab);
|
||||
const openComposeTab = useProTabStore((s) => s.openComposeTab);
|
||||
const updateTabTitle = useProTabStore((s) => s.updateTabTitle);
|
||||
|
||||
const [email, setEmail] = useState<Email | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const composerSessionIdRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!client) return;
|
||||
setIsLoading(true);
|
||||
fetchEmailContent(client, data.emailId)
|
||||
.then((loaded) => {
|
||||
if (cancelled) return;
|
||||
setEmail(loaded);
|
||||
if (loaded?.subject) {
|
||||
updateTabTitle(tabId, loaded.subject);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to fetch email for Pro tab:', err);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [client, data.emailId, fetchEmailContent, tabId, updateTabTitle]);
|
||||
|
||||
const currentMailboxRole = useMemo(() => {
|
||||
if (!email) return undefined;
|
||||
const mb = email.mailboxIds
|
||||
? Object.keys(email.mailboxIds).map((id) => mailboxes.find((m) => m.id === id)).find(Boolean)
|
||||
: undefined;
|
||||
return mb?.role;
|
||||
}, [email, mailboxes]);
|
||||
|
||||
const handleReply = useCallback((draftText?: string) => {
|
||||
if (!email) return;
|
||||
composerSessionIdRef.current += 1;
|
||||
openComposeTab({
|
||||
sessionId: composerSessionIdRef.current,
|
||||
mode: 'reply',
|
||||
replyTo: buildReplyContext(email),
|
||||
sourceEmailId: email.id,
|
||||
initialDraftText: draftText,
|
||||
title: `Re: ${email.subject || t('email_composer.new_message')}`,
|
||||
});
|
||||
}, [email, openComposeTab, t]);
|
||||
|
||||
const handleReplyAll = useCallback(() => {
|
||||
if (!email) return;
|
||||
composerSessionIdRef.current += 1;
|
||||
openComposeTab({
|
||||
sessionId: composerSessionIdRef.current,
|
||||
mode: 'replyAll',
|
||||
replyTo: buildReplyContext(email),
|
||||
sourceEmailId: email.id,
|
||||
title: `Re: ${email.subject || t('email_composer.new_message')}`,
|
||||
});
|
||||
}, [email, openComposeTab, t]);
|
||||
|
||||
const handleForward = useCallback(() => {
|
||||
if (!email) return;
|
||||
composerSessionIdRef.current += 1;
|
||||
openComposeTab({
|
||||
sessionId: composerSessionIdRef.current,
|
||||
mode: 'forward',
|
||||
replyTo: buildReplyContext(email),
|
||||
sourceEmailId: email.id,
|
||||
title: `Fwd: ${email.subject || t('email_composer.new_message')}`,
|
||||
});
|
||||
}, [email, openComposeTab, t]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!client || !email) return;
|
||||
try {
|
||||
await deleteEmail(client, email.id);
|
||||
closeTab(tabId);
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err);
|
||||
toast.error(tNotifications('error_deleting'));
|
||||
}
|
||||
}, [client, email, deleteEmail, closeTab, tabId, tNotifications]);
|
||||
|
||||
const handleArchive = useCallback(async () => {
|
||||
if (!client || !email) return;
|
||||
const archiveMb = mailboxes.find((m) => m.role === 'archive');
|
||||
if (!archiveMb) return;
|
||||
try {
|
||||
await moveToMailbox(client, email.id, archiveMb.id);
|
||||
toast.success(tNotifications('email_archived'));
|
||||
closeTab(tabId);
|
||||
} catch (err) {
|
||||
console.error('Archive failed:', err);
|
||||
}
|
||||
}, [client, email, mailboxes, moveToMailbox, closeTab, tabId, tNotifications]);
|
||||
|
||||
const handleToggleStar = useCallback(async () => {
|
||||
if (!client || !email) return;
|
||||
try {
|
||||
await toggleStar(client, email.id);
|
||||
// Reflect locally — the viewer re-reads from email-store's selectedEmail
|
||||
// shape only for the mail tab; here we update our local copy too.
|
||||
setEmail((prev) => prev ? {
|
||||
...prev,
|
||||
keywords: {
|
||||
...prev.keywords,
|
||||
$flagged: !prev.keywords?.$flagged,
|
||||
},
|
||||
} : prev);
|
||||
} catch (err) {
|
||||
console.error('Toggle star failed:', err);
|
||||
}
|
||||
}, [client, email, toggleStar]);
|
||||
|
||||
const handleMarkAsRead = useCallback(async (emailId: string, read: boolean) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
await markAsRead(client, emailId, read);
|
||||
setEmail((prev) => prev && prev.id === emailId ? {
|
||||
...prev,
|
||||
keywords: { ...prev.keywords, $seen: read },
|
||||
} : prev);
|
||||
} catch (err) {
|
||||
console.error('Mark as read failed:', err);
|
||||
}
|
||||
}, [client, markAsRead]);
|
||||
|
||||
const handleSetColorTag = useCallback((emailId: string, color: string | null) => {
|
||||
if (!email || email.id !== emailId) return;
|
||||
// Drop existing color keywords, optionally add the new one. Matches the
|
||||
// mail page's local optimistic update.
|
||||
const keywords = { ...(email.keywords ?? {}) };
|
||||
for (const kw of settingsKeywords) {
|
||||
delete keywords[`$label:${kw.id}`];
|
||||
}
|
||||
if (color) {
|
||||
const def = settingsKeywords.find((k) => k.color === color);
|
||||
if (def) keywords[`$label:${def.id}`] = true;
|
||||
}
|
||||
setEmailKeywordsLocal(emailId, keywords);
|
||||
setEmail({ ...email, keywords });
|
||||
}, [email, settingsKeywords, setEmailKeywordsLocal]);
|
||||
|
||||
const handleMoveToMailbox = useCallback(async (mailboxId: string) => {
|
||||
if (!client || !email) return;
|
||||
try {
|
||||
await moveToMailbox(client, email.id, mailboxId);
|
||||
closeTab(tabId);
|
||||
} catch (err) {
|
||||
console.error('Move failed:', err);
|
||||
}
|
||||
}, [client, email, moveToMailbox, closeTab, tabId]);
|
||||
|
||||
const handleDownloadAttachment = useCallback(async (blobId: string, name: string, type?: string) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
await client.downloadBlob(blobId, name, type);
|
||||
} catch (err) {
|
||||
console.error('Download failed:', err);
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
const handleQuickReply = useCallback(async (body: string) => {
|
||||
handleReply(body);
|
||||
}, [handleReply]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-background">
|
||||
<ErrorBoundary fallback={EmailViewerErrorFallback}>
|
||||
<EmailViewer
|
||||
email={email}
|
||||
isLoading={isLoading}
|
||||
onReply={handleReply}
|
||||
onReplyAll={handleReplyAll}
|
||||
onForward={handleForward}
|
||||
onDelete={handleDelete}
|
||||
onArchive={handleArchive}
|
||||
onToggleStar={handleToggleStar}
|
||||
onMarkAsRead={handleMarkAsRead}
|
||||
onSetColorTag={handleSetColorTag}
|
||||
onDownloadAttachment={handleDownloadAttachment}
|
||||
onQuickReply={handleQuickReply}
|
||||
onMoveToMailbox={handleMoveToMailbox}
|
||||
currentUserEmail={client?.getUsername()}
|
||||
currentUserName={client?.getUsername()?.split('@')[0]}
|
||||
currentMailboxRole={currentMailboxRole}
|
||||
mailboxes={mailboxes}
|
||||
className="flex-1"
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, type DragEvent } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Mail, Calendar, BookUser, HardDrive, Settings, PenSquare, MailOpen, X, type LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useProTabStore, type ProTab, type ProTabKind } from "@/stores/pro-tab-store";
|
||||
|
||||
/** Custom MIME type used to carry the dragged Pro tab id between handlers. */
|
||||
export const PRO_TAB_DRAG_MIME = "application/x-pro-tab-id";
|
||||
|
||||
interface ProTabBarProps {
|
||||
/** All tabs (both panes). Order in the array is the order in the bar. */
|
||||
tabs: ProTab[];
|
||||
activeMainTabId: string | null;
|
||||
activeSplitTabId: string | null;
|
||||
onActivate: (id: string) => void;
|
||||
onClose: (id: string) => void;
|
||||
onDragStateChange?: (dragging: boolean) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TAB_ICONS: Record<ProTabKind, LucideIcon> = {
|
||||
mail: Mail,
|
||||
calendar: Calendar,
|
||||
contacts: BookUser,
|
||||
files: HardDrive,
|
||||
settings: Settings,
|
||||
compose: PenSquare,
|
||||
email: MailOpen,
|
||||
};
|
||||
|
||||
type DropIndicator = { targetId: string; edge: "before" | "after" } | null;
|
||||
|
||||
export function ProTabBar({
|
||||
tabs,
|
||||
activeMainTabId,
|
||||
activeSplitTabId,
|
||||
onActivate,
|
||||
onClose,
|
||||
onDragStateChange,
|
||||
className,
|
||||
}: ProTabBarProps) {
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
const reorderTab = useProTabStore((s) => s.reorderTab);
|
||||
const focusedPaneId = useProTabStore((s) => s.focusedPaneId);
|
||||
|
||||
const [dropIndicator, setDropIndicator] = useState<DropIndicator>(null);
|
||||
const dragLeaveTimer = useRef<number | null>(null);
|
||||
|
||||
const isProTabDrag = (e: DragEvent) =>
|
||||
e.dataTransfer.types.includes(PRO_TAB_DRAG_MIME);
|
||||
|
||||
const handleDragStart = (e: DragEvent<HTMLDivElement>, tab: ProTab) => {
|
||||
e.dataTransfer.setData(PRO_TAB_DRAG_MIME, tab.id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
onDragStateChange?.(true);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDropIndicator(null);
|
||||
onDragStateChange?.(false);
|
||||
};
|
||||
|
||||
const handleTabDragOver = (e: DragEvent<HTMLDivElement>, tab: ProTab) => {
|
||||
if (!isProTabDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const edge: "before" | "after" =
|
||||
e.clientX < rect.left + rect.width / 2 ? "before" : "after";
|
||||
setDropIndicator((prev) =>
|
||||
prev && prev.targetId === tab.id && prev.edge === edge
|
||||
? prev
|
||||
: { targetId: tab.id, edge },
|
||||
);
|
||||
if (dragLeaveTimer.current !== null) {
|
||||
window.clearTimeout(dragLeaveTimer.current);
|
||||
dragLeaveTimer.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleStripDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||
const next = e.relatedTarget as Node | null;
|
||||
if (next && e.currentTarget.contains(next)) return;
|
||||
if (dragLeaveTimer.current !== null) window.clearTimeout(dragLeaveTimer.current);
|
||||
dragLeaveTimer.current = window.setTimeout(() => {
|
||||
setDropIndicator(null);
|
||||
dragLeaveTimer.current = null;
|
||||
}, 40);
|
||||
};
|
||||
|
||||
const handleTabDrop = (e: DragEvent<HTMLDivElement>, tab: ProTab) => {
|
||||
if (!isProTabDrag(e)) return;
|
||||
e.preventDefault();
|
||||
const draggedId = e.dataTransfer.getData(PRO_TAB_DRAG_MIME);
|
||||
if (!draggedId || draggedId === tab.id) {
|
||||
handleDragEnd();
|
||||
return;
|
||||
}
|
||||
const edge = dropIndicator?.targetId === tab.id ? dropIndicator.edge : "after";
|
||||
reorderTab(draggedId, tab.id, edge);
|
||||
handleDragEnd();
|
||||
};
|
||||
|
||||
const handleStripEndDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||
if (!isProTabDrag(e)) return;
|
||||
e.preventDefault();
|
||||
const draggedId = e.dataTransfer.getData(PRO_TAB_DRAG_MIME);
|
||||
if (!draggedId) {
|
||||
handleDragEnd();
|
||||
return;
|
||||
}
|
||||
const last = tabs[tabs.length - 1];
|
||||
if (last && last.id !== draggedId) {
|
||||
reorderTab(draggedId, last.id, "after");
|
||||
}
|
||||
handleDragEnd();
|
||||
};
|
||||
|
||||
const handleStripEndDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||
if (!isProTabDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
const last = tabs[tabs.length - 1];
|
||||
if (last) {
|
||||
setDropIndicator({ targetId: last.id, edge: "after" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-stretch h-9 bg-secondary px-1 overflow-x-auto scroll-hidden flex-shrink-0",
|
||||
className,
|
||||
)}
|
||||
style={{ borderBottom: '1px solid rgba(128, 128, 128, 0.3)' }}
|
||||
role="tablist"
|
||||
onDragLeave={handleStripDragLeave}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const Icon = TAB_ICONS[tab.kind];
|
||||
const isActiveMain = tab.id === activeMainTabId && tab.paneId === 'main';
|
||||
const isActiveSplit = tab.id === activeSplitTabId && tab.paneId === 'split';
|
||||
const isActive = isActiveMain || isActiveSplit;
|
||||
const isFocusedActive =
|
||||
(isActiveMain && focusedPaneId === 'main')
|
||||
|| (isActiveSplit && focusedPaneId === 'split');
|
||||
const label = tab.title ?? tSidebar(tab.labelKey);
|
||||
const showBefore = dropIndicator?.targetId === tab.id && dropIndicator.edge === "before";
|
||||
const showAfter = dropIndicator?.targetId === tab.id && dropIndicator.edge === "after";
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
data-tab-id={tab.id}
|
||||
data-pane-id={tab.paneId}
|
||||
draggable
|
||||
onClick={() => onActivate(tab.id)}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 1 && tab.closeable) {
|
||||
e.preventDefault();
|
||||
onClose(tab.id);
|
||||
}
|
||||
}}
|
||||
onDragStart={(e) => handleDragStart(e, tab)}
|
||||
onDragOver={(e) => handleTabDragOver(e, tab)}
|
||||
onDrop={(e) => handleTabDrop(e, tab)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={cn(
|
||||
"group relative flex items-center gap-1.5 px-3 h-9 text-sm cursor-pointer select-none transition-colors",
|
||||
"min-w-0 flex-1 basis-0 max-w-[200px] [min-width:80px]",
|
||||
"border-r border-border first:border-l",
|
||||
isActive
|
||||
? "bg-background text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
isFocusedActive && "font-medium",
|
||||
)}
|
||||
style={
|
||||
isActive
|
||||
? { borderRightColor: 'rgba(128, 128, 128, 0.3)', borderLeftColor: 'rgba(128, 128, 128, 0.3)' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Icon className={cn("w-4 h-4 flex-shrink-0", isFocusedActive && "text-primary")} />
|
||||
<span className="truncate flex-1 min-w-0" title={label}>{label}</span>
|
||||
|
||||
{tab.closeable && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose(tab.id);
|
||||
}}
|
||||
className={cn(
|
||||
"ml-1 flex items-center justify-center w-4 h-4 rounded-sm transition-colors flex-shrink-0",
|
||||
"text-muted-foreground hover:bg-muted-foreground/20 hover:text-foreground",
|
||||
!isActive && "opacity-0 group-hover:opacity-100 focus-visible:opacity-100",
|
||||
)}
|
||||
aria-label={tSidebar("close")}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isActive && (
|
||||
<span
|
||||
className="absolute left-0 right-0 -bottom-px h-px bg-background"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{showBefore && (
|
||||
<span
|
||||
className="pointer-events-none absolute top-1 bottom-1 left-0 w-0.5 -translate-x-1/2 bg-primary rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{showAfter && (
|
||||
<span
|
||||
className="pointer-events-none absolute top-1 bottom-1 right-0 w-0.5 translate-x-1/2 bg-primary rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Trailing area soaks up drops past the last tab. */}
|
||||
<div
|
||||
className="flex-1 min-w-[8px]"
|
||||
onDragOver={handleStripEndDragOver}
|
||||
onDrop={handleStripEndDrop}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user