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))) {
|
if (models.length > 0 && (!defaultModel || !models.includes(defaultModel))) {
|
||||||
defaultModel = models[0];
|
// [State Persistence Fix] 저장된 모델이 목록에 없을 때:
|
||||||
await vscode.workspace.getConfiguration('g1nation').update('defaultModel', defaultModel, vscode.ConfigurationTarget.Global);
|
// 즉시 강제 리셋하는 대신, 현재 모델 목록의 첫 번째를 '폴백 후보'로만 사용.
|
||||||
|
// 단, 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);
|
const defaultIdx = models.indexOf(defaultModel);
|
||||||
@@ -1036,6 +1046,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
.input-box {
|
.input-box {
|
||||||
background: var(--input-bg); border: 1px solid var(--border); border-radius: 12px; padding: 10px 14px;
|
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;
|
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); }
|
.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; }
|
.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 --- */
|
/* --- Overlays & Others --- */
|
||||||
.thinking-bar { height: 2px; background: transparent; position: relative; overflow: hidden; margin-top: -1px; }
|
.thinking-bar { height: 2px; background: transparent; position: relative; overflow: hidden; margin-top: -1px; }
|
||||||
.thinking-bar.active {
|
.thinking-bar.active {
|
||||||
@@ -1301,8 +1362,13 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
<button class="icon-btn" id="attachBtn" title="Attach Files">📎</button>
|
<button class="icon-btn" id="attachBtn" title="Attach Files">📎</button>
|
||||||
<span id="statusLabel" style="font-size:10px; color:var(--text-dim);">Ready</span>
|
<span id="statusLabel" style="font-size:10px; color:var(--text-dim);">Ready</span>
|
||||||
</div>
|
</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>
|
||||||
|
<div id="toastNotif" class="toast-notif"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<button class="action-btn" style="flex:1" id="inputNewChatBtn">New Chat</button>
|
<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 chat = document.getElementById('chat');
|
||||||
const input = document.getElementById('input');
|
const input = document.getElementById('input');
|
||||||
const sendBtn = document.getElementById('sendBtn');
|
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 thinkingBar = document.getElementById('thinkingBar');
|
||||||
const statusLabel = document.getElementById('statusLabel');
|
const statusLabel = document.getElementById('statusLabel');
|
||||||
const stepper = document.getElementById('stepper');
|
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 ---
|
// --- Sound Manager ---
|
||||||
const Sound = {
|
const Sound = {
|
||||||
ctx: null,
|
ctx: null,
|
||||||
@@ -1494,7 +1612,9 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
break;
|
break;
|
||||||
case 'streamEnd':
|
case 'streamEnd':
|
||||||
if (streamBody) streamBody.classList.remove('stream-active');
|
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();
|
resetStepper();
|
||||||
Sound.success();
|
Sound.success();
|
||||||
break;
|
break;
|
||||||
@@ -1518,16 +1638,28 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
case 'focusInput':
|
case 'focusInput':
|
||||||
input.focus();
|
input.focus();
|
||||||
break;
|
break;
|
||||||
case 'modelsList':
|
case 'modelsList': {
|
||||||
modelSel.innerHTML = '';
|
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 => {
|
msg.value.models.forEach(m => {
|
||||||
const o = document.createElement('option'); o.value = m; o.innerText = 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);
|
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();
|
if (typeof updateInputPlaceholder === 'function') updateInputPlaceholder();
|
||||||
statusLabel.innerText = \`Model: \${msg.value.selected}\`;
|
statusLabel.innerText = \`Model: \${_preferredModel}\`;
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case 'brainProfiles':
|
case 'brainProfiles':
|
||||||
brainSel.innerHTML = '';
|
brainSel.innerHTML = '';
|
||||||
msg.value.profiles.forEach(p => {
|
msg.value.profiles.forEach(p => {
|
||||||
@@ -1602,21 +1734,61 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
attachPreview.appendChild(chip);
|
attachPreview.appendChild(chip);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
window.removeFile = (i) => { pendingFiles.splice(i, 1); renderAttachments(); };
|
window.removeFile = (i) => {
|
||||||
attachBtn.onclick = () => fileInput.click();
|
pendingFiles.splice(i, 1);
|
||||||
fileInput.onchange = () => {
|
renderAttachments();
|
||||||
Array.from(fileInput.files).forEach(file => {
|
// 파일 삭제 후 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();
|
const reader = new FileReader();
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
const base64 = reader.result.split(',')[1];
|
const base64 = reader.result.split(',')[1];
|
||||||
pendingFiles.push({ name: file.name, type: file.type, data: base64 });
|
pendingFiles.push({ name: file.name, type: file.type, data: base64 });
|
||||||
renderAttachments();
|
renderAttachments();
|
||||||
|
setDraftActive(true);
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
});
|
});
|
||||||
|
showToast(`${files.length}개의 파일이 추가되었습니다.`, 'success');
|
||||||
|
Sound.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
attachBtn.onclick = () => fileInput.click();
|
||||||
|
fileInput.onchange = () => {
|
||||||
|
processFiles(fileInput.files);
|
||||||
fileInput.value = '';
|
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() {
|
function send() {
|
||||||
const val = input.value.trim();
|
const val = input.value.trim();
|
||||||
if (!val && pendingFiles.length === 0) return;
|
if (!val && pendingFiles.length === 0) return;
|
||||||
@@ -1631,7 +1803,10 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
negativePrompt: negativePrompt.value.trim() || undefined
|
negativePrompt: negativePrompt.value.trim() || undefined
|
||||||
});
|
});
|
||||||
input.value = ''; input.style.height = 'auto'; pendingFiles = []; renderAttachments();
|
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;
|
sendBtn.onclick = send;
|
||||||
@@ -1642,7 +1817,21 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
send();
|
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' }); };
|
const startNewChat = () => { Sound.play(660, 'sine', 0.1); vscode.postMessage({ type: 'newChat' }); };
|
||||||
document.getElementById('newChatBtn').onclick = startNewChat;
|
document.getElementById('newChatBtn').onclick = startNewChat;
|
||||||
@@ -1671,8 +1860,18 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
};
|
};
|
||||||
|
|
||||||
modelSel.onchange = () => {
|
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();
|
updateInputPlaceholder();
|
||||||
|
// 상태 레이블 즉시 업데이트
|
||||||
|
statusLabel.innerText = \`Model: \${_selectedModel}\`;
|
||||||
};
|
};
|
||||||
brainSel.onchange = () => {
|
brainSel.onchange = () => {
|
||||||
if (brainSel.value === 'new') {
|
if (brainSel.value === 'new') {
|
||||||
|
|||||||
Reference in New Issue
Block a user