enhancement: open folder log and change data folder dialog confirm (#5159)

* enhancement: ux change data folder with confirmation and reveal in finder logs

* chore: update button open logs local api server

* Update web-app/src/components/ui/button.tsx

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* chore: handle error when change location data folder failed

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
This commit is contained in:
Faisal Amir 2025-06-02 08:54:16 +07:00 committed by GitHub
parent 057accfb96
commit b98c31b184
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 258 additions and 52 deletions

View File

@ -25,6 +25,7 @@ tauri = { version = "2.4.0", features = [ "protocol-asset", "macos-private-api",
tauri-plugin-log = "2.0.0-rc" tauri-plugin-log = "2.0.0-rc"
tauri-plugin-shell = "2.2.0" tauri-plugin-shell = "2.2.0"
tauri-plugin-os = "2.2.1" tauri-plugin-os = "2.2.1"
tauri-plugin-opener = "2.2.7"
flate2 = "1.0" flate2 = "1.0"
tar = "0.4" tar = "0.4"
rand = "0.8" rand = "0.8"

View File

@ -2,13 +2,9 @@
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "enables the default permissions", "description": "enables the default permissions",
"windows": [ "windows": ["main"],
"main"
],
"remote": { "remote": {
"urls": [ "urls": ["http://*"]
"http://*"
]
}, },
"permissions": [ "permissions": [
"core:default", "core:default",
@ -19,6 +15,7 @@
"core:app:allow-set-app-theme", "core:app:allow-set-app-theme",
"core:window:allow-set-focus", "core:window:allow-set-focus",
"os:default", "os:default",
"opener:default",
"log:default", "log:default",
"updater:default", "updater:default",
"dialog:default", "dialog:default",

View File

@ -17,6 +17,7 @@ pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_updater::Builder::new().build())

View File

@ -31,6 +31,7 @@
"@tanstack/react-router-devtools": "^1.116.0", "@tanstack/react-router-devtools": "^1.116.0",
"@tauri-apps/api": "^2.5.0", "@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.2.1", "@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-opener": "^2.2.7",
"@tauri-apps/plugin-os": "^2.2.1", "@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.7.1", "@tauri-apps/plugin-updater": "^2.7.1",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",

View File

@ -16,7 +16,7 @@ const buttonVariants = cva(
}, },
size: { size: {
default: 'h-7 px-3 py-2 has-[>svg]:px-3 rounded-sm', default: 'h-7 px-3 py-2 has-[>svg]:px-3 rounded-sm',
sm: 'h-6 rounded gap-1.5 px-2 has-[>svg]:px-2.5', sm: 'h-6 gap-1.5 px-2 has-[>svg]:px-2.5 rounded-sm',
lg: 'h-9 rounded-md px-4 has-[>svg]:px-4', lg: 'h-9 rounded-md px-4 has-[>svg]:px-4',
icon: 'size-8', icon: 'size-8',
}, },

View File

@ -0,0 +1,81 @@
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { IconFolder } from '@tabler/icons-react'
interface ChangeDataFolderLocationProps {
children: React.ReactNode
currentPath: string
newPath: string
onConfirm: () => void
open: boolean
onOpenChange: (open: boolean) => void
}
export default function ChangeDataFolderLocation({
children,
currentPath,
newPath,
onConfirm,
open,
onOpenChange,
}: ChangeDataFolderLocationProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconFolder size={20} />
Change Data Folder Location
</DialogTitle>
<DialogDescription>
Are you sure you want to change the data folder location? This will
move all your data to the new location and restart the application.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-main-view-fg/80 mb-2">
Current Location:
</h4>
<div className="bg-main-view-fg/5 border border-main-view-fg/10 rounded">
<code className="text-xs text-main-view-fg/70 break-all">
{currentPath}
</code>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-main-view-fg/80 mb-2">
New Location:
</h4>
<div className="bg-accent/10 border border-accent/20 rounded">
<code className="text-xs text-accent break-all">{newPath}</code>
</div>
</div>
</div>
<DialogFooter className="flex items-center gap-2">
<DialogClose asChild>
<Button variant="link" size="sm">
Cancel
</Button>
</DialogClose>
<DialogClose asChild>
<Button onClick={onConfirm}>Change Location</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -10,6 +10,8 @@ import { useTranslation } from 'react-i18next'
import { useGeneralSetting } from '@/hooks/useGeneralSetting' import { useGeneralSetting } from '@/hooks/useGeneralSetting'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { open } from '@tauri-apps/plugin-dialog' import { open } from '@tauri-apps/plugin-dialog'
import { revealItemInDir } from '@tauri-apps/plugin-opener'
import ChangeDataFolderLocation from '@/containers/dialogs/ChangeDataFolderLocation'
import { import {
Dialog, Dialog,
@ -32,19 +34,35 @@ import {
IconExternalLink, IconExternalLink,
IconFolder, IconFolder,
IconLogs, IconLogs,
IconCopy,
IconCopyCheck,
} from '@tabler/icons-react' } from '@tabler/icons-react'
import { WebviewWindow } from '@tauri-apps/api/webviewWindow' import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
import { windowKey } from '@/constants/windows' import { windowKey } from '@/constants/windows'
import { toast } from 'sonner'
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export const Route = createFileRoute(route.settings.general as any)({ export const Route = createFileRoute(route.settings.general as any)({
component: General, component: General,
}) })
const openFileTitle = (): string => {
if (IS_MACOS) {
return 'Show in Finder'
} else if (IS_WINDOWS) {
return 'Show in File Explorer'
} else {
return 'Open Containing Folder'
}
}
function General() { function General() {
const { t } = useTranslation() const { t } = useTranslation()
const { spellCheckChatInput, setSpellCheckChatInput } = useGeneralSetting() const { spellCheckChatInput, setSpellCheckChatInput } = useGeneralSetting()
const [janDataFolder, setJanDataFolder] = useState<string | undefined>() const [janDataFolder, setJanDataFolder] = useState<string | undefined>()
const [isCopied, setIsCopied] = useState(false)
const [selectedNewPath, setSelectedNewPath] = useState<string | null>(null)
const [isDialogOpen, setIsDialogOpen] = useState(false)
useEffect(() => { useEffect(() => {
const fetchDataFolder = async () => { const fetchDataFolder = async () => {
@ -97,6 +115,52 @@ function General() {
} }
} }
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
setIsCopied(true)
setTimeout(() => setIsCopied(false), 2000) // Reset after 2 seconds
} catch (error) {
console.error('Failed to copy to clipboard:', error)
}
}
const handleDataFolderChange = async () => {
const selectedPath = await open({
multiple: false,
directory: true,
defaultPath: janDataFolder,
})
if (selectedPath === janDataFolder) return
if (selectedPath !== null) {
setSelectedNewPath(selectedPath)
setIsDialogOpen(true)
}
}
const confirmDataFolderChange = async () => {
if (selectedNewPath) {
try {
setJanDataFolder(selectedNewPath)
await relocateJanDataFolder(selectedNewPath)
// Only relaunch if relocation was successful
window.core?.api?.relaunch()
setSelectedNewPath(null)
setIsDialogOpen(false)
} catch (error) {
console.error('Failed to relocate data folder:', error)
// Revert the data folder path on error
const originalPath = await getJanDataFolder()
setJanDataFolder(originalPath)
toast.error(
'Failed to relocate data folder. Please try again or choose a different location.'
)
}
}
}
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<HeaderPage> <HeaderPage>
@ -133,42 +197,71 @@ function General() {
{t('settings.dataFolder.appDataDesc', { {t('settings.dataFolder.appDataDesc', {
ns: 'settings', ns: 'settings',
})} })}
&nbsp;
</span> </span>
<div className="flex items-center gap-2 mt-1">
<span <span
title={janDataFolder} title={janDataFolder}
className="bg-main-view-fg/10 text-xs mt-1 px-1 py-0.5 rounded-sm text-main-view-fg/80 line-clamp-1" className="bg-main-view-fg/10 text-xs px-1 py-0.5 rounded-sm text-main-view-fg/80"
> >
{janDataFolder} {janDataFolder}
</span> </span>
<button
onClick={() =>
janDataFolder && copyToClipboard(janDataFolder)
}
className="cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/15 bg-main-view-fg/10 transition-all duration-200 ease-in-out p-1"
title={isCopied ? 'Copied!' : 'Copy path'}
>
{isCopied ? (
<div className="flex items-center gap-1">
<IconCopyCheck size={12} className="text-accent" />
<span className="text-xs leading-0">Copied</span>
</div>
) : (
<IconCopy
size={12}
className="text-main-view-fg/50"
/>
)}
</button>
</div>
</> </>
} }
actions={ actions={
<>
<Button <Button
variant="link" variant="link"
size="sm" size="sm"
className="hover:no-underline" className="p-0"
title="App Data Folder" title="App Data Folder"
onClick={async () => { onClick={handleDataFolderChange}
const selectedPath = await open({ >
multiple: false, <div className="cursor-pointer flex items-center justify-center rounded-sm hover:bg-main-view-fg/15 bg-main-view-fg/10 transition-all duration-200 ease-in-out px-2 py-1 gap-1">
directory: true, <IconFolder
defaultPath: janDataFolder, size={12}
}) className="text-main-view-fg/50"
if (selectedPath === janDataFolder) return />
if (selectedPath !== null) { <span>Change Location</span>
setJanDataFolder(selectedPath) </div>
await relocateJanDataFolder(selectedPath) </Button>
window.core?.api?.relaunch() {selectedNewPath && (
// TODO: we need function to move everything into new folder selectedPath <ChangeDataFolderLocation
// eg like this currentPath={janDataFolder || ''}
// await window.core?.api?.moveDataFolder(selectedPath) newPath={selectedNewPath}
onConfirm={confirmDataFolderChange}
open={isDialogOpen}
onOpenChange={(open) => {
setIsDialogOpen(open)
if (!open) {
setSelectedNewPath(null)
} }
}} }}
> >
<div className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/15 bg-main-view-fg/10 transition-all duration-200 ease-in-out"> <div />
<IconFolder size={18} className="text-main-view-fg/50" /> </ChangeDataFolderLocation>
</div> )}
</Button> </>
} }
/> />
<CardItem <CardItem
@ -177,17 +270,47 @@ function General() {
})} })}
description="View detailed logs of the App" description="View detailed logs of the App"
actions={ actions={
<div className="flex items-center gap-2">
<Button <Button
variant="link" variant="link"
size="sm" size="sm"
className="p-0"
onClick={handleOpenLogs} onClick={handleOpenLogs}
title="App Logs" title="App Logs"
> >
{/* Open Logs */} <div className="cursor-pointer flex items-center justify-center rounded-sm hover:bg-main-view-fg/15 bg-main-view-fg/10 transition-all duration-200 ease-in-out px-2 py-1 gap-1">
<div className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/15 bg-main-view-fg/10 transition-all duration-200 ease-in-out"> <IconLogs size={12} className="text-main-view-fg/50" />
<IconLogs size={18} className="text-main-view-fg/50" /> <span>Open Logs</span>
</div> </div>
</Button> </Button>
<Button
variant="link"
size="sm"
className="p-0"
onClick={async () => {
if (janDataFolder) {
try {
const logsPath = `${janDataFolder}/logs`
await revealItemInDir(logsPath)
} catch (error) {
console.error(
'Failed to reveal logs folder:',
error
)
}
}
}}
title="Reveal logs folder in file explorer"
>
<div className="cursor-pointer flex items-center justify-center rounded-sm hover:bg-main-view-fg/15 bg-main-view-fg/10 transition-all duration-200 ease-in-out px-2 py-1 gap-1">
<IconFolder
size={12}
className="text-main-view-fg/50"
/>
<span>{openFileTitle()}</span>
</div>
</Button>
</div>
} }
/> />
</Card> </Card>

View File

@ -140,11 +140,13 @@ function LocalAPIServer() {
<Button <Button
variant="link" variant="link"
size="sm" size="sm"
className="p-0"
onClick={handleOpenLogs} onClick={handleOpenLogs}
title="Server Logs" title="Server Logs"
> >
<div className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/15 bg-main-view-fg/10 transition-all duration-200 ease-in-out"> <div className="cursor-pointer flex items-center justify-center rounded-sm hover:bg-main-view-fg/15 bg-main-view-fg/10 transition-all duration-200 ease-in-out px-2 py-1 gap-1">
<IconLogs size={18} className="text-main-view-fg/50" /> <IconLogs size={18} className="text-main-view-fg/50" />
<span>Open Logs</span>
</div> </div>
</Button> </Button>
} }