diff --git a/src/App.vue b/src/App.vue index b8863a1..80e0537 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,38 +1,14 @@ + + + + diff --git a/src/components/AppPickerPanel.vue b/src/components/AppPickerPanel.vue new file mode 100644 index 0000000..b1d718e --- /dev/null +++ b/src/components/AppPickerPanel.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/src/components/AppSettingsPanel.vue b/src/components/AppSettingsPanel.vue new file mode 100644 index 0000000..02ccc7d --- /dev/null +++ b/src/components/AppSettingsPanel.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/src/components/DowngradeConfirmDialog.vue b/src/components/DowngradeConfirmDialog.vue new file mode 100644 index 0000000..0be4b91 --- /dev/null +++ b/src/components/DowngradeConfirmDialog.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/src/components/InstallResultPanel.vue b/src/components/InstallResultPanel.vue new file mode 100644 index 0000000..f87d25f --- /dev/null +++ b/src/components/InstallResultPanel.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/src/styles/App.module.css b/src/styles/App.module.css new file mode 100644 index 0000000..fc067d6 --- /dev/null +++ b/src/styles/App.module.css @@ -0,0 +1,36 @@ +.content { + height: 100%; + margin: 16px; +} + +.layout { + width: 100%; +} + +.mainContent { + width: 100%; + padding-left: 16px; + padding-right: 16px; + box-sizing: border-box; +} + +.contentRow { + display: block; + margin-top: 8px; +} + +.contentRowSplit { + display: flex; + gap: 16px; + align-items: stretch; +} + +.leftColumn, +.rightColumn { + flex: 1 1 0; + min-width: 0; +} + +.leftColumnFull { + width: 100%; +} diff --git a/src/styles/AppInfoPanel.module.css b/src/styles/AppInfoPanel.module.css new file mode 100644 index 0000000..8574059 --- /dev/null +++ b/src/styles/AppInfoPanel.module.css @@ -0,0 +1,342 @@ +.infoPanel { + margin-top: 8px; + width: 100%; + overflow: visible; + max-height: 0; + opacity: 0; + transform: scaleX(0); + transform-origin: right center; + pointer-events: none; + background: var(--color-main-background); + border: 1px solid var(--color-border-dark); + border-left-width: 4px; + border-radius: 6px; + padding: 10px; + display: flex; + flex-direction: column; + gap: 8px; + box-sizing: border-box; + transition: + max-height 0.28s ease, + opacity 0.2s ease, + transform 0.28s ease; +} + +.infoPanelOpen { + opacity: 1; + transform: scaleX(1); + max-height: calc(100vh - 160px); + pointer-events: auto; +} + +.installed { + border-left: 4px solid var(--color-border-dark); + padding: 8px 10px; + width: 100%; + margin: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.selectedApp, +.installedCurrent, +.selectedVersion { + display: flex; + flex-direction: column; + gap: 2px; +} + +.installedLabel { + font-size: 12px; + color: var(--color-text-maxcontrast); + margin-right: 6px; +} + +.installedValue { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-weight: 600; + font-size: 14px; +} + +.installedSubvalue { + font-size: 12px; + color: var(--color-text-maxcontrast); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.changeAppButton { + align-self: flex-start; + margin-top: 8px; +} + +.versionTransition { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; +} + +.versionChip { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-weight: 600; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 8px; + border: 1px solid var(--color-border-dark); + border-radius: 9999px; + background: var(--color-main-background); +} + +.versionArrow { + font-weight: 700; + color: var(--color-text-light); +} + +.versionSummary { + margin: 0; + font-size: 12px; + color: var(--color-text-light); +} + +.versionDegradeSummary { + margin: 2px 0 0; + color: #7c2d12; + font-size: 12px; + font-weight: 600; +} + +.versionListContainer { + max-height: calc(100vh - 420px); + min-height: 120px; + overflow: hidden; + overflow-x: hidden; + width: 100%; + display: flex; + flex-direction: column; + padding-inline-end: 4px; +} + +.versionFilterInput { + width: 100%; + box-sizing: border-box; + border: 1px solid var(--color-border-dark); + border-radius: 6px; + padding: 6px 8px; + margin-bottom: 8px; +} + +.versionListWrapper { + width: 100%; + max-height: calc(100vh - 460px); + min-height: 80px; + flex: 1; + overflow-y: scroll; + overflow-x: hidden; + scrollbar-gutter: stable; + scrollbar-width: thin; + scrollbar-color: var(--color-text-maxcontrast) var(--color-background-dark); +} + +.versionListWrapper::-webkit-scrollbar { + width: 8px; +} + +.versionListWrapper::-webkit-scrollbar-track { + background: var(--color-background-dark); + border-radius: 4px; +} + +.versionListWrapper::-webkit-scrollbar-thumb { + background: var(--color-text-maxcontrast); + border-radius: 4px; +} + +.versionListWrapper::-webkit-scrollbar-thumb:hover { + background: var(--color-text-light); +} + +.versionList { + padding-inline-start: 20px; + margin: 8px 0 0; +} + +.versionItem { + width: 100%; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 6px; + justify-content: flex-start; + padding: 6px 0; + transition: + opacity 0.18s ease, + transform 0.18s ease, + max-height 0.18s ease; + overflow: visible; +} + +.versionItemMain { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + width: 100%; +} + +.versionSelectButton { + line-height: 1.1; + margin: 2px 0; + visibility: hidden; + opacity: 0; + transition: opacity 0.12s ease; + padding: 3px 10px; + border: 1px solid var(--color-primary-element); + color: var(--color-primary-element); + border-radius: 6px; + background: var(--color-main-background); + font-size: 12px; +} + +.versionItem:hover .versionSelectButton, +.versionSelectButton:focus-visible { + visibility: visible; + opacity: 1; +} + +.versionSelectButton:hover { + filter: brightness(1.05); +} + +.selectedVersionFlag { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 600; + color: #1f2937; + background: #e0f2fe; + border: 1px solid #38bdf8; + border-radius: 9999px; + padding: 2px 10px; + line-height: 1.3; + margin-left: auto; +} + +.versionActionGroup { + flex-direction: column; + gap: 8px; + display: flex; + width: 100%; +} + +.versionDegradeWarning { + margin: 0; + padding: 8px 10px; + border: 1px solid #fdba74; + background: #ffedd5; + color: #7c2d12; + border-radius: 6px; + font-size: 12px; + line-height: 1.3; +} + +.versionItemActions { + margin-top: 8px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + width: 100%; +} + +.versionActionButton { + appearance: none; + -webkit-appearance: none; + border-radius: 6px; + padding: 3px 10px; + font-size: 12px; + line-height: 1.1; + cursor: pointer; + box-sizing: border-box; + flex: 1 1 0; + width: calc(50% - 5px); +} + +.versionActionUpdateButton { + color: #166534 !important; + border: 1px solid #22c55e !important; + background: #dcfce7 !important; +} + +.versionActionUpdateButton:hover { + background: #bbf7d0 !important; +} + +.versionActionDegradeButton { + color: #991b1b !important; + border: 1px solid #ef4444 !important; + background: #fee2e2 !important; +} + +.versionActionDegradeButton:hover { + background: #fecaca !important; +} + +.versionDeselectButton { + box-sizing: border-box; + flex: 1 1 0; + width: calc(50% - 5px); +} + +.spinner { + display: inline-block; + width: 0.95em; + height: 0.95em; + border: 2px solid rgba(255, 255, 255, 0.35); + border-top-color: currentColor; + border-radius: 50%; + margin-right: 7px; + vertical-align: -1px; + animation: spin 0.85s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +:global(.versionFade-move), +:global(.versionFade-enter-active), +:global(.versionFade-leave-active) { + transition: all 0.2s ease; +} + +:global(.versionFade-enter-from), +:global(.versionFade-leave-to) { + opacity: 0; + transform: translateY(-4px); +} + +:global(.versionFade-leave-active) { + position: absolute; +} + +:global(.versionFade-move) { + transition: transform 0.2s ease; +} + +.noFilterResult, +.note { + margin: 0; + font-size: 12px; + color: var(--color-text-maxcontrast); +} + +.error { + margin: 12px 0 0; + color: var(--color-error); + font-size: 13px; +} diff --git a/src/styles/AppPickerPanel.module.css b/src/styles/AppPickerPanel.module.css new file mode 100644 index 0000000..1cb563e --- /dev/null +++ b/src/styles/AppPickerPanel.module.css @@ -0,0 +1,210 @@ +.selectSection { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 12px; +} + +.label { + font-weight: 600; +} + +.filterToolbar { + display: flex; + align-items: center; + justify-content: flex-start; +} + +.filterToggleButton { + align-self: flex-start; +} + +.filterPanel { + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px 12px; + border: 1px solid var(--color-border-dark); + border-radius: 8px; + background: var(--color-main-background); +} + +.filterField { + display: flex; + flex-direction: column; + gap: 6px; + max-width: 260px; +} + +.filterFieldLabel { + font-size: 12px; + font-weight: 600; + color: var(--color-text-maxcontrast); +} + +.filterSelect { + width: 100%; + box-sizing: border-box; + border: 1px solid var(--color-border-dark); + border-radius: 6px; + padding: 8px 10px; + background: var(--color-main-background); +} + +.appFilterInput { + width: 100%; + box-sizing: border-box; + border: 1px solid var(--color-border-dark); + border-radius: 6px; + padding: 8px 10px; +} + +.appCardList { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 240px), 1fr)); + gap: 16px; + overflow-y: visible; + overflow-x: hidden; + padding-inline-end: 4px; + align-content: start; +} + +.appCardListSplit { + max-height: 360px; + overflow-y: auto; +} + +.appCard { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 12px; + padding: 12px; + border: 1px solid var(--color-border-dark); + border-radius: 8px; + background: var(--color-main-background); + min-height: 124px; + min-width: 0; + box-shadow: 0 6px 18px rgba(15, 23, 42, 0.1); +} + +.appCardSelected { + border-color: var(--color-primary-element); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary-element) 30%, transparent); +} + +.appCardCore { + border-color: #ef4444; + box-shadow: 0 6px 18px rgba(127, 29, 29, 0.12); +} + +.appCardBody { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.appCardHeader { + display: flex; + align-items: flex-start; + gap: 12px; + min-width: 0; + justify-content: space-between; +} + +.appCardTitleBlock { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.appCardTitleRow { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.appCardTitle, +.appCardMeta { + margin: 0; +} + +.appCardTitle { + font-weight: 700; + color: var(--color-main-text); + word-break: break-word; +} + +.appCardMeta { + font-size: 12px; + color: var(--color-text-maxcontrast); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + word-break: break-all; +} + +.appCardCoreFlag { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 8px; + border-radius: 9999px; + background: #fee2e2; + border: 1px solid #ef4444; + color: #991b1b; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + flex-shrink: 0; +} + +.appCardMedia { + display: flex; + align-items: center; + margin-left: auto; + flex-shrink: 0; +} + +.appCardIcon, +.appCardFallbackIcon { + width: 48px; + height: 48px; + border-radius: 10px; + border: 1px solid var(--color-border-dark); + background: color-mix(in srgb, var(--color-main-background) 92%, var(--color-primary-element) 8%); +} + +.appCardIcon { + display: block; + object-fit: contain; + padding: 6px; + box-sizing: border-box; +} + +.appCardFallbackIcon { + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 18px; + color: var(--color-primary-element); +} + +.appCardDescription { + margin: 0; + font-size: 13px; + line-height: 1.35; + color: var(--color-text-maxcontrast); +} + +.appCardButton { + align-self: flex-start; +} + +.noFilterResult { + margin: 0; + font-size: 12px; + color: var(--color-text-maxcontrast); +} diff --git a/src/styles/AppSettingsPanel.module.css b/src/styles/AppSettingsPanel.module.css new file mode 100644 index 0000000..125f338 --- /dev/null +++ b/src/styles/AppSettingsPanel.module.css @@ -0,0 +1,36 @@ +.updateChannel { + margin: 0; + color: var(--color-text-maxcontrast); + font-size: 13px; +} + +.settingsPanel { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 8px; + padding: 12px; + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-main-background); +} + +.settingsToggles { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; +} + +.safeMode { + display: inline-flex; + gap: 8px; + align-items: center; + font-size: 12px; + color: var(--color-text-maxcontrast); + width: 100%; +} + +.safeModeCheckbox { + accent-color: var(--color-primary-element); +} diff --git a/src/styles/DowngradeConfirmDialog.module.css b/src/styles/DowngradeConfirmDialog.module.css new file mode 100644 index 0000000..5b5cb6b --- /dev/null +++ b/src/styles/DowngradeConfirmDialog.module.css @@ -0,0 +1,46 @@ +.downgradeConfirmText { + font-size: 14px; + line-height: 1.4; +} + +.versionTransitionRow { + margin: 0; + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; +} + +.versionChip { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-weight: 600; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 8px; + border: 1px solid var(--color-border-dark); + border-radius: 9999px; + background: var(--color-main-background); +} + +.versionArrow { + font-weight: 700; + color: var(--color-text-light); +} + +.versionRangeSummary { + margin: 0; + font-size: 12px; + color: var(--color-text-light); +} + +.versionItemDegradeMessage { + margin: 8px 0 0; + padding: 8px 10px; + border: 1px solid #fdba74; + background: #ffedd5; + color: #7c2d12; + border-radius: 6px; + font-size: 12px; + line-height: 1.3; +} diff --git a/src/styles/InstallResultPanel.module.css b/src/styles/InstallResultPanel.module.css new file mode 100644 index 0000000..485f102 --- /dev/null +++ b/src/styles/InstallResultPanel.module.css @@ -0,0 +1,171 @@ +.versionSummary { + margin: 0; + font-size: 12px; + color: var(--color-text-light); +} + +.resultPanel { + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 8px; + background: var(--color-main-background); + display: flex; + flex-direction: column; + gap: 8px; +} + +.resultStatus { + margin: 0; + align-self: flex-start; + padding: 3px 10px; + border-radius: 9999px; + font-size: 11px; + font-weight: 700; + color: var(--color-main-background); +} + +.resultStatusSuccess { + background: #16a34a; +} + +.resultStatusWarning { + background: #ea580c; +} + +.resultStatusError { + background: #dc2626; +} + +.resultStatusInfo { + background: #475569; +} + +.resultMessage { + margin: 0; + font-size: 13px; + font-weight: 600; +} + +.resultGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + border: 1px solid var(--color-border-dark); + border-radius: 6px; + padding: 8px; +} + +.resultGrid div { + display: flex; + flex-direction: column; + gap: 2px; +} + +.resultGrid span { + font-size: 11px; + color: var(--color-text-maxcontrast); +} + +.resultGrid strong { + font-size: 12px; + word-break: break-all; +} + +.debugPanel { + margin-top: 0; + height: 100%; + display: flex; + flex-direction: column; + gap: 6px; + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 8px; + background: var(--color-main-background); +} + +.debugSubtitle { + margin: 0; + font-size: 11px; + color: var(--color-text-maxcontrast); +} + +.debugTimeline { + display: flex; + flex-direction: column; + gap: 6px; +} + +.debugStep { + border: 1px solid var(--color-border-dark); + border-radius: 6px; + padding: 6px 8px; + background: color-mix(in srgb, var(--color-main-background) 96%, white 4%); + display: flex; + flex-direction: column; + gap: 6px; +} + +.debugStepHeader { + margin: 0; + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; +} + +.debugStepIndex { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + border-radius: 9999px; + font-weight: 700; + font-size: 11px; + padding: 0 6px; + background: var(--color-primary-element); + color: var(--color-primary-element-text); +} + +.debugStepStage { + font-weight: 600; +} + +.debugStepDetails { + margin: 0; +} + +.debugStepSummary { + font-size: 12px; + color: var(--color-text-maxcontrast); + cursor: pointer; +} + +.debugOutput { + list-style: none; + max-height: 260px; + overflow: auto; + margin: 4px 0 0; + padding: 8px; + background: #0f172a; + color: #e2e8f0; + border-radius: 4px; + border: 1px solid #1e293b; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + line-height: 1.35; +} + +.debugOutputLine { + margin: 0; + padding: 0; + line-height: 1.35; + white-space: pre; + font-family: inherit; +} + +.debugNoData { + margin: 0; + font-size: 12px; + color: var(--color-text-maxcontrast); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..0fa1d4e --- /dev/null +++ b/src/types.ts @@ -0,0 +1,38 @@ +export type AppOption = { + id: string + label: string + description: string + summary: string + preview: string + isCore: boolean +} + +export type AppVersion = { + version: string +} + +export type InstallDebugEntry = { + stage: string + data?: unknown +} + +export type InstallResult = { + appId: string + fromVersion?: string | null + toVersion: string + installedVersion?: string | null + updateType?: string + message: string + dryRun: boolean + installStatus: string + debug?: InstallDebugEntry[] +} + +export type VersionRangeInfo = { + major: number + minor: number + patch: number + direction: 'upgrade' | 'degrade' + from: string + to: string +} diff --git a/src/utils/debug.ts b/src/utils/debug.ts new file mode 100644 index 0000000..f754b35 --- /dev/null +++ b/src/utils/debug.ts @@ -0,0 +1,102 @@ +const debugValueToString = (value: unknown): string => { + if (value === null) { + return 'null' + } + + if (typeof value === 'string') { + return value + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value) + } + + if (typeof value === 'bigint') { + return value.toString() + } + + return JSON.stringify(value) +} + +const formatDebugLines = (value: unknown, depth = 0): string[] => { + const indent = ' '.repeat(depth * 2) + const lines: string[] = [] + + if (value === null || value === undefined) { + lines.push(`${indent}—`) + return lines + } + + if (Array.isArray(value)) { + if (value.length === 0) { + lines.push(`${indent}[]`) + return lines + } + + value.forEach((entry, index) => { + if (entry === null || entry === undefined) { + lines.push(`${indent}[${index}]: —`) + return + } + + if (typeof entry === 'object') { + lines.push(`${indent}[${index}]:`) + lines.push(...formatDebugLines(entry, depth + 1)) + } else { + lines.push(`${indent}[${index}]: ${debugValueToString(entry)}`) + } + }) + + return lines + } + + if (typeof value === 'object') { + const objectValue = value as Record + const keys = Object.keys(objectValue) + if (keys.length === 0) { + lines.push(`${indent}{}`) + return lines + } + + for (const key of keys) { + const nested = objectValue[key] + if (nested === null || nested === undefined) { + lines.push(`${indent}${key}: —`) + continue + } + + if (typeof nested === 'object') { + lines.push(`${indent}${key}:`) + lines.push(...formatDebugLines(nested, depth + 1)) + continue + } + + lines.push(`${indent}${key}: ${debugValueToString(nested)}`) + } + return lines + } + + lines.push(`${indent}${debugValueToString(value)}`) + return lines +} + +export const debugHasData = (value: unknown): boolean => { + if (value === null || value === undefined) { + return false + } + + if (typeof value === 'string') { + return value.trim() !== '' + } + + return true +} + +export const debugToTextLines = (value: unknown): string[] => { + const lines = formatDebugLines(value) + if (lines.length === 0) { + return ['—'] + } + + return lines +} diff --git a/src/utils/versioning.ts b/src/utils/versioning.ts new file mode 100644 index 0000000..c79bcae --- /dev/null +++ b/src/utils/versioning.ts @@ -0,0 +1,152 @@ +import type { AppVersion, VersionRangeInfo } from '../types' + +export const parseVersionCore = (version: string): { major: number, minor: number, patch: number } => { + const [core] = version.split('-', 2) + const [rawMajor, rawMinor, rawPatch] = core.split('.') + + return { + major: Number.parseInt(rawMajor || '0', 10) || 0, + minor: Number.parseInt(rawMinor || '0', 10) || 0, + patch: Number.parseInt(rawPatch || '0', 10) || 0, + } +} + +export const compareVersions = (left: string, right: string): number => { + const [leftCore, leftPre = ''] = left.split('-', 2) + const [rightCore, rightPre = ''] = right.split('-', 2) + const leftParts = leftCore.split('.').map((part) => Number(part || '0')) + const rightParts = rightCore.split('.').map((part) => Number(part || '0')) + + for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index++) { + const leftPart = leftParts[index] ?? 0 + const rightPart = rightParts[index] ?? 0 + + if (leftPart > rightPart) { + return 1 + } + if (leftPart < rightPart) { + return -1 + } + } + + if (leftPre === rightPre) { + return 0 + } + if (!leftPre) { + return 1 + } + if (!rightPre) { + return -1 + } + + const leftPreParts = leftPre.split('.') + const rightPreParts = rightPre.split('.') + for (let index = 0; index < Math.max(leftPreParts.length, rightPreParts.length); index++) { + const leftPart = leftPreParts[index] + const rightPart = rightPreParts[index] + + if (leftPart === undefined) { + return -1 + } + if (rightPart === undefined) { + return 1 + } + + const leftNumeric = /^\d+$/.test(leftPart) + const rightNumeric = /^\d+$/.test(rightPart) + + if (leftNumeric && rightNumeric) { + const leftNum = Number(leftPart) + const rightNum = Number(rightPart) + if (leftNum > rightNum) { + return 1 + } + if (leftNum < rightNum) { + return -1 + } + continue + } + + if (leftNumeric) { + return -1 + } + if (rightNumeric) { + return 1 + } + + if (leftPart > rightPart) { + return 1 + } + if (leftPart < rightPart) { + return -1 + } + } + + return 0 +} + +export const getVersionRangeSummary = (from: string, to: string, versions: AppVersion[]): VersionRangeInfo | null => { + if (!from || !to || from === to) { + return null + } + + const direction = compareVersions(to, from) + const comparison = direction === 0 ? 0 : (direction > 0 ? 1 : -1) + const low = comparison <= 0 ? to : from + const high = comparison <= 0 ? from : to + + const inRange = versions.filter((entry) => { + return compareVersions(entry.version, low) >= 0 && compareVersions(entry.version, high) <= 0 + }) + + const majors = new Set() + const minors = new Set() + for (const entry of inRange) { + const parsed = parseVersionCore(entry.version) + majors.add(parsed.major) + minors.add(`${parsed.major}.${parsed.minor}`) + } + + const fromParsed = parseVersionCore(low) + const toParsed = parseVersionCore(high) + const patch = fromParsed.major === toParsed.major && fromParsed.minor === toParsed.minor + ? Math.abs(toParsed.patch - fromParsed.patch) + : 0 + + if (majors.size === 0) { + const major = Math.abs(toParsed.major - fromParsed.major) + const minor = comparison === 0 + ? 0 + : (toParsed.major === fromParsed.major ? Math.abs(toParsed.minor - fromParsed.minor) : Math.abs(toParsed.minor - fromParsed.minor)) + + return { + major, + minor, + patch, + direction: comparison > 0 ? 'upgrade' : 'degrade', + from, + to, + } + } + + return { + major: Math.max(0, majors.size - 1), + minor: Math.max(0, minors.size - 1), + patch, + direction: comparison > 0 ? 'upgrade' : 'degrade', + from, + to, + } +} + +export const versionRangeText = (summary: VersionRangeInfo | null): string => { + if (!summary) { + return '' + } + + if (summary.major === 0 && summary.minor === 0 && summary.patch > 0) { + return `${summary.direction === 'upgrade' ? 'Upgrade' : 'Downgrade'} stays within major/minor and changes ${summary.patch} patch version step${summary.patch === 1 ? '' : 's'}.` + } + + return `${summary.direction === 'upgrade' ? 'Upgrade' : 'Downgrade'} crosses ${summary.major} major and ${summary.minor} minor version step${summary.minor === 1 ? '' : 's'}.` +}