Uma janela
Ao terminar este guia o seu app tem título que acompanha o documento aberto, abre no tamanho certo, responde aos controles da janela, sabe abrir uma segunda janela sobre o mesmo backend, volta no lugar certo quando a sessão é restaurada e, se precisar, desenha a própria barra de título. O que a janela é para o shell está em a janela.
Os nomes de verbo deste guia seguem a nota sobre os nomes:
o SDK que os expõe chega com a sub-etapa 5.2, e o shim de hoje fala os nomes antigos
(vssh.window.minimize, vssh.setTitle, vssh.lembrarRota, vssh.onOpenContext).
Título
Você não precisa de API para o caso comum. O shim observa document.title e o repassa ao shell:
document.title = `${nome} - Editor`; // a janela do ambiente acompanha
É o que faz um app portado ganhar título sem uma linha nova: o mesmo código que dá título à aba do
navegador dá título à janela. Para quem prefere ser explícito, vssh.app.titulo(t) faz o mesmo. O
shell corta em 200 caracteres e projeta o título na barra de título, na barra de tarefas e no
Alt+Tab.
Tamanho e posição
O tamanho de abertura é do manifesto:
{ "window": { "title": "Meu App", "width": 1100, "height": 720 } }
Depois disso o tamanho e a posição são da pessoa, e o ambiente os guarda entre sessões. Não há
setSize nem setPosition de propósito.
O tamanho do manifesto e o piso de window.minimos valem para a tela larga. Num celular em pé o
ambiente põe a janela na tela inteira e o piso cede, então o app precisa caber em 320 px de
largura. Quem monta a tela com a moldura ganha essa forma sem CSS próprio.
Controles
vssh.janela.minimizar();
vssh.janela.maximizar();
vssh.janela.restaurar(); // desfaz minimizado ou maximizado
vssh.janela.focar();
vssh.janela.fechar();
São disparos: não devolvem promessa e não têm resposta a esperar. Fora do ambiente cada um não faz nada.
Uma janela ou várias
Por padrão, pedir para abrir um app já aberto foca a janela existente e entrega o pedido a ela
pelo evento abertura. Se cada coisa aberta no seu app é uma sessão inteira e independente (um
player, um visualizador de imagem, um terminal), declare:
{ "window": { "multiplasJanelas": true } }
O backend continua sendo um. Duas janelas são duas visões do mesmo processo, como duas abas do
navegador no mesmo servidor: mesmo socket, mesmo token, mesmo VSSH_APP_DATA_DIR. O estado por
janela vive no frontend; um app que guarda estado de UI no backend como se houvesse um cliente só
vê as janelas disputando esse estado.
O app também pede a própria janela extra, numa rota dentro dele:
await vssh.janela.abrir('?painel=notas', { titulo: 'Notas', largura: 380, altura: 520 });
Sem rota, a janela nova abre a mesma página. Com rota, o app escolhe o que vai dentro. A rota é um
caminho relativo, como todo fetch que você escreve; URL absoluta, javascript: e .. são
recusados. A chamada devolve false num shell anterior a esta capacidade; trate como "aqui não
dá" e siga.
Notificação com ação e o veredito do healthcheck vão para uma janela só, a última que teve foco: entregar às duas faria a ação acontecer duas vezes.
O contexto de abertura
"Abrir com", "Abrir Terminal Aqui", um item da jump list, um link de um host que o app declarou
em opens.urls: todos chegam pelo mesmo evento, depois do load, e de novo quando uma ação
alcança uma janela já aberta.
vssh.app.ao('abertura', ({ caminho, url, tipo, rota }) => {
if (caminho) abrir(caminho); // tipo: 'arquivo' ou 'pasta'
if (url) tocar(url); // tipo: 'url'
if (rota) navegar(rota); // a jump list ou uma notificação, com a janela já aberta
});
caminho e url nunca chegam juntos: são as duas formas de dizer o que abrir, e o ambiente sabe
qual tem na mão. rota chega quando alguém usa a jump list do ícone, ou clica numa notificação que
leva abrir, com a janela já aberta; com o app fechado, a rota entra na URL e o app boota nela.
Quem sabe se ir a novo é trocar de tela, abrir um painel ou criar um documento é o app. Sem
tratar o evento, o item da jump list e a notificação só levam ao lugar na primeira abertura.
Voltar no lugar certo
O ambiente restaura as janelas abertas quando a pessoa volta. Sem você dizer nada, o app volta na raiz, o que para um editor significa voltar sem a pasta que estava aberta. Uma linha resolve:
addEventListener('popstate', () => vssh.app.lembrarRota(location.search + location.hash));
Não há código de restauração do seu lado: a janela volta em …/proxy/app/<id>/<rota>, o app
boota naquele endereço, e para ele é indistinguível de alguém ter aberto o link.
lembrarRota('') limpa, e é o que dizer quando a pessoa fecha o documento. O ambiente guarda com
atraso e não repete o que não mudou, então reportar a cada navegação não custa rede.
A rota é um caminho dentro do app, sem esquema, sem / inicial, sem .., com teto de 512
caracteres; o ambiente recusa o resto no console, sem resposta. A rota é um ponteiro:
o que não couber num endereço (conteúdo não salvo, índice, cache) vai para o backend do app ou
para o filesystem da pessoa.
Abas no cabeçalho
Com "richChrome": true no manifesto, a barra de abas é do shell e o dono do estado é o app. O app
reporta a lista de abas e a ativa sempre que o conjunto muda, e recebe de volta os pedidos de nova
aba, fechar e ativar, além de restore-tabs depois de um F5. No fio de hoje:
// app -> shell, sempre que o conjunto de abas ou a aba ativa mudar
window.parent.postMessage({ vsshApp: true, type: 'tabs',
tabs: [{ id: 't1', title: 'nota.md', sessionName: 's1' }], activeTabId: 't1' }, location.origin);
// shell -> app
// { vsshApp: true, type: 'new-tab' | 'close-tab' | 'activate-tab', tabId? }
// { vsshApp: true, type: 'restore-tabs', tabs: [{ sessionName }] | null, activeSessionName }
sessionName é o identificador estável por aba que sobrevive a um reload; é o que persiste na
janela e volta em restore-tabs. Recrie uma aba por sessionName recebido, sem reaproveitar o
id antigo, que é bookkeeping local. Apps sem sessionName não têm abas restauradas, sem erro.
A forma desses verbos no SDK está na referência gerada, sob vssh.janela.
A própria barra de título
"cabecalho": "app" no manifesto faz o ambiente parar de desenhar o cabeçalho da janela. É para
quem já tem uma barra própria e boa e não quer duas. Moldura, sombra e alças continuam do
ambiente; três gestos passam a ser seus:
barra.addEventListener('pointerdown', (e) => vssh.janela.arrastar(e.clientX, e.clientY, e.screenX, e.screenY));
barra.addEventListener('pointermove', (e) => vssh.janela.arrastarPara(e.screenX, e.screenY));
barra.addEventListener('pointerup', () => vssh.janela.terminarArraste());
barra.addEventListener('dblclick', () => vssh.janela.alternarMaximizado());
barra.addEventListener('contextmenu', (e) => vssh.janela.menuDoCabecalho(e.clientX, e.clientY));
Os pontos do meio vão em coordenada de tela (screenX, screenY). Enquanto a janela se move, o
sistema de coordenadas do documento se move junto com ela; um clientX mediria o ponteiro contra
um referencial que está fugindo, e a janela derraparia. O limiar entre clique e arraste é seu,
porque o ambiente não sabe o que na sua barra é botão. Containment, encaixe nas bordas e onde a
janela para continuam sendo do ambiente.
Um app que declara cabecalho: "app" e não liga esses gestos entrega uma janela que não se move,
sem erro e sem aviso. Os três botões de janela também passam a ser seus, com os controles da seção
acima.
O menu de contexto dentro do app
O botão direito dentro de uma janela do ambiente não mostra o menu do navegador hospedeiro, porque ele fala de uma página e de um iframe, e nada ali pertence à janela em que a pessoa clicou. O que aparece é o que o app montou, com o menu do ambiente:
el.addEventListener('contextmenu', async (e) => {
e.preventDefault();
const id = await vssh.dialogos.menuDeContexto(e.clientX, e.clientY, [
{ id: 'abrir', label: 'Abrir', icon: 'folder' },
{ id: 'renomear', label: 'Renomear', icon: 'edit' },
{ separator: true },
{ id: 'excluir', label: 'Excluir', icon: 'trash', danger: true },
]);
if (id) executar(id);
});
Cada item é { id, label, icon }, com danger, checked e disabled quando cabem. Entre os
itens vão { separator: true } e { header: 'Título' }, e um item com submenu: [...] abre um
nível a mais. icon é o nome de um ícone do Tuff, como
o folder e o trash do exemplo.
Devolve o id escolhido (o label, num item sem id), ou null se a pessoa fechou sem
escolher; trate o null, que é o caso comum. Um clique fora do menu, inclusive dentro do próprio
app, fecha o menu e também devolve null. Só dado atravessa: rótulo, ícone e id, nunca função
e nunca HTML. As coordenadas são as do seu viewport, e o shell soma a posição da janela. A exceção
ao menu suprimido é o que for editável (<input>, <textarea>, contenteditable), onde o menu
do navegador é a única forma de recortar e colar com o mouse; e um stopPropagation() no
contextmenu de um elemento devolve a caixa nativa ali.