diff --git a/plane-src/apps/api/plane/settings/common.py b/plane-src/apps/api/plane/settings/common.py
index fd53132..352a31c 100644
--- a/plane-src/apps/api/plane/settings/common.py
+++ b/plane-src/apps/api/plane/settings/common.py
@@ -461,8 +461,14 @@ ATTACHMENT_MIME_TYPES = [
"text/css",
"text/javascript",
"application/json",
+ "application/x-ndjson",
+ "application/yaml",
+ "application/x-yaml",
+ "text/yaml",
+ "application/toml",
"text/xml",
"text/csv",
+ "text/tab-separated-values",
"application/xml",
# SQL
"application/x-sql",
@@ -470,6 +476,7 @@ ATTACHMENT_MIME_TYPES = [
"application/x-gzip",
# Markdown
"text/markdown",
+ "text/x-markdown",
]
# Seed directory path
diff --git a/plane-src/apps/web/core/components/issues/attachment/attachment-list-item.tsx b/plane-src/apps/web/core/components/issues/attachment/attachment-list-item.tsx
index 3c43a20..859e250 100644
--- a/plane-src/apps/web/core/components/issues/attachment/attachment-list-item.tsx
+++ b/plane-src/apps/web/core/components/issues/attachment/attachment-list-item.tsx
@@ -19,11 +19,12 @@ import { EIssueServiceType } from "@plane/types";
import type { TContextMenuItem } from "@plane/ui";
import { ActionDropdown, EModalPosition, EModalWidth, ModalCore, Spinner } from "@plane/ui";
import { convertBytesToSize, getFileExtension, getFileName, getFileURL, renderFormattedDate } from "@plane/utils";
-import { AlertTriangle, Box, Download, Eye, History, ImageIcon, Play, UploadCloud, X } from "lucide-react";
+import { AlertTriangle, Box, Download, Eye, FileText, History, ImageIcon, Play, UploadCloud, X } from "lucide-react";
// components
//
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { getFileIcon } from "@/components/icons";
+import { MarkdownRenderer } from "@/components/ui/markdown-to-component";
import {
buildBeamViewerUrl,
dispatchBeamViewerOpenEvent,
@@ -57,6 +58,26 @@ type TIssueAttachmentsListItem = {
const IMAGE_EXTENSIONS = new Set(["apng", "avif", "bmp", "gif", "jpg", "jpeg", "png", "svg", "webp"]);
const VIDEO_EXTENSIONS = new Set(["avi", "m4v", "mov", "mp4", "mpeg", "mpg", "ogv", "webm"]);
const PDF_EXTENSIONS = new Set(["pdf"]);
+const MARKDOWN_EXTENSIONS = new Set(["markdown", "md", "mdown", "mkd", "mkdn"]);
+const TEXT_EXTENSIONS = new Set([
+ "cfg",
+ "conf",
+ "csv",
+ "ini",
+ "json",
+ "jsonl",
+ "log",
+ "ndjson",
+ "toml",
+ "tsv",
+ "txt",
+ "xml",
+ "yaml",
+ "yml",
+]);
+const MAX_INLINE_TEXT_PREVIEW_SIZE = 8 * 1024 * 1024;
+
+type TAttachmentPreviewType = "image" | "video" | "pdf" | "markdown" | "text" | "file";
const appendSearchParam = (url: string | undefined, key: string, value: string): string => {
if (!url) return "";
@@ -94,11 +115,13 @@ const withBeamViewerSettingsSrc = (
}
};
-const getPreviewType = (extension: string) => {
+const getPreviewType = (extension: string): TAttachmentPreviewType => {
const normalizedExtension = extension.toLowerCase();
if (IMAGE_EXTENSIONS.has(normalizedExtension)) return "image";
if (VIDEO_EXTENSIONS.has(normalizedExtension)) return "video";
if (PDF_EXTENSIONS.has(normalizedExtension)) return "pdf";
+ if (MARKDOWN_EXTENSIONS.has(normalizedExtension)) return "markdown";
+ if (TEXT_EXTENSIONS.has(normalizedExtension)) return "text";
return "file";
};
@@ -170,8 +193,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
})
: undefined) || storedModelViewerUrlWithSettings;
const canOpenModelViewer =
- !!beamViewer &&
- (beamViewer.previewAvailable || beamEffectiveStatus === "ready" || !!modelViewerUrl);
+ !!beamViewer && (beamViewer.previewAvailable || beamEffectiveStatus === "ready" || !!modelViewerUrl);
const modelDownloadUrl = beamViewer?.downloadUrl || beamViewer?.src;
const previewDownloadUrl = modelDownloadUrl || fileURL;
const isBeamModel = isBeamModelFile(fullFileName);
@@ -210,7 +232,8 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
() => mergeBeamModelVersionRecordLists(localBeamVersions, liveBeamVersions),
[localBeamVersions, liveBeamVersions]
);
- const rawVersion = beamViewer?.version ?? (attachment?.attributes as { version?: number | string } | undefined)?.version;
+ const rawVersion =
+ beamViewer?.version ?? (attachment?.attributes as { version?: number | string } | undefined)?.version;
const versionLabel =
typeof rawVersion === "number"
? `v${rawVersion}`
@@ -476,15 +499,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
isMounted = false;
if (timeoutId) window.clearTimeout(timeoutId);
};
- }, [
- attachmentId,
- attachmentService,
- beamViewer,
- issueId,
- projectId,
- storedModelViewerUrl,
- workspaceSlug,
- ]);
+ }, [attachmentId, attachmentService, beamViewer, issueId, projectId, storedModelViewerUrl, workspaceSlug]);
if (!attachment) return <>>;
@@ -523,6 +538,8 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
) : (
)
+ ) : previewType === "markdown" || previewType === "text" ? (
+
) : previewType === "file" ? (
getFileIcon(fileExtension, 18)
) : (
@@ -720,6 +737,8 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
) : (
)
+ ) : previewType === "markdown" || previewType === "text" ? (
+
) : previewType === "file" ? (
fileIcon
) : (
@@ -812,7 +831,7 @@ type TAttachmentPreviewContent = {
isBeamConversionFailed: boolean;
modelDownloadUrl: string | undefined;
previewDownloadUrl: string | undefined;
- previewType: "image" | "video" | "pdf" | "file";
+ previewType: TAttachmentPreviewType;
previewURL: string;
setIsPreviewOpen: (isOpen: boolean) => void;
size: number;
@@ -876,6 +895,13 @@ const AttachmentPreviewContent = (props: TAttachmentPreviewContent) => {
) : previewType === "pdf" && previewURL ? (
+ ) : (previewType === "markdown" || previewType === "text") && previewURL ? (
+
) : isBeamAwaitingPreview || isBeamConversionFailed ? (
{
);
};
+type TAttachmentTextPreview = {
+ fileName: string;
+ fileURL: string;
+ isMarkdown: boolean;
+ size: number;
+};
+
+const AttachmentTextPreview = (props: TAttachmentTextPreview) => {
+ const { fileName, fileURL, isMarkdown, size } = props;
+ const [content, setContent] = useState
(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (size > MAX_INLINE_TEXT_PREVIEW_SIZE) {
+ setContent(null);
+ setError(`Файл больше ${convertBytesToSize(MAX_INLINE_TEXT_PREVIEW_SIZE)}. Скачайте его для просмотра.`);
+ return;
+ }
+
+ const controller = new AbortController();
+ setContent(null);
+ setError(null);
+
+ fetch(fileURL, {
+ cache: "no-store",
+ credentials: "include",
+ signal: controller.signal,
+ })
+ .then(async (response) => {
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ return response.text();
+ })
+ .then((text) => setContent(text))
+ .catch((fetchError) => {
+ if (controller.signal.aborted) return;
+ console.error("Error in loading text attachment preview:", fetchError);
+ setError("Не удалось загрузить содержимое файла для предпросмотра.");
+ });
+
+ return () => controller.abort();
+ }, [fileURL, size]);
+
+ const renderedContent = useMemo(() => {
+ if (content === null || isMarkdown) return content;
+ const extension = getFileExtension(fileName).toLowerCase();
+ if (extension !== "json") return content;
+
+ try {
+ return JSON.stringify(JSON.parse(content), null, 2);
+ } catch (_error) {
+ return content;
+ }
+ }, [content, fileName, isMarkdown]);
+
+ if (error) {
+ return (
+ {error}
+ );
+ }
+
+ if (renderedContent === null) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {isMarkdown ? (
+
+
+
+ ) : (
+
+ {renderedContent}
+
+ )}
+
+ );
+};
+
type TBeamVersionHistoryModal = {
currentVersionId: string | undefined;
deletingVersionKey: string | null;
diff --git a/plane-src/apps/web/core/components/issues/peek-overview/view.tsx b/plane-src/apps/web/core/components/issues/peek-overview/view.tsx
index a9f6b75..3554891 100644
--- a/plane-src/apps/web/core/components/issues/peek-overview/view.tsx
+++ b/plane-src/apps/web/core/components/issues/peek-overview/view.tsx
@@ -10,14 +10,16 @@ import {
useRef,
useState,
type CSSProperties,
+ type DragEvent as ReactDragEvent,
type MouseEvent as ReactMouseEvent,
type ReactNode,
} from "react";
import { observer } from "mobx-react";
import { createPortal } from "react-dom";
-import { Download, Maximize2, Minimize2, X } from "lucide-react";
+import { Download, Maximize2, Minimize2, UploadCloud, X } from "lucide-react";
// plane imports
import type { EditorRefApi } from "@plane/editor";
+import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TNameDescriptionLoader } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
import { cn, convertBytesToSize } from "@plane/utils";
@@ -26,6 +28,7 @@ import { BEAM_VIEWER_OPEN_EVENT, type TBeamViewerOpenEventDetail } from "@/helpe
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import useKeypress from "@/hooks/use-keypress";
import usePeekOverviewOutsideClickDetector from "@/hooks/use-peek-overview-outside-click";
+import { useFileSize } from "@/plane-web/hooks/use-file-size";
// local imports
import type { TIssueOperations } from "../issue-detail";
import { IssueActivity } from "../issue-detail/issue-activity";
@@ -40,6 +43,8 @@ import { PeekOverviewProperties } from "./properties";
const SIDE_PEEK_WIDTH_STORAGE_KEY = "nodedc:issue-peek-width";
const BEAM_VIEWER_CLOSING_CLASS_NAME = "nodedc-beam-viewer-closing";
+const isFileDragEvent = (event: ReactDragEvent) => Array.from(event.dataTransfer.types).includes("Files");
+
interface IIssueView {
workspaceSlug: string;
projectId: string;
@@ -96,6 +101,8 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
const [beamPeekViewer, setBeamPeekViewer] = useState(null);
const [isBeamPeekFullscreen, setIsBeamPeekFullscreen] = useState(false);
const [isBeamPeekClosing, setIsBeamPeekClosing] = useState(false);
+ const [isCardAttachmentUploading, setIsCardAttachmentUploading] = useState(false);
+ const [isCardAttachmentDragActive, setIsCardAttachmentDragActive] = useState(false);
const [sidePeekWidth, setSidePeekWidth] = useState(() => {
if (typeof window === "undefined") return 720;
@@ -114,17 +121,158 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
const initialMouseXRef = useRef(0);
const livePeekWidthRef = useRef(sidePeekWidth);
const beamCloseTimeoutRef = useRef(null);
+ const cardAttachmentDragDepthRef = useRef(0);
// store hooks
const {
setPeekIssue,
isAnyModalOpen,
+ createAttachment,
+ fetchActivities,
+ fetchAttachments,
issue: { getIssueById },
} = useIssueDetail();
const { isAnyModalOpen: isAnyEpicModalOpen } = useIssueDetail(EIssueServiceType.EPICS);
+ const { fileSizeLimitEnabled, maxFileSize } = useFileSize();
const issue = getIssueById(issueId);
const shouldUseInteractiveEmbeddedLayout = embedIssue && interactiveEmbeddedLayout;
const shouldRenderPeekSurface = !embedIssue || shouldUseInteractiveEmbeddedLayout;
const shouldAllowPeekResize = !embedIssue || shouldUseInteractiveEmbeddedLayout;
+ const isCardAttachmentDropDisabled =
+ disabled || is_archived || !!isLoading || !!isError || !issue || isCardAttachmentUploading;
+
+ const handleCardAttachmentDrop = useCallback(
+ async (acceptedFiles: File[], rejectedFileCount = 0) => {
+ if (acceptedFiles.length === 0) {
+ if (rejectedFileCount > 0) {
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: "Файлы не прикреплены",
+ message: fileSizeLimitEnabled
+ ? `Проверьте размер файлов: максимум ${Math.round(maxFileSize / 1024 / 1024)} МБ на файл.`
+ : "Не удалось принять выбранные файлы.",
+ });
+ }
+ return;
+ }
+
+ setIsCardAttachmentUploading(true);
+ try {
+ const uploadResults = await Promise.allSettled(
+ acceptedFiles.map((file) => createAttachment(workspaceSlug, projectId, issueId, file))
+ );
+ const failedUploads = uploadResults.filter((result) => result.status === "rejected");
+
+ await Promise.allSettled([
+ fetchAttachments(workspaceSlug, projectId, issueId),
+ fetchActivities(workspaceSlug, projectId, issueId),
+ ]);
+
+ if (failedUploads.length > 0 || rejectedFileCount > 0) {
+ const failedCount = failedUploads.length + rejectedFileCount;
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title:
+ failedCount === acceptedFiles.length + rejectedFileCount ? "Файлы не прикреплены" : "Загружено не всё",
+ message: `${failedCount} ${failedCount === 1 ? "файл не удалось прикрепить" : "файла не удалось прикрепить"}.`,
+ });
+ } else {
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: acceptedFiles.length === 1 ? "Файл прикреплён" : "Файлы прикреплены",
+ message:
+ acceptedFiles.length === 1
+ ? (acceptedFiles[0]?.name ?? "Вложение добавлено в карточку.")
+ : `${acceptedFiles.length} файлов добавлено в карточку.`,
+ });
+ }
+ } finally {
+ setIsCardAttachmentUploading(false);
+ }
+ },
+ [
+ createAttachment,
+ fetchActivities,
+ fetchAttachments,
+ fileSizeLimitEnabled,
+ issueId,
+ maxFileSize,
+ projectId,
+ workspaceSlug,
+ ]
+ );
+
+ const resetCardAttachmentDrag = useCallback(() => {
+ cardAttachmentDragDepthRef.current = 0;
+ setIsCardAttachmentDragActive(false);
+ }, []);
+
+ useEffect(() => {
+ window.addEventListener("dragend", resetCardAttachmentDrag, true);
+ window.addEventListener("drop", resetCardAttachmentDrag, true);
+ window.addEventListener("blur", resetCardAttachmentDrag);
+
+ return () => {
+ window.removeEventListener("dragend", resetCardAttachmentDrag, true);
+ window.removeEventListener("drop", resetCardAttachmentDrag, true);
+ window.removeEventListener("blur", resetCardAttachmentDrag);
+ };
+ }, [resetCardAttachmentDrag]);
+
+ const handleCardDragEnterCapture = useCallback(
+ (event: ReactDragEvent) => {
+ if (isCardAttachmentDropDisabled || !isFileDragEvent(event)) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+ cardAttachmentDragDepthRef.current += 1;
+ setIsCardAttachmentDragActive(true);
+ },
+ [isCardAttachmentDropDisabled]
+ );
+
+ const handleCardDragOverCapture = useCallback(
+ (event: ReactDragEvent) => {
+ if (isCardAttachmentDropDisabled || !isFileDragEvent(event)) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+ event.dataTransfer.dropEffect = "copy";
+ setIsCardAttachmentDragActive(true);
+ },
+ [isCardAttachmentDropDisabled]
+ );
+
+ const handleCardDragLeaveCapture = useCallback(
+ (event: ReactDragEvent) => {
+ if (isCardAttachmentDropDisabled || !isFileDragEvent(event)) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+ cardAttachmentDragDepthRef.current = Math.max(0, cardAttachmentDragDepthRef.current - 1);
+ if (cardAttachmentDragDepthRef.current === 0) setIsCardAttachmentDragActive(false);
+ },
+ [isCardAttachmentDropDisabled]
+ );
+
+ const handleCardDropCapture = useCallback(
+ (event: ReactDragEvent) => {
+ if (!isFileDragEvent(event)) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+ resetCardAttachmentDrag();
+ if (isCardAttachmentDropDisabled) return;
+
+ const droppedFiles = Array.from(event.dataTransfer.files);
+ const acceptedFiles = fileSizeLimitEnabled
+ ? droppedFiles.filter((file) => file.size <= maxFileSize)
+ : droppedFiles;
+ const rejectedFileCount = droppedFiles.length - acceptedFiles.length;
+
+ void handleCardAttachmentDrop(acceptedFiles, rejectedFileCount);
+ },
+ [fileSizeLimitEnabled, handleCardAttachmentDrop, isCardAttachmentDropDisabled, maxFileSize, resetCardAttachmentDrag]
+ );
// remove peek id
const removeRoutePeekId = () => {
setPeekIssue(undefined);
@@ -407,7 +555,32 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
};
const issuePanel = issueId ? (
-
+
+ {isCardAttachmentDragActive && (
+
+
+
+
+
+
+
Отпустите файлы, чтобы прикрепить
+
+ {fileSizeLimitEnabled
+ ? `Можно несколько файлов за раз, до ${Math.round(maxFileSize / 1024 / 1024)} МБ каждый`
+ : "Можно прикрепить несколько файлов за раз"}
+
+
+
+ )}
{shouldAllowPeekResize && peekMode === "side-peek" && (
* + * {
+ margin-top: 1rem;
+ }
+
+ .nodedc-attachment-markdown-preview h1,
+ .nodedc-attachment-markdown-preview h2,
+ .nodedc-attachment-markdown-preview h3,
+ .nodedc-attachment-markdown-preview h4 {
+ color: var(--text-color-primary);
+ line-height: 1.3;
+ }
+
+ .nodedc-attachment-markdown-preview h1 {
+ font-size: 1.5rem;
+ }
+
+ .nodedc-attachment-markdown-preview h2 {
+ font-size: 1.25rem;
+ }
+
+ .nodedc-attachment-markdown-preview h3,
+ .nodedc-attachment-markdown-preview h4 {
+ font-size: 1rem;
+ }
+
+ .nodedc-attachment-markdown-preview blockquote {
+ padding-left: 1rem;
+ border-left: 2px solid rgba(var(--nodedc-accent-rgb), 0.48);
+ color: var(--text-color-tertiary);
+ }
+
+ .nodedc-attachment-markdown-preview code {
+ padding: 0.12rem 0.35rem;
+ border-radius: 0.35rem;
+ background: rgba(255, 255, 255, 0.07);
+ color: var(--text-color-primary);
+ font-family: var(--font-code);
+ }
+
+ .nodedc-attachment-markdown-preview pre {
+ overflow-x: auto;
+ padding: 1rem;
+ border: 1px solid rgba(255, 255, 255, 0.07);
+ border-radius: 0.85rem;
+ background: rgba(0, 0, 0, 0.24);
+ }
+
+ .nodedc-attachment-markdown-preview pre code {
+ padding: 0;
+ background: transparent;
+ }
+
+ .nodedc-attachment-markdown-preview table {
+ display: block;
+ overflow-x: auto;
+ width: 100%;
+ border-collapse: collapse;
+ }
+
+ .nodedc-attachment-markdown-preview th,
+ .nodedc-attachment-markdown-preview td {
+ padding: 0.55rem 0.75rem;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ text-align: left;
+ }
+
+ .nodedc-attachment-markdown-preview a {
+ color: rgb(var(--nodedc-accent-rgb));
+ }
+
.nodedc-attachments-panel[data-view-mode="list"] {
padding-bottom: 0.75rem;
}
diff --git a/plane-src/packages/services/src/file/helper.ts b/plane-src/packages/services/src/file/helper.ts
index b8e9628..b3de2b4 100644
--- a/plane-src/packages/services/src/file/helper.ts
+++ b/plane-src/packages/services/src/file/helper.ts
@@ -10,6 +10,30 @@ import { fileTypeFromBuffer } from "file-type";
import type { TFileMetaDataLite, TFileSignedURLResponse } from "@plane/types";
import { DANGEROUS_EXTENSIONS } from "@plane/constants";
+const TEXT_MIME_TYPES_BY_EXTENSION: Readonly> = {
+ cfg: "text/plain",
+ conf: "text/plain",
+ csv: "text/csv",
+ ini: "text/plain",
+ json: "application/json",
+ jsonl: "application/x-ndjson",
+ log: "text/plain",
+ markdown: "text/markdown",
+ md: "text/markdown",
+ mdown: "text/markdown",
+ mkd: "text/markdown",
+ mkdn: "text/markdown",
+ ndjson: "application/x-ndjson",
+ toml: "application/toml",
+ tsv: "text/tab-separated-values",
+ txt: "text/plain",
+ xml: "application/xml",
+ yaml: "application/yaml",
+ yml: "application/yaml",
+};
+
+const getFileExtension = (filename: string): string => filename.split(".").pop()?.trim().toLowerCase() ?? "";
+
/**
* @description Filename validation - checks for double extensions and dangerous patterns
* @param {string} filename
@@ -82,8 +106,10 @@ const detectMimeTypeFromSignature = async (file: File): Promise => {
};
/**
- * @description Validate and detect the MIME type of a file using signature detection
- * Also performs basic security checks on filename
+ * @description Validate and detect the MIME type of a file.
+ * Binary signatures take precedence. Text formats are resolved from a conservative
+ * extension map because they do not have a binary signature. Browser MIME metadata
+ * and application/octet-stream provide compatibility for other safe attachments.
* @param {File} file
* @returns {Promise} validated and detected MIME type
*/
@@ -91,7 +117,7 @@ const validateAndDetectFileType = async (file: File): Promise => {
// Basic filename validation
const filenameError = validateFilename(file.name);
if (filenameError) {
- console.warn(`File validation warning: ${filenameError}`);
+ throw new Error(filenameError);
}
try {
@@ -103,8 +129,15 @@ const validateAndDetectFileType = async (file: File): Promise => {
console.warn("Error detecting file type from signature:", _error);
}
- // fallback for unknown files
- return "";
+ const extensionMimeType = TEXT_MIME_TYPES_BY_EXTENSION[getFileExtension(file.name)];
+ if (extensionMimeType) return extensionMimeType;
+
+ const browserMimeType = file.type.split(";", 1)[0]?.trim().toLowerCase();
+ if (browserMimeType) return browserMimeType;
+
+ // Preserve generic attachment support when neither the file signature nor the
+ // browser can identify a safe filename. The API still enforces its MIME allowlist.
+ return "application/octet-stream";
};
/**