fix(quick-open): guide rg install after fallback budget errors (#9627)
* fix(quick-open): guide rg install after fallback budget errors * fix(quick-open): show local host wording for local install guidance The install-rg guidance component hardcoded 'on the remote', so the new local fallback path told local users to install ripgrep 'on the remote' — wrong for the exact case #9627 targets. Parse the location out of the message and render the matching wording; add the local locale string and a render test that guards against the 'on the remote' regression. Also harden the reason capture against a stray ')' in the error text. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
569e9d88f8
commit
e8d5d50c35
|
|
@ -0,0 +1,71 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Store } from '../persistence'
|
||||
|
||||
const {
|
||||
listFilesWithGitMock,
|
||||
resolveAuthorizedPathMock,
|
||||
checkRgAvailableMock,
|
||||
getLocalGitOptionsForRegisteredWorktreeMock
|
||||
} = vi.hoisted(() => ({
|
||||
listFilesWithGitMock: vi.fn(),
|
||||
resolveAuthorizedPathMock: vi.fn(),
|
||||
checkRgAvailableMock: vi.fn(),
|
||||
getLocalGitOptionsForRegisteredWorktreeMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./filesystem-list-files-git-fallback', () => ({
|
||||
listFilesWithGit: listFilesWithGitMock
|
||||
}))
|
||||
|
||||
vi.mock('./filesystem-auth', () => ({
|
||||
resolveAuthorizedPath: resolveAuthorizedPathMock
|
||||
}))
|
||||
|
||||
vi.mock('./rg-availability', () => ({
|
||||
checkRgAvailable: checkRgAvailableMock
|
||||
}))
|
||||
|
||||
vi.mock('./local-worktree-runtime-options', () => ({
|
||||
getLocalGitOptionsForRegisteredWorktree: getLocalGitOptionsForRegisteredWorktreeMock
|
||||
}))
|
||||
|
||||
import { listQuickOpenFiles } from './filesystem-list-files'
|
||||
|
||||
describe('filesystem-list-files ripgrep guidance', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resolveAuthorizedPathMock.mockImplementation(async (path) => path)
|
||||
checkRgAvailableMock.mockResolvedValue(false)
|
||||
getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({})
|
||||
})
|
||||
|
||||
it('turns only a readdir budget failure into install guidance', async () => {
|
||||
listFilesWithGitMock.mockRejectedValue(new Error('File listing exceeded 10000 files'))
|
||||
const rejection = listQuickOpenFiles('/workspace', {} as Store)
|
||||
|
||||
await expect(rejection).rejects.toThrow(
|
||||
'Quick Open scan too large (File listing exceeded 10000 files).'
|
||||
)
|
||||
await rejection.catch((error: Error) =>
|
||||
expect(error.message).toContain('Install ripgrep on the host running the Quick Open scan')
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps cancellation and Git errors unchanged', async () => {
|
||||
const cancellation = new Error('File listing cancelled')
|
||||
listFilesWithGitMock.mockRejectedValueOnce(cancellation)
|
||||
await expect(listQuickOpenFiles('/workspace', {} as Store)).rejects.toBe(cancellation)
|
||||
|
||||
const gitFailure = new Error('git ls-files exited with code 128')
|
||||
listFilesWithGitMock.mockRejectedValueOnce(gitFailure)
|
||||
await expect(listQuickOpenFiles('/workspace', {} as Store)).rejects.toBe(gitFailure)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform !== 'darwin')('shows the macOS install command', async () => {
|
||||
listFilesWithGitMock.mockRejectedValue(new Error('File listing timed out'))
|
||||
|
||||
await expect(listQuickOpenFiles('/workspace', {} as Store)).rejects.toThrow(
|
||||
'brew install ripgrep'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -14,6 +14,8 @@ import {
|
|||
shouldExcludeQuickOpenRelPath,
|
||||
shouldIncludeQuickOpenPath
|
||||
} from '../../shared/quick-open-filter'
|
||||
import { isQuickOpenReaddirBudgetError } from '../../shared/quick-open-readdir-walk'
|
||||
import { buildInstallRgMessage } from '../../shared/quick-open-install-rg'
|
||||
import { listFilesWithGit } from './filesystem-list-files-git-fallback'
|
||||
|
||||
export async function listQuickOpenFiles(
|
||||
|
|
@ -42,13 +44,20 @@ export async function listQuickOpenFiles(
|
|||
// can run.
|
||||
const rgAvailable = await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro)
|
||||
if (!rgAvailable) {
|
||||
return listFilesWithGit(
|
||||
authorizedRootPath,
|
||||
excludePathPrefixes,
|
||||
localGitOptions,
|
||||
signal,
|
||||
maxResults
|
||||
)
|
||||
try {
|
||||
return await listFilesWithGit(
|
||||
authorizedRootPath,
|
||||
excludePathPrefixes,
|
||||
localGitOptions,
|
||||
signal,
|
||||
maxResults
|
||||
)
|
||||
} catch (err) {
|
||||
if (!isQuickOpenReaddirBudgetError(err)) {
|
||||
throw err
|
||||
}
|
||||
throw new Error(await buildInstallRgMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
const files = new Set<string>()
|
||||
|
|
|
|||
|
|
@ -1,85 +1,11 @@
|
|||
import { readFile } from 'node:fs/promises'
|
||||
import {
|
||||
getProcessOutputFields,
|
||||
iterateProcessOutputLines
|
||||
} from '../shared/process-output-field-scanner'
|
||||
buildInstallRgMessage as buildSharedInstallRgMessage,
|
||||
detectInstallCommand,
|
||||
detectLinuxInstallCommandFromOsRelease
|
||||
} from '../shared/quick-open-install-rg'
|
||||
|
||||
const GENERIC_LINUX_RIPGREP_INSTALL =
|
||||
'install ripgrep via your package manager (e.g. apt/dnf/pacman)'
|
||||
const OS_RELEASE_ID_LIKE_MAX_FIELDS = 16
|
||||
export { detectInstallCommand, detectLinuxInstallCommandFromOsRelease }
|
||||
|
||||
export async function detectInstallCommand(): Promise<string> {
|
||||
if (process.platform === 'darwin') {
|
||||
return 'brew install ripgrep'
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
try {
|
||||
const osRelease = await readFile('/etc/os-release', 'utf-8')
|
||||
return detectLinuxInstallCommandFromOsRelease(osRelease)
|
||||
} catch {
|
||||
/* fall through to generic guidance */
|
||||
}
|
||||
return GENERIC_LINUX_RIPGREP_INSTALL
|
||||
}
|
||||
return 'install ripgrep (https://github.com/BurntSushi/ripgrep#installation)'
|
||||
}
|
||||
|
||||
export function detectLinuxInstallCommandFromOsRelease(osRelease: string): string {
|
||||
for (const id of getOsReleasePackageFamilyIds(osRelease)) {
|
||||
if (id === 'debian' || id === 'ubuntu') {
|
||||
return 'sudo apt install ripgrep'
|
||||
}
|
||||
if (id === 'fedora' || id === 'rhel' || id === 'centos') {
|
||||
return 'sudo dnf install ripgrep'
|
||||
}
|
||||
if (id === 'arch') {
|
||||
return 'sudo pacman -S ripgrep'
|
||||
}
|
||||
if (id === 'alpine') {
|
||||
return 'sudo apk add ripgrep'
|
||||
}
|
||||
}
|
||||
|
||||
return GENERIC_LINUX_RIPGREP_INSTALL
|
||||
}
|
||||
|
||||
function getOsReleasePackageFamilyIds(osRelease: string): string[] {
|
||||
const ids: string[] = []
|
||||
|
||||
for (const line of iterateProcessOutputLines(osRelease)) {
|
||||
const separatorIndex = line.indexOf('=')
|
||||
if (separatorIndex <= 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const key = line.slice(0, separatorIndex)
|
||||
const value = readOsReleaseValue(line.slice(separatorIndex + 1))
|
||||
if (key === 'ID') {
|
||||
const id = getProcessOutputFields(value, 1)[0]
|
||||
if (id) {
|
||||
ids.push(id)
|
||||
}
|
||||
} else if (key === 'ID_LIKE') {
|
||||
ids.push(...getProcessOutputFields(value, OS_RELEASE_ID_LIKE_MAX_FIELDS))
|
||||
}
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
function readOsReleaseValue(rawValue: string): string {
|
||||
const trimmed = rawValue.trim()
|
||||
const quote = trimmed[0]
|
||||
return (quote === '"' || quote === "'") && trimmed.at(-1) === quote
|
||||
? trimmed.slice(1, -1)
|
||||
: trimmed
|
||||
}
|
||||
|
||||
export async function buildInstallRgMessage(cause: unknown): Promise<string> {
|
||||
const reason = cause instanceof Error ? cause.message : String(cause)
|
||||
const cmd = await detectInstallCommand()
|
||||
return (
|
||||
`Quick Open scan too large (${reason}). ` +
|
||||
`Install ripgrep on the remote to enable fast, gitignore-aware listing: ${cmd}`
|
||||
)
|
||||
export function buildInstallRgMessage(cause: unknown): Promise<string> {
|
||||
return buildSharedInstallRgMessage(cause, 'remote')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ export default function QuickOpen(): React.JSX.Element | null {
|
|||
return guidance ? (
|
||||
<QuickOpenInstallRgGuidance
|
||||
reason={guidance.reason}
|
||||
location={guidance.location}
|
||||
command={guidance.command}
|
||||
guidance={guidance.guidance}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { QuickOpenInstallRgGuidance } from './quick-open-install-rg-guidance'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('QuickOpenInstallRgGuidance', () => {
|
||||
it('says the local host, not the remote, for a local scan', () => {
|
||||
render(
|
||||
<QuickOpenInstallRgGuidance
|
||||
reason="File listing timed out"
|
||||
location="local"
|
||||
command="brew install ripgrep"
|
||||
guidance={null}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/on the host running the Quick Open scan/i)).toBeTruthy()
|
||||
expect(screen.queryByText(/on the remote/i)).toBeNull()
|
||||
})
|
||||
|
||||
it('says the remote for a relay scan', () => {
|
||||
render(
|
||||
<QuickOpenInstallRgGuidance
|
||||
reason="File listing exceeded 10000 files"
|
||||
location="remote"
|
||||
command="sudo apt install ripgrep"
|
||||
guidance={null}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/on the remote to enable fast/i)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { parseQuickOpenInstallRgGuidance } from './quick-open-install-rg-guidance'
|
||||
|
||||
describe('parseQuickOpenInstallRgGuidance', () => {
|
||||
it('parses the local message and reports the local location', () => {
|
||||
expect(
|
||||
parseQuickOpenInstallRgGuidance(
|
||||
'Quick Open scan too large (File listing timed out). Install ripgrep on the host running the Quick Open scan to enable fast, gitignore-aware listing: brew install ripgrep'
|
||||
)
|
||||
).toEqual({
|
||||
reason: 'File listing timed out',
|
||||
location: 'local',
|
||||
command: 'brew install ripgrep',
|
||||
guidance: null
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps parsing the legacy remote message and reports the remote location', () => {
|
||||
expect(
|
||||
parseQuickOpenInstallRgGuidance(
|
||||
'Quick Open scan too large (File listing exceeded 10000 files). Install ripgrep on the remote to enable fast, gitignore-aware listing: sudo apt install ripgrep'
|
||||
)
|
||||
).toEqual({
|
||||
reason: 'File listing exceeded 10000 files',
|
||||
location: 'remote',
|
||||
command: 'sudo apt install ripgrep',
|
||||
guidance: null
|
||||
})
|
||||
})
|
||||
|
||||
it('renders generic install prose through the guidance path', () => {
|
||||
expect(
|
||||
parseQuickOpenInstallRgGuidance(
|
||||
'Quick Open scan too large (File listing timed out). Install ripgrep on the host running the Quick Open scan to enable fast, gitignore-aware listing: install ripgrep via your package manager (e.g. apt/dnf/pacman)'
|
||||
)
|
||||
).toEqual({
|
||||
reason: 'File listing timed out',
|
||||
location: 'local',
|
||||
command: null,
|
||||
guidance: 'install ripgrep via your package manager (e.g. apt/dnf/pacman)'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for regular errors', () => {
|
||||
expect(parseQuickOpenInstallRgGuidance('git ls-files exited with code 128')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -5,10 +5,16 @@ import { translate } from '@/i18n/i18n'
|
|||
|
||||
export type QuickOpenInstallRgGuidanceParts = {
|
||||
reason: string
|
||||
// Why: the fallback runs on whichever host performs the scan. A local scan
|
||||
// must not tell the user to install ripgrep "on the remote"; the location
|
||||
// phrase in the message is the only signal for which wording to render.
|
||||
location: 'local' | 'remote'
|
||||
command: string | null
|
||||
guidance: string | null
|
||||
}
|
||||
|
||||
const REMOTE_LOCATION_PHRASE = 'on the remote'
|
||||
|
||||
/**
|
||||
* Parses the install-ripgrep guidance message produced by the relay's
|
||||
* buildInstallRgMessage(). Returns the parts needed to render as formatted
|
||||
|
|
@ -23,19 +29,21 @@ export function parseQuickOpenInstallRgGuidance(
|
|||
message: string
|
||||
): QuickOpenInstallRgGuidanceParts | null {
|
||||
const match = message.match(
|
||||
/^Quick Open scan too large \(([^)]+)\)\. Install ripgrep on the remote to enable fast, gitignore-aware listing: (.+)$/
|
||||
/^Quick Open scan too large \((.+?)\)\. Install ripgrep (on the remote|on the host running the Quick Open scan) to enable fast, gitignore-aware listing: (.+)$/
|
||||
)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const reason = match[1]
|
||||
const tail = match[2].trim()
|
||||
const location = match[2] === REMOTE_LOCATION_PHRASE ? 'remote' : 'local'
|
||||
const tail = match[3].trim()
|
||||
// Why: on unknown distros the relay emits prose like "install ripgrep via
|
||||
// your package manager (e.g. apt/dnf/pacman)"; there is no single command
|
||||
// to copy, so surface it as plain guidance without the code block.
|
||||
const looksLikeCommand = /^(sudo\s+)?(brew|apt|dnf|pacman|apk)\s/.test(tail)
|
||||
return {
|
||||
reason,
|
||||
location,
|
||||
command: looksLikeCommand ? tail : null,
|
||||
guidance: looksLikeCommand ? null : tail
|
||||
}
|
||||
|
|
@ -43,6 +51,7 @@ export function parseQuickOpenInstallRgGuidance(
|
|||
|
||||
export function QuickOpenInstallRgGuidance({
|
||||
reason,
|
||||
location,
|
||||
command,
|
||||
guidance
|
||||
}: QuickOpenInstallRgGuidanceParts): React.JSX.Element {
|
||||
|
|
@ -112,10 +121,15 @@ export function QuickOpenInstallRgGuidance({
|
|||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-foreground">
|
||||
{translate('auto.components.QuickOpen.5d80dc39bb', 'ripgrep')}
|
||||
</code>{' '}
|
||||
{translate(
|
||||
'auto.components.QuickOpen.1cf8561ab4',
|
||||
'on the remote to enable fast, gitignore-aware listing:'
|
||||
)}
|
||||
{location === 'remote'
|
||||
? translate(
|
||||
'auto.components.QuickOpen.1cf8561ab4',
|
||||
'on the remote to enable fast, gitignore-aware listing:'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.QuickOpen.344f8a48dd',
|
||||
'on the host running the Quick Open scan to enable fast, gitignore-aware listing:'
|
||||
)}
|
||||
</p>
|
||||
{command ? (
|
||||
<div className="flex items-center gap-2 rounded border border-border bg-muted/50 px-3 py-2 font-mono text-xs text-foreground">
|
||||
|
|
|
|||
|
|
@ -1486,7 +1486,8 @@
|
|||
"4725b0e931": "Quick Open scan too large (",
|
||||
"b227d88520": "{{value0}} files found",
|
||||
"995be8ea22": "Copy",
|
||||
"cf144856dc": "Copied"
|
||||
"cf144856dc": "Copied",
|
||||
"344f8a48dd": "on the host running the Quick Open scan to enable fast, gitignore-aware listing:"
|
||||
},
|
||||
"SelectedTextCopyMenu": {
|
||||
"9b40d7b018": "Copy"
|
||||
|
|
|
|||
|
|
@ -1463,7 +1463,8 @@
|
|||
"4725b0e931": "El escaneo de apertura rápida es demasiado grande (",
|
||||
"b227d88520": "{{value0}} archivos encontrados",
|
||||
"995be8ea22": "Copiar",
|
||||
"cf144856dc": "Copiado"
|
||||
"cf144856dc": "Copiado",
|
||||
"344f8a48dd": "on the host running the Quick Open scan to enable fast, gitignore-aware listing:"
|
||||
},
|
||||
"SelectedTextCopyMenu": {
|
||||
"9b40d7b018": "Copiar"
|
||||
|
|
|
|||
|
|
@ -1463,7 +1463,8 @@
|
|||
"4725b0e931": "クイック オープン スキャンが大きすぎます (",
|
||||
"b227d88520": "{{value0}} ファイルが見つかりました",
|
||||
"995be8ea22": "コピー",
|
||||
"cf144856dc": "コピーされました"
|
||||
"cf144856dc": "コピーされました",
|
||||
"344f8a48dd": "on the host running the Quick Open scan to enable fast, gitignore-aware listing:"
|
||||
},
|
||||
"SelectedTextCopyMenu": {
|
||||
"9b40d7b018": "コピー"
|
||||
|
|
|
|||
|
|
@ -1463,7 +1463,8 @@
|
|||
"4725b0e931": "Quick Open 스캔이 너무 큼(",
|
||||
"b227d88520": "{{value0}} 파일을 찾았습니다.",
|
||||
"995be8ea22": "복사",
|
||||
"cf144856dc": "복사됨"
|
||||
"cf144856dc": "복사됨",
|
||||
"344f8a48dd": "on the host running the Quick Open scan to enable fast, gitignore-aware listing:"
|
||||
},
|
||||
"SelectedTextCopyMenu": {
|
||||
"9b40d7b018": "복사"
|
||||
|
|
|
|||
|
|
@ -1463,7 +1463,8 @@
|
|||
"4725b0e931": "快速打开扫描太大(",
|
||||
"b227d88520": "找到 {{value0}} 文件",
|
||||
"995be8ea22": "复制",
|
||||
"cf144856dc": "已复制"
|
||||
"cf144856dc": "已复制",
|
||||
"344f8a48dd": "on the host running the Quick Open scan to enable fast, gitignore-aware listing:"
|
||||
},
|
||||
"SelectedTextCopyMenu": {
|
||||
"9b40d7b018": "复制"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
import { readFile } from 'node:fs/promises'
|
||||
import { getProcessOutputFields, iterateProcessOutputLines } from './process-output-field-scanner'
|
||||
|
||||
const GENERIC_LINUX_RIPGREP_INSTALL =
|
||||
'install ripgrep via your package manager (e.g. apt/dnf/pacman)'
|
||||
const OS_RELEASE_ID_LIKE_MAX_FIELDS = 16
|
||||
|
||||
export async function detectInstallCommand(): Promise<string> {
|
||||
if (process.platform === 'darwin') {
|
||||
return 'brew install ripgrep'
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
try {
|
||||
const osRelease = await readFile('/etc/os-release', 'utf-8')
|
||||
return detectLinuxInstallCommandFromOsRelease(osRelease)
|
||||
} catch {
|
||||
/* fall through to generic guidance */
|
||||
}
|
||||
return GENERIC_LINUX_RIPGREP_INSTALL
|
||||
}
|
||||
return 'install ripgrep (https://github.com/BurntSushi/ripgrep#installation)'
|
||||
}
|
||||
|
||||
export function detectLinuxInstallCommandFromOsRelease(osRelease: string): string {
|
||||
for (const id of getOsReleasePackageFamilyIds(osRelease)) {
|
||||
if (id === 'debian' || id === 'ubuntu') {
|
||||
return 'sudo apt install ripgrep'
|
||||
}
|
||||
if (id === 'fedora' || id === 'rhel' || id === 'centos') {
|
||||
return 'sudo dnf install ripgrep'
|
||||
}
|
||||
if (id === 'arch') {
|
||||
return 'sudo pacman -S ripgrep'
|
||||
}
|
||||
if (id === 'alpine') {
|
||||
return 'sudo apk add ripgrep'
|
||||
}
|
||||
}
|
||||
|
||||
return GENERIC_LINUX_RIPGREP_INSTALL
|
||||
}
|
||||
|
||||
function getOsReleasePackageFamilyIds(osRelease: string): string[] {
|
||||
const ids: string[] = []
|
||||
|
||||
for (const line of iterateProcessOutputLines(osRelease)) {
|
||||
const separatorIndex = line.indexOf('=')
|
||||
if (separatorIndex <= 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const key = line.slice(0, separatorIndex)
|
||||
const value = readOsReleaseValue(line.slice(separatorIndex + 1))
|
||||
if (key === 'ID') {
|
||||
const id = getProcessOutputFields(value, 1)[0]
|
||||
if (id) {
|
||||
ids.push(id)
|
||||
}
|
||||
} else if (key === 'ID_LIKE') {
|
||||
ids.push(...getProcessOutputFields(value, OS_RELEASE_ID_LIKE_MAX_FIELDS))
|
||||
}
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
function readOsReleaseValue(rawValue: string): string {
|
||||
const trimmed = rawValue.trim()
|
||||
const quote = trimmed[0]
|
||||
return (quote === '"' || quote === "'") && trimmed.at(-1) === quote
|
||||
? trimmed.slice(1, -1)
|
||||
: trimmed
|
||||
}
|
||||
|
||||
export async function buildInstallRgMessage(
|
||||
cause: unknown,
|
||||
host: 'local' | 'remote' = 'local'
|
||||
): Promise<string> {
|
||||
const reason = cause instanceof Error ? cause.message : String(cause)
|
||||
const cmd = await detectInstallCommand()
|
||||
const location = host === 'local' ? 'on the host running the Quick Open scan' : 'on the remote'
|
||||
return (
|
||||
`Quick Open scan too large (${reason}). ` +
|
||||
`Install ripgrep ${location} to enable fast, gitignore-aware listing: ${cmd}`
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue