feat(webview): Drag and Drop 파일 업로드 기능 및 시각적 피드백 구현
This commit is contained in:
+213
-14
@@ -813,8 +813,18 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
}
|
||||
|
||||
if (models.length > 0 && (!defaultModel || !models.includes(defaultModel))) {
|
||||
defaultModel = models[0];
|
||||
await vscode.workspace.getConfiguration('g1nation').update('defaultModel', defaultModel, vscode.ConfigurationTarget.Global);
|
||||
// [State Persistence Fix] 저장된 모델이 목록에 없을 때:
|
||||
// 즉시 강제 리셋하는 대신, 현재 모델 목록의 첫 번째를 '폴백 후보'로만 사용.
|
||||
// 단, defaultModel이 완전히 없는 경우에만 실제로 저장함.
|
||||
if (!defaultModel) {
|
||||
defaultModel = models[0];
|
||||
await vscode.workspace.getConfiguration('g1nation').update('defaultModel', defaultModel, vscode.ConfigurationTarget.Global);
|
||||
} else {
|
||||
// 저장된 모델명은 유지하고, UI에는 첫 번째 모델을 보여주되
|
||||
// 설정은 건드리지 않아 다음 번에 같은 모델이 다시 로드될 경우 복원 가능하도록 함
|
||||
logInfo('Saved model not found in current list, using first available as fallback.', { saved: defaultModel, fallback: models[0] });
|
||||
defaultModel = models[0];
|
||||
}
|
||||
}
|
||||
|
||||
const defaultIdx = models.indexOf(defaultModel);
|
||||
@@ -1036,6 +1046,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
.input-box {
|
||||
background: var(--input-bg); border: 1px solid var(--border); border-radius: 12px; padding: 10px 14px;
|
||||
display: flex; flex-direction: column; gap: 8px; transition: 0.2s;
|
||||
position: relative; /* Toast 위치 앙커 */
|
||||
}
|
||||
.input-box:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
|
||||
|
||||
@@ -1065,6 +1076,56 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
}
|
||||
.send-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
|
||||
.footer-right { display: flex; align-items: center; gap: 6px; }
|
||||
|
||||
.cancel-btn {
|
||||
background: transparent; color: var(--text-dim); border: 1px solid var(--border);
|
||||
padding: 6px 10px; border-radius: 6px; font-weight: 600; font-size: 11px;
|
||||
cursor: pointer; align-items: center; gap: 4px; transition: all 0.15s ease;
|
||||
}
|
||||
.cancel-btn:hover { background: rgba(248, 81, 73, 0.12); color: var(--error); border-color: var(--error); }
|
||||
|
||||
.stop-btn {
|
||||
background: rgba(248, 81, 73, 0.15); color: var(--error); border: 1px solid var(--error);
|
||||
padding: 6px 14px; border-radius: 6px; font-weight: 700; font-size: 12px;
|
||||
cursor: pointer; display: inline-flex; align-items: center; gap: 5px;
|
||||
animation: stopPulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
.stop-btn:hover { background: rgba(248, 81, 73, 0.3); }
|
||||
@keyframes stopPulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(248,81,73,0.4); }
|
||||
50% { box-shadow: 0 0 0 5px rgba(248,81,73,0); }
|
||||
}
|
||||
|
||||
/* --- Drag and Drop Overlay --- */
|
||||
body.drag-over::after {
|
||||
content: '📂 파일을 여기에 놓으세요';
|
||||
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: rgba(88, 166, 255, 0.2);
|
||||
backdrop-filter: blur(4px);
|
||||
border: 2px dashed var(--accent);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--text-bright); font-size: 18px; font-weight: 700;
|
||||
z-index: 9999; pointer-events: none;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
/* --- Toast Notification --- */
|
||||
.toast-notif {
|
||||
position: absolute; bottom: calc(100% + 8px); left: 50%;
|
||||
transform: translateX(-50%) translateY(4px);
|
||||
background: var(--surface); border: 1px solid var(--border-bright);
|
||||
color: var(--text-primary); font-size: 11px; font-weight: 600;
|
||||
padding: 7px 14px; border-radius: 20px; white-space: nowrap;
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
z-index: 500; box-shadow: 0 4px 16px rgba(0,0,0,0.3);
|
||||
}
|
||||
.toast-notif.toast-visible { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
.toast-notif.toast-warn { border-color: var(--error); color: var(--error); background: rgba(248,81,73,0.08); }
|
||||
.toast-notif.toast-success { border-color: var(--success); color: var(--success); background: rgba(35,134,54,0.08); }
|
||||
|
||||
/* --- Overlays & Others --- */
|
||||
.thinking-bar { height: 2px; background: transparent; position: relative; overflow: hidden; margin-top: -1px; }
|
||||
.thinking-bar.active {
|
||||
@@ -1301,8 +1362,13 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
<button class="icon-btn" id="attachBtn" title="Attach Files">📎</button>
|
||||
<span id="statusLabel" style="font-size:10px; color:var(--text-dim);">Ready</span>
|
||||
</div>
|
||||
<button id="sendBtn" class="send-btn">Send</button>
|
||||
<div class="footer-right">
|
||||
<button id="cancelBtn" class="cancel-btn" title="Clear draft" style="display:none;">✕ Clear</button>
|
||||
<button id="stopBtn" class="stop-btn" title="Stop generation" style="display:none;">■ Stop</button>
|
||||
<button id="sendBtn" class="send-btn">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="toastNotif" class="toast-notif"></div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<button class="action-btn" style="flex:1" id="inputNewChatBtn">New Chat</button>
|
||||
@@ -1317,10 +1383,62 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
const chat = document.getElementById('chat');
|
||||
const input = document.getElementById('input');
|
||||
const sendBtn = document.getElementById('sendBtn');
|
||||
const stopBtn = document.getElementById('stopBtn');
|
||||
const cancelBtn = document.getElementById('cancelBtn');
|
||||
const toastNotif = document.getElementById('toastNotif');
|
||||
const thinkingBar = document.getElementById('thinkingBar');
|
||||
const statusLabel = document.getElementById('statusLabel');
|
||||
const stepper = document.getElementById('stepper');
|
||||
|
||||
// --- Draft State Management ---
|
||||
let isDraftActive = false;
|
||||
let _toastTimer = null;
|
||||
|
||||
function showToast(msg, type = 'info') {
|
||||
toastNotif.textContent = msg;
|
||||
toastNotif.className = 'toast-notif toast-' + type + ' toast-visible';
|
||||
if (_toastTimer) clearTimeout(_toastTimer);
|
||||
_toastTimer = setTimeout(() => {
|
||||
toastNotif.classList.remove('toast-visible');
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
function setDraftActive(active) {
|
||||
isDraftActive = active;
|
||||
cancelBtn.style.display = active ? 'inline-flex' : 'none';
|
||||
}
|
||||
|
||||
// 생성 중/완료 시 Send ⇔ Stop 전환
|
||||
function setGenerating(generating) {
|
||||
if (generating) {
|
||||
sendBtn.style.display = 'none';
|
||||
stopBtn.style.display = 'inline-flex';
|
||||
// 생성 중에는 Clear 버튼 숨김
|
||||
cancelBtn.style.display = 'none';
|
||||
} else {
|
||||
stopBtn.style.display = 'none';
|
||||
sendBtn.style.display = 'inline-flex';
|
||||
sendBtn.disabled = false;
|
||||
// Draft 상태에 따라 Clear 버튼 복원
|
||||
if (isDraftActive) cancelBtn.style.display = 'inline-flex';
|
||||
}
|
||||
}
|
||||
|
||||
function clearDraft() {
|
||||
// Step 1: 상태 초기화 (Draft State Reset)
|
||||
setDraftActive(false);
|
||||
// Step 2: UI 반영 (Input + Attachments 초기화)
|
||||
input.value = '';
|
||||
input.style.height = 'auto';
|
||||
pendingFiles = [];
|
||||
renderAttachments();
|
||||
input.focus();
|
||||
// Step 3: Toast 알림으로 즉각적 피드백
|
||||
showToast('✕ 작성 내용이 초기화되었습니다.', 'warn');
|
||||
Sound.warn();
|
||||
}
|
||||
|
||||
|
||||
// --- Sound Manager ---
|
||||
const Sound = {
|
||||
ctx: null,
|
||||
@@ -1494,7 +1612,9 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
break;
|
||||
case 'streamEnd':
|
||||
if (streamBody) streamBody.classList.remove('stream-active');
|
||||
streamBody = null; sendBtn.disabled = false;
|
||||
streamBody = null;
|
||||
// \uc0dd\uc131 \uc644\ub8cc \uc2dc Stop \ubc84\ud2bc \uc228\uae30\uace0 Send \ubcf5\uad50
|
||||
setGenerating(false);
|
||||
resetStepper();
|
||||
Sound.success();
|
||||
break;
|
||||
@@ -1518,16 +1638,28 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
case 'focusInput':
|
||||
input.focus();
|
||||
break;
|
||||
case 'modelsList':
|
||||
case 'modelsList': {
|
||||
modelSel.innerHTML = '';
|
||||
// [State Persistence - Tier 2] LocalStorage에서 마지막 선택 모델 복원 시도
|
||||
const _savedModel = localStorage.getItem('g1nation_last_model');
|
||||
// 서버 추천 모델 vs 로컬 저장 모델 중 우선순위 결정
|
||||
// LocalStorage에 저장된 값이 현재 목록에 있으면 그것을 우선 사용 (Tier 2 우선)
|
||||
const _preferredModel = (_savedModel && msg.value.models.includes(_savedModel))
|
||||
? _savedModel
|
||||
: msg.value.selected;
|
||||
msg.value.models.forEach(m => {
|
||||
const o = document.createElement('option'); o.value = m; o.innerText = m;
|
||||
if (m === msg.value.selected) o.selected = true;
|
||||
if (m === _preferredModel) o.selected = true;
|
||||
modelSel.appendChild(o);
|
||||
});
|
||||
// LocalStorage에 저장된 모델이 실제로 적용된 경우, 백엔드 설정도 동기화
|
||||
if (_savedModel && _savedModel !== msg.value.selected && msg.value.models.includes(_savedModel)) {
|
||||
vscode.postMessage({ type: 'model', value: _savedModel });
|
||||
}
|
||||
if (typeof updateInputPlaceholder === 'function') updateInputPlaceholder();
|
||||
statusLabel.innerText = \`Model: \${msg.value.selected}\`;
|
||||
statusLabel.innerText = \`Model: \${_preferredModel}\`;
|
||||
break;
|
||||
}
|
||||
case 'brainProfiles':
|
||||
brainSel.innerHTML = '';
|
||||
msg.value.profiles.forEach(p => {
|
||||
@@ -1602,21 +1734,61 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
attachPreview.appendChild(chip);
|
||||
});
|
||||
}
|
||||
window.removeFile = (i) => { pendingFiles.splice(i, 1); renderAttachments(); };
|
||||
attachBtn.onclick = () => fileInput.click();
|
||||
fileInput.onchange = () => {
|
||||
Array.from(fileInput.files).forEach(file => {
|
||||
window.removeFile = (i) => {
|
||||
pendingFiles.splice(i, 1);
|
||||
renderAttachments();
|
||||
// 파일 삭제 후 Draft State 재평가
|
||||
setDraftActive(input.value.trim().length > 0 || pendingFiles.length > 0);
|
||||
};
|
||||
function processFiles(files) {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
Array.from(files).forEach(file => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const base64 = reader.result.split(',')[1];
|
||||
pendingFiles.push({ name: file.name, type: file.type, data: base64 });
|
||||
renderAttachments();
|
||||
setDraftActive(true);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
showToast(`${files.length}개의 파일이 추가되었습니다.`, 'success');
|
||||
Sound.success();
|
||||
}
|
||||
|
||||
attachBtn.onclick = () => fileInput.click();
|
||||
fileInput.onchange = () => {
|
||||
processFiles(fileInput.files);
|
||||
fileInput.value = '';
|
||||
};
|
||||
|
||||
// --- Drag and Drop Implementation ---
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
document.body.addEventListener(eventName, e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, false);
|
||||
});
|
||||
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
document.body.addEventListener(eventName, () => {
|
||||
document.body.classList.add('drag-over');
|
||||
}, false);
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
document.body.addEventListener(eventName, () => {
|
||||
document.body.classList.remove('drag-over');
|
||||
}, false);
|
||||
});
|
||||
|
||||
document.body.addEventListener('drop', e => {
|
||||
const dt = e.dataTransfer;
|
||||
const files = dt.files;
|
||||
processFiles(files);
|
||||
}, false);
|
||||
|
||||
function send() {
|
||||
const val = input.value.trim();
|
||||
if (!val && pendingFiles.length === 0) return;
|
||||
@@ -1631,7 +1803,10 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
negativePrompt: negativePrompt.value.trim() || undefined
|
||||
});
|
||||
input.value = ''; input.style.height = 'auto'; pendingFiles = []; renderAttachments();
|
||||
sendBtn.disabled = true; thinkingBar.classList.add('active');
|
||||
// 전송 완료 후 Draft State 리셋 + Stop 버튼 표시
|
||||
setDraftActive(false);
|
||||
setGenerating(true);
|
||||
thinkingBar.classList.add('active');
|
||||
}
|
||||
|
||||
sendBtn.onclick = send;
|
||||
@@ -1642,7 +1817,21 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
send();
|
||||
}
|
||||
});
|
||||
input.addEventListener('input', () => { input.style.height = 'auto'; input.style.height = input.scrollHeight + 'px'; });
|
||||
input.addEventListener('input', () => {
|
||||
input.style.height = 'auto';
|
||||
input.style.height = input.scrollHeight + 'px';
|
||||
// Draft State: 내용이 있으면 cancelBtn 표시
|
||||
setDraftActive(input.value.trim().length > 0 || pendingFiles.length > 0);
|
||||
});
|
||||
|
||||
cancelBtn.onclick = () => clearDraft();
|
||||
stopBtn.onclick = () => {
|
||||
vscode.postMessage({ type: 'stopGeneration' });
|
||||
setGenerating(false);
|
||||
thinkingBar.classList.remove('active');
|
||||
showToast('■ 생성이 중단되었습니다.', 'warn');
|
||||
Sound.warn();
|
||||
};
|
||||
|
||||
const startNewChat = () => { Sound.play(660, 'sine', 0.1); vscode.postMessage({ type: 'newChat' }); };
|
||||
document.getElementById('newChatBtn').onclick = startNewChat;
|
||||
@@ -1671,8 +1860,18 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
};
|
||||
|
||||
modelSel.onchange = () => {
|
||||
vscode.postMessage({ type: 'model', value: modelSel.value });
|
||||
const _selectedModel = modelSel.value;
|
||||
// [State Persistence - Tier 2] 모델 변경 시 LocalStorage에 즉시 저장 (클라이언트 측 지속성)
|
||||
try {
|
||||
localStorage.setItem('g1nation_last_model', _selectedModel);
|
||||
} catch(e) {
|
||||
console.warn('[G1nation] LocalStorage 저장 실패:', e);
|
||||
}
|
||||
// [State Persistence - Tier 1] VS Code 전역 설정에 동기화 (영구 저장)
|
||||
vscode.postMessage({ type: 'model', value: _selectedModel });
|
||||
updateInputPlaceholder();
|
||||
// 상태 레이블 즉시 업데이트
|
||||
statusLabel.innerText = \`Model: \${_selectedModel}\`;
|
||||
};
|
||||
brainSel.onchange = () => {
|
||||
if (brainSel.value === 'new') {
|
||||
|
||||
Reference in New Issue
Block a user