Merge pull request 'feat: entity move' (#10) from feat/entity-move into main

Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
2026-03-28 13:29:10 +00:00
6 changed files with 628 additions and 22 deletions

View File

@@ -0,0 +1,283 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useServicesStore } from '@MailManager/stores/servicesStore'
import { useMailStore } from '@/stores/mailStore'
import type { ServiceObject, CollectionObject } from '@MailManager/models'
import FolderSelectionTreeNode from './FolderSelectionTreeNode.vue'
interface Props {
modelValue: boolean
loading?: boolean
title?: string
confirmText?: string
emptyText?: string
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
title: 'Move To',
confirmText: 'Move',
emptyText: 'No folders are available.',
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
select: [folder: CollectionObject]
cancel: []
}>()
const collectionsStore = useCollectionsStore()
const servicesStore = useServicesStore()
const mailStore = useMailStore()
const selectedFolderKey = ref<string | null>(null)
const dialogValue = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value),
})
const folderKeyFor = (folder: CollectionObject): string => {
return `${folder.provider}:${String(folder.service)}:${String(folder.identifier)}`
}
interface ServiceGroup {
service: ServiceObject
loading: boolean
loaded: boolean
error: string | null
}
const serviceGroups = computed<ServiceGroup[]>(() => {
const moveCandidate = mailStore.moveMessageCandidate
if (!moveCandidate) {
return []
}
const service = servicesStore.services.find(entry =>
entry.provider === moveCandidate.provider &&
String(entry.identifier) === String(moveCandidate.service),
)
if (!service) {
return []
}
if (service.identifier === null) {
return []
}
return [{
service,
loading: mailStore.isServiceFolderLoading(service.provider, service.identifier),
loaded: mailStore.hasServiceFoldersLoaded(service.provider, service.identifier),
error: mailStore.getServiceFolderError(service.provider, service.identifier),
}]
})
const getRootFolders = (service: ServiceObject): CollectionObject[] => {
if (service.identifier === null) {
return []
}
return collectionsStore.collectionsInCollection(service.provider, service.identifier, null)
}
const getServiceFolders = (service: ServiceObject): CollectionObject[] => {
if (service.identifier === null) {
return []
}
return collectionsStore.collectionsForService(service.provider, service.identifier)
}
const selectedFolder = computed(() => {
if (!selectedFolderKey.value) {
return null
}
const group = serviceGroups.value[0]
if (!group) {
return null
}
return getServiceFolders(group.service).find(folder => folderKeyFor(folder) === selectedFolderKey.value) ?? null
})
const canConfirm = computed(() => {
return selectedFolder.value !== null && !props.loading
})
watch(
() => [props.modelValue, mailStore.moveMessageCandidate],
([isOpen]) => {
if (!isOpen) {
return
}
selectedFolderKey.value = null
},
)
const handleSelect = (folder: CollectionObject) => {
selectedFolderKey.value = folderKeyFor(folder)
}
const handleCancel = () => {
emit('cancel')
dialogValue.value = false
}
const handleConfirm = () => {
if (!selectedFolder.value) {
return
}
emit('select', selectedFolder.value)
}
</script>
<template>
<v-dialog
v-model="dialogValue"
max-width="680"
>
<v-card>
<v-card-title class="text-h6">
{{ title }}
</v-card-title>
<v-card-text>
<div class="folder-tree-card">
<v-list
density="compact"
nav
class="folder-tree-list"
>
<template
v-for="group in serviceGroups"
:key="`${group.service.provider}-${group.service.identifier}`"
>
<v-list-item
class="account-header-item account-header-static"
:title="group.service.label || 'Mail Account'"
:subtitle="group.service.primaryAddress || undefined"
>
<template #prepend>
<v-icon icon="mdi-email-outline" />
</template>
</v-list-item>
<FolderSelectionTreeNode
v-for="folder in getRootFolders(group.service)"
:key="folderKeyFor(folder)"
:folder="folder"
:service="group.service"
:selected-folder-key="selectedFolderKey"
@select="handleSelect"
/>
<v-list-item
v-if="group.loading && getServiceFolders(group.service).length === 0"
disabled
class="folder-status-item"
>
<template #prepend>
<v-progress-circular indeterminate size="18" width="2" color="primary" />
</template>
<v-list-item-title>Loading folders</v-list-item-title>
</v-list-item>
<v-list-item
v-else-if="group.error && getServiceFolders(group.service).length === 0"
disabled
class="folder-status-item"
>
<template #prepend>
<v-icon icon="mdi-alert-circle-outline" color="error" />
</template>
<v-list-item-title>Folders unavailable</v-list-item-title>
<v-list-item-subtitle>{{ group.error }}</v-list-item-subtitle>
</v-list-item>
<v-list-item
v-else-if="group.loaded && getServiceFolders(group.service).length === 0"
:title="emptyText"
disabled
class="folder-status-item"
>
<template #prepend>
<v-icon icon="mdi-folder-off-outline" />
</template>
</v-list-item>
</template>
<v-list-item
v-if="serviceGroups.length === 0"
:title="emptyText"
disabled
>
<template #prepend>
<v-icon icon="mdi-folder-off-outline" />
</template>
</v-list-item>
</v-list>
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
variant="text"
:disabled="loading"
@click="handleCancel"
>
Cancel
</v-btn>
<v-btn
color="primary"
variant="elevated"
:disabled="!canConfirm"
:loading="loading"
@click="handleConfirm"
>
{{ confirmText }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
.folder-tree-card {
min-height: 280px;
}
.folder-tree-list {
max-height: 380px;
overflow-y: auto;
}
.account-header-item {
--v-list-item-prepend-size: 22px;
background-color: rgba(var(--v-theme-primary), 0.1);
border-radius: 6px;
}
.account-header-static {
margin-bottom: 4px;
}
.account-header-item :deep(.v-list-item__prepend) {
padding-inline-start: 4px;
margin-inline-end: 2px;
}
.folder-status-item {
padding-inline-start: 16px;
}
</style>

View File

@@ -0,0 +1,185 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import type { CollectionObject } from '@MailManager/models/collection'
import type { ServiceObject } from '@MailManager/models'
interface Props {
folder: CollectionObject
service: ServiceObject
selectedFolderKey: string | null
}
const props = defineProps<Props>()
const collectionsStore = useCollectionsStore()
const emit = defineEmits<{
select: [folder: CollectionObject]
}>()
const expanded = ref(false)
const folderKeyFor = (folder: CollectionObject): string => {
return `${folder.provider}:${String(folder.service)}:${String(folder.identifier)}`
}
const folderLabelFor = (folder: CollectionObject): string => {
return folder.properties.label || String(folder.identifier)
}
const folderIconFor = (folder: CollectionObject): string => {
switch (folder.properties.role) {
case 'inbox':
return 'mdi-inbox'
case 'sent':
return 'mdi-send'
case 'drafts':
return 'mdi-file-document'
case 'trash':
return 'mdi-delete'
case 'junk':
return 'mdi-alert-octagon'
case 'archive':
return 'mdi-archive'
case 'outbox':
return 'mdi-tray-arrow-up'
default:
return 'mdi-folder'
}
}
const folderColorFor = (folder: CollectionObject): string | undefined => {
switch (folder.properties.role) {
case 'inbox':
return 'primary'
case 'sent':
return 'success'
case 'drafts':
return 'warning'
case 'trash':
return 'error'
case 'junk':
return 'orange'
default:
return undefined
}
}
const key = computed(() => folderKeyFor(props.folder))
const childFolders = computed(() => {
const serviceIdentifier = props.service.identifier
if (serviceIdentifier === null) {
return []
}
return collectionsStore.collectionsInCollection(props.service.provider, serviceIdentifier, props.folder.identifier)
})
const hasChildren = computed(() => {
const serviceIdentifier = props.service.identifier
if (serviceIdentifier === null) {
return false
}
return collectionsStore.hasChildrenInCollection(props.service.provider, serviceIdentifier, props.folder.identifier)
})
const isSelected = computed(() => props.selectedFolderKey === key.value)
const onSelect = () => {
emit('select', props.folder)
}
const toggleExpanded = () => {
expanded.value = !expanded.value
}
const onGroupDoubleClick = () => {
toggleExpanded()
onSelect()
}
</script>
<template>
<div
v-if="hasChildren"
class="folder-node-group"
>
<v-list-item
class="folder-node"
:title="folderLabelFor(folder)"
:active="isSelected"
@click="onSelect"
@dblclick.stop="onGroupDoubleClick"
>
<template #prepend>
<v-icon
:icon="folderIconFor(folder)"
:color="folderColorFor(folder)"
/>
</template>
<template #append>
<v-btn
variant="text"
density="compact"
size="x-small"
:icon="expanded ? 'mdi-chevron-down' : 'mdi-chevron-right'"
@click.stop="toggleExpanded"
/>
</template>
</v-list-item>
<div
v-if="expanded"
class="folder-node-children"
>
<FolderSelectionTreeNode
v-for="childFolder in childFolders"
:key="folderKeyFor(childFolder)"
:folder="childFolder"
:service="service"
:selected-folder-key="selectedFolderKey"
@select="emit('select', $event)"
/>
</div>
</div>
<v-list-item
v-else
class="folder-node"
:title="folderLabelFor(folder)"
:active="isSelected"
@click="onSelect"
>
<template #prepend>
<v-icon
:icon="folderIconFor(folder)"
:color="folderColorFor(folder)"
/>
</template>
</v-list-item>
</template>
<style scoped>
.folder-node {
--v-list-item-prepend-size: 22px;
}
.folder-node.v-list-item--active {
background-color: rgba(var(--v-theme-primary), 0.12);
}
.folder-node :deep(.v-list-item__prepend) {
padding-inline-start: 4px;
margin-inline-end: 2px;
}
.folder-node-children {
padding-left: 12px;
margin-left: 8px;
border-left: 2px solid rgba(var(--v-border-color), 0.3);
}
</style>

View File

@@ -24,6 +24,7 @@ const { getSetting } = useUser()
const emit = defineEmits<{
reply: [message: EntityInterface<MessageInterface>]
forward: [message: EntityInterface<MessageInterface>]
move: [message: EntityInterface<MessageInterface>]
delete: [message: EntityInterface<MessageInterface>]
flag: [message: EntityInterface<MessageInterface>]
compose: []
@@ -105,6 +106,12 @@ const handleDelete = () => {
}
}
const handleMove = () => {
if (props.message) {
emit('move', props.message)
}
}
const handleFlag = () => {
if (props.message) {
emit('flag', props.message)
@@ -134,6 +141,7 @@ const handleCompose = () => {
:is-security-overridden="overrideSecurityLevel !== null"
@reply="handleReply"
@forward="handleForward"
@move="handleMove"
@delete="handleDelete"
@flag="handleFlag"
@toggle-images="toggleImages"

View File

@@ -17,6 +17,7 @@ const props = defineProps<Props>()
const emit = defineEmits<{
reply: []
forward: []
move: []
delete: []
flag: []
toggleImages: []
@@ -146,13 +147,31 @@ const currentSecurityLevel = computed(() => {
<v-spacer />
<v-btn
icon="mdi-dots-vertical"
variant="text"
>
<v-icon>mdi-dots-vertical</v-icon>
<v-tooltip activator="parent" location="bottom">More Actions</v-tooltip>
</v-btn>
<v-menu>
<template #activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
icon="mdi-dots-vertical"
variant="text"
>
<v-icon>mdi-dots-vertical</v-icon>
<v-tooltip activator="parent" location="bottom">More Actions</v-tooltip>
</v-btn>
</template>
<v-list density="compact">
<v-list-item
prepend-icon="mdi-folder-move-outline"
title="Move To Folder"
@click="emit('move')"
/>
<v-list-item
prepend-icon="mdi-delete-outline"
title="Delete"
@click="emit('delete')"
/>
</v-list>
</v-menu>
</v-toolbar>
</template>

View File

@@ -4,14 +4,13 @@ import { storeToRefs } from 'pinia'
import { useDisplay } from 'vuetify'
import { useModuleStore } from '@KTXC'
import { useMailStore } from '@/stores/mailStore'
import type { CollectionObject, EntityObject } from '@MailManager/models'
import FolderTree from '@/components/FolderTree.vue'
import MessageList from '@/components/MessageList.vue'
import MessageReader from '@/components/MessageReader.vue'
import MessageComposer from '@/components/MessageComposer.vue'
import FolderSelectionDialog from '@/components/FolderSelectionDialog.vue'
import SettingsDialog from '@/components/settings/SettingsDialog.vue'
import type { EntityInterface } from '@MailManager/types/entity'
import type { MessageInterface } from '@MailManager/types/message'
import type { CollectionObject } from '@MailManager/models'
// Vuetify display for responsive behavior
const display = useDisplay()
@@ -36,6 +35,7 @@ const {
composeMode,
composeReplyTo,
currentMessages,
moveDialogVisible,
} = storeToRefs(mailStore)
// Complex store/composable objects accessed directly (not simple refs)
@@ -50,17 +50,25 @@ onMounted(async () => {
// Handlers — thin wrappers that delegate to the store
const handleFolderSelect = (folder: CollectionObject) => mailStore.selectFolder(folder)
const handleMessageSelect = (message: EntityInterface<MessageInterface>) => mailStore.selectMessage(message, isMobile.value)
const handleMessageSelect = (message: EntityObject) => mailStore.selectMessage(message, isMobile.value)
const handleCompose = (replyTo?: EntityInterface<MessageInterface>) => mailStore.openCompose(replyTo)
const handleCompose = (replyTo?: EntityObject) => mailStore.openCompose(replyTo)
const handleComposeClose = () => mailStore.closeCompose()
const handleComposeSent = () => mailStore.afterSent()
const handleReply = (message: EntityInterface<MessageInterface>) => mailStore.openCompose(message)
const handleReply = (message: EntityObject) => mailStore.openCompose(message)
const handleDelete = (message: EntityInterface<MessageInterface>) => mailStore.deleteMessage(message)
const handleDelete = (message: EntityObject) => mailStore.deleteMessage(message)
const handleMove = (message: EntityObject) => mailStore.openMoveDialog(message)
const handleMoveConfirm = async (folder: CollectionObject) => {
await mailStore.moveMessage(folder)
}
const handleMoveCancel = () => mailStore.closeMoveDialog()
const toggleSidebar = () => mailStore.toggleSidebar()
@@ -193,6 +201,7 @@ const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Fold
v-else
:message="selectedMessage"
@reply="handleReply"
@move="handleMove"
@delete="handleDelete"
@compose="handleCompose()"
/>
@@ -203,6 +212,16 @@ const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Fold
<!-- Settings Dialog -->
<SettingsDialog v-model="settingsDialogVisible" />
<FolderSelectionDialog
v-model="moveDialogVisible"
:loading="loading"
title="Move To"
confirm-text="Move"
empty-text="No other folders are available in this account."
@select="handleMoveConfirm"
@cancel="handleMoveCancel"
/>
</div>
</template>

View File

@@ -5,10 +5,9 @@ import { useEntitiesStore } from '@MailManager/stores/entitiesStore'
import { useServicesStore } from '@MailManager/stores/servicesStore'
import { useMailSync } from '@MailManager/composables/useMailSync'
import { useSnackbar } from '@KTXC'
import type { CollectionObject } from '@MailManager/models/collection'
import type { EntityInterface } from '@MailManager/types/entity'
import type { CollectionIdentifier, EntityIdentifier } from '@MailManager/types/common'
import type { MessageInterface } from '@MailManager/types/message'
import type { ServiceObject } from '@MailManager/models'
import type { ServiceObject, CollectionObject, EntityObject } from '@MailManager/models'
export const useMailStore = defineStore('mailStore', () => {
const collectionsStore = useCollectionsStore()
@@ -42,11 +41,15 @@ export const useMailStore = defineStore('mailStore', () => {
// ── Selection State ───────────────────────────────────────────────────────
const selectedFolder = shallowRef<CollectionObject | null>(null)
const selectedMessage = shallowRef<EntityInterface<MessageInterface> | null>(null)
const selectedMessage = shallowRef<EntityObject | null>(null)
// ── Compose State ─────────────────────────────────────────────────────────
const composeMode = ref(false)
const composeReplyTo = shallowRef<EntityInterface<MessageInterface> | null>(null)
const composeReplyTo = shallowRef<EntityObject | null>(null)
// ── Move State ────────────────────────────────────────────────────────────
const moveDialogVisible = ref(false)
const moveMessageCandidate = shallowRef<EntityObject | null>(null)
// ── Computed ──────────────────────────────────────────────────────────────
const currentMessages = computed(() => {
@@ -205,6 +208,27 @@ export const useMailStore = defineStore('mailStore', () => {
return serviceFolderErrorState.value[_serviceKey(provider, service)] ?? null
}
function _entityIdentifier(entity: EntityObject): EntityIdentifier {
return `${entity.provider}:${String(entity.service)}:${String(entity.collection)}:${String(entity.identifier)}` as EntityIdentifier
}
function _collectionIdentifier(collection: CollectionObject): CollectionIdentifier {
return `${collection.provider}:${String(collection.service)}:${String(collection.identifier)}` as CollectionIdentifier
}
function _isSameMessage(left: EntityObject | null, right: EntityObject): boolean {
if (!left) {
return false
}
return (
left.provider === right.provider &&
String(left.service) === String(right.service) &&
String(left.collection) === String(right.collection) &&
String(left.identifier) === String(right.identifier)
)
}
// ── Actions ───────────────────────────────────────────────────────────────
async function selectFolder(folder: CollectionObject) {
@@ -227,7 +251,7 @@ export const useMailStore = defineStore('mailStore', () => {
_updateSyncSources()
}
function selectMessage(message: EntityInterface<MessageInterface>, closeSidebar = false) {
function selectMessage(message: EntityObject, closeSidebar = false) {
selectedMessage.value = message
composeMode.value = false
@@ -236,12 +260,22 @@ export const useMailStore = defineStore('mailStore', () => {
}
}
function openCompose(replyTo?: EntityInterface<MessageInterface>) {
function openCompose(replyTo?: EntityObject) {
composeMode.value = true
composeReplyTo.value = replyTo ?? null
selectedMessage.value = null
}
function openMoveDialog(message: EntityObject) {
moveMessageCandidate.value = message
moveDialogVisible.value = true
}
function closeMoveDialog() {
moveDialogVisible.value = false
moveMessageCandidate.value = null
}
function closeCompose() {
composeMode.value = false
composeReplyTo.value = null
@@ -257,11 +291,64 @@ export const useMailStore = defineStore('mailStore', () => {
}
}
async function deleteMessage(message: EntityInterface<MessageInterface>) {
async function deleteMessage(message: EntityObject) {
// TODO: implement delete via entity / collection store
console.log('[Mail] Delete message:', message.identifier)
}
async function moveMessage(targetFolder: CollectionObject) {
const message = moveMessageCandidate.value
if (!message) {
return
}
const isSameCollection =
targetFolder.provider === message.provider &&
String(targetFolder.service) === String(message.service) &&
String(targetFolder.identifier) === String(message.collection)
if (isSameCollection) {
closeMoveDialog()
return
}
loading.value = true
try {
const sourceIdentifier = _entityIdentifier(message)
const response = await entitiesStore.move(_collectionIdentifier(targetFolder), [sourceIdentifier])
const result = response[sourceIdentifier]
if (!result || !result.success) {
throw new Error(result && 'error' in result ? result.error : 'Failed to move message')
}
if (_isSameMessage(selectedMessage.value, message)) {
selectedMessage.value = null
}
const service = servicesStore.services.find(entry =>
entry.provider === message.provider &&
String(entry.identifier) === String(message.service),
)
if (service) {
void loadFoldersForService(service)
}
notify(`Message moved to "${targetFolder.properties.label || targetFolder.identifier}"`, 'success')
closeMoveDialog()
} catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to move message'
console.error('[Mail] Failed to move message:', error)
notify(messageText, 'error')
throw error
} finally {
loading.value = false
}
}
function toggleSidebar() {
sidebarVisible.value = !sidebarVisible.value
}
@@ -291,6 +378,8 @@ export const useMailStore = defineStore('mailStore', () => {
selectedMessage,
composeMode,
composeReplyTo,
moveDialogVisible,
moveMessageCandidate,
serviceFolderLoadingState,
serviceFolderLoadedState,
serviceFolderErrorState,
@@ -302,9 +391,12 @@ export const useMailStore = defineStore('mailStore', () => {
selectFolder,
selectMessage,
openCompose,
openMoveDialog,
closeMoveDialog,
closeCompose,
afterSent,
deleteMessage,
moveMessage,
toggleSidebar,
openSettings,
notify,