feat(cloud-integration): add external URL handling for mounted cloud files
Some checks are pending
E2E / Playwright e2e (push) Waiting to run

- Introduced `driveMountExternalURLApiPath` function to generate API paths for external file URLs.
- Enhanced `DriveFileInfo` interface with new properties for cloud mount providers and external URLs.
- Implemented functions to determine if a mounted file should open externally and to resolve external URLs.
- Updated existing components to utilize the new external URL functionality for improved user experience when opening cloud documents.
This commit is contained in:
R3D347HR4Y 2026-06-13 13:44:37 +02:00
parent 4d31ac294b
commit 918ce6e348
8 changed files with 144 additions and 2 deletions

View File

@ -14,6 +14,7 @@ import {
Trash2,
Undo2,
} from "lucide-react"
import { Icon } from "@iconify/react"
import {
ContextMenuItem,
} from "@/components/ui/context-menu"
@ -28,6 +29,7 @@ import {
} from "@/lib/drive/drive-menu-guard"
import { cn } from "@/lib/utils"
import { driveTrashItemKey } from "@/lib/drive/drive-trash"
import { mountCloudSuiteBrand } from "@/lib/drive/cloud-native-open"
export const DRIVE_MENU_ITEM_CLASS =
"gap-3 py-2 text-[#3c4043] focus:text-[#3c4043] dark:text-[#e8eaed] dark:focus:text-[#e8eaed] [&_svg]:text-[#3c4043] dark:[&_svg]:text-[#e8eaed]"
@ -96,6 +98,7 @@ export function DriveFileMenuActions({
}: DriveFileMenuActionsProps) {
const single = targets.length === 1 ? targets[0]! : null
const multi = targets.length > 1
const mountSuiteBrand = single ? mountCloudSuiteBrand(single) : null
const runAsync = async (fn: () => Promise<void>, ok: string, err: string) => {
onClose?.()
@ -169,8 +172,12 @@ export function DriveFileMenuActions({
}> = [
{
key: "open",
label: "Ouvrir",
icon: <ExternalLink className="h-4 w-4" aria-hidden />,
label: mountSuiteBrand?.label ?? "Ouvrir",
icon: mountSuiteBrand ? (
<Icon icon={mountSuiteBrand.icon} className="h-4 w-4" aria-hidden />
) : (
<ExternalLink className="h-4 w-4" aria-hidden />
),
visible: !hideOpen && !multi && Boolean(single),
onSelect: () => runMenuAction(() => window.setTimeout(() => onOpen(), 0)),
},

View File

@ -15,6 +15,7 @@ import {
driveServerThumbnail,
isOfficeFormat,
} from "@/lib/drive/drive-preview"
import { shouldOpenMountFileExternally } from "@/lib/drive/cloud-native-open"
import { cn } from "@/lib/utils"
function useInView(rootMargin = "200px") {
@ -112,7 +113,9 @@ export function FileThumbnail({
const retriedRef = useRef(false)
const queryClient = useQueryClient()
const previewKind = drivePreviewKind(file)
const forceSuiteIcon = shouldOpenMountFileExternally(file)
const canPreview =
!forceSuiteIcon &&
file.type === "file" &&
previewKind !== "audio" &&
(driveServerThumbnail(file) || previewKind !== null)

View File

@ -52,6 +52,11 @@ export function driveMountPreviewApiPath(
return `/drive/mounts/${encodeURIComponent(mountId)}/preview/${encoded}?w=${width}&h=${height}`
}
export function driveMountExternalURLApiPath(mountId: string, filePath: string): string {
const encoded = encodeFolderPath(filePath)
return `/drive/mounts/${encodeURIComponent(mountId)}/files/external-url/${encoded}`
}
export interface DrivePathRef {
root?: "personal" | "org" | "mount"
root_id?: string

View File

@ -392,6 +392,12 @@ export interface DriveFileInfo {
source?: string
root_kind?: DriveRootKind
root_id?: string
/** Cloud mount provider (google, microsoft) for external mounts. */
mount_backend?: string
/** Provider web editor URL (Google Docs, Office Online, …). */
external_url?: string
/** Default open uses external_url instead of Ultidocs / OnlyOffice. */
open_externally?: boolean
capabilities?: DriveFileCapabilities
}

View File

@ -0,0 +1,90 @@
import { apiClient } from "@/lib/api/client"
import { driveMountExternalURLApiPath } from "@/lib/api/drive-roots"
import type { DriveFileInfo } from "@/lib/api/types"
/** Mounted cloud file that should open in Google / Microsoft web apps by default. */
export function shouldOpenMountFileExternally(file: DriveFileInfo): boolean {
return (
file.root_kind === "mount" &&
file.type !== "directory" &&
Boolean(file.open_externally)
)
}
export async function resolveMountExternalURL(
mountId: string,
filePath: string
): Promise<string | null> {
const path = filePath.startsWith("/") ? filePath : `/${filePath}`
const data = await apiClient.get<{ external_url?: string }>(
driveMountExternalURLApiPath(mountId, path)
)
const url = data.external_url?.trim()
return url || null
}
/** Open mount cloud document in provider web app (new tab). Returns false if blocked or unresolved. */
export async function openMountFileExternally(file: DriveFileInfo): Promise<boolean> {
let url = file.external_url?.trim()
if (!url && file.root_id) {
url = (await resolveMountExternalURL(file.root_id, file.path)) ?? undefined
}
if (!url) return false
const opened = window.open(url, "_blank", "noopener,noreferrer")
return Boolean(opened)
}
/** Branding for mount cloud-native files (label + Iconify icon). */
export type MountCloudSuiteBrand = {
label: string
icon: string
}
export function mountCloudSuiteBrand(file: DriveFileInfo): MountCloudSuiteBrand | null {
if (!shouldOpenMountFileExternally(file)) return null
const label = mountExternalOpenLabel(file)
const icon = mountCloudSuiteIcon(file)
if (!label || !icon) return null
return { label, icon }
}
/** Iconify id for native suite file type on external mounts. */
export function mountCloudSuiteIcon(file: DriveFileInfo): string | null {
if (!shouldOpenMountFileExternally(file)) return null
const backend = file.mount_backend
if (backend === "google") {
const mime = (file.mime_type ?? "").toLowerCase()
if (mime.includes("spreadsheet")) return "logos:google-sheets"
if (mime.includes("presentation")) return "logos:google-slides"
if (mime.includes("document")) return "logos:google-docs"
return "logos:google-drive"
}
if (backend === "microsoft") {
const ext = file.name.split(".").pop()?.toLowerCase() ?? ""
if (ext === "xls" || ext === "xlsx") return "vscode-icons:file-type-excel"
if (ext === "ppt" || ext === "pptx") return "vscode-icons:file-type-powerpoint"
if (ext === "doc" || ext === "docx") return "vscode-icons:file-type-word"
return "logos:microsoft-onedrive"
}
return null
}
export function mountExternalOpenLabel(file: DriveFileInfo): string | null {
if (!shouldOpenMountFileExternally(file)) return null
const backend = file.mount_backend
if (backend === "google") {
const mime = (file.mime_type ?? "").toLowerCase()
if (mime.includes("spreadsheet")) return "Ouvrir dans Google Sheets"
if (mime.includes("presentation")) return "Ouvrir dans Google Slides"
if (mime.includes("document")) return "Ouvrir dans Google Docs"
return "Ouvrir dans Google Drive"
}
if (backend === "microsoft") {
const ext = file.name.split(".").pop()?.toLowerCase() ?? ""
if (ext === "xls" || ext === "xlsx") return "Ouvrir dans Excel Online"
if (ext === "ppt" || ext === "pptx") return "Ouvrir dans PowerPoint Online"
if (ext === "doc" || ext === "docx") return "Ouvrir dans Word Online"
return "Ouvrir dans Microsoft 365"
}
return "Ouvrir dans lapplication cloud"
}

View File

@ -1,6 +1,7 @@
import { apiClient } from "@/lib/api/client"
import type { DriveFileInfo } from "@/lib/api/types"
import { driveFolderHref } from "@/lib/drive/drive-sidebar-tree"
import { shouldOpenMountFileExternally } from "@/lib/drive/cloud-native-open"
import { shouldOpenInOnlyOffice, shouldOpenInRichTextEditor } from "@/lib/drive/drive-preview"
import { buildDriveDocsEditHref, buildDriveEditHref } from "@/lib/drive/drive-url"
@ -19,6 +20,10 @@ export async function resolveDriveItemLinkHref(file: DriveFileInfo): Promise<str
return driveFolderHref(view, file.path)
}
if (shouldOpenMountFileExternally(file) && file.external_url) {
return file.external_url
}
if (shouldOpenInRichTextEditor(file)) {
let fileId = file.file_id
if (!fileId) {

View File

@ -1,6 +1,7 @@
import { Icon } from "@iconify/react"
import type { DriveFileInfo } from "@/lib/api/types"
import { classifyDriveFile, type DriveMimeCategory } from "@/lib/drive/drive-filters"
import { mountCloudSuiteIcon } from "@/lib/drive/cloud-native-open"
import { cn } from "@/lib/utils"
export type DriveIconSize = "sm" | "md" | "lg"
@ -127,5 +128,17 @@ export function DriveFileTypeIcon({
/>
)
}
const suiteIcon = mountCloudSuiteIcon(file)
if (suiteIcon) {
return (
<span data-drive-type-icon className="inline-flex shrink-0">
<Icon
icon={suiteIcon}
className={cn(SIZE_CLASS[size], "shrink-0", className)}
aria-hidden
/>
</span>
)
}
return <DriveMimeCategoryIcon category={category} size={size} className={className} />
}

View File

@ -1,5 +1,9 @@
import { downloadDriveFile } from "@/lib/api/drive-download"
import type { DriveFileInfo } from "@/lib/api/types"
import {
openMountFileExternally,
shouldOpenMountFileExternally,
} from "@/lib/drive/cloud-native-open"
import {
drivePreviewKind,
isPreviewNavigable,
@ -50,6 +54,15 @@ export function openDriveItem(file: DriveFileInfo, options: OpenDriveItemOptions
return
}
if (shouldOpenMountFileExternally(file)) {
void openMountFileExternally(file).then((opened) => {
if (!opened) {
window.alert("Impossible douvrir le document dans lapplication cloud.")
}
})
return
}
if (drivePreviewKind(file)) {
const ext = file.name.split(".").pop()?.toLowerCase() ?? ""
const richTextPreview =